취소와 타임아웃

취소와 타임아웃 (Cancellation and timeouts)

취소(cancellation)는 코루틴이 완료되기 전에 그것을 멈추도록 요청하는 기능입니다. 더 이상 필요 없는 작업을 중단해요. 예를 들어 사용자가 창을 닫거나, 코루틴이 아직 실행 중인데 사용자가 UI 밖으로 이동하는 경우 같은 상황에서요.

취소를 사용하면 리소스를 일찍 해제하고, 코루틴이 이미 폐기된 객체에 접근하지 못하게 막을 수 있어요. 또한 반복적인 작업을 수행하는 오래 실행되는 코루틴을 중단하는 데도 씁니다.

  • 하트비트(heartbeat)를 보내는 작업.
  • 예약된 작업 실행.
  • 시계 UI처럼 최신 값을 반영하도록 상태를 갱신하는 작업.

취소는 코루틴의 생명주기와 부모-자식 관계를 나타내는 Job 핸들을 통해 동작합니다. Job은 코루틴이 활성화되어 있는지 확인하고, 구조적 동시성(structured concurrency)에 정의된 대로 자식들과 함께 코루틴을 취소할 수 있게 해줘요.

출처: Kotlin 공식 문서

본문

코루틴 취소하기

코루틴은 그 Job 핸들에 cancel() 함수가 호출되면 취소됩니다. .launch() 같은 코루틴 빌더 함수Job을 반환해요. .async() 함수는 Deferred를 반환하는데, DeferredJob을 구현하며 같은 취소 동작을 지원합니다.

cancel() 함수를 직접 호출할 수도 있고, 부모 코루틴이 취소될 때 취소 전파(cancellation propagation)를 통해 자동으로 호출될 수도 있어요.

코루틴이 취소되면, 다음에 취소를 확인할 때 CancellationException을 던집니다. kotlinx.coroutines 라이브러리의 delay() 함수 같은 중단 함수들은 중단할 때 취소를 확인하죠.

코루틴을 취소될 때까지 중단시키려면 awaitCancellation() 함수를 사용할 수 있어요. 이것은 delay(Duration.INFINITE)를 호출하는 것과 같습니다.

코루틴이 언제 어떻게 취소를 확인하는지에 대한 자세한 내용은 Suspension points and cancellation을 참고하세요.

코루틴을 수동으로 취소하는 예시를 볼게요.

import kotlinx.coroutines.*

suspend fun main() {
//sampleStart
withContext(Dispatchers.Default) {
    // 코루틴이 실행을 시작했음을 알리는 신호로 사용된다
    val childStarted = CompletableDeferred<Unit>()

    val childJob: Job = launch {
        println("The coroutine has started")

        // CompletableDeferred를 완료해
        // 코루틴이 실행을 시작했음을 알린다
        childStarted.complete(Unit)
        try {
            // 무기한 중단한다
            // 코루틴이 취소되지 않는 한 이 호출은 절대 반환되지 않는다
            awaitCancellation()
        } catch (e: CancellationException) {
            println("The coroutine was canceled: $e")

            // 취소 예외는 항상 다시 던져야 한다!
            throw e
        }
        println("This line will never be executed")
    }

    // 취소하기 전에 코루틴이 시작하길 기다린다
    childStarted.await()

    // 코루틴을 취소한다.
    // 그러면 awaitCancellation()이 CancellationException을 던진다
    childJob.cancel()
}
// withContext()나 coroutineScope() 같은 코루틴 빌더는
// 자식이 취소되어도 모든 자식 코루틴이 완료되기를 기다린다
println("All coroutines have completed")
//sampleEnd
}

이 예시에서 CompletableDeferred는 코루틴이 실행을 시작했음을 알리는 신호로 사용됩니다. 코루틴은 실행을 시작할 때 complete()를 호출하고, await()는 그 CompletableDeferred가 완료된 뒤에만 반환해요. 코루틴을 취소하는 데 이 검사가 필요한 것은 아닙니다. 코루틴이 취소되기 전에 시작하고 메시지를 출력하도록 보장해 예시를 재현 가능하게 만들기 위해 포함된 거예요.

