分享

深入理解POSIX标准:多线程编程和同步机制

 深度Linux 2024-04-13 发布于湖南

可移植操作系统接口(英语:Portable Operating System Interface,缩写为POSIX)是IEEE为要在各种UNIX操作系统上运行软件,而定义API的一系列互相关联的标准的总称,其正式称呼为IEEE Std 1003,而国际标准名称为ISO/IEC 9945。此标准源于一个大约开始于1985年的项目。

POSIX这个名称是由理查德·斯托曼(RMS)应IEEE的要求而提议的一个易于记忆的名称。它基本上是Portable Operating System Interface(可移植操作系统接口)的缩写,而X则表明其对Unix API的传承。

一、POSIX概念

POSIX是由IEEE指定的一系列标准,用于澄清和统一Unix-y操作系统提供的应用程序编程接口(以及辅助问题,如命令行shell实用程序),当您编写程序以依赖POSIX标准时,您可以非常肯定能够轻松地将它们移植到大量的Unix衍生产品系列中(包括Linux,但不限于此!)。

如果你使用的某些Linux API没有标准化为Posix的一部分,那么如果你希望将来将该程序或库移植到其他Unix-y系统(例如MacOSX)。

  • POSIX 可移植操作系统接口,Portable Operating System Interface of UNIX

  • POSIX标准定义了操作系统应该为应用程序提供的接口标准;

  • 调用了符合POSIX标准的API的应用程序可以确保在不同的系统上使用;

  • POSIX则是操作系统为应用程序提供系统调用的接口规范

UNIX中最通用的操作系统API基于POSIX标准(Portable Operating System Interface of UNIX 可移植操作系统接口),操作系统API通常以C库的方式提供,C库封装了这些符合POSIX标准的系统调用接口;

在UNIX世界里,最通用的操作系统API基于POSIX(Portable Operating System Interface of UNIX,可移植操作系统接口)标准。

C POSIX library是C语言的POSIX系统下的标准库。包含了一些在C语言标准库之外的函数,为了OS之间的可移植性,POSIX标准规定了一些标准的接口。而这些接口标准的集合就是POSIX库。

该标准的目的是定义了标准的基于UNIX操作系统的系统接口和环境来支持源代码级的可移植性,现在,标准主要提供了依赖C语言的一系列标准服务,再将来的版本中,标准将致力于提供基于不同语言的规范。

  • 驱动层对于硬件层来说是硬件接口的使用者;这些硬件接口往往被叫作硬件规格

  • 硬件很多,硬件的规格就很多;相应的驱动程序就要写很多,来适配不同的硬件;

  • 这样的话很麻烦,所以一般来说硬件生产厂商要根据一定的接口和规范生产硬件;

  • 这样同一套驱动程序可以在不同的硬件上编写;当然根据实际情况还要编写特定的驱动程序;

  • 另外操作系统开发者也为驱动程序提供了一系列接口和框架;

  • 那么按照这个接口和框架开发的驱动程序就可以使得操作系统正常在该硬件上运行;

  • 硬件也被操作系统抽象了成了一系列的概念;

  • 磁盘被抽象成了文件系统;

  • 图形硬件被抽象成了GDI,

  • 声音和多媒体设备被抽象成了DirectX对象等;

  • 程序员从硬件细节中解放出来,关注应用程序开发本身,繁琐的硬件细节则交给操作系统;

POSIX(Portable Operating System Interface for Computing Systems)是由IEEE 和ISO/IEC 开发的一簇标准。
该标准是基于现有的UNIX 实践和经验,描述了操作系统的调用服务接口,用于保证编制的应用程序可以在源代码一级上在多种操作系统上移植运行。

目前POSIX已经成为类UNIX(Unix-like)操作系统编程的通用接口,极大方便了类UNIX环境下应用程序源码级的可移植性,Glibc(GNU C Library),即C运行库,是Linux系统中最底层的API,它就是完全按照POSIX标准编写的。

