把协程里的 “并发/异步、阻塞/非阻塞” 讲清楚
开篇:把协程里的“并发/异步、阻塞/非阻塞”讲清楚
用过协程的人,大多都经历过这些困惑:到底什么时候是“并发”,什么时候是“异步”?明明用了 suspend,为什么看起来还是在“阻塞”?别担心,你不是一个人。下面用一组可以直接运行的示例,把这些概念讲透,让你亲眼看到它们的差异。
小提醒:严格说来,协程跑在“线程池”上,而不是某个特定线程。为了便于理解,文中会用“线程”作为类比。
先准备两个函数,分别代表阻塞型工作和非阻塞型工作,方便观察差异。
阻塞型工作:线程忙碌中
suspendfunworkOnBlockingTask(time: Long) {
Thread.sleep(time)
yield()
}
Thread.sleep()用来模拟真正的计算密集型工作(比如给大数组排序、图像处理等)。这段时间线程确实忙着干活,干不了别的。yield()很关键,它人为制造一个挂起点,让同线程上的其他协程也有机会执行。没有它,当前协程会一直霸占线程到结束,协作式多任务也就无从谈起。
非阻塞型工作:线程空闲中
suspendfunworkOnNonBlockingTask(time: Long) {
delay(time)
}
delay()会挂起协程并释放线程。就像发出一次网络请求后,你不用原地干等,线程可以转身去帮别的协程干活,等定时器到了或 I/O 完成再回来继续。
四个动作:两组阻塞 vs 两组非阻塞
接下来用阻塞和非租塞函数分别构造两个动作(action)
// 阻塞型
suspendfunactionA() {
println("A >> STARTED")
workOnBlockingTask(500)
println("A >> Step 1 done")
workOnBlockingTask(300)
println("A >> Step 2 done")
println("A >> DONE")
}
suspendfunactionB() {
println("B >> Started")
workOnBlockingTask(200)
println("B >> Step 1 done")
workOnBlockingTask(400)
println("B >> Step 2 done")
println("B >> DONE")
}
// 非阻塞型
suspendfunactionC() {
println("C >> Started")
workOnNonBlockingTask(500)
println("C >> Step 1 done")
workOnNonBlockingTask(300)
println("C >> Step 2 done")
println("C >> DONE")
}
suspendfunactionD() {
println("D >> Started")
workOnNonBlockingTask(200)
println("D >> Step 1 done")
workOnNonBlockingTask(400)
println("D >> Step 2 done")
println("D >> DONE")
}
A、B 表示 CPU 密集;C、D 表示 I/O 等待。 时间刻意不同,方便观察交错执行。
实验一:顺序执行(基准线)
// 阻塞
val blockingTime = measureTimeMillis {
runBlocking {
actionA()
actionB()
}
}
println("Blocking operations: $blockingTime ms")
// 非阻塞
val nonBlockingTime = measureTimeMillis {
runBlocking {
actionC()
actionD()
}
}
println("Non-blocking operations: $nonBlockingTime ms")
阻塞和非阻塞两组,都是一步一步顺序跑完,整体都在 1400ms 左右。这个结果作为对照基线。
输出结果:
// 阻塞型工作
A >> STARTED
A >> Step 1 done
A >> Step 2 done
A >> DONE
B >> Started
B >> Step 1 done
B >> Step 2 done
B >> DONE
Blocking operations: 1441 ms
// 非阻塞型工作
C >> Started
C >> Step 1 done
C >> Step 2 done
C >> DONE
D >> Started
D >> Step 1 done
D >> Step 2 done
D >> DONE
Non-blocking operations: 1419 ms
实验二:并发执行
用 launch 把动作并发跑起来:
// 阻塞
val blockingTime = measureTimeMillis {
runBlocking {
launch { actionA() }
launch { actionB() }
}
}
println("Blocking operations: $blockingTime ms")
// 非阻塞
val nonBlockingTime = measureTimeMillis {
runBlocking {
launch { actionC() }
launch { actionD() }
}
}
println("Non-blocking operations: $nonBlockingTime ms")
输出:
// 阻塞型工作
A >> STARTED
B >> Started
A >> Step 1 done
B >> Step 1 done
A >> Step 2 done
A >> DONE
B >> Step 2 done
B >> DONE
Blocking operations: 1442 ms
// 非阻塞型工作
C >> Started
D >> Started
D >> Step 1 done
C >> Step 1 done
D >> Step 2 done
D >> DONE
C >> Step 2 done
C >> DONE
Non-blocking operations: 815 ms
阻塞这组还是约 1400ms。虽然“并发”了,但实质是在同一线程上“有礼貌地轮流”:A 跑到 yield()才让 B 上,再切回 A,来回切换。这不是真正的并行。非阻塞这组时间几乎砍半!原因是两个协程几乎同时开始“等待”,各自的定时器并行走表;线程在等待期间是空闲的,可以去干别的活。更具体的时间线: 0ms:C 启动, delay(500)挂起0ms:C 挂起后 D 立即启动, delay(200)挂起0–200ms:两个都在等,线程空闲 200ms:D 醒来,打印 Step 1,然后 delay(400)再挂起200–500ms:又都在等 500ms:C 醒来,打印 Step 1,然后 delay(300)600ms:D 再醒来完成 800ms:C 再醒来完成 这就是非阻塞 I/O 的优势:等待不占据线程时间。
实验三:异步执行
给 runBlocking 指定调度器,放到默认线程池上:
// 阻塞
val blockingTime = measureTimeMillis {
runBlocking(Dispatchers.Default) {
launch { actionA() }
launch { actionB() }
}
}
println("Blocking operations: $blockingTime ms")
// 非阻塞
val nonBlockingTime = measureTimeMillis {
runBlocking(Dispatchers.Default) {
launch { actionC() }
launch { actionD() }
}
}
println("Non-blocking operations: $nonBlockingTime ms")
这次两组都在 ~800ms 左右完成。因为现在有多个线程真的“同时”在干活——无论任务是阻塞还是非阻塞,都能吃到并行带来的红利。
结果:
// 阻塞型工作
A >> STARTED
B >> Started
B >> Step 1 done
A >> Step 1 done
B >> Step 2 done
B >> DONE
A >> Step 2 done
A >> DONE
Blocking operations: 834 ms
// 非阻塞型工作
C >> Started
D >> Started
D >> Step 1 done
C >> Step 1 done
D >> Step 2 done
D >> DONE
C >> Step 2 done
C >> DONE
Non-blocking operations: 814 ms
再往下看:到底发生了什么
在每个动作开头加三行调试信息:
println("A >> Job: ${coroutineContext[Job]}")
println("A >> Dispatcher: ${coroutineContext[ContinuationInterceptor]}")
println("A >> Thread: ${Thread.currentThread().name}")
顺序执行:所有打印都显示在主线程上,同一个 runBlocking作用域内共享同一个 Job 和 Dispatcher。并发执行:仍在主线程,但每个 launch都是各自的 Job,Dispatcher 共享。异步执行:线程名变成了 DefaultDispatcher-worker-*,不同协程跑在不同工作线程上,真正并行无误。
// 阻塞型工作
A >> Job: BlockingCoroutine{Active}@57855c9a
A >> Dispatcher: BlockingEventLoop@3b084709
A >> Thread: main
[...]
B >> Job: BlockingCoroutine{Active}@57855c9a
B >> Dispatcher: BlockingEventLoop@3b084709
B >> Thread: main
// 非阻塞型工作
C >> Job: BlockingCoroutine{Active}@184f6be2
C >> Dispatcher: BlockingEventLoop@56aac163
C >> Thread: main
[...]
D >> Job: BlockingCoroutine{Active}@184f6be2
D >> Dispatcher: BlockingEventLoop@56aac163
D >> Thread: main
顺序执行的结果: 都在主线程,同一个 runBlocking 作用域里共享相同的 Job 和 Dispatcher。
// 阻塞型工作
A >> Job: StandaloneCoroutine{Active}@3712b94
A >> Dispatcher: BlockingEventLoop@2833cc44
A >> Thread: main
[...]
B >> Job: StandaloneCoroutine{Active}@536aaa8d
B >> Dispatcher: BlockingEventLoop@2833cc44
B >> Thread: main
// 非阻塞型工作
C >> Job: StandaloneCoroutine{Active}@2bbf4b8b
C >> Dispatcher: BlockingEventLoop@30a3107a
C >> Thread: main
[...]
D >> Job: StandaloneCoroutine{Active}@6b57696f
D >> Dispatcher: BlockingEventLoop@30a3107a
D >> Thread: main
并发执行的结果: 仍在主线程,但每个 launch 都有了自己的 Job,同时共享同一个 Dispatcher。
// 阻塞型工作
A >> Job: StandaloneCoroutine{Active}@b9bf36d
A >> Dispatcher: Dispatchers.Default
A >> Thread: DefaultDispatcher-worker-2
[...]
B >> Job: StandaloneCoroutine{Active}@44799410
B >> Dispatcher: Dispatchers.Default
B >> Thread: DefaultDispatcher-worker-3
// 非阻塞型工作
C >> Job: StandaloneCoroutine{Active}@656b6164
C >> Dispatcher: Dispatchers.Default
C >> Thread: DefaultDispatcher-worker-3
[...]
D >> Job: StandaloneCoroutine{Active}@79e8c7c0
D >> Dispatcher: Dispatchers.Default
D >> Thread: DefaultDispatcher-worker-2
异步执行的结果: 可以看到使用了不同的工作线程,验证了真正的并行。
关键结论:
阻塞 vs 非阻塞的本质,不在于“是不是 suspend”,而在于你的工作“是否释放线程”。
delay会释放,sleep不会。并发 vs 异步(更贴近“并行”):前者更多是单线程上的协作式轮换;后者是多线程同时跑。
真正弄懂的最好方式是“自己跑一遍代码”。改改时间、换换调度器、加几个任务,或在同一协程里混搭阻塞/非阻塞。多试几次,直觉就自然建立起来了。
打印调试信息很有用:看线程名、看 Job,是理解行为变化的“显微镜”。
-- END --
推荐阅读