跳转到内容

第 20 章 · 通道与消息传递:queue、Channel、Go channel 与 select

解决什么问题:几个并发执行的部分要交换数据时,怎样不共享变量、不加锁,只靠“发一条、收一条”把它们串起来,并让下游知道上游什么时候结束。

四种语言的态度:Go 把通道和 select 做进语言;C# 用标准库 System.Threading.Channels;Python 用标准库的 queue.Queue 和 asyncio.Queue;JS/TS 没有通道原语,单线程里用异步生成器串流水线,跨 Worker 用 postMessage。

一条三段的流水线:生产者依次发出 1 到 5;中间的工人把每个数平方;消费者收集结果并求和。三段之间只通过通道(或队列)传递数据,不共享任何变量。生产者发完后关闭通道,工人取完剩下的数就关闭自己往下游发的通道,消费者据此结束并打印。只有一个工人,结果按发出的顺序到达,所以输出是确定的两行:收到:1 4 9 16 25 和 合计:55。

第 17 章用锁保护共享的计数器:几条线程都能碰到同一块内存,所以每次读写都要先拿锁。另一条路是干脆不共享:每份数据在某一时刻只属于一个执行单位,要交给别人时就“发送”过去,发出方从此不再碰它。这就是消息传递(message passing)。Go 的说法是“不要通过共享内存来通信,而要通过通信来共享内存”。好处是一段代码只要看自己收到了什么、发出了什么,不必关心别的线程此刻在改哪个变量;代价是每次交接都有同步开销,大块数据要么复制、要么交出所有权。

通道(channel)就是这条交接的管子,一端写、一端读,先写进去的先读出来。按有没有存储空间分两种。无缓冲(unbuffered)通道没有存储空间,发送方要等到接收方来取才能继续,一次发送同时也是一次会合(rendezvous)。有缓冲(buffered)通道带一个容量为 n 的队列,没满时发送立刻返回,满了才等。容量有限的通道叫有界(bounded)通道,满了就让生产者停下来,这叫背压(backpressure):下游处理不过来时,压力沿流水线往上游传,内存不会被积压的消息撑爆。无界(unbounded)通道从不让发送方等,也就没有背压,生产者比消费者快,队列就一直长。

流水线和关闭信号

上排是数据,下排是结束信号。生产者发完 5 之后关闭 numbers;工人读完通道里剩下的值,再读就得到“已关闭”,循环结束,它接着关闭 squares;消费者照样取完为止,然后打印。关闭不会丢掉已经在通道里的值,它的意思是“不会再有新值”。有的实现不支持关闭,就约定一个特殊的值,叫哨兵(sentinel),例如 None,读到它就当作结束,工人还要负责把哨兵往下传。无论哪种,忘了发结束信号,下游就会永远等下去。

消息传递有两个主要流派。Tony Hoare 1978 年发表通信顺序进程(Communicating Sequential Processes,CSP):若干顺序执行的进程只通过同步的输入、输出命令交换数据。1978 年的论文里,进程按名字指定通信对象;后来的 CSP(包括 Hoare 1985 年的同名著作)改成通过通道通信。Go 继承的是后一种,并把通道当成可以传来传去的普通值。Carl Hewitt 等人 1973 年提出 actor 模型,后来的研究补全成今天的样子:每个 actor 有一个地址和一个邮箱(mailbox),发消息就是把消息投进对方的邮箱,发送方不等待;actor 一次从邮箱取一条处理,可以改自己的状态、创建新 actor、再发消息。

CSP 和 actor 的结构

上半部分是 Go 一系的通道式 CSP:进程 A、B、C 互相不知道名字,只认通道;同一条 ch1 可以有好几个读者,谁先来谁拿到。下半部分里,消息要发给一个具体的 actor,它的邮箱只有它自己读,回信也要知道对方的地址。所以通道式 CSP 里通道是主角,适合搭固定形状的流水线;actor 里身份是主角,适合很多个各自有状态的对象互相发消息。

还有一个需要:同时等好几条通道,哪条先有数据就处理哪条,或者超时就放弃。这叫多路等待,Go 的语法是 select,CSP 里叫选择(alternative)。

