Java/Kotlin

[Coroutine] 코루틴 학습 - 7 (Cancellation)

Icarus8050 2022. 5. 3. 10:12
반응형

Cancellation

  • Job 인터페이스는 cancel() 메서드를 가지고 있다.
  • 코루틴은 첫 번째 일시중단 지점에서 job을 중단한다.
  • job에 자식 코루틴이 포함되어 있다면 자식 코루틴 또한 cancel 된다.
  • job이 cancel되고 나면, 부모 코루틴으로써 새로운 코루틴 실행할 수 없다.
suspend fun main(): Unit = coroutineScope {
    val job = launch {
        repeat(1_000) { i ->
            delay(200)
            println("Printing number $i")
        }
    }

    delay(1100)
    job.cancel()
    job.join()
    println("Cancelled successfully")
}
// Printing number 0
// Printing number 1
// Printing number 2
// Printing number 3
// Printing number 4
// Cancelled successfully
  • cancel 과 exception의 차이점은 cause를 명시하는 방법이다.
  • 코루틴의 cancel에서 cause는 CancellationException의 서브타입만 사용할 수 있다.
  • cancel 후에는 join을 함께 사용하여 cancellation이 완료될 때까지 기다려야 한다. 그렇지 않으면 레이스 컨디션이 발생할 수 있다.

 

suspend fun main() = coroutineScope {
    val job = launch {
        repeat(1_000) { i ->
            delay(100)
            Thread.sleep(100)
            println("Printing number $i")
        }
    }
    delay(1000)
    job.cancel()
    println("Cancelled successfully")
}
// Printing number 0
// Printing number 1
// Printing number 2
// Printing number 3
// Cancelled successfully
// Printing number 4

 위 코드에서는 cancel을 사용하면서 join()을 호출하지 않았는데, "Cancelled successfully" 뒤에 "Printing number 4"가 출력된 것을 확인할 수 있다. job.join()은 cancellation이 완료될 때까지 일시중단 시키기 때문에 이러한 차이가 발생하는 것이다.

 

 join()과 cancel()을 매번 함께 써주기에는 매우 성가시고, 실수할 여지도 있다. 그래서 좀더 편하게 코드를 작성할 수 있도록 cancelAndJoin() 함수가 제공된다. cancelAndJoin() 함수는 kotlinx.coroutines 라이브러리에서 편의를 위해 제공하는 확장 함수이다.

public suspend fun Job.cancelAndJoin() {
    cancel()
    return join()
}

 

Cancellation의 동작

  • job이 cancel 되었을 때, job의 상태는 'cancelling'이 된다.
  • 그리고 첫 번째 일시중단 지점에 CancellatoinException 익셉션이 던져진다.
  • CancellationException은 try-catch로 잡을 수 있지만 필요한 작업을 수행한 후, 다시 throw 하는 것이 좋다.
suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        try {
            repeat(1_000) { i ->
                delay(200)
                println("Printing number $i")
            }
        } catch (e: CancellationException) {
            println(e)
            throw e
        }
    }
    delay(1100)
    job.cancelAndJoin()
    println("Cancelled successfully")
    delay(1000)
}
// Printing number 0
// Printing number 1
// Printing number 2
// Printing number 3
// Printing number 4
// JobCancellationException: Job was cancelled; job=JobImpl{Cancelling}@2c3e373
// Cancelled successfully
  • canceled된 코루틴은 단순히 중지되는 것이 아니라 내부적으로 예외를 사용하여 canceled 된다.
  • finally block을 사용해서 자원을 닫는 메커니즘을 이용할 수도 있다. (데이터베이스 커넥션이나 file 등..)

 

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        try {
            delay(200)
            println("Job is done")
        } finally {
            println("Finally")
            launch {	// 이 코루틴은 무시된다
                println("Will not be printed")
            }
            delay(100)	// 예외가 발생한다
            println("Will not be printed")
        }
    }
    delay(100)
    job.cancelAndJoin()
    println("Cancel done")
}
// Finally
// Cancel done
  • 코루틴은 catch문을 통해서 CancellationException에 대해서 예외처리를 할 수 있지만, 일시중단은 더이상 허용되지 않는다.
  • Job이 Cancelling 상태일 때, 일시중단이나 다른 코루틴이 시작되는 것을 허용하지 않는다.
  • 새로운 코루틴을 시작하려 한다면 해당 코루틴은 무시된다.
  • 일시중단을 시작하려 한다면 CancellationException을 던지고, finally block이 호출된다.
  • 만약 cancel 되더라도 일시중단 함수를 사용해야 한다면, withContext(NonCancellable) 함수를 사용하면 된다.
    • withContext는 코드 block의 컨텍스트를 바꾼다.
    • NanCancellable 객체는 cancel 할 수 없는 Job이다.
    • block 내부에서 job은 'Active'상태이며, 언제든 일시중단 함수를 호출할 수 있다.
suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        try {
            delay(200)
            println("Coroutine finished")
        } finally {
            println("Finally")
            withContext(NonCancellable) {
                delay(1000L)
                println("Cleanup done")
            }
        }
    }
    delay(100)
    job.cancelAndJoin()
    println("Done")
}
// Finally
// Cleanup done
// Done

 

