参考:Linux进程间的通信方式
讲解进程间通信之前,我们可以看一下客户端服务端之间的通信。标准的C/S通信为,服务端在进程1开启一个监听端口(如http为80、8080),收到客户端请求后,fork进程2分配一个空闲socket用于该客户端的通信,原监听端口继续监听。当然,进程2中需要把监听端口close掉(仅引用计数减一)。那么我们可以看到,进程间通过打开的文件操作fd是可以进行信息交换的。

  • 每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。Linux 内核提供了不少进程间通信的机制。

image.png

  • 管道,消息队列,信号量,共享内存,socket套接字
  • PIPE和FIFO用来实现进程间相互发送非常短小的、频率很高的信息;这两种方式通常适用于两个进程间的通信。
  • 共享内存是用来实现进程间共享的、非常庞大的、读写操作频率很高的数据(配合信号量使用);这种方式通常适用于多进程间通信。
  • 其他考虑用socket,在多进程、多线程、多模块所构成的今天最常见的分布式系统开发中,socket是第一选择。

    管道

  • 上面命令行里的「|」竖线就是一个管道,它的功能是将前一个命令(ps auxf)的输出,作为后一个命令(grep mysql)的输入,从这功能描述,可以看出管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行。

    • 同时,我们得知上面这种管道是没有名字,所以「|」表示的管道称为匿名管道,用完了就销毁。
    • 管道还有另外一个类型是命名管道,也被叫做 FIFO,因为数据是先进先出的传输方式。
  • 在使用命名管道前,先需要通过 mkfifo 命令来创建,并且指定管道名字:

    1. >> mkfifo myPipe

    myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,我们可以用 ls 看一下,这个文件的类型是 p,也就是 pipe(管道) 的意思。

    管道的创建

    匿名管道的创建,需要通过下面这个系统调用:

    1. int pipe(int fd[2])

    这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。
    image.png
    其实,所谓的管道,就是内核里面的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。

  • 看到这,你可能会有疑问了,这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?

    • 我们可以使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0] 与 fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。
    • 管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和子进程都可以同时写入,也都可以读出。那么,为了避免这种情况,通常的做法是:
      • 父进程关闭读取的 fd[0],只保留写入的 fd[1];
      • 子进程关闭写入的 fd[1],只保留读取的 fd[0];
    • 所以说如果需要双向通信,则应该创建两个管道。

image.png image.png

  • 到这里,我们仅仅解析了使用管道进行父进程与子进程之间的通信,但是在我们 shell 里面并不是这样的。
    • 在 shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。
    • 所以说,在 shell 里通过「|」匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,那么在我们编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销。

image.png

  • 我们可以得知,对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。
  • 另外,对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。
  • 不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

    消息队列

    前面说到管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。
    对于这个问题,消息队列的通信模式就可以解决。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。

  • 消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。

  • 消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。
  • 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

可以通过调用msgget打开或新建一个消息队列,msgsnd将新消息添加到队列尾端,msgrcv用于从队列中读取消息。与管道不同的是,消息队列不一定按次序,而是可以按消息类型读取消息。
组装的消息类型结构体为:

  1. struct mymesg{
  2. long int mtype; //消息类型
  3. char mtext[512]; //消息数据(若len为0则无数据)
  4. };

可以看到,写进程需要先将数据拷贝至kernel,再由读进程拷贝出来。

信号量

用信号量一般和其他共享资源如共享内存配合使用,解决多进程竞争。通过semget获取信号量ID,使用方法为:
进程需要获取某公共资源时,先测试该资源对应的信号量,若值为正,则该进程使用该资源,并将信号量减1;否则,若值为0,则进入休眠等待信号量为正。当其他进程使用完毕公共资源,对应信号量值增1,休眠进程被唤醒。

共享内存

消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。
现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
image.png

两个进程通过虚拟内存空间映射至同一物理内存进行数据交换。由于数据不需要在进程间复制,这是最快的一种IPC。使用shmget创建共享存储段,成功之后进程可以使用shmat将其映射连接到自己的地址空间中。可以看到共享存储功能类似于mmap,它们的区别是mmap映射的存储段是与文件关联的,即mmap需要传入fd参数,而共享存储不需要。

Socket

前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。
实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。
根据创建 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;

    针对TCP协议通信的socket编程模型

    image.png

  • 服务端和客户端初始化 socket,得到文件描述符;

  • 服务端调用 bind,将绑定在 IP 地址和端口;
  • 服务端调用 listen,进行监听;
  • 服务端调用 accept,等待客户端连接;
  • 客户端调用 connect,向服务器端的地址和端口发起连接请求;
  • 服务端 accept 返回用于传输的 socket 的文件描述符;
  • 客户端调用 write 写入数据;服务端调用 read 读取数据;
  • 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭

这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。

针对UDP协议通信的socket编程模型

image.png
UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。
对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。
另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。

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

  • 本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;
  • 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现;
    • 对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。
    • 对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。

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