探究进程间通信技术,优化数据传输效率

不同进程运行在各自的虚拟地址空间内,相互之间被操作系统隔离开。

就像在《黑客帝国》中,每个人都生活在一个封闭的充满营养液的容器里,人与人之间利用接在后脑勺的数据线通过 Matrix 相互交流一样,在 linux 中进程间的相互通信需经过内核中转。

内核中转的不同实现方式,催生出了进程间不同的通信技术。主要包括

  • 管道 pipe
  • 有名管道 fifo
  • 消息队列 message
  • 信号量 semaphore
  • 共享内存 share memory

管道 pipe

在 fork() 成功创建子进程后,已经打开的文件描述符在父子进程间是共享的,管道就是利用这一特性来工作的。

创建管道的系统调用如下所示:

int pipe(int fds[2]);

它会打开两个文件描述符分别用于读取(fds[0])和写入(fds[1])。这两个文件描述符构成了管道的两端,从一端写入数据,从另一端读出数据。所有数据采用比特流形式,读取顺序与写入顺序完全一致,且数据流向为单向。这也正是把它称为管道的原因,像极了真实世界中的管子,只是其中流动的不是流体,而是二进制的数据比特。创建完成后的管道如下图所示:

pipe

管道创建之后,随着 fork() 的成功,子进程继承了这两个已打开的文件描述符。这是,如果我们在子进程中向 fds[1] 写入一些数据,使用父进程的 fds[0] 便可读取这些数据了,反之亦然。于是,这条管道就可用来在父子进程之间交换数据了。如下图所示:

fork_pipe

单向管道

如果就这样使用管道,每个进程是无法确定读取的数据是自己写入的还是其它进程写入的。更常见的做法是在父进程中关闭一个文件,在子进程中关闭另一个文件,在父子进程间形成一条单向通道。如此操作之后的管道状态如下所示:

single_pipe

需要注意的是,这时管道中的数据的流向是单向的

双向管道

如果要实现父子进程间的双向通信,就需要在两个方向上分别创建管道。

管道的环形缓冲

pipe_环形缓冲

每条管道在内核中都有一块环形缓冲区。在 linux 中,它是一个包含 16 块 pipe_buffer 结构的数组。在每块结构中,page 指针指向一块单独的内存页,并由 offset 和 len 字段指明当前缓冲里待读取数据的位值和长度。

进程向管道写入数据时,内核会在空闲的缓冲区中找一块足够长度的空间。如果当前 pipe_buffer 中的剩余空间不足以存放要写入的全部数据,内核会选择一个新的管道缓冲区,而不会让数据分散在相邻的两个管道缓冲区内。这就保证了不大于页框长度(4KB)的数据写入操作的原子性,也就是说,一次写入操作过程中不会穿插其他进程的写入操作。

因此,如果管道有多个写入进程,每个进程每次写入的数据长度不要超过 4KB,否则,需要在读取进程中实现消息乱序拼接逻辑才能保证万无一失。

如果管道的缓冲区已满,内核会将写入进程挂起,直到管道中的数据被读出并有足够的连续空间存放写入数据为止。如果管道的缓冲区是空的,内核会把试图读取管道数据的进程挂起,直到管道中有任何数据被写入时再唤醒。

FIFO

管道是一种十分简单且灵活的通信机制,但最大的缺点是只能用于父子进程间的通信。为了突破这种限制,Linux 实现了一种称为命名管道的机制,也叫 FIFO(First In,First Out)。

其实现与管道类似,但在创建时需要为其指定文件系统中的一个路径名。该路径名对所有进程可见,任何进程都可以用该路径访问管道,从而将自己设置为管道的读取或写入端,实现进程间的通信。

创建 FIFO 的系统调用如下:

int mkfifo(const char *pathname, mode_t mode);

FIFO 的实现与行为和管道非常相似,更详细的信息请读者自行参考帮助手册。

管道和 FIFO 的局限性

管道和 FIFO 是非常古老的进程间通信方法,在 20 世纪 70 年代的早期就出现了。

  • Shell 中常用的管道操作符(把一个命令的输出作为另一个命令的输入)就是用管道实现的。
  • 常用的 tee 命令,也是利用管道结合文件描述符复制功能实现的。

管道的实现方式很优雅,而且应用灵活,但它自身还有一些固有的限制,比如下面这几项:

  • 管道与 FIFO 中传输的是比特流,没有消息边界的概念,很难实现这样一类需求——有多个读取进程,每个进程每次只从管道中读取特定长度的数据;
  • 管道与 FIFO 中数据读出的顺序与数据写入的顺序严格一致,没有优先级的概念;
  • 管道和 FIFO 使用的都是内核存储空间,允许滞留在管道中的数据容量有限。

消息队列

消息队列在如下两个方面上比管道有所增强:

  • 消息队列中的数据是有边界的,发送端和接收端能以消息为单位进行交流,而不再是无分隔的字节流,这大大降低了某些应用的逻辑复杂度;
  • 每条消息都包括一个整型的类型标识,接收端可以读取特定类型的消息,而不需要严格按消息写入的顺序读取,这样可使消息优先级的实现非常简单,而且每个进程可以非常方便地只读取自己感兴趣的消息

