预留了8字节进行自定义,用于在实现上,在构建编程接口时使用。举个例子:想要构建类 posix socket 语义的连接,此处称为虚拟连接,它不占用任何 OS 资源。 那么可以定义一个4字节的 ID 用于标识不同虚拟连接上的数据, 另外4个字节用于描述虚拟连接的状态(打开或关闭等)。那么ID=1,offset = 1024 ,status=1, 可以描述为 ID 为1的虚拟连接,在共享内存偏移1024的位置写入了若干数据,当前虚拟连接为打开状态(status=1)。 ID=1,offset=0,status=0,可以描述为关闭 ID 为1的虚拟连接,不再写入数据。
Shmipc规范
English | 中文
一、概述
共享内存是 Linux 下最快的进程间通讯(IPC)方式,但其没有定义 IPC 的进程如何进行同步,以及对共享内存如何进行管理等。开源社区关于这方面的资料不多,本文从性能的角度出发,定义了基于共享内存 IPC 的进程同步机制、共享内存管理机制、错误回退机制等,形成一套生产环境下可用的高性能 IPC 方案。
二、通讯模型
2.1 定义
如上图所示,Client 进程和 Server 进程第一次通讯时需要建立 TCP 连接或是 Unix Domain Socket 连接,然后通过该连接完成协议初始化。初始化涉及共享内存的初始化和映射等过程,初始化完成之后可以通过共享内存进行全双工通讯。
2.2 多进程
Client 进程和 Server 进程是 N : N 的关系。一个 Client 进程可以和多个 Server 进程进行连接。一个 Server 进程也可以和多个 Client 进程进行连接。
2.3 单进程-多线程
Client 进程和 Server 进程的多个线程也可以是 N : N 的关系。
三、通信协议
通讯协议定义了 TCP 连接或 Unix Domain Socket 连接所传递的消息格式。
3.1 消息格式
Client 进程和 Server 进程在连接上所传递的一条消息定义如下:
3.2 消息类型
消息payload
u16str 编码格式: string length 2 Byte | string body
当前定义的 feature list:
四、协议初始化
协议初始化主要完成 Client 和 Server 的连接,然后通过该连接映射共享内存,包含三个部分。
localhost:$PORT或 Unix Domain Socket五、共享内存管理
共享内存由 Client 进程创建并初始化,通过连接 Server 进程完成共享。其后的管理涉及三个问题:
本章将描述了一种简单高效的机制,以 O(1) 的算法复杂度,完成跨进程共享内存的分配、回收、清理。核心思想是:将连续的共享内存区域划分为多个切片,以链表的形式组织切片。从链头分配,回收至链尾。
5.1 数据结构
BufferManager
BufferManager是一片连续的共享内存区域,BufferManager 包含一个 Header 和若干个 BufferList。每个 BufferList 包含若干个连续的固定长度的 buffer。
BufferList
BufferSlice
通过映射一片连续的共享内存区域,可以得到 BufferManager、BufferList、BufferSlice 这三个数据结构。通过 BufferList 我们很容易完成跨进程的共享内存的分配和回收。
5.2 分配和回收
从一个例子出发,了解 BufferSlice 的分配和回收流程。
设:BufferList 中有3个 slice,每个 slice 的容量为1 KB。
分配前
BufferListHeader 中,head 指向链头 BufferSlice 1, tail 指向链尾 BufferSlice 3,size 为3。
分配后
此时 BufferListHeader 中,head 指向链头 BufferSlice 2, tail 指向链尾 BufferSlice 3,size 变为2。BufferSlice 1已经被分配出去,不再指向 BufferSlice2。
回收后
此时 BufferListHeader 中,head 指向链头 BufferSlice 2, tail 指向链尾 BufferSlice 1,size变为3。BufferSlice 3指向BufferSlice 1,bufferSlice 1被回收。
关于竞态:
head进行 CAS 操作避免并发冲突。tail进行 CAS 操作避免并发冲突。size时,需要使用编程语言提供的 atomic API 确保原子性。算法复杂度
假设 BufferSlice 的数量为 N,分配和回收操作本身的算法复杂度为 O(1),即使存在并发冲突,产生冲突的概率也非常低。每产生一次并发冲突需要进行一次 CAS 操作,一次分配或回收操作所进行 CAS 操作的最大次数(冲突次数)约等于 BufferSlice 的数量即O(N)。
5.3 清理
进程退出后共享内存的清理和最初映射共享内存的方式有关。
六、进程同步
Client 进程和 Server 进程需要存在某种同步机制,使得当一方写入数据时,另一方能够收到通知并进行处理。本章会描述一种高效的同步进制,面对高并发请求时,能够降低延迟同时有更好的性能。面对低频请求,不会引入增量延迟。
6.1 通知
使用 Client 与 Server 建立起的连接,发送章节3.2定义的 SyncEvent 来进行进程间的通知。当发送方将数据写入共享内存后,通过 SyncEvent 通知对端进行处理。
6.2 IO 队列
IO 队列是一个映射在共享内存中的数据结构,用于描述 IPC 的数据在共享内存中的位置等元信息,便于对端读取。
QueueHeader 用于描述和维护 IO 队列。
Event 用于描述IPC通讯的数据。
连接,此处称为虚拟连接,它不占用任何 OS 资源。 那么可以定义一个4字节的 ID 用于标识不同虚拟连接上的数据, 另外4个字节用于描述虚拟连接的状态(打开或关闭等)。那么ID=1,offset = 1024 ,status=1, 可以描述为 ID 为1的虚拟连接,在共享内存偏移1024的位置写入了若干数据,当前虚拟连接为打开状态(status=1)。 ID=1,offset=0,status=0,可以描述为关闭 ID 为1的虚拟连接,不再写入数据。6.3 一次通讯
6.1节和6.2节已描述如何读取共享内存中的数据以及进程间的事件通知。本节描述一次通讯的过程。
Server 给 Client 回复数据也类似上述过程,不同点仅在于 IO Queue 是隔离的。对于 Client 而言 SendQueue 相当于 Server 的 ReceiveQueue,Server 的 SendQueue 相当于 Client 的 ReceiveQueue。
6.4 批量收割 IO
考虑在线并发场景,Client 会同时发起多个请求(写入多份数据),每个请求相当于一个 IO Event。第一个请求会设置 workingFlag = 1 并发送 SyncEvent。那么在 Server 处理完 IO Queue 之前,Client 后续发送的所有请求,可以通过 workingFlag = 1 知晓 Server 正在处理 IOEvent,故都不需要再发送 SyncEvent 通知 Server。
以此实现一次 SyncEvent,批量收割 IO。
七、错误回退
本章描述如何处理共享内存不足时的场景。
7.1 共享内存耗尽
当共享内存已耗尽时,新的数据不再通过共享内存通讯。借助 Client 和 Server 已建立起的连接,
TCP loopback或unix Domain Socket。并且由于单连接的 socket buffer 有限,也能够起到反压的作用,缓解 Server 压力。7.2 IO 队列满载
IO 队列满载说明 Server 的压力山大,此时 Client 阻塞直至 IO 队列有空闲位置。相当于反压,类似于 unix socket buffer 填满时,写入操作要么失败(EAGAIN),要么阻塞。不宜引入过多操作增加复杂性。
八、热升级
热升级是指对 Server 进程代码更新升级时不会影响 Client 写入数据。热升级的技术选型非常多,考虑到 IPC 以 lib 形式提供服务,已嵌入至应用进程中,那么可以选择简单的应用层热升级。
九、一些设计考量
关于进程同步机制的选择
Linux下,生产环境常用的高性能的进程同步机制有
TCP loopback、Unix domain socket、event fd等。event fdbenchmark 的性能会略好,但 跨进程传递 fd 会引入过多复杂性 ,其带来的性能提升在 IPC 上并不明显。基于此,复用 Client 与 Server 建立起的连接会降低复杂性,对一些没有 event fd 的系统也能使用。此外,该方案适用于在线场景,对实时性的要求比较高。对于离线场景,使用高间隔的 sleep,轮询 IO 队列也是个不错的选择,但注意 sleep 本身也需要系统调用,并且开销大于通过连接发送 SyncEvent。
关于错误回退
也许可以通过运行时动态新增映射共享内存预期来缓解共享内存不足的问题,但又会为管理带来更多的 复杂性 。一个应用场景的 workload 是比较固定的,现在计算机的内存资源不再紧缺,可以在一开始就分配比较大的一块共享内存供后续使用。共享内存的 buffer 大小有点类似于 unix socket buffer,当其填满时说明 server 已经过载,无法及时处理并释放,需要缓缓。此时再分配更多的共享内存也无济于事。
关于热升级
Server 热升级有标准的 listener fd 热迁移方案,但其强调无损无感知。而共享内存通信以 lib 提供服务,已嵌入应用。在应用层进行热升级相对 listener fd 热迁移会简洁得多。
关于引入IO队列
主要为了批量收割 IO,避免频繁地在连接上收发 SyncEvent,此过程带来的 system call 对性能的影响不可忽略。