ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Akka.NET 并发与分布式术语完全指南:从并发并行到无锁进度保证与死信机制

Akka.NET 并发与分布式术语完全指南:从并发并行到无锁进度保证与死信机制
  • 后端
  • 微服务

【免费下载链接】akka.net

Canonical actor model implementation for .NET with local + distributed actors in C# and F#.

项目地址:https://gitcode.com/gh_mirrors/ak/akka.net
点击查看免费下载

并发与分布式编程充满了容易混淆的术语:并发与并行、同步与异步、阻塞与非阻塞、死锁与活锁、竞态条件与无锁进度保证……这些概念不仅是理论名词,更是理解 Akka.NET Actor 模型(本地 + 分布式 Actor)之所以成立的底层逻辑。本文以 Akka.NET 官方文档 Terminology and Concepts 为核心骨架,结合本仓库的源码实现与配置,为你建立一套可以在 Akka.NET 文档语境中直接使用的精确定义,并延伸说明"消息传递可靠性""无锁算法强度层级""死信(Dead Letters)机制"如何在代码中落地。读完本文,你将能够:准确辨析 Actor 模型背后的并发概念;理解 Akka.NET 为何只承诺"至多一次投递 + 按序投递";掌握死信邮箱与DeadLetterListener的底层工作方式及akka.log-dead-letters配置调优。

术语说明:为什么需要一套"工作定义"

Akka.NET 面向的是并发、分布式系统。然而对于本节讨论的许多术语,业界并不存在唯一公认的定义。因此 Terminology and Concepts 给出的是一套"工作定义"(working definitions),其目的不是发起学术争论,而是为 Akka.NET 文档全篇建立一个坚实的共同语言基础。下文所有定义均以此为准,读者在阅读 Actor Systems、Actors、Message Delivery Reliability 等章节时会反复遇到这些概念。

并发(Concurrency)与并行(Parallelism)

并发与并行是相关但不同的概念:

  • 并发(Concurrency):两个或更多任务"都在推进"(making progress),即使它们并没有在真正同一时刻执行。典型实现方式是时间片轮转(time slicing):每个任务的一小部分被顺序执行,与其它任务的部分交错进行。
  • 并行(Parallelism):执行是真正同时发生的(truly simultaneous),通常需要多核硬件支撑。

对 Akka.NET 而言,并发性是 Actor 模型的内建属性:成千上万个 Actor 共享数量有限的真实线程,各自独立推进。从源码结构看,ActorCell被调度到Dispatcher上执行,而Dispatcher通常被多个 Actor 共享(默认调度器为线程池),这正是"许多 Actor 共享一个线程、单 Actor 状态仍保持单线程处理"这一承诺的实现基础,详见 Actors 中关于 State 的论述。

异步(Asynchronous)与同步(Synchronous)

  • 同步方法调用:调用方在方法返回值或抛出异常之前无法继续推进。
  • 异步方法调用:调用方在有限步数之后即可继续推进,方法的完成通过某种附加机制来通知,例如注册回调(callback)、Task或消息。

需要注意:同步 API 可能用"阻塞"来实现同步,但这并非必需——一个 CPU 密集型任务也可能表现出类似阻塞的行为。总体而言,应优先使用异步 API,因为它们保证系统能够持续推进。Actor 天然是异步的:一个 Actor 发出消息后无需等待投递完成即可继续前进。

在 Akka.NET 中,异步的核心入口是IActorRef.Tell。见 ActorRef.cs:Tell(object message, IActorRef sender)是"即发即忘"(fire-and-forget)的异步发送,调用方立即返回;而Ask模式则通过内部PromiseActorRef返回Task,本质上是注册了一个以"回复消息"为完成信号的回调机制,二者共享同一条投递路径(见 Message Delivery Reliability 中 "The General Rules")。

非阻塞(Non-Blocking)与阻塞(Blocking)

  • 阻塞(Blocking):如果某个线程的延迟可以无限期地拖延其它线程的推进,就称为阻塞。典型例子是互斥独占的资源:一个线程无限期持有资源(例如误入死循环),其它等待该资源的线程就无法推进。
  • 非阻塞(Non-Blocking):没有任何线程能够无限期拖延其它线程。

非阻塞操作优先于阻塞操作,因为一旦系统中存在阻塞操作,系统的整体推进就无法被平凡地保证。在 Akka.NET 中,Actor 代码应当避免阻塞(例如避免在 Receive 内直接Thread.Sleep或同步等待网络/数据库),这一点在 Actor Systems 的 "Blocking Needs Careful Management" 一节有专门论述:若必须执行阻塞调用,应将阻塞调用封装在由专用且足够大的线程池支撑的 Actor(或 Router 管理的 Actor 集合)内,或用带线程数上限的Task执行,而不是直接把阻塞塞进 Actor 的消息处理路径。

