Back
Featured image of post Kotlin Coroutine 协程 - 07 异常处理

Kotlin Coroutine 协程 - 07 异常处理

协程中的异常处理

本文是对 Kotlin Coroutines by Tutorials By Filip Babić and Nishant Srivastava 书本内容的转述和笔记书本内容是受原版权保护的内容

上一篇:Kotlin Coroutine 协程 - 06 上下文切换与协程调度器

Chapter 7: 异常处理

Exception Propagation 异常传递

你可以用多种方式构建一个协程。你使用的协程构建器的种类决定了异常将如何传播,以及如何处理它们。

  • 当使用 launchactor 协程构建器时,异常会自动传播并被视为未处理的,类似于 Java 的 Thread.uncaughExceptionHandler

  • 当使用 asyncproduce 协程构建器时,异常会暴露给用户,以便在协程执行结束时通过 awaitreceive 最终消费。

了解异常是如何传播的,有助于找出正确的策略来处理它们。

Handling Exceptions 处理异常

异常处理在协程中是非常直接的。如果代码抛出了一个异常,上下文会自动传播它,你不需要做任何事情。协程使异步代码看起来是同步的,类似于处理同步代码的预期方式──也就是说,try-catch 也适用于协程。

下面是一个简单的例子,它在 GlobalScope 中创建了新的协程,并从不同的协程构建器中抛出了异常:

fun main() = runBlocking {
  val asyncJob = GlobalScope.launch {
    println("1. Exception created via launch coroutine")

    // Will be printed to the console by
    // Thread.defaultUncaughtExceptionHandler
    throw IndexOutOfBoundsException()
  }

  asyncJob.join()
  println("2. Joined failed job")

  val deferred = GlobalScope.async {
    println("3. Exception created via async coroutine")

    // Nothing is printed, relying on user to call await
    throw ArithmeticException()
  }

  try {
    deferred.await()
    println("4. Unreachable, this statement is never executed")
  } catch (e: Exception) {
    println("5. Caught ${e.javaClass.simpleName}")
  }
}

Output:

1. Exception created via launch coroutine
Exception in thread "DefaultDispatcher-worker-1" java.lang.IndexOutOfBoundsException
  - - -
2. Joined failed job
3. Exception created via async coroutine
5. Caught ArithmeticException

在前面的代码中,你使用 GlobalScope.launch 协程构建器启动了一个协程,并在其主体中抛出一个 IndexOutOfBoundsException。这是一个正常异常传播的例子,它由默认的 Thread.uncaughExceptionHandler 实现处理。这是一个负责管理程序中抛出的未处理异常的对象。它只是将异常传播给调用者的线程处理程序(如果有的话),或者将其信息打印在标准输出上。在本例中,你已经进入了主函数,所以错误信息是输出的一部分。

如你所知,GlobalScope.launch 创建了一个 Job 实例,你对它调用了 join 函数。第一个 Job,因为出现了异常,所以输出了 2. Joined failed job。在第二个协程中,你使用 GlobalScope.async 协程构建器,它在其主体中抛出一个 ArithmeticException。在这种情况下,这个异常在被创建的时候不会被 Thread.uncaughExceptionHandler 处理,但可以被 GlobalScope.async 返回的 Deferred 对象上调用的 await 函数抛出。在这种情况下,可能的异常也被推迟了。

CoroutineExceptionHandler

类似于使用 Java 的 Thread.defaultUncaughtExceptionHandler,它为未捕获的线程异常返回一个处理程序,协程提供了一个可选的通用 catch 块来处理未捕获的异常,称为 CoroutineExceptionHandler

注意:在 Android 上,uncaughtExceptionPreHandler 是全局的协程异常处理程序。

通常情况下,未捕获的异常只能由使用启动协程构造器创建的协程导致。使用 async 创建的协程总是捕捉所有的异常,并在产生的 Deferred 对象中表示它们。

当使用 launch 构造器时,异常将被存储在一个 Job 对象中。要检索它,你可以使用 invokeOnCompletion 辅助函数。

fun main() {
  runBlocking {
    val job = GlobalScope.launch {
      println("1. Exception created via launch coroutine")

      // Will NOT be handled by
      // Thread.defaultUncaughtExceptionHandler
      // since it is being handled later by `invokeOnCompletion`
      throw IndexOutOfBoundsException()
    }

    // Handle the exception thrown from `launch` coroutine builder
    job.invokeOnCompletion { exception ->
      println("2. Caught $exception")
    }

    // This suspends coroutine until this job is complete.
    job.join()
  }
}

Output:

1. Exception created via launch coroutine
Exception in thread "main" java.lang.IndexOutOfBoundsException
....
2. Caught java.lang.IndexOutOfBoundsException

默认情况下,当你没有设置处理程序时,系统会按照以下顺序处理未捕获的异常:

  • 如果异常是 CancellationException,那么系统会忽略它,因为那是取消运行中的协程的机制。
  • 否则,如果上下文中有一个 Job,那么 Job.cancel 就会被调用。
  • 否则,通过 ServiceLoader 和当前线程的 Thread.uncaughtExceptionHandler 找到的所有 CoroutineExceptionHandler 实例,全部都将会被调用。

注意:1. Job.cancel 优先与其他 exceptionHandler 2. CoroutineExceptionHandler 只在那些不期望被用户处理的异常中被调用,所以在异步协程构建器中注册它,以及类似的东西没有任何作用。

下面是一个简单的例子来演示 CoroutineExceptionHandler 的用法:

fun main() {
  runBlocking {
    // 1
    val exceptionHandler = CoroutineExceptionHandler { _, exception ->
      println("Caught $exception")
    }
    // 2
    val job = GlobalScope.launch(exceptionHandler) {
      throw AssertionError("My Custom Assertion Error!")
    }
    // 3
    val deferred = GlobalScope.async(exceptionHandler) {
      // Nothing will be printed,
      // relying on user to call deferred.await()
      throw ArithmeticException()
    }
    // 4
    // This suspends current coroutine until all given jobs are complete.
    joinAll(job, deferred)
  }
}

Output:

Caught java.lang.AssertionError: My Custom Assertion Error!

这里是对代码块的解释: - 实现一个全局异常处理程序;即 CoroutineExceptionHandler。这是你定义当一个未处理的协程抛出异常时如何处理的地方。 - 使用协程构造器创建一个简单的协程,抛出一个自定义消息 AssertionError - 使用 async 协程构造器创建一个简单的协程,抛出一个 ArithmeticException。 - joinAll 用于挂起当前的循环程序,直到所有给定的工作都完成。

CoroutineExceptionHandler 在你想有一个全局的异常处理程序在协程之间共享时很有用,但如果你想以不同的方式处理特定协程的异常,你需要提供具体的实现。让我们来看看如何实现。

Try-Catch to the Rescue

当涉及到为一个特定的协程处理异常时,你可以使用 try-catch 块来捕捉异常,并像你在用 Kotlin 进行正常的同步编程时那样处理它们。

但是有一个问题。如果你不小心,用 async 构建器创建的协程通常会「吞噬」异常。如果一个异常在 async 块中被抛出,该异常实际上不会被立即抛出。相反,它将在你对返回的 Deferred 对象调用 await 时被抛出。这种行为,如果不加以考虑,可能会导致没有异常被跟踪的情况,但将异常处理推迟到稍后的时间也可能是一个理想的行为,这取决于你实际的用例。

这里有一个例子来证明这一点。

fun main() {
  runBlocking {
    // Set this to ’true’ to call await on the deferred variable
    val callAwaitOnDeferred = true

    val deferred = GlobalScope.async {
      // This statement will be printed with or without
      // a call to await()
      println("Throwing exception from async")
      throw ArithmeticException("Something Crashed")
      // Nothing is printed, relying on a call to await()
    }

    if (callAwaitOnDeferred) {
      try {
        deferred.await()
      } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
      }
    }
  }
}

Output:

  • callAwaitOnDeferred 被设置为 false 的情况下的输出──也就是说,没有调用 await 的情况下:
1. Throwing exception from async
  • callAwaitOnDeferred 被设置为 true 的情况下的输出–也就是说,有调用 await 的情况下:
1. Throwing exception from async
2. Caught ArithmeticException

Handling Multiple Child Coroutine Exceptions 处理多个子协程异常

只拥有一个协程是一个理想的用例。在实践中,你可能会有多个协程,并在其下运行其他子协程。如果这些子协程抛出异常会怎样?这就是所有这些可能变得棘手的地方。在这种情况下,一般的规则是「第一个异常赢」。如果你设置了一个 CoroutineExceptionHandler,它将只管理第一个异常,而抑制其他所有的异常。

下面是一个例子来证明这一点:

fun main() = runBlocking {

  // Global Exception Handler
  val handler = CoroutineExceptionHandler { _, exception ->
    println("Caught $exception with suppressed " +

        // Get the suppressed exception
        "${exception.suppressed?.contentToString()}")
  }

  // Parent Job
  val parentJob = GlobalScope.launch(handler) {
    // Child Job 1
    launch {
      try {
        delay(Long.MAX_VALUE)
      } catch (e: Exception) {
        println("${e.javaClass.simpleName} in Child Job 1")
      } finally {
        throw ArithmeticException()
      }
    }

    // Child Job 2
    launch {
      delay(100)
      throw IllegalStateException()
    }

    // Delaying the parentJob
    delay(Long.MAX_VALUE)
  }
  // Wait until parentJob completes
  parentJob.join()
}

Output:

JobCancellationException in Child Job 1
Caught java.lang.IllegalStateException with suppressed [java.lang.ArithmeticException]

在前面的例子中:

  • 你定义了一个 CoroutineExceptionHandler 来打印第一个被捕获的异常的名字,以及它从被抑制的属性中获得的被抑制的异常。
  • 在这之后,你使用 launch 协程构建器启动一个父协程,将异常处理程序作为参数。父协程包含几个子协程,你再次使用 launch 函数来启动这些子协程。第一个循环程序包含一个 try-catch-finally 块。
  • try 块中,你用一个 Long.MAX_VALUE 参数值调用 delay 函数,以便等待很长一段时间。
  • catch 中,你打印一个关于捕获异常的信息。
  • 最后,你抛出一个 ArithmeticException
  • 在第二个协程中,你只延迟了几毫秒,然后立即抛出一个 IllegalStateException
  • 然后你完成了父协程,在另一个很长的时间段内调用 delay 函数。
  • 主函数的最后一条行代码允许程序等待父 Job 的完成。

当你运行这段代码时,父协程开始,其子协程也开始。第一个子协程在等待,第二个子程序抛出一个 IllegalStateException,这是处理程序将管理的第一个异常,你可以在输出中看到。正因为如此,系统因此强制取消了第一个协程的 delay,这就是 JobCancellationException 消息的原因。这也使得父 Job 失败,因此,处理程序将被调用并显示其输出。

简而言之,在这种嵌套 Job 关系中,一个出事全部 cancelexception.suppressed 是个数组,存着所有由 cancel 导致的异常

需要注意的是,CoroutineExceptionHandler 是父协程的一部分(在父协程的上下文中),所以它可以处理在它作用域下的所有异常。

Callback Wrapping 包装回调

处理异步代码的执行通常需要实现某种回调机制。

例如,对于一个异步网络调用,你可能希望有 onSuccessonFailure 回调,这样你就可以适当地处理这两种情况。

这样的代码往往会变得相当复杂,难以阅读。幸运的是,协程提供了一种包装回调的方法,通过协程库中的 suspendCoroutine 挂起函数,将异步代码处理的复杂性从调用者那里隐藏起来。它捕获了当前的 Continuation 实例并挂起了当前运行的协程。

Continuation 提供了两个函数,你可以用它们恢复协程的执行。调用 resume 函数可以恢复协程的执行并返回一个值,而 resumeWithException 则在最后一个挂起点之后重新抛出异常。

resume 是通过在未来在可挂起函数中安排调用 Continuation 方法来完成的。

看一个简单的长期运行的 Job 的例子,它有一个处理结果的回调。你将把回调包在一个协程中,并大大简化了工作:

fun main() {
  runBlocking {
    try {
      val data = getDataAsync()
      println("Data received: $data")
    } catch (e: Exception) {
      println("Caught ${e.javaClass.simpleName}")
    }
  }
}

// Callback Wrapping using Coroutine
suspend fun getDataAsync(): String {
  return suspendCoroutine { cont ->
    getData(object : AsyncCallback {
      override fun onSuccess(result: String) {
        cont.resumeWith(Result.success(result))
      }

      override fun onError(e: Exception) {
        cont.resumeWith(Result.failure(e))
      }

    })

  }
}

// Method to simulate a long running task
fun getData(asyncCallback: AsyncCallback) {
  // Flag used to trigger an exception
  val triggerError = false

  try {
    // Delaying the thread for 3 seconds
    Thread.sleep(3000)

    if (triggerError) {
      throw IOException()
    } else {
      // Send success
      asyncCallback.onSuccess("[Beep.Boop.Beep]")
    }
  } catch (e: Exception) {
    // send error
    asyncCallback.onError(e)
  }
}

// Callback
interface AsyncCallback {
  fun onSuccess(result: String)
  fun onError(e: Exception)
}

Output:

triggerError 字段在 getData() 方法中被设置为 false 时。

Data received: [Beep.Boop.Beep]

triggerError 字段在 getData() 方法中被设置为 true 时。

Caught IOException

下一篇:Kotlin Coroutine 协程 - 08 取消管理

comments powered by Disqus