一、概要

  最近一段时间,工作中经常会处理进程间通信相关的事情。于是将一些经验汇总一下。

  总结起来,Linux下进程间通信主要有以下几种方法:

  1. 信号(Signal)
  2. 管道(Pipe)和命名管道(Named pipe)
  3. 消息队列(Message queue)
  4. 共享内存(Shared memory)
  5. 信号量(Semaphore)
  6. 套接字(Socket)

  在众多的进程间通信的方法里面,每一种方法都有优缺点,如何根据使用情景选择合适的通信方法就非常重要了。

二、信号

  信号是非常基础的进程间通信方法,它从软件层面上对中断机制进行了模仿。所以信号也被称作“软中断信号”。信号用来通知进程发生了异步事件。进程之间可以通过系统调用发送软中断信号。操作系统内核也可以因为内部事件而给进程发送信号,通知进程发生了某个事件。Linux支持不可靠信号和可靠信号。

  Linux系统共定义了64种信号,分为两大类:实时信号与标准信号,前32种信号(信号值小于SIGRTMIN)为标准信号(也称之为不可靠信号和非实时信号),后32种(信号值位于SIGRTMIN和SIGRTMAX之间)为实时信号(也称之为可靠信号)。信号很重要的一个特点就是当进程接收到信号后,能够中断当前执行的代码,转而执行信号处理函数。实时信号和标准信号有如下区别:

  • 实时信号没有明确的含义,而是由使用者自己来决定如何使用。而标准信号则一般有确定的用途及含义,并且每种信号都有各自的缺省动作。如按键盘的CTRL+C时,会产生SIGINT信号,进程对该信号的默认反应就是进程终止。
  • 一个进程可以接受多个同样的实时信号,这些实时信号会被缓存在一个队列中,然后按次序被处理。而标准信号则不能。在标准信号没有得到处理的时候,多个标准信号会被合为一个。这也造成了标准信号可能丢失的情况。
  • 实时信号使用sigqueue发送的时候,可以携带附加的数据(int或者pointer)。标准信号不能。
  • 实时信号具有优先级的概念,数值越低的实时信号其优先级越高。在处理的时候,也是数值低的实时信号优先得到处理。
  • 实时信号的默认行为都一样,都是结束当前的进程,这个和标准信号是不一样的。
  • 进程每次处理标准信号后,就将对信号的响应设置为默认动作。在某些情况下,将导致对信号的错误处理。因此,用户如果不希望这样的操作,那么就要在信号处理函数结尾再一次调用signal(),重新安装该信号[注1]

  信号的相关API:

  • signal():安装信号
  • sigaction():安装信号
  • kill():向进程或进程组发送信号
  • sigqueue():发送信号
  • alarm():调用进程指定时间后发出SIGALARM信号
  • setitimer():设置定时器,计时达到后给进程发送SIGALRM信号
  • abort():向进程发送SIGABORT信号,默认进程会异常退出
  • raise():向进程自身发送信号
  • sigemptyset():信号集全部清0
  • sigfillset():信号集全部置1
  • sigaddset():向信号集中加入signum信号
  • sigdelset():向信号集中删除signum信号
  • sigismember():判定信号signum是否存在信号集中
  • sigprocmask():把信号集中的信号添加到进程信号集
  • sigpending():获取已发送到进程,却被阻塞的所有信号
  • sigsuspend():替换进程的原有掩码,并暂停进程执行,直到收到信号再恢复原有掩码并继续执行进程

  值得注意的是:

  • 由于信号会中断主进程的运行,所以在信号处理函数中多任务的并发控制也会经常使用。当主进程获取某个资源并加锁后,如果在它解锁前接收到了一个信号并进入了信号处理函数,并且在这个信号处理函数中,同样需要获取此资源并加锁。但是主进程还没有解锁,这就会导致死锁。所以,在信号处理函数中进行并发控制需要格外小心。
  • 当进程正阻塞在系统调用(或者库函数)中时,如果一个信号处理函数被调用。此时会出现两种情况:1.信号处理函数返回后,自动重新进入系统调用(库函数调用)。2.系统调用(库函数)失败并且返回EINTR。具体情况请参考Linux man pages。