System V, 曾经也被称为 AT&T System V,是Unix操作系统众多版本中的一支,它最初由 AT&T 开发,在1983年第一次发布。一共发行了4个 System V 的主要版本:版本1、2、3 和 4,System V Release 4,或者称为SVR4,是最成功的版本,成为一些UNIX共同特性的源头。例如 ”SysV 初始化脚本“ (/etc/init.d),用来控制系统启动和关闭,System V Interface Definition (SVID) 是一个System V 如何工作的标准定义。

照上面所说的System V和POSIX是一种应用于系统的接口协议,POXIS相对于System V可以说是比较新的标准,语法相对简单。

在linux/unix系统编程中支持System V和POSIX。我们常见的一个名词就是POSIX IPC和System V IPC。IPC的全称是Inter-process Comminication,就是进程间通信。

二、Posix网络API

网络编程客户端和服务端常用API

客户端和服务端代码示例

(1)服务端server.cpp

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc,char *argv[])
{
if (argc != 2)
{
printf("Using:./server port\nExample:./server 5005\n\n"); return -1;
}
// 第1步:创建服务端的socket。
int listenfd;
if ( (listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("socket");
return -1;
}

// 第2步:把服务端用于通信的地址和端口绑定到socket上。
struct sockaddr_in servaddr; // 服务端地址信息的数据结构。
memset(&servaddr,0,sizeof(servaddr));
servaddr.sin_family = AF_INET; // 协议族,在socket编程中只能是AF_INET。
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 任意ip地址。
//servaddr.sin_addr.s_addr = inet_addr("192.168.190.134"); // 指定ip地址。
servaddr.sin_port = htons(atoi(argv[1])); // 指定通信端口。
if (bind(listenfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) != 0 )
{
perror("bind");
close(listenfd);
return -1;
}

// 第3步:把socket设置为监听模式。
if (listen(listenfd,5) != 0 )
{
perror("listen");
close(listenfd);
return -1;
}

// 第4步:接受客户端的连接。
int clientfd; // 连上来的客户端socket。
int socklen = sizeof(struct sockaddr_in); // struct sockaddr_in的大小
struct sockaddr_in clientaddr; // 客户端的地址信息。

clientfd = accept(listenfd, (struct sockaddr *)&clientaddr, (socklen_t*)&socklen);
printf("client (%s) connect server success。。。\n", inet_ntoa(clientaddr.sin_addr));

// 第5步:与客户端通信,接收客户端发过来的报文后,将该报文原封不动返回给客户端。
char buffer[1024];
// memset(buffer, 0, 1024);
while (1)
{
int ret;
memset(buffer, 0, sizeof(buffer));
// 接收客户端的请求报文。
if ( (ret = recv(clientfd, buffer, sizeof(buffer), 0)) <= 0)
{
printf("ret = %d , client disconected!!!\n", ret);
break;
}
printf("recv msg: %s\n", buffer);

// 向客户端发送响应结果。
if ( (ret = send(clientfd, buffer, strlen(buffer), 0)) <= 0)
{
perror("send");
break;
}
printf("response client: %s success...\n", buffer);

}
// 第6步:关闭socket,释放资源。
close(listenfd);
close(clientfd);
return 0;
}

(2)客户端client.cpp

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc,char *argv[])
{
if (argc != 3)
{
printf("Using:./client ip port\nExample:./client 127.0.0.1 5005\n\n"); return -1;
}

// 第1步:创建客户端的socket。
int sockfd;
if ( (sockfd = socket(AF_INET,SOCK_STREAM,0))==-1)
{
perror("socket");
return -1;
}

// 第2步:向服务器发起连接请求。
struct hostent* h;
if ( (h = gethostbyname(argv[1])) == 0 ) // 指定服务端的ip地址。
{ printf("gethostbyname failed.\n"); close(sockfd); return -1; }
struct sockaddr_in servaddr;
memset(&servaddr,0,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2])); // 指定服务端的通信端口。
memcpy(&servaddr.sin_addr,h->h_addr,h->h_length);