invokeOnCompletion

  • 리소스를 해제하기 위해 종종 사용하는 Job의 다른 메커니즘으로는 invokeOnCompletion() 함수가 있다.
  • invokeOnCompletion() 함수는 핸들러를 지정하여 job이 종료 상태('Completed' 또는 'Cancelled')가 될 때 호출되도록 한다.
suspend fun main(): Unit = coroutineScope {
    val job = launch { delay(1000) }
    job.invokeOnCompletion { exception: Throwable? ->
        println(exception)
        println("Finished")
    }
    delay(400)
    job.cancelAndJoin()
}
// kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelled}@ea99795
// Finished
  • 핸들러의 exception 파라미터는 job이 Exception 발생없이 완료되면 null로 받는다.
  • 코루틴이 취소되면 CancellationException 타입으로 받는다.

 

코루틴이 멈추지 않을 때..

  • cancellation은 일시중단 지점에서 일어나기 때문에 일시중단 지점이 없으면 취소되지 않는다.
  • 아래의 코드에서는 delay 대신에 Thread.sleep()을 호출하고 있는데, 이는 예시를 위해 작성된 코드일 뿐 실제로는 사용해서는 안된다.
suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        repeat(1_000) { i ->
            Thread.sleep(200)
            println("Printing $i")
        }
    }
    delay(1100)
    job.cancelAndJoin()
    println("Cancelled successfully")
    delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// ...
// Printing 999
  • 위 코드는 일시중단 지점이 없기 때문에 코루틴이 취소되지 않는다.
  • 이러한 경우에 yield() 함수를 매 반복마다 사용해주면 취소할 수 있다.
    • yield()는 일시중단하고 코루틴을 즉시 재개시킨다.
    • yield()는 cancellation(또는 dispatcher를 이용한 쓰레드 변경)을 포함한 일시중단 중에 필요한 공간을 제공한다.
suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        repeat(1_000) { i ->
            Thread.sleep(200)
            yield()
            println("Printing $i")
        }
    }
    delay(1100)
    job.cancelAndJoin()
    println("Cancelled successfully")
    delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully

 

  • 또 다른 방법으로는 job의 상태를 추적하는 방법이 있다.
  • coroutine builder는 해당 builder의 scope를 참조하고 있다. CoroutineScope는 context를 가지고 있고, coroutineContext 프로퍼티를 이용하여 접근할 수 있다.
  • coroutineContext[Job]을 통해서 job에 접근할 수 있고, 아래와 같이 job의 현재 상태가 무엇인지 체크할 수 있다.
suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        do {
            Thread.sleep(200)
            println("job is active - $isActive")
        } while (isActive)
    }
    delay(1100)
    job.cancelAndJoin()
    println("Cancelled!")
    delay(1000)
}
// job is active - true
// job is active - true
// job is active - true
// job is active - true
// job is active - true
// job is active - false
// Cancelled!
  • do-while문을 사용하는 대신 ensureActive() 함수를 사용할 수도 있다.
  • ensureActive() 함수는 Job이 'Active' 상태가 아니면 CancellationException을 던진다.
suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        repeat(1_000) { i ->
            Thread.sleep(200)
            ensureActive()
            println("Printing number $i")
        }
    }
    delay(1100)
    job.cancelAndJoin()
    println("Cancelled!")
    delay(1000)
}
// Printing number 0
// Printing number 1
// Printing number 2
// Printing number 3
// Printing number 4
// Cancelled!

 

ensureActive()와 yield()의 차이

 위의 예시로만 보면 두 함수가 유사해 보이지만 아래와 같은 차이점이 있다.

  • ensureActive()
    • CoroutineScope(또는 CoroutineContext, Job)에서 호출된다.
    • job이 더이상 'Active' 상태가 아니라면 예외를 던진다.
    • 조금 더 일반적으로 사용되고, 더 가볍다.
  • yield()
    • 일반적인 최상위 일시중단 함수다.
    • scope를 필요로 하지 않으므로 일반적인 일시중단 함수에서 사용될 수 있다.
    • 일시 중단 및 재개를 수행하기 때문에 쓰레드 풀과 함께 디스패처를 사용하면 쓰레드가 변경되는 등 다른 영향이 발생할 수 있습니다.
    • 조금 더 CPU 집약적인 작업이나 쓰레드를 블로킹하는 일시중단 함수에서 자주 사용된다.

 

suspendCancellableCoroutine

  • suspendCancellableCoroutine() 함수는 suspendCoroutine처럼 동작하지만, continuatnion을 추가적인 메서드를 제공하는 CancellableContinuation<T>으로 래핑하고 있다.
  • CancellableContinuation<T>이 제공하는 중요한 메서드 중 하나로는 invokeOnCancellation()이 있다. 이는 코루틴이 취소되었을 때 처리해야할 작업을 지정할 수 있다.
fun main(): Unit = runBlocking {
    val hello = createHelloFoo("I'm not hello")
    println(hello)
}

suspend fun createHelloFoo(name: String): Foo = suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation {
        println("Cancellation invoked!!")
    }

    if (name != "hello") {
        continuation.cancel(Exception("name is not hello"))
    } else {
        continuation.resume(Foo("hello"))
    }
}

data class Foo(
    val name: String,
)

 


참고자료

https://www.amazon.com/Kotlin-Coroutines-Deep-Marcin-Moskala/dp/8396395837

반응형