04Go CSP 与 Rust 所有权
CSP channel 与 Actor mailbox 同源;Rust 把「状态私有」从运行时约定抬成编译期强制。
actor/mailbox.go:mailboxactor/pid.go:PIDActor 并非凭空长出来的模型。它与你早已熟稔的两门语言的核心设计哲学遥相呼应: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 有唯一地址/生命周期 |
| 失败处理 | 语言不内建(靠 context、errgroup) | 内建监督树、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 腾出空位、或者对方彻底停止为止。post 与 postBlocking 这对非阻塞/阻塞组合,恰好对应 Go channel 本身 select 的 default 分支与裸 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.Mutex、sync.RWMutex、atomic 一直都在标准库里,官方运行时自己的调度器、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 所有权系统的核心规则可以归纳成三条:
- 每个值有且只有一个所有者(owner);所有者离开作用域,值被自动丢弃(drop)。
- 借用规则:任一时刻,要么存在任意多个不可变引用
&T,要么恰好存在一个可变引用&mut T,二者不可兼得——这条术语叫 Aliasing XOR Mutability(别名与可变,二选一)。 - 移动语义(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 CSP | data race | channel 传递所有权 | 运行时(结构约定) | 需要重新组织为消息流 |
| Rust 所有权 | data race | Aliasing XOR Mutability | 编译期(强制) | 与 borrow checker 搏斗 |
| Actor | data 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 之间,还有一层更深的同构。