【操作系统】进程间通信的五种方式

引言

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

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

人们最常使用的通信手段就是对白。对白的特点就是一方发出声音,另一方接收声音。而声音的传递则通过空气(当面或无线交谈)、线缆(有线电话)进行传递。类似,进程对白就是一个进程发出某种数据信息,另外一方接收数据信息,而这些数据信息通过一片共享的存储空间进行传递。 在这种方式下,一个进程向这片存储空间的一端写入信息,另一个进程从存储空间的另外一端读取信息。这看上去像什么?管道。管道所占的空间既可以是内存,也可以是磁盘。就像两人对白的媒介可以是空气,也可以是线缆一样。要创建一个管道,一个进程只需调用管道创建的系统调用即可。该系统调用所做的事情就是在某种存储介质上划出一片空间,赋给其中一个进程写的权利,另一个进程读的权利即可。

1.管道

从根本上说,管道是一个线性字节数组,类似文件,可以使用文件读写的方式进行访问。但却不是文件。因为通过文件系统看不到管道的存在。另外,我们前面说了,管道可以设在内存里,而文件很少设在内存里(当然,有研究人员在研发基于内存的文件系统,但这个还不是主流)。 创建管道在壳命令行下和在程序里是不同的。壳(shell)命令行下,只需要使用符号“|”即可。例如,在UNIX壳下,我们可以键入如下命令: $sort<file1|grep zou 在两个utility“排序”(sort)和“查找”(grep)之间创建了一个管道,数据从sort流向grep。即sort的结果将作为grep的输入。上述命令的意思是对file1的内容进行排序,排完序的结果作为utility程序grep的输入,在结果里面找出所有包括字符串zou的文本行。 在程序里面,创建管道需要使用系统调用popen()或者pipe()。popen()需要提供一个目标进程作为参数,然后在调用该函数的进程和给出的目标进程之间创建一个管道。这很像人们打电话时必须提供对方的号码,才能创建连接一样。

在这里插入图片描述

在Linux 0.11中,管道操作分为两部分,一部分是创建管道,另一部分是管道的读写操作。
实例1代码如下:

 #include<stdio.h> 
 #include<unistd.h>
  int main() {
   int n,fd[2]; pid _t pid; int i,j; char str1[]="ABCDEABCDEABCDEABCDEABCDEABCDEABCDEABCDEABCDEABCDE ABCDEABCDEABCDEABCDEABCDEABCDEABCDEABCDEABCDE";
char str2[512]; 
if(pipe(fd)<0){//创建管道 
printf("pipe error\n");
 return -1; 
 } 
if((pid=fork())<0){ 
printf("fork error\n"); 
return-1;
 } 
else if(pid>0)//父进程向管道中写入数据
 { 
 close(fd[0]); 
 for(i=0;i<10000;i++) 
 write(fd[1],str1,strlen(str1));
  } 
  else{
  //子进程从管道中读取数据 
  close(fd[1]); 
  for(j=0;j<20000;j++) 
  read(fd[0],str2,strlen(str2));
   } 
   return 0; }  
    

