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

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

48 个回答

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

案例: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的例子内部实现仍然是个状态机。但进程和线程间的状态隔离内存隔离,以冗余换低耦合本来就是一种经住实践考验的好思路。

这是个好问题。这句俏皮话具体说来就是,不同的线程不共享内存不用锁,线程之间通讯用channel同步也用channel。

这种并发的范式实际上已经是主流了,不提erlang,即使是C++也有很多高性能的架构是只依赖于高性能的MPSC队列,而从来不在事务逻辑里用锁。在Rust里面,配合所有权概念和Send trait,编译器能够静态的保证没有数据竞争。

用这种范式的主要优点是逻辑简单清楚,系统有高正确性。你的程序能保证每个线程里事件都是sequential consistent的,不会有竞争出现。你不需要在写完程序之后花大笔时间去debug各种诡异的线程竞争问题。在交易系统中,这个对保证交易逻辑的正确性至关重要。

另一方面很多人没有看到的是,这种范式反而会比线程之间单纯的共享内存更快。在我看到的顶尖低延迟领域,这种范式越来越主流。线程之间不用共享内存,共享内存和memory-order的细致优化被完全使用在实现高性能的无锁队列上。由于队列可以无锁,系统延迟完全没有锁的contention的影响,单线程的逻辑同时保证最低延迟。宏观上来讲,由于逻辑能够被更容易的reason about(理清?)。没有数据竞争,人会更容易而且更倾向于写出清楚的责任划分,可以随意并行的系统。大型系统的性能从来都更在乎是否有一个好的架构而通常不是去优化个别函数。

另外,这跟一份内存还是两份内存是没有关系的。很多情况下,数据从channel的一端到另一端其实并没有拷贝,而只是一个move,也就是一个指针的替换。上面所说的对延迟的影响也很容易看到这并不是通过降低速度而换取低复杂性的作法。通常正确实现这类范式的结果是速度变快而不是变慢,无论是延迟还是吞吐。

golang的特色就是它的channel是所谓的first-class citizen(一等公民),使用方便,配套设施完备。加上go-routine,它可以在避免操作系统线程切换的overhead的同时享受channel通信的简单方便。在我看来,这应该是golang的杀手特性。