코루틴 취소와 타임아웃 (Coroutines Cancellation and timeouts)
이 섹션은 코루틴 취소와 타임아웃에 관한 내용을 다룹니다.
코루틴 실행 취소
긴 실행 시간이 필요한 애플리케이션에서는 백그라운드 코루틴에 대한 미세한 제어가 필요할 수 있습니다. 예를 들어 사용자가 코루틴을 시작한 페이지를 닫았으며 이제 해당 결과가 더 이상 필요하지 않으며 작업을 취소할 수 있습니다. launch 함수는 실행 중인 코루틴을 취소하는 데 사용할 수 있는 Job을 반환합니다:
val job = launch {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
}
delay(1300L) // 약간 지연
println("main: I'm tired of waiting!")
job.cancel() // 작업 취소
job.join() // 작업 완료 대기
println("main: Now I can quit.")
이것은 다음과 같은 출력을 생성합니다:
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.
main이 job.cancel을 호출하자마자, 다른 코루틴에서는 더 이상 출력을 볼 수 없습니다. 또한 cancel과 join 호출을 결합하는 Job 확장 함수인 cancelAndJoin도 있습니다.
취소는 협력적임
코루틴 취소는 협력적입니다. 코루틴 코드는 협력해야 취소할 수 있습니다. kotlinx.coroutines의 모든 중단 함수는 취소 가능합니다. 이들은 코루틴의 취소를 확인하고 취소될 때 CancellationException을 throw합니다. 그러나 코루틴이 계산 중인 경우 취소를 확인하지 않으면 다음 예제와 같이 취소할 수 없습니다:
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 5) { // 계산 루프, CPU를 낭비함
// 0.5초마다 메시지 출력
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L) // 약간 지연
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 작업 취소하고 완료 대기
println("main: Now I can quit.")
취소 후에도 작업이 다섯 번의 반복 후 자체적으로 완료될 때까지 "I'm sleeping"을 계속 출력하는 것을 확인하기 위해 실행하십시오.
동일한 문제는 CancellationException을 catch하고 다시 던지지 않는 경우에도 발생할 수 있습니다:
val job = launch(Dispatchers.Default) {
repeat(5) { i ->
try {
// 0.5초마다 메시지 출력
println("job: I'm sleeping $i ...")
delay(500)
} catch (e: Exception) {
// 예외 기록
println(e)
}
}
}
delay(1300L) // 약간 지연
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 작업 취소하고 완료 대기
println("main: Now I can quit.")
Exception을 catch하는 것은 안티 패턴이지만, 이 문제는 CancellationException을 다시 던지지 않는 runCatching 함수를 사용할 때와 같은 미묘한 방식으로 나타날 수 있습니다.
취소가능한 계산 코드 만들기
계산 코드를 취소할 수 있도록 만드는 방법은 두 가지가 있습니다. 첫 번째 방법은 취소를 확인하는 중단 함수를 주기적으로 호출하는 것입니다. 이러한 목적에는 적합한 yield 함수가 있습니다. 또 다른 방법은 취소 상태를 명시적으로 확인하는 것입니다. 후자의 방법을 시도해 봅시다.
이전 예제에서의 while (i < 5)를 while (isActive)로 대체하고 다시 실행해 보세요.
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (isActive) { // 취소 가능한 계산 루프
// 0.5초마다 메시지 출력
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L) // 약간 지연
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 작업 취소하고 완료 대기
println("main: Now I can quit.")
이제 이 루프가 취소됩니다. isActive는 CoroutineScope 개체를 통해 코루틴 내에서 사용 가능한 확장 속성입니다.
finally를 사용하여 리소스 닫기
취소 가능한 중단 함수는 취소 시 CancellationException을 throw하며, 이를 일반적인 방법으로 처리할 수 있습니다. 예를 들어 try {...} finally {...} 표현식 및 Kotlin의 use 함수는 코루틴이 취소될 때 정상적으로 최종 작업을 수행합니다.
val job = launch {
try {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
} finally {
println("job: I'm running finally")
}
}
delay(1300L) // 약간 지연
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 작업 취소하고 완료 대기
println("main: Now I can quit.")
join과 cancelAndJoin은 모든 최종 작업이 완료될 때까지 기다립니다. 따라서 위의 예제는 다음 출력을 생성합니다:
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 컨텍스트를 사용합니다.
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) // 약간 지연
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 작업 취소하고 완료 대기
println("main: Now I can quit.")
타임아웃
코루틴 실행 시간이 일정 시간 초과되면 코루틴 실행을 취소해야 하는 가장 명백한 실용적 이유 중 하나입니다. 직접 참조를 추적하고 지연 후 추적된 코루틴을 취소하는 별도의 코루틴을 시작할 수 있지만, 이러한 작업을 수행하는 데 사용할 수 있는 withTimeout 함수가 준비되어 있습니다. 다음 예제를 살펴보세요:
withTimeout(1300L) {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
}
이것은 다음 출력을 생성합니다:
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에서 throw되는 TimeoutCancellationException은 CancellationException의 하위 클래스입니다. 우리는 이전에 콘솔에 그 스택 트레이스가 출력되지 않았습니다. 이것은 취소된 코루틴 내에서는 CancellationException이 코루틴 완료의 정상적인 이유로 간주되기 때문입니다. 그러나 이 예제에서는 withTimeout을 직접 main 함수 내에서 사용했습니다.
취소가 예외에 불과하므로 모든 리소스는 보통의 방법으로 닫힙니다. 시간 제한 내에서 코드를 래핑하고 try {...} catch (e: TimeoutCancellationException) {...} 블록에 추가 동작을 수행해야 하는 경우나 예외를 throw하는 대신 null을 반환하는 withTimeoutOrNull 함수를 사용할 수 있습니다:
val result = withTimeoutOrNull(1300L) {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
"Done" // 결과 생성 전에 취소됨
}
println("Result is $result")
이 코드를 실행할 때 더 이상 예외가 발생하지 않습니다:
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null
비동기 타임아웃 및 리소스
withTimeout의 타임아웃 이벤트는 해당 블록 내에서 실행 중인 코드와 비동기적으로 발생할 수 있으며, 때로는 타임아웃 블록 내부에서 반환 직전에 발생할 수 있습니다. 이 블록 내에서 닫거나 해제해야 하는 어떤 리소스를 열거나 획득하는 경우, 블록 외부에서 리소스를 닫거나 해제해야 할 때 이 사실을 염두에 두세요.
예를 들어, 여기에서는 간단한 closeable 리소스를 모방한 Resource 클래스를 사용합니다. 이 클래스는 획득 횟수를 기록하는 방식으로 closeable 리소스를 흉내 냅니다. 이제 withTimeout 블록의 끝에서 Resource를 만들고 블록 외부에서 리소스를 해제하는 많은 코루틴을 만들어 보겠습니다. 타임아웃이 이미 withTimeout 블록이 완료된 상태에서 발생하는 경우 리소스 누수가 발생할 가능성이 높아지도록 작은 지연을 추가합니다.
var acquired = 0
class Resource {
init { acquired++ } // 리소스 획득
fun close() { acquired-- } // 리소스 해제
}
fun main() {
runBlocking {
repeat(10_000) { // 10,000개의 코루틴 실행
launch {
val resource = withTimeout(60) { // 60 ms의 타임아웃
delay(50) // 50 ms 대기
Resource() // 리소스 획득 및 withTimeout 블록에서 반환
}
resource.close() // 리소스 해제
}
}
}
// runBlocking 외부에서는 모든 코루틴이 완료됨
println(acquired) // 아직 획득 중인 리소스의 개수 출력
}
위의 코드를 실행하면 항상 0이 아닌 값을 출력할 수 있으므로 컴퓨터의 시간대에 따라 다를 수 있습니다. 이 예제에서 타임아웃을 실제로 0이 아닌 값을 볼 수 있도록 조정해야 할 수도 있습니다.
여기서 10,000개의 코루틴에서 acquired 카운터를 증가하고 감소시키는 것은 완전히 스레드 안전하며, 이는 항상 runBlocking에서 사용하는 동일한 스레드에서 발생하기 때문입니다. coroutine context에 관한 장에서 자세히 설명됩니다.
이 문제를 해결하려면 리소스를 withTimeout 블록에서 반환하는 대신 변수에 저장하면 됩니다.
runBlocking {
repeat(10_000) { // 10,000개의 코루틴 실행
launch {
var resource: Resource? = null // 아직 획득되지 않음
try {
withTimeout(60) { // 60 ms 타임아웃
delay(50) // 50 ms 대기
resource = Resource() // 획득한 리소스를 변수에 저장
}
// 여기서 리소스를 사용할 수 있음
} finally {
resource?.close() // 획득한 경우 리소스 해제
}
}
}
}
// runBlocking 외부에서는 모든 코루틴이 완료됨
println(acquired) // 아직 획득 중인 리소스의 개수 출력
이 예제에서는 항상 0을 출력합니다. 리소스가 누출되지 않습니다.
원문
댓글