Skip to content

キャンセルとタイムアウト

このセクションでは、コルーチンのキャンセルとタイムアウトについて説明します。

コルーチン実行のキャンセル

長時間実行されるアプリケーションでは、バックグラウンドコルーチンをきめ細かく制御する必要がある場合があります。 たとえば、ユーザーがコルーチンを起動したページを閉じると、その結果は不要になり、その操作をキャンセルできます。 launch関数は、実行中のコルーチンをキャンセルするために使用できるJobを返します。

kotlin
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

完全なコードはこちらから取得できます。

次の出力が生成されます。

text
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.

mainjob.cancelを呼び出すとすぐに、他のコルーチンからは何も出力されなくなります。これは、そのコルーチンがキャンセルされたためです。 また、canceljoinの呼び出しを組み合わせるcancelAndJoinというJobの拡張関数もあります。

キャンセルは協調的

コルーチンのキャンセルは_協調的_です。コルーチンコードはキャンセル可能であるために協調する必要があります。 kotlinx.coroutinesのすべてのサスペンド関数は_キャンセル可能_です。それらはコルーチンのキャンセルをチェックし、キャンセルされたときにCancellationExceptionをスローします。 ただし、コルーチンが計算処理中にキャンセルのチェックを行わない場合、次の例に示すように、キャンセルすることはできません。

kotlin
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をキャッチし、それを再スローしないことによっても観察できます。

kotlin
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)に置き換えて、再度実行してください。

kotlin
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関数は、コルーチンがキャンセルされたときに、そのファイナライゼーションアクションを正常に実行します。

kotlin
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

完全なコードはこちらから取得できます。

joincancelAndJoinはどちらもすべてのファイナライゼーションアクションの完了を待機するため、上記の例では次の出力が生成されます。

text
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) {...}でラップできます。

kotlin
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関数があります。 次の例を見てください。

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
    withTimeout(1300L) {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
}

NOTE

完全なコードはこちらから取得できます。

次の出力が生成されます。

text
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関数を使用できます。

kotlin
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

完全なコードはこちらから取得できます。

このコードを実行しても、例外は発生しなくなります。

text
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null

非同期タイムアウトとリソース

withTimeoutにおけるタイムアウトイベントは、そのブロック内で実行されているコードに対して非同期であり、タイムアウトブロックの内部から戻る直前であっても、いつでも発生する可能性があります。 ブロック内で開いたり取得したりしたリソースをブロック外でクローズまたは解放する必要がある場合、この点に留意してください。

たとえば、ここではResourceクラスを使用して、クローズ可能なリソースを模倣します。このクラスは、acquiredカウンターをインクリメントすることで作成された回数を追跡し、close関数でカウンターをデクリメントします。 ここで、多数のコルーチンを作成してみましょう。それぞれのコルーチンは、withTimeoutブロックの最後にResourceを作成し、ブロック外でリソースを解放します。 タイムアウトがwithTimeoutブロックが完了した直後に発生しやすくなるように小さな遅延を追加します。これにより、リソースリークが発生します。

kotlin
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ブロックからリソースを返すのではなく、変数にリソースへの参照を格納することができます。

kotlin
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

完全なコードはこちらから取得できます。

この例では常にゼロが出力されます。リソースはリークしません。