Go 16:channel 的发送、接收与关闭怎样构成协议
上篇让统计函数在 goroutine 中运行,但 CLI 还是启动一项、等一项。要把任务交给另一个持续读取任务的计算者,仅有“某项结束了”的 done 不够:谁能继续发送?谁宣布不会再发?接收者如何知道该退出?关闭错了 channel,程序可能 panic;不关闭,又可能永远等在接收处。这些问题需要一份双方能遵守的协议,不是画一条 chan 箭头就解决。
本章沿用同一个任务工程和旧的任务 ID、错误分类及 CLI 入口。queue.go 增加一个生产者、一个消费者和两条 channel;CLI 在校验及输入准备后把任务交给生产者,最终排空结果,再生成原有记录。没有为本章另起一个互不相关的服务。完整累计快照、SHA-256和解包命令可独立下载复跑;原始命令、退出码和双流另存 writing-plans/go-language/evidence/16/。
首先约定唯一关闭者
任务路径如下:
1 | |
生产者是唯一向 inputs 发送的 goroutine,也是唯一关闭它的 goroutine。消费者通过 for task := range inputs 收到任务;生产者发送结束后关闭输入,消费者将已经在缓冲区里的最后一项收完,循环才结束。消费者是 outputs 的唯一发送者,也是它的唯一关闭者。CLI 只负责 for result := range outputs 排空,不会为了“让它快点结束”擅自关闭别人正在写的输入或输出 channel。defer close 放在各自唯一发送者的函数内,不能把这一写法不加分析地推广到多个生产者。
这是关闭所有权,而不是“谁最方便就关”。Go 语言规范对关闭后的接收有明确定义:已经送入的值先被接走,此后接收立刻返回元素类型零值;两值接收的 ok 为 false。range 利用这个结束信号退出。关闭也不是清空缓冲区;若把 inputs 关在仍有发送者运行的时候,稍后的发送会 panic。testdata/send-after-close.go 单独编译、运行后,stderr 为 panic: send on closed channel,直接运行的二进制退出 2;这个反例没有混入正常 CLI,也不靠吞掉 panic 假装成功。
什么叫“空”“满”“nil”“已关闭”
本版 Go 语言规范的 Send statements 与 Receive operator 分别约定等待条件。queue_test.go 不使用 Sleep 猜测阻塞,而用容量为 1 的通道和带 default 的 select 检查某个操作在检查时是否可执行。空的、尚未关闭的 channel 上没有值,接收不可立即进行;塞入 7 以后缓冲区已满,下一次发送不可立即进行。这只是本地瞬时可执行性,不代表可以对将来的调度先后做保证。
nil 的含义又不同:一个 var disabled chan int 没有指向已创建通道,在测试的 select 中发送和接收两条路径都不会准备好,进入 default。普通代码若直接在 nil channel 上收/发,则会一直阻塞,不能把它当作“空但是有人马上能填”的队列。某些状态机可用 nil 暂时禁用一条 select 分支,但本章并没有设计可取消的等待;到下一章才会给阻塞发送与接收加入取消协议。
最后关闭仍含 7 的通道:第一次接收到 7, true,第二次接收到 0, false。两个 0 的语义可能不同:如果生产者合法发送了整数零,两值接收会是 0, true;如果是排空后的关闭,才是 0, false。测试的日志按先后记下 empty: receive is not ready、full: send is not ready、nil: neither send nor receive is ready 和 closed: first value=7 ok=true; drained value=0 ok=false。四者不能只用“没拿到任务”笼统概括,尤其不能写 if value==0 就认为生产者已退出。
排空之后再处理最早的错误
processTasksUsing 从现有 []string 送入带原始位置的任务值,计算者仍调用上篇 analyzeTaskAsyncUsing 并等待其完成。一个持续运行的消费者按输入接收顺序发送结果;outputs 是无缓冲通道,所以若 CLI 不接收,消费者会停在发送处,生产者也可能因容量 1 而被反压。这是明确的握手,不是“关闭输入后所有任务瞬时完成”。CLI 将收到的结果按原位置放入切片,直到 outputs 被关闭才返回该切片,随后按位置依次选择错误或生成记录。当前只有一个消费者,不能借输出顺序声称以后增加多个 worker 仍天然有序;位置字段留出了以后对乱序结果重排的依据。
为什么碰到错误不马上返回?假设输入是 Go|,后一项被判空;如果接收者在看到这个错误时直接退出,而生产者还在发送、消费者还在 outputs <- ...,两边都可能卡住。现在先把已接受的输入逐项处理并排空结果,之后才复用 CLI 原有的错误分类与 stdout 暂存:--batch 'Go|' 的二进制 stdout 空、stderr 为 usage: taskstats: text must not be empty,退出 2。--batch 'Go|one two' --lookup 'task[missing]' 的 stderr 则为 task not found: task[missing],也是退出 2,但不是同一种错误。当前策略的代价是批次中前项失败后,后续任务仍会计算;下一篇的取消应明确决定是停止排队、请求已开始的工作退出,还是继续排空,不能只关一个 channel 就假定工作已停。
空任务切片也要走通关闭路径。TestQueueEmptyInputTerminates 直接以 nil 切片调用队列函数,生产者不发送、关闭输入,消费者发现输入已结束、关闭输出,CLI 收到空结果并返回。这不是 CLI 接受空文本:--batch '' 经业务校验仍报 text must not be empty。这里的空是队列内部的零项,文本空白是任务输入无效,两者验收目的不同。
TestQueueDrainsBeforeReturning 用闸门挡住第一项统计。测试已经收到 started 时,消费者尚未产生该项的结果,调用队列的函数不应返回;打开闸门后断言三项结果按位置到达,最后一项是 ErrInvalidInput。两包 go test -count=1 -v ./... 退出 0,闸门、空队列与四种 channel 状态重跑 30 次退出 0。编译后的 --batch 'Go|one two' 仍输出两行 task[0] words=1 bytes=2 runes=2、task[1] words=2 bytes=7 runes=7,退出 0;--json、批次增删和查询也保存了对应的双流与退出码。这些实测只证明覆盖到的路径,没有运行 race detector,不能据此宣称所有并发路径都安全。
容量为 1 只是 inputs 通道里暂存的任务值数量上限:--batch 已经先拆成完整 []string,结果也被全部保留到输出前。它不是整个程序的内存预算,不是 20 章的有界 worker pool,更不是满队列时明确拒绝提交的契约。这里生产者可能阻塞等待消费者推进,没有截止时间或取消出口。outputs 的关闭证实消费者执行到尾声;但取消请求、工作确实退出、HTTP 请求已经返回、任务作为后台工作仍在运行,各自需要独立证据,不能互相冒充。
练习:若把 close(inputs) 移到 CLI 的输出循环里,且消费者还可能向 inputs 发送,能否因为已经收到第一项结果就安全关闭?答案:不能。收到一个输出只确认相应的通信发生,不保证其他发送者全部结束;向已关闭 channel 发送将 panic。必须先撤掉其他发送者或等待它们确认退出,由确认所有权的一方关闭。更简单的本章设计是从一开始限定唯一生产者持有关闭责任,而 CLI 只接收。
本章仍仅有同一进程的一位消费者与可控测试数据。HTTP、SQL、请求取消、数据库提交及外部副作用均 NOT_RUN;未对未来的 HTTP 应答与任务完成作同一承诺。当前实测 Go 1.27.1/Linux amd64、GOMAXPROCS=2、CGO_ENABLED=0;全站构建、race 检测、其他平台及发布未验收。工具链源码 tag go1.27.1 与镜像 SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 沿用系列基线,官方 Gitiles 交叉验证仍 NOT_RUN;本章引用规范/内存模型而非 runtime 源码。下一篇在阻塞发送、接收和分段计算处引入 context,并把“发出取消请求”与“收到退出确认”拆开验证。
参考资料
- Go 语言规范:Channel types、Send statements、Receive operator、Close。本版文档对应
doc/go_spec.html:1688、:6106、:5346、:7548。 - Go 内存模型:Channel communication。固定版本摘录、实际命令和观察边界见
writing-plans/go-language/research/16.md与writing-plans/go-language/evidence/16/。






