最近 review 代码,又看到一个很熟悉的写法:
kotlin
val executor = Executors.newFixedThreadPool(4)
val dispatcher = executor.asCoroutineDispatcher()
问了一下原因:"任务有点多,怕一下子跑太猛,所以限制一下并发。"
这个思路本身没问题,只是它更像 Java 时代的条件反射------把"控制并发"直接等价成"控制线程数"。
但在 Kotlin 协程里,这个等价关系其实已经不成立了。
一、协程本身已经有一套调度系统
Kotlin 协程默认就有两套调度器:
Dispatchers.Default:CPU 密集任务(共享线程池)Dispatchers.IO:IO 阻塞任务(弹性线程池)
它们有一个关键特点:全局共享
你写的每一个 launch {},本质上都是进入同一个调度体系排队执行,而不是自己占一套资源。
所以协程的默认假设其实是:
线程池是运行时基础设施,不是业务控制手段。
一旦你在每个模块里都 newFixedThreadPool,这个假设就被打破了。
系统很快会变成这样:
sql
Default pool
IO pool
Module A pool
Module B pool
SDK pool
...
看起来"更可控",但实际上是把统一调度拆碎了。
二、"限流"和"隔离"不是一件事儿
这里是关键点。
很多人用线程池,其实想解决的是:
"最多同时跑 4 个请求"
但线程池表达的是:
- 用多少线程执行
- 任务在哪执行
- 是否和其他模块隔离资源
这两个维度不是一回事。
1. 语义被埋进了线程配置里
kotlin
newFixedThreadPool(4)
看起来是在做限流,但实际上表达的是:
"这里有 4 条线程"
问题是,线程数 ≠ 并发语义。
当需求变成:
- 从"4 并发"变成"串行"
- 或"新任务覆盖旧任务"
线程池这个模型就开始不够用了。
2. 共享调度能力被破坏
协程的优势之一是:
线程可以复用,全局调度统一管理
但一旦拆线程池,就变成:
- A 模块一套线程
- B 模块一套线程
- SDK 再来一套
结果是:
- 线程利用率下降
- 空闲资源无法共享
- 调度变成碎片化
所谓"更可控",很多时候只是"更分裂"。
3. 排查问题会变复杂
出了问题之后你会看到:
- IO pool 在忙
- A pool 在排队
- B pool 卡住了
但真正难的是回答:
到底是并发太高,还是资源分配不合理?
如果用的是协程原语(Semaphore / Channel / limitedParallelism),你看到的是:
- 并发上限是多少
- 有没有排队
- 哪个环节被限流
这些信息是"业务语义级"的。
而线程池暴露的是"执行细节"。
三、一个更合理的折中:limitedParallelism
如果只是想"限制某类任务并发",其实可以用:
kotlin
val networkDispatcher = Dispatchers.IO.limitedParallelism(10)
或者:
kotlin
val imageDispatcher = Dispatchers.Default.limitedParallelism(2)
它的特点很直接:
- 不创建新线程
- 复用全局调度池
- 但限制并发上限
适合大多数"怕打爆系统"的场景。
四、真正表达并发语义的方式
1. Semaphore:限制并发数
kotlin
val semaphore = Semaphore(4)
suspend fun loadData() {
semaphore.withPermit {
api.request()
}
}
表达的是:
同时最多 4 个任务在执行
2. Channel / Flow:任务排队
kotlin
val channel = Channel<Task>()
launch {
for (task in channel) {
handle(task)
}
}
表达的是:
所有任务按队列串行消费
3. Structured Concurrency:控制生命周期
kotlin
coroutineScope {
val a = async { fetchA() }
val b = async { fetchB() }
}
表达的是:
这一组任务是一个整体,要么一起成功,要么一起取消
五、什么时候新线程池还是合理的
线程池不是不能用,只是用途变了。
比较合理的场景:
- CPU 密集型任务(图像、加密、AIGC)
- 第三方 SDK 阻塞调用(不可控)
- 音视频 pipeline 这种长任务流,以及下载任务
这些本质是在做:
资源隔离,而不是并发控制
六、简单的分层理解
可以这么拆:
bash
UI 层:管生命周期(viewModelScope)
Domain 层:管并发语义(Semaphore / async)
Data 层:只负责数据(suspend / Flow)
Data 层不应该知道"并发是多少",它只负责"给数据"。
七、对比一下几种方式
| 方式 | 本质 | 推荐 |
|---|---|---|
| 自建线程池 | 资源隔离 | ❌ 慎用 |
| limitedParallelism | 调度限制 | ✅ |
| Semaphore | 并发语义 | ✅ |
| Channel / Flow | 数据流 | ✅ |
| structured concurrency | 生命周期 | ✅ |
八、总结
线程池解决的是:
"任务在哪里跑"
而协程里的并发控制解决的是:
"允许多少任务同时发生"
很多问题之所以变复杂,就是因为把"并发语义"错误地降级成了"线程配置"。
在协程体系里,更自然的做法是:
- 用
limitedParallelism控制资源上限 - 用
Semaphore表达并发规则 - 用
Channel / Flow表达任务流 - 用 structured concurrency 管生命周期
而不是一遇到问题就:
"要不要先新建个线程池稳一下?"
完