관리 메뉴

나만을 위한 블로그

[코틀린 코루틴] 9. 취소 본문

책/코틀린 코루틴

[코틀린 코루틴] 9. 취소

참깨빵위에참깨빵 2024. 2. 24. 20:26
728x90
반응형

코루틴의 가장 중요한 기능 중 하나는 취소다. 취소는 정말 중요한 기능이라 중단 함수를 쓰는 몇몇 클래스, 라이브러리는 반드시 지원한다.

단순히 쓰레드를 죽이면 연결을 닫고 해제하는 기회가 없어서 최악의 취소 방식이라고 볼 수 있다. 상태가 여전히 Active인지 확인하는 것도 불편하다. 코루틴이 제시하는 방식은 아주 간단하면서 안전하다.

 

기본적인 취소

 

Job 인터페이스는 취소하게 하는 cancel()을 가졌다. 이걸 호출하면 아래 효과를 볼 수 있다.

 

  • 호출한 코루틴은 첫 중단점(아래 코드에선 delay())에서 Job을 끝냄
  • Job이 자식을 가졌다면 그 자식들도 취소되지만, 부모는 영향받지 않음
  • Job이 취소되면 Job은 새 코루틴의 부모로 쓰일 수 있음. 취소된 Job은 Cancelling 상태가 됐다가 Cancelled로 바뀜

 

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main(): Unit = coroutineScope {
    val job = launch {
        repeat(1_000) { i ->
            delay(200)
            println("i : $i")
        }
    }

    delay(1100)
    job.cancel()
    job.join()
    println("취소 성공!")
}

// i : 0
// i : 1
// i : 2
// i : 3
// i : 4
// 취소 성공!

 

코루틴을 취소하기 위해 쓰는 에러는 CancellationException이어야 한다. 그래서 인자로 쓰인 예외는 반드시 CancellationException의 서브타입이어야 한다(JobCancellationException 등)

job을 통해 join()을 사용하면 코루틴이 취소를 마칠 때까지 중단되어 경쟁 상태가 발생하지 않는다.

 

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main(): Unit = coroutineScope {
    val job = launch {
        repeat(1_000) { i ->
            delay(100)
            Thread.sleep(100)   // 오래 걸리는 연산이라 가정
            println("i : $i")
        }
    }

    delay(1000)
    job.cancel()
    job.join()
    println("취소 성공!")
}

// i : 0
// i : 1
// i : 2
// i : 3
// i : 4
// 취소 성공!

 

코루틴은 cancel, join을 같이 호출할 수 있게 cancelAndJoin()도 지원한다.

Job() 팩토리 함수로 생성된 Job은 같은 방법으로 취소될 수 있다. 이 방법은 Job에 딸린 여러 코루틴을 한 번에 취소할 때 자주 쓰인다.

 

import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        repeat(1_000) { i ->
            delay(200)
            println("i : $i")
        }
    }

    delay(1100)
    job.cancelAndJoin()
    println("취소 성공")
}

// i : 0
// i : 1
// i : 2
// i : 3
// i : 4
// 취소 성공!

 

취소는 어떻게 작동하는가?

 

Job이 취소되면 Cancelling 상태가 된다. 상태가 바뀐 뒤 1번째 중단점에서 CancellationException 예외를 던진다. 이것은 try-catch로 잡을 수 있지만 다시 던지는 게 좋다.

 

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        try {
            repeat(1_000) { i ->
                delay(200)
                println("i : $i")
            }
        } catch (e: CancellationException) {
            println(e)
            throw e
        }
    }

    delay(1100)
    job.cancelAndJoin()
    println("취소 성공!")
    delay(1000)
}

// i : 0
// i : 1
// i : 2
// i : 3
// i : 4
// kotlinx.coroutines.JobCancellationException: Job was cancelled; job=JobImpl{Cancelling}@2b41297a
// 취소 성공!

 

취소된 코루틴이 멈추기만 하는 게 아니라 내부적으로 예외를 써서 취소되는 걸 명심하라. 따라서 finally 안에서 모든 걸 정리할 수 있다. 대부분의 자원 정리 과정은 finally 블록에서 실행되므로 코루틴에서도 이 블록을 마음껏 쓸 수 있다.

 

취소 중 코루틴을 1번 더 호출

 

코루틴은 모든 자원을 정리할 필요가 있다면 계속 실행될 수 있다. 하지만 정리 중에 중단은 허용되지 않는다.

Job이 Cancelling 상태가 됐다면 중단하거나 다른 코루틴을 시작할 수는 없다. 다른 코루틴을 시작하려 하면 무시하고, 중단하려 하면 CancellationException을 던진다.

 

중단할 수 없는 걸 중단하기

 

취소는 중단점에서 일어나기 때문에 중단점이 없으면 취소 불가능하다. 이 상황을 만들기 위해 delay 대신 Thread.sleep()을 쓸 수 있지만 정말 나쁜 방식이기 때문에 현업에선 절대 쓰지 마라. 여기선 코루틴을 확장해서 사용하고 중단시키지 않는 상황을 만들기 위해 썼다.

아래 코드는 1초 뒤에 실행이 취소돼야 하지만 실제론 더 오래 걸린다.

 

import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        repeat(1_000) { i ->
            Thread.sleep(200)
            println("i : $i")
        }
    }
    delay(1000)
    job.cancelAndJoin()
    println("취소 성공")
    delay(1000)
}

// i : 0
// i : 1
// i : 2
// i : 3
// (1000까지 출력)

 

이 상황에 대처하는 법은 먼저 yield()를 주기적으로 호출하는 것이다. 이 함수는 코루틴을 중단하고 즉시 재실행한다. 중단점이 생겼기 때문에 취소 or 디스패처로 쓰레드를 바꾸는 걸 포함한 중단(또는 재실행) 중에 필요한 모든 작업을 할 수 있다.

 

import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        repeat(1_000) { i ->
            Thread.sleep(200)
            yield()
            println("i : $i")
        }
    }
    delay(1100)
    job.cancelAndJoin()
    println("취소 성공")
    delay(1000)
}

// i : 0
// i : 1
// i : 2
// i : 3
// i : 4
// 취소 성공

 

다른 방법은 Job의 상태를 추적하는 것이다. 코루틴 빌더 안에서 this는 빌더의 스코프를 참조하고 있다. CoroutineScope는 coroutineContext 프로퍼티를 써서 참조할 수 있는 컨텍스트를 갖고 있다.

isActive 프로퍼티로 Job이 Active한지 여부를 알 수 있고, 그렇지 않다면 연산을 중단할 수 있다.

 

import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        do {
            Thread.sleep(200)
            println("출력 중...")
        } while (isActive)
    }
    delay(1100)
    job.cancelAndJoin()
    println("취소 성공")
}

// 출력 중...
// 출력 중...
// 출력 중...
// 취소 성공

 

suspendCancellableCoroutine

 

이 메서드는 라이브러리 실행을 취소하거나 자원 해제 시에 주로 사용한다.

 

suspend fun someTask() = suspendCancellableCoroutine<Unit> { cont ->
    cont.invokeOnCancellation { 
        // 정리 작업
    }
    
    // 나머지 구현
}

 

CancellableContinuation<T>에서도 isActive, isCompleted, isCancelled 프로퍼티를 통해 Job의 상태를 확인할 수 있고 Continuation을 취소할 때 취소된 원인을 추가로 제공할 수 있다.

취소를 적절하게 쓰면 메모리 누수, 자원 낭비를 줄일 수 있으니 얻을 수 있는 이점은 잘 써야 한다.

반응형
Comments