// 向服务端发起连接清求。
if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) != 0)
{
perror("connect");
close(sockfd);
return -1;
}

char buffer[1024];

// 第3步:与服务端通信,发送一个报文后等待回复,然后再发下一个报文。
for (int i = 0; i < 3; i++)
{
int ret;
memset(buffer, 0, sizeof(buffer));
sprintf(buffer, "这是第[%d]条消息!", i+1);
if ( (ret = send(sockfd, buffer, strlen(buffer),0)) <= 0) // 向服务端发送请求报文。
{
perror("send");
break;
}
printf("发送:%s\n", buffer);

memset(buffer,0,sizeof(buffer));
if ( (ret = recv(sockfd, buffer, sizeof(buffer), 0)) <= 0) // 接收服务端的回应报文。
{
printf("ret = %d error\n", ret);
break;
}
printf("从服务端接收:%s\n", buffer);
sleep(1);
}

// 第4步:关闭socket,释放资源。
close(sockfd);
}

运行结果:

着重分析以下几个函数

(1)socket函数

int socket(int domain, int type, int protocol);

调用socket()函数会创建一个套接字(socket)对象。套接字由两部分组成,文件描述符(fd)和 TCP控制块(Tcp Control Block,tcb) 。Tcb主要包括关系信息有网络的五元组(remote IP,remote Port, local IP, local Port, protocol),一个五元组就可以确定一个具体的网络连接。

(2)listen函数

listen(int listenfd, backlog);

服务端在调用listen()后,就开始监听网络上连接请求。第二个参数 backlog, 在Linux是指全连接队列的长度,即一次最多能保存 backlog 个连接请求。

(3)connect 函数

客户端调用connect()函数,向指定服务端发起连接请求。

(4)accept 函数

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept()函数只做两件事,将连接请求从全连接队列中取出,给该连接分配一个fd并返回。

(5) 三次握手过程分析

三次握手与listen/connect/accept三个函数有关,这里放到一起进行描述。

客户端调用 connect 函数,开始进入三次握手。客户端发送syn包,以及带着随机的seq;

服务端listen函数监听到有客户端连接,listen函数会在内核协议栈为该客户端创建一个Tcb控制块,并将其加入到半连接队列。服务端在收到syn包后,会给客户端恢复ack和syn包;

客户端收到服务端的ack和syn后再次恢复ack,连接建立成功。

服务端在收到客户端的ack后,会将该客户端对应的Tcb数据从半连接队列移动到全连接队列。只要全连接队列中有数据就会触发accept,返回连接成功的客户端fd、IP以及端口。此时,Tcb完整的五元组构建成功。

(6)send/recv 函数

至此,客户端与服务端已经成功建立连接,就可以相互通信了。

send/recv函数主要负责数据的收发。

过程分析

send函数:负责将数据从用户空间拷贝到内核(具体是拷贝到该连接对应的Tcb控制块中的发送缓冲区)。注意:send函数返回并不意味着数据已成功发送,因为数据在到达内核缓冲区后,内核会根据自己的策略决定什么时候将数据发出。

recv函数:负责将数据从内核缓冲区拷贝到用户空间。同理,数据也显示到达该连接对应的Tcb控制块的接受缓冲区。

(7)close 函数

在服务器与客户端建立连接之后,会进行一些读写操作,完成读写操作后我们需要关闭相应的socket,好比操作完打开的文件要调用fclose关闭打开的文件一样。close过程涉及到四次挥手的全过程

