- CSP
- go channel
- MMORPG AOI 模块
- IM广播进程
Communicating Sequential Processes
由 pthread 众多 API 来看,多核/分布式环境里实现一个正确又高效的通信原语是非常困难的
不要试图用共享内存去实现你的 节点/进程/线程/goroutine 之间的复杂通信,重新发明这些通信原语,而是用系统或者语言提供的实现好的
从架构上来讲,降低共享内存的使用,本来就是解耦和的重要手段之一
基于高性能MPSC队列,可以不在事务逻辑里用锁
Rust里面,配合所有权概念和Send trait,编译器能够静态的保证没有数据竞争
用这种范式的主要优点是逻辑简单清楚,系统有高正确性
程序能保证每个线程里事件都是sequential consistent的,不会有竞争出现
不需要在写完程序之后花大笔时间去debug各种诡异的线程竞争问题
在交易系统中,这个对保证交易逻辑的正确性至关重要
线程之间不用共享内存,共享内存和memory-order的细致优化被完全使用在实现高性能的无锁队列上
由于队列可以无锁,系统延迟完全没有锁的contention的影响,单线程的逻辑同时保证最低延迟
宏观上来讲,没有数据竞争,更容易而且更倾向于写出清楚的责任划分,可以随意并行的系统
另外,这跟一份内存还是两份内存是没有关系的。很多情况下,数据从channel的一端到另一端其实并没有拷贝,而只是一个move,也就是一个指针的替换
上面所说的对延迟的影响也很容易看到这并不是通过降低速度而换取低复杂性的作法。通常正确实现这类范式的结果是速度变快而不是变慢,无论是延迟还是吞吐
golang的channel是first-class citizen,使用方便,配套设施完备
加上goroutine,可以在避免操作系统线程切换的overhead的同时享受channel通信的简单方便
一种纯粹的想法
只用一种交互方式:消息传递, 但这样传递大数据不经济(现代计算机架构很多时候拷贝比共享访问更经济,另一个主题)
经典思路
控制流和数据流分开走
低带宽控制信息走相对高延迟,但一致安全的通信原语 各个 player 协同保证有序安全访问共享数据资源
go channel
Go里最简单的例子就是用channel传指针,并且 约定 各个goroutine从channel收到了指针可以随便玩,但是把它送出去到别的channel之后你就别再碰它了
相当于用channel这种标准安全的通信原语传一个控制令牌(几个字节的指针),而让大的数据块不用拷贝就能安全共享
Erlang是纯消息传递,但传一个大 binary 类似自动变成个引用计数的内存块然后传指针,并不是真的拷贝,binary是只读的这种共享访问在语言级保证安全而不用约定
「不要通过共享内存来通信,而应该通过通信来共享内存」就是这么个意思
但完是说不应该用mutex,mutex也是通信原语
如果 goroutine 之间的协同语义确实就是简单的: 保证只有一个 goroutine 能够进入临界区,那用 mutex 没有什么不对
只是在通信语义变复杂的时候,不要用 mutex 加锁操作共享对象来传递控制信息,重新发明轮子
如果你的goroutine之间的协同语义确实就是简单的: 保证只有一个goroutine能够进入临界区,那用mutex没有什么不对
只是在通信语义变复杂的时候,不要用mutex加锁操作共享对象来传递控制信息,重新发明轮子
用共享内存(文件)的方式重新发明通信协议
几个互相依赖的任务,扫一把共享的文件目录,看见上游文件好了就开工,撞上了几个 race condition 就开始搞个 done file 表示真的搞定了不骗你之类
到这里就很清楚在用共享内存(文件)的方式重新发明通信协议并且一定会撞板了
老老实实地改用现成的 message queue 做通知调度,数据还是放共享目录互相读,但通信协同就必须走标准协议了
MMORPG AOI 模块
AOI Area Of Interest
AOI模块需要实时告诉其他模块,对于某个玩家:
- 哪些人进入了我的视线范围?
- 哪些人离开了我的视线范围?
- 区域内的角色发生了些什么事情?
游戏逻辑依赖上述计算结果,角色有动作时才能准确的通知到对它感兴趣的人,这个计算很费 CPU,特别是 ARPG 跑来跑去那种
一般另开一个线程来做,但此模块又需频繁读各角色之间位置信息和一些用户基本资料
- 简单的加锁
- 主线程维护的用户位置信息加锁,保证AOI模块读取不会出错
- AOI模块生成的结果数据加锁,方便主线程访问
如此代码得写的相当小心,性能问题都不说了,稍有不慎状态就会弄挂,写一处代码要经常回过头去看另外一处是怎么写的,担心自己这样写会不会出错
新人加入后,经常因为漏看老代码,考虑少了几处情况,弄出问题来你还要定位一半天难以查证
- 演进
AOI、主线程间不再有共享内存,主线程维护玩家上下线和移动,变化情况抄一份用消息发送给 AOI
AOI根据消息在内部构建出另外一份完整的玩家数据,自己访问不必加锁
计算好结果后,又用消息投递给主线程,主线程根据AOI模块的消息自己在内存中构建出一份AOI结果数据来,自己频繁访问也不需要加锁
由此AOI得以完全脱离游戏,单独开发优化,相互之间的偶合已经降低到只有消息级别的了
AOI并不需要十分精密的结果,主线程针对角色位置变化不必要每次都通知AOI,隔一段时间(比如0.2秒)通知下变动情况即可
而两个线程都需要频繁的访问全局玩家坐标信息,各自维护一份以后,“高频访问” 动作限制在各自线程私有数据中,完全避免了锁冲突和逻辑状态冲突
用一定程度的数据冗余,换取了较低的模块偶合。出问题概率大大降低,可靠性也上升了,每个模块还可以单独的开发优化
IM 广播进程
-
第一代 频道/房间/群 人数少于5000,基本不需要考虑优化广播
-
第二代
同频道/房间/群的人数超过 1万,甚至线上跑到10万的时候,广播优化就不得不考虑了
拆线程,拆了线程以后跟AOI一样的由广播线程维护用户状态
然而针对不同的用户集合(频道、房间、群)广播模块需要维护的状态太多了,群的广播一套,房间广播一套,用户离线推送一套,都是不同的用户数据结构
- 第三代
广播系统彻底独立成了一个唯一的广播进程,使用 “用户标签” 决定广播的范围,不光是何种类型的逻辑需要广播了
用户身上打不同标签,群1所有用户都有群1的标签,频道3用户都有频道3的标签
逻辑模块在用户登录时给用户打一个标签,打标签的消息汇总到广播进程自己维护的用户状态数据区,以:用户<->标签 双向关系进行维护
发广播时逻辑模块只需要告诉广播进程给什么标签的所有用户发什么广播,优先级多少即可
广播进程组会做好命令拆分,用户分组筛选,消息合并,丢弃,压缩,节拍控制,等一系列标准化操作
比起第一代来,单次实时广播支持广播的人数从几千上升到几十万,模块间也彻底解耦了
两个例子,做的事情都是把原来共享内存干掉,重新设计了以消息为主的接口方式,各自维护一份数据,以一定程度的数据冗余换取了更低的代码偶合,提升了性能和稳定性还有可维护性
很多教多线程编程的书讲完多线程就讲数据锁,给人一个暗示好像以后写程序也是这样,建立了一个线程,接下来就该考虑数据共享访问的事情了
所以 减少共享内存和 多用消息,并不单单是物理分布问题,这本来就是一种良好的编程模型
不仅针对数据,代码结构设计也同样实用,有时候不要总想着抽象点什么,弄出一大堆 Base Object/Inerface 的后果有时候是灾难性的
不同模块内部做一定程度的冗余代码,有时反而能让整个项目逻辑更加清晰起来。所以才会说:高内聚低耦合
上面 aoi 的例子内部实现仍然是个状态机。但进程和线程间的状态隔离内存隔离,以冗余换低耦合本来就是一种经住实践考验的好思路, 而不是纯粹无状态的 actor
共享内存 / 消息,本质都是不同实体间如何协调信息,以达成某种一致
共享内存基于的通讯协议由硬件和 OS 保证,这种保证是宽泛的,事实上可以完成任何事情,带来管理的复杂和安全上的妥协
消息是高级的接口,可以通过不同的消息定义和实现把大量的控制,安全,分流等相关的复杂细节封装在消息层,免除上层代码的负担
所以其实是增加了一层来解决共享内存存在的问题,实际上印证了另一句行业黑话:计算机科学领域所有的问题都可以通过增加一个额外的间接层来解决