近年来,协程作为一种高效的并发编程模型,受到了广泛的关注。然而,现有关于协程的讨论较为分散,缺乏系统性的阐述。本文旨在通过对协程的概念、实现机制及其与线程的关系进行分析,并结合多语言协程实现方式的比较,探讨协程在实际应用中的价值和性能表现。 本文采用一种基于问题导向的方式展开,围绕以下几个关键问题展开讨论:协程的定义、协程与线程的关系、不同语言中协程的实现,以及协程在性能与应用场景中的优劣势。
1. 协程的基本定义
协程(Coroutine)的概念可以追溯到计算机科学的早期发展阶段。根据维基百科的定义:
Coroutines are computer program components that allow execution to be suspended and resume.
直译为:协程是一种允许程序执行被挂起并随后恢复的计算机程序组件。这一定义尽管简洁,但对于理解协程而言仍显抽象。本文将协程定义简化为:一段程序能够在某个时刻被挂起,并在稍后恢复其执行,即可视为协程。
为了更直观地理解这一概念,以下是一个用Java编写的例子:
java
public class Main {
public static void log(String content) {
System.out.println("[" + Thread.currentThread().getName() + "]:" + content);
}
public static void main(String[] args) {
// 创建一个线程并执行
new Thread(() -> {
log("2"); // 输出线程名称和内容
try {
Thread.sleep(1000); // 线程挂起1秒
log("3"); // 恢复后继续执行
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
log("1");
}
}
上述代码展示了线程的创建与任务的挂起恢复过程。从广义上看,线程(Thread)在Java中也是可以挂起与恢复的,符合协程的基本定义。然而,由于"线程"这一术语在编程语言中已被广泛使用且与操作系统的调度概念紧密相关,将Java线程视为协程的观点可能并不被普遍接受。
2. 协程与线程的关系
协程与线程的关系是理解协程机制的核心。维基百科中对二者的比较描述如下:
Coroutines are very similar to threads. However, coroutines are cooperatively multitasked, whereas threads are typically preemptively multitasked. Coroutines provide concurrency, because they allow tasks to be performed out of order or in a changeable order, without changing the overall outcome, but they do not provide parallelism, because they do not execute multiple tasks simultaneously.
从中可以提炼以下几点关键特性:
-
任务切换方式:协程采用协作式多任务处理(cooperative multitasking),而线程通常使用抢占式多任务处理(preemptive multitasking)。
-
并发与并行:协程支持并发(concurrency),即任务执行顺序可以灵活调整,而不改变整体结果;但不支持并行(parallelism),因为协程无法同时执行多个任务。
-
实现机制:协程的调度完全由用户态完成,不依赖操作系统的支持,而线程的调度通常需要操作系统的参与。
目前关于协程的定义尚未有一个明确统一的表述,网络上对协程的描述更倾向于"用户线程"或"轻量级线程"。因此,本文将以"用户线程"这一概念来讨论协程,它解决了下面两个问题:
-
作为用户态线程:
a. 避免IO操作阻塞线程,从而减少上下文切换的开销。
b. 减少线程数量,节约内存资源。
-
改善异步回调的写法,增强代码可读性:
在没有协程的情况下,为了实现相同的性能,通常需要使用非阻塞IO库并依赖回调函数。由于回调函数之间的嵌套,代码的执行流程不是线性的,导致可读性较差。而使用协程可以让代码以更直观、顺序的方式进行编写,显著提升可读性和维护性。
3. 多语言中的协程实现
协程的实现方式在不同编程语言中各有特色,主要可分为两类:有栈协程 与无栈协程。本节将从Go语言、Kotlin、JavaScript等语言出发,探讨其协程实现细节及其背后的设计哲学。
3.1 Go语言与Java的协程
有栈协程 是一种具备独立调用栈的用户态线程,其典型实现包括Go语言的goroutine
和Java的虚拟线程(Virtual Thread)。
Go语言的goroutine
Go语言的goroutine
是协程的典型实现之一。goroutine
通过用户态调度器(runtime scheduler)实现多任务切换,其高效性使得Go语言成为开发高并发网络服务的首选。例如:
go
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 5; i++ {
go func(i int) {
fmt.Printf("Goroutine %d\n", i)
}(i)
}
time.Sleep(time.Second)
}
在上述代码中,goroutine
创建的成本远低于传统线程,其运行时可动态扩展栈内存,同时通过多路复用技术降低了线程切换开销。
Java的虚拟线程
Java的虚拟线程是一种轻量级的有栈协程。虚拟线程通过JVM的增强实现了与操作系统线程的解耦,从而显著降低线程的创建成本和内存开销。其典型用例如下:
java
public class VirtualThreadExample {
public static void main(String[] args) {
Thread.startVirtualThread(() -> {
System.out.println("Hello from Virtual Thread");
});
}
}
虚拟线程在任务挂起时不会占用操作系统线程,而是返回线程池供其他任务使用。这种设计使得虚拟线程在高并发场景下具有显著优势。
3.2 无栈协程:async/await
模式
无栈协程通过编译器生成状态机来实现逻辑挂起与恢复。以下以JavaScript和Kotlin为例分析其实现机制。
JavaScript的async/await
JavaScript的async/await
基于Promise和事件循环实现异步操作的简化语法,其核心是通过状态机转换实现挂起与恢复。例如:
javascript
async function fetchData() {
const data = await fetch('https://api.example.com/data');
console.log(data);
}
使用Babel编译成旧版本的代码,其将每个await
语句转化为一个状态,控制状态切换的核心逻辑如下:
javascript
switch (state) {
case 0:
fetchData().then(value => {
state = 1;
continueExecution(value);
});
break;
case 1:
console.log(value);
break;
}
这种基于状态机的设计解决了"回调地狱"的问题,使异步代码的可读性得到了显著提升。
Kotlin的挂起函数
Kotlin的协程通过suspend
关键字实现挂起函数,其运行时会将挂起点与继续执行点关联为一个状态机。例如:
kotlin
// 一个在协程中使用的挂起函数
suspend fun sleep(ms: Long) = suspendCoroutine<Int> { c ->
thread {
Thread.sleep(ms)
c.resume(1000)
}
}
fun main(): Unit = runBlocking {
launch {
val v = sleep(2000)
println("2000 ms了, $v")
}
}
编译成Java 21之后的部分代码如下:
Java
public final class CKt {
// suspend fun sleep函数编译后的方法,增加了一个continuation对象参数
@Nullable
public static final Object sleep(final long ms, @NotNull Continuation $completion) {
SafeContinuation var4 = new SafeContinuation(IntrinsicsKt.intercepted($completion));
final Continuation c = (Continuation)var4;
int var6 = 0;
// 启动一个Java线程
ThreadsKt.thread$default(false, false, (ClassLoader)null, (String)null, 0, new Function0() {
public final void invoke() {
Thread.sleep(ms);
Result.Companion var10001 = Result.Companion;
c.resumeWith(Result.constructor-impl(1000));
}
public Object invoke() {
this.invoke();
return Unit.INSTANCE;
}
}, 31, (Object)null);
// 获取当前这个协程的xx对象,返回这个对象
Object var10000 = var4.getOrThrow();
// 从这里可以看到,如果协程是挂起状态的,那么返回的这个xx对象应该是一个已经定义的常量
if (var10000 == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
DebugProbesKt.probeCoroutineSuspended($completion);
}
return var10000;
}
public static final void main() {
// 把协程放到runBlocking中启动
BuildersKt.runBlocking$default((CoroutineContext)null, new Function2((Continuation)null) {
int label;
private Object L$0;
public final Object invokeSuspend(Object $result) {
Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch (this.label) {
case 0:
ResultKt.throwOnFailure($result);
CoroutineScope $this$runBlocking = (CoroutineScope)this.L$0;
// launch函数,启动协程
BuildersKt.launch$default($this$runBlocking, (CoroutineContext)null, (CoroutineStart)null, new Function2((Continuation)null) {
int label;
public final Object invokeSuspend(Object $result) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
Object var10000;
// 编译时把挂起函数(sleep)拆成了状态机
switch (this.label) {
case 0: // 初始状态
ResultKt.throwOnFailure($result);
Continuation var10001 = (Continuation)this;
this.label = 1;// 状态更新成1
// 执行sleep函数
var10000 = CKt.sleep(2000L, var10001);
// 如果协程是挂起状态的,就停止执行,否则继续执行
if (var10000 == var4) {
return var4;
}
break;
// 调用了continuation的resume之后, 因为case 0里已经把状态更新为1,所以继续执行这里的逻辑
case 1:
ResultKt.throwOnFailure($result);
var10000 = $result;
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
// case 1执行完成,执行这里,取出resume函数的值,然后继续执行协程中的逻辑
int v = ((Number)var10000).intValue();
String var3 = "2000 ms了, " + v;
System.out.println(var3);
return Unit.INSTANCE;
}
}, 3, (Object)null);
return Unit.INSTANCE;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
}
}, 1, (Object)null);
}
}
3.3 有栈、无栈、async/await的关系
async/await
关键字的引入旨在简化异步编程。通常,结合异步和多线程能够提升 I/O 密集型程序的性能,因此协程(用户级线程)在解决 IO 场景下多线程性能问题时,同时也改善了异步编程可维护性差的问题。实际上,await
关键字本身就足以实现这一目标,前提是使用 await
的函数/方法返回的是类似 Promise
这样的对象。async
的作用仅仅是省略了显式包装返回值的步骤,它使代码更加简洁,并对初学者更为友好。不过有趣的是,Kotlin 只保留了 suspend
(类似于 async
)一个关键字,尽管如此,Kotlin 也能够实现与 async/await
相同的效果。
关于"染色"问题(即使用 await
的整个调用栈中的函数都需要标记为 async
),作者认为这并不是一个大问题。实际上,这种情况通常只在更新老旧代码时会遇到麻烦,例如"这段代码不能改,但又必须标记为 async
"。这种问题在实际编程中并不常见。
对于有栈协程(如 Java 虚拟线程)而言,由于底层运行时的支持,它们可以自动判断何时挂起协程、何时恢复。举个例子,当调用 Thread.sleep
时,JVM 可以判断当前代码运行在真实线程还是虚拟线程中。如果是真实线程,直接调用操作系统的 sleep
函数即可;如果是虚拟线程,JVM 会将当前虚拟线程加入睡眠队列,直到合适的时机再恢复执行。因此,带有栈的协程实际上不需要显式的关键字标记(当然,强行加上也是可以的)。
而没有底层支持的无栈协程,需要与传统的函数做出区分,只能通过 Promise
库和 await
语法糖来实现。这也导致了一个问题:同时存在两套 API,一套是阻塞的传统库,另一套是非阻塞的协程库。
那么,是否可以让无栈协程也拥有底层支持呢?作者认为这是可行的。例如,假设将 Thread.sleep
改为返回 Promise
,虚拟线程的调度逻辑会将回调添加到延迟队列中,然后继续执行其他虚拟线程,这种机制与当前的无栈协程类似,唯一的区别是调度下沉到了 JVM 层面。当然,这种 Promise
的改动只是为了便于理解,实际上 JVM 本身是能自动处理的。
然而,作者认为无底层支持的 async
协程无法做到有栈化。原因在于应用层很难捕捉到所有的函数调用,这样就无法维护协程的栈。因此,有栈协程天生不需要显式标记,而无栈协程则必须使用 await
显式标记。
4. 有栈、无栈协程的性能分析
为了探讨有栈无栈协程的性能差异,我们在相同硬件与软件环境下对比了线程池、虚拟线程和Kotlin协程的性能。
4.1 实验设计
环境配置
-
硬件:Intel i7,32GB内存
-
软件:GraalVM Java 21,Kotlin 2.0.21
实验代码
实验分别测试了线程池、虚拟线程和Kotlin协程在高并发任务中的执行时间和内存、CPU开销(代码见附录部分)。
4.2 实验结果
执行时间
实现方式 | 执行时间(秒) |
---|---|
CachedThreadPool线程池 | 57.7 |
虚拟线程 | 2.1 |
协程(默认调度器) | 2.0 |
协程(IO调度器) | 7.8 |
内存占用
-
CachedThreadPool
-
虚拟线程
-
Kotlin协(默认调度器)
-
Kotlin协(IO调度器)
CPU占用
不知是否因为负载太低,运行程序前后CPU占用几乎无明显变化,因此不进行对比。
4.4 分析
-
执行时间:默认调度器的协程和虚拟线程表现出相近的性能,协程略强,而线程池、IO调度器的协程由于线程切换开销显著较慢。
-
内存占用:Kotlin无栈协程内存占用最低,虚拟线程次之,而线程池由于大量线程栈的存在消耗较高。
4.5 讨论
既然有栈协程的执行时间相比无栈协程并没有明显优势,且在内存占用上存在劣势,是否可以认为无栈协程优于有栈协程呢?作者认为,并非如此。以下是作者的几点分析:
- 有栈协程使用门槛较低
以 Kotlin 协程和 Java 虚拟线程为例,Java 虚拟线程几乎没有门槛,特别对于熟悉 Java 语法的开发者来说,虚拟线程的使用相当直观。而 Kotlin 协程则需要开发者专门学习协程的原理及如何使用协程的非阻塞 API。
- 无栈协程的调用栈不完整,调试难度较大
无栈协程由于不保存完整的调用栈,因此在调试时存在一定的困难。在某些情况下,问题难以复现或者调试。举个例子:
kotlin
fun main(): Unit = runBlocking {
launch {
val p = 1000
println(p)
delay(100)
println("10")
}
}
在 println("10")
处打断点时,无法获取变量 p
的值。如果 p
在 delay
后被引用,依然可以通过闭包访问到它。但如果 p
没有被引用,那么在该点就无法查看到 p
的值。这是因为无栈协程不保存调用栈,因此在函数执行完毕前,无法查看局部变量的状态。而有栈协程则会保留调用栈,因此在函数执行的任何时刻,都能查看到所有变量的值。
因此,尽管无栈协程在某些方面具有优势,但它也存在易用性和调试上的一些挑战,而有栈协程的使用门槛较低,且在调试时能够提供更多的上下文信息。在选择使用有栈或无栈协程时,需要根据实际场景和需求进行权衡。
5. 其他
JS async/await 与协程的关系
在 JavaScript 社区中,关于协程的讨论较为罕见,通常对 async/await
的讨论集中于其解决回调地狱的问题。这一现象引发了作者的思考:async/await
是否与其他语言中的用户线程(例如 Java 虚拟线程)有本质的不同?具体而言,JavaScript 中的 async/await
是否可以视作协程的一种形式?
与其他编程语言的协程实现不同,JavaScript 采用的是单线程事件循环模型,并不直接支持传统意义上的多线程(不考虑如 Web Worker 等特定的并行计算 API)。因此,JavaScript社区不会存在用户线程的讨论。然而,JavaScript 的事件循环机制本质上可以被视为一种用户线程的实现。这意味着,JavaScript 环境天然支持某种形式的用户线程,因此async/await
仍然可以视为协程的一种表现形式。
6. 参考文献
7. 附录
性能对比代码
kotlin
val cpu = mutableListOf<Double>()
val mem = mutableListOf<Long>()
val listLock = ReentrantLock()
fun getCpuUsage(): Double {
val osBean = ManagementFactory.getOperatingSystemMXBean() as com.sun.management.OperatingSystemMXBean
return osBean.systemCpuLoad * 100 // 转换为百分比
}
fun getMemoryUsage(): MemoryUsage {
val memoryBean: MemoryMXBean = ManagementFactory.getMemoryMXBean()
return memoryBean.heapMemoryUsage // 获取堆内存使用情况
}
fun monitor() {
// 创建线程定期读取CPU和内存使用情况
thread(isDaemon = true) {
while (true) {
try {
// val cpuUsage = getCpuUsage()
val memoryUsage = getMemoryUsage()
// cpu.addLast(cpuUsage)
listLock.lock()
mem.addLast(memoryUsage.used)
Thread.sleep(10) // 每10ms读取一次
} finally {
listLock.unlock()
}
}
}
}
fun writeListToFile(list: List<Double>, filePath: String) {
// 创建File对象
val file = File(filePath)
// 使用bufferedWriter写入文件
file.bufferedWriter().use { writer ->
list.forEach { item ->
writer.write(item.toString())
writer.newLine() // 每个元素写入后换行
}
}
println("列表已成功写入到 $filePath")
}
const val REPEAT_COUNT = 1_000_000
const val SLEEP_TIME = 100L
fun testThreadPool(): Long {
val pool = Executors.newCachedThreadPool()
val latch = CountDownLatch(REPEAT_COUNT)
val now = System.currentTimeMillis()
repeat(REPEAT_COUNT) {
pool.submit {
Thread.sleep(SLEEP_TIME)
latch.countDown()
}
}
latch.await()
val time = System.currentTimeMillis() - now
pool.shutdown()
return time
}
fun testVirtualThread(): Long {
val latch = CountDownLatch(REPEAT_COUNT)
val now = System.currentTimeMillis()
repeat(REPEAT_COUNT) {
Thread.ofVirtual().start {
Thread.sleep(SLEEP_TIME + (Math.random() * 5000.0 / 1000.0).toLong() )
latch.countDown()
}
}
latch.await()
return System.currentTimeMillis() - now
}
fun testCoroutine(): Long {
val now = System.currentTimeMillis()
runBlocking {
repeat(REPEAT_COUNT) {
launch {
delay(SLEEP_TIME)
}
}
}
return System.currentTimeMillis() - now
}
fun main(args: Array<String>) {
// 检查是否有传入参数
if (args.isEmpty()) {
println("没有输入测试类型")
exitProcess(-1)
}
val type = args[0]
monitor()
var time = 0L
when (type) {
"0" -> time = testThreadPool()
"1" -> time = testVirtualThread()
"2" -> time = testCoroutine()
}
writeListToFile(mutableListOf(time.toDouble()), "./res/${type}_time.txt")
val size = mem.size
listLock.lock()
val currentMem = mem.subList(0, size).map { it/ 1024.0.pow(2) }
// writeListToFile(currentCpu, "./res/${type}_cpu.txt")
writeListToFile(currentMem, "./res/${type}_mem.txt")