Python 有两个标准库队列。线程之间用 queue.Queue,它的 put 和 get 是阻塞的,队列空时 get 会把当前线程挂起。协程之间用 asyncio.Queue,接口几乎一样,只是 put 和 get 要 await。两者在 3.13 之前都没有“关闭”,习惯做法是放一个哨兵。下面的主示例用线程和 queue.Queue,两个队列都设了容量 2。

import threading
from queue import Queue
DONE = None # 哨兵:约定好的"没有了"
def produce(out: Queue[int | None]) -> None:
for n in range(1, 6):
out.put(n) # 队列满了就在这里等:背压
out.put(DONE)
def square(inp: Queue[int | None], out: Queue[int | None]) -> None:
while (n := inp.get()) is not DONE:
out.put(n * n)
out.put(DONE) # 把结束信号往下传
numbers: Queue[int | None] = Queue(maxsize=2)
squares: Queue[int | None] = Queue(maxsize=2)
threading.Thread(target=produce, args=(numbers,)).start()
threading.Thread(target=square, args=(numbers, squares)).start()
results: list[int] = []
while (s := squares.get()) is not DONE: # 取完为止
results.append(s)
print("收到:" + " ".join(map(str, results)))
print(f"合计:{sum(results)}")
# 输出:
# 收到:1 4 9 16 25
# 合计:55

运行:python pipeline.py。Queue(maxsize=2) 是有界的,maxsize 默认为 0,表示无界。哨兵用 None;如果 None 本身可能是正常数据,就用 DONE = object() 这样独一无二的对象。3.13 起两种队列都有了 shutdown():之后 put 抛出 queue.ShutDown(asyncio 版是 asyncio.QueueShutDown),get 先取完剩下的元素,再抛 ShutDown,效果和关闭通道一样。

Python 没有 select。线程队列只能带超时等待:q.get(timeout=1) 超时抛出 queue.Empty。asyncio 里可以把几个 q.get() 包成任务,用 asyncio.wait(..., return_when=FIRST_COMPLETED) 等最先完成的那个,再取消其余的。

底层怎么实现。 queue.Queue 是纯 Python 写的:一个 collections.deque 存元素,一把 threading.Lock,加上三个共用这把锁的条件变量(condition variable):not_empty、not_full 和 all_tasks_done。get 发现队列空,就在 not_empty 上等,等待期间锁和 GIL 都释放;put 放进元素后通知 not_empty,唤醒一个等待者。asyncio.Queue 不用锁,它只在一条线程的事件循环里用:get 发现队列空,就创建一个 Future 放进等待者列表,然后 await 它挂起协程;put 先把值放进内部的 deque,再把最早的那个 Future 设为完成,它只负责唤醒;醒来的 get 自己从 deque 里取值,值不经过 Future。它不是线程安全的,别的线程要经过 loop.call_soon_threadsafe。跨进程用第 18 章的 multiprocessing.Queue。

  • 忘了发哨兵,或者某条路径上抛了异常没走到 put(DONE):消费者在 get() 上永远等下去,非守护线程也让进程退不出去。发哨兵的代码要放进 finally。
  • 有多个工人时,生产者要发和工人数一样多的哨兵,消费者也要数够个数才结束;少发一个,就有一个工人永远等着。
  • 把 queue.Queue 交给另一个进程:用 spawn 方式启动(Windows 和 macOS 的默认)时,start() 直接报 TypeError: cannot pickle '_thread.lock' object;用 fork 方式时不报错,子进程放进去的是自己那份内存副本,父进程永远收不到。

JS 语言里没有通道,也没有阻塞的 get:主线程一旦阻塞,整个事件循环就停了。同一线程里把几段异步代码串成流水线,最自然的写法是第 13 章讲过的生成器,换成异步版本:每一段是一个 async function*,用 for await 从上游拉值,用 yield 交给下游。跨 Worker 才有真正的消息:postMessage 发送,MessageChannel 建一对互通的端口。主示例在单文件里,用异步生成器。