死锁(Deadlock)、饥饿(Starvation)与活锁(Live-Lock)

  • 死锁(Deadlock):多个参与者互相等待对方到达某个特定状态以便自身推进,形成"第 22 条军规"式的循环等待,所有受影响的子系统全部停滞。死锁与阻塞密切相关——死锁的前提正是参与者线程能够无限期拖延其它线程的推进。
  • 饥饿(Starvation):存在能够推进的参与者,但也存在一个或多个无法推进的参与者。典型场景是朴素的调度算法总是先选高优先级任务:如果高优先级任务源源不断,低优先级任务将永远得不到执行。
  • 活锁(Live-Lock):与死锁相似——所有参与者都无法推进,但参与者并非冻结在等待状态,而是持续改变自身状态。典型例子是两个参与者各有两个相同资源,它们都想获取资源,同时检查对方是否需要——如果对方需要就转而尝试另一个资源实例。最坏情况下两个参与者不停地在两个资源之间"弹跳",永远获取不到资源,却始终在向对方让步。

对 Actor 系统而言,Akka.NET 的邮箱模型本身就有助于规避上述三类问题:Actor 之间不共享可变状态、不互相持有锁,消息进入各自的邮箱队列,因而经典的锁死锁不会出现在 Actor 之间的交互层面。但若在 Actor 内自行引入锁、共享状态或阻塞式等待,死锁与饥饿的风险会立刻卷土重来。

竞态条件(Race Condition)

竞态条件指:关于一组事件顺序的假设可能被外部的非确定性效应所破坏。最常见的成因是多个线程共享可变状态,线程对状态的操作交错执行导致意外行为。但共享状态并非竞态条件的必要条件:例如客户端向服务器发送无序数据包 P1、P2,由于数据包可能走不同网络路由,服务器可能先收到 P2 后收到 P1;若消息本身不携带发送顺序信息,服务器就无法判断它们乱序到达——这也会产生竞态条件。

[!NOTE] Akka.NET 对"给定一对 Actor 之间发送的消息"提供的唯一保证是:顺序总是被保持。详见 Message Delivery Reliability。

这条提示正是术语章节与消息投递章节之间的关键纽带。消息传递可靠性章节将这条保证形式化为两条通用规则:

  • 至多一次投递(at-most-once delivery):不保证送达;
  • 按发送方–接收方对保持消息顺序(message ordering per sender–receiver pair)。

顺序保证只适用于发送方直接Tell到最终目的地的情况,且不具备传递性(transitive):A 发 M1 给 C、A 又发 M2 给 B、B 转发 M2 给 C,则 C 可能以任意顺序收到 M1 与 M2——因为不同网络路径的投递延迟不同。此外,该保证针对的是消息进入接收方邮箱(enqueue)的顺序;如果邮箱实现不遵循 FIFO(例如UnboundedPriorityMailbox),Actor 实际处理的顺序就可能偏离入队顺序。从源码看,默认邮箱正是 FIFO 语义的UnboundedMailbox(见 Mailbox.cs),而BoundedMailbox通过mailbox-capacity与mailbox-push-timeout-time两个配置项控制容量与入队超时(见 Mailbox.cs)。

非阻塞保证:三种"无锁"进度条件

阻塞有诸多弊端(死锁风险、吞吐下降),于是有了强弱不等的非阻塞性质。术语文档按强度从高到低给出三种:

无等待(Wait-Freedom)

一个方法如果每次调用都保证在有限步数内完成,就是 wait-free 的。若步数存在有限上界,则称为有界无等待(bounded wait-free)。由定义可推出:wait-free 方法绝不阻塞,因此不可能死锁;且每个参与者在有限步数后都能推进,因此也免于饥饿。

无锁(Lock-Freedom)

lock-free 是比 wait-free 更弱的性质:无限频繁地(infinitely often)有某个调用在有限步数内完成。该定义保证 lock-free 调用不会死锁,但"某个调用总能完成"不足以保证"所有调用最终都能完成"——换句话说,lock-free 不足以排除饥饿。

无阻碍(Obstruction-Freedom)

obstruction-free 是这里讨论的最弱的非阻塞保证:一个方法如果在某个时刻之后处于孤立执行状态(其它线程不再推进,例如被挂起),它就能在有限步数内完成。所有 lock-free 对象都是 obstruction-free 的,但反之一般不成立。

