搜狐技术产品

把协程里的 “并发/异步、阻塞/非阻塞” 讲清楚

开篇:把协程里的“并发/异步、阻塞/非阻塞”讲清楚

用过协程的人,大多都经历过这些困惑:到底什么时候是“并发”,什么时候是“异步”?明明用了 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 --

推荐阅读