export {}; // 让本文件成为模块,顶层才能写 for await
// 每一段是一个异步生成器:yield 交出一个值,函数返回就是"关闭"
async function* produce(): AsyncGenerator<number> {
for (let n = 1; n <= 5; n++) {
yield n; // 下游不来取,就停在这里
}
} // 走到末尾,下游的 for await 随之结束
async function* square(input: AsyncIterable<number>): AsyncGenerator<number> {
for await (const n of input) { // 取完为止
yield n * n;
}
}
const results: number[] = [];
for await (const s of square(produce())) {
results.push(s);
}
console.log(`收到:${results.join(" ")}`);
console.log(`合计:${results.reduce((a, b) => a + b, 0)}`);
// 输出:
// 收到:1 4 9 16 25
// 合计:55

运行:tsx pipeline.ts。第一行的 export {} 只是让 TypeScript 把文件当作模块,否则不允许顶层 for await。这里没有一个叫“通道”的对象,生成器本身就是下游看到的那一端。生成器函数返回,下游 next() 得到 { done: true },for await 结束,这就是关闭信号;生产者里抛出异常,异常会顺着 for await 传到下游,不会有人永远等着。拉取式的流水线天然有背压:消费者不调用 next(),生产者就一直停在 yield 上,相当于一条容量为 0 的通道。三段在同一条线程上轮流执行,不会并行。

需要缓冲时,有标准的 Web Streams:ReadableStream、TransformStream 和 pipeThrough,每段用 highWaterMark 设缓冲上限,Node 18 起是全局对象。要让各段在不同的核上并行,就放进不同的 Worker,用 postMessage 通信;new MessageChannel() 返回一对端口,一端发的消息在另一端的 message 事件里收到,端口本身也能随消息转交,让任意两个 Worker 直接接通。

底层怎么实现。 异步生成器对象内部有一个请求队列:每次 next() 返回一个 Promise 并排进队列,生成器从上次停下的 yield 继续执行,执行到下一个 yield 时用这个值完成队首的 Promise,再挂起。挂起和恢复与第 19 章的 async 函数是同一套机制。postMessage 则走另一条路:发送方用 V8 的序列化器把值按结构化克隆(structured clone)算法编码成字节,接收方所在的线程从自己的事件循环里收到消息,再解码出一个全新的对象。

  • postMessage 传的是副本:收到的对象改了,发送方的不变。函数、Symbol 不能克隆,Node 22 实测报 DataCloneError: () => 1 could not be cloned.;类的实例能克隆,但到了对面只剩一个普通对象,原型和方法都丢了,instanceof 为假。Map、Set、Date、BigInt、循环引用都能传。
  • 大的 ArrayBuffer 每次复制都要花时间,可以把它放进第二个参数的转移列表里,所有权直接转给对方,发送方手里那个变成长度为 0,再碰它就读不到数据了。
  • 端口上注册了 message 监听,又不调用 close() 或 unref(),Node 进程就不会退出:和第 18 章的 IPC 通道一样,活着的端口让事件循环一直有事可等。

C# 的通道在标准库 System.Threading.Channels 里。它 2018 年前后先作为 NuGet 包发布,从 .NET Core 3.0 起包含在共享框架里,现在不用另装。Channel.CreateUnbounded<T>() 建无界通道,Channel.CreateBounded<T>(n) 建容量为 n 的有界通道;Writer 负责写和 Complete(),Reader 负责读,ReadAllAsync() 把读端变成可以 await foreach 的异步流。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Channels;
using System.Threading.Tasks;
var numbers = Channel.CreateBounded<int>(2); // 容量 2:满了写方就等
var squares = Channel.CreateBounded<int>(2);
var producer = Task.Run(async () =>
{
for (int n = 1; n <= 5; n++)
await numbers.Writer.WriteAsync(n); // 满了在这里等:背压
numbers.Writer.Complete(); // 关闭:读方取完剩下的就结束
});
var worker = Task.Run(async () =>
{
await foreach (int n in numbers.Reader.ReadAllAsync()) // 取到关闭为止
await squares.Writer.WriteAsync(n * n);
squares.Writer.Complete(); // 把结束信号往下传
});
var results = new List<int>();
await foreach (int s in squares.Reader.ReadAllAsync())
results.Add(s);
await Task.WhenAll(producer, worker);
Console.WriteLine($"收到:{string.Join(" ", results)}");
Console.WriteLine($"合计:{results.Sum()}");
// 输出:
// 收到:1 4 9 16 25
// 合计:55