四次挥手流程:

  • 客户端调用close函数,内核会发送fin包,客户端进入fin_wait1状态;

  • 服务端收到fin包回复ack,客户端进入close_wait状态。此时,客户客户端往服务端发送的通道就关闭了,因为Tcp是全双工的,服务端还可以向客户端发数据。

  • 客户端收到ack,进入到fin_wait2状态;

  • 服务端发送完数据,发送fin包,服务端进入last_ack状态;

  • 客户端收到fin包后,回复ack,进入到time_wait状态;

  • 服务端收到ack,双方连接正常关闭。

注意:close操作只是让相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求

双方同时调用close

常见面试问题

为什么要三次握手?

答:因为一个完整的TCP连接需要双方都得到确认,客户端发送请求和收到确认需要两次;服务端发送请求和收到确认需要两次,当中服务回复确认和发送请求合并为一次总共需要3次;才能保证双向通道是通的。

一个服务器的端口数是65535,为何能做到一百万的连接?

答:主要是因为一条连接是由五元组所组成,所以一个服务器的连接数是五个成员数的乘积。

如何应对Dos(Deny of Service,拒绝服务)攻击?

答:Dos攻击就是利用三次握手的原理,模拟客户端只向服务器发送syn包,然后耗尽被攻击对象的资源。比较多的做法是利用防火墙,做一些过滤规则

如何解决Tcp的粘包问题?

答:(1) 在包头上添加一个数据包长度的字段,用于数据的划分,实际项目中这个也用的最多;(2)包尾部加固定分隔符;

Tcp如何保证顺序到达?

答:顺序到达是由于TCP的延迟ACK的机制来保证的,TCP接收到数据并不是立即回复而是经过一个延迟时间,回复接收到连续包的最大序列号加1。如果丢包之后的包都需要重传。在弱网情况下这里就会有实时性问题和带宽占用的问题;

time_wait 作用?

答:防止最后一个ACK没有顺利到达对方,超时重新发送ack。time_wait时常一般是120s可以修改。

服务器掉线重启出现端口被占用怎么办?

答:其实主要是由于还处于time_wait状态,端口并没有真正释放。这时候可以设置SO_REUSEADDR属性,保证掉线能马上重连。

三、POSIX信号量(实现线程间通信)

POSIX提供了两种信号量:有名信号量和无名信号量,这两种信号量的本质都是一样的。

信号量的本质是什么?

本质上为计数器 + PCB等待队列 + 一堆接口(等待接口,唤醒接口)
计数器:本质上是对资源的计数

当执行流获取信号量成功之后,信号量当中的计数器会进行减1操作,当获取失败后,该执行流就会被放到PCB等待队列中去。

当执行流释放信号量成功之后,信号量当中的计数器会进行加1操作,又称为基于内存的信号量, 由于其没有名字, 没法通过open操作直接找到对应的信号量, 所以很难直接用于没有关联的两个进程之间。无名信号量多用于线程之间的同步。

3.1有名信号量

有名信号量由于其有名字, 多个独立的进程可以通过名字来打开同一个信号量, 从而完成同步操作, 所以有名信号量的操作要方便一些, 适用范围也比无名信号量更广。

(1)有名信号量创建

#include <fcntl.h>
#include <sys/stat.h>
#include <semaphore.h>
sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag,mode_t mode, unsigned int value);

当sem_open函数失败时, 返回SEM_FAILED, 并且设置errno。

(2)有名信号量的关闭

#include <semaphore.h>
int sem_close(sem_t *sem);

当一个进程打开有名信号量时, 系统会记录进程与信号的关联关系。调用sem_close时, 会终止这种关联关系, 同时信号量的进程数的引用计数减1。

进程终止时, 进程打开的有名信号量会自动关闭。当进程执行exec系列函数时, 进程打开的有名信号量会自动关闭。注意:关闭不等于删除。

(3)有名信号量的删除

#include <semaphore.h>
int sem_unlink(const char *name);

将有名信号量的名字作为参数, 传递给sem_unlink, 该函数会负责将该有名信号量删除。由于系统为信号量维护了引用计数, 所以只有当打开信号量的所有进程都关闭了之后, 才会真正地删除。

