Kotlin Coroutine 异常处理机制全面解析

Kotlin Coroutine 异常处理机制全面解析

引言

Kotlin Coroutines 已经成为 Android 和 Kotlin 后端开发中处理异步操作的标准方案。然而,异常处理机制是协程中最容易让人困惑的部分之一——launchasync 的异常传播行为不同,SupervisorJob 和普通 Job 的表现也不同,结构化并发的取消和异常传播更是让很多开发者踩坑。

本文从协程异常处理的底层原理出发,覆盖所有常见场景和最佳实践,帮助你彻底理解协程的异常处理机制。

协程异常处理基础

在传统编程中,try-catch 是异常处理的唯一方式。但在协程的世界里,异常处理的方式取决于你使用的是 launch 还是 async,以及协程的层次结构。

try-catch 在协程中的局限性

考虑以下代码:

fun main() = runBlocking {
    try {
        launch {
            throw RuntimeException("协程异常")
        }
    } catch (e: Exception) {
        println("捕获到异常: $e")
    }
}

这段代码能捕获异常吗?不能。 抛出异常的协程在 launch 内部,而 try-catch 包裹的是 launch 本身(它是非阻塞的)。异常在协程体内部抛出时,launch 已经返回了。

正确的方式是将 try-catch 放在协程体内部:

fun main() = runBlocking {
    launch {
        try {
            throw RuntimeException("协程异常")
        } catch (e: Exception) {
            println("捕获到异常: $e")
        }
    }
}

这是最简单的异常处理方式,但在复杂场景下不够用。

Launch 与 Async 的异常传播差异

这是协程异常处理的核心知识点。launchasync 对异常的处理方式完全不同。

Launch:主动传播异常

launch 创建的协程遇到未捕获异常时,会立即将异常传播给父协程,并取消父协程及其所有子协程。

fun main() = runBlocking {
    val scope = CoroutineScope(Job())

    scope.launch {
        println("子协程 1 开始")
        delay(100)
        throw RuntimeException("子协程 1 异常")
    }

    scope.launch {
        println("子协程 2 开始")
        try {
            delay(500)
        } catch (e: CancellationException) {
            println("子协程 2 被取消了")
        }
    }

    delay(1000)
    println("主协程结束")
}
// 输出:
// 子协程 1 开始
// 子协程 2 开始
// 子协程 2 被取消了
// 子协程 1 异常 -> 未捕获异常,程序崩溃

因为两个子协程共享同一个父 Job,子协程 1 的异常会传播给父 Job,父 Job 取消自己并取消所有子协程。

Async:等待时才暴露异常

async 将异常延迟到调用 .await() 时才抛出。这与 Future.get() 的行为类似——异常被存储起来,等待获取结果时才暴露。

fun main() = runBlocking {
    val deferred = async {
        throw RuntimeException("async 异常")
    }

    delay(100) // async 已经执行完毕,异常被存储
    println("这里还能执行")

    try {
        deferred.await() // 异常在这里抛出
    } catch (e: Exception) {
        println("在 await() 时捕获: $e")
    }
}

这个特性非常关键:如果 async 的结果被忽略(从不调用 await()),异常也不会被抛出——但协程框架仍然会记���它,并在未处理时触发全局异常处理器。

fun main() = runBlocking {
    val scope = CoroutineScope(Job())

    val deferred = scope.async {
        throw RuntimeException("被忽略的异常")
    }

    // 从不调用 deferred.await()
    delay(500)
    // 程序退出时,异常由 CoroutineExceptionHandler 处理
}

这会导致异常在协程被垃圾回收时由未捕获异常处理器处理,通常意味着程序崩溃。

核心对比

特性 launch async
异常传播时机 立即传播 await() 调用时传播
向上传播 立即传递给父协程 等待 await()
try-catch 位置 协程体内部 包裹 await()
未处理后果 全局异常处理器 全局异常处理器(仅在 await 被调用时)

结构化并发下的异常传播

