查看原文
其他

Linux 进程间通信之管道(pipe)、命名管道(FIFO)与信号(Signal)

Linux爱好者 2021-01-31

(给Linux爱好者加星标,提升Linux技能

转自:华山大师兄

https://www.cnblogs.com/biyeymyhjob/archive/2012/11/03/2751593.html

管道(pipe)

管道可用于具有亲缘关系进程间的通信,有名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。

实现机制:

管道是由内核管理的一个缓冲区,相当于我们放入内存中的一个纸条。管道的一端连接一个进程的输出。这个进程会向管道中放入信息。


管道的另一端连接一个进程的输入,这个进程取出被放入管道的信息。


一个缓冲区不需要很大,它被设计成为环形的数据结构,以便管道可以被循环利用。


当管道中没有信息的话,从管道中读取的进程会等待,直到另一端的进程放入信息。


当管道被放满信息的时候,尝试放入信息的进程会等待,直到另一端的进程取出信息。当两个进程都终结的时候,管道也自动消失。

从原理上,管道利用fork机制建立,从而让两个进程可以连接到同一个PIPE上。


最开始的时候,上面的两个箭头都连接在同一个进程Process 1上(连接在Process 1上的两个箭头)。


当fork复制进程的时候,会将这两个连接也复制到新的进程(Process 2)。


随后,每个进程关闭自己不需要的一个连接 (两个黑色的箭头被关闭; Process 1关闭从PIPE来的输入连接,Process 2关闭输出到PIPE的连接),这样,剩下的红色连接就构成了如上图的PIPE。

实现细节:

在 Linux 中,管道的实现并没有使用专门的数据结构,而是借助了文件系统的file结构和VFS的索引节点inode。


通过将两个 file 结构指向同一个临时的 VFS 索引节点,而这个 VFS 索引节点又指向一个物理页面而实现的。如下图

有两个 file 数据结构,但它们定义文件操作例程地址是不同的,其中一个是向管道中写入数据的例程地址,而另一个是从管道中读出数据的例程地址。


这样,用户程序的系统调用仍然是通常的文件操作,而内核却利用这种抽象机制实现了管道这一特殊操作。

 

关于管道的读写

管道实现的源代码在fs/pipe.c中,在pipe.c中有很多函数,其中有两个函数比较重要,即管道读函数pipe_read()和管道写函数pipe_wrtie()。


管道写函数通过将字节复制到 VFS 索引节点指向的物理内存而写入数据,而管道读函数则通过复制物理内存中的字节而读出数据。


当然,内核必须利用一定的机制同步对管道的访问,为此,内核使用了锁、等待队列和信号


当写进程向管道中写入时,它利用标准的库函数write(),系统根据库函数传递的文件描述符,可找到该文件的 file 结构


file 结构中指定了用来进行写操作的函数(即写入函数)地址,


于是,内核调用该函数完成写操作。写入函数在向内存中写入数据之前,必须首先检查 VFS 索引节点中的信息,同时满足如下条件时,才能进行实际的内存复制工作:

       ·内存中有足够的空间可容纳所有要写入的数据;

       ·内存没有被读程序锁定。

如果同时满足上述条件,写入函数首先锁定内存,然后从写进程的地址空间中复制数据到内存。


否则,写入进程就休眠在 VFS 索引节点的等待队列中,接下来,内核将调用调度程序,而调度程序会选择其他进程运行。


写入进程实际处于可中断的等待状态,当内存中有足够的空间可以容纳写入数据,或内存被解锁时,读取进程会唤醒写入进程,这时,写入进程将接收到信号。


当数据写入内存之后,内存被解锁,而所有休眠在索引节点的读取进程会被唤醒。


管道的读取过程和写入过程类似。但是,进程可以在没有数据或内存被锁定时立即返回错误信息,而不是阻塞该进程,这依赖于文件或管道的打开模式。


反之,进程可以休眠在索引节点的等待队列中等待写入进程写入数据。当所有的进程完成了管道操作之后,管道的索引节点被丢弃,而共享数据页也被释放。


Linux函数原型

#include <unistd.h>
int pipe(int filedes[2]);

filedes[0]用于读出数据,读取时必须关闭写入端,即close(filedes[1]);


filedes[1]用于写入数据,写入时必须关闭读取端,即close(filedes[0])。

程序实例:

int main(void){ int n; int fd[2]; pid_t pid; char line[MAXLINE];
if(pipe(fd) 0){ /* 先建立管道得到一对文件描述符 */ exit(0); }
if((pid = fork()) 0) /* 父进程把文件描述符复制给子进程 */ exit(1); else if(pid > 0){ /* 父进程写 */ close(fd[0]); /* 关闭读描述符 */ write(fd[1], "\nhello world\n", 14); } else{ /* 子进程读 */ close(fd[1]); /* 关闭写端 */ n = read(fd[0], line, MAXLINE); write(STDOUT_FILENO, line, n); }
exit(0);}


命名管道(named PIPE)

由于基于fork机制,所以管道只能用于父进程和子进程之间,或者拥有相同祖先的两个子进程之间 (有亲缘关系的进程之间)。


为了解决这一问题,Linux提供了FIFO方式连接进程。FIFO又叫做命名管道(named PIPE)。


FIFO (First in, First out)为一种特殊的文件类型,它在文件系统中有对应的路径。


当一个进程以读(r)的方式打开该文件,而另一个进程以写(w)的方式打开该文件,那么内核就会在这两个进程之间建立管道,所以FIFO实际上也由内核管理,不与硬盘打交道。


之所以叫FIFO,是因为管道本质上是一个先进先出的队列数据结构,最早放入的数据被最先读出来,从而保证信息交流的顺序。


FIFO只是借用了文件系统(file system,命名管道是一种特殊类型的文件,因为Linux中所有事物都是文件,它在文件系统中以文件名的形式存在。)来为管道命名。


写模式的进程向FIFO文件中写入,而读模式的进程从FIFO文件中读出。

当删除FIFO文件时,管道连接也随之消失。


FIFO的好处在于我们可以通过文件的路径来识别管道,从而让没有亲缘关系的进程之间建立连接


函数原型:

#include <sys/types.h>#include <sys/stat.h>
int mkfifo(const char *filename, mode_t mode);int mknode(const char *filename, mode_t mode | S_IFIFO, (dev_t) 0 );

其中pathname是被创建的文件名称,mode表示将在该文件上设置的权限位和将被创建的文件类型(在此情况下为S_IFIFO),dev是当创建设备特殊文件时使用的一个值。


因此,对于先进先出文件它的值为0。

 程序实例:

#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h>
int main() { int res = mkfifo("/tmp/my_fifo", 0777); if (res == 0) { printf("FIFO created/n"); } exit(EXIT_SUCCESS); }

编译这个程序:

gcc –o fifo1.c fifo

 运行这个程序:

$ ./fifo1

 用ls命令查看所创建的管道

$ ls -lF /tmp/my_fifoprwxr-xr-x 1 root root 0 05-08 20:10 /tmp/my_fifo|

注意:ls命令的输出结果中的第一个字符为p,表示这是一个管道。

最后的|符号是由ls命令的-F选项添加的,它也表示是这是一个管道。


FIFO读写规则

1.从FIFO中读取数据:约定:如果一个进程为了从FIFO中读取数据而阻塞打开了FIFO,那么称该进程内的读操作为设置了阻塞标志的读操作


2.从FIFO中写入数据:约定:如果一个进程为了向FIFO中写入数据而阻塞打开FIFO,那么称该进程内的写操作为设置了阻塞标志的写操作。


详见:http://blog.csdn.net/MONKEY_D_MENG/article/details/5570468

 

信号(Signal)

信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;


Linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数)