3.2无名信号量

无名信号量, 由于其没有名字, 所以适用范围要小于有名信号量。只有将无名信号量放在多个进程或线程都共同可见的内存区域时才有意义, 否则协作的进程无法操作信号量, 达不到同步或互斥的目的。所以一般而言, 无名信号量多用于线程之间。因为线程会共享地址空间, 所以访问共同的无名信号量是很容易办到的事情。或者将信号量创建在共享内存内, 多个进程通过操作共享内存的信号量达到同步或互斥的目的。

(1)无名信号量的创建

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);

无名信号量的生命周期是有限的, 对于线程间共享的信号量, 线程组退出了,无名信号量也就不复存在了。对于进程间共享的信号量, 信号量的持久性与所在的共享内存的持久性一样。

无名信号量初始化以后, 就可以像操作有名信号量一样操作无名信号量了。

(2)无名信号量的销毁

#include <semaphore.h>
int sem_destroy(sem_t *sem);

sem_destroy用于销毁sem_init函数初始化的无名信号量。只有在所有进程都不会再等待一个信号量时, 它才能被安全销毁。

3.3信号量的使用

信号量的使用, 总是和某种可用资源联系在一起的。创建信号量时的value值,其实指定了对应资源的初始个数。当申请该资源时, 需要先调用sem_wait函数;当发布该资源或使用完毕释放该资源时, 则调用sem_post函数。

(1)等待信号量

#include <semaphore.h>
int sem_wait(sem_t *sem);

注意:调用该接口的执行流会对计数器进行减1。

两种情况:

  1. 如果减1操作执行完之后,计数器的值是大于0的,表示可以访问临界资源,意味着sem_wait可以返回。

  2. 如果减1操作执行完之后,计数器的值是小于0的,该接口的执行流被阻塞,该执行流被放入到PCB等待队列当中。

int sem_trywait(sem_t *sem);

sem_trywait会尝试将信号量的值减1, 如果信号量的值大于0, 那么该函数将信号量的值减1之后会立刻返回。如果信号量的当前值为0, 那么sem_trywait也不会陷入阻塞, 而是立刻返回失败, 并置errno为EAGAIN。

EAGAIN:当前操作不会执行,但不会阻塞。

int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

如果超过了等待时间, 信号量的值仍然为0, 那么返回-1, 并置errno为ETIMEOUT。
ETIMEOUT:超出等待时间限制。

(2)发布信号量

sem_post函数用于发布信号量, 表示资源已经使用完毕, 可以归还资源了。该函数会使信号量的值加1。

#include <semaphore.h>
int sem_post(sem_t *sem);

如果发布信号量之前, 信号量的值是0, 并且已经有进程或线程正等待在信号量上, 此时会有一个进程被唤醒, 被唤醒的进程会继续sem_wait函数的减1操作。如果有多个进程正等待在信号量上, 那么将无法确认哪个进程会被唤醒

当函数调用成功时, 返回0;失败时, 返回-1, 并置errno。当参数sem并不指向合法的信号量时, 置errno为EINVAL;当信号量的值超过上限时, 置errno为EOVERFLOW。

(3)信号量如何实现互斥

初始信号量当中的计数器为1,表示只有一个资源可以使用。

当执行流A想要访问临界资源的时候,首先获取信号量,由于计数器当中值为1,表示可以访问,计数器的值从1变为0,从而执行流A去访问临界资源。

此时当执行流B想要访问临界资源的时候,首先获取信号量,但是计数器当中值为0,表示不能访问当前临界资源,执行流B就被放到了PCB等待队列当中,同时信号量当中的计数器值从0变为-1(0–>-1),-1表示当前还有一个执行流在等待访问临界资源。