结构化并发是协程的核心设计原则:子协程中的异常会取消父协程,并级联取消所有兄弟协程。

默认行为:异常会向上取消整个作用域

fun main() = runBlocking {
    val job = launch {
        launch {
            delay(100)
            throw RuntimeException("子协程 A 异常")
        }

        launch {
            delay(200)
            println("子协程 B 完成")
        }
    }

    job.join()
    println("父协程完成")
}
// 输出:
// 子协程 A 异常 -> 子协程 B 被取消
// 父协程被取消

这个模型保证了:任何一个子协程失败,整个作用域内的所有协程都停止工作。 这在很多场景下是合理的——如果一个子任务失败了,整体结果也就无意义了。

捕获父协程的异常

既然异常会传播到父协程,我们可以通过捕获父协程的异常来统一处理:

fun main() = runBlocking {
    val job = launch {
        try {
            launch {
                delay(100)
                throw RuntimeException("子协程异常")
            }
        } catch (e: Exception) {
            // 这能捕获到吗?
            println("在父协程中捕获: $e")
        }
    }
}

不能。 因为 try-catch 包裹的是 launch 调用本身(也是非阻塞的),而不是子协程的执行。

正确的方式是在父协程体中使用 try-catch 包裹子协程的调用:

fun main() = runBlocking {
    launch {
        launch {
            try {
                delay(100)
                throw RuntimeException("子协程异常")
            } catch (e: Exception) {
                println("在子协程中捕获: $e")
            }
        }
    }
}

或者,使用 SupervisorJob 阻止异常向上传播。

SupervisorJob:隔离异常传播

SupervisorJob 是解决子协程异常影响兄弟协程的关键工具。

基本用法

fun main() = runBlocking {
    val scope = CoroutineScope(SupervisorJob())

    scope.launch {
        delay(100)
        throw RuntimeException("子协程 1 异常")
    }

    scope.launch {
        delay(200)
        println("子协程 2 完成")
    }

    delay(500)
    println("作用域完成")
}
// 输出:
// Exception in thread "..." RuntimeException: 子协程 1 异常
// 子协程 2 完成
// 作用域完成

可以看到,子协程 1 的异常没有影响子协程 2 的执行,也没有取消作用域。

SupervisorJob 的工作原理

普通 JobSupervisorJob 的区别在于如何处理子协程的异常:

// Job 的行为:子协程异常 -> 通知父 Job -> 父 Job 取消所有子协程
// fun Job(): Job = JobImpl(true) // true 表示会处理子协程异常

// SupervisorJob 的行为:子协程异常 -> 忽略,不通知父 Job
// fun SupervisorJob(): Job = JobImpl(false) // false 表示不处理子协程异常

关键区别就在 JobImplhandleChildCompletion 参数。普通 Job 在子协程失败时调用 childCancelled() 方法,而 SupervisorJob 不调用。

supervisorScope

supervisorScope 提供了在现有作用域内容器化使用 SupervisorJob 的能力:

fun main() = runBlocking {
    supervisorScope {
        launch {
            delay(100)
            throw RuntimeException("子协程异常")
        }

        launch {
            delay(200)
            println("兄弟协程正常运行")
        }
    }
    println("supervisorScope 结束")
}
// 输出:
// 兄弟协程正常运行
// supervisorScope 结束

supervisorScope 的典型用途:多个不相关的并发任务,其中一个失败不应该影响其他的。

实际场景:批量网络请求

suspend fun fetchUserData(userIds: List<String>): List<UserData?> {
    return supervisorScope {
        userIds.map { userId ->
            async {
                try {
                    api.fetchUser(userId)
                } catch (e: Exception) {
                    Log.e("TAG", "获取用户 $userId 失败", e)
                    null // 单个失败不影响其他的
                }
            }
        }.awaitAll()
    }
}

这里使用 supervisorScope 确保某个用户的数据请求失败不会取消其他请求。

CoroutineExceptionHandler

