Kotlin 26:换个 Dispatcher,阻塞还在吗
基线仍为 Kotlin/KGP/stdlib 2.2.21、Gradle 8.14、JDK 21、language/API 2.2、jvmTarget 21、kotlinx.coroutines core/test 1.10.2,库源码 tag 5f8900478a8e20c073145b1608fbc71fe3d7378b。本文查 官方上下文与调度器文档、withContext 和 asContextElement;runInterruptible仅作中断边界的资料,不将其名字当作本篇已验证中断的证据。
把同步步骤明确放进有容量的执行器
累计任务系统新增一个很小的适配入口:
1 | |
operation 是普通同步函数,withContext 在调用边界切换执行上下文;不在它内部调用 suspend 网络/文件 API,也不自动改变阻塞调用的实现。executeManagedTask 的父子结构仍负责步骤正常结束后才返回 Succeeded,若被取消则不应把这次返回值误记为已持久化的业务成功。专用执行器从调用方传入,谁创建、谁关闭的边界在测试中明确;没有擅自把全局 Dispatchers.IO 解释为某个具体业务的单任务限流器。
真实线程实验建两个独立 ThreadPoolExecutor(1, 1, ...),分别将线程命名为 kotlin-26-caller 和 kotlin-26-blocking。在调用工作者上启动协程,进入 executeBlockingTask 后,阻塞工作者通知测试线程已到达闸门,再执行 release.await()。测试线程在放闸之前向两个执行器各投一个直接线程探针:调用方探针在五秒上限内返回 kotlin-26-caller;阻塞执行器上的探针尚未完成,队列大小为 1。释放闸门后,阻塞执行器上的探针才返回 kotlin-26-blocking,调用方最终得到 Succeeded("completed:task-26")。原始输出 REAL caller_probe=kotlin-26-caller io_queued=1 刻画的就是放闸之前的队列状态。
这里“一进一排队”的结果只针对这次显式给出的单工作者执行器,不是所有 Dispatcher 的排队策略,也不是吞吐基准。asCoroutineDispatcher() 不会让被包装的同步操作自动支持暂停;该协程在 withContext 等待时可让调用方工作者执行探针,同时同步操作仍把另一个工作者占满。五秒只是避免测试永远悬挂,不是平均时延。首轮实验失败也说明观察口径要谨慎:当前测试启用的协程调试模式会在执行期间给线程名附加 @coroutine#...,所以对“正在运行协程的工作者”改为检查线程名前缀,而直接 Executor.submit 探针仍观察原始线程名。线程名装饰不属于业务 ABI。
任务 ID 在换线程以后去了哪里
ThreadLocal 保存的是当前线程上的值,不能凭 withContext(ioDispatcher) 就期望原线程的请求 ID 自动出现。第二个实验先在调用线程把 ThreadLocal<String> 设为 request-26;在一个新建专用工作者上执行未传播的 withContext(ioDispatcher),读到 null。然后明确将 requestId.asContextElement("request-26") 放进这一小段 withContext 的上下文,专用工作者读到 request-26;离开这段上下文后在同一个专用工作者再读一次,恢复为 null,调用线程仍持有原值。输出为 CONTEXT worker_before=null propagated=request-26 worker_after=null。
这不是无条件“自动透传所有 ThreadLocal”。asContextElement 只处理明确指定的那一个值和作用域;如果同步库会在内部改写 ThreadLocal,或请求跨到未纳入上下文的线程,还需要另测传播和清理策略。尤其不能把该实验的请求 ID 填入 TaskState 便声称跨服务链路也有相同上下文。诊断日志里的 request ID 与任务自身 ID 应分别给出归属;这篇只证明测试专用线程切换的前后状态。
取消 Job 就能结束同步调用吗
第三个测试专门制造反例。用另一条真实专用工作者执行同步 release.await(),先等它进入闸门,再对关联的 Deferred 调用 cancel()。此时底层同步块的 finally 还没有执行,计数闸门值保持 1;由测试线程放行 release 后,底层 finally 才把退出闸门放开。输出 REAL cancelled_before_release=true blocked_exit_after_release=true,它检查的是底层同步块退出时刻,不只是 Job.isCancelled。
这是当前 withContext(ioDispatcher) { blockingCall() } 对 CountDownLatch.await() 的实测反例,不是说 Kotlin 永远无法中断任何阻塞 I/O。库还提供 runInterruptible 等有不同契约的专用 API;JDBC 驱动、同步客户端、底层 socket 与文件系统会不会响应中断需要分别构造真实资源并检查句柄、线程与外部结果。27 再处理取消与超时的完成竞争。无论超时异常是什么类型,仅看到异常都不足以断言外部操作已经停止,更不能当作事务回滚的证据。
练习与逐篇验收
- 为什么
withContext正例不等于“这段同步 I/O 现在非阻塞”?因为请求工作者虽然可以跑探针,专用工作者仍被await()占据。 同时检查调用方探针可完成与阻塞方探针排队,才能定位工作落在何处;本篇的队列值为 1。 - 把请求 ID 存进
ThreadLocal后直接调用withContext(ioDispatcher)会怎样?在本次新工作者上读到 null。 显式asContextElement传入值后读取相同字符串,退出后原值恢复;不据此推断任意第三方线程池都已经传播。 - 取消任务后
finally没执行,可以报告资源已关闭吗?不能。 这次阻塞闸门直到外部放行才结束;在真实 I/O 上还需检查连接或文件句柄和外部副作用。Job状态、业务TaskState与资源结束应分别记账。
verification/26/ 保存逐组测试 XML、线程/队列/上下文/取消输出、首轮失败及修正后的命令和退出码;旧 CLI 仍只收合成文本,未通过它运行后台服务。同名素材的完整累计工程 kotlin-language-26.tar.gz 提供 Wrapper、RUN/VERSIONS 与解包复跑步骤。第 27 篇在此基础上用闸门验证协作取消、资源清理与完成/取消竞争,不把本篇的一个阻塞反例扩展成所有 I/O 的断言。