DeferredJob을 구현하므로, async() 코루틴 빌더 함수로 만든 코루틴도 취소가 같은 방식으로 동작합니다.

val deferred = async { /* ... */ }
deferred.cancel()

CancellationException을 잡으면 취소 전파를 깨뜨릴 수 있어요. 꼭 잡아야 한다면, 코루틴 계층을 통해 취소가 올바르게 전파되도록 그것을 다시 던지세요. 자세한 내용은 Coroutine exceptions handling을 참고하세요.

취소 전파

구조적 동시성은 코루틴을 취소하면 그 모든 자식을 함께 취소하도록 보장합니다. 이는 부모 코루틴이 취소된 뒤에도 자식 코루틴이 계속 작업하는 것을 막아 줘요.

예시를 볼게요.

import kotlinx.coroutines.*

suspend fun main() {
    withContext(Dispatchers.Default) {
//sampleStart
// 자식 코루틴들이 실행되었음을 알리는 신호로 사용된다
val childrenLaunched = CompletableDeferred<Unit>()

// 두 개의 자식 코루틴을 실행한다
val parentJob = launch {
    launch {
        println("Child coroutine 1 has started running")
        try {
            awaitCancellation()
        } finally {
            println("Child coroutine 1 has been canceled")
        }
    }
    launch {
        println("Child coroutine 2 has started running")
        try {
            awaitCancellation()
        } finally {
            println("Child coroutine 2 has been canceled")
        }
    }
    // CompletableDeferred를 완료해
    // 자식 코루틴들이 실행되었음을 알린다
    childrenLaunched.complete(Unit)
}
// 부모 코루틴이 모든 자식을 실행했음을 알릴 때까지 기다린다
childrenLaunched.await()

// 부모 코루틴을 취소하면 모든 자식이 함께 취소된다
parentJob.cancel()
//sampleEnd
    }
}

이 예시에서 각 자식 코루틴은 finally 블록을 사용하므로, 코루틴이 취소될 때 그 안의 코드가 실행됩니다. 여기서 CompletableDeferred는 자식들이 취소되기 전에 실행되었음을 알리지만, 그들이 실제로 시작했다는 것을 보장하지는 않아요. 먼저 취소되면 아무것도 출력되지 않습니다.

코루틴이 취소에 반응하게 만들기

Kotlin에서 코루틴 취소는 **협력적(cooperative)**입니다. 코루틴은 중단하거나 명시적으로 취소를 확인함으로써 협력할 때만 취소에 반응해요.

이 섹션에서는 yield() 함수 호출 같은 중단 지점(suspension point)을 추가하면 코루틴이 취소에 반응하게 되는 방법을 배웁니다.

중단 지점과 취소

코루틴이 취소되면, 중단할 수 있는 코드 지점(중단 지점)에 도달할 때까지 계속 실행됩니다. 거기서 코루틴이 중단되면, 중단 함수는 취소되었는지 확인해요. 취소되었다면 코루틴은 멈추고 CancellationException을 던집니다.

suspend 함수 호출은 중단 지점이지만 항상 중단되지는 않아요. 예를 들어 Deferred 결과를 기다릴 때, 코루틴은 그 Deferred가 아직 완료되지 않은 경우에만 중단됩니다.

중단되는 일반적인 중단 함수들을 사용하는 예시를 볼게요. 이 함수들이 중단을 통해 코루틴이 취소를 확인하고 멈출 수 있게 해줍니다.

import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.channels.Channel
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration

suspend fun main() {
//sampleStart
withContext(Dispatchers.Default) {
    val childJobs = listOf(
        launch {
            // 취소될 때까지 중단한다
            awaitCancellation()
        },
        launch {
            // 취소될 때까지 중단한다
            delay(Duration.INFINITE)
        },
        launch {
            val channel = Channel<Int>()
            // 결코 보내지지 않는 값을 기다리며 중단한다
            channel.receive()
        },
        launch {
            val deferred = CompletableDeferred<Int>()
            // 결코 완료되지 않는 값을 기다리며 중단한다
            deferred.await()
        },
        launch {
            val mutex = Mutex(locked = true)
            // 무기한 잠긴 상태로 남는 mutex를 기다리며 중단한다
            mutex.lock()
        }
    )

    // 자식 코루틴들이 시작하고 중단할 시간을 준다
    delay(100.milliseconds)

    // 모든 자식 코루틴을 취소한다
    childJobs.forEach { it.cancel() }
}
println("All child jobs completed!")
//sampleEnd
}

kotlinx.coroutines 라이브러리의 모든 중단 함수는 내부적으로 suspendCancellableCoroutine()을 사용하기 때문에 취소에 협력합니다. 이 함수는 코루틴이 중단할 때 취소를 확인하지요. 반대로 suspendCoroutine()을 사용하는 커스텀 중단 함수는 취소에 반응하지 않아요.

yield() 중단 함수

코루틴이 중단하지 않으면, 같은 스레드의 다른 코루틴은 그것이 완료될 때까지 실행할 수 없습니다. 결과적으로 중단하지 않는 코루틴들은 그 스레드에서 순차적으로 실행돼요. 코루틴이 오랫동안 중단하지 않으면, 취소되어도 멈추지 않습니다.

CPU 집약적인 계산이나 중단 없이 오래 실행되는 다른 코드에서는 yield() 함수를 주기적으로 호출하세요. 이 함수는 현재 스레드를 해제하고 다른 코루틴에게 그 스레드에서 실행할 기회를 줍니다. 또한 코루틴이 정기적으로 취소를 확인하도록 보장하죠. 코루틴이 취소되면 yield() 함수는 CancellationException을 던집니다.

예시를 볼게요.

import kotlinx.coroutines.*

fun main() {
//sampleStart
// runBlocking은 모든 코루틴 실행에 현재 스레드를 사용한다
runBlocking {
    val coroutineCount = 5
    repeat(coroutineCount) { coroutineIndex ->
        launch {
            val id = coroutineIndex + 1
            repeat(5) { iterationIndex ->
                val iteration = iterationIndex + 1
                // 다른 코루틴에게 실행할 기회를 주기 위해 잠시 중단한다
                // 이게 없으면 코루틴들이 순차적으로 실행된다
                yield()
                // 코루틴 인덱스와 반복 인덱스를 출력한다
                println("$id * $iteration = ${id * iteration}")
            }
        }
    }
}
//sampleEnd
}

이 예시에서 각 코루틴은 yield()를 사용해 반복 사이에 다른 코루틴이 실행되게 합니다.

명시적으로 취소 확인하기

중단 없이 취소에 반응해야 하는 오래 실행되는 코드는 명시적으로 취소를 확인할 수 있어요. 중단하지 않는 오래 실행되는 코루틴은 그것이 완료될 때까지 같은 스레드의 다른 코루틴이 실행되는 것을 막을 수 있습니다. 사용 사례에 의도된 동작이 아니라면, 대신 yield() 함수를 사용하세요.

API에 따라 검사는 불리언 값을 반환하거나 예외를 던집니다.

  • isActive 속성은 코루틴이 취소되면 false를 반환해요.
  • ensureActive() 함수는 코루틴이 취소되면 CancellationException을 던져요.

코루틴 취소 시 차단 코드 인터럽트하기

JVM에서 Thread.sleep()이나 BlockingQueue.take() 같은 일부 차단 함수는 현재 스레드를 차단할 수 있어요. 이런 차단 함수는 인터럽트되어 조기에 멈출 수 있습니다. 하지만 코루틴에서 호출하면, 취소는 스레드를 인터럽트하지 않아요.

코루틴을 취소할 때 스레드를 인터럽트하려면 차단 코드를 runInterruptible() 함수로 감싸세요.

import kotlinx.coroutines.*