CoroutineExceptionHandler 是协程的全局异常处理器,用于处理未捕获的异常。

基本用法

val handler = CoroutineExceptionHandler { _, exception ->
    println("全局异常处理器捕获: $exception")
}

fun main() = runBlocking {
    val scope = CoroutineScope(Job() + handler)

    scope.launch {
        throw RuntimeException("未捕获异常")
    }

    delay(500)
}
// 输出:
// 全局异常处理器捕获: RuntimeException: 未捕获异常

处理器的生效条件

CoroutineExceptionHandler 只在以下情况下生效:

  1. 异常是未捕获的(没有被 try-catch 捕获)
  2. 协程是 launch 启动的(async 的异常由 await() 抛出)
  3. 异常传播到了顶级协程(没有父协程可以传递)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("处理器被调用: $exception")
    }

    launch(handler) {
        launch {
            throw RuntimeException("子协程异常")
        }
    }
}
// 输出:(不会打印处理器消息)
// 子协程异常传播到了父 launch,但父 launch 有父协程(runBlocking)
// 异常最终由 runBlocking 处理

只有在顶级协程(直接由 CoroutineScope 创建的协程)中的异常才会由 CoroutineExceptionHandler 处理:

fun main() {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("处理器被调用: $exception")
    }

    val scope = CoroutineScope(Job() + handler)

    scope.launch {
        launch {
            throw RuntimeException("子协程异常")
        }
    }

    Thread.sleep(500)
}
// 输出:
// 处理器被调用: RuntimeException: 子协程异常
// 因为 scope.launch 是顶级协程,异常会传播到这里,由 handler 处理

Handler 与 SupervisorJob 搭配使用

SupervisorJob 阻止了异常向上传播时,CoroutineExceptionHandler 也帮不上忙了:

fun main() {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("处理器: $exception")
    }

    val scope = CoroutineScope(SupervisorJob() + handler)

    scope.launch {
        throw RuntimeException("异常")
    }

    Thread.sleep(500)
}
// 输出:
// 处理器: RuntimeException: 异常
// ✅ SupervisorJob 没有取消父协程,异常由子协程的 handler 处理

设置全局默认处理器

可以通过设置 JVM 级全局处理器来兜底:

// 全局未捕获异常处理器
Thread.setDefaultUncaughtExceptionHandler { thread, exception ->
    Log.e("APP", "全局未捕获异常: $exception", exception)
}

// 协程框架也会使用这个处理器

或者在 Android 中设置 CoroutineExceptionHandler 作为全局默认处理器(较新版本的 Kotlin 协程支持):

// 在 Application 类中设置
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()

        // Kotlin 协程提供的全局异常处理器
        CoroutineExceptionHandler { _, throwable ->
            Log.e("APP", "未处理的协程异常", throwable)
            // 上报到 Crash 统计
        }
    }
}

Async 的异常处理:应该在哪里 try-catch

async 的异常处理有两种方式,效果不同。

在 async 内部捕获

fun main() = runBlocking {
    val deferred = async {
        try {
            riskyOperation()
        } catch (e: Exception) {
            null // 返回默��值
        }
    }

    val result = deferred.await() // 不会抛出异常
}

在 await() 时捕获

fun main() = runBlocking {
    val deferred = async {
        riskyOperation() // 异常存储在这里
    }

    try {
        val result = deferred.await() // 异常在这里抛出
    } catch (e: Exception) {
        println("捕获 async 异常: $e")
        // 降级处理
    }
}

推荐的 Async 异常处理模式

sealed class AsyncResult<out T> {
    data class Success<T>(val data: T) : AsyncResult<T>()
    data class Error(val exception: Throwable) : AsyncResult<Nothing>()
}

suspend fun <T> CoroutineScope.safeAsync(
    block: suspend CoroutineScope.() -> T
): AsyncResult<T> {
    return try {
        AsyncResult.Success(async { block() }.await())
    } catch (e: Exception) {
        AsyncResult.Error(e)
    }
}

