进程间通信方式

引言

每个进程都拥有自己的用户地址空间,任何一个进程的全局变量在另一个进程中完全不可见,但是内核空间中每个进程都是共享的,所以进程之间要交换数据必须通过内核空间进行。在内核中开辟一块缓冲区,进程1把数据从用户空间拷贝到内核缓冲区,进程2再从内核缓冲区把数据读取走,内核提供的这种机制称之为进程间通信(ICPC,InterProcess Communication)。

进程间通信机制共有七种,分别是匿名管道、命名管道、消息队列、共享内存、信号量、信号以及Scocket。各有不同的优缺点。下面我们分别来解释一下。

匿名管道

管道的本质实际上就是一个内核缓冲区,进程以先进先出(FIFO)的方式从缓冲区中存取数据。管道一端的进程将数据写入缓冲区中,写入的内容每次都添加在缓冲区的末尾;管道的另一端则从缓冲区中读取数据,每次读取的时候都是从缓冲区的头部读取数据,如下所示:

由此,也可以看出,管道是半双工的,数据只能向一个方向流动。当双方都需要进行通信时,就需要建立起两个管道。

Linux中,管道一个常见的表现形式就是「|」这个竖线了,如常见的操作

ps -ef | grep redis

ps是显示某个进程,grep是查找,中间的「|」就是一个创建管道的命令、这个「|」的功能就是将前一个命令(ps -ef)的输出当做是后一个命令的输入(grep redis)。同时可以看出,这种管道是没有任何名字的,所以「|」这种形式的管道也称之为匿名管道,当这条命令执行结束之后该匿名管道就会被销毁

匿名管道的缺点

  • 半双工,数据只能单向流动,要是想双向流动,必须创建两个管道;
  • 匿名管道只适用于具有父子关系的进程间
  • 没有名字;
  • 效率低下,不适合进程间频繁交换数据
  • 管道的缓冲区大小有限,在管道创建的时候,就会为缓冲区分配一个页面大小;
  • 管道传输的数据都是无格式字节流,这就要求管道的写入端和读取端必须事先约定好数据的格式,便于写入和读取;

匿名管道的创建

Linux中,通过下面这个系统调用就可以创建一个匿名管道:

int pipe(int fd[2])

这里表示创建一个匿名管道,并返回两个文件描述符(PS,在Linux中一切皆文件),一个是管道的读取端描述符fd[0],一个是管道的写入端描述符fd[1]。这个匿名管道在Linux中构成一种特殊的文件系统,只存在于内存当中,不存在于文件系统中。

在调用pipe函数创建匿名管道时,返回的两个文件描述符都是在同一个进程中,并没有起到进程间通信的作用,那么怎么样才能使得管道跨进程通信呢?

我们可以使用fork命令来创建子进程,创建的子进程会复制父进程的文件描述符,这样两个进程分别就各有两个fd[0]fd[1],两个进程就可以通过各自的fd写入和读取同一个管道文件实现跨进程通信了。

但是这样的话会造成混乱,因为父子进程都可以同时写入或者同时读取,但管道只能一端写入,另一端读取。因而,通常情况下会进行一些操作进行修正:

  • 父进程关闭读取的fd[0],只保留写入的fd[1]
  • 子进程关闭写入的fd[1],只保留读取的fd[0]

这里也再次说明了管道半双工特性,要是想双向通信,需要创建两个管道。

shell中的进程通信

上面解释了使用管道进行父子进程间的通信过程,shell中还不一样。对于shell来说,当执行A|B这类命令时,AB进程其实都是shell进程的子进程,AB之间不存在父子关系。此时的通信过程如下所示:

shell中,通过「|」匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,那么在我们编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销

对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。

命名管道