发送和接收消息

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflag);
int msgrcv(int msqid, void *msgp, size_t maxmsgsz, long msgtp, int msgflag);

其中:

  • msqid 是由 msgget() 生成的消息队列 ID;
  • msgp 指向用户定义的消息体,第一个字段需要是 int msgtype,后续的其他字段可以自由定义;
  • msgsz 指定要发送的消息体的长度;
  • msgflag 指定发送动作的行为参数,目前只有一个可选参数 IPC_NOWAIT,表示当内核中消息队列已满时不挂起发送进程,而是立即返回一个 EAGAIN 错误。

消息读取函数中的 msgtp 字段指定了要读取的消息类型,可以有多种消息过滤的方法:

  • 传入正值表示只取指定类型的消息;
  • 传入 0 值表示不区分消息类型,按照先入先出的顺序依次读取;
  • 传入负值表示按照优先级从高到低依次读取消息类型值不大于给定值的绝对值的消息。

信号量

信号量用于协调进程间的运行步调,也叫进程同步。经典的生产者消费者问题,就是典型应用场景之一。另外,封装的二元信号量可以用于保护进程间共享的临界资源,类似于在多线程程序中用互斥量保护全局临界区。

实际上,信号量在线程互斥量之前已经出现了,因为早在多线程出现之前,进程间就已经存在同步运行步调的需求了。信号量通常与共享内存配合使用

信号量的工作逻辑相对比较简单,它有增加、减少和检查三种操作。

创建和操作信号量的函数为:

int semget(key_t key, int nsems, int semflag);
int semctl(int semid, int semnum, int cmd, ...);
int semop(int semid, struct sembuf * sops, unsigned int nsops);

共享内存

共享内存技术是功能最强、应用最广的进程间通信技术。其原理是多个进程共享相同的物理内存区,一个进程对该内存区的任意修改,可被其他进程立即看到。

通过共享内存区,进程之间可交换任意长度的数据,且交换过程无需经过内核转发,在进程的用户空间就可完成,所以数据传输速率非常高。参与通信的进程只是修改或访问了自己的某个特定线性地址的数据而已

底层实现

对操作系统内核来说,要实现不同进程共享相同的物理内存,只需让不同进程的某个线性地址范围映射到相同的物理内存页就可以了。原理如下图所示,图中的物理内存页 4 和 6 就是被进程 A 和 B 共享的内存页:

share_memory

创建和操作共享内存的函数有如下所示。

int shmget(key_t key, size_t size, int shmflg);
void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);
  • shmget() 函数创建或获取一块指定大小(size)的共享内存,key 和 shmflg 的意义与消息队列函数中的 key 和 flag 类似。
  • shmat() 将指定的共享内存附加到进程的线性地址空间内,可以指定起始线性地址(shmaddr),而更常见的做法是让内核决定起始地址(shmaddr == NULL)。函数成功执行后,返回值是该共享内存附加到进程的线性起始地址。这两步操作成功之后,进程就可以像使用其他内存一样使用这块内存区。如果还有其他进程附加了该共享内存,任意进程对内存区域的修改对其他进程都是可见的。基于此种数据交换方式,共享内存通常可与信号量配合使用,实现临界区的一致性保护,除非在其上实现的是某种无锁的数据结构。
  • shmdt() 函数用于将共享内存段从当前进程中分离

总结

Linux 系统实现了丰富的进程间通信机制。有古老的匿名管道和命名管道,也有 System V IPC 系列实现的消息队列、信号量和共享内存机制。

我们把系统想象成一片海洋,把每个进程看成一个个孤立的小岛,每种通信机制在其中又扮演着怎样的角色?我们一起来看下面这些形象的比喻。

  • 管道机制好比在小岛之间铺设的一条条管子。通过这些管子,岛与岛之间可以互相运送物资,但每条管子只能单向运送,接收顺序只能与发送顺序严格一致,只能传送管道容量允许范围内的东西,太大或太重都运不了。
  • 消息队列好比在岛间修建的跨海大桥。双向通车,每个方向都有多个车道,紧急的物资可以通过快车道更快地送达对方。但桥也有限高限重,太大或太重的东西同样运不了。
  • 信号量好比在岛之间建立的无线通信塔。一些重要的信息可以通过信号塔快速传送。传送内容仅限于有限的信息,货物显然运送不了。
  • 共享内存好比可瞬间转移任意物体的黑科技。不管多大多重的东西,都可以实现瞬间送达,能传送物体的体积仅受限于发送和接收方使用的场地。传送时,通常会先给对方打个电话:“我要给你发个东西,你把某场地留给我。”如果有多个发送方同时向一个场地传送东西,有可能会合成一个四不像。

实际上,除了 System V IPC 机制,Linux 还发展了基于 POSIX 标准的消息队列、信号量和共享内存技术,是 System V IPC 机制的升级版。相当于扩宽了跨海大桥的车道,改善了路标指引和工程质量,通信塔采用了更先进的通信标准,瞬间转移设备在稳定性和操作界面上进行了升级,等等。

参考

探究进程间通信技术,优化数据传输效率