suspend fun main() {
//sampleStart
withContext(Dispatchers.Default) {
    val childStarted = CompletableDeferred<Unit>()
    val childJob = launch {
        try {
            // 취소가 스레드 인터럽트를 촉발한다
            runInterruptible {
                childStarted.complete(Unit)
                try {
                    // 현재 스레드를 아주 오랫동안 차단한다
                    Thread.sleep(Long.MAX_VALUE)
                } catch (e: InterruptedException) {
                    println("Thread interrupted (Java): $e")
                    throw e
                }
            }
        } catch (e: CancellationException) {
            println("Coroutine canceled (Kotlin): $e")
            throw e
        }
    }
    childStarted.await()

    // 코루틴을 취소하고 Thread.sleep()을 실행하는 스레드를 인터럽트한다
    childJob.cancel()
}
//sampleEnd
}

코루틴 취소 시 값을 안전하게 다루기

중단된 코루틴이 취소되면, 값이 이미 사용 가능하더라도 그 값을 반환하는 대신 CancellationException으로 재개됩니다. 이 동작을 **즉시 취소(prompt cancellation)**라고 해요. 이미 닫힌 화면을 갱신하는 것처럼, 취소된 코루틴의 스코프에서 코드가 계속되는 것을 막아 줍니다.

예시를 볼게요.

// UI 스레드를 사용하는 코루틴 스코프를 정의한다
class ScreenWithButtons(private val scope: CoroutineScope) {
    fun loadAndUpdateButtons(filename: String) {
        scope.launch {
            // withContext()는 블록에 들어가기 전과
            // 블록이 반환된 뒤 취소를 확인한다
            val buttonNames = withContext(Dispatchers.IO) {
                // 이것은 취소에 반응하지 않는 차단 호출이다
                readLines(filename)
            }

            // updateUi()를 호출해도 안전하다.
            // 코루틴이 취소되면 withContext()가 반환하지 않고,
            // 이 호출 전에 UI 스레드에서 실행되는 코드가 버튼을 폐기할 수 없기 때문이다
            updateUi(buttonNames)
        }
    }

    // UI 스레드에서만 이 함수를 호출한다. 버튼에 접근하기 때문이다
    // 버튼이 폐기된 뒤 호출되면 예외를 던진다
    private fun updateUi(buttonNames: List<String>) {
        // 지정된 이름으로 버튼을 갱신하는 placeholder 코드
    }

    // UI 스레드에서만 이 함수를 호출한다
    fun leaveScreen() {
        // 화면을 떠날 때 스코프를 취소한다
        // 더 이상 UI를 갱신할 수 없다
        scope.cancel()
    }
}

// UI 컨트롤러 코드
setHandler(Event.ScreenClosed) {
    // UI 스레드에서 실행된다
    screenWithButtons.leaveScreen()
    buttons.dispose()
}

이 예시에서 withContext(Dispatchers.IO)는 취소에 협력하며, leaveScreen() 함수가 withContext(Dispatchers.IO)가 버튼 이름을 반환하기 전에 코루틴을 취소하면 updateUi()가 실행되지 않도록 막아 줍니다.

즉시 취소는 값이 더 이상 유효하지 않게 된 뒤 그 값을 사용하는 것을 막지만, 중요한 값이 여전히 사용 중인데 코드를 멈추게 해 그 값을 잃을 수도 있어요. 코루틴이 AutoCloseable 리소스 같은 값을 받았는데, 그것을 닫는 코드 부분에 도달하기 전에 취소될 때 이런 일이 벌어집니다. 이를 막으려면, 값을 받는 코루틴이 취소되어도 실행이 보장되는 곳에 정리 로직을 두세요.

예시를 볼게요.

import java.nio.file.*
import java.nio.charset.*
import kotlinx.coroutines.*
import java.io.*

// UI 스레드에서 코루틴을 실행하는 스코프를 사용한다
class ScreenWithFileContents(private val scope: CoroutineScope) {
    fun displayFile(path: Path) {
        scope.launch {
            // reader를 변수에 저장해 finally 블록이 닫을 수 있게 한다
            var reader: BufferedReader? = null

            try {
                withContext(Dispatchers.IO) {
                    reader = Files.newBufferedReader(
                        path, Charset.forName("US-ASCII")
                    )
                }
                // withContext()가 완료된 뒤 저장된 reader를 사용한다
                updateUi(reader!!)
            } finally {
                // 코루틴이 취소되어도 reader가 닫히도록 보장한다
                reader?.close()
            }
        }
    }

