所谓 IT,离不开不同信息数据的交换。同一操作系统中运行的不同程序之间,不同操作系统中的程序之间,甚至是不同体系架构的计算机系统之间,都会出现交换数据信息即通信的需求。现代计算机系统中的通信,归根结底是由操作系统的进程来进行的(大型的自动化系统中的每一个在运行任务的智能节点通常抽象为一个独立的进程)。Wikipedia 上列出主要的进程间通信技术包括了:匿名管道、命名管道(fifo)、公共对象请求代理体系结构 CORBA、D-Bus、分布式计算环境 DCE、可扩展标记语言 XML、开放式网络计算技术与远程过程调用
ONC RPC(即 Sun ONC & RPC)、套接字 Sockets 等等。可见进程间通信技术事实上是一个相当广泛的概念。
本章主要讲述匿名管道、fifo、XSI IPC这三种经典的 Unix 系统的进程间通信技术及其应用。
1、管道
管道技术在 Unix 的语境中有时候指 shell 中的管道线技术,有时候指进程间通信程序设计中的匿名管道技术,有时是匿名管道和 fifo(7)的统称。本书特指匿名管道。
匿名管道技术的一个经典应用为shell 中的管道线,它实现将前一个程序的标准输出成为后一个程序的标准输入。
管道技术使得 Unix 系统只需要提供基本的工具零部件,用户用管道的把所需的工具组合在一起就可以实现出许多新的应用,而不用专门重新发明轮子。管道的发明对 Unix 哲学影响深远,如“Do One Thing And Do It Well”、“Keep It Simple, Stupid”、“Small Is Beautiful”、“Less Is More”等。ESR认为,管道技术的发明人Doug Mcllroy 是在UNIX 的作者Ken Thompson 和Dennis Ritchie 之后,对早期UNIX
影响最重要的人。
下面的这个命令来自网上,它通过history(1)(显示命令历史记录的shell 内置工具)、awk(1)(格式化文本和正则表达式过滤工具)、sort(1)(对输入进行排序的工具)、uniq(1)(统计某种特征的输入重复次数的工具)、head(1)(显示输入的前面部分的工具)这几个工具用管道线组合起来,用于显示你在shell 中最常用的前10个命令及其使用次数:
$ history | awk '{print $2}' | awk 'BEGIN {FS="|"} {print $1}'| sort | uniq -c | sort -rn | head -10
利用netcat(1)工具,就可以把管道应用到网络上。下面的例子使用netcat将主机2 的数据压缩后发送到主机 1 再解压缩:
主机 1,监听端口 12345,将网络上发送过来的数据解包到指定目录:
host1 $ nc -l -p 12345 | tar zxvf - -C /home/jamnix/datatorecv/
主机 2,将指定的目录(或文件)打包并压缩,通过 netcat发送给主机 1:
host2 $ tar zcvf - /home/mjxian/datatosend/ | nc host1 12345
可以用下面的函数在程序中创建一个匿名管道:
#include <unistd.h> int pipe(int filedes[2]);
该函数用数组参数创建并打开了两个匿名(即不能通过文件名引用)的管道文件。描述符filedes[0]作为输入端,用于读取管道传来的数据,对它的 write 调用将失败;而 filedes[1]作为输出端,用于向管道写数据,对它的 read 调用将失败。写入 fildes[1]的数据可以从 fildes[0]中读出。
进程在 fork 之前创建匿名管道,由于子进程继承文件描述符,就可以使用这个管道和父进程及其它兄弟进程进行通信。
应注意的几点:
- 从一个输出端已经关闭的管道读数据时,所有数据读取完毕后 read 将返回 0,表示已经读取完毕;而往一个输入端已经关闭的管道写数据时,将产生信号SIGPIPE,不阻塞此信号的话,write(2)调用将返回-1并设置errno 为EPIPE;
- 多个进程同时并发地往一个管道写数据时,如果某个进程写入的字节数≥PIPE_BUF 时,管道数据将穿插在一起。要避免这个问题,应采取有效的同步措施;
- 可以利用 dup2(2)重定向标准输入和标准输出到管道;
管道技术的主要局限:
- 可移植的管道是半双工的,全双工管道不能保证移植性(所以 pipe(2)要使用两个文件描述符);
- 由于匿名管道使用文件描述符实现,故对进程的属性有限制,只在父进程及其各子进程之间使用;
2、popen 和 pclose
popen(3) 创建一个子进程,用于执行指定的 shell 命令。和 system(3)不同的是,可以将此子进程的标准输入或标准输出为管道,该管道的另一端为调用进程中返回的管道文件流指针所引用:
#include <stdio.h> FILE *popen(const char *cmdstring, const char *type);
type 为"r"时,子进程所执行命令的标准输出为管道的输入端,该管道的输出端为 popen 的返回值;type 为"w"时,子进程执行的命令的标准输入为管道的输出端,该管道的输入端为 popen 的返回值;
pclose(3)则用于关闭 popen 打开的该指针:
#include <stdio.h> int pclose(FILE *fp);
该函数返回popen执行shell 命令的终止状态($?)。
注意:为了防止别有用心的用户利用,使用了popen(3)函数的程序应该防止SetUID 或者SetGID 可能带来的破坏。可通过执行类似以下流程预防:
oldeuid = geteuid(); setuid(getuid()); /* do your popen(3) and other SetUID tasks */... /* task done */ setuid(oldeuid);
popen 可应用于构造简单的过滤器程序;
3、协同进程的概念
如果一个进程的标准输入和标准输出都在另外一个进程的控制之下,则称该进程为另一个进程(通常是其父进程)的协同进程。ksh(1)提供了关键字“|&”实现协作进程;
由于协同进程是使用管道来实现的,而管道只有写端输入EOF 时才会关闭读端。所以协同进程必须使用行缓冲或者无缓冲的的 I/O,否则除非使用了 fflush(3),否则输入时协同进程不会工作。
4、fifo
fifo(7)指的是命名管道,它和匿名管道都属于管道文件,区别是能否存在于文件系统中。在文件系统中创建一个 fifo 文件的函数为:
#include <sys/stat.h> int mkfifo(const char *pathname, mode_t mode);
以后就可以使用标准的 I/O 函数访问它。也可以通过 mkfifo(1)命令创建一个 fifo。
如果当前任务使用阻塞的方式读一个 fifo 文件,任务将阻塞到有另外一个任务往此 fifo 中写数据。如果没有进程在读一个fifo 文件,则对其写入数据将产生信号SIGPIPE;
如果fifo 文件的最后一个写进程关闭了它,则读进程将读到一个EOF;
利用 fifo 可以实现简单的 C/S 通信模型 —— 客户端使用一个公共的fifo 向服务器进程发送请求,而服务器通过 hash 到客户进程 PID 的专用fifo 发送响应;
使用公共 fifo 要注意并发的问题;还要注意写端被所有的客户进程关闭后,服务器进程读到 EOF情况下的妥善处理;
专用fifo 要注意捕捉由于客户关闭fifo 的输入端而产生的SIGPIPE信号,并回收已终止的客户进程的fifo;
5、XSI IPC
XSI IPC 以 System V IPC 机制为基础制定。它包括三种类型:消息队列、信号量及共享内存(书中译为“共享存储”,我这里采用更要广泛一些的译名)。它们的特点是不存在于文件系统命名空间,即不存在于文件系统,不能用访问文件的 I/O 方法去访问,而只能使用专门设计的系统调用。APUE2 和TAOUP 这两本书都认为 XSI IPC 是历史遗留功能,新程序最好使用其它的进程间通信机制(例如管道或者套接字)来取代它们。
(1) XSI IPC 对象的标识符与键
标识符使用 int 类型,它唯一标识了系统运行时的一个 IPC对象。标识符和文件描述符有几个地方不一样:新的 IPC对象标识符值总为上一次的标识符值加 1,直到溢出翻转;IPC 对象标识符在整个系统中是唯一的,而文件描述符只能被进程及其子进程使用。
任何进程都可以通过键(key)获得一个系统中的IPC对象标识符。有3 种方法可以得到一个键;
A. 以 IPC_PRIVATE 为键值创建一个新的 IPC 对象。返回的标识符可以存放在文件系统中供其它进程获得。缺点是需要读写文件;
B. 在公用的头文件中指定一个键,通过此键创建 IPC 对象。缺点是可能导致重复(此时相关函数将返回出错)。
C. 将指定的路径和 id 通过 ftok(3)转换为一个键值,通过方法 B 使用此键。
#include <sys/ipc.h> key_t ftok(const char *path, int id);
其中,path 必须为真实存在的路径,id 只使用其低 8位;但此种方法并不能完全避免产生相同的键。
此后可以使用 IPC 对象的get 函数(在下面说明这些函数)创建或访问指定的IPC对象。这些函数共有的规则是:
- k ey 参数为 IPC_PRIVATE 的话表示创建一个新的 IPC 对象;
- k ey 没有被现有的 IPC 对象使用,并在参数 flag 中指定 IPC_CREAT 时,也创建一个新的 IPC对象;判定 key 是否已经被使用,可以在 flag 中指定 IPC_EXCL;
- 否则,get 函数则用于通过指定的 key 获得对应 IPC 对象的标识符;
(2)访问模式
消息队列和共享内存包括“读”和“写”两种访问模式,而信号量为“读”和“更改”两种模式。
(3)IPC 对象的主要优点和缺点
- 需要自己设计引用计数等释放对象的规则;
- 不属于文件范畴,不能使用文件系统的 open(2)、read(2)、write(2)等 I/O 函数,故也不能利用已有的高级I/O 机制并行地访问多个IPC 对象;
(4)消息队列
消息队列是一个链表,新建或打开一个消息队列的函数为
#include <sys/msg.h> int msgget(key_t key, int flag);
管理一个消息的函数为:
#include <sys/msg.h> int msgctl(int msqid, int cmd, struct mdqid_ds *buf);
注意有关消息队列的函数中给的参数有时是msq,有时是msg,前者是消息队列,后者指单个消息。
参数 cmd 包括:
IPC_STAT: 将指定的队列 msqid 的属性结构 msqid_ds 存入 buf;
IPC_SET: 按 buf 指定的值,修改 msqid 的 uid、gid、mode 和 qbyte 等 4 个属性。此操作需要相应权限,特别是 qbyte 的修改需要 root 权限;
IPC_RMID: 删除队列 msqid,立即生效。此操作需要相应权限。
往队列中添加一个消息的函数为:
#include <sys/msg.h> int msgsnd(int msqid, const void *ptr, size_t nbytes, int flag);
其中消息 ptr 头部必须是一个 long 类型的消息类型,供 msgrcv(2)函数检别。
flag 可以设置为 IPC_NOWAIT,否则函数在队列已经满的时候将被阻塞,直到有信号被递送或者队列腾出空间;
从队列中取走一个消息(该消息将直接出队)的函数为
#include <sys/msg.h> int msgrcv(int msqid, void *ptr, size_t nbytes, long type, int flag);
如果type 为 0,则函数取队列中最早的消息;为正整数,则取匹配消息头部的最早的消息;为负整数,则取头部所标识类型小于type 绝对值的第一个消息;
flag 可以为 IPC_NOWAIT(不使用的话,则阻塞到队列非空或者被信号中断)或者MSG_NOERROR(不使用的话,ptr 指向的空间长度 nbytes 不够装入匹配 type 的消息时将报错)等;
(5)信号量
信号量的实质是一个同步机制,实际实现为一个引用计数器。
信号量(Semaphore)和信号(Signal)在概念上的区别:信号量(有时也称为信号灯)展现的信号带有规则性,例如交通灯就是一种 Semaphore,它规定了红灯停、绿灯行等;而单纯的 Signal只展现一种状态,怎么处理这种状态多数情况下是用户自主的,例如手机上的网络强度信号;
而在计算机同步机制中,信号量的值用来描述允许多少个任务引用指定资源,为0 时则表示不可用。
以二元信号量最为常见,它只有 1 和 0两个值,初始值为 1。
新建或打开一个信号量集的函数为
#include <sys/sem.h> int semget(key_t key, int nsems, int flag);
nsems 是 key 所关联的信号量集中的信号数;如果调用该函数是为了引用一个现存的信号量集,则 nsems 应设为 0;
管理一个信号量集的函数为:
#include <sys/sem.h> int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);
semid 为指定的信号量集;semnum 为集合中指定的信号量,取值范围为 0~总数–1;cmd 为指定的操作;arg 为指定的数据源,它是一个联合体类型,定义为:
union semun { int val; /* cmd 为 SETVAL 时作为数据源 */ struct semid_ds *buf; /* cmd 为 IPC_STAT 和 IPC_SET 时的数据源 */ unsigned short *array; /* cmd 为 GETALL 和 SETALL 时的数据源 */ }
cmd 的取值包括:
IPC_STAT 取信号量集的属性结构 semid_ds 到 arg.buf 中;
IPC_SET 以 arg.buf 中的数据修改信号量集的属性;
IPC_RMID 删除信号量集 semid,立即生效;
SETVAL 以 arg.val 的值修改信号量的 semval 值;
GETALL 将 semid 中当前所有信号量的值保存到 arg.array 指向的数组;
SETALL 以 arg.array 所指向数组的数据来更新 semid 中的所有信号量;
GETXXX 有多种,用于使函数返回信号量的属性;
信号量的属性结构没有名字,只作描述用途而不存在实例对象,一般包括
struct { unsigned short semval; /* 信号量当前值 */ pid_t sempid; /* 上次访问该信号量的进程 PID */ unsigned short semncnt; /* 等待该信号量释放资源的进程数 */unsigned short semzcnt; /* 等待信号量不可用(即 semval 为 0)的进程数 */ ... }
信号量一个不靠谱的地方是创建 semget 和初始化 semctl 是分开的,需要自行设计这种非原子的操作步骤可能带来并发问题;
信号量通过函数 semop(2)来使用,该函数是以原子操作实现的:
#include <sys/sem.h> int semop(int semid, struct sembuf semoparray[], size_t nops);
它使用一组 sembuf 对象(长度为 nops)对 semid 集合中指定的信号量进行管理。sembuf 定义为:
struct sembuf { unsigned short sem_num; /* 指定集合中的信号量,取值 0 ~ nsems-1 */ short sem_op; /* 指定操作数 */ short sem_flg; /* 包括 IPC_NOWAIT 和 SEM_UNDO*/ }
该函数将使信号量的值semval 更新为semval + sem_op。
如果没有设置IPC_NOWAIT 标志,且更新后semval 为负数。则进程将阻塞(同时信号量的semncnt 将增加 1)到该信号量值被其它进程修改重新变为非负,或者到信号量被删除,或者被阻塞的进程捕捉了信号。解除阻塞时同时 semncnt也相应减 1。
特别地,如果没有设置IPC_NOWAIT,而sem_op 为0,则进程将阻塞(同时信号量的semzcnt增加1)到信号量变为0,或者到信号量被删除,或者被阻塞的进程捕捉了信号。解除阻塞时同时semzcnt 也相应减 1。
信号量和记录锁等其它同步机制相比的较明显的优势主要在:时间性能较好、可以同时锁多个资源。弱点包括:不提供原子地创建和赋初值的 API、生存期独立于进程且不提供引用计数。故需要使用时,需要自行进行更多复杂的设计。
(6)共享内存
共享内存将公共的内存单元映射到进程的地址空间(有点类似mmap(2)),使得几个进程之间可以不受限制的同时访问同一个内存区域。它是理论上最快的 IPC方式。但可能需要使用适用的同步机制(一般用信号量)来处理并发。
新建或打开一个共享内存对象:
#include <sys/shm.h> int shmget(key_t key, size_t size, int flag);
size 应取 PAGESIZE 的整数倍,用作打开现存的共享内存对象时,size 应设为 0;
管理共享内存对象:
#include <sys/shm.h> int shmctl(int shmid, int cmd, struct shmid_ds *buf);
cmd 包括 IPC_STAT、IPC_SET、IPC_RMID、SHM_LOCK、SHM_UNLOCK(后面两个非 SUS 标准,仅在 Linux 等系统下提供)。
注意:执行IPC_RMID 后,shmid 立即不可用,但已经连接到进程的地址空间依然可以正常访问。
连接共享内存到进程地址空间(一般为堆栈):
#include <sys/shm.h> void *shmat(int shmid, const void *addr, int flag);
addr 指定进程地址空间的首址,但一般应取 NULL,而让系统自行选择地址。函数返回实际连接到的地址空间的首址。出错时返回-1(而不是 NULL)。
解除连接的函数为:
#include <sys/shm.h> int shmdt(void *addr);
也可以通过其它技术实现内存空间共享:
使用 mmap(2),设置为 MAP_SHARED,参数 fd 引用的文件是/dev/zero。可以以这样的方式与子进程共享这块映射到的空间;fd 还可以设置为-1,并设置 MAP_ANON,实现匿名存储映射。
也可以干脆直接使用线程机制;
6.以上几种进程间通信机制应用在 C/S 模型中的比较
- 匿名管道
父进程可以通过fork(2)传递管道文件描述符给子进程。进程间通过管道传输数据。模型简单。主要问题是有进程关系的限制。
- fifo
典型的模型是:客户进程通过公用fifo 向服务进程发请求,服务进程通过专用fifo 向客户进程应答。该模型在设计上比较清晰,需要注意的问题有:处理管道异常信号、清理已失效的fifo。
- XSI IPC 的消息队列
服务器和客户进程只需要一个消息队列就可以实现通信,通过不同的消息类型字段区分客户进程。但这种方式很容易让不遵循规则的恶意进程随意读取非授权的消息,而需要专门设计安全机制,例如在消息中定义相关的授权协议;
也可以实现为类似上述的fifo 模型,服务器进程用公用队列,客户进程用专用队列。可以通过msgid_ds 的 msg_lspid(最新一次发送消息到队列的进程 PID)成员获得 PID,但没有一种可移植的方法可以通过 PID 得到 EUID。且这种方式容易浪费资源,同时服务器需要专门实现多路队列的读取。
消息队列比较突出的一个问题是:由于任意进程只要拿到标识符(而无需其它授权)就可以读取消息(使得消息出队),需要针对此专门设计安全措施(见习题 15.11)。
- XSI IPC 的共享内存
共享内存结合同步方法,也可以实现上述的消息队列的客户机/服务器模型;
7.APUE2 对本章所述的几种进程间通信机制的建议
- 掌握匿名管道和 fifo 技术,因为它们清晰简单;
- 尽量不使用消息队列和信号量,而以全双工管道和记录锁代替之;
- 可以用 mmap(2)代替 XSI IPC 的共享内存;
另外,ESR 在 TAOUP 中指出,System V IPC,即 XSI IPC,用于定义短小二进制协议的功能多数已经被套接字机制取代。