白菜通信过程调用框架

简介

仓库包含三个主要项目及其下属的各子项目

  • 易语言 实现的协程本体(基于Win32Fiber)与协程扩展(文件读写、TCP客户Duan)
  • IPC(进程间通信,基于Win32API邮槽和共享内存实现,可采用APC/协程模型或IOCP/多线程模型)
  • RPC(远程过程调用,易的实现模型同上,但关键在于跨语言能力,目前实现或已部分实现的语言有E、PHP、JS、Python)

github仓库: https://github.com/xbcsoft/BC-ProcedureCall

前言的一些问题探讨与设计理念

[1.进程间通信遇到什么问题]

如果进程间通信是完全一对一的(指一个服务器端对应一个单线程客户),并且该单线程的客户不采用任何异步手段进行并发(即每次访问必定等待服务端的响应后才再次访问)那么进程间通信只需两端维护一个共享内存即可(当然该共享内存在设计上可以根据预定好缓冲大小,如果超过该大小则再次采用额外创建共享内存然后用完就释放它)

但如果该单线程客户允许使用异步并发(或是多进程客户或单进程内的多线程客户),也就是一时间的消息到来对于服务器而言没办法维护一个共享内存了,那么此时服务器端就要采用进程级的消息队列的形式(这样即便是多个客户同时往一个服务进程去塞进消息也不会覆盖多个消息本身),然后服务端根据其消息队列本身还可以采用多线程方式去进行“并发式消费”则可同时处理消息并返回处理结果到多个客户,从而进一步提高处理性能!

[2.如何实现进程级消息队列]

尽管有Windows自带的消息队列(Get/SendMessage)但这玩意通常是为窗口UI服务的会受到总消息数和性能的制约;

如果只是进程内的线程间通信可以采用性能最好的APC异步过程调用来实现,但QueueUserAPC并不能跨进程去投递APC队列,因为它所给的回调函数本身就只能在本进程内去访问其自身的地址空间(故只投递消息的话可能本身没什么问题但传递大数据或引用到非本进程的地址就完全不可行了),并且不同位数(32位<->64位)的程序去发起因为它的回调函数代码本身更加不能兼容跨进程的处理(微软官方也指出跨进程APC会存在问题)

如果使用最基本的事件/信号量+固定size的共享内存去搞消息队列,尽管可行,但也不容易向其中发出大量且体积较大的数据,再来是消息队列扩容本身也是个问题,因为CreateFileMapping一旦创建后该大小无法改变、并且也不容易销毁,因为只要有一方打开后内核对象的引用计数就不会是0,只有全都closehandle时才会销毁,故只能再额外创建新共享内存并用链表串起来——但还是那句话维护起来真的很麻烦,像是要手动写共享内存版的内存池管理一样。。

基于以上原因跨进程的消息队列本身不适宜自己手工实现,所以应当寻求系统自带的对于跨进程处理有什么好的内核级对象支持消息队列和传递大数据的通信方案!

[3.邮槽/命名管道/套接字选哪个]——套接字就不用说了性能肯定不如前两者

虽然我最终是使用了邮槽作为通信方案,但我还是要说说命名管道存在的问题

命名管道尽管支持双向通信(可客户到服务端然后服务端返回客户Duan数据),且在使用的时候参数繁杂不说(提供支持流式和用户报文两种形式),关键是它并不能像TCP套接字那样即时连接即时支持多客户的多路句柄构建(这里边内幕挺深的,写过才知道用这玩意是真坑)

具体来讲,首先需求上应当支持多客户同时访问,那么命名管道本身的管道实例就肯定不能是一个,这句话的理解在于服务端一开始自身是一条管道,消息到来后就必须要为刚进来的客户那端去创建新的管道(这个在TCP套接字编程那边是支持这样做的),故:用多个管道的根本意义在于服务端输出返回值时不与其他客户冲突

尽管命名管道它也支持多个管道实例的连接创建但其实现是不完美的(ConnectNamedPipe用于等待新客户的到来但先前必须要创建好足够多的实例做准备,而不是客户每来一个就临时自动创建一个),此外将命名管道进行IOCP模型化时不容易区分是新到来的客户事件还是正在通信中的客户数据到来(虽然后来知道可以在重叠结构中给出自定义状态,对于新创建的实例投递ConnectNamedPipe后第一次IO完成响应时一定是新客户到来再次发出ReadFile投递后响应才是数据到来,通信完后务必要销毁掉该新实例,故总体而言在编码上还是挺麻烦的——但这不是关键,当你看我对下面分析邮槽IOCP模型化的设计优化你就知道我为什么更亲赖于使用邮槽了);

使用邮槽的话它虽然仅支持单向通信且以用户报文形式进行传递,并且好处在于无差别客户发送数据进来不需要进行连接和等待连接,不过由于单向性故服务端并不能对其自身的同个邮槽发往到客户Duan,当然解决方案自然是客户Duan自己准备好它那边的邮槽就可以了那么这就不是什么问题了,然后客户投递给服务端数据时需额外提供客户这边自己的邮槽命名就可以让服务端打开后就可以发返回数据了!