// 使用
fun main() = runBlocking {
    val result = safeAsync {
        fetchUserData()
    }

    when (result) {
        is AsyncResult.Success -> showData(result.data)
        is AsyncResult.Error -> showError(result.exception)
    }
}

取消引发的 CancellationException

CancellationException 是协程框架中的一个特殊异常,它的处理方式和普通异常不同。

特殊性

fun main() = runBlocking {
    val job = launch {
        try {
            delay(1000)
        } finally {
            // 即使协程被取消,finally 块也会执行
            println("清理资源")
        }
    }

    delay(100)
    job.cancel()
    job.join()
}

在 finally 块中调用挂起函数

当协程被取消后,在 finally 块中调用挂起函数需要特殊处理:

fun main() = runBlocking {
    val job = launch {
        try {
            delay(1000)
        } finally {
            // ❌ 协程已取消,这里调用挂起函数会再次抛出 CancellationException
            delay(100) // 这里会抛出 CancellationException
            println("清理完成")
        }
    }

    delay(100)
    job.cancel()
    job.join()
}

正确的做法是使用 withContext(NonCancellable)

fun main() = runBlocking {
    val job = launch {
        try {
            delay(1000)
        } finally {
            // ✅ 使用 NonCancellable 上下文执行挂起函数
            withContext(NonCancellable) {
                delay(100)
                println("资源清理完成")
            }
        }
    }

    delay(100)
    job.cancel()
    job.join()
}

CancellationException 不会被传播

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("处理器不会收到 CancellationException: $exception")
    }

    val scope = CoroutineScope(Job() + handler)

    val job = scope.launch {
        launch {
            delay(1000)
        }
        launch {
            delay(500)
        }
    }

    delay(100)
    job.cancel() // 取消会传递 CancellationException,但不会被异常处理器捕获
    job.join()
}

CancellationException 不会被任何异常处理器捕获,也不会导致程序崩溃。

异常聚合

当一个父协程有多个子协程同时失败时,异常会被聚合到一个 CancellationException 中。

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("聚合异常: $exception")
        exception.suppressed.forEach {
            println("  包含: $it")
        }
    }

    val scope = CoroutineScope(Job() + handler)

    val job = scope.launch {
        launch {
            try {
                delay(Long.MAX_VALUE)
            } finally {
                throw RuntimeException("清理异常 1")
            }
        }
        launch {
            try {
                delay(Long.MAX_VALUE)
            } finally {
                throw RuntimeException("清理异常 2")
            }
        }
    }

    delay(100)
    job.cancelAndJoin()
}
// 输出:
// 聚合异常: kotlinx.coroutines.JobCancellationException: Job was cancelled
//   包含: RuntimeException: 清理异常 1
//   包含: RuntimeException: 清理异常 2

异常聚合通过 Kotlin 的 Throwable.addSuppressed() 机制实现,可以用 exception.suppressed 访问所有被抑制的异常。

Android 中的协程异常处理最佳实践

在 Android 开发中,协程异常处理需要特别注意 UI 层和业务层的分离。

ViewModel 中的异常处理

class UserViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState<List<User>>>(UiState.Loading)
    val uiState: StateFlow<UiState<List<User>>> = _uiState.asStateFlow()

    fun loadUsers() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val users = userRepository.fetchUsers()
                _uiState.value = UiState.Success(users)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "未知错误")
                Log.e("UserViewModel", "加载用户失败", e)
            }
        }
    }
}

sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

Repository 层的异常处理

class UserRepository(
    private val api: UserApi,
    private val cache: UserCache
) {
    suspend fun fetchUsers(): List<User> = withContext(Dispatchers.IO) {
        try {
            val response = api.getUsers()
            cache.saveUsers(response)
            response
        } catch (e: IOException) {
            // 网络错误,尝试从缓存读取
            val cached = cache.getUsers()
            if (cached.isNotEmpty()) {
                cached
            } else {
                throw e // 缓存也没有,继续抛出
            }
        } catch (e: HttpException) {
            // HTTP 错误,返回缓存的过期数据(如果存在)
            cache.getUsers().also { users ->
                if (users.isEmpty()) {
                    throw e
                }
            }
        }
    }
}

