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

内核中转的不同实现方式,催生出了进程间不同的通信技术。在本文中,我们就来探究进程间通信的不同方式及底层实现原理,主要包括以下几方面内容:
管道与 FIFO
消息队列
信号量
共享内存
1.1 管道与 FIFO
1.1.1管道
fork() 成功创建子进程之后,已经打开的文件描述符在父子进程间是共享的,管道就是利用这一特性来工作的。
创建管道的系统调用如下所示:

int pipe(int fds[2]);

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

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

  • 单向管道

如果就这样使用管道,每个进程是无法确定读取的数据是自己写入的还是其他进程写入的。更常见的做法是在父进程中关闭一个文件,在子进程中关闭另一个文件,在父子进程间形成一条单向通道。如此操作之后的管道状态如下所示:
探究进程间通信技术,优化数据传输效率
需要注意的是,这时管道中数据的流向是单向的

总结:管道是一种十分简单且灵活的通信机制,但最大的缺点是只能用于父子进程间的通信。
1.1.2 FIFO
为了管道只能在父子进程间的通讯这种限制,Linux 实现了一种称为命名管道的机制,也叫 FIFO(First In,First Out)。其实现与管道类似,但在创建时需要为其指定文件系统中的一个路径名。该路径名对所有进程可见,任何进程都可以用该路径访问管道,从而将自己设置为管道的读取或写入端,实现进程间的通信。
创建 FIFO 的系统调用如下:

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

FIFO 的实现与行为和管道非常相似。
1.1.3 管道和 FIFO 的局限性
管道和 FIFO 是非常古老的进程间通信方法,在 20 世纪 70 年代的早期就出现了。

Shell 中常用的管道操作符(把一个命令的输出作为另一个命令的输入)就是用管道实现的。
常用的 tee 命令,也是利用管道结合文件描述符复制功能实现的。
管道的实现方式很优雅,而且应用灵活,但它自身还有一些固有的限制,比如下面这几项:

  • 管道与 FIFO 中传输的是比特流,没有消息边界的概念,很难实现这样一类需求——有多个读取进程,每个进程每次只从管道中读取特定长度的数据;

  • 管道与 FIFO 中数据读出的顺序与数据写入的顺序严格一致,没有优先级的概念;

  • 管道和 FIFO 使用的都是内核存储空间,允许滞留在管道中的数据容量有限。
    1.2 消息队列
    消息队列在如下两个方面上比管道有所增强:

  • 消息队列中的数据是有边界的,发送端和接收端能以消息为单位进行交流,而不再是无分隔的字节流,这大大降低了某些应用的逻辑复杂度;

  • 每条消息都包括一个整形的类型标识,接收端可以读取特定类型的消息,而不需要严格按消息写入的顺序读取,这样可使消息优先级的实现非常简单,而且每个进程可以非常方便地只读取自己感兴趣的消息。
    1.2.1 创建消息队列

int msgget(key_t key,int flag);

数中的 key 参数为消息队列的标识符。

可以是通信双方约定好的整数标识,就像网络应用中约定服务器使用的端口号一样。
也可以使用 ftok() 函数根据约定的文件名生成的整数值。
还可以指定为 IPC_PRIVATE,让内核选定一个未被占用的整数作为标识符,但这种用法需要通过另外的机制通知对方所选定的 key。
总之,要让通信双方都知道选定的 key 值,而且该 key 值与系统中其他进程没有冲突就可以了。

**flag 参数用来指定获取消息队列的控制标记。**组合使用 IPC_CREAT 和 IPC_EXCL 可以实现新建、检查重复的功能。

