如何理解 Golang 中“不要通过共享内存来通信,而应该通过通信来共享内存”?

不要通过共享内存来通信,而应该通过通信来共享内存 这是一句风靡golang社区的经典语,对于刚接触并发编程的人,该如何理解这句话?
关注者
1147
被浏览
103926

34 个回答

在多核或者分布式环境里实现一个正确又高效的通信原语是非常困难的,所以不要试图用共享内存去实现你的节点/进程/线程/goroutine之间的复杂通信,重新发明这些通信原语,而是用系统或者语言提供的实现好的。

一种纯粹的想法就是那就让系统里只有一种交互方式:消息传递。但是这样传递大数据很不经济啊(其实现代计算机架构很多时候拷贝比共享访问更经济,不过这是另一个主题了),所以有个很经典的思路,控制流和数据流分开走,低带宽的控制信息走相对高延迟,但一致安全的通信原语,让系统里的各个player协同保证有序安全访问共享数据资源。

Go里最简单的例子就是用channel传指针,并且约定各个goroutine从channel收到了指针可以随便玩,但是把它送出去到别的channel之后你就别再碰它了。这就相当于用channel这种标准安全的通信原语传一个控制令牌(8个字节的指针),而让大的数据块不用拷贝就能安全共享。Erlang是纯消息传递,但传一个大binary它是类似的自动变成个引用计数的内存块然后传指针,并不是真的拷贝,binary是只读的这种共享访问在语言级保证安全而不用约定。「不要通过共享内存来通信,而应该通过通信来共享内存」就是这么个意思。

这句话完全不是说你不应该用mutex —— mutex也是通信原语好伐。如果你的goroutine之间的协同语义确实就是简单的「保证只有一个goroutine能够进入临界区」,那用mutex没有什么不对。只是在通信语义变复杂的时候,你不要用mutex加锁操作共享对象来传递控制信息,重新发明轮子。

这个design principle在Go以外的地方也很有用。我的项目里有一个数据pipeline(Flume, 系统编程 里提到过),后来越搞越复杂,变成了好几个互相依赖的任务,之前赶deadline都是乱写的什么扫一把共享的文件目录,看见上游文件好了就开工,撞上了几个race condition就开始搞个done file表示真的搞定了不骗你之类。到这里就很清楚我们在用共享内存(文件)的方式重新发明通信协议并且一定会撞板了,于是老老实实地改用现成的message queue做通知调度,数据还是放共享目录互相读,但通信协同就必须走标准协议了。

从架构上来讲,降低共享内存的使用,本来就是解耦和的重要手段之一,举几个例子

案例:MMORPG AOI 模块

MMORPG 服务器逻辑依赖实时计算 AOI,AOI计算模块需要实时告诉其他模块,对于某个玩家:

  • 有哪些人进入了我的视线范围?
  • 有哪些人离开了我的视线范围?
  • 区域内的角色发生了些什么事情?

所有逻辑都依赖上述计算结果,因此角色有动作的时候才能准确的通知到对它感兴趣的人。这个计算很费 CPU,特别是 ARPG跑来跑去那种,一般放在另外一个线程来做,但这个模块又需要频繁读取各个角色之间的位置信息和一些用户基本资料。

最早的做法就是简单的加锁:

第一是对主线程维护的用户位置信息加锁,保证AOI模块读取不会出错。

第二是对AOI模块生成的结果数据加锁,方便主线程访问。

如此代码得写的相当小心,性能问题都不说了,稍有不慎状态就会弄挂,写一处代码要经常回过头去看另外一处是怎么写的,担心自己这样写会不会出错。新人加入后,经常因为漏看老代码,考虑少了几处情况,弄出问题来你还要定位一半天难以查证。

演进后的合理做法当然是 AOI和主线程之间不再有共享内存,主线程维护玩家上线下线和移动,那么它会把这些变化情况抄一份用消息发送给 AOI模块,AOI模块根据这些消息在内部构建出另外一份完整的玩家数据,自己访问不必加锁;计算好结果后,又用消息投递给主线程,主线程根据AOI模块的消息自己在内存中构建出一份AOI结果数据来,自己频繁访问也不需要加锁。

由此AOI模块得以完全脱离游戏,单独开发优化,相互之间的偶合已经降低到只有消息级别的了,由于AOI并不需要十分精密的结果,主线程针对角色位置变化不必要每次都通知AOI,隔一段时间(比如0.2秒)通知下变动情况即可。而两个线程都需要频繁的访问全局玩家坐标信息,这样各自维护一份以后,将“高频率的访问” 这个动作限制在了各自线程自己的私有数据中,完全避免了锁冲突和逻辑状态冲突。

用一定程度的数据冗余,换取了较低的模块偶合。出问题概率大大降低,可靠性也上升了,每个模块还可以单独的开发优化。