运行:dotnet run pipeline.cs。生产者和工人用 Task.Run 放到线程池上,三段可以并行。有界通道满了时 WriteAsync 不占线程,而是返回一个未完成的 ValueTask,有空位再继续。满了怎么办还可以选:BoundedChannelOptions 的 FullMode 默认是 Wait,另有 DropOldest、DropNewest、DropWrite 三种丢弃策略。Complete(exception) 可以带一个异常关闭通道,读方取完后会收到这个异常,这样上游的失败能传到下游。

C# 没有 select 语法。同时等两个读端,要对各自的 WaitToReadAsync() 用 Task.WhenAny,再 TryRead;超时则给 ReadAsync 传一个定时取消的 CancellationToken。

底层怎么实现。 CreateBounded 返回内部类 BoundedChannel<T>(本机反射看到的类型名),它用一把锁保护一个双端队列,外加两个队列分别记着正在等的读者和写者。CreateUnbounded 返回 UnboundedChannel<T>,元素放在无锁的 ConcurrentQueue<T> 里。等待者不是普通的 Task,而是可以重复使用的操作对象,实现 IValueTaskSource 接口,通过 ValueTask 交给调用方,所以稳定运行时一次读写通常不分配新对象。写入时如果正好有读者在等,值直接交给它并安排它的延续运行,不经过队列。无界通道设 SingleReader 时换用单消费者的实现;其余情况下 SingleReader、SingleWriter 只是提示。

  • 忘了 Complete():await foreach 永远不结束,程序挂在最后一段。生产者中途抛异常也会跳过 Complete(),所以应当写在 try/finally 里,或者在 catch 里调用 Complete(ex)。
  • 有界通道满了,WriteAsync 会一直等;如果读方因为某个错误已经不读了,写方就永远挂着。TryWrite 不等,满了直接返回 false。
  • Complete() 之后再写:TryWrite 返回 false,WriteAsync 抛 ChannelClosedException,实测消息是 The channel has been closed.。

通道是 Go 的内建类型:make(chan int) 建无缓冲通道,make(chan int, n) 建缓冲为 n 的通道;ch <- v 发送,v := <-ch 接收,close(ch) 关闭,for v := range ch 一直取到通道关闭且取空。select 是语句,可以同时等几个发送或接收,再加一个超时分支。

package main
import (
"fmt"
"strings"
"time"
)
func produce(out chan<- int) {
for n := 1; n <= 5; n++ {
out <- n // 无缓冲:等到有人接手才往下走
}
close(out) // 关闭:下游取完就结束
}
func square(in <-chan int, out chan<- int) {
for n := range in { // 取到关闭为止
out <- n * n
}
close(out) // 把结束信号往下传
}
func main() {
numbers := make(chan int) // 无缓冲:同步交接
squares := make(chan int, 2) // 有缓冲:最多先放 2 个
go produce(numbers)
go square(numbers, squares)
var results []string
sum := 0
timeout := time.After(time.Second) // 整条流水线最多等 1 秒
for {
select {
case s, ok := <-squares:
if !ok { // 通道已关闭,而且取空了
fmt.Println("收到:" + strings.Join(results, " "))
fmt.Printf("合计:%d\n", sum)
return
}
results = append(results, fmt.Sprint(s))
sum += s
case <-timeout:
fmt.Println("超时")
return
}
}
}
// 输出:
// 收到:1 4 9 16 25
// 合计:55

运行:go run pipeline.go。chan<- int 和 <-chan int 是只能发送、只能接收的通道类型,编译器据此保证 square 不会往 in 里写,也不会去关它。numbers 无缓冲,生产者每发一个都要等工人接手;squares 有两格缓冲,工人可以比消费者先走两步。消费者用 select 同时等结果和一个 1 秒的计时器:v, ok := <-ch 的 ok 为假,说明通道已关闭且取空。几个分支同时就绪时,select 按均匀的伪随机选一个,不偏向写在前面的分支。

