Post

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 함수를 사용하면 취소에 협조할 수 있습니다.

먼저 취소를 협조하는 함수들을 설명합니다.

  1. 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 체크가 없다면, 작업이 계속 진행될 수도 있습니다.

  1. 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()을 호출해도 작업이 계속 진행될 가능성이 있습니다.

  1. 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()이 호출되었음에도 불구하고 코루틴이 취소되지 않는 이유는 코루틴이 취소 상태를 체크하지 않기 때문입니다.

크게 두가지로 설명할 수 있습니다.

  1. job.cancel()은 코루틴을 즉시 중단시키지 않는다 job.cancel()을 호출하면 코루틴이 취소 상태(canceled state)가 되지만, 코루틴 내부에서 취소 상태를 체크하지 않으면 계속 실행됩니다. 현재 코드에서는 while (i <= 5) 내부에서 취소 상태를 체크하는 로직이 없기 때문에 job.cancel()을 호출해도 종료되지 않습니다.

  2. suspend 함수가 없어 취소 협조가 이루어지지 않는다 코루틴의 취소 메커니즘은 suspend 함수(delay(), yield(), withTimeout())를 사용할 때 동작합니다. 하지만 현재 while (i <= 5) 내부에는 suspend 함수가 없습니다. 위의 코드는 무한 루프처럼 계속 실행되면서 취소 상태를 체크할 기회가 없습니다.

즉 위의 코드에서 취소가 되지 않는 이유는 루프 내에서 취소 상태를 체크하지 않기 때문입니다.

그렇다면, 코루틴을 취소할 수 있게하려면 아래와 같이 코드를 수정해야합니다.

  1. isActive 체크 추가 && Dispatchers.Default

    • isActive
      • 코틀린을 만들 때 사용한 함수 블록 안에서는 isActive 라는 프로퍼티에 접근할 수 있습니다. 이 프로퍼티는 현재 코루틴이 활성화 되어 있는지, 아니면 취소 신호를 받았는지 구분할 수 있게 해줍니다.
      • 즉, isActive는 코루틴이 취소 요청을 받았는지 확인하는 방법입니다. 코루틴 내부에서 isActive 플래그를 확인하면 취소 상태일 때 루프를 빠져나갈 수 있습니다.
    • Dispatchers.Default
      • 취소 신호를 정상적으로 전달하려면, 우리가 만든 코루틴이 다른 스레드에서 동작해야합니다.
      • Dispatchers.Default 를 launch() 함수에 전달하면 우리의 코루틴을 다른 스레드에서 동작시킬 수 있습니다.
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
    }
}
  1. 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
This post is licensed under CC BY 4.0 by the author.