每次群里有人问“Kotlin协程到底是什么”,下面总有很多回答,有人说是“轻量级线程”,有人说是“可以暂停的函数”,还有人说“就是回调的语法糖”。这些说法都对,但都不够透,导致很多人看完概念还是写不出代码,照抄Demo能跑,一换场景就懵。
这篇文章我想换个思路讲协程。不讲那些绕来绕去的理论定义,而是从“它到底解决了什么痛苦”出发,一段一段带你过关键概念、看真实代码、记避坑清单。你不需要有任何协程基础,但最好写过一点Kotlin,哪怕只写过Hello World都行。
1. 协程到底解决了什么问题
先放下概念,我们回忆一下过去写异步代码的日子。
拿一个最常见的登录场景举例:用户输入账号密码,点登录按钮,发请求,拿到结果后更新UI。放在几年前,标准写法是这样的:
loginApi.login(userName, password, object : Callback<LoginResult> { override fun onSuccess(result: LoginResult) { runOnUiThread { tvStatus.text = "登录成功: ${result.token}" } } override fun onError(e: Exception) { runOnUiThread { tvStatus.text = "登录失败: ${e.message}" } } })这只是单个请求。如果你要“登录成功后顺便拉取用户信息再刷新页面”,代码就开始往“回调地狱”一路狂奔了。回调套回调,逻辑一多,缩进越来越深,出错的概率呈指数增长。而且你永远要记得“UI更新要切回主线程”,漏掉一次就是崩溃。
协程对这个问题的解法非常暴力:把异步代码写得像同步代码一样。
lifecycleScope.launch { val result = withContext(Dispatchers.IO) { loginApi.login(userName, password) // 挂起函数,自动切线程 } tvStatus.text = "登录成功: ${result.token}" }注意这段代码里没有回调,没有手动切换线程,代码从上往下读就是业务顺序。这就是协程最核心的价值:用同步的写法,干异步的事。它不是帮你多开线程,而是帮你摆脱线程切换带来的心智负担。
我个人很喜欢一个比喻:把线程想象成一个厨师,把协程想象成菜谱。以前一个厨师做菜,必须从头做到尾,中间切菜的时候不能去炒别的菜;协程的做法是,切菜切到一半菜刀钝了,厨师可以把这条菜谱暂时放一边,拿起另一条菜谱先炒个饭,等磨好刀再接着回来切菜。线程一直在干活,菜谱暂停了,但厨师没闲着。
放到技术层面:挂起(suspend)不等于阻塞。线程被阻塞时啥也干不了,但协程挂起时,它所在的线程可以转头去执行别的协程。这就是协程“轻量”的根源。
这个内容解决了什么问题?一句话:它让你从回调地狱和线程切换的泥潭里爬出来,用更接近思维习惯的方式写并发代码。适合所有写Kotlin的服务端、Android、桌面端开发者去学习。
2. 核心概念逐个拆解
协程最劝退新手的地方,就是概念名词太多:suspend、Scope、Dispatcher、Job、Deferred……而且这些概念是互相纠缠的,不搞懂底层联系,光看单词根本没法串联起来。这一节我按“从编译原理到运行时”的顺序挨个讲,保证每个词都能落地。
2.1 suspend关键字:协程世界的“暂停键”
Kotlin协程的基石是suspend关键字。一个函数如果被suspend修饰,就代表它是可挂起的函数,可以在函数内部调用其他挂起函数。普通函数不能反过来调它。
suspend fun fetchUserInfo(): UserInfo { return withContext(Dispatchers.IO) { api.getUserInfo() } }很多新手到这一步就卡住:为什么挂起函数可以“暂停”?谁来实现暂停?答案藏在编译器里。Kotlin编译器会把带suspend的函数改写成一种状态机的形式——每次遇到挂起点,就记录当前执行到哪一行、局部变量是什么,然后返回;恢复执行时,再从记录的位置接着往下跑。
这就是我说的CPS(Continuation Passing Style)变换的通俗版本。你不用会写CPS,但你得理解一件事:挂起不是魔法,它只是把“稍后继续执行的现场”保存了起来。现场就是局部变量加执行位置。
挂起和阻塞最大的区别是什么?我用一个生活场景说明。你在食堂排队打饭,如果排队时你死死站在原地,什么都干不了,这叫阻塞;如果你把饭卡交给前面的朋友,先回工位写两行代码,等你朋友快排到了再过来,这叫挂起。阻塞浪费的是“人”本身,挂起浪费的只是“这个人当前正在执行的那个任务”的进度。
所以suspend函数不一定会真的挂起。它内部如果没有任何耗时的操作、没有调用其他会挂起的函数,那它本质上就是普通函数,只是穿了一件马甲。这一点很多人会误解,后面我会在问题排查里再强调一次。
2.2 CoroutineScope:协程的“容器”和“计划生育”
协程不能无根而生,它必须被放进一个作用域里。这个作用域就是CoroutineScope。
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)CoroutineScope干的事有两件:第一,给协程提供默认的上下文(比如跑在哪个线程、用什么异常处理);第二,管理所有在这个作用域里启动的协程。
这里必须说一下为什么不能随便用GlobalScope。GlobalScope是一个全局空作用域,用它会启动一个跟任何组件生命周期都没关系的协程。如果你在Activity里用GlobalScope.launch发了一个网络请求,用户退出了页面,这个请求还在后台跑,线程没有引用Activity了,Activity通常不会被泄漏,但UI更新会成为空指针崩溃。更麻烦的是,你没法统一取消,协调起来极其痛苦。
正确的做法是用和生命周期绑定的作用域。Android上最典型的就是lifecycleScope和viewModelScope:
class MainViewModel : ViewModel() { fun loadData() { viewModelScope.launch { // 当ViewModel被clear时,这里的所有协程自动取消 } } }viewModelScope会在ViewModel销毁时自动取消所有子协程,你不用手动写任何取消逻辑。这就是结构化并发的入门形态:协程之间有父子关系,父协程取消,子协程全部取消;子协程抛出异常,会影响父协程。你不需要到处cancel(),只要把协程放在合适的Scope里,生命周期会帮你兜底。
2.3 Dispatcher:控制协程跑在哪个线程
Dispatcher决定协程在哪个线程或线程池上执行。这其实是你写协程时碰得最多的概念,因为线程切换基本都靠它。
Kotlin提供了四类标准Dispatcher:
| Dispatcher | 运行环境 | 适用场景 |
|---|---|---|
| Dispatchers.Main | 主线程(Android),需要依赖Android框架 | UI操作、更新界面 |
| Dispatchers.IO | 线程池,适合IO密集型任务 | 网络请求、数据库读写、文件操作 |
| Dispatchers.Default | CPU密集型线程池,大小等于CPU核心数 | 大量计算、排序、解析 |
| Dispatchers.Unconfined | 不限制线程,在哪个线程启动就在哪继续 | 测试、个别极短任务,不推荐日常使用 |
选Dispatcher的直觉是:Main只管UI,IO管一切跟外部设备交互的慢操作,Default管吃CPU的活。但新手最容易犯的错是把所有耗时操作都丢给IO,不管它是不是真的阻塞。比如一个大数组的排序是CPU密集型的,它不读文件也不做网络请求,塞给IO线程池反而会占用IO资源,拖慢真正的IO任务。正确做法是排序用Default。
withContext是切换Dispatcher最常用的工具。它本身是个挂起函数,接收一个Dispatcher,在内部执行代码块,执行完后带着结果回到原来的上下文:
lifecycleScope.launch { // 此时在主线程 val result = withContext(Dispatchers.IO) { // 此时在IO线程 networkApi.fetchData() } // 回到主线程,可以安全更新UI tvContent.text = result.toString() }withContext本质上是一个挂起点,线程切换对开发者来说是透明的,代码栈上看不出来,但运行时它会让代码块跑在指定的Dispatcher上,再带着返回值把控制权交还回来。这一点一定要理解透,后面排查“为什么卡了”就靠它。
2.4 Job与Deferred:协程的“身份证”和“返回值”
launch启动协程后返回一个Job对象。Job代表协程本身,你可以用它判断协程状态、取消协程、等待协程完成。
val job = lifecycleScope.launch { // 业务逻辑 } job.isActive // 是否活跃 job.cancel() // 主动取消 job.join() // 等待协程结束Job是有状态的:New、Active、Completing、Completed、Cancelling、Cancelled。多数情况下你只需要关心“Active”和“Completed/Cancelled”,因为我们在日常开发中很少需要手动维护状态,Scope会帮你处理。
async是另一个启动协程的方式,它返回的是Deferred——一个带返回值的Job子类。Deferred可以理解为“未来的结果容器”,你通过.await()拿结果:
lifecycleScope.launch { val deferred1 = async(Dispatchers.IO) { api.getUserInfo() } val deferred2 = async(Dispatchers.IO) { api.getFriendList() } val userInfo = deferred1.await() // 会挂起,直到结果可用 val friendList = deferred2.await() // 两个请求是并发执行的 }注意,async启动的两个协程是并发跑的,不是按顺序执行。用await()只是“等结果”,不会让另一个协程先执行完再来执行这个。这个特性在合并多个接口返回时特别香。
3. 实操:从零搭一个协程Demo
铺垫了这么多概念,还是要落到代码上。这一节我带你从环境搭建开始,完整跑一个“并发请求合并结果”的Demo。思路很简单:模拟一个登录接口和一个个人信息接口,两个请求并发发出,都返回后合并展示。
3.1 环境准备与依赖引入
如果你用的是Android Studio,需要在build.gradle.kts(Module级)中加入协程依赖:
dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") }如果做的是纯JVM项目(比如命令行工具),去掉-android版本,用kotlinx-coroutines-core就可以了。这里提醒一句:协程库版本跟Kotlin版本有兼容要求,如果你遇到Module was compiled with an incompatible version of Kotlin这类错误,绝大多数是协程库版本太老或太新,和Kotlin编译器不匹配,换个对应版本就好。
3.2 写一个模拟网络请求的挂起函数
为了不引入Retrofit也能看明白,我用delay模拟网络延迟:
suspend fun fetchLoginToken(): String { delay(1000) return "token-9527" } suspend fun fetchUserProfile(token: String): String { delay(800) return "用户信息, token=$token" }这里有两点值得说明。第一,delay是协程里推荐的“睡眠”方式,它不是Thread.sleep,不会阻塞线程,而是挂起协程并指定恢复时间。在协程里写Thread.sleep是非常不推荐的,它会白白占住线程,导致线程池资源紧张。第二,这两个函数是suspend的,意味着只能在协程或者其他挂起函数里调用。
3.3 用async+await实现并发合并
下面把两个接口并发跑起来:
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { val startTime = System.currentTimeMillis() val loginDeferred = async(Dispatchers.IO) { fetchLoginToken() } val token = loginDeferred.await() val profileDeferred = async(Dispatchers.IO) { fetchUserProfile(token) } val profile = profileDeferred.await() val totalTime = System.currentTimeMillis() - startTime println("登录结果: $token, 用户信息: $profile, 总耗时: ${totalTime}ms") } } }运行一下你会发现总耗时大概在1000ms左右,而不是1800ms。为什么?因为两个async并发执行了,两者中最长的那个是1秒,所以总耗时接近1秒。如果你把第二个请求放在loginDeferred.await()之后才用async启动,那就退化成串行,总耗时变成1.8秒。
这里有个特别容易踩的坑:写了async但不立即启动。async函数一旦被调用,协程立刻开始执行,不需要等await()。很多人误以为await()才开始执行,结果写代码时把async放在一个很晚才轮到的地方,产生了预期外的锁等待。正确的理解是:async是“发射火箭”,await是“等火箭落地”。
3.4 用withContext实现“并发但各自切线程”
有时候你并不需要并发启动两个协程,只是想快速切个线程执行一段操作。这时用async反而显得重。更轻的做法是withContext:
lifecycleScope.launch { val data = withContext(Dispatchers.IO) { // 模拟数据库查询 queryDb() } updateUI(data) }我对两者的选型建议是这样的:如果你需要并行执行多个任务,或者要对每个任务单独控制结果,选async;如果只是“这段代码要换个线程跑”,选withContext。async的问题在于它创建了一个新的子协程,有额外的状态管理,用多了会增加结构复杂度,没必要。
3.5 在Android里正确绑定生命周期
Demo最后一步,把协程跟生命周期绑起来,防止页面销毁后协程还在跑。这一步极其重要,它直接关系到内存泄漏。Android提供了两个开箱即用的Scope:
| Scope | 所属类 | 取消时机 |
|---|---|---|
| lifecycleScope | LifecycleOwner(Activity/Fragment) | 组件销毁时 |
| viewModelScope | ViewModel | ViewModel.clear()时 |
在Activity里直接用lifecycleScope,在ViewModel里用viewModelScope。如果你的网络层是仓库类,它不应该持有UI的Scope,更好的做法是让挂起函数只负责执行,Scope由调用方传入,这样仓库层既不知道UI存在,也不会泄漏生命周期。
这里给一句实在话:如果你在真实的App项目里写了
GlobalScope.launch,几乎可以肯定这是一个隐患,早晚会出问题。把Scope的选用变成肌肉记忆,比背任何概念都管用。
4. 常见问题与排查技巧实录
协程的代码写起来很爽,出了问题排查起来却可能很痛苦。因为挂起函数在IDE里单步调试时,步进逻辑跟普通函数完全不一样,经常跳来跳去。这一节我把实操中遇到频率最高的问题整理成速查表,再逐条展开说排查思路。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 协程没执行 | Scope已取消,或launch在非Active状态 | 检查生命周期,用isActive判断 |
| 主线程卡顿 | 在Main里做了耗时操作,忘切Dispatcher | 用withContext(Dispatchers.IO)包裹耗时逻辑 |
| withContext里改了UI结果没用 | 更新UI发生在withContext代码块内部 | withContext结束后,回到外层再更新UI |
| 协程被取消后还在跑 | 取消不是强杀,需要挂起点配合 | 使用suspend函数作为检查点,delay(yield()) |
| 抛异常直接崩溃 | 协程异常默认会传播到父结构 | 用CoroutineExceptionHandler或SupervisorJob |
| 数据返回顺序错乱 | 并发任务各自完成时间不同 | 用async/await聚合,别手动标记完成状态 |
下面挑三个最典型的展开讲。
4.1 为什么你在Main里写的“耗时”操作还是卡了
很多新手以为写个suspend就万事大吉,代码变成这样:
lifecycleScope.launch { val result = fetchData() // fetchData是suspend,但内部没有切换线程 updateUI(result) } suspend fun fetchData(): String { // 做了一个很重的计算,耗时5秒 Thread.sleep(5000) return "data" }这段代码跑起来主线程照样卡死。为什么?因为suspend不等于“自动异步”。如果你在suspend函数里直接写了Thread.sleep,它就是在当前线程(这里是主线程)上睡觉。协程的挂起需要调用真正的挂起函数,比如delay、withContext。Thread.sleep是Java的东西,协程管不着它。
我的排查经验是:看到“协程没生效”的问题,先检查suspend函数体里有没有至少一个真正的挂起函数调用。如果从头到尾读下来一个挂起点都没有,那这函数就是普通函数,执行它时线程一样会被占住。
4.2 协程取消不生效
Job.cancel()调用后协程会进入Cancelling状态,但如果你在协程里做的全是非协作的CPU计算,没有调用任何挂起函数,这个取消可能很长时间都观察不到。
val job = lifecycleScope.launch { var i = 0 while (i < Int.MAX_VALUE) { i++ // 这里没有任何挂起点,取消不会立刻生效 } } job.cancel()为什么?取消的机制是协作式的:协程库不能随便中断正在执行的代码,它只能在挂起点抛一个CancellationException,然后让协程体退出。如果你的循环里没有任何挂起点,取消信号就没有“可乘之机”。解决办法是在循环里加检查:
while (i < Int.MAX_VALUE) { ensureActive() // 如果协程被取消,这里会立刻抛出CancellationException i++ }或者用currentCoroutineContext().isActive判断一下。这个坑我踩过好几次,尤其是做图片处理、视频编码这类CPU密集任务时,取消不生效会让用户感觉“点了取消页面还在转”。
4.3 SupervisorJob什么时候用
默认情况下,协程的作用域是“一条船沉,全员沉没”——子协程抛出未捕获异常,会向上传播导致兄弟协程也取消,甚至让整个Scope挂掉。这在很多场景下是合理的,比如页面发三个并行请求,其中一个失败,页面整体应该提示错误,其他两个继续跑已经没意义了。
但有些场景不是你想要的。比如你同时维护了两个独立的数据模块,模块A抛异常不该影响模块B。这时你需要SupervisorJob。它隔离了异常:子协程的异常只会终止自己,不会让兄弟协程和父Scope一起遭殃。
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) scope.launch { throw RuntimeException("A挂了") } scope.launch { // 这个协程仍然会正常执行 println("B正常") }viewModelScope用的是SupervisorJob + Main.immediate,所以ViewModel里多个launch互相不会因为一个异常全部取消,这也是Google故意设计的。
我有一个经验判断法:如果多个协程之间是“并行独立服务不存在依赖”的关系,用SupervisorJob;如果存在明确的依赖关系(比如一个失败了其他就没必要执行),就保留默认的取消传播。别一律用SupervisorJob把异常全部隔离,那样会让错误的发现变难。
5. 更进一步的协程技巧与心得
到了这一步,你应该已经能写出能跑的协程代码了。最后再分享几个我在实际项目中高频使用、且网上教程经常一笔带过的小技巧。
5.1 用withTimeout给接口加超时控制
网络总会出现慢到离谱的情况,直接这么处理:
withContext(Dispatchers.IO) { withTimeout(3000) { // 3秒内没返回,会抛TimeoutCancellationException api.fetchData() } }如果你希望超时后给一个默认值而不是直接抛异常,用withTimeoutOrNull,它超时返回null,这样你连异常处理都省了。这个api看着不起眼,但真实项目中几乎必备,谁也不想让用户无限等待。
5.2 别在协程里持有非线程安全的东西
协程虽然能让你写出同步风格代码,但底层线程切换是真实发生的。withContext(Dispatchers.IO)代码块里如果传入了外部的可变对象,并且你从多个协程同时写这个对象,数据竞争跟普通多线程一样存在。比如两个协程同时修改一个HashMap,该崩还是崩。
我见过的典型翻车现场:把MutableSharedFlow当成总线,在多个协程里同时emit,没有做并发控制;以及协程内直接修改ArrayList,然后切回主线程读列表。
解决思路跟多线程一样:要么加锁,要么用线程安全的集合,要么用协程通道(Channel)做串行化。千万别因为协程写法像单线程,就真的当单线程用。
5.3 Flow是协程的“数据流版”
Flow简单理解是“可以发射多个值的挂起序列”。它和协程配合尤其默契:数据库监听、传感器数据、分页加载,这些持续变化的数据源都适合用Flow表达。它的完整体系是另一个大话题,但你会用协程之后,Flow的很多概念是水到渠成的——因为collect就是一个挂起函数,它就在协程里跑。
5.4 给团队的一句建议:规范先行
如果你现在不是一个人在写了,建议在项目里统一几条协程规范,否则后期非常痛苦:不允许直接使用GlobalScope,所有协程必须绑定生命周期Scope;挂起函数里不允许出现Thread.sleep;耗时逻辑必须放在Dispatchers.IO或Dispatchers.Default;并发请求优先用async/await,不要用回调去手动凑并发。这些规则简单明确,配合代码评审完全可以执行到位。
最后再分享一点个人心得:学协程最大的障碍从来不是语法,而是“把代码当作并发程序来想”的思维定式。你越是想用同步思维去套,就越容易漏掉挂起点、线程和生命周期。反过来,每次写一个挂起函数,先问一句“它跑在哪个线程?挂起会不会阻塞主线程?协程取消后它能不能及时退出?”——带着这三个问题去写,基本不会出大错。
协程这套东西,用起来是真的顺手,但底下的调度机制还是值得花时间摸一摸的。一旦你理解了对编译器的状态机改造和调度器的线程模型,以后不管看深度博客还是排查疑难问题,都会通透很多。