底层怎么实现。 每个通道是运行时堆上的一个 hchan 结构:一把锁,一个环形缓冲区和读写下标,还有两个等待队列,分别挂着阻塞在发送和接收上的 goroutine。发送时如果接收队列里已经有 goroutine 在等,运行时把值直接复制到对方的栈上,然后把它标记为可运行,不经过缓冲区;没人等而缓冲区有空位,就放进缓冲区;都不行,就把当前 goroutine 挂进发送队列,第 16 章说过,挂起的只是这个 G,系统线程去跑别的 G。无缓冲通道的缓冲区大小为 0,所以每次发送都要么直接交给等着的接收者、要么自己排队等。close 把所有等着接收的 goroutine 唤醒,让它们拿到零值和 ok == false;等着发送的则在醒来后 panic。select 先随机打乱各分支的轮询顺序,再按通道地址的顺序统一加锁,避免两个 select 互相死锁;没有分支就绪时,把当前 goroutine 同时挂到每个通道的等待队列上,哪个先就绪就被哪个唤醒,再从其余队列撤下。

  • 向已关闭的通道发送会 panic:panic: send on closed channel;重复关闭也会 panic:panic: close of closed channel。所以规矩是只由发送方关闭,有多个发送方时由一个协调者等它们都结束(例如用 sync.WaitGroup)后再关。
  • 没人接收的发送永远阻塞:如果所有 goroutine 都卡住,运行时报 fatal error: all goroutines are asleep - deadlock! 退出;只要还有别的 goroutine 活着,就什么都不报,卡住的 goroutine 成了泄漏(第 19 章实测过 100 次调用留下 100 个)。下游提前退出时,要用 select 加取消通道或 context 让上游也退出,第 21 章细讲。
  • nil 通道上的收发永远阻塞。select 里把某个分支的通道设成 nil 就能关掉这个分支;但忘了 make 的通道变量也是 nil,用它会无声地卡住。
维度 Python JS/TS C# Go
支持程度 标准库 queue.Queue、asyncio.Queue 没有通道;异步生成器、MessageChannel 标准库 System.Threading.Channels 原生 chan、select
实现机制 双端队列 + 锁和条件变量 生成器挂起 + 微任务;跨 Worker 结构化克隆 锁或无锁队列 + 可复用的 ValueTask 等待者 运行时 hchan:锁 + 环形缓冲 + 等待队列
有界 / 无界 Queue(maxsize=n),默认无界 生成器容量为 0;Web Streams 用 highWaterMark CreateBounded(n) / CreateUnbounded() make(chan T, n),n 为 0 即无缓冲;没有无界
结束信号 哨兵;3.13 起 shutdown() 生成器返回;端口 close() Writer.Complete() close(ch)
多路等待 没有;asyncio.wait 模拟 没有;Promise.race 模拟 没有;WaitToReadAsync + Task.WhenAny select
跨线程 / 进程 线程用 queue,进程用 multiprocessing.Queue 同线程用生成器,Worker 间 postMessage 跨线程;跨进程要另找管道 跨 goroutine;跨进程要另找管道
背压 有界队列满时 put 阻塞 拉取式天然有;postMessage 没有 有界通道满时 WriteAsync 等待 缓冲满时发送阻塞

差异最大的是“多路等待”一行:只有 Go 有 select 语句,另外三种语言都要把等待包成任务或 Promise 再求“最先完成的那个”。

Go 把通道放进语言,是设计者多年积累的结果。Rob Pike 1980 年代末设计 Newsqueak,已经把通道当作一等值,还有 select 式的多路等待;贝尔实验室随后的 Alef(Phil Winterbottom 设计,用于 Plan 9)和 Limbo(用于 Inferno)延续了这条路。Go 2009 年发布时,goroutine 便宜到可以每件事开一个,通道和 select 就成了组织并发的主要工具,Go 团队还把它们写进了“不要通过共享内存来通信”这句口号。因为是语言的一部分,运行时能直接操作 goroutine 的栈和调度队列,一次交接只是一次复制加一次唤醒。

C# 走的是“语言给 async/await,结构交给库”的路线。线程池、Task、async/await 已经能表达“等一个结果”,缺的是“连续交接很多个值”,于是 .NET 用一个库把有界、无界通道和背压补上,读端再借 C# 8 的异步流接进 await foreach。多路等待没有语法,只能用 Task.WhenAny 组合。

Python 和 JS 的出发点是单线程的执行模型。Python 有 GIL,线程主要用来等 I/O,queue.Queue 早就是线程间交接的标准工具,asyncio.Queue 是它在事件循环里的翻版。JS 从一开始就是事件驱动的,主线程不能阻塞,Worker 之间只能复制消息,所以它的“通道”是 postMessage 这样的事件接口,而不是可以阻塞读的对象。Erlang 走的是 actor 式的路:电信交换机要求单个呼叫出错不影响别的呼叫,于是每个 actor 有独立的堆,靠消息联系。