(4)信号量如何实现同步

  • 不要求信号量当中的计数器一定为1,也可以为其它整数

  • 当执行流想要访问临界资源的时候,首先获取信号量

  • 如果信号量当中计数器值大于0,则表示能够访问临界资源,该执行流不会阻塞,顺序执行临界区代码

  • 如果信号量当中计数器值小于等于0,则表示不能访问该临界资源,该执行流就会被放到PCB等待队列中去,同时计数器也会进行减一操作

  • 当发布信号量的时候,会对信号量当中的计数器进行加1操作,是否唤醒PCB等待队列的执行流?

  • 若加1之后,计数器还为负数或者为0,则需要通知PCB等待队列当中的执行流

  • 计数器加1之后为整数,则不需要通知PCB等待队列。

注意:计数器值为负,则代表还有多少个执行流在PCB等待队列中等待。

(5)无名信号量实现生产者与消费者模型

代码:下述代码,可随意更改资源数量和线程数量,结果都是可行的,附注释

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <vector>
#include <pthread.h>
#include <semaphore.h>
#include <sys/syscall.h>
#include <iostream>

#define REOURSECOUNT 4 //资源数量
#define PTHREADCOUNT 2 //线程数量

class ModelOfProdConsByPosix
{
public:
ModelOfProdConsByPosix()//构造
:_queue(REOURSECOUNT)
{
_capacity = REOURSECOUNT;
sem_init(&_lock,0,1);//0代表线程间,1为计数器的初始值

sem_init(&_prod,0,REOURSECOUNT);//生产者,最开始计数器初始值为4,表示可以有4个资源可用
sem_init(&_cons,0,0);//消费者

_write_pos = _read_pos = 0;
}

~ModelOfProdConsByPosix()//析构
{
sem_destroy(&_lock);
sem_destroy(&_prod);
sem_destroy(&_cons);
}

void push(int& data)//插入数据
{
sem_wait(&_prod);
sem_wait(&_lock);
_queue[_write_pos] = data;//在这里我们不需要循环判断,因为可用资源为RESOURCECOUNT个,每写入数据之后,计数器就会减1
_write_pos = (_write_pos + 1) % _capacity;
sem_post(&_lock);
sem_post(&_cons);
}
void pop(int &data)//删除数据
{
sem_wait(&_cons);
sem_wait(&_lock);
data = _queue[_read_pos];//pop时,也不需要循环判断,生产者给消费者发送信号量之后,其计数器就会加1,当pop之后,其计数器就会减1,就达到了同步和互斥
_read_pos = (_read_pos + 1) % _capacity;
sem_post(&_lock);
sem_post(&_prod);
}
private:
std::vector<int> _queue;
int _capacity;
sem_t _lock;
sem_t _prod;
sem_t _cons;
int _write_pos;
int _read_pos;
};


void *ConsumerStart(void *arg)
{
ModelOfProdConsByPosix *cp = (ModelOfProdConsByPosix*) arg;
int data = 0;
while(1)
{
cp->pop(data);
printf("i am pid : %d,i consume : %d\n",(int)syscall(SYS_gettid),data);
}
}

void *ProductStart(void *arg)
{
ModelOfProdConsByPosix *cp = (ModelOfProdConsByPosix*) arg;
int data = 0;
while(1)
{
cp->push(data);
printf("i am pid : %d,i product : %d\n",(int)syscall(SYS_gettid),data);
++data;
}
}
int main()
{
ModelOfProdConsByPosix *cp = new ModelOfProdConsByPosix;
pthread_t cons[PTHREADCOUNT],prod[PTHREADCOUNT];
for(int i = 0; i < PTHREADCOUNT;++i)
{
int ret = pthread_create(&cons[i],NULL,ConsumerStart,(void*)cp);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
ret = pthread_create(&prod[i],NULL,ProductStart,(void*)cp);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
}

for(int i = 0;i < PTHREADCOUNT;++i)
{
pthread_join(cons[i],NULL);
pthread_join(prod[i],NULL);
}

return 0;
}

结果:

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多