乐观并发控制(Optimistic Concurrency Control,OCC)通常就是 obstruction-free 的:每个参与者都尝试在共享对象上执行自己的操作,若检测到来自其它参与者的冲突,就回滚修改并按某种调度重试;只要存在某个时刻只有一个参与者在尝试,操作就会成功。

死信(Dead Letters):投递失败的兜底机制

术语章节强调"消息可能丢失"是 Akka.NET 的显式设计——它拥抱分布式计算,将通信的不可靠性通过消息传递显式化,而不是模拟一个"泄漏的抽象"。那么,当系统能够确定一条消息无法投递时,会发生什么?答案就是死信机制:无法投递的消息会被投递给一个合成 Actor,即akka://MySystem/deadLetters路径对应的DeadLetterActorRef。

死信的产生路径:DeadLetterMessageQueue

从源码看,死信机制的核心在 DeadLetterMailbox.cs。其中DeadLetterMessageQueue是一个特殊的消息队列:

  • HasMessages恒为false、Count恒为0——它不保留任何消息;
  • Enqueue一旦收到消息就立即转发为死信:_deadLetters.Tell(new DeadLetter(envelope.Message, envelope.Sender, receiver), envelope.Sender);
  • 若收到的消息本身就是AllDeadLetters类型则直接忽略,以防止递归转发造成栈溢出。

DeadLetterMailbox本身则通过BecomeClosed()保持"永远关闭",系统消息也会以DeadLetter形式转发。当 Actor 终止时,其邮箱中的所有剩余消息会被排空到死信邮箱(dead letter mailbox),之后该 Actor 引用内部被替换为系统邮箱,新消息也全部导向死信——这一过程是尽力而为(best-effort),因此不能依赖死信机制来实现"保证投递"。

死信数据模型:DeadLetter / SuppressedDeadLetter / Dropped / AllDeadLetters

DeadLetter.cs 定义了完整的死信消息层次:

  • AllDeadLetters(抽象基类):所有未投递消息的统一父类,携带Message(原始消息)、Sender(发送者)、Recipient(预期接收者)三个属性,ToString()输出形如DeadLetter from {Sender} to {Recipient}: <{Message}>;
  • DeadLetter:发送给已终止 Actor 的消息;
  • SuppressedDeadLetter:与DeadLetter类似,但默认死信监听器不会记录它(用于内部消息,其成为死信是预期且无害的);
  • Dropped:因队列溢出(overfull queues)或 Router 没有路由目标而被丢弃的消息,带Reason字段说明丢弃原因,并可通过IDeadLetterSuppression标记抑制记录。

如何接收死信

订阅死信的标准方式是通过 ActorSystem 的 EventStream。参见 event-bus.md 中的示例:创建一个监视 Actor,然后执行

system.EventStream.Subscribe(deadletterWatchActorRef, typeof(DeadLetter));

之后该 Actor 会收到系统内(本地)从订阅时刻起发布的所有DeadLetter。也可以订阅typeof(AllDeadLetters)一次性接收全部死信、Dropped与SuppressedDeadLetter。死信不会跨网络传播:若要在集群中集中收集,需要在每个网络节点各订阅一个 Actor 并手动转发;且死信是在"能够判定发送失败"的那个节点上产生的——远程发送时,可能是本地系统(无法建立网络连接),也可能是远程系统(目标 Actor 此刻不存在)。

死信日志与配置调优:DeadLetterListener

DeadLetterListener(见 DeadLetterListener.cs)是系统内置的死信日志 Actor:它在PreStart中订阅DeadLetter、Dropped和UnhandledMessage,收到后记录形如Message [...] to ... was not delivered. ...的 INFO 日志,并在日志末尾提示可依据akka.log-dead-letters与akka.log-dead-letters-during-shutdown调整或关闭该日志。其日志计数、暂停恢复等行为由三个配置项驱动,默认值见 akka.conf:

# on: 记录所有死信 # off: 不记录死信 # n: 正整数,最多记录 n 条死信 log-dead-letters = 10 # ActorSystem 关闭期间是否记录死信(仅当 log-dead-letters 启用时生效) log-dead-letters-during-shutdown = off # 达到 log-dead-letters 上限后暂停记录多久;infinite 表示永久暂停 log-dead-letters-suspend-duration = 5 minutes

从 DeadLetterListener.cs 的Receive实现可以看出三种运行模式:log-dead-letters设为int.MaxValue时始终记录;否则当计数达到上限后,若log-dead-letters-suspend-duration为无限期则停止记录,若是有限时长则进入"挂起"状态、到期后计数归零并恢复记录。默认值10意味着系统默认最多记录 10 条死信日志,防止刷屏。

哪些死信通常无需担心

