Kotlin 24:suspend 为何没有自动让线程空出来
任务执行步骤要等待外部结果。把函数前面加上 suspend,是让它有了暂停的可能,还是已经让工作线程在等待期间空出来了?这两件事不能混为一谈。尤其当原先的代码是 Thread.sleep、CountDownLatch.await 或同步文件读取时,改了签名而没改等待方式,调用线程仍然会被占住。
本篇沿用 23 的累计任务工程,但不把文件操作偷偷改称异步 I/O。冻结组合为 Kotlin Gradle Plugin/stdlib 2.2.21、Gradle 8.14、JDK/toolchain 21、language/API 2.2、jvmTarget 21,新增 kotlinx-coroutines-core 与 kotlinx-coroutines-test 1.10.2,上游 tag 指向 5f8900478a8e20c073145b1608fbc71fe3d7378b。实际解析树及构建运行记录在素材工程的 verification/24/;tag 是版本锚点,并不表示本篇已逐行审过调度器实现。Kotlin 协程基础、上下文与调度器、delay API 与 runTest API说明库的契约;下面用本工程的测试验证具体观察。
先把“暂停”与“线程卡住”分开
线程是 JVM 执行代码的载体:一个单线程执行器上运行着阻塞调用时,排在队列里的第二项工作无法开始。协程则是可恢复的计算;挂起时保存需要继续执行的信息,以后可由调度器安排恢复。并发是多个任务的时间范围交叠,并行是它们在多核等资源上同时执行;仅有 suspend 修饰符不提供任一结果。初学时可先问两个问题:函数调用是否实际到达了挂起点?如果到达,等待期间承载它的线程能否执行另一项工作?
实验把“执行合成任务”改为一个很窄的挂起边界:
1 | |
传入 {} 时调用立即返回,虽然函数的静态类型允许挂起,这一次没有实际等待。传入 { delay(1_000) } 时,协程等计时器再继续,但 delay 是协程库操作,不是让当前线程睡一秒。传入 { blockingGate.await() } 时,编译器同样允许调用,因为 suspend 不是禁止阻塞操作的类型限定符;这一条会真的占住执行它的线程。running 是 TaskState.Running 的局部业务值,尚未写入内存 Store;不能因为函数“开始”就声称持久化状态已变更。
| 步骤 | 有 suspend 类型吗 |
此次实际挂起吗 | 单线程工作者在等待时可接新任务吗 |
|---|---|---|---|
{} 空步骤 |
有 | 无 | 无等待期可讨论 |
delay(1_000) |
有 | 测试调度器中确实延后完成 | 虚拟时间本身不能证明真实线程行为 |
suspendCoroutine 保存恢复点 |
有 | 有,直到外部恢复 | 真实单线程实验:可接探针 |
在步骤里调用 CountDownLatch.await() |
有 | 等待是线程阻塞而非释放工作者的协程挂起 | 真实单线程实验:探针排队直到放闸 |
为什么两本时间账要分别记
虚拟测试由 runTest、runCurrent() 和 advanceTimeBy 驱动。空步骤运行后,调度器 currentTime 为 0,函数已返回 Succeeded("completed:task-24")。启动延迟步骤,执行当前队列后 Deferred 尚未完成;推进 999 毫秒并排空当前队列,仍未完成;再推进 1 毫秒,得到相同的业务成功值,逻辑时间是 1000。JUnit XML 留有 VIRTUAL immediate_time=0 delay_completion_time=1000。这里的毫秒是测试调度器的逻辑时间,既不是实耗一秒,也不是线程占用率测量。
另一条测试单独建立只有一个工作者的 ThreadPoolExecutor,用 asCoroutineDispatcher() 明确把协程派到名为 kotlin-24-worker 的线程。第一次步骤进入 suspendCoroutine 后将 Continuation<Unit> 保存在原子引用中,用 CountDownLatch 通知测试线程:工作者已到达挂起点。测试线程在恢复之前直接向执行器提交线程名探针,探针能在五秒上限内返回 kotlin-24-worker。再恢复 continuation 并 join,确认该任务走到 Succeeded。这是实际释放工作者的证据,不需要猜测某次 Thread.sleep 是否已经结束。
接着仍在这个工作者上执行另一条 suspend 步骤,不过步骤调用 blockingGate.await()。测试先等它确实进入闸门,随后向执行器提交相同探针;此时探针未完成且执行器队列大小为 1。测试线程释放闸门,探针才在同一工作者上完成,任务随后 join。原始输出 REAL suspended_worker_probe=kotlin-24-worker blocked_queue_size=1 分别对应前后的两个检查点。所有闸门都有有界等待,finally 放闸、恢复尚存的 continuation、关闭 dispatcher 并等待执行器终止,避免断言失败时把测试进程留住。五秒上限只是防止卡死,不作为性能结论。
这一比较不测实际文件读取、网络客户端和数据库驱动。即使把阻塞 I/O 移到另一个 Dispatcher,也只是换了承担阻塞的线程;并未自动实现取消底层调用。线程调度和限制并发数留给 构建边界之外的后续章节进一步实验。
哪个“完成”需要谁来等待
这里有四层容易混淆的状态:suspend 是函数的调用约束;Continuation 是某次执行返回后恢复的位置;Dispatcher 决定协程在什么线程执行;Job 追踪协程的生命周期。这四层不是领域的 Queued/Running/Succeeded。示例通过 launch(dispatcher) 创建 Job 并由 join() 等待,但 executeSyntheticTask 返回 Succeeded 只说明本地合成步骤完成,既没有提交 Store,也没有把结果存入数据库,更没有回答取消与成功同时到达谁赢。把 TaskState.Succeeded 当成 Job 的 isCompleted,会漏掉子任务和异常路径。
编译后的 SuspendTaskStepKt.executeSyntheticTask 用 JDK 21 javap -p -s -c 可以看到附加的 kotlin.coroutines.Continuation 形参以及相应的状态转换字节码。这是当前 Kotlin 2.2.21 编译结果的观测,不保证每个 suspend 函数总有同一形状的机器码、同样的恢复线程或恒定 ABI。源码追踪留在 36;本篇没有假装由反编译产物证明完整编译器实现。
练习与逐篇验收
- 如果把空步骤写成
suspend fun noWait() {}并调用,能否仅凭签名宣称工作者空出来过?不能。 调用未经过挂起点;本篇空步骤的currentTime=0与立即返回只覆盖本次测试。若要观察工作者释放,应像真实闸门实验那样,在恢复之前派一项可被工作者执行的探针。 - 在
suspend步骤里调用阻塞await(),为什么虚拟delay的测试不能证明它可取消?虚拟调度器控制的是测试中的协程计时,不接管 JDBC/锁/文件等线程阻塞调用。 本篇真实单线程探针在闸门释放前仍排队;取消、清理与超时后底层操作是否结束,需要分别用受控闸门和资源计数测。 executeSyntheticTask返回Succeeded后就能对外报告持久化成功吗?不能。 它只返回一个业务值,尚未调用 Store;还要单独验证提交、父级等待、外部副作用和完成竞争。
本篇验收以 verification/24/build*.txt、SuspendTaskStepTest.txt、assertions.txt、bytecode.txt 和 dependencies.txt 的实际结果为准。旧 CLI 正反输入仍走 00 的同步路径,不因新增协程库而偷偷执行后台任务。完整累计工程、Wrapper、RUN 与 VERSIONS 可从同名素材目录下载 kotlin-language-24.tar.gz;素材 README 给出解包复跑命令与 SHA-256 校验。后续将建立明确的 CoroutineScope 与 Job 父子关系,再谈失败、取消以及由谁回收任务。






