跳到内容

第 9 卷 / 第 3 章 / 第 2 课

3.2 消息并发:Actor、Channel 与背压 ​

共享内存把问题表述为“谁可以访问这份状态”;消息模型换了一个角度:“谁拥有状态,其他执行体怎样提出请求”。这个转换能缩小共享范围,但不会自动消除排队、丢消息、重复处理和资源耗尽。

Actor:把状态和行为放在同一边界 ​

一个 Actor 通常包含三部分:

  • 只能由它自己直接修改的状态;
  • 接收消息的邮箱;
  • 根据消息决定下一步行为的处理逻辑。
text
调用方 ──Reserve(orderId)──> 库存 Actor
                               ├─ 私有库存状态
                               ├─ 去重记录
                               └─ 邮箱
调用方 <──Reserved / Rejected──┘

如果某个 Actor 一次只处理一条消息,那么单个消息处理过程不需要和同一 Actor 的另一条消息竞争状态。它提供的是状态所有权和处理顺序,系统整体仍然存在并发。许多 Actor 可以并行运行,多个发送者的消息也可能以不同顺序到达。

不要把 Actor 误解成以下绝对规则:

  • 本地消息不一定经过字节序列化;框架可能直接传递对象引用,因此消息最好保持不可变;
  • 消息发送成功不一定等于目标已经处理;
  • 顺序保证通常有明确范围。以 Erlang 为例,同一发送者发往同一接收者的信号保持发送顺序;选择性接收仍可先处理后到的匹配消息,也不存在多个发送者共同的全局顺序;
  • Actor 不等于无限邮箱。无界邮箱会把过载延迟转化为内存增长。

Actor 的工程重点:协议和故障 ​

Actor 以一组消息协议作为接口:

java
sealed interface InventoryCommand {}
record Reserve(String orderId, int quantity) implements InventoryCommand {}
record Cancel(String orderId) implements InventoryCommand {}
record GetAvailability() implements InventoryCommand {}

这些 Java 17 声明只表达消息形状,不是 Actor 实现,也没有校验字段。边界应检查订单 ID 非空白、数量为正,并拒绝同一幂等键携带不同请求内容。

协议设计要明确:

  • 消息是否幂等,重放会怎样;
  • 请求与响应怎样关联;
  • 处理失败后重试、跳过还是停止;
  • 邮箱满时拒绝、阻塞、丢弃还是降级;
  • Actor 重启后,状态从日志、快照还是外部存储恢复。

监督树是 Actor 生态常见的故障组织方式:父级观察子级失败,按策略重启或停止。它解决的是“谁负责处置失败”,不是让失败消失。若消息已经触发外部付款,再重启处理器,也不能自动判断外部副作用是否已发生。Outbox 能原子记录本地状态与待发送意图,最终接收方仍需去重或幂等键,才能避免重复付款。

Channel:把通信本身变成一等对象 ​

CSP 风格更强调独立执行过程通过 Channel 同步。发送者通常不必知道具体接收者,只需依赖 Channel 的元素类型和关闭协议。

go
type Job struct {
	ID  string
	URL string
}

func worker(ctx context.Context, jobs <-chan Job, results chan<- error,
	process func(context.Context, Job) error) {
	for {
		select {
		case <-ctx.Done():
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			if ctx.Err() != nil {
				return
			}
			err := process(ctx, job)
			select {
			case results <- err:
			case <-ctx.Done():
				return
			}
		}
	}
}

这是适用于 Go 1.22 或更高版本的 worker 片段,所在文件需要 import context 并传入 process 实现。process 和结果发送都必须响应取消;Channel 关闭由调用方管理,多 worker 共享 results 时,应等待全部 worker 结束再关闭。

签名表达了三件事:

  • jobs <-chan Job 只能接收;
  • results chan<- error 只能发送;
  • context.Context 承载取消和截止时间。

无缓冲 Channel 要等发送者和接收者同时就绪,通信同时形成同步点。有缓冲 Channel 允许生产与消费短暂错峰,但容量也定义了系统愿意吸收多少积压。

背压是稳定性协议 ​

假设入口每秒产生 2,000 个任务,下游只能处理 1,200 个:

text
积压增长率 = 2,000 - 1,200 = 800 个/秒

如果持续接收任务而不限制积压,任何有限内存最终都会耗尽。扩大队列只是延后故障。系统必须在以下策略中做出可观测的选择:

  • 阻塞生产者:把压力向上游传播;
  • 拒绝新任务:快速失败,并给调用方明确重试信息;
  • 丢弃或合并:只适合允许损失的遥测、刷新类任务;
  • 扩容消费者:前提是瓶颈确实能横向扩展;
  • 降级工作量:减少单个任务的成本。

粗略估算时,以稳定的 1,200 个/秒排空 2,400 个积压任务约需 2 秒。这不是逐请求延迟保证:任务大小差异、突发与下游停顿都会影响尾部等待。先按延迟和内存预算选一个容量,再实测排队时间与过载行为。扩大队列能存住延迟,不能把消费者催快。

Actor 与 Channel 不只是语法不同 ​

维度ActorCSP / Channel
核心抽象拥有状态和邮箱的实体执行过程之间的通信通道
寻址通常向某个 Actor 地址发送通常向某个 Channel 发送
状态归属状态封装在 Actor 内由使用 Channel 的过程决定
协调方式异步消息和行为切换同步或缓冲通信、选择操作
常见强项实体生命周期、监督、位置透明流水线、扇入扇出、背压
常见风险邮箱膨胀、协议演进、失败重放泄漏 goroutine、关闭权不清、环路等待

现实系统可以组合二者:Actor 内部使用 Channel 调度工作,流水线阶段用 Actor 承载持久状态。选择模型时看状态所有权和故障边界,不要按语言阵营站队。

关闭协议决定系统能否收尾 ​

生产者与消费者最常见的错误往往不在数据竞争,而在关闭权没有归属:

  • 通常由发送方关闭 Channel,接收方不应猜测“不会再有消息”;
  • 多发送方需要一个协调者,在全部发送方结束后关闭;
  • 取消与正常完成是不同语义,不能只靠一个空值混在一起;
  • 消费者退出后,仍在阻塞发送的生产者必须能收到取消,否则会泄漏。

分别测试两种情况:输入 Channel 保持打开却没有任务;任务已处理,但没有人接收 results。在每种情况下取消 context,再等待 worker 退出,都不应挂住;还要检查关闭 jobs 后能正常结束。results 外层的 select 解决了阻塞发送,但无法强行终止一个忽略 context 的 process。如果发送与取消同时就绪,任一分支都可能被选中;取消也不会撤回已经发出的结果。

完成检查 ​

为“图片处理流水线”画出:上传、病毒扫描、转码、入库四个阶段,然后标注:

  1. 每个阶段的并发上限;
  2. 队列容量及满载策略;
  3. 谁关闭队列;
  4. 某张图片失败时,是跳过、重试还是取消整条流水线;
  5. 如何防止同一任务重复入库。

如果图中只有箭头而没有容量、取消和失败路径,它还不是可运行的并发设计。

参考资料 ​

Built with VitePress | Software Systems Atlas