Skip to content

进程通信

为什么要通信

通信是人的基本需求。

进程作为人类的发明,自然免不了脱离人类的习性,也有通信需求。如果进程之间不进行任何通信,那么进程所能完成的任务就要大打折扣。
例如,父进程在创建子进程后,通常需要监督子进程的状态,以便在子进程没有完成给定的任务时,可以再创建一个子进程来继续。这就需要父子进程间通信。

进程之间的交互称为进程间通信(Inter Process Communication, IPC)。那么进程之间的通信是如何进行的呢?
由于进程是人类的创造,我们只要看看人类是如何通信的就知道了。

人类通信的方式无外乎对白(通过声音沟通)、打手势、写信、发电报、拥抱等方法。
类似,进程也可以同样的方式进行通信。下面我们就来看一下进程的这些交互方式。

进程对白:管道、记名管道、套接字

人们最常使用的通信手段就是对白。对白的特点就是一方发出声音,另一方接收声音。而声音的传递则通过空气(当面或无线交谈)、线缆(有线电话)进行传递。
类似,进程对白就是一个进程发出某种数据信息,另外一方接收数据信息,而这些数据信息通过一片共享的存储空间进行传递

在这种方式下,一个进程向这片存储空间的一端写入信息,另一饿进程从存储空间的另外一端读取信息。这看上去像什么?管道。
管道所占的空间既可以是内存,也可以是磁盘。就像两人对白的媒介可以是空气,也可以是线缆一样。
要创建一个管道,一个进程只需调用管道创建的系统调用即可。
该系统调用所做的事情就是在某种存储介质上划出一片空间,赋给其中一个进程写的权利,另一饿进程读的权利即可。

管道

从根本上说,管道是一个线性字节数组,类似文件可以使用文件读写的方式进行访问。但却不是文件。
因为通过文件系统看不到管道的存在。另外,我们前面说了,管道可以设在内存里,而文件很少设在内存里(当然,有研究人员在研发基于内存的文件系统,但这个还不是主流)

创建管道在克命令行下和在程序里是不同的。壳(shell)命令行下,只需要使用符号"|"即可。例如,在 UNIX 壳下,我们可以键入如下命令:

sort < file1 | grep zou

在两个 utility “排序”(sort)和“查找”(grep)之间创建了一个管道,数据从 sort 流向 grep。即 sort 的结果将作为 grep 的输入。
上述命令的意思是对 file1 的内容进行排序,排完序的结果作为 utility 程序 grep 的输入,在结果里找出所有包括字符串 zou 的文本行。

在程序里面,创建管道需要使用系统调用 popen() 或者 pipe()。
popen() 需要提供一个目标进程作为参数,然后在调用该函数的进程和给出的目标进程之间创建一个管道。
这很像人们打电话时必须提供对方的号码,才能创建连接一样。

创建时还需要提供一个参数表明管道类型:读管道或者写管道。而 pipe() 调用将返回两个文件描述符(文件描述符是用来识别一个文件流的一个整数,与句柄不同),其中一个用于从管道读操作,一个用于写入管道。
也就是说,pipe() 将两个文件描述符连接起来,使得一端可以读,另一端可以写。
通常情况下,在使用 pipe() 调用创建管道后,再使用 fork 产生两个进程,这两个进程使用 pipe() 返回的两个文件描述符进行通信。

管道的一个重要特点是使用管道的两个进程之间必须存在某种关系。
例如,使用 popen 需要提供另一端进程的文件名,使用 pipe() 两个进程则分别隶属于父子进程

记名管道

如果要在两个不相关的进程(如两个不同进程里面的进程)之间进行管道通信,则需要使用记名管道。顾名思义,命名管道是一个有名字的通信管道。
记名管道与文件系统共享一个名字空间,即我们可以从文件系统中看到记名管道。也就是说,记名管道的名字不能与文件系统里的任何文件重名。

一个进程创建一个记名管道后,另外一个进程可使用 open 来打开这个管道(无名管道则不能使用 open 操作),从而与另外一端进行交流。