如果消息队列创建成功,则返回一个正整数作为消息队列的 ID,后期发送和接收消息都通过这个 ID 进行。
1.2.2 发送和接受消息
发送和接收消息的函数分别为:
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 值表示不区分消息类型,按照先入先出的顺序依次读取;

  • 传入负值表示按照优先级从高到低依次读取消息类型值不大于给定值的绝对值的消息。
    1.2.3 内核结构
    消息队列在内核中的结构如下图所示:探究进程间通信技术,优化数据传输效率- 蓝色部分为消息队列的队列头结构 struct msg_queue,其中比较重要的字段有队列中的数据总字节数消息数量字段,以及 struct list_head 结构的消息体节点 q_messages

  • 橘色部分是存放消息内容的节点。其中的 m_list 也是 struct list_head 结构,它与队列头节点中的 q_messages 互相连接,把所有待读取的消息组织成一个双向队列。每个消息内容节点会占用一个内存页框(默认 4KB),当单条消息在当前页框中放不下时,会利用消息内容节点中 next 指针指向的一块附加内存分页继续存储。因为,在利用消息队列实现进程间通信,且通信比较频繁时,应合理控制单个消息体的长度,避免消息分页。在 Linux 中,这个长度为 4072 字节。

系统对可以创建的消息队列数量、单条消息的最大长度,以及队列中总的消息内容长度做了限制,可分别通过内核参数 /proc/sys/kernel/msgmni、/proc/sys/kernel/msgmax 和 /proc/sys/kernel/msgmnb 进行调整。
1.3 信号量
信号量用于协调进程间的运行步调,也叫进程同步。经典的生产者消费者问题,就是典型应用场景之一。另外,封装的二元信号量可以用于保护进程间共享的临界资源,类似于在多线程程序中用互斥量保护全局临界区。

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

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

进行减少操作时,如果当前信号量过少,即减少指定值后小于 0,执行减操作的进程将被挂起,直到有进程执行加操作且所加的值足够减时,挂起的进程才会被唤醒。

检查操作用来检查当前信号量的值是否为 0,不是 0,该进程将被挂起,直到该信号量被其他进程修改为 0 时再被唤醒。

另外,进程可同时操作多个信号量。这种情况下,对组内所有信号量的操作都是原子性的,也就是说,要么同时完成组内所有信号量的指定操作,要么挂起进程,不执行组内任何一个信号量的操作。

信号量的创建和初始化分两个步骤进行,当多个平行进程有可能同时运行时,需要特别注意可能出现的竞争条件。
1.3.1创建和操作
创建和操作信号量的函数为:
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);

  • semget 用于创建一组信号量,nsems 指定创建的信号量的数量,key 和 semflag 分别是信号量的整数标识和控制标志,取值和意义与消息队列中的 key 和 flag 参数一样;
  • semctl() 用于控制信号量,包括初始化、删除、状态查询等;
  • semop() 用于对信号量执行一组操作,sops 参数提供了所需执行的操作组,是指向 sembuf 结构数组的一个指针,nsops 指定了该数组的元素个数,sembuf 结构定义如下:
    struct sembuf {
    unsigned short sem_num;
    short sem_op;
    short sem_flag;
    };
    其中,sem_num 指示要操作的信号量在组内的位置坐标;sem_op 指定要执行的操作,大于 0 标识执行增加操作,小于 0 标识则执行减少操作,等于 0 标识便检查信号量是否为 0。

sem_flag 有两个取值,一个为 IPC_NOWAIT,表示当该信号量不能完成操作时,semop 会立即返回 EAGAIN 错误,而不是挂起进程,且同组内所有操作都不执行;另一个为 SEM_UNDO,表示需要内核记录该信号量操作的效果,当进程终止(不管有意退出还是意外终止)时,要求内核撤销该操作,以防止进程意外终止时其他进程因正在等待一个信号量而被永久阻塞。
13.2 内核结构
信号量在内核中的结构如下:
探究进程间通信技术,优化数据传输效率
在信号量结构中,sem_array 下的 sem_base 字段指向一个 sem 结构的数组,数组中包含了指定数量的信号量值(semval)以及最近一次访问该信号量的进程 ID(sempid)。

当前未完成的信号量操作均存放在 sem_pending 指向的 sem_queue 结构的双向队列中,下面还有一个辅助字段 sem_pending_last,用来指向该队列的最后一个元素,以方便新加入的操作被追加到队列的队尾。

如果信号量操作时指定了 IPC_UNDO 标志,该操作会被记录到 sem_undo 结构的链表中,链表的每个元素记录了某个进程对信号量操作的累加结果,用于在进程终止时撤销对特定信号量的操作。当前挂起的操作如果指定了 IPC_UNDO 标志,在其结构中也会有 undo 字段指向相应的 sem_undo 结构在队列中的位置。

