目录 · 第 4 / 16 章
ActorPart II · 跨范式对照

04Go CSP 与 Rust 所有权

CSP channel 与 Actor mailbox 同源;Rust 把「状态私有」从运行时约定抬成编译期强制。

actor/mailbox.go:mailboxactor/pid.go:PID

Actor 并非凭空长出来的模型。它与你早已熟稔的两门语言的核心设计哲学遥相呼应:Go 的 CSP 与它「同源双生」,Rust 的所有权与它「殊途同归」。把这层关系讲透,Actor 在你眼里就会从「一个框架」跃升为「一类并发世界观」的一支。

本章走三步:先看 Go 的 CSP 为何与 Actor 系出同源,再看 Rust 的所有权系统如何把同一套纪律从「运行时约定」抬升成「编译期语法」,最后把传统锁也拉进来,画一张贯穿四种方案的对照表。

Go 的 CSP:与 Actor 同源的另一条路

Go 的并发信条几乎人人会背:不要通过共享内存来通信,而要通过通信来共享内存(share memory by communicating)。这句话和 Actor 的「状态私有、只靠消息交互」是同一个思想的两种表述。Go 的理论根基是 Tony Hoare 1978 年提出的 CSP(Communicating Sequential Processes),而 Actor 是 Carl Hewitt 1973 年提出的模型——二者是并发理论里的一对表亲,都主张用消息传递取代共享内存,只是切入点不同:CSP 把管道奉为一等公民,发送方甚至不知道另一头是谁在收;Actor 把实体奉为一等公民,消息总是明确发给某个地址。

把两者摆在同一张表里,分野看得更清楚:

维度CSP(Go)Actor(Erlang/Akka)
一等公民channel(通信管道)actor(有地址的实体)
通信对象面向管道:发送方不知道谁在另一头收面向地址:明确发给某个 PID
耦合方式匿名、通过 channel 解耦具名、通过地址寻址
默认同步性无缓冲 channel 是同步的(发送阻塞到有人收)消息是异步的(发完就走)
身份goroutine 匿名,无内建标识每个 actor 有唯一地址/生命周期
失败处理语言不内建(靠 contexterrgroup)内建监督树、let-it-crash

一句话记牢:chan T 是一只无主的邮筒,谁都能投、谁都能取;而 mailbox 是某个 Actor 专属的收件箱,唯有它自己能取。

这里有个容易被忽略的细节:无缓冲 channel 的发送方,在没有接收方准备好之前会一直阻塞——这其实是一次「会合」(rendezvous),语义上比 Actor 更「同步」。而 Actor 的消息投递,语义上被设计成异步的:发出去就不再等待,哪怕底层实现拿一个带缓冲的 channel。mailbox「有界」的意义也不是为了强制同步,而是为了背压——防止发送方在接收方跟不上时,把内存无节制地堆爆。

📝 注意

别把「channel」和「mailbox」当成同一个东西的两个名字。无缓冲 channel 的发送方会真的停下来等,这是一次双方对齐的「会合」;而给 Actor 发消息永远是「扔了就走」——即便实现细节上,mailbox 内部也不过是一个带缓冲的 channel。这一点同步性上的差异,恰好对应上面表格里「默认同步性」那一行。

flowchart LR
  subgraph CSP["Go CSP:匿名管道"]
    direction LR
    P1["sender goroutine"] -->|"ch <- v"| Q1[("chan T<br/>有界队列 · 无主")]
    Q1 -->|"v := <-ch"| C1["receiver goroutine<br/>for range 串行消费"]
  end
  subgraph ACT["Actor:具名邮箱"]
    direction LR
    P2["sender actor"] -->|"pid.Tell(msg)"| Q2[("mailbox<br/>有界队列 · 属于该 PID")]
    Q2 --> C2["run() 循环<br/>串行消费"]
    C2 --> S2[("私有状态<br/>仅此一份")]
  end

同形不同魂:两边都是「有界队列 + 串行消费者」,区别只在收件箱有没有主人

用 Go 原语手搓一个 Actor

正因为同源,用 Go 的 goroutine 加 channel 就能拼出一个最朴素的 Actor——这也正是本笔记手写运行时的内核。剥掉监督树、泛型、可观测之后,一个 Actor 的本质就这几行:

// 一个最朴素的「Actor」:goroutine 持有私有状态,channel 当 mailbox。
func spawnCounter() chan<- int {
inbox := make(chan int, 64) // mailbox:有界 channel
go func() {
count := 0 // 私有状态:只有这个 goroutine 摸得到,天然无需锁
for delta := range inbox { // 一次处理一条:单线程语义
count += delta
}
}()
return inbox // 只把「投递口」交出去,等价于返回一个 PID
}