使用 Result 类型

Kotlin 标准库提供了 Result 类型,适合在 Repository 层包装可能失败的操作:

class UserRepository(private val api: UserApi) {

    suspend fun fetchUsers(): Result<List<User>> = runCatching {
        api.getUsers()
    }

    suspend fun fetchUser(id: String): Result<User> = runCatching {
        api.getUser(id)
    }
}

// ViewModel 中使用
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    fun loadUsers() {
        viewModelScope.launch {
            repository.fetchUsers()
                .onSuccess { users ->
                    _uiState.value = UiState.Success(users)
                }
                .onFailure { exception ->
                    _uiState.value = UiState.Error(exception.message ?: "加载失败")
                }
        }
    }
}

Flow 中的异常处理

class UserRepository(private val api: UserApi) {

    fun usersFlow(): Flow<List<User>> = flow {
        while (true) {
            val users = api.getUsers()
            emit(users)
            delay(30_000) // 每 30 秒��询
        }
    }.retry(3) { cause ->
        // 网络错误时重试,最多 3 次
        cause is IOException
    }.catch { exception ->
        // 所有重试都失败后,发射空列表
        emit(emptyList())
        Log.e("UserRepository", "获取用户列表失败", exception)
    }
}

常见陷阱与最佳实践

陷阱 1:错误的 try-catch 位置

// ❌ 错误:try-catch 包裹的是 launch,不是协程体
try {
    launch { throw Exception() }
} catch (e: Exception) {
    // 永远不会执行
}

// ✅ 正确:try-catch 在协程体内部
launch {
    try {
        throw Exception()
    } catch (e: Exception) {
        // 在这里处理
    }
}

陷阱 2:忽略 async 的异常

// ❌ 错误:async 的异常被忽略
scope.async {
    throw RuntimeException("异常")
}

// ✅ 正确:必须 await()
scope.async {
    throw RuntimeException("异常")
}.let { deferred ->
    try {
        deferred.await()
    } catch (e: Exception) {
        // 处理异常
    }
}

// ✅ 或者如果不需要结果,使用 launch 代替 async
scope.launch {
    throw RuntimeException("异常")
}

陷阱 3:在 CancellationException 中调用挂起函数

// ❌ 错误:在 finally 块中调用挂起函数
job = launch {
    try {
        delay(1000)
    } finally {
        delay(100) // 协程取消后,这里抛出 CancellationException
        cleanup()
    }
}

// ✅ 正确:使用 withContext(NonCancellable)
job = launch {
    try {
        delay(1000)
    } finally {
        withContext(NonCancellable) {
            delay(100)
            cleanup()
        }
    }
}

陷阱 4:在 CoroutineScope 中使用 try-catch 捕获子协程异常

// ❌ 错误:try-catch 不能跨协程边界
scope.launch {
    try {
        launch { throw Exception() }  // 异常发生在这里
    } catch (e: Exception) {
        // 捕获不到
    }
}

// ✅ 正确:在子协程内部捕获
scope.launch {
    launch {
        try {
            throw Exception()
        } catch (e: Exception) {
            // 在这里处理
        }
    }
}

陷阱 5:ViewModelScope 中使用 async 不 await

在 Android 的 ViewModel 中,viewModelScope 使用 SupervisorJob,这意味着子协程的异常不会相互影响。但如果你使用 async 而不 await()

class MyViewModel : ViewModel() {
    fun load() {
        viewModelScope.async { // ❌ 不 await 的 async
            throw RuntimeException("异常")
        }
        // 异常不会崩溃,但会丢失
    }
}

虽然不会崩溃,但异常信息会丢失。要么使用 launch,要么确保 await()

