Q+是以第三方应用为核心的QQ客户端开放平台,为了提高Q+平台的安全性、稳定性和可扩展性,我们实现了微内核多进程架构,可以将第三方应用和服务调度到独立的进程,进程间通信(IPC)模块作为纽带,性能是很重要的指标。
操作系统提供了什么
  Windows下有很多方法实现进程间通讯,比如套接字(Socket),管道(Pipe),邮件槽(Mailslot)等,但最直接、最有效的方式还是使用内存共享,通过将同一份物理内存映射到多个进程的虚拟地址空间上,每个进程都可以直接进行读写,从而实现高效的数据交换。内存共享的API也非常简单,使用CreateFileMapping/OpenFileMapping可以创建/打开一个命名内存文件映射对象,然后调用MapViewOfFile 将指定的物理内存映射到当前进程的虚拟地址空间。一旦用完共享内存,调用UnmapViewOfFile回收内存地址空间,调用CloseHandle关闭文件映射对象。
高性能与可复用的较量
  IPC作为一个基础模块,需要向上层模块提供更加抽象的接口,屏蔽进程间通信细节,原始的API调用方式最高效,但显然不适合复用,而且需要处理数据竞争等问题。一种基于共享内存的IPC方案是在模块内部使用缓存(图1),IPC模块会将 I/O 数据缓存起来,也就是说,发送数据会先拷贝到发送缓冲区中,然后由发送线程将数据从发送缓冲区拷贝到共享内存;接受数据则先由接受线程将数据从共享内存拷贝到接收缓冲区,再以回调的形式交给上层应用处理。


图1 IPC模块逻辑流程


如何建立与断开连接
  Windows的共享内存大小必须在CreateFileMapping时指定。由于IPC模块需要适应各种通信需求,在代码中HardCode共享内存大小具有很大的局限性:若共享内存过大,会浪费物理内存,若共享内存太小,在传输大数据量时就会出现瓶颈,这意味着需要通过握手来协商确定共享内存大小。
  假设数据的发送方为C,数据接收方为S, S需要先调用IpcListen创建公用共享内存MAPPING_NAME_S_RESERVE准备接受连接,C调用IpcConnect主动连接S时,先创建自己的接收共享内存MAPPING_NAME_C_S,然后发送一个带SYN标志的数据包到S的公用连接通道MAPPING_NAME_S_RESERVE,S收到SYN后根据数据包内的共享内存大小以及关键字C,创建接收共享内存MAPPING_NAME_S_C,并打开发送共享内存MAPPING_NAME_C_S写入一个带ACK标志的数据包,C收到ACK后也打开发送共享内存MAPPING_NAME_S_C并写入一个带ACK标志的数据包,IpcConnect成功返回,C与S已经建立双向连接。


图2 IPC连接建立过程


  任何一方主动断开连接只需向另一方发送一个带FIN标志的数据包并清理连接资源,另一方收到FIN包后也将清理连接资源, IPC模块内部还有一个检测线程,确保进程结束时所有资源得到释放。
如何发送和接收数据

  共享内存的最小读写粒度是Packet(图2),第一个成员变量PacketLength指明了包的总长度。因为共享内存的大小在整个生命期内是固定的,为了能够被反复读写,共享内存被设计成循环队列,并在队列头部分配3个标记(Head, Tail, Reversal)维护读写状态, Head和 Tail被初始化为0,Reversal是队列写满时的折返位置,初始化为共享内存大小。


图3 循环队列、包结构


  发送和接收数据使用了生产/消费者模型。发送方调用SendPacket发送数据,假设数据长度为DataLength,需要队列折返的情况出现在Head <= Tail && Tail + DataLength > Reversal,若需要折返,先将数据从发送缓冲区拷贝到队列原点位置指向的共享内存,然后设置新的Reversal为原Tail,新的Tail为DataLength,否则,将数据从发送缓冲区拷贝到原Tail指向的共享内存,新的Tail置为原Tail + DataLength即可,无论是否折返,都需要确保Tail + PacketNeedLength < Head,以免出现数据覆写的情况,发送完成后,使用事件通知对方进程有数据可以接收。接收方调用RecvPacket接收数据,当Head等于Tail时说明队列为空,否则,Head处必然存在至少一个数据包,将数据包拷贝到接收缓冲区,并读取数据包的PacketLength,新的Head置为原Head + PacketLength,并且当新Head等于Reversal时新Head重置为0即可。
如何处理接收缓冲区内的数据
  在处理接收缓冲区内的数据时许多实现使用了拉模型,上层应用在消息循环PeekMessage失败(消息队列没有其他消息)时主动查询并处理接收缓冲区内的数据,对于进程间通信不频繁的应用,这会浪费大量的CPU空转时间,而且加重了调用者负担。通过Hook消息循环的GetMessage/PeekMessage,可以使用推模型处理接收缓冲区内的数据,接收线程在将数据放入接收缓冲区后调用PostThreadMessage通知主线程有数据可以读取,主线程的MessageHook函数将接收缓冲区数据作为参数调用上层应用注册的回调函数,这种方式可以明显降低进程空闲时不必要的CPU开销,而且将所有的细节屏蔽在IPC模块内部,调用者只需注册数据处理回调函数即可。
与其他实现的性能对比
  到目前为止,我们所有的讨论都是基于共享内存的进程间通信,管道(Pipe)是Windows下另一种常用的进程间通信方式,同样提供了FIFO模型,我们是不是在重复造轮子呢?为了评估其通信性能,在下面的测试中,我们发送不同请求包大小,计算两种方式在异步测试下的吞吐量(异步测试是指测试中发送数据后不需要等待响应就可以继续发送其他数据,可以充分体现通信能力,采用这种测试的另一原因是实践中的异步通信占据很大的比重):


图4、吞吐次数对比(次/s)


  从以上数据可以看出(图4),当请求包比较小的时候,基于共享内存的IPC在性能上优于管道10%左右,随着请求包增大,管道性能下降非常快,基于共享内存的IPC性能对比超过100%,并且实践中管道性能还受限于读取时传入的缓冲区大小,综合来看,基于共享内存的IPC具有更大的优势。
结束语

  Q+平台广泛的应用了基于共享内存的IPC机制,并在此基础上搭建了RPC模块,任务管理和调度模块,增强了Q+平台的安全性、稳定性和扩展性。