ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从死锁到Actor模型:一种彻底告别共享内存的并发编程范式

从死锁到Actor模型:一种彻底告别共享内存的并发编程范式 我一直记得那次线上事故。凌晨两点钟报警群炸了锅支付链路的响应时间从30毫秒一路飙到十几秒。我和同事查了很久最终定位到一段看起来毫无问题的Java代码四个线程要去更新同一个用户账户的余额我们用了一把synchronized锁做保护而锁的内部又嵌套着一层RPC调用。结果两个线程互相拿着对方需要的锁谁也不肯放手——标准死锁。那天晚上我回家路上翻来覆去想的不是这台机器怎么办而是一个更基础的问题并发程序真正难搞的到底是计算还是通信后来我花了大半年时间研究Actor模型才一点点把这个问题想通。Actor模型是一种并发编程的通信范式它的核心观点是不共享内存通过消息传递来协作。每个Actor都有自己的私有状态别的Actor碰不到想让它做点什么就给它发一条消息。这个思路在Erlang、Akka、Proto.Actor这些语言和框架里被证明极其好用尤其适合分布式、高并发的场景。这篇想写的是我从理解Actor模型到在实际项目里用它踩过坑的全过程相信对正在和锁、信号量、共享变量搏斗的并发编程实践者会有点帮助。1. 从一次死锁事故说起共享内存加锁这条路为什么越走越累先复盘那一晚的问题。逻辑并不复杂账户A要向账户B转一笔钱A减余额B加余额这笔操作要求原子性于是我们很自然地在两个账户对象上分别加了锁为了减少临界区长度还特意把锁粒度设计得很小。问题出在转账本身需要同时拿到两把互不关联的锁线程1先拿A锁再去拿B锁线程2先拿B锁再去拿A锁于是僵住。更坑的是我们为了拿到其他服务里的用户信息在锁里做了一次RPC调用锁的持有时间从微秒级一下子拉长到毫秒级死锁概率被几何级放大。那晚最终靠重启服务和临时开关才恢复但根因没解决所有人都知道它还会回来。1.1 锁的本质是让一块内存同一时刻只被一个人摸锁解决的是互斥问题。任何一个写并发的人都知道两个线程同时修改同一个变量结果不可控所以必须保证同一时刻只有一个线程能碰它。听起来很简单但一旦涉及多个变量、多个操作步骤锁的粒度就成了一个两难的选择粒度调小了为了覆盖完整操作往往需要多次加锁解锁中间稍有交叉就是竞态粒度调大了临界区变长别的线程长期阻塞整体吞吐掉下来。更麻烦的是锁这东西在物理现实中是排队一旦排队就存在等待顺序问题等待顺序一旦形成环就没有人能往前走。用生活化的例子类比锁的本质像是一间只有一把钥匙的公共办公室。谁拿着钥匙谁就能进去办自己的事。可如果你的流程需要多间这样的办公室你拿着1号钥匙想去2号办公室另一位同事拿着2号钥匙等着进1号办公室两个人就在走廊里互相干瞪眼。共享内存加锁模型的特点就是所有线程往同一块黑板上改数字然后用锁维持秩序。1.2 竞态、死锁、内存可见性三个名词背后是同一种尴尬加锁能解决原子性却防不住死锁换成原子变量和CAS能防止一部分竞态又解决不了ABA问题再引入内存屏障和volatile还要处理可见性——看起来每一种并发工具都只能解决一个侧面。在真正的高并发服务里往往需要同时使用锁、原子变量、线程池、队列甚至再叠加事务、分布式一致性协议复杂度呈指数增长。这就是为什么很多团队做并发测试时总是提心吊胆你以为覆盖了所有加锁路径但实际上代码重构后漏了一条分支死锁又回来了。实践里还有更隐蔽的加锁后如果持有锁的线程发生异常锁不释放后续所有线程都挂死。你需要用try/finally确保释放用超时锁兜底用诊断工具抓现场。可以说共享内存模型把并发问题隐藏在业务代码的每一处角落。出问题的时候我们很难直接说清是逻辑错误、调度时序还是内存模型的问题只能靠经验一步步排查。1.3 换一种协作方式把数据藏起来用消息传心愿那晚之后我一直在思考有没有一种结构能从一开始就避免共享这件事。后来读到1973年Carl Hewitt等人提出Actor模型才意识到解决思路不是把锁用得更好而是把共享彻底拿掉。在Actor模型里每个计算单元是一个Actor它拥有完全私有的状态外部没有任何人能读取或修改它。别人想让它做点什么只能发一条消息由它自己在内部串行处理。因为状态不共享就不需要锁因为通信是明确的投递消息就不存在隐性的内存可见性问题。第一次听到这个想法我很怀疑不共享数据那怎么协作后来用了一段Erlang才明白协作并不需要通过同一个变量完成而是通过消息的你来我往完成。转账逻辑在Actor模型里是这样账户A的Actor收到转出指令扣减自己的余额然后发一条消息给账户B的Actor告诉它收入金额整个过程没有一个共享变量但业务照样跑通还天然回避了锁的问题。这就是通信范式的核心魅力并发单元之间的所有交互都显式地通过消息通道完成。2. Actor模型的落点私有状态、信箱和消息通道2.1 一个Actor就是一台袖珍计算机Actor模型的定义其实很简洁一个Actor 私有状态 行为逻辑 一个信箱。私有状态只有Actor自己维护行为逻辑决定它收到消息后做什么往往是处理消息改变自己状态然后继续等待下一条信箱mailbox是消息排队的地方消息进入信箱后Actor按顺序一条一条处理。因为每一条消息都在同一个Actor内部串行处理所以Actor不需要锁。这就像一家只开一个柜台的银行窗口只有一个客户排队进入每个客户的需求在窗口前按序办完柜员永远不会遇到两个客户同时抢同一张表格的问题。你可能会说那一个Actor同时只能干一件事性能不就废了注意Actor模型里的并发单位是很多个Actor成千上万个Actor分布在同一个进程里每个都持有小块独立状态它们天然可以并行。这有点像把一台大电脑拆成一屋子小柜台每个柜台独立接待客户柜员数量由调度器决定。Erlang里创建Actor叫进程极端便宜几万个进程同时存在很正常Akka里创建Actor实例也远比创建系统线程轻量。正是这种低成本让一个任务一个Actor成为可行设计而不是像线程池那样精打细算。2.2 tell与ask异步发送和请求响应的两种姿势在Actor框架里核心操作就两个发送与接收。Erlang用Pid ! Msg发送用receive接收Akka里用actorRef.tell(msg, sender)发送。发消息默认是异步的发送方发完立刻回来不等待接收方处理这是Actor区别于传统函数调用的关键。请求响应的场景同样存在典型实现叫ask。Akka的ask返回一个Future调用方可以等待其完成。但要注意严格意义上的Actor模型并不提倡同步阻塞式调用因为这会绑定线程很多框架建议尽量用tell配合回调或事件流减少跨Actor同步等待。Erlang里则通常这样写发送消息时把自己的PID放进消息里接收方处理完把结果发回这个PID发送方再用receive等待。这也解释了为什么Actor之间通信时消息里经常带着一个返回地址。下面是一个Erlang计数器的实现我删掉了不必要的细节保留主干-module(counter). -export([start/0, add/2, get/1]). start() - spawn(fun() - loop(0) end). add(Pid, N) - Pid ! {add, N}. get(Pid) - Pid ! {get, self()}, receive {ok, N} - N end. loop(Count) - receive {add, N} - loop(Count N); {get, From} - From ! {ok, Count}, loop(Count) end.这段代码看起来非常直白loop(Count)这个递归循环本身就代表一个Actor在持续运行每处理一条消息就带着新状态调用自己消息自然形成串行队列。因为Count只在当前Actor内部流转不需要任何锁。说句实话我第一次看到这种写法时愣了一下没见过这种把循环当对象的写法但理解之后反而觉得比对象加锁的模型干净得多。同样的逻辑用Akka来表达就是下面这段Java代码import akka.actor.AbstractActor; import akka.actor.Props; public class CounterActor extends AbstractActor { private long count 0; public static Props props() { return Props.create(CounterActor.class); } Override public Receive createReceive() { return receiveBuilder() .match(Add.class, msg - count msg.amount) .match(GetCount.class, msg - getSender().tell(count, getSelf())) .build(); } public static class Add { public final long amount; public Add(long amount) { this.amount amount; } } public static class GetCount {} }这两种实现对应着两种语言生态但背后逻辑完全一致Actor内部的变量不需要任何同步修饰因为同一时刻只有信箱里的一条消息在驱动它执行。这一点是我当初决定深入Actor模型的重要原因——写锁的代码时我总是要反复确认临界区是否足够安全写Actor时却几乎不用想这个问题。另外补充一句getSender()这种写法在Akka里非常常见它拿到的是消息发送方的ActorRef相当于通信双方在每次交互中都隐式地交换了回信地址。2.3 不可变消息为什么把引用发过去会出大问题Actor模型有一个容易忽略的前提消息本身最好不可变。道理很简单如果发送方发出去的是一条指向可变对象的引用接收方拿到的只是同一块内存地址两个Actor实际上还是在共享内存只不过共享的载体从状态变量变成了消息对象。一旦接收方改了消息里的字段发送方立刻受影响内存可见性问题重新回归锁的需求也跟着回来了。Erlang天生规避了这个坑因为它的数据全是不可变值发消息相当于复制一份结构或共享不可变数据之后没人能改Akka和Proto.Actor则把不可变消息作为编码约定官方文档反复强调。实操建议是消息里只放原始类型、不可变集合和只读对象如果非要传某个内部对象先深拷贝再发送宁可多花一点内存也别埋下共享引用的雷。这点在做分布式时尤其重要因为消息将来为了跨机器传输一定会被序列化可变引用在序列化前后语义完全不同。3. 调度、顺序与容错Actor模型在真实框架里如何运作3.1 几万个Actor共用一个线程池调度器怎么做到不打架有人会问几万个Actor如果要一人一个线程机器早就被拖死了。实际上Actor和线程是两码事。Actor是逻辑调度单元真正干活的线程是调度器从线程池里分配出来的。一个调度器背后就是一组工作线程忙的时候从各Actor的信箱里取消息处理完再去取下一条Actor本身处于等待状态时不占用系统线程。所以多少Actor都不是按线程数算的而是看它们活跃的时候需要多少真正的执行资源。Akka里这个组件叫Dispatcher默认基于ForkJoinPool可以通过配置控制并行度和吞吐量。有一个参数我建议认真调吞吐量throughput它表示调度器每次从同一个信箱里连续取多少条消息。如果把吞吐量设得很大一个繁忙Actor会持续霸占线程其他Actor可能出现饥饿设得太小线程频繁切换缓存局部性又会变差。我一般在压测环境里从吞吐量5到20之间测试兼顾公平和吞吐。Erlang的调度器则每个调度器线程维护自己的运行队列配合reduction计数实现抢占式调度不同Actor按执行步数轮转效果更接近操作系统调度进程。下面是一段Akka dispatcher配置my-dispatcher { type Dispatcher executor fork-join-executor fork-join-executor { parallelism-min 8 parallelism-factor 3.0 parallelism-max 64 } throughput 5 }parallelism-factor通常按CPU核数计算4核机器上大约12个线程。对于大部分IO密集型服务这套默认配置够用如果消息处理里有重CPU计算再额外隔离一个dispatcher别让计算型Actor拖垮整个系统的交互响应。3.2 消息顺序、送达保证与不丢失的谎言Actor模型里消息是有序的但有序有明确边界同一个发送Actor发给同一个接收Actor的消息会按照发送顺序进入信箱。跨Actor、跨网络的全局顺序是不存在的这是分布式系统的固有约束想保证反而要引入昂贵的全局排序机制。送达语义上绝大多数Actor框架包括Erlang和Akka默认是至多一次消息要么到达要么因进程崩溃、网络分区丢失不会自动重试。这对很多从可靠消息队列转过来的开发者是个不小的冲击——他们习惯不会丢而Actor模型的说法是能接受丢失然后用监督和重建机制来兜底。这意味着如果你的业务逻辑需要严格保证这条消息必须处理完成且只处理一次光是发消息不够还得自己实现确认、重试和幂等。用Actor模型的正确姿势是把消息可能丢失当成设计前提而不是意外。比如在一个分布式支付场景里可以把账户状态做成Actor的持久化状态收到消息先落盘再应用重启后从最近的状态恢复做不到落盘的就要在业务层做好幂等让重复或丢失的影响降到可控范围。3.3 监督树与let it crash把错误处理从防御变成恢复Actor模型最容易被忽视、也最强大的部分是容错。在共享内存模型里一个线程抛异常可能只影响当前线程但如果状态被多个线程污染系统整体就不可信了。Actor模型的思路正好相反接受故障会发生把故障隔离在单个Actor内部然后通过监督树让父Actor决定是重启、停止还是升级。Erlang/OTP里这个机制叫supervisor它在进程树里监控子进程的生命周期。子进程崩溃时supervisor收到退出信号按策略重启它。比较常见的是one_for_one和rest_for_one前者只重启崩溃的那一个后者会把兄弟进程也按顺序重启保证它们状态一致。这就是let it crash哲学——与其写一堆防御性代码不如让失败的Actor带着它的状态一起消失再从一个干净的状态重新开始。这个设计给我最大的启发是代码的职责从永不失败变成了失败后能恢复。你会发现Actor系统天然像一台可自我修复的机器。配合后端的持久化即使一台机器宕机集群里的其他节点也能接管它负责的Actor消息。这也是为什么通信设备、游戏服务器这类对可用性要求极高的场景愿意选择Erlang/OTP这类Actor系统作为底座。4. 和主流并发思路摆在一起Actor、锁模型与CSP的取舍4.1 共享内存模型竞态、死锁与内存可见性代价全在开发者身上前面已经聊了不少共享内存模型的问题这里再给一个总纲式的对比。在锁模型里开发者既要应对业务逻辑又要时刻处理线程安全用锁保护什么、锁的顺序怎么定义、临界区多长合适、崩溃时锁会不会泄漏。每一次加锁都是在牺牲一定的并行度这是共享内存模型固有的借来的并发——你从并发中借了能力但每时每刻都要还利息。而Actor模型把并发基础设施私有化了每个Actor内部天然串行外部只能发消息。这就把并发时一半的问题在结构上消灭掉剩下的并发压力交给调度器。当然Actor模型不是没有代价消息传递有开销按消息粒度的原子性比共享变量的原子操作慢跨Actor协调也比直接改一个共享变量繁琐。但它换来的可维护性和可理解性在高复杂度业务里通常更值钱。4.2 别把Go的channel和Actor邮箱搞混了很多从Go转过来的朋友会把Actor邮箱和Go的channel划等号其实两者差别不小。CSP模型Communicating Sequential Processes以通道为中心Send和Receive往往是同步握手发送方发一条消息接收方不接发送方就阻塞。Actor模型则以Actor为中心每个Actor有自己独立的信箱发送方发完即走队列由接收方独自消费。一个像面对面递纸条一个像往对方私人信箱里投信。这个差别看起来很细但对设计影响很大。CSP里通道是显式的通信连接参与双方通过在某个通道上收发来同步Actor里通信关系是点到点的只要持有一个ActorRef随时随地给它投递消息不需要专门建立连接。所以在很多Actor框架里ActorRef就像一把地址钥匙可以复制、传递甚至发给另一个节点上的Actor。要说Actor和CSP哪个更好我的观点是各有所长CSP非常适合流水线、异步流处理这类结构Actor则更适合无状态的服务编排、分布式容错和高并发状态管理。4.3 Actor模型的适用边界它不是什么都能干看多了Actor模型的好话很容易把它当成万能解药。实际工程里我在三个场景不太会选Actor模型第一单纯追求极致吞吐的CPU密集计算比如在超大数组上做矩阵变换Actor的消息传递开销甚至大于计算本身不如直接用并行数组加SIMD第二需要频繁跨Actor联合更新的数据结构比如全局维护一棵大B树散成Actor之后反而更难协调第三简单的请求-响应微服务接口如果逻辑不复杂、状态不共享直接线程池加HTTP就能跑得很好。更准确地说Actor模型擅长的是并发单元之间有大量交互状态归属清晰且天然异构的场景。游戏服务器里每个玩家一个Actor、聊天室里每个房间一个Actor、交易系统里每个账户一个Actor都是教科书式用法。判断标准很简单你能不能把整个系统的可变状态拆成一组互不重叠的小岛如果能Actor模型很适合如果不能或者拆分后会让你在岛与岛之间频繁建桥那它就不值得用。5. 工程实战我在Actor框架里踩过的几个真坑理论讲完聊聊实际落地。我在三家不同业务的团队里用过Actor体系——一家做聊天网关一家做交易风控还有一家做游戏后端。说句实话纸上画消息流图总觉得完美一上生产全是细节。这一节我打算记录下自己反复踩过、也花最多时间调通的几个问题每一个都是线上真实出现过的。5.1 mailbox只进不出内存先爆为敬Actor信箱默认通常是无界队列这意味着如果某个Actor处理速度跟不上消息到达速度信箱会无限增长系统内存迟早被打穿。我第一次上线就犯了这样的错某个入库Actor突然遇到流量高峰处理一条要50毫秒而发送方每秒给它打几千条实时数据十几分钟后堆到几十万条内存直接顶上去了。解决办法有三个层面。第一监控给Actor的信箱长度加上监控队列深度超过阈值就告警这比等内存报警早得多。第二背压从源头限制消息速率比如把消息聚合、批处理或在接口层做限流让上游少发。第三配置容量Akka里可以给信箱设置容量上限和投递超时超时的消息会退回发送方返回失败供业务处理akka.actor.default-mailbox.mailbox-capacity 1000 akka.actor.default-mailbox.mailbox-push-timeout-timeout 10s本质上Actor模型把排队从无到有显式化了这块的背压设计和消息队列系统是一样的不能等到线上爆了再想对策。5.2 两个Actor互相等回复死锁换了个马甲前面说过Actor内部天然无锁但如果跨Actor使用同步等待死锁仍然会出现。我就被坑过一次Actor A用ask给Actor B发了消息然后阻塞等Future完成Actor B在处理请求时需要调用Actor CActor C为了完成某个功能又反过来ask Actor A。于是A等BB等CC等A三个Actor全部卡在ask的等待上和线程死锁如出一辙。只不过锁变成了信号量阻塞。更隐蔽的是自死锁当一个Actor在处理某条消息时用阻塞方式等待自己发出的ask结果而ask结果的投递恰恰需要这个Actor继续在信箱里处理后续消息时等待就不会被满足。因此我的经验是在一个Actor的处理逻辑里尽量别用阻塞性ask去等另一个Actor需要上下游协作时改成完全异步的消息链或给ask加一个足够短的超时。超时至少能保证失败而不是永久挂死。当一个Actor系统里同时存在大量ask调用时我建议专门列一份消息等待关系表一旦卡住先看这张表找环。5.3 优雅关闭、测试与选型从写Demo到上生产的三道坎第一道坎是优雅关闭。ActorSystem关停时正在信箱里排队、还在处理的消息都会中断直接kill进程会丢一堆消息。恰当的做法是先通知各Actor停止接收新消息、排空存量消息再手动触发关停最后给最后一波消息一个接收完的确认再释放资源。这个流程和一般服务优雅停机一样只是很多Actor框架文档没写细默认就是强杀。第二道坎是测试。Actor是并发的普通单元测试很难确定性断言。Akka测试包提供的TestActorRef可以直接同步调用Actor的receive逻辑Erlang也一样可以发消息给本地进程再断言语义。我的土办法是把Actor的纯逻辑拆出来写普通单元测试把消息编排单独做集成测试两者分开比纯对着Actor写一堆sleep后再断言靠谱得多。第三道坎是框架选型。Erlang/OTP适合需要极致容错、软实时、高可用的通信和游戏后端生态成熟但函数式语法有学习门槛Akka适合已经在JVM生态里的团队可以渐进式引入分布式集群设施完善Proto.Actor支持.NET和Go如果你不想被某一门语言绑死它也是个不错的选择微软的Orleans则把Actor升级成了虚拟Actor粒度更粗云原生友好。没有绝对的排名选型核心还是看团队已有技术栈和你能接受的容错模型复杂度。我个人的体会是用了Actor模型之后我的并发代码从到处找锁变成了画消息流图这几乎是质的变化。消息从哪里来去哪里谁负责处理处理不了怎么办——所有问题都变成了一张清晰的通信图排查问题也从翻栈找锁变成了看消息路径。如果你正在被共享内存并发的各种疑难杂症折磨建议先别急着在新项目里上锁拿一个小模块试试Actor式的消息驱动设计也许你会跟我一样对通信范式这四个字有完全不一样的理解。
返回列表