キャンセルとタイムアウト
このセクションでは、コルーチンのキャンセルとタイムアウトについて説明します。
コルーチン実行のキャンセル
長時間実行されるアプリケーションでは、バックグラウンドコルーチンをきめ細かく制御する必要がある場合があります。 たとえば、ユーザーがコルーチンを起動したページを閉じると、その結果は不要になり、その操作をキャンセルできます。 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.")
}
NOTE
完全なコードはこちらから取得できます。
次の出力が生成されます。
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
を呼び出すとすぐに、他のコルーチンからは何も出力されなくなります。これは、そのコルーチンがキャンセルされたためです。 また、cancel
とjoin
の呼び出しを組み合わせるcancelAndJoin
というJob
の拡張関数もあります。
キャンセルは協調的
コルーチンのキャンセルは_協調的_です。コルーチンコードはキャンセル可能であるために協調する必要があります。 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.")
}
NOTE
完全なコードはこちらから取得できます。
実行すると、キャンセルされた後でも、ジョブが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.")
}
NOTE
完全なコードはこちらから取得できます。
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.")
}
NOTE
完全なコードはこちらから取得できます。
ご覧のとおり、このループはキャンセルされます。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.")
}
NOTE
完全なコードはこちらから取得できます。
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.")
}
NOTE
完全なコードはこちらから取得できます。
タイムアウト
コルーチンの実行をキャンセルする最も明白で実用的な理由は、その実行時間がタイムアウトを超過したためです。 対応するJob
への参照を手動で追跡し、遅延後に追跡対象をキャンセルする別のコルーチンを起動することもできますが、それを実行するすぐに使えるwithTimeout
関数があります。 次の例を見てください。
import kotlinx.coroutines.*
fun main() = runBlocking {
withTimeout(1300L) {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
}
}
NOTE
完全なコードはこちらから取得できます。
次の出力が生成されます。
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")
}
NOTE
完全なコードはこちらから取得できます。
このコードを実行しても、例外は発生しなくなります。
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
}
NOTE
完全なコードはこちらから取得できます。
上記のコードを実行すると、常にゼロが出力されるわけではないことがわかります。これは、お使いのマシンのタイミングに依存する場合があります。実際にゼロ以外の値を確認するには、この例のタイムアウトを調整する必要があるかもしれません。
NOTE
ここで1万個のコルーチンから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
}
NOTE
完全なコードはこちらから取得できます。
この例では常にゼロが出力されます。リソースはリークしません。