实例1表现了进程间共享数据的情景:父进程把str1中的数据写入管道,子进程从管道中读出数据,其中str1中字符长度为1024字节,即1kb

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

 int pp[2]; 
 pipe(pp);//创建管道 
 if(fork()==0)
 {//子进程 
 read(pp[0]);
 //从父进程读 ……else
   { 
   write(pp[1]);//写给子进程 

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

记名管道

如果要在两个不相关的进程(如两个不同进程里面的进程)之间进行管道通信,则需要使用记名管道。顾名思义,命名管道是一个有名字的通信管道。记名管道与文件系统共享一个名字空间,即我们可以从文件系统中看到记名管道。也就是说,记名管道的名字不能与文件系统里的任何文件名重名。例如,在UNIX下使用ls命令可以查看已经创建的记名管道。
%ls-l fifo1
prw-r——r——1 john users 0 Sep 22 23:11 fifo1|
一个进程创建一个记名管道后,另外一个进程可使用open来打开这个管道(无名管道则不能使用open操作),从而与另外一端进行交流。
记名管道的名称由两部分组成:计算机名和管道名,例如\\[主机名]\管道\[管道名]\。对于同一主机来讲,允许有多个同一命名管道的实例并且可以由不同的进程打开,但是不同的管道都有属于自己的管道缓冲区而且有自己的通信环境,互不影响。命名管道可以支持多个客户端连接一个服务器端。命名管道客户端不但可以与本机上的服务器通信也可以同其他主机上的服务器通信。 管道和记名管道虽然具有简单、无需特殊设计(指应用程序方面)就可以和另外一个进程进行通信的优点,但其缺点也很明显。首先是管道和记名管道并不是所有操作系统都支持。主要支持管道通信方式的是UNIX和类UNIX(如Linux)的操作系统。这样,如果需要在其他操作系统上进行通信,管道机制就多半会力不从心了。其次,管道通信需要在相关的进程间进行(无名管道),或者需要知道按名字来打开(记名管道),而这 在某些时候会十分不便。

2.虫洞:套接字

套接字(socket)是另外一种可以用于进程间通信的机制。套接字首先在BSD操作系统中出现,随后几乎渗透到所有主流操作系统中。套接字的功能非常强大,可以支持不同层面、不同应用、跨网络的通信。使用套接字进行通信需要双方均创建一个套接字,其中一方作为服务器方,另外一方作为客户方。服务器方必须先创建一个服务区套接字,然后在该套接字上进行监听,等待远方的连接请求。欲与服务器通信的客户则创建一个客户套接字,然后向服务区套接字发送连接请求。
服务器套接字在收到连接请求后,将在服务器方机器上创建一个客户套接字,与远方的客户机上的客户套接字形成点到点的通信通道。之后,客户方和服务器方就可以通过send和recv命令在这个创建的套接字通道上进行交流了。 服务区套接字有点类似于传说中的虫洞(worm hole)
虫洞的一端是开放的,它在宇宙内或宇宙间飘移着,另外一端处于一个不同的宇宙,监听是否有任何东西从虫洞来。而欲使用虫洞者需要找到虫洞的开口端(发送连接请求),然后穿越虫洞即可。
使用套接字进行通信稍微有点复杂,我们下面以一个网页浏览的例子对套接字这种通信方式予以说明。对于一个网站来说,要想提供正常的网页浏览服务,其网站服务器需要首先创建一个服务区套接字,作为外界与本服务器的通信信道。为了使该信道为外人所知,我们通常将该服务区套接字与某公共主机的一个众所周知的端口进行绑定,如下:

//创建一个INET的流套接字
serversocket = socket.socket(socket.AF_INET,socket.SOCK_STREAM);
//将套接字与某公共主机的一个众所周知的端口绑定
serversocket.bind(socket.gethostname(),80);

serversocket.listen(5);//将套接字变为一个服务区套接字,语句里的数字5表示端口上的等待队列长度限制为5,超过5个将被拒绝

对于客户方来说,如要访问上述网站,则需要点击该网站的网址。在点击网址后(我们这里假定该网站网址为www.sjtu.edu.cn),客户机上的网络浏览器进行若干步操作,如下

s = socket.socket(socket.AF_INET,socket.SOCK_STREAM);
s.connect("www.sjtu.edu.cn,80);

s.connect命令将向服务器www.sjtu.edu.cn在端口80打开的服务器套接字发送连接请求。而服务器端在接收到该连接请求后,将生成一个新的客户端套接字与该客户端套接字对接,从而建立一个套接字通信信道。如下为网站服务器上运行的主循环。

while(TRUE)
{
(clientsocket,address) = serversocket.accept();
newct  = client_thread(clientsocket);//对clientsocket进行相关操作,如创建一个新进程来处理客户请求
newct.run();
}

至此,套接字通信信道成功创建。客户端程序可以使用套接字s来发送请求、索取网页,而服务器端则使用套接字clientsocket进行发送和接收消息。 这里需要指出的是服务区套接字既不发送数据,也不接收数据(指不接收正常的用户数据,而不是连接请求数据),而仅仅生产出“客户”套接字。当其他(远方)的客户套接字发出一个连接请求时,我们就创建一个客户套接字。一旦创建客户套接字clientsocket,与客户的通信任务就交给了这个刚刚创建的客户套接字。而原本的服务器套接字serversocket则回到其原来的监听操作上。 套接字由于其功能强大而获得了很大发展,并出现了许多种类。不同的操作系统均支持或实现了某种套接字功能。例如按照传输媒介是否为本地,套接字可以分为本地(UNIX域)套接字和网域套接字。而网域套接字又按照其提供的数据传输特性分为几个大类,分别是:
★ 数据流套接字(stream socket):提供双向、有序、可靠、非重复数据通信。
电报流套接字(datagram socket):提供双向消息流。数据不一定按序到达。
序列包套接字(sequential packet):提供双向、有序、可靠连接,包有最大限制。
裸套接字(raw socket):提供对下层通信协议的访问。 套接字从某种程度上来说非常繁杂,各种操作系统对其处理并不完全一样。因此,如要了解某个特定套接字实现,读者需要查阅关于该套接字实现的具体手册或相关文档。

3.信号

管道和套接字虽然提供了丰富的通信语义,并且也得到了广泛应用,但它们也存在某些缺点,并且在某些时候,这两种通信机制会显得很不好用。 首先,如果使用管道和套接字方式来通信,必须事先在通信的进程间建立连接(创建管道或套接字),这需要消耗系统资源。其次,通信是自愿的。即一方虽然可以随意向管道或套接字发送信息,但对方却可以选择接收的时机。即使对方对此充耳不闻,你也奈何不得。再次,由于建立连接消耗时间,一旦建立,我们就想进行尽可能多的通信。而如果通信的信息量微小,如我们只是想通知一个进程某件事情的发生,则用管道和套接字就有点“杀鸡用牛刀”的味道,效率十分低下。 因此,我们需要一种不同的机制来处理如下通信需求:
★ 想迫使一方对我们的通信立即做出回应。
★ 我们不想事先建立任何连接,而是临时突然觉得需要与某个进程通信。
★ 传输的信息量微小,使用管道或套接字不划算。 应付上述需求,我们使用的是信号(signal)。
那么信号是什么呢?在计算机里,信号就是一个内核对象,或者说是一个内核数据结构。发送方将该数据结构的内容填好,并指明该信号的目标进程后,发出特定的软件中断。操作系统接收到特定的中断请求后,知道是有进程要发送信号,于是到特定的内核数据结构里查找信号接收方,并进行通知。接到通知的进程则对信号进行相应处理。 信号非常类似我们生活当中的电报,如果你想给某人发一封电报,就拟好电文,将报文和收报人的信息都交给电报公司。电报公司则将电报发送到收报人所在地的邮局(中断),并通知收报人来取电报。发报时无需收报人事先知道,更无需进行任何协调。如果对方选择不对信号做出响应,则将被操作系统终止运行。
示例代码:
有两个用户进程。一个进程用来接收及处理信号,名字叫做processsig。它所对应的程序源代码如下:

 #include<stdio.h> 
 #include<signal.h> 
 void sig_usr(int signo)
 //处理信号的函数 
 { if(signo==SIGUSR1) 
 printf("received SIGUSR1\n");
 else 
 printf("received%d\n",signo);
  signal(SIGUSR1,sig_usr);
  //重新设置processsig进程的信号处理函数指针,以便下次使用 
  }
int main(int argc,char ** argv)
 { signal(SIGUSR1,sig_usr);
 //挂接processsig进程的信号处理函数指针 
 for(;)
  pause();
   return 0; } 
   // 另一个进程用来发送信号,名字叫做sendsig。它所对应的源代码如下:  
   #include<stdio.h>
    int main(int argc,char ** argv)
     { 
     int pid,ret,signo;
      int i; 
      if(argc!=3{ 
      printf("Usage:sensig<signo><pid>\n";
       return-1;
      } 
      signo=atoi(argv[1]);
       pid=atoi(argv[2]);
        ret=kill(pid,signo);//这里发送信号 
        for(i=0;i<1000000;i++) 
        if(ret!=0) 
        printf("send signal error\n"; 
        return 0;
         }  

操作系统需要具备以下三个功能,以支持信号机制。

  1. 系统要支持进程对信号的发送和接收 系统在每个进程task_struct中设置了用以接收信号的数据成员signal(信号位图),每个进程接收到的信号就“按位”存储在这个数据结构中。系统支持两种方式给进程发送信号:一种方式是一个进程通过调用特定的库函数给另一个进程发送信号;另一种方式是用户通过键盘输入信息产生键盘中断后,中断服务程序给进程发送信号。这两种方式的信号发送原理是相同的,都是通过设置信号位图(signal)上的信号位来实现的。

  2. 系统要能够及时检测到进程接收到的信号 系统通过两种方式来检测进程是否接收到信号:一种方式是在系统调用返回之前检测当前进程是否接收到信号;另一种方式是时钟中断产生后,其中断服务程序执行结束之前,检测当前进程是否接收到信号。

  3. 系统要支持进程对信号进行处理 系统要能够保证,当用户进程不需要处理信号时,信号处理函数完全不参与用户进程的执行;当用户进程需要处理信号时,进程的程序将暂时停止执行,转而去执行信号处理函数,待信号处理函数执行完毕后,进程程序将从“暂停的现场处”继续执行。

4.信号旗语:信号量

信号量(semaphore)是由荷兰人E.W.Dijkstra在20世纪60年代所构思出的一种程序设计构造。其原型来源于铁路的运行:在一条单轨铁路上,任何时候只能有一列列车行驶在上面(如图6-9)。而管理这条铁路的系统就是信号量。任何一列火车必须等到表明铁路可以行驶的信号后才能进入轨道。当一列列车进入单轨运行后,需要将信号改为禁止进入,从而防止别的火车同时进入轨道。而当列车驶出单轨后,则需要将信号变回允许进入状态。这很像以前的旗语, 在计算机里,信号量实际上就是一个简单整数。一个进程在信号变为0或者1的情况下推进,并且将信号变为1或0来防止别的进程推进。当进程完成任务后,则将信号再改变为0或1,从而允许其他进程执行。

5.进程拥抱:共享内存

管道、套接字、信号、信号量,虽然满足了多种通信需要,但还是有一种需要未能满足。这就是两个进程需要共享大量数据。这就像两个人,他们互相喜欢,并想要一起生活时(共享大量数据量),打电话、握手、对白等就显得不够了,这个时候需要的是拥抱,只有将其紧紧拥抱于怀,感觉才最到位,也才能尽可能地共享。 进程的拥抱就是共享内存。共享内存就是两个进程共同拥有同一片内存。对于这片内存中的任何内容,二者均可以访问。要使用共享内存进行通信,一个进程首先需要创建一片内存空间专门作为通信用,而其他进程则将该片内存映射到自己的(虚拟)地址空间。这样,读写自己地址空间中对应共享内存的区域时,就是在和其他进程进行通信。
共享内存有点像管道,有些管道不也是一片共享内存吗?这是形似而神不似。首先,使用共享内存机制通信的两个进程必须在同一台物理机器上;其次,共享内存的访问方式是随机的,而不是只能从一端写,另一端读,因此其灵活性比管道和套接字大很多,能够传递的信息也复杂得多。 共享内存的缺点是管理复杂,且两个进程必须在同一台物理机器上才能使用这种通信方式。共享内存的另外一个缺点是安全性脆弱。因为两个进程存在一片共享的内存,如果一个进程染有病毒,很容易就会传给另外一个进程。就如同人传染病毒一样。
需要注意的是,使用全局变量在同一个进程的进程间实现通信不称为共享内存。

在这里插入图片描述

  • 11
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值