管道
管道具有两个局限性:
- 历史上,管道是半双工的(即数据只能在一个方向上流动),目前有些系统实现全双工,但为了最大可移植性,应该假定系统支持半双工;
- 管道只能在具有公共祖先的两个进程之间使用。
管道通过调用pipe函数创建。
1 |
|
参数fd返回两个文件描述符:fd[0]负责读、fd[1]负责写。fd[1]的输出是fd[0]的输入。对于支持全双工管道的系统,fd[0]和fd[1]以读/写方式打开。
一般来讲,使用管道通常进程会调用pipe,然后调用fork,从而创建从父进程到子进程的IPC通道,如下图所示:
之后如果想创建从父进程和子进程的管道,父进程可以关闭读端(fd[0]),子进程关闭写端(fd[2])。如下图所示:
当管道一端被关闭时,会有以下规则:
- 如果读一个写端已关闭的管道,在所有数据被读完后,read返回0,表示文件结束;
- 如果写一个读端已关闭的管道,会产生SIGPIPE信号。如果忽略或捕捉该信号从处理程序返回后,write返回-1,errno设置为EPIPE。
实例1
下面创建了一个从父进程到子进程的管道,并父进程从管道中向子进程传送数据。
1 |
|
函数popen和pclose
标准I/O库提供了两个函数popen和pclose,这两个函数的操作是:创建一个管道,fork一个子进程,关闭未使用的管道端,执行shell命令,然后等待命令终止。
1 |
|
函数popen先执行fork,然后调用exec执行cmdstring,并返回一个文件指针。若type是“r“,则文件指针连接到cmdstring的标准输出,表示进程可以从管道里读数据;若type是”w”,则文件指针连接到cmdstring的标准输入,表示进程可以向管道里写数据,如下所示:
函数pclose关闭标准I/O流,等待命令终止,然后返回shell的终止状态。
实例
popen和pclose的具体实现:
1 |
|
协同进程
UNIX系统过滤程序从标准输入读取数据,向标准输出写数据,几个过滤程序在shell管道中线性连接。当一个过滤程序既产生某个过滤程序的输入,又读取该过滤程序的输出,则它就是协同进程。
popen只提供连接到另一个进程的标准输入或标准输出的一个单项管道,而协同进程有连接到另一个进程的两个单项管道:一个连接到标准输入、一个连接到标准输出。可以将数据写到标准输入,经过处理后,在从其标准输出读取数据。示意图如下图所示:
实例
下面是一个简单的协同进程示例,协同进程从其标准输入读取两个数,计算它们的和,然后输出到标准输出。
1 |
|
FIFO
FIFO又称命名管道,匿名管道只能在只能用于有相同父进程的两个进程通信。而通过FIFO,不相关的进程也能交换数据。
FIFO是一种文件类型,通过stat结构的st_mode字段可以直到文件是否是FIFO。创建FIFO类似于创建文件:
1 |
|
当用mkfifo或mkfifoat创建FIFO使,要用open打开它。正常的文件I/O函数都需要FIFO。
当open一个FIFO是,非阻塞标志(O_NONBLOCK)会有一下影响:
- 在没有阻塞情况下(默认,没有指定O_NONBLOCK),只读open会阻塞到某个进程以写打开该FIFO为止。同理,只写open要阻塞到某个其他进程以读打开该FIFO为止;
- 如果指定了O_NONBLOCK,只读open立即返回。若此时没有其他进程以读打开FIFO,则只读open返回-1,并将errno设置为ENXIO。
类似于管道,若write一个没有读进程的FIFO,则产生信号SIGPIPE。若FIFO的最后一个写进程关闭了FIFO,则为该FIFO产生一个文件结束标志。
一个给定的FIFO有多个写进程是常见的,如果要保证数据不交叉,则需要保证写操作的原子性。和管道一样,PIPE_BUF是原子地写入FIFO的最大数据量。
XSI IPC
有三种称为XSI IPC的IPC:消息队列、信号量、共享内存。
每个IPC结构关联一个ipc_perm结构,该结构定义了权限和所有者,至少包括:
1 | struct ipc_perm { |
修改这些值,调用进程必须是IPC结构的创建者或者超级用户。
mode字段表示权限,任何IPC不存在执行权限,消息队列和共享内存使用术语“读”和“写”,信号量使用“读”和“更改”,下表是每种IPC的权限。
| 权限 | 位 |
|---|---|
| 用户读 | 0400 |
| 用户写(更改) | 0200 |
| 组读 | 0040 |
| 组写(更改) | 0020 |
| 其他读 | 0004 |
| 其他写(更改) | 0002 |
消息队列
消息队列是消息的链接表,存储在内核中,有消息队列标识符标识。
msgget用于创建新队列或打开一个现有队列;msgsnd将消息添加到队列尾部;msgrcv用于从队列中取消息。消息并不一定要以先进先出的次序取,也可以按照消息的类型取消息。
每个队列都有一个msqid_ds结构与其关联:
1 | struct msqid_ds { |
此结构定义了队列的当前状态。
调用的第一个函数通常是msgget,其功能是打开一个现有队列或创建一个新队列:
1 |
|
参数key讨论了是创建一个新队列还是引用现有队列。在创建新队列时,需要初始化msqid_ds结构的下列成员:
- ipc_perm按上述的XSI IPC所述舒适化,mode成员按flag设置相应权限位,权限XSI IPC所述;
- msg_qnum、msg_lspid、msg_lrpid、msg_stime、msg_rtime都设置为0;
- msg_ctime设置为当前时间;
- msg_qbytes设置为系统限制值(如Linux是16384字节)。
若执行成功,msgget返回非负队列ID。该ID可用于后续的消息队列函数。
函数msgctl可以对队列执行多种操作。
1 |
|
参数cmd指定了msqid指定队列要执行的命令,有:
- IPC_STAT: 取mspid所指队列的msqid_ds结构,并将其存放在buf执行的结构中
- IPC_SET:将buf中的msg_perm.uid、msg_perm.gid、msg_perm.mode、msg_qbytes复制到msqid指定的msqid_ds结构中。此命令只能由两种进程执行:1)有效用户ID等于msg_perm.cuid或msg_perm.uid;2)拥有超级用户权限的进程。还有只有超级用户才能增加msg_qbytes的值;
- IPC_RMID:从系统中删除该消息队列以及仍在消息队列中的所有数据。删除立即生效。仍在使用这一消息队列的进程在下一次试图操作该队列时,会得到EIDRM错误。执行此命令的进程只有两种,与上述的IPC_SET一致。
这三条命令(IPC_STAT、IPC_SET、IPC_RMID)可以用于信号量和共享存储。
函数msgsnd将数据放进消息队列中。
1 |
|
每个消息由3部分组成:正的长整型字段、非负的长度、实际数据字节数。并且消息总是放在队列尾端。
参数ptr指向一个长整型数,包括整型消息类型,紧接着是消息数据(若nbyte为0,则无消息数据)。若发送的最长消息为512字节,因此可以定义以下结构:
1 | struct mymesg { |
ptr就可以是指向mymesg结构的指针。接受者可以通过消息类型以非先进先出的次序取消息。
参数flag可以指定为IPC_NOWAIT。这类似于I/O中的非阻塞I/O标志,若消息已满,则指定了IPC_NOWAIT的msgsnd操作会立即出错并返回EAGAIN。如果没有指定IPC_NOWAIT,则进程会一直阻塞,直到1)有空间容纳消息;2)或系统删除该消息队列,返回EIDRM错误;3)或捕捉到一个信号,并从信号处理函数返回,返回EINTR错误。
当msgsnd成功返回时,消息队列的msqid_ds结构会更新,表明调用的进程ID(msg_lspid)、调用时间(msg_stime)、新增消息(msg_qnum)。
函数msgrcv从消息队列中取消息。
1 |
|
参数ptr与msgsnd类似,包括消息类型和消息数据缓冲区;nbytes指定缓冲区长度,若返回消息的长度大于nbytes,如果flag设置MSG_NOERROR位,则将该消息截断,如果没有设置该位,则函数出错返回E2BIG(消息仍在队列中)。
参数type有三种情况:
- type == 0:返回队列中的第一个消息;
- type > 0 :返回消息队列中第一个类型为type的消息;
- type < 0 :返回消息类型值小于等于type绝对值的消息,若符合的消息有多个,则返回类型值最小的消息。
参数flag可指定为IPC_NOWAIT,这样,若无指定类型的消息,则msgrcv返回-1,error设置为ENOMSG。如果没有设置IPC_NOWAIT,则进程阻塞,直到1)有指定类型消息可用、2)或系统删除此队列(返回-1,error设置为EIDRM)、3)或捕捉到一个信号并从信号处理函数返回(返回-1,error设置为EINTR)。
msgrcv成功执行时,内核更新该消息队列相关的msgid_ds结构,有指示调用者的进程ID(msg_lrpid)、调用时间(msg_rtime)、指示消息数减少1(msg_qnum)。
因为:
该IPC没有引用计数,当向队列中添加消息后直接终止,该消息队列不会被删除,直到进程调用msgrcv或msgctl或删除该消息队列。直到最后一个引用FIFO的进程结束,FIFO名字仍保留在系统中;
该IPC不使用文件描述符,因此无法使用I/O多路复用函数(select、poll),这使得很难一次使用一个以上的IPC结构。
目前为止,在速度上管道和FIFO相差无几,因此,在新的程序中不应该使用FIFO。
信号量
信号量与前面的IPC不同,它是一个计数器,用于为多个进程提供对共享数据对象的访问。
为了获得共享资源,进程需要:
- 判断控制该资源的信号量;
- 若信号量为正,则进程可以使用该资源,并将信号量值减1;
- 若信号量为0,则进程进入休眠状态,直到信号量大于0,进程被唤醒,执行步骤1.
常用的信号量为二元信号量,它控制单个资源,初始值为1。但是信号量初值可以是任意初值,表示控制了多少个共享资源单位。
内核为每个信号量集合维护一个semid_ds结构:
1 | struct semid_ds { |
每个信号量由一个无名结构表示,它至少包括:
1 | struct { |
要想使用信号量,首先需要调用semget函数获得一个信号量ID。
1 |
|
创建一个新集合时,要对semid_ds结构的下列成员赋值:
- 初始化ipc_perm结构,与FIFO中的msqid_ds类似;
- sem_otime设置为0
- sem_ctime设置为当前时间
- sem_nsems设置为nsems
nsems是该集合中的信号量数。
函数semctl包含了多个信号量操作
1 |
|
第4个参数可选,若使用,则类型是semun,如下:
1 | union semun { |
注意这里是union,不是指向union的指针。
参数cmd有以下命令:
- IPC_STAT:取该集合的semid_ds结构,存储在arg.buf指向的结构中
- IPC_SET:按arg.buf指向的结构的值,设置集合中的sem_perm.uid、sem_perm.gid、sem_perm.mode字段。
- IPC_RMID:从系统中删除该信号量集合
- GETVAL:返回semnum的semval值
- SETVAL:设置semnum的semval值。该值大小由arg.val指定
- GETPID:返回semnum的sempid值
- GETNCNT:返回semnum的semncnt值
- GETZCNT:返回semnum的semzcnt值
- GETALL:取该集合中所有的信号量值。这些值存储在arg.array指向的数组中
- SETALL:将集合中所有信号量的值设置为arg.array指向的数组中的值。
除GETALL以外的所有GET命令,semctl返回相应的值。其他命令,若成功,返回0;如出错,返回-1,并设置errno。
函数semop自动执行信号量集合上的操作数组。
1 |
|
semoparray是一系列信号量操作的数组:
1 | struct sembuf { |
参数nops指定了该数组中操作的数量。
semop具有原子性,对数组中的操作,它或者执行所有操作,或者一个不做。
注意,对于多个进程间共享一个资源,对单一资源加锁,我们应该使用记录锁,因为它比信号量更简单、速度更快,并且系统会管理进程结束后遗留下的锁(对于信号量要指定SEM_UNDO标志)。
共享存储
共享存储允许多个进程共享给定的存储区,因为数据不需要拷贝,因此是最快的IPC。使用共享存储时,需要同步多个进程,例如在服务进程正在写数据,那么客户进程不应该读数据。通常,信号量用于同步共享存储的访问。
XSI共享存储与内存映射文件的区别是,前者没有相关的文件,且共享存储段是内存匿名段。
内核为每个共享存储段维护一个结构,该结构至少有:
1 | struct shmid_ds { |
shmget通常是第一个调用的函数,它获得一个共享存储标识符
1 |
|
key用于表示是创建一个新共享存储段,还是引用一个现有的共享存储段。当创建一个新段时,需要初始化shmid_ds结构的以下成员(和消息队列、信号量类似):
- ipc_perm按之前XSI IPC描述的方式初始化
- shm_lpid、shm_nattach、shm_atime、shm_dtime都设置为0;
- shm_ctime设置为当前时间;
- shm_segsz设置为请求的size。
参数size是共享存储段的长度,以字节为单位。通常为向上取整的系统页长(Linux是4096字节)的整数倍。当创建段时,指定size大小,当引用段时,size为0。
和消息队列、信号量类似,shmctl函数对共享存储段执行多种操作。
1 |
|
cmd参数指定5中命令:
- IPC_STAT:取段的shmid_ds结构,存储在buf指向的结构中
- IPC_SET:由buf参数指向的结构设置段的shmid_ds结构中的参数:shm_perm.uid、shm_perm.gid、shm_perm.mode。
- IPC_RMID:从系统中删除该共享存储段。
Linux和Solaris提供了额外两个命令,它们不是Single UNIX Specification的组成部分。 - SHM_LOCK:在内存中对该段加锁,此命令只能由超级用户执行。
- SHM_UNLOCK:解锁共享存储段,只能由超级用户执行。
创建了一个共享存储段后,进程可以通过函数shmat将其映射到它的地址空间中。
1 |
|
对于参数addr,表示共享存储映射到进程的地址,除非只计划在一个硬件上允许,否则不应该设置该值,应当指定addr为0,由内核决定地址。
参数flag如果为SHM_RDONLY,表示只读该共享存储段,否则为读写方式连接此段。
函数shmdt可以将进程与该段分离,注意,此时并没有删除其标识符和相关数据结构。标识符会一直存在,直到有进程使用IPC_RMID的调用shmctl函数删除它为止。
1 |
|
实例
下面的程序是测试存储区各个段(bss段、堆段、栈段、data段)和共享存储段的空间分布位置。
1 |
|
在我的Linux中,其输出如下:
array[] form 0x555e5adf8060 to 0x555e5adf9000
stack around 0x7fff0cbb73a4
malloced from 0x555e5ca946b0 to 0x555e5caacd50
shared memory attached from 0x7f150fdd7000 to 0x7f150fdef6a0
可以看到,它与典型存储区分布类似:
POSIX信号量
POSIX信号量相较于XSI信号量有了优化,解决了XSI信号量的缺点:
- POSIX信号量性能更高;
- POSIX信号量使用更简单,没有信号量集机制;
- POSIX在删除时表现更好。当XSI信号量被删除时,使用信号量标识符的操作会失败,并设置errno为EIDRM,而使用POSIX信号量,在信号量标识符被删除时,操作不会失败并且正常工作直到该信号量的最后一次引用被释放。
POSIX信号量有两种形式:命名的和未命名的。它们的差异在创建和销毁上,其他工作一样。
未命名信号量只存在内存中,要求使用信号量的进程必须可以访问该内存(也就是信号量所在内存位置),因此它只用于:(1)同一进程的线程;(2)不同进程将信号量所在内存映射到各自空间中的线程。
命令信号量可以通过名字访问,可以被任何一直它名字的进程使用。
函数sem_open可以创建一个新的命名信号量或使用一个现有信号量。
1 |
|
参数name是信号量的名字;
参数oflag指定函数动作标志,oflag如果是O_CREAT,则表示创建信号量,若信号量存在,则无额外的初始化发生,并且函数不会出错;若确保要创建信号量,则oflag设置诶O_CREAT|O_EXCL,此时如果信号量已存在,sem_open会调用失败。
另外两个参数用于创建信号量,mode指示信号量权限,value指示信号量的初始值。
函数sem_close用于释放任何与信号量相关的资源。
1 |
|
如果进程没有调用sem_close后退出,则内核会自动关闭任何打开的信号量。
函数sem_unlink用于销毁一个命名信号量
1 |
|
如果name指示的信号量没有被引用,则该信号量被销毁;若有引用,则销毁会延迟到最后一个引用关闭。
函数sem_wait或sem_trywait用于信号量减1操作。
1 |
|
调用sem_wait函数在信号量为0时,进程会进入阻塞状态,而调用sem_trywait函数在信号量为0时,则直接出错返回-1,并将errno置为EAGAIN。
函数sem_timewait可以设定阻塞时间
1 |
|
如果超时信号量没能减1,则返回-1,并将errno设置为ETIMEOUT。
函数sem_post可使信号量加1,若调用sem_post时,有进程因为sem_wait阻塞,则进程被唤醒,并信号量被sem_wait减1。
1 |
|
若只想在单个进程中使用POSIX信号量,使用未命名信号量更容易。函数sem_init创建一个未命名信号量。
1 |
|
pshared参数表示是否要在多个进程中使用信号量,如果是,则设置其为非0。value指定信号量的初始值。
sem参数则是一个声明的sem_t类型变量的地址,而不需要通过sem_open。
未命名信号量使用完成后,调用sem_destroy丢弃它。
1 |
|
函数sem_getvalue可以检索信号量值。
1 |
|
成功后,valp指向的整数就是信号量值。注意,在获取到信号量值后,该值有可能已经改变,除非使用额外的同步机制避免竞争,否则该函数只适用于调试。
实例
下面通过信号量来实现互斥锁,该锁能被一个线程加锁而被另外一个线程解锁,它的结构可以是:
1 | struct slock { |
下面是通过信号量实现互斥原语。
1 | /* 头文件 */ |
小结
这一章介绍了进程间通信的方式:管道、命名管道(FIFO)、通常称为XSI IPC的3种形式的IPC(消息队列、信号量、共享存储)、POSIX提供的替代XSI IPC信号量的机制。
APUE上给出的建议是:学会使用管道和FIFO,这两种IPC技术可以有效应用于大量程序。在新程序中,避免使用消息队列及信号量,应当考虑全双工管道和记录锁代替,因为它们使用更简单。共享存储仍有它的用途,虽然mmap(存储映射I/O的函数)有同样的效果。