キャンセルとタイムアウト
このセクションでは、コルーチンのキャンセルとタイムアウトについて説明します。
コルーチンの実行をキャンセルする
長時間実行されるアプリケーションでは、バックグラウンドコルーチンをきめ細かく制御する必要がある場合があります。 たとえば、ユーザーがコルーチンを起動したページを閉じた場合、その結果はもはや不要となり、その操作をキャンセルできます。 launch関数は、実行中のコルーチンをキャンセルするために使用できるJobを返します。
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")
}
完全なコードはこちらから入手できます。
これは以下の出力を生成します。
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.
main
がjob.cancel
を呼び出すとすぐに、キャンセルされたため、他のコルーチンからの出力は表示されません。 また、Jobの拡張関数cancelAndJoinがあり、cancelとjoinの呼び出しを組み合わせます。
キャンセルは協調的である
コルーチンのキャンセルは_協調的_です。コルーチンコードは、キャンセル可能であるために協調する必要があります。 kotlinx.coroutines
内のすべてのサスペンド関数は_キャンセル可能_です。これらはコルーチンのキャンセルをチェックし、キャンセルされたときにCancellationExceptionをスローします。しかし、コルーチンが計算処理中にキャンセルのチェックを行わない場合、以下の例が示すように、キャンセルすることはできません。
import kotlinx.coroutines.*
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 5) { // computation loop, just wastes CPU
// print a message twice a second
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
}
完全なコードはこちらから入手できます。
これを実行すると、キャンセル後もジョブが5回の繰り返し後に自己完了するまで「I'm sleeping」の出力が続くことがわかります。
CancellationExceptionをキャッチして再スローしないことでも、同様の問題が観察されます。
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
repeat(5) { i ->
try {
// print a message twice a second
println("job: I'm sleeping $i ...")
delay(500)
} catch (e: Exception) {
// log the exception
println(e)
}
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
}
完全なコードはこちらから入手できます。
Exception
をキャッチすることはアンチパターンですが、この問題は、CancellationExceptionを再スローしないrunCatching
関数の使用時など、より巧妙な方法で表面化する可能性があります。
計算コードをキャンセル可能にする
計算コードをキャンセル可能にするには、2つのアプローチがあります。 1つ目は、キャンセルをチェックするサスペンド関数を定期的に呼び出す方法です。 その目的には、yieldおよびensureActive関数が最適です。 もう1つは、isActiveを使用してキャンセルのステータスを明示的にチェックする方法です。 後者のアプローチを試してみましょう。
前の例のwhile (i < 5)
をwhile (isActive)
に置き換えて、再度実行してください。
import kotlinx.coroutines.*
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (isActive) { // cancellable computation loop
// prints a message twice a second
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
}
完全なコードはこちらから入手できます。
ご覧のとおり、このループはキャンセルされます。isActiveは、CoroutineScopeオブジェクトを介してコルーチン内で利用可能な拡張プロパティです。
finally
でリソースを閉じる
キャンセル可能なサスペンド関数は、キャンセル時にCancellationExceptionをスローし、これは通常の方法で処理できます。 たとえば、コルーチンがキャンセルされた場合、try {...} finally {...}
式とKotlinのuse関数は、その終了処理を通常どおり実行します。
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
} finally {
println("job: I'm running finally")
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
}
完全なコードはこちらから入手できます。
joinとcancelAndJoinは両方とも、すべての終了処理が完了するのを待つため、上記の例は以下の出力を生成します。
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
main: Now I can quit.
キャンセル不可ブロックの実行
前の例のfinally
ブロックでサスペンド関数を使用しようとすると、このコードを実行しているコルーチンがキャンセルされるため、CancellationExceptionが発生します。通常、これは問題ではありません。なぜなら、適切に動作するすべてのクローズ操作(ファイルのクローズ、ジョブのキャンセル、あらゆる種類の通信チャネルのクローズなど)は通常ノンブロッキングであり、サスペンド関数を含まないためです。しかし、キャンセルされたコルーチン内でサスペンドする必要がある稀なケースでは、以下の例が示すように、withContext関数とNonCancellableコンテキストを使用して、対応するコードをwithContext(NonCancellable) {...}
で囲むことができます。
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
} finally {
withContext(NonCancellable) {
println("job: I'm running finally")
delay(1000L)
println("job: And I've just delayed for 1 sec because I'm non-cancellable")
}
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
}
完全なコードはこちらから入手できます。
タイムアウト
コルーチンの実行をキャンセルする最も明白な実用的な理由は、その実行時間がタイムアウトを超過したためです。 対応するJobへの参照を手動で追跡し、遅延後に追跡対象をキャンセルする別のコルーチンを起動することもできますが、それを実行するためのすぐに使えるwithTimeout関数があります。 以下の例を見てください。
import kotlinx.coroutines.*
fun main() = runBlocking {
withTimeout(1300L) {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
}
}
完全なコードはこちらから入手できます。
これは以下の出力を生成します。
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms
withTimeoutによってスローされるTimeoutCancellationExceptionは、CancellationExceptionのサブクラスです。 これまでそのスタックトレースがコンソールに表示されることはありませんでした。それは、キャンセルされたコルーチン内ではCancellationException
がコルーチン完了の通常の理由と見なされるためです。 しかし、この例では、main
関数のすぐ内側でwithTimeout
を使用しました。
キャンセルは単なる例外であるため、すべてのリソースは通常の方法で閉じられます。 タイムアウト時に特定の追加アクションを実行する必要がある場合は、タイムアウトコードをtry {...} catch (e: TimeoutCancellationException) {...}
ブロックで囲むか、withTimeoutに似ていますが例外をスローする代わりにタイムアウト時にnull
を返すwithTimeoutOrNull関数を使用できます。
import kotlinx.coroutines.*
fun main() = runBlocking {
val result = withTimeoutOrNull(1300L) {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
"Done" // will get cancelled before it produces this result
}
println("Result is $result")
}
完全なコードはこちらから入手できます。
このコードを実行しても、例外は発生しません。
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null
非同期タイムアウトとリソース
withTimeoutでのタイムアウトイベントは、そのブロック内で実行されているコードに対して非同期であり、タイムアウトブロックの内部から戻る直前であっても、いつでも発生する可能性があります。ブロック内でリソースを開いたり取得したりする場合で、そのリソースをブロックの外で閉じたり解放したりする必要がある場合は、この点に注意してください。
たとえば、ここではResource
クラスを使用してクローズ可能なリソースを模倣します。このクラスは、acquired
カウンターをインクリメントすることで作成された回数を追跡し、close
関数でカウンターをデクリメントします。 ここで、多数のコルーチンを作成します。それぞれのコルーチンは、withTimeout
ブロックの最後にResource
を作成し、ブロックの外でリソースを解放します。withTimeout
ブロックが既に終了しているときにタイムアウトが発生する可能性が高まるように、わずかな遅延を追加します。これにより、リソースリークが発生します。
import kotlinx.coroutines.*
var acquired = 0
class Resource {
init { acquired++ } // Acquire the resource
fun close() { acquired-- } // Release the resource
}
fun main() {
runBlocking {
repeat(10_000) { // Launch 10K coroutines
launch {
val resource = withTimeout(60) { // Timeout of 60 ms
delay(50) // Delay for 50 ms
Resource() // Acquire a resource and return it from withTimeout block
}
resource.close() // Release the resource
}
}
}
// Outside of runBlocking all coroutines have completed
println(acquired) // Print the number of resources still acquired
}
完全なコードはこちらから入手できます。
上記のコードを実行すると、常にゼロが出力されるわけではないことがわかります。ただし、これはお使いのマシンのタイミングに依存する可能性があります。非ゼロの値を実際に確認するには、この例のタイムアウトを調整する必要があるかもしれません。
ここで10K個のコルーチンから
acquired
カウンターをインクリメントおよびデクリメントしても、runBlocking
によって使用されるのと同じスレッドから常に発生するため、完全にスレッドセーフであることに注意してください。 詳細については、コルーチンコンテキストに関する章で説明します。
この問題を回避するには、withTimeout
ブロックからリソースを返すのではなく、変数にリソースへの参照を格納できます。
import kotlinx.coroutines.*
var acquired = 0
class Resource {
init { acquired++ } // Acquire the resource
fun close() { acquired-- } // Release the resource
}
fun main() {
runBlocking {
repeat(10_000) { // Launch 10K coroutines
launch {
var resource: Resource? = null // Not acquired yet
try {
withTimeout(60) { // Timeout of 60 ms
delay(50) // Delay for 50 ms
resource = Resource() // Store a resource to the variable if acquired
}
// We can do something else with the resource here
} finally {
resource?.close() // Release the resource if it was acquired
}
}
}
}
// Outside of runBlocking all coroutines have completed
println(acquired) // Print the number of resources still acquired
}
完全なコードはこちらから入手できます。
この例は常にゼロを出力します。リソースはリークしません。