匿名管道,由于没有名字,只能用于父子进程间的通信。为了克服这个缺陷,命名管道(FIFO)应运而生。命名管道与匿名管道之间的区别在于,命令管道提供了一个路径名与之相关联,从而以文件的形式存在于文件系统中。这样,即使与创建命名管道进程不存在父子关系的进程,只要可以访问该路径,就能够通过彼此该命名管道进行通信。这样就实现了不存在父子进程间的通信。**命名管道的名字存在于文件系统当中,而内容存在于内存当中。下面是以Linux为例创建命名管道的流程。

1.使用mkfifo命令来创建命名管道

mkfifo mypipe

2.mypipe就是这个命名管道的名称,在Linux中,一切皆是文件,所以管道也是以文件的形式存在的。这个文件的类型是p,即pipe管道。

3.向管道中写入数据

echo "hello" > mypipe //将数据写入管道
                      // 停住了

在输入完回车之后,你会发现就停在这了,这是因为管道里的内容并没有被读取,只有当管道里的内容被读取走后,命令才可以正常退出。

4.读取数据

cat < mypipe        //读取管道里的数据
hello

在执行完cat命令后,数据显示在终端界面上,同时写命令echo也正常退出了。

综上,对于命名管道,它可以实现不相关的进程间的通信。因为命名管道提前创建了一个类型为管道的设备文件,在进程中只要使用了这个设备文件,就可以相互通信。

匿名管道 vs 命名管道

  • 管道是特殊类型的文件,在满足FIFO的原则下进行读写,但不能进行定位读写
  • 匿名管道是单向的,只适用于具有父子关系的进程间通信;命令管道以磁盘文件的形式存在,可以实现不相关进程间的通信;
  • 匿名管道阻塞问题:匿名管道创建时直接返回文件描述符,在读写时需要确认对方的存在,否则退出。如果当前进程向匿名管道的一端写入数据,必须确认另一端存在另一进程。如果写入的匿名管道数据超过其限制,写操作将会被阻塞;如果匿名管道中没有数据,读操作将会被阻塞;如果匿名管道的某一端退出,将自动退出;
  • 命名管道的阻塞问题:命名管道在打开时需要确认对方的存在,否则将阻塞,即若是以读方式打开某管道,在此之前,必须有一个进程以写方式打开管道,否则阻塞。另外,还可以以读写(O_RDWR)模式打开命名管道,即当前进程读,当前进程写,不会出现阻塞。

消息队列

现有两个进程,AB,使用消息队列进行数据传输时,A进程只需要把数据放到相应的消息队列即可返回;B进程在需要的时候只需要去消息队列中读取相关数据即可。同理,B进程给A进程发送消息也是如此。

消息队列本质上是保存在内核当中的消息链表,在发送数据时,会被分成一个一个独立的数据单元,称之为消息体。消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型。每个消息体都是固定大小的存储块。如果进程从消息队列中读取了消息,则内核会将该消息从消息队列中移除。

与管道的不同

  • 生命周期不同:
    • 管道的生命周期随着进程的创建而建立,随着进程的结束而销毁;
    • 消息队列的生命周期与内核有关,除非是重启内核或者是显示地删除一个消息队列,否则消息队列一直存在;
  • 数据不同:
    • 管道传输的是无格式的字节流;
    • 消息队列传输的是消息体,消息体都是固定大小的存储块;

消息队列缺点

  • 通信不及时;
  • 消息体大小有限制,不适合大数据的传输。在内核中每个消息体都有一个最大长度限制,同时所有队列所包含的消息体总长度也有上限。在Linux内核中,有两个宏定义MSGMAXMSGMNB,以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度;
  • 涉及到用户态和内核态之间的数据拷贝开销。进程向消息队列写入数据时,会发生从用户态拷贝数据到内核态的过程;同理,进程从消息队列读取消息时,会发生内核态拷贝数据到用户态的过程;

共享内存

消息队列的读取过程会发生用户态和内核态之间的消息拷贝过程,使用共享内存这种方式就可以很好地解决这个问题。