记名管道的名称由两部分组成:计算机名和管道名。
对于同一个主机来讲,允许有多个同一命名管道的实例并且可以由不同的进程打开,但是不同的管道都有属于自己的管道缓冲区而且有自己的通信环境,互不影响。
命名管道可以支持多个客户端连接一个服务器端。
命名管道客户端不但可以与本机上的服务器通信也可以同其他主机上的服务器通信。

管道和记名管道虽然具有简单、无需特殊设计(指应用程序方面)就可以和另外一个进程进行通信的优点,但其缺点也很明显。
首先是管道和记名管道并不是所有操作系统都支持。主要支持管道通信方式的是 UNIX 和类 UNIX(如 Linux)的操作系统。
这样,如果需要在其他操作系统上进行通信,管道机制就多半会力不从心了。
其次,管道通信需要在相关的进程间进行(无名管道),或者需要知道按名字来打开(记名管道),而这在某些时候会十分不便。

虫洞:套接字

套接字(socket)是另外一个可以用于进程间通信的机制。
套接字首先在 BSD 操作系统中出现,随后几乎渗透到所有主流操作系统中。
套接字的功能非常强大,可以支持不同层面、不同应用、跨网络的通信。
使用套接字进行通信需要双方均创建一个套接字,其中一方作为服务器方,另外一方作为客户方。
服务器方必须先创建一个服务区套接字,然后在该套接字上进行监听,等待远方的连接请求。
欲与服务器通信的客户则创建一个客户套接字,然后向服务器套接字发送连接请求。
服务器套接字在收到连接请求后,将在服务器方机器上创建一个客户套接字,与远方的客户机上的客户套接字形成点到点的通信通道。
之后,客户方和服务器方就可以通过 send 和 recv 命令在这个创建的套接字通道上进行交流了。

服务区套接字有点类似于传说中的虫洞(worm hole)。
虫洞的一端是开放的,它在宇宙内或宇宙间漂移着,另外一端处于一个不同的宇宙,监听是否有任何东西从虫洞来。
而欲使用虫洞者需要找到虫洞的开头端(发送连接请求),然后穿越虫洞即可

进程电报:信号

管道和套接字虽然提供了丰富的通信语义,并且也得到了广泛应用,但它们也存在某些缺点,并且在某些时候,这两种通信机制会显得很不好用。

首先,如果使用管道和套接字方式来通信,必须事先在通信的进程间建立连接(创建管道或套接字),这需要消耗系统资源。其次,通信是自愿的。
即一方虽然可以随意向管道或套接字发送信息,但对方却可以选择接收的时机。即使对方对此充耳不闻,你也奈何不得。
再次,由于建立连接消耗时间,一旦建立,我们就想进行尽可能多的通信。
而如果通信的信息量微小,如我们只是想通知一个进程某件事情的发生,则用管道和套接字就有点“杀鸡用牛刀”的味道,效率十分低下。

因此,我们需要一种不同的机制来处理如下通信需求。

  • 想迫使一方对我们的通信立即做出回应
  • 我们不想事先建立任何连接,而是临时突然觉得需要与某个进程通信。
  • 传输的信息量微小,使用管道或套接字不划算

应付上述需求,我们使用的是信号(signal)

那么信号是什么呢?在计算机里,信号就是一个内核对象,或者说是一个内核数据结构。
发送方将该数据结构的内容填好,并指明该信号目标进程后,发出特定的软件中断。
操作系统接收到特定的中断请求后,知道是有进程要发送信号,于是到特定的内核数据结构里查找信号接收方,并进行通知。
接到通知的进程则对信号进行相应处理。

信号非常类似我们生活当中的电报。
如果你想给某人发一封电报,就拟好电文,将报文和收报人的信息都交给电报公司。
电报公司则将电报发送到收报人所在地的有句(中断),并通知收件人来取电报。发报时无需收报人事先知道,更无需进行任何协调。
如果对方选择不对信号做出响应,则将被操作系统终止运行。

进程旗语:信号量