在 Linux 系统中,信号量的各种限制定义在 /proc/sys/kernel/sem 文件中,它包含四个用空格分隔的数字,这四个数字代表的意义请看如下说明。

SEMMSL:一组信号量所能包含的最大数量的信号量,常见默认值是 250。
SEMMNS:系统所能包含的最大数量的信号量,常见默认值是 32000。
SEMOPM:每个 semop 系统调用能执行的操作的最大数量,常见默认值是 32。
SEMMNI:能创建的信号量组的数量,常见默认值是 128。

1.4 共享内存
共享内存技术是功能最强、应用最广的进程间通信技术。其原理是多个进程共享相同的物理内存区,一个进程对该内存区的任意修改,可被其他进程立即看到。
通过共享内存区,进程之间可交换任意长度的数据,且交换过程无需经过内核转发,在进程的用户空间就可完成,所以数据传输速率非常高。参与通信的进程只是修改或访问了自己的某个特定线性地址的数据而已。

1.4.1 底层实现
进程访问的地址是自己进程空间内的线性地址,内核负责把线性地址映射为实际的物理地址。操作系统以内存页为单位管理物理内存。在 Linux 中,默认的内存分页大小是 4KB,也就是说,操作系统把物理内存分割成一个个大小为 4KB 的格子,进而管理它们,内存的换入换出也以这样的格子为基本单位。在每个进程的内核数据结构中,都会维护一个内存页表,记录线性地址到物理内存页的映射关系。

对操作系统内核来说,要实现不同进程共享相同的物理内存,只需让不同进程的某个线性地址范围映射到相同的物理内存页就可以了。原理如下图所示,图中的物理内存页 4 和 6 就是被进程 A 和 B 共享的内存页:
探究进程间通信技术,优化数据传输效率
1.4.2 创建和操作
创建和操作共享内存的函数有如下所示。

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() 函数用于将共享内存段从当前进程中分离。
    如果附加共享内存时让操作系统决定起始地址,进程多次运行时选择的起始地址将不固定。若要存储指向共享内存区内某数据对象的地址,应使用偏移量形式,而不能直接存储绝对地址。

另外,共享内存的生存周期与进程内存不同,共享内存会在进程退出之后仍被系统保留。因此,如果共享内存中有指向进程内存的指针,应该在进程重启时重置。

共享内存的各种限制同样可以通过内核参数设置,如下所示。

/proc/sys/kernel/shmmni:可以创建的共享内存块的数量。
/proc/sys/kernel/shmmax:共享内存段的最大容量,实际上限同时依赖物理内存和交换空间的大小。
/proc/sys/kernel/shmall:系统中所有共享内存的分页总数上限,同时受限于物理内存和交换空间的大小。
另外,附加共享内存实际上只是把内存页表指向特定的物理内存页,在使用 fork() 创建子进程之后,这些数据也会被复制一份,所以,子进程会继承父进程附加的共享内存段。而当 exec() 成功执行后,共享内存段会在新进程中被分离。

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

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

管道机制好比在小岛之间铺设的一条条管子。通过这些管子,岛与岛之间可以互相运送物资,但每条管子只能单向运送,接收顺序只能与发送顺序严格一致,只能传送管道容量允许范围内的东西,太大或太重都运不了。

消息队列好比在岛间修建的跨海大桥。双向通车,每个方向都有多个车道,紧急的物资可以通过快车道更快地送达对方。但桥也有限高限重,太大或太重的东西同样运不了。

信号量好比在岛之间建立的无线通信塔。一些重要的信息可以通过信号塔快速传送。传送内容仅限于有限的信息,货物显然运送不了。

共享内存好比可瞬间转移任意物体的黑科技。不管多大多重的东西,都可以实现瞬间送达,能传送物体的体积仅受限于发送和接收方使用的场地。传送时,通常会先给对方打个电话:“我要给你发个东西,你把某场地留给我。”如果有多个发送方同时向一个场地传送东西,有可能会合成一个四不像。

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