35 的事务实验把同步 JDBC 留在固定的工作线程,但外层函数仍写作 `suspend fun insert(...)`。这个修饰符并不表示 SQL 有了一条额外的线程,也不表示持有连接时能安全地随处挂起。理解它要从已经写过的 `executeSyntheticTask` 出发:执行任务步骤时函数可以暂停,步骤返回后又要带着 `TaskId` 和先前状态继续。暂停时这些局部变量去了哪里?

本篇保持编译器/Gradle 插件 2.2.21、language/API 2.2、JDK 21 和 jvmTarget 21。通过 Git tag v2.2.21 定位编译器提交 2146684dcba708e5a304758b41a9e4ec9c7eff71,并在这个提交下阅读 AddContinuationLowering.kt 与 CoroutineCodegen.kt。Gradle --version 所说的 内嵌 Kotlin 2.0.21 是 Gradle 自己的脚本环境,不是编译本工程源码的编译器。以下的类名、局部字段和指令只适用于这份固定生成物;可跨版本引用的是 Kotlin 规范中的 continuation passing style 与状态机模型(2026-10-10 核对)。

从三个源代码动作到一个 JVM 签名

累计工程的源函数只有一个可能的挂起调用,step() 返回之后才构造成功状态:

1
2
3
4
5
suspend fun executeSyntheticTask(id: TaskId, step: suspend () -> Unit): TaskState {
val running = advanceTask(TaskState.Queued, TaskSignal.Start)
step()
return advanceTask(running, TaskSignal.Success("completed:${id.value}"))
}

javap -p -s example.tasks.SuspendTaskStepKt 在本工程生成如下方法(省略泛型显示细节):

1
2
executeSyntheticTask-ZIcwjEw(String, Function1, Continuation): Object
descriptor: (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;

最后一个形参是 Continuation<? super TaskState>,返回值为 Object。函数可以直接返回完成后的 TaskState,也可能在调用 step() 后返回内部的 COROUTINE_SUSPENDED 标记;Continuation 是恢复时通知调用者的通道。这里的 String 不是把领域类型随意改成字符串:16 篇引入的 TaskId 是 value class,其当前 JVM 表示与方法名改编要同源码类型区分。拿一个反编译签名逆推 Kotlin 类型安全很容易失真。

负例是把 suspend 当作“自动后台执行”。executeSyntheticTask(id) {} 的空步骤不会挂起;24 篇现有测试在虚拟时间 0 就读到成功状态。同一个函数传入 delay(1_000) 时,测试先执行调度器可运行步骤,检查尚未完成,在第 1000 个虚拟时间单位才检查 await() 的成功。虚拟时间证明的是这条可控调度路径,不是生产 CPU 耗时,也不证明 JDBC 等同步操作已变非阻塞。

暂存局部状态的是哪一个对象

javap -p -s 还列出 SuspendTaskStepKt$executeSyntheticTask$1,其父类是 ContinuationImpl,包括 L$0、L$1、L$2、result 和整数 label,以及 invokeSuspend(Object): Object。源码没有手写这些字段。对生成方法运行 javap -p -c 可见调用步骤前设置 label = 1,把必要局部变量放进 L$ 字段;若步骤返回的是 COROUTINE_SUSPENDED,本次调用就返回该标记。恢复时以 label 选择后续分支,重新取得局部状态后才执行 TaskSignal.Success。这也解释了为什么局部变量能跨挂起点存活,但它们没有自动成为数据库记录或线程安全快照。

本编译器源码中的 AddContinuationLowering.lower 分别为函数和挂起调用加入 continuation;generateContinuationClassForNamedFunction 生成 result、label 字段及 invokeSuspend。createSuspendFunctionStub 构造带 continuation 参数的 JVM 视图,返回类型变为 Any?。后续 CoroutineCodegen.acceptWithStateMachine 把 JVM 方法节点交给 CoroutineTransformerMethodVisitor。这里核对的是两个明确的入口及最终生成物,不是声称逐条读通该 visitor 的每个分支,更不是从 SuspendLambdaLowering 推断命名函数与 lambda 在所有细节上相同。源码完整符号与 blob SHA 见 writing-plans/kotlin/research/36.md、verification/36/sources.txt。

暂停点和线程切换不是同一层

suspend 允许函数在特定调用点交出执行;调度到哪条线程、何时恢复由调用它的库/调度器与具体上下文决定。24 篇的单工作线程实验用 suspendCoroutine 把 continuation 交出:挂起后,那个唯一的工作线程还能执行探测任务;把 Thread.sleep 放进同样声明为 suspend 的代码,则工作线程仍被占着。这个正反例比“协程很轻量”更直接:生成状态机为暂停准备了表示法,不会替程序改写阻塞 API。

这也为 35 的事务边界划线。withContext(jdbcDispatcher) 可以换一个工作线程去做一段同步 JDBC 操作,却不会把 executeUpdate 变成挂起调用;连接的关闭、异常回滚和取消竞态仍由显式代码管理。若协程在返回给上层时被取消,不能仅凭 CancellationException 推断数据库回滚。生成物中的 label 也不保存业务任务进度到磁盘。

本篇新增的 SuspendBytecodeShapeTest 使用反射核对该固定编译器生成物:方法最后一参为 Continuation、返回类型为 Object,合成类含 label/result 和 invokeSuspend。这是一条版本锁定的形状回归,不是 Kotlin 对合成类名称和字段数作出的公共 ABI 承诺。若换编译器,仅形状断言失败,要重新检查 javap;若真实挂起与恢复行为失败,再检查实际语义。既有测试验证空步骤立即完成、受控调度延迟恢复及单工作线程在挂起与阻塞时的差别。

练习与答案

练习一:把 step() 换成不挂起的空 lambda,executeSyntheticTask 还会创建后台线程吗?答案:不会。测试在虚拟时间 0 读到成功,只说明函数按顺序执行完。

练习二:看到生成签名返回 Object,能否把 Kotlin 源码里的 TaskState 返回类型当成丢失?答案:不能。Object 是 CPS 形状用于容纳结果或挂起标记,源码类型仍由 Kotlin 编译器和调用点约束。

练习三:在 JDBC 事务块里加一个 delay,是否只是让事务“更异步”?答案:不是。持有连接期间跨挂起点会扩大所有权问题,需要重新验证连接线程、事务可见性、取消和关闭,本篇字节码观察不能替代 35 的数据库实验。

复跑 bash examples/kotlin-language/verification/36.sh 保存累计 JUnit、隔离 JVM 回环 HTTP、CLI 正反、javap 签名/指令与断言输出;归档解包的复跑步骤见同名素材目录。真实 HTTP 客户端中途断线、HTTP 与 JDBC 联通、逐步调试整个编译器 IR 管线和部署依旧 NOT_RUN。性能不是从这里的字节码长度推出的;下一篇需先固定负载与工具,再谈实际分配和吞吐。

累计工程归档包含 Gradle Wrapper、00–36 研究与验证日志;摘要在素材目录,不能用归档存在本身替代解包复跑。