本章导读
第 28 章结尾预告了"另一条路线",本章兑现。回顾一下 28.7 用 cond 做生产者消费者要凑齐的三件套:一个队列容器存数据、一把 mutex 保护它、一个 cond 传达"有货了/有位了"的通知,外加 while 谓词循环防虚假唤醒——每件都对,拼起来才对一个,漏一件就是事故。channel<T> 把这三件事收进一个类型:它自带缓冲、自带互斥、自带等待唤醒,send/recvInto 两个调用就是全部协作协议。如果说第 28 章教你"怎么造锁",本章就是给你一把"已经造好的锁"——而且锁的钥匙孔设计得让你很难插错。本章依次讲:创建与容量(实测发现容量向上取整到 2 的幂)、六个收发操作、阻塞与关闭的完整语义、七种合法元素类型、与 thread 的经典配合模式,然后是标准库队列族四种(它们比 channel 更轻,但各司其职),最后用一个三级流水线实战收官,并复盘一次真实踩出来的流水线死锁。
会用 channel::<T>(容量) 创建通道并理解容量的 2 的幂取整规则;掌握 send/trySend/recvInto/tryRecvInto/recvTimeoutInto 五个收发操作与 count/isClosed 两个观察操作(全部返回 bool 的语义);理解关闭后的完整行为(拒绝发送、排空缓冲、唤醒阻塞接收、幂等);知道七种合法元素类型与 class/func 等被拒原因;会用"多生产者计数关门"与"while recvInto 优雅退出"两个模式;分清 SpscQueue/MpscQueue/MpmcQueue/WaitQueue 的方法集差异与适用拓扑;能识别并避开缓冲流水线死锁。
29.1 从锁到通道
并发编程有句老话:不要靠共享内存来通信,要靠通信来共享内存。第 28 章走的是前半句的反路——所有线程盯着同一块内存,用锁维持秩序。这条路没有错(28 章的数素数实战就是这么赢的),但它把"哪些数据共享、哪段代码持锁"的分析负担全压在你肩上,28.4 的三张竞态面孔就是分析失误的代价。
channel 走后半句:数据不再共享,而是从一个线程手里复制一份、递交到另一个线程手里。发送方送出去之后不再拥有那份消息,接收方拿到的是自己的副本——竞态的温床(共享可变状态)在结构上就不存在了。协作协议也简化成一句话:有货就收,没货就等;关门就走。
代价同样真实:消息要复制(29.6 与深入细节 2),吞吐受缓冲容量与调度影响。所以本章后半的队列族提供了"我知道我的拓扑,给我最轻的管子"的选项——单生产单消费就不必付多生产者竞争的同步成本。选型见 29.9。
29.2 创建与容量
声明类型 channel<T>,构造用泛型调用 channel::<T>(容量):
channel<int> ch = channel::<int>(2) // 显式容量 channel<int> def = channel::<int>() // 省略容量:默认 64 槽 auto autoCh = channel::<int>(1) // auto 推断同样可行
容量的真实语义是本章第一个实测发现:容量会被向上取整到 2 的幂,最小为 2;传 0 或省略等价于默认 64。逐档实测(用 trySend 打满计数):
| 声明容量 | 0(或不传) | 1 | 2 | 3 | 4 | 5 | 8 | 100 |
|---|---|---|---|---|---|---|---|---|
| 实际槽位 | 64 | 2 | 2 | 4 | 4 | 8 | 8 | 128 |
为什么取整到 2 的幂?因为底层是无锁环形缓冲,下标回绕用位与代替取余(深入细节 1)。写代码时不要依赖精确容量——把容量当"缓冲量级"而非"精确上限":想要紧背压就给小容量(1 也至少有 2 槽),想要削峰就给几十上百。
容量参数必须是整数,构造函数最多接收一个容量参数,违反均在编译期拦下(报错原文见常见错误)。
29.3 收发六操作
channel 的全部收发 API 是五个 bool 方法加一个超时变体,搭配两个观察方法:
channel<int> ch = channel::<int>(2) // 发送:阻塞版与非阻塞版 bool ok1 = ch.send(42) // 满则等待空间;已关闭返回 false bool ok2 = ch.trySend(43) // 满或已关闭立即返回 false // 接收:目标必须是可以写入的具名变量,类型与元素类型完全一致 int v = 0 bool got1 = ch.recvInto(v) // 空则阻塞等待;关闭且排空返回 false bool got2 = ch.tryRecvInto(v) // 空立即返回 false // 超时接收:ms < 0 等价阻塞,ms = 0 立即检查,ms > 0 限时等待 bool got3 = ch.recvTimeoutInto(v, 100) // 最多等 100ms // 观察 println(ch.count()) // 当前缓冲消息数(瞬时值) println(ch.isClosed()) // 是否已关闭
三个要点实测确认。第一,recvTimeoutInto 超时不碰目标变量:v 预置 999,空队列超时返回 false 后 v 仍是 999——可以安全复用接收变量。第二,ms 三态:-1 等价 recvInto 阻塞(实测另一线程 100ms 后送值成功接到),0 等价 tryRecvInto 立即检查,正数限时。第三,count() 是瞬时值:多线程下刚读完就可能过时,只能用于观测与调试,不能当同步依据——"count() > 0 所以 recvInto 不会阻塞"这种推理在并发下不成立。
所有接收变体的第一个参数都必须是具名、可写、类型精确匹配的变量:const 变量、闭包捕获变量、类型不符都会被编译期拦下(坑 3、坑 4)。这个限制是有意的——通道那头是另一个线程,写入目标必须是一块生命周期明确、独占可写的存储。
29.4 阻塞实录
"阻塞"不是形容词,是线程真的停下来等。两组实测为证。第一组:容量 2 的 channel 先填满,主线程再 send 第三个——它会卡在 send 里,直到 300ms 后消费者醒来取走一个腾出空间:
[main] filled, count=2 [main] send(3) begin (queue full) [consumer] woke up, start recv // 300ms 后消费者醒来 [consumer] got 1 // 取走一个,腾出空间 [main] send(3) returned true // 主线程的 send 此刻才返回
如果 send 不阻塞,"send(3) returned"会立刻出现在 begin 之后。实测顺序证明主线程老老实实等了消费者一手。
第二组:消费者在空 channel 上 recvInto 干等,主线程 100ms 后直接 close——close 会唤醒所有阻塞中的接收者,recvInto 返回 false,线程得以收拾退出(exitCode -1 是我们约定的"被关门"标记):
channel<int> ch = channel::<int>(1) auto waiter = func() as int int v = 0 if ch.recvInto(v) then // 空队列:阻塞在此 return v end return -1 // 被 close 唤醒走到这里 end thread t = thread(waiter) time.sleep(100) ch.close() println(t.waitTimeout(2000)) // true:线程被唤醒并退出 println(t.exitCode()) // -1
这组"关门唤醒"语义是 channel 优雅退出的地基:消费者不需要轮询、不需要退出标志位,while ch.recvInto(v) 一个循环就是完整的生命周期(29.7)。
29.5 关闭语义
close 之后的 channel 行为规则实测完整,四条全部可以直接依赖:
| 操作 | 关闭后的行为 |
|---|---|
| send / trySend | 立即返回 false,消息不进入队列 |
| recvInto / tryRecvInto(缓冲有货) | 照常接收——已入队消息继续排空,一条不丢 |
| recvInto / tryRecvInto(缓冲已空) | 立即返回 false,不再阻塞 |
| close(再次调用) | 幂等:第二次 close 无副作用,isClosed 保持 true |
这套"关而不丢"的语义正是广播式终止信号:生产者关门的瞬间,它等于向所有消费者宣布"货就这些了,收完下班"——已在队列里的货照样送达,消费者收完最后一条自然拿到 false 退出。
另外两条生命周期规则:channel 是 move-only 资源句柄(与 thread、mutex 同族),不能聚合进容器或当普通值复制;离开作用域时自动关闭队列并释放全部未消费消息——忘记 close 不会泄漏句柄,但等待中的消费者也就永远等不到那个 false 了,所以"谁关门"仍是设计的一部分(29.7 给出标准答案)。
29.6 元素类型
channel<T> 当前允许七种元素:bool、整数、浮点、string、time、point、var——共同点是都具备稳定的消息复制语义。实测全家福:
channel<string> cs = channel::<string>(2) string src = "hello" cs.send(src) src = "changed" // 发送后修改原变量 string got = "" cs.recvInto(got) println(got) // hello:队中消息不受影响 channel<var> cv = channel::<var>(4) cv.send(123) // var 通道可以混装类型 cv.send("abc") cv.send(1.25) var x = null cv.recvInto(x) println(int(x)) // 123 cv.recvInto(x) println(string(x)) // abc
复制语义是 channel 安全性的核心:普通值发送时复制进消息箱;string 发送时立即复制 UTF-8 字节、接收线程里重建;var 发送时提升为共享 xvalue、接收方接管新值。一句话——消息一旦出手,就与发送方再无瓜葛,这正是"靠通信共享内存"的落地方式(实现证据见深入细节 2)。
被拒之门外的是另一类:class、func、generator<T>、array<int> 等涉及复杂对象生命周期或 move-only 句柄的类型,语义阶段直接拒绝(坑 1)。class 是引用语义句柄(第 21、27 章),让它过通道等于重新引入共享——与 channel 的设计哲学相悖,所以干脆不许。想让两个线程共享一个 class 实例?那就回到第 28 章:传句柄走共享路线,自己配锁。
29.7 与 thread 的配合
channel 与 thread 是天作之合,两个模式覆盖八成场景。模式一,工厂闭包发 channel:channel 声明在顶层,工厂闭包捕获它(第 28 章的工厂模式原样适用):
channel<int> ch = channel::<int>(8) int producerDone = 0 mutex m = mutex() func makeProducer(int base) as func() as int auto p = func() as int for i in range(1, 6) ch.send(base + i) end m.lock() producerDone += 1 bool last = producerDone == 3 m.unlock() if last then ch.close() // 最后一个完成的生产者关门 end return 0 end return p end auto consumer = func() as int int v = 0 int sum = 0 while ch.recvInto(v) // 模式二:关闭且排空后 false,循环自然结束 sum += v end return sum end thread c = thread(consumer) thread p1 = thread(makeProducer(0)) thread p2 = thread(makeProducer(100)) thread p3 = thread(makeProducer(200)) p1.wait() p2.wait() p3.wait() c.wait() println(c.exitCode()) // 1545:三个生产者 15 条消息一条不差
模式二,while recvInto 优雅退出:消费者的整个生命周期就是一个 while 循环,没有退出标志、没有轮询、没有 sleep 试探。这两个模式合起来的要点是"关门的人要可数":单生产者直接关;多生产者用计数器认出自己的最后一个;生产者数量都不可数时,就由第三方(主线程 wait 完所有生产者后)关。
一个顺口提醒:for-in 遍历 channel 是不支持的(坑 5),while recvInto 就是官方的"遍历到关门"写法。
29.8 队列族四种
标准库还提供四个编译器识别的泛型原生队列:SpscQueue<T>(单生产单消费)、MpscQueue<T>(多生产单消费)、MpmcQueue<T>(多生产多消费)是有界并发队列,WaitQueue<T> 是支持阻塞与超时等待的等待队列。构造统一为 XxxQueue::<T>(容量):
MpmcQueue<string> q = MpmcQueue::<string>(64) q.tryPush("work") // 非阻塞投递,满返回 false string work = "" q.tryPopInto(work) // 非阻塞领取,空返回 false WaitQueue<int> waits = WaitQueue::<int>(64) int v = 0 waits.popInto(v) // 阻塞领取:空则等待(仅 WaitQueue 有) waits.popTimeoutInto(v, 1000) // 限时等待 1000ms
方法集实测有一个与文档口径的出入要钉死:阻塞式的 popInto/popTimeoutInto 只存在于 WaitQueue,三种无锁队列只有非阻塞的 tryPush/tryPopInto,调用 popInto 会吃 [sem] unknown queue method。完整方法集对照:
| 方法 | Spsc / Mpsc / Mpmc | WaitQueue |
|---|---|---|
| tryPush(value) | ✓ | ✓ |
| tryPopInto(out) | ✓ | ✓ |
| popInto(out) 阻塞 | ✗(unknown queue method) | ✓ |
| popTimeoutInto(out, ms) | ✗(unknown queue method) | ✓ |
| count / close / isClosed / isDrained | ✓ | ✓ |
其余语义实测:容量与 channel 一样向上取整到 2 的幂(1→2、3→4、5→8、100→128);isDrained = 已关闭且已排空——关闭但队列里还有残留时 isDrained 是 false,残留可以照常 tryPopInto 取出,取空后才变 true;close 后 tryPush 返回 false;close 会唤醒 WaitQueue 上阻塞的 popInto(返回 false)。元素类型支持 bool/整数/浮点/string/time 等,string 同样是复制语义;var 元素当前有坑——声明 Queue<var> 运行期段错误(退出码 139,check 阶段不拦),请勿使用(已按编译器缺陷上报)。
并发正确性实测:SpscQueue 单生产单消费 5000 条,消费和 12502500 分毫不差;MpscQueue 三生产一消费 3000 条,和 31501500 精确;MpmcQueue 四生产两消费 8000 条、加锁汇总 128004000,与理论值完全一致、队列 leftover=0。拓扑选对,它们就是即插即用的无锁通道。
29.9 选型对照
五条路线摆在一起,按拓扑与需求挑:
| 方案 | 阻塞等待 | 关闭广播 | 开销 | 适用场景 |
|---|---|---|---|---|
| channel<T> | ✓(还有超时) | ✓ | 中 | 通用线程通信,拓扑不定或需要优雅终止 |
| SpscQueue<T> | ✗ | ✓(close/isDrained) | 最低 | 固定一对一管道:解码线程→渲染线程 |
| MpscQueue<T> | ✗ | ✓ | 低 | 多源汇入单点:日志收集、事件上报 |
| MpmcQueue<T> | ✗ | ✓ | 中 | 工作窃取式任务池,消费者自旋领取 |
| WaitQueue<T> | ✓(还有超时) | ✓ | 中 | 要阻塞领取但不需要 var 元素的任务队列 |
| mutex + 容器 + cond(28 章) | ✓ | 手动 | 取决于实现 | 需要优先级、批量取出、自定义谓词等非标语义 |
经验法则三条。一,默认从 channel 起步:阻塞语义省掉自旋烧 CPU,close 广播省掉退出标志,代码最短。二,拓扑固定且性能敏感再上无锁队列:它们没有阻塞接收,消费者空转时得自己处理(自旋、sleep 或配合 sem)。三,需要"拿走消息时还要看一眼挑一挑"才回 28 章手工三件套——channel 与队列族的接口都是严格 FIFO,没有优先级与条件领取。
实战:三级流水线
把全章串起来:生产者线程发 20 个任务,三个工人从 jobs 领任务、平方后投进 results,主线程汇总。工人们在 jobs 关门后排空退出,最后一个退出的工人负责关 results——每个 channel 的关门人都"可数",全程无一处显式加锁保护数据:
channel<int> jobs = channel::<int>(8) channel<int> results = channel::<int>(8) int workerDone = 0 mutex m = mutex() auto producer = func() as int for i in range(1, 21) jobs.send(i) end jobs.close() // 发完即关门:通知工人退出 return 20 end func makeWorker() as func() as int auto w = func() as int int job = 0 int processed = 0 while jobs.recvInto(job) // jobs 关闭排空后退出 results.send(job * job) processed += 1 end m.lock() workerDone += 1 bool last = workerDone == 3 m.unlock() if last then results.close() // 最后一个工人关结果通道 end return processed end return w end thread p = thread(producer) thread w1 = thread(makeWorker()) thread w2 = thread(makeWorker()) thread w3 = thread(makeWorker()) int got = 0 int total = 0 int sumSq = 0 while results.recvInto(got) // results 关闭排空后退出 sumSq += got total += 1 end p.wait() w1.wait() w2.wait() w3.wait() println("results=" + string(total) + " sumSq=" + string(sumSq)) // results=20 sumSq=2870(1²+2²+…+20² = 2870,分毫不差) println("workers: " + string(w1.exitCode()) + "/" + string(w2.exitCode()) + "/" + string(w3.exitCode())) // workers: 12/4/4(任务谁抢到算谁的,负载自然不均但总量守恒)
对比第 28 章 cond 版的同类程序:没有了队列容器、没有了谓词循环、没有了 signal 时机斟酌,锁只剩一把(给 workerDone 计数用)。但别把"结构简单"误解为"随便连都不会死"——这个流水线在写成"主线程先发完 jobs 再收 results"时真实死锁过一次,复盘见深入细节 3。
深入细节
channel 与四种队列的容量取整(1→2、3→4、100→128)指向同一个实现:无锁环形缓冲。环形缓冲每次下标加一要回绕,通用写法是取余 (i+1) % n;而当 n 是 2 的幂时,取余可以换成位与 (i+1) & (n-1)——无锁队列的心跳路径上每一次读写都要做这步运算,位与比取余快且对 CPU 流水更友好。cgen 证据:channel::<int>(2) 编译为 xrtChannelCreate(iCapacity),容量原样传入 XRT 的 xchannel,取整在运行时库里完成。所以"声明 100 实得 128"不是 bug,是无锁实现的通行做法——代价是你要放弃对精确容量的假设。
channel 凭什么敢让两个线程不抢数据?cgen 生成的 C 代码给出答案:send 时运行库 xrtMalloc(iSize) 分配一个消息箱,memcpy(pBox, pValue, iSize) 把值整个复制进去,箱指针入队;recv 时取出箱指针、把内容写进你的目标变量、释放箱。队列里流动的从来不是"你的变量",而是"值的独立副本"——这就是 string 发送后改原变量不影响队中消息的原因,也是 class 被拒的原因:class 句柄指向共享对象,复制句柄不等于复制对象,放行就违背了消息语义。var 走的是另一条路:发送时提升为 XRT 共享 xvalue,接收方接管新值并释放旧值,配合引用计数保证跨线程生命周期。三种接收共用一个选择器 xl6_channel_recv_select:timeoutMs >= 0 走 xrtChannelRecvTimeout,否则按阻塞标志走 xrtChannelRecv / xrtChannelTryRecv。
本章实战的初版把生产放在主线程——"先 for 循环发完 20 个 jobs,再开始收 results"——结果真实挂死(timeout 强制终止,退出码 124)。死锁结构:jobs 只有 8 槽,工人领到任务后往 results 送,results 也只有 8 槽;主线程不发完 jobs 就不收 results,于是 results 很快填满,工人阻塞在 results.send;工人不取了,jobs 跟着填满,主线程阻塞在 jobs.send——主线程等工人腾 jobs,工人等主线程收 results,标准的环形等待。教训两条:流水线每一级的发送方与接收方必须同时活着(实战版把生产者也放进线程,主线程专职收 results,死锁立刻消失);缓冲容量不是保险丝——再大的容量也只是把死锁推迟到第 N 条消息。这与 28.9 的锁序死锁同源:凡是环形等待,不论环是用锁还是队列搭的,都会死。
⚠️ 常见错误
class Box int value = 0 end channel<Box> ch = channel::<Box>(1)
编译报错:bad.xl:5:29: [sem] channel<T> 当前只支持 bool、整数、浮点、string、time、point 等具备稳定消息复制语义的元素类型(第 5 行)。class 是引用语义句柄,复制句柄不等于复制对象;func、generator、array<int> 同样被拒。正确做法:跨线程共享 class 请走第 28 章的共享+锁路线;要走 channel,就把要传的字段拆成值消息。
channel<int> ch = channel::<int>(1) bool ok = ch.send("bad")
编译报错:bad.xl:2:19: [sem] channel.send 参数类型必须匹配 channel<T> 的元素类型: target int, value string(第 2 行)。channel 是静态类型通道,不做隐式转换。想混装类型,显式声明 channel<var>,接收端用 int()/string() 等显式取出。
channel<int> ch = channel::<int>(1) float value = 0.0 bool ok = ch.recvInto(value)
编译报错:bad.xl:3:23: [sem] channel.recvInto 目标变量类型必须等于 channel<T> 的元素类型: target int, value float(第 3 行)。目标类型必须与元素类型完全一致,int 通道配 float 变量也不行。另外两类常见不合格目标:const 变量报 [sem] const 不能被重新赋值;匿名函数里捕获外层变量当目标报 [sem] 捕获变量在匿名函数体内只读——在线程闭包里收消息时,先声明一个函数内的局部变量作目标。
channel<int> ch = channel::<int>(1) bool ok = ch.wait()
编译报错:bad.xl:3:14: [sem] unknown channel method(第 3 行)。channel 没有 wait——那是 thread 的方法。channel 的方法全集就是 29.3 的七个(send/trySend/recvInto/tryRecvInto/recvTimeoutInto/count/isClosed)加 close。同样的报错也会在给 SpscQueue 等无锁队列调 popInto 时出现([sem] unknown queue method,29.8)。
channel<int> ch = channel::<int>(4) for v in ch println(v) end
编译报错:bad.xl:4:10: [sem] for in iterable type is not supported(第 4 行)。channel 不是可迭代容器——"收到关门为止"的遍历要用 while ch.recvInto(v)(29.7 模式二),它天然在关闭排空后结束循环。
本章小结
| 主题 | 要点 |
|---|---|
| 创建 | channel::<T>(容量);容量向上取整到 2 的幂、最小 2,0/省略 = 64 |
| 发送 | send 阻塞(满等待、关闭 false);trySend 非阻塞(满/关闭立即 false) |
| 接收 | recvInto 阻塞;tryRecvInto 立即;recvTimeoutInto 的 ms<0 阻塞、=0 立即、>0 限时,超时不碰目标 |
| 关闭 | 拒绝发送、缓冲照常排空、排空后接收 false、唤醒阻塞接收、幂等;离开作用域自动关闭 |
| 元素类型 | bool/整数/浮点/string/time/point/var;class/func/generator/array<T> 被拒;发送即复制 |
| 配合模式 | 工厂闭包发 channel;多生产者计数关门;while recvInto 优雅退出 |
| 队列族 | Spsc/Mpsc/Mpmc 只有 tryPush/tryPopInto;WaitQueue 另有阻塞 popInto/popTimeoutInto;isDrained = 关闭且排空;<var> 段错误勿用 |
| 选型 | 默认 channel;固定拓扑性能敏感上无锁队列;非标语义回 mutex+cond 三件套 |
复习题
- channel::<int>(3) 实际有多少个槽位?channel::<int>(100) 呢?为什么实现要做这种取整?
- channel 关闭之后,send、缓冲未排空的 recvInto、缓冲已排空的 recvInto 各自什么表现?close 再调一次会怎样?
- send 与 trySend、recvInto 与 tryRecvInto 的区别各是什么?什么场景下 trySend 返回 false 比 send 阻塞更有用?
- recvTimeoutInto(v, ms) 的 ms 取负数、零、正数时分别是什么语义?超时返回 false 时 v 会被改动吗?
- 为什么 channel 拒绝 class 元素却能接受 string?从消息复制语义角度解释。
- 单生产单消费的固定管道,SpscQueue 与 channel 都能胜任,两者各付出什么、得到什么?
编程练习
建一个容量 4 的 channel<int>,慢消费者(每条 sleep 5ms)收 20 条;生产者先用 trySend 投 20 条,统计有多少次撞上"队列已满",再把失败的消息改用 send 阻塞补投,验证 20 条一条不丢。参考结果:快生产慢消费下实测撞满 10 次,改 send 后背压生效,produced=20、consumed=20。
用两个 channel<string>(ping 与 pong)实现主线程与工人线程的五个回合:主线程发 "ping#N"、收 "pong#N";工人用 recvTimeoutInto 收球(超时兜底 2000ms),收到第 N 球回 "pong#N"。主线程发完 5 球后 close(ping) 让工人退出。参考结果:5 个回合依次对齐打印,工人 exitCode=5。
用 WaitQueue::<int>(16) 实现:主线程 tryPush 任务 1..10,两个工人各用 popTimeoutInto(2000ms 兜底)领 5 个任务并加锁汇总。验证总和 55、两个工人各领 5 个。提示:WaitQueue 有阻塞领取,工人不需要自旋。