这些选择带来几条后果。程序结构上,Go 程序常是一张由 goroutine 和通道连成的网;C# 和 Python 多是任务加少量队列;JS 则是一串异步生成器或一组事件处理函数。错误隔离上,进程或 Worker 之间只能复制消息,一方崩溃不会弄坏另一方的内存;同一进程里的线程和 goroutine 共用内存,一个 goroutine 的未恢复 panic 会让整个进程退出。调试上,问题从“谁改了这个变量”变成“谁没发、谁没收”:常见故障从数据竞争变成卡住不动。Go 在全部 goroutine 都卡住时会报死锁,部分卡住时要转储所有 goroutine 的栈去找;其余三种语言要看挂起的任务停在哪个 get 或 await 上。

Erlang 和运行在同一虚拟机上的 Elixir 是 actor 模型的代表。每个进程(Erlang 对 actor 的叫法)都很轻,有自己的堆和邮箱;Pid ! Msg 把消息复制进对方的邮箱,立即返回;receive 按模式从邮箱里挑第一条匹配的消息,不匹配的先留着,这叫选择性接收(selective receive)。邮箱没有容量上限,也就没有背压,消费者慢了邮箱会一直变长。

Worker = spawn(fun() -> receive {square, N, From} -> From ! {result, N * N} end end),
Worker ! {square, 3, self()},
receive {result, R} -> io:format("~p~n", [R]) end.

进程之间可以建立链接(link),一个崩溃,另一方收到退出信号,监督者(supervisor)据此重启出错的进程。“让它崩溃”的习惯正建立在进程之间不共享内存的基础上。

  • 数据有明确的流向(读取、处理、写出)时用通道串成流水线;只是保护一个计数器或缓存时,第 17 章的锁和原子操作更直接。
  • 每条通道都要有明确的关闭者:Go 由发送方 close,C# 由写方 Complete(),Python 由生产者发哨兵(3.13 起可用 shutdown()),而且放进 finally 或 defer,出错的路径也要发。
  • 默认用有界通道,让背压起作用;只有确定生产速度有上限时才用无界通道或 Erlang 式邮箱,并监控队列长度。
  • 多个工人汇到一条通道时,等所有工人都结束再关闭汇合通道:Go 用 sync.WaitGroup 加一个负责关闭的 goroutine,C# 用 Task.WhenAll 之后 Complete(),Python 数够哨兵的个数。

写一个“扇出扇入”(fan-out / fan-in):把 1 到 6 放进一条任务通道,3 个工人并发地从中取数、算平方,结果都发到同一条结果通道;消费者收齐后排序,打印 收到:1 4 9 16 25 36 和 合计:91。用 Python 和 Go 各写一遍。

提示:几个工人同时读一条通道,谁拿到哪个数是不确定的,所以结果要排序后再打印。Python 里要发几个哨兵给工人?消费者又怎么知道所有工人都结束了?Go 里结果通道该由谁、在什么时候关闭,才不会有工人往已关闭的通道里发?

  • 消息传递用“发送、接收”代替共享变量:数据在某一时刻只属于一方,交接靠通道或队列完成。
  • 无缓冲通道是一次会合,有界通道满了让发送方等,这就是背压;关闭表示“不会再有新值”,下游取完为止,忘了关闭下游就永远等下去。
  • Go 一系的通道式 CSP 里通道是独立的值,进程不必知道彼此;actor 里每个 actor 有名字和邮箱,消息发给具体的对象。
  • Python 用 queue.Queue 和 asyncio.Queue 加哨兵;JS/TS 同线程用异步生成器、跨 Worker 用 postMessage(传副本);C# 用 System.Threading.Channels 和 Complete();Go 用 chan、close、for range 和 select。
  • Go 把通道和 select 做进语言,来自 Newsqueak、Alef、Limbo 的积累;C# 用库补齐;Python 和 JS 以队列和事件为主;Erlang 走 actor 式的路。后果体现在程序结构、错误隔离和调试方式上。