    private suspend fun updateUi(reader: BufferedReader) {
        // 파일 내용을 보여준다
        while (true) {
            val line = withContext(Dispatchers.IO) {
                reader.readLine()
            }
            if (line == null)
                break
            addOneLineToUi(line)
        }
    }

    private fun addOneLineToUi(line: String) {
        // UI에 한 줄을 추가하는 코드의 placeholder
    }

    // UI 스레드에서만 호출 가능하다
    fun leaveScreen() {
        // 스코프를 취소하고 그 코루틴들이 UI를 갱신하지 못하게 한다
        scope.cancel()
    }
}

이 예시에서 BufferedReader를 변수에 저장하고 finally 블록에서 닫는 것은, 코루틴이 취소되어도 리소스가 해제되도록 보장합니다.

취소 불가 블록 실행하기

코루틴의 특정 부분에 취소가 영향을 주지 않게 할 수 있어요. 그렇게 하려면 NonCancellablewithContext() 코루틴 빌더 함수의 인자로 전달하면 됩니다.

NonCancellable.launch().async() 같은 다른 코루틴 빌더와 함께 쓰는 것은 피하세요. 그러면 부모-자식 관계가 깨져 구조적 동시성이 무너집니다.

NonCancellable은 중단하는 close() 함수로 리소스를 닫는 것 같은 특정 연산이, 코루틴이 완료되기 전에 취소되어도 완료되도록 보장해야 할 때 유용해요.

예시를 볼게요.

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.milliseconds

//sampleStart
val serviceStarted = CompletableDeferred<Unit>()

fun startService() {
    println("Starting the service...")
    serviceStarted.complete(Unit)
}

suspend fun shutdownServiceAndWait() {
    println("Shutting down...")
    delay(100.milliseconds)
    println("Successfully shut down!")
}

suspend fun main() {
    withContext(Dispatchers.Default) {
        val childJob = launch {
            startService()
            try {
                awaitCancellation()
            } finally {
                withContext(NonCancellable) {
                    // withContext(NonCancellable)이 없으면
                    // 코루틴이 취소되었기 때문에 이 함수는 완료되지 않는다
                    shutdownServiceAndWait()
                }
            }
        }
        serviceStarted.await()
        childJob.cancel()
    }
    println("Exiting the program")
}
//sampleEnd

타임아웃

타임아웃(timeout)은 지정된 시간이 지나면 코루틴을 자동으로 취소할 수 있게 해줍니다. 너무 오래 걸리는 작업을 멈추는 데 사용할 수 있죠.

예를 들어 서버에서 그림을 다운로드하는 요청이 타임아웃되면, 재시도하거나 로컬 캐시로 폴백할 수 있어요.

타임아웃을 지정하려면 Duration과 함께 withTimeoutOrNull() 함수를 사용합니다.

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.milliseconds

//sampleStart
suspend fun slowOperation(): String {
    try {
        delay(300.milliseconds)
        return "A"
    } catch (e: CancellationException) {
        println("The slow operation has been canceled: $e")
        throw e
    }
}

suspend fun fastOperation(): String {
    try {
        delay(15.milliseconds)
        return "B"
    } catch (e: CancellationException) {
        println("The fast operation has been canceled: $e")
        throw e
    }
}

suspend fun main() {
    withContext(Dispatchers.Default) {
        val slow = withTimeoutOrNull(100.milliseconds) {
            slowOperation()
        }
        println("The slow operation finished with $slow")
        val fast = withTimeoutOrNull(100.milliseconds) {
            fastOperation()
        }
        println("The fast operation finished with $fast")
    }
}
//sampleEnd

타임아웃이 지정된 Duration을 초과하면, withTimeoutOrNull()null을 반환합니다.

더 알아보기 (Learn more)