[4.邮槽的IOCP模型化与设计优化]

首先在邮槽设计优化上客户这边如果自身对象不跨线程去调用则并不需要临时创建新邮槽,而是直接缓存该通信客户对象在初始化时所在主线程上的同个邮槽对象即可,以后就省去创建和销毁的开销(当然如果同个对象需要支持多线程则不可避免要临时创建和销毁新建的邮槽,可单独区分与初始化时所给的线程ID相对比即可知道当前对象的方法调用是否跨了线程,新建邮槽的意义在于多出一个独立使用的信道而不与原先的传输起冲突)

邮槽IOCP模型化后按理来说,服务端每条线程都维护各自独立的一个固定缓冲区来接收报文是最理想的(避免多次创建和销毁内存),但如果固定了缓冲区也带来一个问题就是要接的报文本身是多大的数据并不能直接知道,接的时候也不一定不会超出缓冲区大小,且最致命的是由于是无差别的客户报文你不可能再接着继续调用ReadFile读之后的数据(因为那可能不是你当前客户的数据,而是其他客户的),

对于邮槽IOCP模型化固定缓冲区的思路本身没问题,而对上述问题我还另外提出了采用共享内存的方式来给出额外参数,具体实现是这样的,首先固定邮槽的缓冲区64KB,如果超出64KB则额外采用共享内存映射的形式直达服务端的进程空间(该共享内存的内核对象名称是临时的,用完后当即销毁),那为何一开始不全都使用共享内存而是64KB?

之所以是64KB,我做个测试即便是映射了共享内存但它存在创建、映射、关闭、销毁该内核对象本身则存在一定的性能损耗,如果是少量数据本身可以一次性在邮槽/管道内携带(不必额外再给出共享内存),大数据的话传输上用上内存共享由于可以直接映射到对方的服务进程方的地址空间被处理时可以投递映射后的指针故是零复制性能非常高效,在64KB的时候创建和销毁共享内存的耗时几乎忽略不计(因为你就算用邮槽/管道传输了本身数据还会被服务端复制出来一份(在ReadFile时所给的缓冲区肯定是私有内存嘛),量越大时本身损耗会逼近创建/销毁共享内存这一内核对象且来的更快在超过64KB可以说是实验得出的临界点)

[5.关于用户权限和Session隔离问题](付费内容,你也可以尝试问AI自行解决)

[6. 异步单线程协程化处理]

我在易语言这边已经实现了可扩展的协程框架,基于此允许开发者很容易开发出各式的协程生态(比如文件读写、套接字通信等但凡能够原生采用win32异步IO的调用,甚至还有一个通杀的wait信号量的协程化方案)

起初搞协程我是用于游戏脚本,需要每个多任务中死循环延时周期性检测而并行处理子任务,但又不想开多线程去实现(很容易造成资源冲突),由于传统sleep延时本身会阻塞上线程,一个传统非阻塞方案是如果用上可MsgWaitForMultipleObjects临时处理Windows消息的CreateWaitableTimer版本(有些地方被翻译为超级延迟)那么还会造就重叠延迟的问题:

场景是这样的,如果你的第一次超级延迟还没返回则进入了该超级延迟内部的邮槽事件循环处理了下一个客户的延迟的到来,事实上该下一个客户存在延迟那么就会导致该次没结束而上层的上个客户的消息处理循环也不会返回(而每次延迟所用的耗时本身是无意义的),那么这里的延迟会随着回调的增多产生叠加效应(并且,线程栈的大小本身是有限的肯定不可能支持大量的回调地狱)

总的来说如此的叠加效应是会在一个延迟里继续处理了下一个客户的进入于是再延迟导致先前的延迟还要继续等待之后的延迟才能返回!

那么协程这是一个大杀器可以完美解决这一问题,即我需要在延迟的同时还能去做其他事仍需保持其代码上下文的同步性故此需协程化,后来把它慢慢扩展到了在客户Duan/服务端上应用的完整的协程生态!(包括IPC和RPC都通用,甚至还有多子进程模式下的协程版本)

[7. 可变参数及通用数据类型的统一封装格式]

变参由语言本身去实现不必多说,问题在于如何把这些参数高效打包为字节集在通信中的载体,以及不必全解析而随时可访问任意其中的索引参数

参考我的另一个项目: https://bbs.ijingyi.com/forum.php?mod=viewthread&tid=14870183

服务端示例代码(易语言)

IPC服务端示例代码

客户Duan示例代码(易语言)

IPC客户Duan示例代码

IPC的单例服务模式

IPC单例模式

具体示例参考:IPC\单例服务模式





最后欢迎加群讨论,共同打造易的协程生态 【白易语言研究院】: Q群668536886

标签: none