Go 18:共享任务状态怎样避免竞争与终态反转
第 17 篇已经把取消请求和退出确认分开。另一个问题随之出现:worker 正在写任务结果时,查询者或取消者同时读写这个 ID,该读到 RUNNING 还是 SUCCEEDED?如果状态已写成 SUCCEEDED,结果的词数却还是旧值,终态就不再是一条完整事实。单纯“让多个 goroutine 都能访问 map”并不构成服务契约。本章在同一个累计工程增加内存状态提交点,并且保留有锁、无锁同一竞争场景的原始证据。工程与实验完整包、SHA-256可独立下载复跑。
独立复跑步骤列出本章正反例、源码校验及运行命令。
一条任务记录有几个需要一致的字段
从上一阶段沿用的 TaskRecord 有 ID、Status、统计的 Words/Bytes/Runes。本章再加 CancelRequested,它只表示有人提出请求,不能代替 CANCELED 终态。新 TaskStore 把按 ID 查询的记录保存在 map 中,sync.Mutex 放在 store 上,而不是嵌进会被复制的记录值。go doc sync.Mutex 明确说 Mutex 首次使用后不能复制;Lookup 在锁内取出记录并返回值副本,调用者改副本不可能绕开锁修改 store。
状态路径在单个锁保护下检查前态、修改相关字段、回写 map:
1 | |
Start 只有见到 QUEUED 才成功。Complete 只接受 RUNNING,在同一锁内写完结果和 SUCCEEDED;如果此前的 CancelRequested 已经为 true,晚到的完成被拒绝,同一个提交点转入 CANCELED。Fail 对未被取消的工作写 FAILED,对已收到取消请求的工作也由提交点写 CANCELED。取消一个尚未开始的任务可直接提交 CANCELED,因为不存在正在执行的 worker;取消 RUNNING 只置请求标记,任务仍是 RUNNING,直到相应工作真的结束。已经进入 SUCCEEDED、FAILED 或 CANCELED 的记录不能再被 Start、Complete、Fail 或 Cancel 推翻。
当前队列仍只有一位消费者。在取到任务后,它通过 store.Start 领取,调用之前章节的可取消统计;完成或失败时调用对应的提交方法。CLI 在生产者/消费者退出并排空结果后从 store 读取快照,维持之前的文本与 JSON 输出。TestStoreOnlyOneClaim 让 32 个 goroutine 同时尝试领取同一 ID,真实计数为恰好一个成功。这既是锁的临界区,也是业务上的“同一任务不得有两个成功的领取者”:只有让读取前态与更新前态成为一个不可插入的动作,该不变量才成立。目前单消费者本身不会制造 32 个真实 worker,这个测试是在未来增加 worker 前验证存储提交协议,不是吞吐量演示。
取消请求为何不能提前写成终态
TestCancelDuringCalculationKeepsTerminalStatus 让已经 Start 的真实队列消费者停在可控的分析闸门。此时对 TaskStore.Cancel 调用完成,Lookup 仍返回 Status=RUNNING, CancelRequested=true。只有放行工作,分析器走完、worker 尝试提交统计,才得到 CANCELED、词数 0;迟到的成功结果没有覆盖终态。测试日志记录 running -> canceled; late completion rejected: status=CANCELED words=0。这条证据区分“取消按钮已经按下”和“工作者完成收尾”。Cancel 改的是状态请求位,并不会中断这个注入的、不合作的分析函数;第 17 篇 context 的检查点和退出等待才负责协作停止,二者不能互相替代。
如果队列的 context 先过期,原有路径等子统计、消费者和生产者确认退出后,才执行 CancelOpen 收束尚未到终态的记录。正常完成与取消竞争时,先拿到锁提交终态的一方决定结果;不会强行把已经成功提交的记录改写成取消。调用方仍可能因为本次截止时间报错、丢弃暂存 stdout,而某项内部状态已经在取消请求前成功提交;这个工程没有持久化状态或外部副作用,此阶段不把二者勉强说成同一个原子动作。若引入 SQL 事务与网络请求,记录提交和外部执行完成也不会天然相等。
无锁场景实际报告了什么
store_test.go 里有一个仅供故意失败实验的 unprotectedStore。它用相同的 TaskRecord 字段提供 Start/Complete/Cancel/Lookup,但不加锁。TestStoreRaceScenario 的测试体相同:先把一条任务置于 RUNNING,然后同时放行 64 个完成、取消、查询操作,最后检查唯一终态及不能反转。只有将 TASKSTATS_UNSAFE_RACE=1 写进实验命令才会让这个测试切到无锁替身;正常 CLI 和正常测试始终用真实有锁 TaskStore。这不是在生产工程里偷偷留一个可切换的无锁实现。
本机 Go 1.27.1/linux-amd64、CGO_ENABLED=1、gcc 13.3.0 的直接命令 TASKSTATS_UNSAFE_RACE=1 go test -race -count=1 -run '^TestStoreRaceScenario$' -v .,真实输出出现 WARNING: DATA RACE,包括 unprotectedStore.Complete 与 unprotectedStore.Cancel 对同一地址的读写、Complete 与 Lookup 的交叠;测试及进程退出 1。去掉环境变量、运行同一个测试场景,实际退出 0、无报告;两包完整 go test -race -count=1 -v ./... 也退出 0。两种结果来自相同访问情境、不同存储实现,并非同一编译产物打了一个“开启安全”开关。原始双流、退出码和环境分别保存在 writing-plans/go-language/evidence/18/unsafe.*、safe.*、race-full.*、environment.txt。
Go 内存模型讨论的是读写之间的 happens-before:一把 Mutex 的前一次 Unlock 与后续 Lock 建立同步关系。给每个字段各加一把锁虽能避免各字段的数据竞争,却不一定维持“成功状态与正确计数同时出现”的业务不变量;本章把前态判断、结果与终态放在同一临界区提交。反过来,即使输出日志看起来合理也不能证明无 race:无锁反例这一轮的终态日志也可能恰巧是 CANCELED words=0,race detector 仍真实报告读写冲突。go test -race 没报告只说明这轮实际执行的访问没有被报告,不是所有输入、调度、进程生命周期无 race 的全称证明。
旧行为回归也单独运行:--batch 'Go|one two' 仍输出 task[0] words=1 bytes=2 runes=2 与 task[1] words=2 bytes=7 runes=7,退出 0;--json 仍给两条 SUCCEEDED;显式零超时与无效输入各返回相应 stderr、退出 2。另有 50 次受控终态竞争与取消闸门复跑,均退出 0;重复次数不能代替 race detector,更不代表生产负载下没有其他竞争。下载包内有当章累计工程、版本、全部文本实验命令和双流;同名素材目录保留 SHA 核验与实际独立解包的复跑日志。隔离 Hexo 页面、站内链接和静态 HTTP 下载检查另行验收,不能替换 Go 的 race 对照。
练习:只把 Status 改成一个 atomic 值,Words、Bytes、Runes 仍普通写,读者先看见 SUCCEEDED 再读计数,是否满足本章契约?答案:不满足。单一字段的原子化既不能自动给多字段加一个共同提交点,也不能允许并发读写普通字段却不给出同步。需要让“成功状态加正确结果”的查询看到同一份锁下快照,或者设计有明确发布语义的不可变结果;本章选用前者。atomic 的职责和不能替代的部分留给第 19 篇实测。
当前只交付单进程内存状态与单消费者;还没有有界 worker pool、请求取消专属任务 ID API、HTTP、数据库存储或外部副作用的一致性承诺。race 实验只覆盖本机受支持环境和本次路径;包含本章的全站构建、其他平台及发布均 NOT_RUN。Go 源码 tag go1.27.1、镜像 SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 沿用基线,官方 Gitiles 交叉验证未完成。本章依据的是固定版本公开 Mutex API、内存模型和原始 race 输出,不借未读的 runtime 源码推断锁公平性。下一篇在相同状态约束下区分 WaitGroup 的退出等待、Once 的单次初始化和 atomic 的单字段计数。
参考资料
- Go 内存模型:data race、happens-before、Locks。固定 Go 1.27.1 的
doc/go_mem.html:48、:139、:490摘录见writing-plans/go-language/evidence/18/spec-memory.stdout。 sync.Mutex:本版go doc sync.Mutex原始输出见writing-plans/go-language/evidence/18/doc-mutex.stdout;实际来源及实验口径见writing-plans/go-language/research/18.md。






