Go 19:WaitGroup、Once 与 atomic 各负责什么
上篇用一把 Mutex 保护任务记录的状态、结果和取消请求。现在服务还有三件性质不同的事:要等生产者和消费者整组退出,要保证同一份配置只初始化一次,还想从另一条路径读取“已有多少任务到达终态”。把这三件事都压到同一个原语上,往往会丢掉真正需要的约束。本章继续扩展同一个累计任务工程:生产者/消费者用 WaitGroup,配置用 Once,独立终态数用 atomic;记录的多字段提交仍由 Mutex 决定。完整工程及实验包与SHA-256可独立解包复跑。
独立复跑步骤列出本章正反例、源码校验及运行命令。
等整组退出,不等于请求整组停下
第 17 篇在队列里分别建了 producerDone 和 workerDone。本章让同一个队列在启动两个 goroutine 之前执行 workers.Add(2),生产者和消费者各自 defer workers.Done();调用方排空 outputs 后再 workers.Wait(),确保两者都完成自己的尾部工作。子统计 goroutine 仍由先前的单项 done 握手确认完成:WaitGroup 负责哪两个成员,应写在工程边界里,不能把没有加入这组的工作也算作“已经等过”。sync.WaitGroup 首次使用后不能复制,这里它在同一个调用栈内使用,没有作为值嵌进可复制的 TaskRecord。
顺序不能调反:消费者向无缓冲 outputs 发送时,需要调用方持续接收;如果在排空输出之前先 Wait(),消费者可能卡在发送处、生产者因输入缓冲占满也继续等待,整组没有人能调用完 Done()。现在 for range outputs 在消费者关闭输出后才结束,随后 Wait() 确认生产者也退出。发送端退出时仍由唯一生产者关闭 inputs,WaitGroup 没有替代 channel 的关闭所有权。
1 | |
WaitGroup 不是一个取消按钮:它不会让忽略 context 的分析器自行停止。TestCancelRequestWaitsForWorkerExit 沿用上篇的闸门:即使已经 cancel(),工作仍被测试闸门挡住,调用方不返回;放行并确认子统计退出后,组等待才结束并返回 context.Canceled。本次带新 WaitGroup 的同一测试实际通过并纳入 40 次受控复跑。信号表示“应停止”,等待表示“已经走到退出点”;二者不能互相替代。
初始化只执行一次,失败也不能悄悄重试
CLI 先解析 --max-words,随后创建本次运行独有的 taskSettings;Load 用 sync.Once.Do 包住 Config 初始化和校验,保留结果或错误。当前 CLI 实际只调用一次 Load,不需要用 Once 来证明单线程调用也能初始化;设计价值在于共享同一个 settings 对象时并发调用者得到同一份配置/错误,而不会分别看到不同阶段的数据。
TestSettingsOnceInitializesForConcurrentCallers 同时放行 64 个请求读取同一个 settings 对象,原始日志是 64 callers, initializer calls=1,所有读到 MaxWords=3。计数测试自身用 atomic 记录初始化函数被调用多少次,避免“证明初始化无 race 的测试”反而在测试代码里制造普通计数竞争。TestSettingsOnceRetainsInitializationError 先用返回错误的初始化函数调用 Load,再提供一个会让测试失败的“第二次初始化”函数;第二次仍得到第一次错误,并未重跑初始化。这里的 Once 只决定一生一次,不决定失败后按退避策略重试;如果配置来源有暂时性故障,需要另设计可替换的配置快照,而不是绕开 Once 复用这个对象。
本机 Go 1.27.1 的 go doc sync.Once 明确:初始化函数返回先于任何返回的 once.Do 调用建立同步关系。只靠“多数 goroutine 大概稍后才读”无法保证并发读到完整配置;这里依赖的是 Once 的同步契约而非测试碰巧打印了 1。taskSettings 与内部 Once、下文的 atomic 字段都放在指针持有的结构里,不在用过以后复制。
原子数字和任务快照不是一件事
TaskStore 仍用上篇的 Mutex 锁住 records map、状态判断、词数结果及 CancelRequested。新加的 atomic.Uint64 仅统计已到终态的任务数量:Complete、Fail 或确认后取消才加 1;RUNNING 时 Cancel 只把请求位设为 true,不加终态数;排队任务被取消可直接计数。一次测试先看取消请求后终态计数仍为 0、记录还是 RUNNING,再让工作结束得到 CANCELED,计数升到 1;另一条排队任务取消后变成 2;重复收尾仍是 2。原始断言在 TestIndependentAtomicCountDoesNotPublishResult,不依赖日志文字猜测计算是否已经退出。
这里 atomic 的职责是一个独立的标量读数。虽然当前更新计数时还处在状态锁的临界区,另一调用者可以用 TerminalCount() 不拿记录锁读取这个数字,但“先读计数、再 Lookup 一条任务”没有获得跨两次读取的共同快照,也不能从数字 2 推出某一个指定 ID 的终态。任务结果的一致性仍靠 Lookup 在一把锁下取出完整记录值。之后如果计数需要和数据库状态一同提交,还必须重新定义这条边界,不能让一个内存 atomic 冒充事务。
为验证 atomic 不能替换记录锁,18 的同一 TestStoreRaceScenario 故意用环境变量 TASKSTATS_UNSAFE_RACE=1 切到测试内部无锁替身;这一章替身的终态计数也已改成 atomic.Uint64。本机 Linux amd64/Go 1.27.1、CGO_ENABLED=1、gcc 13.3.0 下的 go test -race -count=1 -run '^TestStoreRaceScenario$' -v .(带该环境变量)仍出现 WARNING: DATA RACE,报告的是任务记录字段的冲突,进程退出 1。去掉变量,同一批 64 个竞争操作使用真实有锁 TaskStore,本轮退出 0、无 race 报告;两包完整 go test -race -count=1 -v ./... 也退出 0。无锁替身只活在测试文件里;不要把它当成生产曾经运行的不安全配置。原始 stdout、stderr、退出码及固定环境在 writing-plans/go-language/evidence/19/atomic-unsafe.*、atomic-safe.*、race-full.*。无报告仍只限这次跑到的路径,不是对未来所有调度的全称保证。
旧 CLI 契约继续实跑:编译后的 --batch 'Go|one two' 仍输出两条顺序结果,--json 的两条记录仍是 SUCCEEDED;--batch 'Go|' 与 --timeout 0s 各自仍按原契约退出 2、stdout 空且 stderr 可区分。go test -count=1 -v ./... 完整两包退出 0,初始化/计数/等待的受控测试重复 40 次退出 0。以上每个“实测”都有具体命令、双流和退出码,而不是将 RUN.md 中的教学复跑提示伪装为此次实际执行。
练习:若把 TaskStore 中的 Status 与 Words 都改成两个 atomic 变量,然后拿 WaitGroup 等 worker 结束,是否足以让并发查询总看见“SUCCEEDED 且 Words 正确”?答案:没有共同提交点就不够。WaitGroup 只约束显式等待的那个调用方在返回后的观察;不等待的查询仍可能夹在两个字段更新之间。单字段的原子性不等于一组字段作为业务记录的原子发布。应由同一把状态锁保护更新与查询,或设计一个一次发布的不可变结果对象并证明发布时序;此工程用前者。
本章仍只有单生产者、单消费者,没有满队列拒绝、容量限制与关闭协议的多 worker 验证;那是后续 20–21 的任务。HTTP 应答与后台任务完成、数据库提交与外部副作用,以及真实 SQL/HTTP 服务均 NOT_RUN。工具链 tag go1.27.1、源码镜像 SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 沿用基线,官方 Gitiles 交叉核验仍未完成;本章引用固定版本标准库公开 API 与原始测试,不据此推测调度内部实现或性能。其他平台、包含 19 的全站构建及发布也 NOT_RUN。
参考资料
sync.WaitGroup、sync.Once、sync/atomic.Uint64:本版go doc摘录与实际命令见writing-plans/go-language/evidence/19/。- Go 内存模型:Synchronization 与第 18 篇对照。实验边界和冻结版本见
writing-plans/go-language/research/19.md。