并非所有死信都意味着 Bug。文档明确指出两种常见的良性死信:

  • 系统关闭的复杂场景中,Akka.Dispatch.Terminate消息被丢弃——这说明收到了两次终止请求,但只有一次能成功;
  • 停止 Actor 层级时,若父 Actor 仍在监视子 Actor 便先行终止,可能出现子 Actor 的Akka.Actor.Terminated消息变成死信。

死信服务遵循与普通消息发送相同的投递保证规则,因此它不能用来实现保证投递。

在更强的可靠性之上:从术语到实战

理解了上述术语后,就可以自然地回答一个高频问题:"Akka.NET 的消息到底可不可靠?" 答案是:基础层只提供"至多一次 + 按序"两种保证,这是刻意为之——提供更强的可靠性需要付出性能与状态成本,而不可能在事后反向移除可靠性来换取性能。如果业务确实需要更强保证,应在基础语义之上显式构建:

  • ACK–RETRY 协议:需要(1)为消息标识身份以便与确认关联;(2)超时未确认时的重发机制;(3)接收方检测并丢弃重复消息的能力。Akka.NET Persistence 模块的 At least once delivery 正是基于业务级确认的 ACK-RETRY 实现;另一种实现"去重"的办法是让消息处理幂等。
  • 事件溯源(Event Sourcing):Actor 处理命令时产生事件列表,事件既用于更新状态也被追加存储、永不变更,从而支持复制与重放重建状态。Akka.NET Persistence 提供了完整支持,见 Event-sourcing。
  • 带显式确认的邮箱(Mailbox with Explicit Acknowledgement):通过实现自定义邮箱类型,可以在接收端为处理临时失败而重试消息处理——这一模式在本地通信场景中最为实用。
  • Akka.Delivery:若需要比"至多一次"更强的投递保证,可参考 Reliable Message Delivery with Akka.Delivery。

面向本地发送的额外可靠性:谨慎使用

文档在讨论本地(进程内)消息发送时专门给出警告:不要依赖本地发送的更强可靠性,因为它会把应用绑定到"仅本地部署"——正确的心态是"设计一次,任意方式部署",只应依赖通用规则。本地Tell仍可能因StackOverflowException、OutOfMemoryException等SystemException失败,也可能因邮箱拒绝接收(如BoundedMailbox已满)或接收 Actor 已终止而失败;并且发送方不会收到处理异常的回执——异常通知会发给监督者(supervisor),从外部观察这往往与"消息丢失"无法区分。即使假定严格的 FIFO 邮箱,本地顺序的传递性保证也受制于若干微妙前提(例如顶层 Actor 首次回复前用于保护内部临时队列的锁并非公平锁、Router 构造期使用相同机制、以及任何涉及入队锁的自定义邮箱),文档明确提示这些前提可能被未来的性能优化所推翻。

小结:一张术语速查表

术语定义要点与 Akka.NET 的关系
并发 Concurrency任务交错推进,不必同时执行Actor 共享线程池、独立推进
并行 Parallelism任务真正同时执行多核下调度器可并行处理多个 Actor
同步 / 异步调用方是否需等待完成Tell即发即忘,Ask以 Task/消息完成
阻塞 / 非阻塞线程能否无限期拖延其它线程Actor 内应避免阻塞,阻塞需专用线程池
死锁 / 饥饿 / 活锁互相等待 / 部分无法推进 / 状态弹跳邮箱 + 无共享状态规避经典锁死锁
竞态条件顺序假设被非确定性破坏需共享状态,但网络乱序也可引发
无等待 / 无锁 / 无阻碍有限步完成 / 无限频繁完成 / 孤立时完成锁无关算法强度从强到弱的三档
死信 Dead Letters可判定的未投递消息进入/deadLettersDeadLetterListener+akka.log-dead-letters配置

这套术语是理解 Akka.NET 全部文档的公共地基:无论是阅读 Actor Systems 中的层级结构与监督策略、Actors 中的状态、行为与邮箱,还是深入 Message Delivery Reliability 中的投递语义,回到本节的定义都能找到一致的坐标。当你在日志里看到死信、在压测中遇到饥饿、在设计消息协议时权衡顺序与投递保证,本文给出的工作定义与源码证据就是你判断与决策的起点。

  • 后端
  • 微服务

【免费下载链接】akka.net

Canonical actor model implementation for .NET with local + distributed actors in C# and F#.

项目地址:https://gitcode.com/gh_mirrors/ak/akka.net
点击查看免费下载

相关推荐

上一篇:优雅构建层次化界面:Bootstrap TreeView深度解析与实战指南
下一篇:Android热更新技术终极指南:Tinker、Sophix、Robust实现原理深度对比

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进