实战:构建健壮的协程异常处理框架

统一错误处理基类

abstract class BaseRepository {

    protected suspend fun <T> safeApiCall(
        call: suspend () -> T,
        errorMessage: String = "请求失败"
    ): Result<T> = runCatching {
        call()
    }.recover { exception ->
        val wrappedException = when (exception) {
            is IOException -> NetworkException(errorMessage, exception)
            is HttpException -> HttpException(exception.code(), errorMessage)
            else -> UnknownException(errorMessage, exception)
        }
        throw wrappedException
    }

    protected suspend fun <T> safeDbCall(
        call: suspend () -> T
    ): Result<T> = runCatching {
        call()
    }.recover { exception ->
        throw DatabaseException("数据库操作失败", exception)
    }
}

全局异常监控

// 在 Application 中初始化
class MyApplication : Application() {

    lateinit var coroutineExceptionHandler: CoroutineExceptionHandler
        private set

    override fun onCreate() {
        super.onCreate()

        coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
            when (throwable) {
                is NetworkException -> {
                    Log.w("APP", "网络异常: ${throwable.message}")
                }
                is CancellationException -> {
                    // 忽略正常取消
                }
                else -> {
                    Log.e("APP", "未处理的协程异常", throwable)
                    // 上报到 Crashlytics 等分析平台
                    Crashlytics.logException(throwable)
                }
            }
        }

        // 设置全局默认处理器
        Thread.setDefaultUncaughtExceptionHandler { thread, exception ->
            Log.e("APP", "全局未捕获异常", exception)
            Crashlytics.logException(exception)
        }
    }
}

完整的 ViewModel 异常处理模板

abstract class BaseViewModel : ViewModel() {

    protected fun <T> launchCatching(
        block: suspend CoroutineScope.() -> T,
        onError: ((Throwable) -> Unit)? = null
    ): Job {
        return viewModelScope.launch {
            try {
                block()
            } catch (e: CancellationException) {
                // 正常取消,不处理
                throw e
            } catch (e: Exception) {
                Log.e(TAG, "协程执行异常", e)
                onError?.invoke(e) ?: handleDefaultError(e)
            }
        }
    }

    private fun handleDefaultError(exception: Exception) {
        val message = when (exception) {
            is NetworkException -> "网络连接失败,请检查网络"
            is TimeoutException -> "请求超时,请稍后重试"
            else -> "操作失败: ${exception.message}"
        }
        // 通过 SharedFlow 发送给 UI 层
        _errorEvent.emit(ErrorEvent(message))
    }

    private val _errorEvent = MutableSharedFlow<ErrorEvent>()
    val errorEvent: SharedFlow<ErrorEvent> = _errorEvent.asSharedFlow()

    data class ErrorEvent(val message: String)
}

// 使用
class ProfileViewModel : BaseViewModel() {

    fun loadProfile(userId: String) {
        launchCatching(
            block = {
                val profile = repository.getProfile(userId)
                _profile.value = profile
            },
            onError = { exception ->
                // 自定义错误处理
                _profile.value = Profile.default()
            }
        )
    }
}

总结

Kotlin Coroutine 的异常处理需要理解三个核心概念:

  1. 传播机制launch 立即传播异常,async 延迟到 await() 时传播
  2. 结构化并发:默认子协程异常会取消父协程和兄弟协程,SupervisorJob 可以隔离异常
  3. 异常处理工具try-catch 处理'局部'异常,CoroutineExceptionHandler 处理'全局'未捕获异常

关键原则: - 尽量在协程体内部捕获异常,而不是外部 - 兄弟任务互不依赖时使用 supervisorScope - async 必须搭配 await() 否则异常丢失 - CancellationException 只应在 finally 块中特殊处理 - 在 Android 中使用 viewModelScope + launch + try-catch 处理 UI 层异常

掌握这些机制,你就能写出健壮、可维护的协程代码,再也不会被协程的异常处理问题困扰。