原来的顺序 CLI读取文本后,统计、生成任务记录、输出结果依次发生。把 analyzeTask 前面加上 go,看似只改一个关键字,却把调用方原本拥有的“等统计结束”这项责任删掉了:go analyzeTask(...) 不会把两个返回值交回当前函数,也不会等统计结束。于是问题不只是如何启动计算,而是怎样知道可以安全地写出 SUCCEEDED。

本章仍修改同一累计工程的 CLI:每项任务的统计交给一个 goroutine,调用者显式等待该项工作发出完成信号,然后继续沿用先前的错误判断和记录逻辑。完整累计工程和 SHA-256 校验附在正文素材目录;正文所述的实际实验命令、双流和退出码在 writing-plans/go-language/evidence/15/。这里没有把每个编号做成独立项目。

独立复跑步骤列出本章正反例、源码校验及运行命令。

启动信号不足以发布结果

先看独立反例 testdata/async-without-wait.go。计算者启动后关闭 started,随后卡在 <-release。主流程观察到 started,却没有放行计算者;此时查看 done,只能得到“还没完成”。这不是靠 Sleep 赌时序:闸门没打开,计算者按程序结构无法走到关闭 done 的语句。

本机 Go 1.27.1、Linux amd64 下运行 go run ./testdata/async-without-wait.go,stdout 原样为:

1
2
started does not mean completed
completion confirmed

stderr 为空,退出码 0。第二行是在 close(release) 且 <-done 返回之后才打印;第一行不是失败的命令,而是“启动确认不等于完成确认”的反例。如果这里的主流程不等待 done 而直接 return,进程可能在计算结束前退出。即使某次输出恰巧包含结果,也不能反推所有运行都按同一调度顺序结束。Go 语言规范的 Go statements 只规定新 goroutine 独立执行,调用方不等待被调用函数返回;没有承诺哪个 goroutine 下一步先执行。

之前按位置产生的 task[0]、task[1] 是提交顺序,不是 goroutine 执行顺序。即便以后一次启动多个任务,也不能用索引当作完成的证据。本章故意没有并行派发整个批次:runWithInput 的 for 循环一次发起一项并等待一项,因此当前实测输出有顺序,并不证明 Go 调度总是顺序的。

谁发出完成确认

async.go 提供 analyzeTaskAsyncUsing:

1
2
3
4
5
6
7
8
9
done := make(chan struct{})
var counts stats.Counts
var err error
go func() {
defer close(done)
counts, err = analyzeTaskUsing(text, maxWords, analyze)
}()
<-done
return counts, err

在这一处,一个工作者独占其 done,关闭只发生一次;调用者只等待,不替工作者关闭。analyzeTaskUsing 仍负责空文本、词数限制和 ErrInvalidInput 的包装;外层只改变计算的执行位置,不偷偷更换业务契约。真实 CLI 的循环现在调用 analyzeTaskAsync,其余结果组装、批次索引、JSON 与 stdout 错误路径继续复用旧代码。

数据先写入该次调用自己的 counts、err,之后工作者的 defer 关闭 done,调用者 <-done 返回才读取两个值。这个顺序有同步依据:Go 内存模型规定,关闭 channel 同步于因已关闭而完成的 receive。只靠“两个 goroutine 大概先后运行”来解释这里的读写是不够的。完成信号确认此前的统计和错误已可见;它并非证明操作系统线程已经销毁的测量,更不能代表无关后台任务都已停止。当前代码也没有异步公开返回 handle 供稍后查询,CLI 仍要等每项任务统计完才输出。

1
2
3
4
5
调用者                         工作者
创建 done ── 启动 ──────────→ 分析输入
等待 <-done 写 counts / err
读取结果 ←── close(done) ──── defer 发出完成信号
记录终态并输出

测试 TestAsyncWaitsForWorkBeforePublishingResult 让注入的分析函数先关闭 started,再等 release。测试接收到 started 后立即非阻塞检查调用者的 returned;因为分析器还在闸门里,调用者此时返回就必然是不合约的。随后放行,等待 returned,最后读取 stdout 与 stderr。测试日志先出现 started: worker blocked; caller has not returned,再出现 released: code=0 stdout="words=3 bytes=9 runes=7" stderr=""。这个测试的职责是验证特定握手,不是测量调度耗时;同一握手连续运行 30 次退出 0,也不能扩展为所有并发路径安全的证明。

错误和退出码仍归调用方管理

任务输出的 SUCCEEDED 只在收到完成信号、统计返回 nil 错误后设置。把状态在 go 启动处就标成成功,会在计算尚未退出时制造一条假的终态;本章没有这么做。反向路径上,TestAsyncPreservesValidationErrors 断言超限词数仍为 ErrInvalidInput,且 counts 保留词数 2;既有 CLI 测试再次覆盖空批次、缺失查询和正常批次。构建后二进制 --batch 'Go|one two' 实测输出 task[0] words=1 bytes=2 runes=2 和 task[1] words=2 bytes=7 runes=7,stdout 两行、stderr 空、退出 0。

同一二进制的 --batch 'Go|' 退出 2,stdout 空、stderr 为 usage: taskstats: text must not be empty;--batch 'Go|one two' --lookup 'task[missing]' 也退出 2,但 stderr 是 task not found: task[missing]。两者不能仅凭退出码归为同一种错误。两包完整 go test -count=1 -v ./... 与构建都退出 0;--json 批次输出仍有两条 SUCCEEDED 记录。具体命令、环境、原始字节输出与退出码分别保存在 evidence/15/tests.*、build.*、batch.*、invalid.*、missing.*、json.*;正文中的片段不是用“预期输出”伪装成实测。

这套等待只覆盖正常返回。若统计函数在新 goroutine 中 panic,外层调用者原有的 recover 不能跨 goroutine 捕获;本章没有增加一个掩盖错误的全局 recover。正因先前的 panic 与清理实验只针对同一调用栈,不能拿来声称这个新路径也能恢复。长期驻留的 worker、调用方取消、服务关闭以及缓冲/多生产者下谁能关闭 channel,都需要新的所有权协议。现在的完成信号不能充当 context 取消:发出请求与真正停下是两种事实。

练习:把上例改为每次 go 启动后立即打印 SUCCEEDED,然后再 <-done。能否说“反正最终也等了,不会错”?答案:不能;输出发生在等待之前,外部观察者已经拿到成功状态时计算尚可能被闸门挡住。应把结果发布和终态提交放到完成确认之后,或明确定义 QUEUED/RUNNING 等非终态。即使把 done 放在未来的 HTTP handler 中等待,也只是在处理这次请求时等待;HTTP 应答成功、请求生命周期结束与后来可能继续运行的后台任务结束不能自动画等号。

当前验收边界是 Linux amd64/Go 1.27.1、同一 CLI 调用中逐项受控计算。CGO_ENABLED=0 的这轮没有运行 race detector;测试没报 race 不等于全路径安全。HTTP、SQL、请求取消与数据库提交后的外部副作用均 NOT_RUN;发布也 NOT_RUN。源码 tag go1.27.1、镜像 SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 沿用 00,官方 Gitiles 交叉验证仍未完成;本章依据规范和内存模型,不据此猜测 runtime 调度。下一篇在这一个工程中设计输入 channel 的发送、排空与关闭所有权,再给取消添加独立的退出确认。

参考资料