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 异常传递

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

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

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

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

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 关系中,一个出事全部 cancel,exception.suppressed 是个数组,存着所有由 cancel 导致的异常

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

Callback Wrapping 包装回调

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

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

这样的代码往往会变得相当复杂,难以阅读。幸运的是,协程提供了一种包装回调的方法,通过协程库中的 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