三、管道

  管道是所有进程间通信手段中最简单的一种。对于进程来说,管道和文件类似。命名管道和管道的区别仅在于创建和打开的方式不同。

  在Linux man pages中,命名管道被称作FIFO。管道和命名管道都提供了单向的进程间通信的通道。管道有一个读取端和一个写入端。数据从写入端写入,从读取端读取。由于管道的特点和创建管道的方法限制,导致管道只能用于具有亲缘关系的进程之间。命名管道(FIFO)和管道非常类似。只不过它在文件系统中有唯一的文件与之对应。读取端使用O_RDONLY标志打开,而写入端则是用O_WRONLY打开。管道和命名管道只能发送无格式的字节流,并且其容纳数据的缓冲区大小是受限制的。

  管道的相关API:

  • pipe():创建并打开匿名管道
  • mkfifo():创建命名管道
  • unlink():销毁命名管道

  管道的读写操作和文件操作基本一致。不过需要注意的是,以阻塞方式打开和读写命名管道时,阻塞方式和普通文件有所区别。具体情况请参考Linux man pages。

四、消息队列

  消息队列就是一个消息的链表。可以把消息看作一个记录,具有特定的格式以及特定的优先级。对消息队列有写权限的进程可以向中按照一定的规则添加新消息;对消息队列有读权限的进程则可以从消息队列中读走消息。消息队列解决了信号只能通知事件,而不能发送信息的问题。同时又克服了管道只能承载无格式字节流以及缓冲区大小受限等缺点。结构化的消息通信机制使通信双方在约定好数据结构之后,无须对消息再次解析。

  Linux既支持POSIX消息队列,也支持System V 消息队列,相关API如下所示:

操作 POSIX 函数 System V 函数
创建消息队列 mq_open() msgget()
发送消息 mq_send()
mq_timedsend()
msgsnd()
接收消息 mq_receive()
mq_timedreceive()
msgrcv()
操作消息队列 mq_getattr()
mq_setattr()
msgctl()
消息队列通知 mq_notify() -


  消息队列具有内核延续性。所谓内核延续性是指:某个资源创建后会随着内核的运行而一直存在,直到这个资源被有意的回收。相似的概念还有进程延续性和文件系统延续性。

  Linux中POSIX消息队列的描述符直接使用了文件描述符,这意味着可以使用select、poll和epoll来操作消息队列。不过这些并不包含在POSIX标准中,所以这一特性不能保证可移植性。

五、共享内存

  采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。不过共享内存往往要配合其他的通信方法使用,来达到进程间的同步及互斥。

  Linux同样也支持POSIX共享内存和System V 共享内存,相关API如下所示:

  • shm_open():(POSIX)创建并打开共享内存
  • ftruncate():(POSIX)设置共享内存大小
  • mmap():(POSIX)映射共享内存至虚拟进程空间
  • munmap():(POSIX)取消共享内存映射
  • shm_unlink():(POSIX)删除共享内存
  • close():(POSIX)关闭共享内存描述符
  • fstat():(POSIX)获取共享内存状态
  • fchown():(POSIX)更改共享内存所属
  • fchmod():(POSIX)更改共享内存访问权限
  • shmget():(System V)创建并打开共享内存
  • shmat():(System V)映射共享内存至虚拟进程空间
  • shmctl():(System V)设置共享内存空间
  • shmdt():(System V)取消共享内存映射

  共享内存具有内核延续性。