案例:IM广播进程

同频道/房间/群 人数少于5000,那么你基本不需要考虑优化广播;而你如果需要处理同频道/房间/群的人数超过 1万,甚至线上跑到10万的时候,广播优化就不得不考虑了。

第二代广播当然是拆线程,拆了线程以后跟AOI一样的由广播线程维护用户状态。然而针对不同的用户集合(频道、房间、群)广播模块需要维护的状态太多了,群的广播需要写一套,房间广播又需要写一套,用户离线推送还需要写一套,都是不同的用户数据结构。

于是第三代广播系统彻底独立成了一个唯一的广播进程,使用 “用户标签” 来决定广播的范围,不光是何种类型的逻辑需要广播了,他只是在同一个用户身上加入了不同的标签(唯一字符串),比如群1的所有用户都有一个群1的标签,频道3的用户都有一个频道3的标签。

所有逻辑模块在用户登录的时候都给用户打一个标签,这个打标签的消息汇总到广播进程自己维护的用户状态数据区,以:用户<->标签 双向关系进行维护,发广播时逻辑模块只需要告诉广播进程给什么标签的所有用户发什么广播,优先级多少即可。

广播进程组会做好命令拆分,用户分组筛选,消息合并,丢弃,压缩,节拍控制,等一系列标准化操作,比起第一代来,单次实时广播支持广播的人数从几千上升到几十万,模块间也彻底解耦了。


两个例子,做的事情都是把原来共享内存干掉,重新设计了以消息为主的接口方式,各自维护一份数据,以一定程度的数据冗余换取了更低的代码偶合,提升了性能和稳定性还有可维护性。

很多教多线程编程的书讲完多线程就讲数据锁,给人一个暗示好像以后写程序也是这样,建立了一个线程,接下来就该考虑数据共享访问的事情了。所以Erlang的成功就是给这些老模式很好的举了个反例。

所以 “减少共享内存” 和多用 “消息”,并不单单是物理分布问题,这本来就是一种良好的编程模型。它不仅针对数据,代码结构设计也同样实用,有时候不要总想着抽象点什么,弄出一大堆 Base Object/Inerface 的后果有时候是灾难性的。不同模块内部做一定程度的冗余代码,有时反而能让整个项目逻辑更加清晰起来。

所以才会说:高内聚低耦合嘛


关于冗余与偶合的关系,推荐阅读这篇文章:

Redundancy vs dependencies: which is worse?


------------------------------------------------------------------------

案例3:NUMA 架构

多CPU共享一块内存的结构很难再有大的发展,各个核之间的数据同步和控制协议的复杂度随着核的数量上升而成几何级数上升,并发访问性能却不断下降,传统的SMP结构如今碰到了很大瓶颈,

因此同物理主机内部也出现了 NUMA结构,让不同核心访问各自独立的内存区域,由此核心数量可以大大提升,Linux内核已早已支持这样的结构。而很多程序至今仍然用SMP的方式进行编码。

倘若哪天NUMA逐步取代SMP时,要写高性能服务端代码,共享内存这玩意儿,估计你想用都用不了了。

-----------------------------------------------------------------------

反例:XXGAME服务端引擎

国内某两个字母的最大型的休闲游戏平台,XXGAME,游戏为了避免逻辑崩溃影响网络链接,十多年前就把网络进程独立出来了,逻辑一个进程,网络一个进程,其实就是大多数架构的 LinkServer / Gate 和业务的关系,网络进程和业务之间使用socket通信即可(Linux2.6以后本地 socket通行有 short cut,性能和本地管道一样,基本等同 两次memcpy)。可XXGame服务端引擎,发明了一个 “牛逼的” 共享内存模块,用共享内存+RingBuffer 来给网络进程和逻辑进程做数据交换用,然后写了一大堆觉得很高明的代码来维护这个东西。

听说这套引擎后来还用到了该公司其他牛逼的大型游戏中去了。

这里问一句,网卡每秒钟能传输多少数据?内存的带宽是网卡的多少倍?写那么多的代码避免了一到两次memcpy换来把时间从 100降低到 99,却让代码之间充满了各种偶合,飞线,好玩么?十多年前我听说这套架构的时候就笑了,如今十多年过去了,面对那么多新产生的架构方法和设计理念,你们这套模块自己都不敢怎么改了吧?新人都不敢给他们怎么维护了吧?要不怎么我最近听着还有好几个游戏在用这么老的模式呢。


----

今天也并非向大家提倡纯粹无状态的actor,上面aoi的例子内部实现仍然是个状态机。但进程和线程间的状态隔离内存隔离,以冗余换低耦合本来就是一种经住实践考验的好思路。

为什么?