为什么我认为 Java 虚拟线程不会取代 Kotlin 协程

1,259 阅读5分钟

Java 虚拟线程和 Kotlin 协程走的两个方向。Java 是有栈协程,协程上下文保存在与执行处独立的栈上,所以异步函数可以直接像正常函数那样写,程序执行到阻塞部分时 JVM 内部自动挂起;Kotlin 是无栈协程,因为干预不了 JVM,所以协程上下文必须保存在堆上,同时得显式声明函数是可挂起的,否则不知道什么函数支持挂起(suspend 在转译为字节码时会添加 Continuation 参数)。

干预不了 JVM,既是 Kotlin 协程的优势,也是它的劣势。因为不需要 JVM 出手,这个特性可以在旧版本 JVM 上实现,也可以在不支持有栈协程的平台上实现,比如 WASM。又因为必须标记可挂起函数,Kotlin 协程也暴露出典型的函数染色问题。

函数染色问题是人们关心的重点,在 Project Loom 的 proposal 中,JDK 团队这样说:

While async/await makes code simpler and gives it the appearance of normal, sequential code, like asynchronous code, it still requires significant changes to existing code, explicit support in libraries, and does not interoperate well with synchronous code.

虽然 async/await 简化了代码,让异步代码看起来更直观,但它对代码的入侵性太强,需要有额外的支持,还很难与同步逻辑交互。

虽然 Kotlin 协程也有上述缺点,但那都只是一个角度,它真有 那么 不堪吗?

我在与 Kotlin 新手交流协程时发现一个问题,他们分辨不了 CoroutineScope.launchwithContext,看起来确实是这样:

val scope = CoroutineScope(Dispatchers.Default)

scope.launch {
    launch(Dispatchers.Main) {
        // ...
    }
    
    withContext(Dispatchers.Main) {
        // ...
    }
}

它们俩都会在对应协程调度器(CoroutineDispatcher,类似 Java 的 Executor,或者在 Project Loom 里叫 Scheduler)执行一段逻辑,但是 CoroutineScope.launch 是异步的,而 withContext 是同步的,也就是说,CoroutineScope.launch 只是在对应协程调度器上 schedule,相当于 fire and forget,而 withContext 是一定先执行它自己的逻辑,结束后才继续后面的。要想解释这一点差别,其实只需要一个东西:suspend。只有可挂起函数才具备同步的能力,这恰好说明“同步是有代价的”。如果没有 suspend,用户只能通过文档来了解一个函数是异步还是同步,从而避免混淆上述示例中的两个函数。

值得注意的是,某些可挂起函数可能永远不会 resume,比如 suspend fun SharedFlow.collect(/* ... */): Nothing,从 API 设计的角度讲,它的 提醒 作用就比 fun SharedFlow.collect(/* ... */) 好。

UI 编程这种存在主线程、事件循环等概念的领域不是虚拟线程的受众,因为虚拟线程解决的是高并发问题,而 UI 编程显然不存在这个问题,但 UI 编程涉及大量的同步操作,所以要让虚拟线程支持 UI 编程,我们需要这样写:

Thread thread;

thread = Thread.startVirtualThread(() -> {
    int value = fetchValue();
    final int valueForUpdate = value;
    // 在主线程更新状态
    UI.schedule(() -> {
        state.setValue(valueForUpdate);
        LockSupport.unpark(thread);
    });
    // 等待同步
    LockSupport.park();
    // 重启
    value = fetchAnotherValue();
});

Kotlin 的“等价”形式:

val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
    var value = fetchValue()
    // 等待同步
    suspendCoroutine { continuation ->
        // 在主线程更新状态
        UI.schedule {
            state.value = value
            continuation.resume(Unit)
        }
    }
    // 重启
    value = fetchAnotherValue()
}

可以看到,二者的写法十分相似,Kotlin 协程并没有因为 suspend 而写得更多。

可能有人觉得 CoroutineScope 是“多”,但它的作用并不只是提供 suspend 上下文,更重要的是方便管理(其实就是在合适的时候 cancel),这在结构化并发中是不可或缺的,而这一点在 Project Loom 中也有实现(JEP 435),StructuredTaskScope 与 CoroutineScope 的用法差不多。

try (var scope = new StructuredTaskScope<Object>()) {
    var job = scope.fork(() -> /* ... */);
    scope.join();
    var value = job.get();
}
CoroutineScope(Dispatchers.Default).launch {
    val job = async { /* ... */ }
    val value = job.await()
}

注意,Java 示例是阻塞的,如果要在 Kotlin 中实现阻塞,需要使用 runBlocking

runBlocking { // this: CoroutineScope
    val job = async { /* ... */ }
    val value = job.await()
}

所以,suspend 的函数染色问题真的没那么突出,它已经很好地融入 Kotlin 的设计了。

尽管如此,还是有人提出这个问题:

如果 Kotlin 协程能像 Java 的 Stream 那样直接原地并行(parallelStream)就好了,每次都需要 asyncawait 很麻烦。

Kotlin 有两个类似 Stream 的设计,一个是 Sequence,不支持并发,仅作为延迟求值的实现(好多人还在用重量级的 Stream),也俗称 Kotlin 的 Generator(巧了,也是基于 Continuation 实现的);另一个是 Flow,支持并发。Flow 的 suspend 浓度更高,一个比较典型的示例:

flow { /* ... */ }
    .debounce(timeoutMillis = 1000L)
    .map { /* ... */ }
    .collect { // suspend
        delay(timeMillis = 1000L)
    }

Flow 的操作符中只有末尾操作符(collecttoList、…)是可挂起的,因为 flow 支持并发,末尾操作符必须等待元素到来才能执行。这又回到 suspend 的传染性问题上了。还是那个回答,CoroutineScope 拯救一切,不仅提供 suspend 上下文,还方便管理。

即使必须在当前非 suspend 上下文中阻塞执行 suspend 函数,Kotlin 也有 runBlocking,其底层实现也是 LockSupport.parkNanos,完全兼容虚拟线程。

当然,Kotlin 协程有一个致命的缺点:不方便调试。这是无栈协程的主要弊端之一,而有栈协程,因为是在独立的栈上运行的,所以栈帧可以比较完美地保留。但是别忘了,这不是免费的午餐。

Kotlin 协程既有优点也有缺点,不管怎样,至少它能实现高并发需求;虚拟线程,也没想象的 那么 强大(除了更好的调试体验)。所以,我个人认为,谁都不能取代谁,二者都只是相同概念的不同实现罢了。