(Kotlin Coroutines) Part 2 - Cancellation and timeouts

Kotlin Coroutines Part 2 - Cancellation and timeouts

코루틴을 취소하는 방법과 타밍아웃에 대해 알아보자.

Cancelling coroutine execution

사용자의 동작에 따라 코루틴이 시작된 뷰가 닫히는 등의 이벤트가 언제든지 발생할 수 있다.

이를 대비하기 위해 launch에서 반환하는 job 객체를 활용하여 코루틴을 종료시킬 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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.")
}

출력 결과는 아래와 같다.

1
2
3
4
5
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.

job 객체의 cancel() 메서드를 실행시키는 즉시 해당 job에 매핑된 코루틴은 취소된다.

위의 예제처럼 cancel()join()을 따로 쓸 필요없이 cancelAndJoin() 메소드를 활용할 수도 있다.

Cancellation is cooperative

코루틴은 언제든지 취소가 가능하도록 작성되어야 한다.

모든 kotlinx.coroutinessuspending function 역시 언제든지 취소가 가능하며, 취소시 CancellationException을 throw 한다.

참고 kotlinx.coroutines#CancellationException

이때 주의해야할 점은 코루틴이 computation 관련 작업을 하고 있는 상태라면, 취소 확인 후 취소가 가능하다는 점이다.

아래 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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.")
}

출력 결과는 아래와 같다.

1
2
3
4
5
6
7
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm sleeping 3 ...
job: I'm sleeping 4 ...
main: Now I can quit.

while (i < 5)에 대한 작업이 있기때문에 cancelAndJoin()이 호출되어도 computation 연산이 끝날떄까지 출력됨을 확인할 수 있다.

Making computation code cancellable

computation 연산이 있다고 코루틴을 취소할 수 없다면 Cancellation is cooperative 라는 표현을 쓸 수 없을 것이다.

computation 연산을 가진 코드를 취소하는 방법은 두 가지가 있다.

먼저 suspend 키워드를 가진 yield() 메서드를 이용하는 방법, 그리고 CoroutineScope에서 제공하는 isActive 속성을 사용하는 것이다.

참고 kotlinx.coroutines#yield

참고 kotlinx.coroutines#isActive

동일한 환경에서 isActive를 활용해 취소할 수 있도록 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
// 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.")
}

출력 결과는 아래와 같다.

1
2
3
4
5
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.

cancelAndJoin()이후 바로 종료되는 것을 알 수 있다.

Closing resources with finally

위에서 모든 kotlinx.coroutinessuspending function 역시 언제든지 취소가 가능하며, 취소시 CancellationException을 throw 한다고 언급했다.

CancellationException을 받아 처리하기 위해 try {...} finally {...}use() 메서드를 활용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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.")
}

출력 결과는 아래와 같다.

1
2
3
4
5
6
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.

Run non-cancellable block

만약 이미 동작이 종료된 코루틴에서 suspend를 사용해야 하는 경우가 존재할 수 있다.

이 경우 withContext() 메서드와 NonCancellable 객체를 활용해 코드를 랩핑하면 된다.

참고 kotlinx.coroutines#withContext

참고 kotlinx.coroutines#NonCancellable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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.")
}

출력 결과는 아래와 같다.

1
2
3
4
5
6
7
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
job: And I've just delayed for 1 sec because I'm non-cancellable
main: Now I can quit.

Timeout

코루틴을 취소하는 가장 확실한 이유 중 하나는 바로 타임아웃일 것이다.

이떄 withTimeout() 메서드를 이용해 시간초과로 종료로 발생한 TimeoutCancellationException을 stacktrace로 출력할 수 있다.

참고 kotlinx.coroutines#withTimeout

참고 kotlinx.coroutines#TimeoutCancellationException

아래 코드를 실행했을 때 출력되는 stacktrace를 참고하자.

1
2
3
4
5
6
7
8
9
10
import kotlinx.coroutines.*

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

출력 결과는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
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
at (Coroutine boundary. (:-1)
at FileKt$main$1$1.invokeSuspend (File.kt:-1)
at FileKt$main$1.invokeSuspend (File.kt:-1)
Caused by: kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms
at (Coroutine boundary .(:-1)
at FileKt$main$1$1 .invokeSuspend(File.kt:-1)
at FileKt$main$1 .invokeSuspend(File.kt:-1)

TimeoutCancellationExceptionCancellationException의 서브클래스인데, 이는 타임아웃으로 취소된 코루틴 동작의 완료 이기 때문이다.

withTimeout()과 비슷한 용도로 withTimeoutOrNull()도 있는데, withTimeoutOrNull를 쓰면 Exception 대신 null을 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
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")
}

출력 결과는 아래와 같다.

1
2
3
4
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null

Asynchronous timeout and resources

withTimeout()을 통해 얻어오는 타임아웃 이벤트는 해당 블럭 내에서 비동기식으로 처리되지만, withTimeout() 블록 내에서 반환되기전에도 발생할 여지가 있다.

만약 특정 리소스를 관리하는 코드라면 아래 예제를 통해 리소스의 유출을 방지하는 코드 작성법을 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import kotlinx.coroutines.*

var acquired = 0

class Resource {
init { acquired++ } // Acquire the resource
fun close() { acquired-- } // Release the resource
}

fun main() {
runBlocking {
repeat(100_000) { // Launch 100K 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
}

출력 결과는 코드 구조상 repeat()에 부여된 수의 범위로 나온다.

즉 0 ~ 100,000 중 랜덤한 수가 나오게 되므로 리소스가 유출되었다고 볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import kotlinx.coroutines.*

var acquired = 0

class Resource {
init { acquired++ } // Acquire the resource
fun close() { acquired-- } // Release the resource
}

fun main() {
runBlocking {
repeat(100_000) { // Launch 100K 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
}

위와 같은 구조로 코드를 작성하면 아래와 같은 출력 결과를 얻을 수 있다.

1
0

리소스가 유출되지 않았음을 알 수 있다.