共享内存可以使得多个进程可以直接读写在同一块内存空间中,这是效率最高的进程间通信方式。

现代操作系统中,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己的独立虚拟空间。不同进程的虚拟内存映射到不同的物理内存。因而为了在多个进程间交换信息,内核专门开辟了一块内存区域,不同的进程将其映射到自己的私有地址空间。这样A进程写入的数据,另一个B进程立刻就可以看到,避免了数据的拷贝,大大提高了进程间的通信速度。如下所示:

信号量

虽然共享内存大大提高进程间通信的速度,但也带来了新的问题。假如某个时刻多个进程同时对同一个变量进行修改就会产生冲突。为了防止多进程竞争共享资源,需要一些进程间同步机制,使得在某一时刻只有一个进程可以访问共享资源。信号量就是其中之一。

信号量其实就是一个整型的计数器,主要用于实现进程间的互斥和同步,而不是用于缓存进程间的通信数据

信号量表示资源的数量,控制信号量的方式有两种原子操作:

  • P操作:该操作会把信号量减去1。相减后如果信号量<0,表示该资源已被占用,进程需要被阻塞进行等待;相减后如果信号量>=0,表明资源还可被访问和使用,进程正常执行操作即可;
  • V操作:该操作会把信号量加上1。相加后信号量>=0,表明当前有被阻塞着的进程,将该进程唤醒运行;相加后如果信号量>0,表明当前没有被阻塞的进程;

P操作用在进入共享资源之前,V操作用在离开共享资源之后,这两个操作必须成对出现。

信号

在异常情况下,需要使用信号这种方式来通知进程。信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件。

Linux操作系统中,为了响应各种各样的事件,提供了很多种信号,分别代表不同的含义,如常见的SIGINT信号,表示终止该进程;SIGTSTP信号,表示停止该进程,但还未结束。

Linux中有两种方式触发信号,一种是通过键盘组合键(如CTRL+C)的方式,一种是命令(如KILL)的方式。

信号是进程间通信唯一的异步通信机制,因为可以在任何时候发送信号给某一个进程。一旦接收到信号,用户有以下三种处理方式:

  • 执行默认操作;
  • 捕捉信号。可以为信号定义一个信号处理函数。当进程接收信号时,执行相应的信号处理函数;
  • 忽略信号。当接收到信号时,进程可以选择不做处理。但有两个信号无法使用该功能,即SIGKILLSEGSTOP,他们用于在任何时候中断或者结束某一进程。

Socket

Socket不仅可以用于跨网络与不同主机间的主机进行通讯,还可以在同主机上进程间通信。

Linux中创建socket的函数如下:

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

三个入参意义分别如下:

  • domain:指定协议族,AF_INET 用于IPV4AF_INET用于IPV6AF_LOCAL/AF_UNIX用于本机;
  • type:指定通信类别,SOCK_STREAM表示字节流,对应TCPSOCK_DGRAM表示数据报,对应UDPSOCK_RAW表示原始套接字;
  • protocol:指定通信协议的,但一般写作0,因为前两个参数基本确定了。

根据创建 socket 类型的不同,通信的方式也就不同:

  • 实现 TCP 字节流通信:socket 类型是 AF_INET 和 SOCK_STREAM;
  • 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;
  • 实现本地进程间通信:「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;

针对本地进程间的数据通信的socket编程模型:

  • 本地 socket 的编程接口和 IPv4IPv6 套接字编程接口是一致的,可以支持字节流和数据报两种协议;
  • 本地 socket 的实现效率大大高于 IPv4IPv6 的字节流、数据报 socket 实现;

对于本地字节流 socket,其 socket 类型是 AF_LOCAL SOCK_STREAM

对于本地数据报 socket,其 socket 类型是 AF_LOCAL SOCK_DGRAM

本地字节流 socket 和 本地数据报 socketbind 的时候,不像 TCPUDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。

总结

原文地址:https://www.cnblogs.com/reecelin/p/13442983.html