まつちよの日記

プログラミングに関する知見や、思ったことを書き残します。

【Coroutine】 await()をtry-catchで囲んでも、async {}でthrowされたExceptionをcatchできないことがある

tags: Android, Coroutine

これまでCoroutineをよく使っていて慣れているつもりでいたのですが、はじめこの現象を全く理解できず、解決に時間がかかってしまいました。 自分と同じような経験をした人のため、記事として残したいと思います。

現象

以下のようなソースで、mayThrowHttpException()がHttpExceptionをthrowしたときに、Androidアプリがクラッシュしてしまいました。

class SampleActivity: AppCompatActivity() {
    override fun onResume() {
      super.onResume()
      
      lifecycleScope.launch {
        val deferred = async { mayThrowHttpException() }
        try {
          deferred.await()
        } catch (e: HttpException) {
          // Handle exception
        }
      }
    }
}

↑async内でthrowされたExceptionは、await()でthrowされcatchされると思っていました。

Android Developerのドキュメントにも以下の通り書かれています。

警告: launch と async では例外の処理が異なります。async は、await への最終呼び出しを想定しているので、例外を保持し、await 呼び出しの一部として再スローします。 コルーチンを開始する

↑しかし、このように動くのには条件があります(後述)。

原因

await{}でrootのcoroutineを生成した場合に限って、await()でエラーがスローされるようになるためです。rootでない場合、async内でthrowされたExceptionは、自動で親coroutineに伝搬されます(今回、その結果アプリがクラッシュしていました)。

↓Kotlin公式にさりげなく、「(asyncなどの)builderがroot coroutineの生成に利用された場合、他のcoroutineの子でない場合(にawait()でExceptionがthrowされるようになる。)」(かっこ内は筆者意訳です。下の引用で筆者が太字にした部分です。)とあります。

Coroutine builders come in two flavors: propagating exceptions automatically (launch and actor) or exposing them to users (async and produce). When these builders are used to create a root coroutine, that is not a child of another coroutine, ...省略... the latter are relying on the user to consume the final exception, for example via await or receive https://kotlinlang.org/docs/exception-handling.html#exception-propagation

Android公式から参照されている、Android公式のブログでも以下の記述があります。root coroutineとしてasyncが利用されたときに、await()でthrowされます。

When async is used as a root coroutine (coroutines that are a direct child of a CoroutineScope instance or supervisorScope), exceptions are not thrown automatically, instead, they’re thrown when you call .await(). Exceptions in coroutines

対応

下のソースの通り、supervisorScope {}内で、async {}とawait()をします。

上の「原因」の記載と重なる部分があるのですが、async {}内でthrowされたExceptionが、直ちに親coroutineに伝搬されるのではなく、await()でthrowされるようにするには以下のどちらかが必要でした。

  • async {}をroot coroutineの生成に使う
  • supervisorScope {}内で、async {}とawait()する。

上のどちらかなのですが、後者しかとり得ないと思っています。 なぜなら、await()はsuspend関数なので、await()を呼び出す処理は何らかの親coroutineに属する必要があるためです。(下の例ではlifecycleScope.launch{}で生成されるcoroutine) await()が親coroutineを持つなら、await()と同じ場所で呼び出すはずのasync {}もその親coroutineの中で呼び出されます。 そのため、async {}をroot coroutineとしては使えないはずです。その結果後者しかとり得ないと思っています。

class SampleActivity: AppCompatActivity() {
    override fun onResume() {
      super.onResume()
      
      lifecycleScope.launch {
        supervisorScope { // ★追加
          val deferred = async { mayThrowHttpException() }
          try {
            deferred.await()
          } catch (e: HttpException) {
            // Handle exception
          }
        }
      }
    }
}

そもそもsuperviorScopeとは何か?

そもそもsuperviorScopeとは何かというと、現在実行中のCoroutineContextのJobをSupervisorJobに上書きすることで、子coroutineで発生したExceptionを親coroutineに自動で伝搬しないようにするものです。

↓CoroutineはデフォルトでExceptionを親Coroutineに伝搬するようになっています。

When a coroutine fails with an exception, it will propagate said exception up to its parent! Then, the parent will 1) cancel the rest of its children, 2) cancel itself and 3) propagate the exception up to its parent. Exceptions in coroutines

↓伝搬したくない時のために、SupervisorJobがあります。 SupervisorJobは、子coroutineのExceptionをその子coroutineに処理させ、親に伝搬させません。

With a SupervisorJob, the failure of a child doesn’t affect other children. A SupervisorJob won’t cancel itself or the rest of its children. Moreover, SupervisorJob won’t propagate the exception either, and will let the child coroutine handle it.

そのSupervisorJobをCoroutineContextに設定するため、supervisorScope {}で囲みます。その内部のasync {}は親coroutineにExceptionを伝搬するのではなく、自分で処理します(await()でthrowするようになります)。

CoroutineContextについては、上記ブログ(これは3部構成のうちPart3)のPart1のCoroutines: first things firstが参考になります。

おわりに

私の感想としては、async {}はクセがあるなと思ったのですが、いかがだったでしょうか? この記事が少しでもお役に立てれば嬉しいです。