28给故障找到了父级和观察者,但还没约束成功路径:两个协程同时想更新同一条任务,都会先读到旧值。即使单个计数器用 `AtomicInteger.incrementAndGet()`,在“先检查任务是否 Running,再写成 Succeeded,最后增加完成次数”的组合里,两个请求仍可能都认为自己完成了这条任务。问题不是协程库的某个例外,而是**读、比较、修改跨越多个对象**时,究竟把哪段操作算作一个不可拆分的业务转移。

本篇仍在冻结的 Kotlin/JVM 2.2.21、Gradle 8.14、JDK/toolchain 21、language/API 2.2、jvmTarget 21、kotlinx.coroutines core/test 1.10.2 上运行,上游协程源码 tag 为 5f8900478a8e20c073145b1608fbc71fe3d7378b。Kotlin 官方共享可变状态指南、Mutex、withLock 及 JDK 21 AtomicInteger提供机制的依据;具体的 1、2 等数字来自本次实验。测试调度器控制的读写交错并不等于跑过真实跨核线程数据竞争。

两个调用为什么都把旧值写成 1

测试用普通 MutableMap<TaskId, Int> 保存某任务的计数。两个 async 先各自读取 map 中尚不存在的同一 ID,都得到 0,并用各自的 CompletableDeferred 报告已读;测试线程等两个报告都到齐,才放开共用的写入闸门。两条路径各把事先保存的旧值加一再写回。最终 map 中的数值为 1,而不是 2。

1
2
协程 A:读 0 ────── 等闸 ── 写 1
协程 B: 读 0 ── 等闸 ── 写 1

先保证两次读取真的都发生在写入之前,才能说明这个实验反驳了什么:失败的不是 Kotlin 的 Int 加法,而是复合的“读当前值 → 算下一个值 → 写回”没有互斥。调度器恰好按某个顺序执行,也不使两次写回变成两个累加。这条反例只控制同一测试调度器中的挂起交错;若要证明真实多线程下的可见性、线性化顺序或硬件级数据竞争,还要另设并行工作者、足够次数及有界竞争门槛,不得把本篇虚拟时间实验冒充那个结论。

Mutex 锁住的必须是整段读改写

主工程的 TaskAttemptIndex 用一个 Mutex 保护内存 map,对某 ID 的 record(id) 在 withLock 中读取、加一、回写,count(id) 也在同一把锁下读取。第二组实验同样让两个异步调用先等启动闸,放闸后才各自进入 record;得到的两个返回值集合为 {1, 2},最终 count 为 2。与未加锁实验的结果合在 JUnit 输出中:VIRTUAL unsafe_read_0_twice=true unsafe_count=1 mutex_count=2。

锁放在读取 map 前、并直到写回结束,才对同一索引实例中的这段数据构成临界区。只把 mutex.withLock 包在最后一行 counts[id] = newValue 外面无济于事:两次 newValue 早在锁外根据相同旧值计算完了。Mutex 是协程友好的互斥原语,不应在锁内引入长时间阻塞或需要再次获取同锁的步骤;本实验临界区只做内存计算,不把网络、数据库事务或任意外部回调藏在锁里。

这也没有让尚未采用这把锁的旧 InMemoryTaskStore 默认提交逻辑突然变成原子操作。索引是新建的小型内存对象,完整服务接入、取消、进程重启后恢复和分布式互斥都不在本篇验收范围。

AtomicInteger 对哪个动作提供原子性

第二个反例更接近业务条件:同一 ID 在本地 map 中是 TaskState.Running。两个协程先各自读它,确认都是 Running,测试确保二者都已看见这个状态,再放闸。每条路径都调用一次 AtomicInteger.incrementAndGet() 并把业务状态写成 Succeeded。计数器正确地增加了 2,但是任务只有同一条。JVM 单字段原子操作没有把“状态是否还是 Running”的判断、状态修改和计数更新包成同一个事务。原始输出 VIRTUAL atomic_field_count=2 business_task_count=1。如果把计数器的值叫作“成功完成的唯一任务数”,它现在已与业务事实不符。

累计工程另建 TaskCompletionIndex,在同一个 Mutex 临界区内:检查 states[id] 是否为 Running,若是则调用已有的 advanceTask 生成 Succeeded,并将同 ID 的完成计数加一;若已离开 Running 就返回 false,不修改计数。两个受同一起始闸门控制的 completeOnce(id) 中恰好一个返回 true,另一个返回 false,snapshot(id) 读取到 Succeeded("completed:task-29-state") 和计数 1。这次输出是 protected_accepts=1 protected_count=1。

本次实验 两次尝试都被接受吗 最终计数 缺失或新增的约束
map 读 0、等闸、各写 1 两次都做了写操作 1 读改写缺少共享临界区,丢失一次更新
TaskAttemptIndex.record 每次记录都有唯一结果 2 同一进程、同一实例中的 map 被 Mutex 包住
状态在锁外读、计数用 AtomicInteger 两次都认为 Running 2 原子性只涵盖计数器本身,不涵盖状态条件
TaskCompletionIndex.completeOnce 一次 true,一次 false 1 状态比较、转移与计数在同一临界区

这里的 snapshot 在同一把锁下读取状态与计数,所以不会读到该实例内部半途更新的组合;并不意味着写到多个外部系统时也会一起提交。既有内存任务状态与数据库事务的边界还要在后续章节用真实连接和隔离读数验收。不要用“单次业务状态是 sealed class”推断并发更新的原子性,sealed 只约束可能的值,不管理两个执行者何时写。

练习与逐篇验收

  1. 两次读旧值都为 0,为什么最后是 1 而不是 2?两条路径独立算出相同的新值 1,晚写者覆盖早写者。 先等两个已读信号才放闸,避免把偶然顺序写成确定反例。
  2. 如果把 MutableMap 中的数值改成 AtomicInteger,还需要 Mutex 吗?对单纯递增该字段未必需要;对“只有 Running 才允许首次完成,且状态与计数一致”仍需要共同的状态转移机制。 测试中原子计数为 2 却只有一条业务任务。
  3. completeOnce 在内存测试中只返回一个 true,能否说并发提交到数据库也只会成功一个?不能。 这把 Mutex 只属于当前内存对象;跨进程、连接、事务及故障恢复需另设数据库唯一约束与事务实验。

逐组 JUnit XML、受控闸门输出、依赖和旧 CLI 正反证据在 verification/29/;同名素材的完整累计工程 kotlin-language-29.tar.gz 包含 Wrapper、RUN/VERSIONS 与解包复跑说明。下一篇要把这组业务更新接上有界 Channel,分别核对队列接纳、消费者处理以及服务退出后未处理项的归属,不拿队列入列冒充业务成功。