这段代码里,Actor 的四条金律全部自动成立:count 是闭包局部变量,语言层面没有任何路径能碰到它(状态私有);for range 串行取消息,加法永远不会并发执行,所以没有锁(单线程语义);调用方只拿到 chan<- int 这个只写端,读不到 count,只能投消息(靠地址通信);容量 64 的 channel 满了自然产生背压(mailbox)。

这四条金律不是笔者杜撰的黑话——它们几乎是 Carl Hewitt 1973 年论文里给 Actor 下的原始定义的直接翻译:一个 Actor 能做的事只有三件——①创建更多 Actor,②给别的 Actor 发消息,③为自己收到的下一条消息指定处理行为。spawnCounter 里,②对应 inbox <- delta(调用方发消息)与内部的 count += delta(处理消息),③对应那个 for range 循环体本身——它就是「下一条消息该怎么处理」的定义;①在这个玩具例子里被省略了,但只要在循环体里再调一次 spawnCounter(),这个 Actor 立刻就有了创建子 Actor 的能力——本笔记运行时里的 SpawnChild 正是这条公理的工程化版本。

本笔记的运行时正是把这个玩具模型做厚了一层。先看收件箱本身:业务消息不是直接塞进裸 chan T,而是先包进带 traceID 的信封,再进有界的 user channel;另有一条独立的 done 信号 channel,用来在关闭时唤醒所有阻塞在收发两端的 goroutine——这本身也是「用 channel 表达状态」的例子,而不是拿一个 bool 标志位加锁保护:

// mailbox[T] 是 Actor 的收件箱:一个有界、强类型的 FIFO 队列。
//
// 用带缓冲的 channel 实现,天然获得 FIFO 语义与 goroutine 安全,无需手写锁——
// 这本身就是「share memory by communicating」的示范。
//
// 「有界」是刻意设计,也是背压 (back pressure) 的根源:
// - post:非阻塞。队列满则返回 false(忙信号),由上游决定降速/丢弃/重试。
// - postBlocking:阻塞直到有空位或关闭。用于「宁慢勿丢」的可靠投递。
// 这与 Flink 的 credit-based 背压是同一思路:下游没有余量时,上游必须停下来等。
//
// 控制信令(重启/停止)走独立的 ctrl channel + 关闭信号,优先于业务消息处理,
// 保证「停止」这类指令不会被积压的业务消息饿死。
type mailbox[T any] struct {
user chan envelope[T] // 业务消息队列(有界)
done chan struct{} // 关闭信号:一旦关闭,post 立即失败,run 循环退出
cap int
}

真正体现「CSP 精神」的是 post——它不加任何锁,纯靠 select 的多路 case 做非阻塞判断:先看 done 是否已关闭,再往 user 里投,投不进去(说明队列满)就直接返回 false 当忙信号,把「怎么办」的决策权交还给上游调用方(重试?丢弃?降级?)。这正是 Credit-based 背压思想的微缩版:mailbox 从不会因为塞不下而拖垮整个系统,它只是诚实地告诉你「现在满了」:

// post 非阻塞投递。返回 false 表示 mailbox 已满(背压忙信号)或已关闭。
func (m *mailbox[T]) post(e envelope[T]) bool {
// 先判关闭,避免向已关闭 mailbox 投递。
select {
case <-m.done:
return false
default:
}
select {
case m.user <- e:
return true
default:
return false // 队列满:返回忙信号,交由上游背压处理
}
}

PID[T] 把「投递口」升级成强类型地址:你永远拿不到 Actor 对象本身,只能拿到它的 PID[T],然后调用 Tell(msg)。没有对象引用,就无法直接读写别人的状态——这是「状态私有」的物理保证。泛型参数 T 还让「地址」自带类型信息:SessionPID 就是 PID[SessionMsg],拿去投 WorkerMsg 编译器直接拦截,不必等到运行时才 panic:

// PID[T] 是一个 Actor 的「强类型地址」:只能往它投递类型为 T 的消息。
//
// 你永远拿不到 Actor 对象本身,只能拿到它的 PID,然后 pid.Tell(msg)。
// 这是「状态私有」的物理保证——没有对象引用,就无法直接读写别人的状态。
//
// 泛型参数 T 让「地址」自带类型信息:SessionPID = PID[SessionMsg] 无法接收 WorkerMsg,
// 编译器直接拦截。ID 字段是位置无关的路径(如 /user/session-abc),
// 分布式实现里同一个 ID 可指向另一台机器上的 Actor——调用方代码不变,即「位置透明」。
type PID[T any] struct {
id string
proc *process[T]
}

Tell 是最基本的通信原语,语义上完全对应「fire-and-forget」:把消息包进信封,调用 post,不等回应就返回。它的返回值同样诚实——false 不代表消息「静默消失」,而是立刻记入死信队列并计数,绝不允许「发了却不知道去哪」这种情况发生:

// Tell 异步投递一条类型为 T 的消息,不等待回应(fire-and-forget)。
//
// 这是最基本的 Actor 通信原语。方法接收者已绑定类型参数 T,因此当 T 是消息联合接口时,
// 可以直接传入任意实现了该接口的【具体类型】——编译器保证你不会发错类型的消息。
// 返回 false = 对方 mailbox 已满(背压忙信号)或已停止,此时消息进入死信队列并计数。
func (p *PID[T]) Tell(msg T) bool {
return p.deliver(envelope[T]{msg: msg})
}

如果业务场景「宁可慢,不可丢」(比如一次关键的状态迁移通知),TellBlocking 提供了另一半选择:阻塞到 mailbox 腾出空位、或者对方彻底停止为止。postpostBlocking 这对非阻塞/阻塞组合,恰好对应 Go channel 本身 selectdefault 分支与裸 send——mailbox 只是把这套 CSP 惯用法包了一层业务语义的皮:

// TellBlocking 与 Tell 类似,但当 mailbox 已满时【阻塞等待】直到有空位或对方停止,
// 而不是立刻返回忙信号。用于「宁可慢,不可丢」的可靠投递场景。
func (p *PID[T]) TellBlocking(msg T) bool {
if p == nil || p.proc == nil {
return false
}
if p.proc.mailbox.postBlocking(envelope[T]{msg: msg}) {
return true
}
p.proc.system.deadLetter(p.id, msg, "mailbox closed (blocking)")
return false
}

🔑 设计钥匙

CSP 说「通过通信来共享内存」,数据本身依然可以被讨论、被传递;Actor 把这条原则往前推了一步——不只是通信共享数据,而是把状态本身锁进某个实体内部,外界连读的资格都没有,只能发消息。Rust 则在编译期把这条边界钉死:稍后你会看到,“私有状态”从一条运行时的君子协定,变成了编译器强制的语法规则。

为什么 Go 不直接用锁

值得强调的是,Go 并非不能用锁,sync.Mutexsync.RWMutexatomic 一直都在标准库里,官方运行时自己的调度器、GC 内部也大量使用它们保护真正局部、性能敏感的临界区。但 Go 团队和社区的态度很明确:业务层面优先用 channel 表达「所有权转移」,把锁留给「保护一小块共享字段」的局部场景——比如给一个计数器套 atomic.Int64,而不是给整个业务状态机套一把大锁。

原因和 Actor 选择「状态私有」如出一辙:锁是一种非局部的约束。这块内存被哪把锁保护、加锁顺序是什么、忘记解锁会怎样——这些信息全靠人脑记忆和代码注释维持,重构时极易踩坑(经典的「锁的作用域随代码演进悄悄扩大,最后谁都不敢动」)。而消息传递把「谁有资格碰这块数据」变成了代码结构本身能表达、编译器能检查类型是否匹配的东西:调用方拿到的只是一个 PID[T] 或者一个 chan<- T,连「尝试碰数据」的语法入口都没有,更谈不上忘记加锁。

还有一层更微妙的:Go 的内存模型保证 channel 的发送 happens-before 对应的接收完成——这条规则让「把数据的所有权通过 channel 转移给另一个 goroutine」在语言规范层面是安全的,不需要额外的内存屏障或锁。代价是这条「发出去之后自己不该再碰」的约定,Go 编译器并不强制:你完全可以在 ch <- data 之后继续读写 data,编译器不会拦你,这纯粹是一份君子协定。Actor 把这条路走到了极致——干脆连「保护共享字段的局部锁」都不要,所有状态都私有,连「读」的资格都没有。Rust 则换了一条路:它让编译器把这条「发出去别再碰」的君子协定,变成语法规则——这正是接下来要看的。

Rust 所有权:从运行时约定到编译期强制

如果说 Go 的 CSP 与 Actor 是同源,那 Rust 的所有权系统与 Actor 就是殊途同归——它们要解决的是同一个敌人:数据竞争(data race),但手段截然不同。Rust 所有权系统的核心规则可以归纳成三条:

  1. 每个值有且只有一个所有者(owner);所有者离开作用域,值被自动丢弃(drop)。
  2. 借用规则:任一时刻,要么存在任意多个不可变引用 &T,要么恰好存在一个可变引用 &mut T,二者不可兼得——这条术语叫 Aliasing XOR Mutability(别名与可变,二选一)。
  3. 移动语义(move):把一个值传给别处(赋值、传参、跨线程闭包捕获),所有权随之转移,原变量立即失效,再用就是编译错误。