信号种类

每种信号类型都有对应的信号处理程序(也叫信号的操作),就好像每个中断都有一个中断服务例程一样。


大多数信号的默认操作是结束接收信号的进程;

然而,一个进程通常可以请求系统采取某些代替的操作,各种代替操作是:

  • 忽略信号。随着这一选项的设置,进程将忽略信号的出现。

    有两个信号  不可以被忽略:SIGKILL,它将结束进程;

    SIGSTOP,它是作业控制机制的一部分,将挂起作业的执行。

  • 恢复信号的默认操作。

  • 执行一个预先安排的信号处理函数。

    进程可以登记特殊的信号处理函数。

    当进程收到信号时,信号处理函数将像中断服务例程一样被调用,当从该信号处理函数返回时,控制被返回给主程序,并且继续正常执行。

但是,信号和中断有所不同。


中断的响应和处理都发生在内核空间,而信号的响应发生在内核空间,信号处理程序的执行却发生在用户空间。


那么,什么时候检测和响应信号呢?通常发生在两种情况下:

  • 当前进程由于系统调用、中断或异常而进入内核空间以后,从内核空间返回到用户空间前夕;

  • 当前进程在内核中进入睡眠以后刚被唤醒的时候,由于检测到信号的存在而提前返回到用户空间。

函数原型等详见:

http://www.cnblogs.com/biyeymyhjob/archive/2012/08/04/2622265.html


信号本质

信号是在软件层次上对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。


信号是异步的,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。


信号是进程间通信机制中唯一的异步通信机制,可以看作是异步通知,通知接收信号的进程有哪些事情发生了。

信号机制经过POSIX实时扩展后,功能更加强大,除了基本通知功能外,还可以传递附加信息。

信号来源

信号事件的发生有两个来源:

硬件来源(比如我们按下了键盘或者其它硬件故障);

