这是个好问题。这句俏皮话具体说来就是,不同的线程不共享内存不用锁,线程之间通讯用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的杀手特性。
如果程序设计成通过通信来共享数据的话,那么通信的两端是不是在同一个物理设备上就无所谓了,只有这样才能实现真正的分布式计算。
golang就是在向这个方向走,但是路还很长。
比如 golang 里常见的 chan
ch := make(chan int) go func() { n := <-ch println(n) }() ch <- 123
chan 目前在golang里就是个队列,是个内存的数据结构,一个goroutine往chan里放数据,另一个goroutine从里面取数据,go程序开发者并不关心chan里面是怎么保存和传递数据的,对开发者来说chan就是个goroutine之间的通信管道。
如果golang能把chan用TCP/IP来实现,就可以达到跨设备通信的目的,甚至可以用更高速的总线来达到接近内存的性能,这样两个goroutine是可以运行在不同的物理设备上的,他们之间只通过chan通信来达到交换数据的目的,并不关心对方是不是和自己在同一个进程同一个机器上。
从架构上来讲,降低共享内存的使用,本来就是解耦和的重要手段之一,举几个例子
案例: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的例子内部实现仍然是个状态机。但进程和线程间的状态隔离内存隔离,以冗余换低耦合本来就是一种经住实践考验的好思路。
需要回炉重造……
其实摇滚精神是人的精神,不用太强调摇滚这两个字。
前两天我看到了臧鸿飞对摇滚的解释,我觉得挺好,他说摇滚是面对着生活的不服,而流行音乐是面对生活服了。我觉得这种解释挺好,我们始终在质问自己还是不是自己。摇滚不摇滚不重要,重要的是你还是不是你自己。
摇滚是小众这个话题已经不用再说了,因为摇滚在西方已经成为主流过了,摇滚是大众音乐,在中国可能是小众音乐,所以说中国文化和经济的现状造成了摇滚乐在现在社会的定位不能说明它是大众还是小众,这个真的不重要,重要的是在于每一个人在面对自己现实的妥协的自我的时候,是否会持续的去发问,这种东西不一定偏要体现在摇滚乐,而流行音乐也有,古典音乐也有,现在一些hip hop音乐、嘻哈音乐里都有大量的质疑的声音。
当然,这些都是大的流行音乐的范畴里的,但都不是以人们所理解的摇滚音乐的形式所存在的,所以没必要偏要强调摇滚两个字。
我自己写过两首歌去比较中国的摇滚乐和西方的摇滚乐,近三十年前我写过一首《像是一把刀子》,我觉得当时的中国摇滚就像一把刀子;在十年前我写过另外一首歌叫做《滚动的蛋》,实际上也是针对Bob Dylan的《Like a rolling stone》做一次呼应,我们站在中国的土地上,对西方的摇滚乐做的呼应,我觉得我们更像一颗滚动的蛋,我觉得这是对中国摇滚乐现状的一种描述。中国摇滚是一颗滚动的蛋,但它没有破碎,雨后的大地路途好像有些松软,滚动的时候受到了保护,但的确是危险的状态。我更愿意说中国摇滚乐是从下而上的,蛋破碎了之后变成了生命。
从三十年前到十年前再到现在,中国摇滚没有走向世界,我觉得走不走真的不重要,类似的比较本身是功利心态的,把摇滚乐当做了一种商品。摇滚乐存在的形式也不是为了要走向世界,或者要流向中国,它们存在是为了身心的娱乐,这种娱乐自然会带来与市场的互动,这种互动造成了传播,所以在中国有大量的人受到了影响,可以了解到西方文化,而且他们可以轻而易举的买到这些唱片,这是一种正常的文化根基带来的良性的反应。所以我们就应该去听这种良性的音乐,我觉得每个时代都要有一种形式去表达自己,摇滚乐就是西方发展到六十年代七十年代,人们需要用新的形式去表达自己的时候适时出现的,那个时候已有的音乐形式不能表达人们的想法了。像现在一样,很多的年轻人选择用他们自己的方式表达自己,我们也没办法。这个就是自然的一种规律,人们在经济发展、文化发展、全球化发展的过程中,人们就会轻而易举的找到自己最想选择的方式表达自己。
我觉得中国的摇滚乐,或者说中国自由表达性音乐受到了很多限制,所以说在某种程度上,流行音乐更是很多人追求的一种结果,它的成功方式是一种结果,包括摇滚乐也希望能够像流行音乐一样成功,但不一定像流行音乐那样去表达。
遗憾的是,这个社会的环境和传统文化环境没有鼓励批判,或者说是坚持自我、怀疑现实这种审美,在别的人完全趟出一条路之后,我们亚洲人沿着他们的脚步往前走的时候可能会受益,因为大家会说这种东西我们已经知道结果了,是可控的。但真正达到了不可控制的时候,所谓的不可控制就是真正自由状,他们一定会出面干涉或强加限制。这个时候才需要人站出来,人的自由创造需要调整,针对这种状态的时候,才能产生出接地气的作品,这个时候我认为才是所谓最自由的状态,他自己也不知道会是什么样,人的自由状态在某种程度上是不可控的状态,当然这种不可控的状态是由内心强大的信仰牵引着的,不会造成危险。这种东西只有自己能知道,很多人是不相信的,所以我们的文化环境、我们的宗教信仰完全不信任人的自由状态,一定会有人干预你,不光是家长,即使是比你年轻的人都会来说小心点小心点别出圈、别超越底线。这些东西都有可能造成自己对自己说,那好吧,回头。
就像臧鸿飞说的那样,我们服了,我们对困难做出了妥协做出让步,我们的理想稍微静音一段时间,完了之后,我们可以获取到的东西要远远大于我们的挑战。
你持续的用你的理想去对抗你对现实的认知,你会发现你对现实的认知没有那么强大,你甚至可以说你自己征服现实就是你的现实观,现实怎么可能不被征服呢?他怎么可能会养成这种习惯呢?如果现实都是不可征服的,人就永远是在倒退。
摇滚不仅仅是一个音乐的概念,而是一种态度和人生观的概念。