coroutine 알아보기 (3) - coroutine 취소
coroutine cancel
cancel
더 이상 사용하지 않는 코루틴을 취소하는 것은 중요합니다. 특히 여러 코루틴을 사용할 때, 필요 없어진 코루틴을 적절하게 취소하며 컴퓨터 자원을 절약할 수 있습니다.
코루틴을 취소하기 위해서는 Job객체의 cancel() 함수를 사용할 수 있습니다. 다만, 취소 대상인 코루틴도 취소에 협조를 해주어야합니다.
취소에 협조한다는 무슨 의미?
코루틴이 “취소에 협조한다”는 것은 취소 요청을 감지하고 이를 반영하여 안전하게 종료될 수 있도록 구현하는 것을 의미합니다. 코루틴은 기본적으로 cancel()이 호출되었다고 즉시 종료되지 않습니다. 취소 가능한 상태여야만 정상적으로 종료됩니다.
isActive, yield(), 또는 suspend 함수(delay() 등)를 사용하면 취소 요청을 감지하고 적절히 종료할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun main(): Unit = runBlocking {
val job1 = launch {
delay(1_000L)
printWithThread("job 1")
}
val job2 = launch {
delay(1_000L)
printWithThread("job 2")
}
delay(100) // 첫 번째 코루틴 코드가 시작되는 것을 잠시 기다린다.
job1.cancel()
}
// [main] job 2
runBlocking안에서 두개의 코루틴을 만들고 job1을 취소시켰습니다. 첫 번째 코루틴은 취소에 잘 협조하고 있기에 정상적으로 취소되어 “Job 1”이 출력되지 않고, “Job 2”만 출력되었습니다.
바로 delay() 함수가 바로 코루틴 취소에 대한 협조입니다. 더 근본적으로는 delay()
혹은 yield()
와 같은 kotlinx.coroutines 패키지의 suspend 함수를 사용하면 취소에 협조할 수 있습니다.
먼저 취소를 협조하는 함수들을 설명합니다.
- isActive를 사용한 취소 협조
isActive는 코루틴이 활성 상태인지 확인하는 플래그입니다. 코루틴 내부에서 반복 작업을 수행할 때, 주기적으로 isActive를 확인하면 취소 요청이 들어왔을 때 빠르게 종료할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
if (!isActive) return@launch // 취소 협조
println("작업 실행 중... $i")
delay(500) // delay()는 자동으로 취소 협조
}
}
delay(1300) // 1.3초 후 취소
println("코루틴 취소 요청")
job.cancel()
job.join() // 코루틴이 완전히 종료될 때까지 대기
println("코루틴이 취소됨")
}
// 작업 실행 중... 0
// 작업 실행 중... 1
// 작업 실행 중... 2
// 코루틴 취소 요청
// 코루틴이 취소됨
isActive가 false이면 루프를 빠져나가서 자연스럽게 종료되며, cancel() 호출 후에도 isActive 체크가 없다면, 작업이 계속 진행될 수도 있습니다.
- yield()를 사용한 취소 협조
코루틴은 suspend 함수 내부에서만 취소 요청을 감지할 수 있습니다. yield()를 호출하면 취소 상태를 체크한 후, 취소가 요청되었으면 즉시 종료됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
yield() // 취소 상태 체크
println("작업 실행 중... $i")
}
}
delay(1300)
println("코루틴 취소 요청")
job.cancel()
job.join()
println("코루틴이 취소됨")
}
yield()는 suspend 함수이므로 취소 신호를 체크할 수 있습니다. yield()가 없으면 cancel()을 호출해도 작업이 계속 진행될 가능성이 있습니다.
- withContext(NonCancellable)를 사용하여 특정 블록 보호
cancel()이 호출되더라도 어떤 작업은 끝까지 실행되어야 할 경우가 있습니다. 예를 들어 파일 저장, 데이터베이스 트랜잭션 정리 등의 작업은 중단되면 안됩니다. 이때, withContext(NonCancellable)을 사용하면 취소 요청이 와도 그 블록만큼은 끝까지 실행됩니다.
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
fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println("작업 실행 중... $i")
delay(500)
}
} finally {
withContext(NonCancellable) {
println("정리 작업 실행 중...")
delay(1000) // 취소되었어도 실행됨
println("정리 작업 완료")
}
}
}
delay(1300)
println("코루틴 취소 요청")
job.cancelAndJoin()
println("코루틴이 취소됨")
}
// 작업 실행 중... 0
// 작업 실행 중... 1
// 작업 실행 중... 2
// 코루틴 취소 요청
// 정리 작업 실행 중...
// 정리 작업 완료
// 코루틴이 취소됨
finally 블록에서 withContext(NonCancellable)을 사용하면 취소 요청이 와도 정리 작업을 완료할 수 있습니다.
이러한 함수들을 바탕으로 코루틴이 취소에 협조할 수 있습니다. 그렇다면 코루틴이 취소에 협조하지않으면 정말 취소가 되지않는지 확인해봅니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fun main(): Unit = runBlocking {
val job = launch {
var i = 1
var nextPrintTime = System.currentTimeMillis()
while (i <= 5) {
if (nextPrintTime <= System.currentTimeMillis()) {
printWithThread("${i++}번째 호출")
nextPrintTime += 1_000L
}
}
}
delay(100L)
job.cancel()
}
// 코루틴 취소가 안됨.
//[main] 1번째 호출
//[main] 2번째 호출
//[main] 3번째 호출
//[main] 4번째 호출
//[main] 5번째 호출
위 코드를 살펴보면 job.cancel()
을 사용해 코루틴을 취소시켰지만, launch로 만든 코루틴은 5번을 모두 출력할 때까지 취소되지 않는 모습을 확인할 수 있습니다.
위 코드에서 job.cancel()이 호출되었음에도 불구하고 코루틴이 취소되지 않는 이유는 코루틴이 취소 상태를 체크하지 않기 때문입니다.
크게 두가지로 설명할 수 있습니다.
job.cancel()은 코루틴을 즉시 중단시키지 않는다 job.cancel()을 호출하면 코루틴이 취소 상태(canceled state)가 되지만, 코루틴 내부에서 취소 상태를 체크하지 않으면 계속 실행됩니다. 현재 코드에서는
while (i <= 5)
내부에서 취소 상태를 체크하는 로직이 없기 때문에 job.cancel()을 호출해도 종료되지 않습니다.suspend 함수가 없어 취소 협조가 이루어지지 않는다 코루틴의 취소 메커니즘은 suspend 함수(delay(), yield(), withTimeout())를 사용할 때 동작합니다. 하지만 현재 while (i <= 5) 내부에는 suspend 함수가 없습니다. 위의 코드는 무한 루프처럼 계속 실행되면서 취소 상태를 체크할 기회가 없습니다.
즉 위의 코드에서 취소가 되지 않는 이유는 루프 내에서 취소 상태를 체크하지 않기 때문입니다.
그렇다면, 코루틴을 취소할 수 있게하려면 아래와 같이 코드를 수정해야합니다.
isActive 체크 추가 && Dispatchers.Default
- isActive
- 코틀린을 만들 때 사용한 함수 블록 안에서는 isActive 라는 프로퍼티에 접근할 수 있습니다. 이 프로퍼티는 현재 코루틴이 활성화 되어 있는지, 아니면 취소 신호를 받았는지 구분할 수 있게 해줍니다.
- 즉, isActive는 코루틴이 취소 요청을 받았는지 확인하는 방법입니다. 코루틴 내부에서 isActive 플래그를 확인하면 취소 상태일 때 루프를 빠져나갈 수 있습니다.
- Dispatchers.Default
- 취소 신호를 정상적으로 전달하려면, 우리가 만든 코루틴이 다른 스레드에서 동작해야합니다.
- Dispatchers.Default 를 launch() 함수에 전달하면 우리의 코루틴을 다른 스레드에서 동작시킬 수 있습니다.
- isActive
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fun main(): Unit = runBlocking {
// Dispatchers.Default : 다른 스레드에서 launch안의 코드가 돌게 만듦
val job = launch(Dispatchers.Default) {
var i = 1
var nextPrintTime = System.currentTimeMillis()
while (i <= 5) {
if (nextPrintTime <= System.currentTimeMillis()) {
printWithThread("${i++}번째 호출")
nextPrintTime += 1_000L
}
if(!isActive) {
throw CancellationException()
}
}
}
delay(100L)
job.cancel()
}
// //[DefaultDispatcher-worker-1] 1번째 호출
Dispatchers.Default
로 다른 스레드를 배정해 주었기 때문에 출력 결과에서 스레드 이름이 변경된 것을 확인할 수 있습니다. 또한, while문의 loop가 최초 한 번 동작한 이후, 두번째 반복하려 할 때, 취소 신호를 정상적으로 받은것을 확인할 수 있습니다.
여기서 launch(Dispatchers.Default)
를 사용하지 않고 launch()
만을 사용한다면, “취소 시작” 자체가 출력되지않습니다. 그 이유는 launch()
에서 동작시키고 있는 코드가 main 스레드를 점유한 채 비켜주지 않기 때문입니다.
추가적으로, throw CancellationException하지 않아도 isActive를 확인할 수 있습니다.
1
2
3
4
5
6
while (isActive) { // isActive일 때만 아래 로직을 실행한다.
if (nextPrintTime <= System.currentTimeMillis()) {
printWithThread("${i++} 번째 출력!")
nextPrintTime += 1_000L
}
}
- yield() 사용
yield()는 suspend 함수이며, 취소 상태를 체크하면서 실행을 멈추는 역할을 합니다. yield()를 사용하여 취소 상태를 감지하도록 만듭니다.
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(): Unit = runBlocking {
val job = launch {
var i = 1
var nextPrintTime = System.currentTimeMillis()
while (i <= 5) {
yield() // 취소 상태를 체크하는 suspend 함수
if (nextPrintTime <= System.currentTimeMillis()) {
printWithThread("${i++}번째 호출")
nextPrintTime += 1_000L
}
}
}
delay(100L)
job.cancel() // 취소 요청
}
fun printWithThread(message: String) {
println("[${Thread.currentThread().name}] $message")
}
위의 두가지 방법으로 코루틴을 취소하는 방법에 대해 설명하였습니다. 이 글 앞쪽에서 보았던 delay() 같은 함수 역시 CancellationException을 던지며 코루틴을 취소시키게 되는것입니다.
delay()는 suspend 함수로, 코루틴이 취소되었을 때 CancellationException을 던지면서 종료됩니다.
하지만, try-catch 블록에서 CancellationException을 잡아버리면 코루틴이 멈추지 않고 계속 실행될 수 있습니다.
아래의 코드를 보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fun main(): Unit = runBlocking {
val job = launch {
try {
delay(1000L)
} catch (e: CancellationException) {
printWithThread("CancellationException")
}
printWithThread("delay에 의해 취소되지않음")
}
delay(100L)
printWithThread("취소시작")
job.cancel()
}
// [main] 취소시작
// [main] CancellationException
// [main] delay에 의해 취소되지않음
위의 코드에서 delay(1000L)가 실행되는 동안 job.cancel()이 호출되면 CancellationException이 발생합니다. 그런데, catch 블록에서 CancellationException을 잡은 후 throw를 하지 않으면 예외가 사라지고 코루틴이 계속 진행됩니다.
즉, 코루틴이 취소되지 않고 남아 있는 코드가 실행됩니다 => 의도한 취소가 이루어지지 않음
올바르게 처리하려면 아래와 같이 CancellationException을 예외로 던져서 코루틴이 취소되도록 만들어야합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun main(): Unit = runBlocking {
val job = launch {
try {
delay(1000L)
} catch (e: CancellationException) {
throw e // 반드시 다시 던져야 정상적으로 취소됨
}
printWithThread("delay에 의해 취소되지않음") // 실행되지 않음
}
delay(100L)
printWithThread("취소시작")
job.cancel()
}
// [main] 취소시작
혹은 CancellationException를 catch하지않는것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
fun main(): Unit = runBlocking {
val job = launch {
delay(1000L)
printWithThread("delay에 의해 취소되지않음") // 실행되지 않음
}
delay(100L)
printWithThread("취소시작")
job.cancel()
}
// [main] 취소시작
정리
- 코루틴은 cancel() 호출만으로 즉시 중단되지 않는다.
- suspend 함수 (delay(), yield())가 포함되어 있어야 취소 신호를 감지할 수 있다.
- 루프 내부에서 isActive()를 활용하여 취소 상태를 확인해야 합니다.
- 취소 신호를 정상적으로 전달하려면, Dispatchers.Default를 전달하여 우리가 만든 코루틴이 다른 스레드에서 동작하게합니다.
- withContext(NonCancellable)을 사용하면 취소되어도 실행이 필요한 작업을 보호할 수 있다.
[출처]
- https://kotlinlang.org/docs/coroutines-overview.html