软件来源最常用发送信号的系统函数是kill, raise, alarm和setitimer以及sigqueue函数,软件来源还包括一些非法运算等操作。

 

关于信号处理机制的原理(内核角度)

内核给一个进程发送软中断信号的方法,是在进程所在的进程表项的信号域设置对应于该信号的位。


这里要补充的是,

如果信号发送给一个正在睡眠的进程,那么要 看该进程进入睡眠的优先级,如果进程睡眠在可被中断的优先级上,则唤醒进程;

否则仅设置进程表中信号域相应的位,而不唤醒进程。


这一点比较重要,因为进程检查是否收到信号的时机是:一个进程在即将从内核态返回到用户态时;

或者,在一个进程要进入或离开一个适当的低调度优先级睡眠状态时。   


内核处理一个进程收到的信号的时机是在一个进程从内核态返回用户态时。


所以,当一个进程在内核态下运行时,

软中断信号并不立即起作用,要等到将返回用户态时才处理。


进程只有处理完信号才会返回用户态(上面的例子程序中,在步骤5中,解除阻塞后,先打印caught SIGQUIT,再打印SIGQUIT unblocked,即在sigprocmask返回前,信号处理程序先执行),进程在用户态下不会有未处理完的信号。  


内核处理一个进程收到的软中断信号是在该进程的上下文中,因此,进程必须处于运行状态。


如果进程收到一个要捕捉的信号,那么进程从内核态返回用户态时执行用户定义的函数。


而且执行用户定义的函数的方法很巧妙,内核是在用户栈上创建一个新的层,该层中将返回地址的值设置成用户定义的处理函数的地址,


这样进程从内核返回弹出栈顶时就返回到用户定义的函数处,从函数返回再弹出栈顶时,才返回原先进入内核的地方,接着原来的地方继续运行。


这样做的原因是用户定义的处理函数不能且不允许在内核态下执行(如果用户定义的函数在内核态下运行的话,用户就可以获得任何权限)。


在信号的处理方法中有几点特别要引起注意。  


第一,在一些系统中,当一个进程处理完中断信号返回用户态之前,内核清除用户区中设定的对该信号的处理例程的地址,

即下一次进程对该信号的处理方法又改为默认值,除非在下一次信号到来之前再次使用signal系统调用。


这可能会使得进程在调用signal之前又得 到该信号而导致退出。在BSD中,内核不再清除该地址。


但不清除该地址可能使得进程因为过多过快的得到某个信号而导致堆栈溢出。为了避免出现上述情况。


在 BSD系统中,内核模拟了对硬件中断的处理方法,即在处理某个中断时,阻止接收新的该类中断。   


第二个要引起注意的是,如果要捕捉的信号发生于进程正在一个系统调用中时,并且该进程睡眠在可中断的优先级上(若系统调用未睡眠而是在运行,根据上面的分 析,等该系统调用运行完毕后再处理信号),


这时该信号引起进程作一次longjmp,跳出睡眠状态,返回用户态并执行信号处理例程。


当从信号处理例程返回 时,进程就象从系统调用返回一样,但返回了一个错误如-1,并将errno设置为EINTR,指出该次系统调用曾经被中断。


这要注意的是,BSD系统中内 核可以自动地重新开始系统调用,或者手如上面所述手动设置重启。  


第三个要注意的地方:若进程睡眠在可中断的优先级上,则当它收到一个要忽略的信号时,该进程被唤醒,但不做longjmp,一般是继续睡眠。


但用户感觉不 到进程曾经被唤醒,而是象没有发生过该信号一样。


所以能够使pause、sleep等函数从挂起态返回的信号必须要有信号处理函数,如果没有什么动作,可以将处理函数设为空。   


第四个要注意的地方:内核对子进程终止(SIGCLD)信号的处理方法与其他信号有所区别。


当进程正常或异常终止时,内核都向其父进程发一个SIGCLD 信号,缺省情况下,父进程忽略该信号,就象没有收到该信号似的,


如果父进程希望获得子进程终止的状态,则应该事先用signal函数为SIGCLD信号设 置信号处理程序,在信号处理程序中调用wait。


SIGCLD信号的作用是唤醒一个睡眠在可被中断优先级上的进程。

如果该进程捕捉了这个信号,就象普通信号处理一样转到处理例程。


如果进程忽略该信号,则 什么也不做。


其实wait不一定放在信号处理函数中,但这样的话因为不知道子进程何时终止,在子进程终止前,wait将使父进程挂起休眠。

信号生命周期


- EOF -

推荐阅读  点击标题可跳转

1、聊聊 Linux 的匿名管道

2、介绍 Linux 中的管道和命名管道

3、Linux 信号应用之黑匣子程序设计


看完本文有收获?请分享给更多人

关注「Linux 爱好者」加星标,提升Linux技能

点赞和在看就是最大的支持❤️

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存