六、信号量

  信号量使用一个正整数来表示的,通常用在进程间同步上。对信号量有两种操作:1.给信号量加1;2.给信号量减1。当信号量为0时,减1操作会阻塞,直到信号量不为0。

  在POSIX标准中,信号量分两种,匿名信号量和命名信号量。类似命名管道,命名信号量会以文件系统中的一个文件作为名字。所以命名信号量一般用于进程间同步。匿名信号量顾名思义没有名字,所以一般用于线程间同步。当需要用于进程间同步时需要将匿名信号量放置在共享内存中。

  需要注意的是:和匿名管道不同,匿名信号量不能直接用于父子进程之间。

  Linux支持POSIX信号量和System V 信号量,相关API如下所示:

  • sem_open():(POSIX)创建并打开信号量
  • sem_init():(POSIX)初始化信号量
  • sem_getvalue():(POSIX)获取信号量的值
  • sem_post():(POSIX)post操作
  • sem_wait():(POSIX)wait操作
  • sem_close():(POSIX)关闭信号量
  • sem_destroy():(POSIX)销毁信号量
  • sem_unlink():(POSIX)销毁信号量
  • semget():(System V)创建并打开信号量
  • semctl():(System V)控制信号量
  • semop():(System V)操作信号量

  System V 信号量非常难用,在此就不做介绍了。

  命名信号量具有内核延续性,匿名信号量如果没有放在共享内存中的话具有进程延续性,如果放在共享内存中,我猜测应该会跟随共享内存的生命周期。

七、套接字

  是的,你没有看错,套接字不仅用于网络通信,还可用于进程间通信。非但如此,套接字在设计之初便充分考虑到了进程间通信。套接字最早出现在《RFC 147:The Definition of a Socket》标准中,用于ARPANET(The Advanced Research Projects Agency Network)中。之后便发展出了用于TCP/IP协议的网络套接字(Network sockets)和用于进程间通信的Unix域套接字(Unix Domain sockets)。后来伯克利套接字(Berkeley sockets,也称作BSD sockets)基于前两者的基础上,随着4.2 BSD Unix操作系统发展起来。然而后来伯克利套接字慢慢被POSIX套接字所取代。

  Linux实现了POSIX套接字。创建时选择相应的域和协议,便可以使用套接字进行进程间通信。

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

  分别为domain和type传入AF_UNIX和SOCK_SEQPACKET便可创建一个套接字来支持Unix域套接字。它保证了通信的可靠性和有序性。

八、其他

  我们注意到:有POSIX和System V两套标准都支持信号量、消息队列和共享内存。为什么会这样呢?他们两个有什么区别呢?应该如何选择呢?

  System V实际上指的是Unix System V。这里V是罗马数字五,读作“System Five”。1983年,UNIX System V是第一个发布的商业版的Unix操作系统。随着Unix操作系统的成功,Unix中的各种特性成为操作系统界的事实标准。后来直接使用“System V”来指代此标准。而POSIX标准虽然起源于1988年,但是直到2001年才被IEEE标准委员会所接纳。Linux的初版于1991年发布,所以最早支持了System V标准。不过由于POSIX在很多地方都优秀于System V,所以POSIX标准也逐渐被大部分人接受。

  • 按照Linux man pages的说法,System V是比较古老的接口,有很多设计不合理的地方。POSIX吸取了很多经验,提供了更加友好的接口设计。但是由于POSIX比较年轻,有些平台并没有完整的实现,尤其是比较古老的系统。
  • 按照《UNIX, Third Edition: The Textbook》上的说法,POSIX的进程间通信是线程安全的,而System V的不是。我还未对此作验证。
  • 由于POSIX标准的设计目标就是可移植性,所以POSIX的进程间通信方式更具可移植性。
  • POSIX提供了更多的功能,比如支持select,poll等方法。

注1:Linux支持不可靠信号,但是对不可靠信号机制做了改进:在调用完信号处理函数后,不必重新调用该信号的安装函数(信号安装函数是在可靠机制上的实现)。因此,Linux下的不可靠信号问题主要指的是信号可能丢失。

参考:

  • Linux Man Pages
  • Wikipedia