fn main() {
let mut state = vec![1, 2, 3];
// ① Aliasing XOR Mutability:下面两行不能同时存在
let r1 = &state; // 不可变借用
// let w = &mut state; // 编译错误:已有不可变借用时不能再可变借用
println!("{:?}", r1);
// ② move:所有权转移给另一个线程后,主线程不能再用 state
let handle = std::thread::spawn(move || {
// state 被 move 进来,现在这个线程是唯一所有者
println!("owned by worker: {:?}", state);
});
// println!("{:?}", state); // 编译错误:state 已被 move,「发出去别再碰」
handle.join().unwrap();
}

注意 move 那一步——它和 Actor「把消息发给另一个 Actor 后,自己不该再持有可变引用」是同一个动作。区别只在于哪一层负责兜底:Rust 让编译器在你违反规则时当场报错;Actor 让这件事在架构上根本无法发生,因为你手里始终只有 PID,拿不到对方数据的引用。

把镜头拉近到第二条规则,你会看到本章最关键的一次对齐:&mut T 在任一时刻全局唯一,这在语义上等价于「同一时刻只有一个执行体在修改这块数据」——这恰好就是 Actor 的单线程语义(一次只处理一条消息)想要保证的同一件事。换句话说:Rust 的借用检查器和 Actor 的 mailbox 加串行 Receive 循环,是同一条「单一写者(Single Writer)」纪律的两套实现——一个由编译器在编译期静态验证,一个由运行时架构在执行期天然保证。这正是本节标题「从运行时约定到编译期强制」的下半句:Rust 把「私有状态」从一条运行时约定,抬升成了编译器强制的语法规则。

现在把它和 Actor 逐条对照:

Rust 所有权Actor 模型共同本质
&mut T 全局唯一,同一时刻只有一个可变引用每个 Actor 的状态只被它自己的单线程 Receive 修改单一写者(Single Writer):任何数据在任一时刻只有一个修改者
move 后原变量失效,给出去就别再碰消息发出后不可变、发送方不再持有所有权转移:数据交出去了,就别再动它
编译器静态拒绝数据竞争运行时结构上杜绝共享,从而没有竞争消灭 data race,只是一个在编译期、一个在架构期

💡 小贴士

如果 Rust 里真的需要「共享可变状态」(比如多个线程都要碰同一份配置),Arc<Mutex<T>> 是标准的逃生舱——用运行时锁把编译期让渡出去的灵活性借回来一份。但这恰恰是 Actor 思路的价值所在:与其把数据包进 Arc<Mutex<T>> 到处传引用,不如干脆不共享它——把「需要修改这份状态」的请求做成一条消息,发给唯一持有这份状态的 Actor。Rust 生态里 Actix 之类的 actor 框架之所以顺理成章,正是因为「把消息 move 进 mailbox」与 Rust 的所有权转移在语义上天生同构,不需要运行时锁就能拿到共享可变状态的效果。

三者合流:一张对照表

把传统锁、Go CSP、Rust 所有权、Actor 放在一张表里,主线就非常清楚了——它们其实都在回答同一个问题:谁有资格在什么时候修改这块数据,只是把答案钉在了软件生命周期的不同阶段:

敌人核心手段保证时机代价
传统锁data race互斥锁保护临界区运行时(靠自觉)死锁、粒度难调、心智负担
Go CSPdata racechannel 传递所有权运行时(结构约定)需要重新组织为消息流
Rust 所有权data raceAliasing XOR Mutability编译期(强制)与 borrow checker 搏斗
Actordata race状态私有 + 串行处理架构期(结构杜绝)异步、消息建模成本

Actor、CSP、Rust 所有权,是同一场「消灭共享可变状态」战争里的三支不同兵种。想通这一层,你便明白为何用 Go 实现 Actor 如此顺滑——两者本就同源;也明白为何 Rust 社区能把 Actor 玩出花来——所有权与「单一写者」天然契合,&mut 的独占性从写下第一行代码起,就已经在替你做 Actor 想做的事。

小结

  • Go 的 CSP 与 Actor 同源双生:一等公民不同(CSP 认管道、Actor 认地址),但都用消息传递取代共享内存;channel 与 mailbox 骨架相同——有界队列加串行消费者,区别只在收件箱有没有主人。
  • 用 goroutine 加 channel 手搓的最朴素模型,已经自动满足状态私有、单线程语义、地址通信、mailbox 背压这四条金律,并直接对应 Hewitt 1973 年论文里 Actor 的原始定义;本笔记的 mailbox/post/PID/Tell/TellBlocking 只是把这层玩具模型做厚,把背压从裸 select 升格成诚实的忙信号。
  • Rust 所有权与 Actor 殊途同归:&mut T 全局唯一与 Actor 的单线程 Receive,本质上是同一条「单一写者」纪律的两种实现——一个由编译器在编译期强制,一个由架构在执行期保证。
  • 通向下一章:把这套「消灭共享可变状态」的思路搬到流计算世界——Actor 与 Flink 的 Keyed State、背压、Checkpoint 之间,还有一层更深的同构。
源码

正在读取完整文件…