信号量(semaphore)是荷兰人 E.W.Dijkstra 在 20 世纪 60 年代所构思出的一种程序设计构造。
其原型来源于铁路的运行:在一条单轨铁路上,任何时候只能有一列列车行驶在上面。而管理这条铁路的系统就是信号量。
任何一列火车必须等到表明铁路可以行驶的信号后才能进入轨道。
当一列列车进入单轨运行后,需要将信号改为禁止进入,从而防止别的火车同时进入轨道。
而打给你列车驶出单轨后,则需要将信号变回允许进入状态。这很像以前的旗语。

信号量

在计算机里,信号量实际上就是一个简单整数。
一个进程在信号变为 0 或者 1 的情况下推进,并且将信号变为 1 或 0 来防止别的进程推进。
当进程完成任务后,则将信号再改变为 0 或 1,从而允许其他进程执行。

需要注意的是,信号量不只是一种通信机制,更是一种同步机制。

进程拥抱:共享内存

管道、套接字、信号、信号量,虽然满足了多种通信需要,但还是有一种需要未能满足。这就是两个进程需要共享大量数据。
这就像两个人,他们互相喜欢,并想要一起生活时(共享大量数据量),打电话、握手、对白等就显得不够了,这个时候需要的是拥抱,只有将其紧紧拥抱于怀,感觉才最到位,也才能尽可能地共享

进程的拥抱就是共享内存。共享内存就是两个进程共同拥有同一片内存。
对于这片内存中的任何内容,二者均可以访问。
要使用共享内存进行通信,一个进程首先需要创建一片内存空间专门作为通信用,而其他进程则将该片内存映射到自己的(虚拟)地址空间。
这样,读写自己地址空间中对应共享内存的区域时,就是在和其他进程进行通信。

共享内存

乍一看,共享内存有点像管道,有些管道不也是一片共享内存吗?这是形似而神不似。
首先,使用共享内存机制通信的两个进程必须在同一台物理机器上;
其次,共享内存的访问方式是随机的,而不是只能从一端写,另一端读,因此其灵活性比管道和套接字大很多,能够传递的信息也复杂得多。

共享内存的缺点是管理复杂,且两个进程必须在同一台物理机器上才能使用这种通信方式。
共享内存的另外一个缺点是安全性脆弱。因为两个进程存在一片共享的内存,如果一个进程染有病毒,很容易就会传给另外一个进程。
就像两个紧密接触的人,一个人的病毒是很容易传染另外一个人的。

这里需要注意的是,使用全局变量在同一个进程的进程间实现通信不称为共享内存。

信件发送:消息队列

消息队列是一列具有头和尾的消息队列。新来的消息放在队列尾部,而读取消息则从队列头部开始。

消息队列

乍一看,这不是管道吗?一头读、一头写?没错。这的确看上去像管道,但它不是管道。
首先,它无需固定的读写进程,任何进程都可以读写(当然是有权限的进程)。其次,它可以同时支持多个进程,多个进程可以读写消息队列。
即所谓的多对多,而不是管道的点对点。另外,消息队列只在内存中实现。
最后,它并不是只在 UNIX 和类 UNIX 操作系统中实现。几乎所有主流操作系统都支持消息队列。

其他通信机制

有些操作系统还提供了一些其所特有的通信机制,例如 Windows 支持的进程通信方式就有所谓的剪贴板(clipboard)、COM/DCOM、动态数据交换(DDE)、邮箱(mailslots);
而 Solaris 则有所谓的 Solaris 门机制,让客户通过轻量级(16KB)系统调用使用服务器的服务。

虽然进程之间的通信机制繁多,且每种机制都有着自己的特性,但归根结底都来源于 AT & T 的 UNIX V 系统。
该系统在 1983 年加入了对共享内存、信号量和消息队列的支持。
而这三者就是众所周知的 System V IPC(POSIX IPC 也是源于该系统并称为当前 IPC 的标准)。
因此,虽然不同操作系统的 IPC 机制可能不尽相同,但其基本原理则并无大的区别。