본문 바로가기

[Kotlin] 코루틴 #6 - 취소(Cancellation), 예외(Exception), 핸들러(Handler)

취소(Cancellation), 예외(Exception), 핸들러(Handler)

코루틴에서 취소예외 / 일반 예외의 발생방법 / 유형과 처리방법 Handler에 대해서 작성

 

Coroutine 이전 글

  1. 코루틴 #1 - 기본
  2. 코루틴 #2 - CoroutineContext와 CoroutineScope란?
  3. 코루틴 #3 - suspend Function (중단함수)
  4. 코루틴 #4 - CoroutineBuilder와 ScopeBuilder
  5. 코루틴 #5 - Channel (채널)

 

 

취소 (Cancellation)

코루틴은 내부적으로 취소 동작을 위해 CancellationException을 사용

CancellationException 예외는 모든 핸들러들이 무시하므로 handler를 등록해도 아무런 효과가 없습니다.

그래서 코루틴 동작 중 취소 상황 시 처리를 해야할 일이 있다면 try-catch블록을 이용해서 예외처리용으로 사용이 가능

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = launch {
		// child 코루틴 생성
        val child = launch {
            try {
                delay(Long.MAX_VALUE)	// Long자료형 최대값만큼 대기
            } catch (e :CancellationException) {	// CancellationException 예외 발생 처리
                println("Child is cancelled")
            }
        }
        yield()		// Thread순서 양보, 생략 시 child코루틴이 시작도 전에 cancel실행 가능
        println("Cancelling child")
        child.cancelAndJoin()	// child 코루틴 취소/대기
        yield()
        println("Parent is not cancelled")
    }
    job.join()
}

출력 결과

Cancelling child
Child is cancelled
Parent is not cancelled

보통 Internet 통신 / 파일(File)에 사용할 경우 코루틴 종료 후 자원 반납도 해야하기 때문에 

try-catch문으로 CancellationException에만 처리를 할 경우 취소시(catch) 자원반납, 종료시 자원반납 2중코드가 됨

-> 그렇기 때문에 try-finally문에서 자원반납을 한번에 처리한다. cancel() 취소 호출에도 finally는 필수 호출되기 때문

 

취소예외에 핸들러 사용

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = launch {

        val child = launch(handler) {
            try {
                delay(Long.MAX_VALUE)
            } catch (e :CancellationException) {
                println("Child is cancelled")
            }
        }
        yield()
        println("Cancelling child")
        child.cancelAndJoin()
        yield()
        println("Parent is not cancelled")
    }
    job.join()
}

val handler = CoroutineExceptionHandler { _, exception ->
    println("job is handler")
}

위 예시 코드와 동일하지만 launch 코루틴 생성시 handler 요소(Element)를 추가하였다.

CancellationException 예외는 Handler에 호출되지 않기 때문에 소용이 없다. 위 예시와 동일한 결과 발생

 

예외 (Exception)

코루틴은 CancellationException(취소예외) 이외의 예외를 만나면,

그 예외를 부모에게 전달하여 부모를 취소시키게 됩니다 -> 양방향 예외 (부모->자식, 부모<-자식)

최초 발생한 예외는 모든 자식을 종료시키고 부모 코루틴에 의해 처리됩니다.

일반적인 코루틴은 취소예외 이외의 예외는 양방향 전파라는 점을 기억해야합니다.

    * 양방향 전파 : 아래 방향(부모 -> 자식) + 위 방향 (자식 -> 부모) 모두 전파
                            부모 예외로 인한 취소 발생 시 모든 자식 종료
                            자식 예외로 인한 취소 발생 시 부모 / 모든 자식 종료 

즉, 1부모에 4개의 자식 코루틴 중
1개의 자식 코루틴에서 예외 발생할 경우, 나머지 3개의 코루틴을 모두 종료시키고 부모에게 예외처리를 맡긴다

fun main(args: Array<String>) = runBlocking<Unit> {
	// 예외처리 핸들러 (CoroutineExceptionHandler)
    val handler = CoroutineExceptionHandler { _, exception ->
        println("Caught $exception")
    }

    // 부모 코루틴 job, 핸들러 등록
    val job = GlobalScope.launch(handler) {
        launch { // 첫번째 자식 코루틴, Child 1
            try {
                delay(Long.MAX_VALUE)
            } finally {	// 코루틴 종료 시 (실행종료 / 취소)
                withContext(NonCancellable) {
                    println("Children are cancelled, but exception is not handled until all children terminate")
                    delay(100)
                    println("The first child finished its non cancellable block")
                }
            }
        }
        launch { // 두번째 자식 코루틴, Child 2
            delay(10)
            println("Second child throws an exception")
            throw ArithmeticException()	// 임의로 예외 발생 (취소예외 제외)
        }
    }
    job.join()
}

취소 결과

Second child throws an exception
Children are cancelled, but exception is not handled until all children terminate
The first child finished its non cancellable block
Caught java.lang.ArithmeticException

취소 결과를 보면 Child2에서 예외가 발생한 뒤, Child1이 종료되어 finally문이 실행되었다.

그 이후 Child2,1 모두 종료된 다음 parent(부모) 코루틴의 핸들러에게 예외 처리를 맡긴 모습을 볼 수 있다. 

  

그러면 예외가 발생 시 항상 전체가 모두 취소 되어야 할까? 

SupervisorJob (감독자 작업)으로 예외의 양방향 전파 문제점을 해결.

 

SupervisorJob (감독자 작업)

SupervisorJob은 일반적인 Job과 다르게 예외를 단방향전파
        (예외로 인한 코루틴의 취소가 단방향/아래 방향 (부모 -> 자식)으로만 전파된다는 점)

    양방향 전파 : 아래 방향(부모 -> 자식) + 위 방향 (자식 -> 부모) 모두 전파
                            부모 예외로 인한 취소 발생 시 모든 자식 종료
                            자식 예외로 인한 취소 발생 시 부모 / 모든 자식 종료 

    * 단방향 전파 : 아래 방향(부모 -> 자식)만 전파되는 것
                            부모 예외로 인한 취소 발생 시 모든 자식 종료
                            자식 예외로 인한 취소 발생 시 해당 Job만 종료

즉, 자식 코루틴에서 예외 발생 시 그 자식 코루틴만 죽는다는 것을 의미

 

SupervisorJob은 사용 시 코루틴 빌더에 인자로 넘겨줘서 사용

fun main(args: Array<String>) = runBlocking<Unit> {
    val supervisor = SupervisorJob()
    with(CoroutineScope(coroutineContext + supervisor)) {
        // launch the first child -- its exception is ignored for this example (don't do this in practice!)
        val firstChild = launch(CoroutineExceptionHandler { _, _ ->  }) {
            println("First child is failing")
            throw AssertionError("First child is cancelled")
        }
        // launch the second child
        val secondChild = launch {
            firstChild.join()
            // Cancellation of the first child is not propagated to the second child
            println("First child is cancelled: ${firstChild.isCancelled}, but second one is still active")
            try {
                delay(Long.MAX_VALUE)
            } finally {
                // But cancellation of the supervisor is propagated
                println("Second child is cancelled because supervisor is cancelled")
            }
        }
        // wait until the first child fails & completes
        firstChild.join()
        println("Cancelling supervisor")
        supervisor.cancel()
        secondChild.join()
    }
}

출력 결과

First child is failing
First child is cancelled: true, but second one is still active
Cancelling supervisor
Second child is cancelled because supervisor is cancelled

결과를 보면 firstChild에서 예외를 발생시켰는데, secondChild는 영향없이 잘 작동하는걸 볼 수 있다.

 

예외전파 (Exception Propagation)

예외 처리방식에 따라 코루틴 빌더들을 크게 두가지 타입으로 구분

1) 예외를 자동으로 전파 - launch / actor
       : Java의 Thread.uncaughtExceptionHanlder와 유사하게 처리되지 않은 예외로 간주

2) 사용자에게 노출하여 예외처리를 맡김 - async / produce 
       : 마지막으로 예외를  처리하는 예외 처리 핸들러에게 예외 처리를 맡기는 방식
(상위에 떠넘기기) 

 예시를 보면서 설명

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = GlobalScope.launch {
        println("Throwing exception from launch")
        throw IndexOutOfBoundsException() // Will be printed to the console by Thread.defaultUncaughtExceptionHandler
    }

    job.join()
    println("Joined failed job")
    val deferred = GlobalScope.async {
        println("Throwing exception from async")
        throw ArithmeticException() // Nothing is printed, relying on user to call await
    }

    try {
        deferred.await()
        println("Unreached")
    } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
    }
}

출력 결과

// launch 코루틴에서 실행부분
Throwing exception from launch
Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException: Exception while trying to handle coroutine exception
	at kotlinx.coroutines.CoroutineExceptionHandlerKt.handlerException(CoroutineExceptionHandler.kt:38)
	at kotlinx.coroutines.CoroutineExceptionHandlerImplKt.handleCoroutineExceptionImpl(CoroutineExceptionHandlerImpl.kt:34)
	at kotlinx.coroutines.CoroutineExceptionHandlerKt.handleCoroutineException(CoroutineExceptionHandler.kt:33)
	at kotlinx.coroutines.StandaloneCoroutine.handleJobException(Builders.common.kt:184)
	at kotlinx.coroutines.JobSupport.tryFinalizeFinishingState(JobSupport.kt:226)
	at kotlinx.coroutines.JobSupport.tryMakeCompletingSlowPath(JobSupport.kt:849)
	.
	.
	.

// async 코루틴에서 실행부분
Joined failed job
Throwing exception from async
Caught ArithmeticException		// try-catch로 사용자 예외 처리

 

launch 코루틴은 예외발생 시 해당 CoroutineContext의 요소인 Handler가 맡아서 처리를 하는 방식인데

따로 할당을 해주지 않았기 때문에 일반적인 처리되지 않은 예외로 인식되서 Log에 예외가 나열된 결과

 

async 코루틴은 예외발생 시 사용자가 호출한 await() 부분에 예외 처리 구문(try-catch)이  필요 

async 코루틴 생성시 launch와 다르게 handler를 등록해도 소용이 없기 때문에 try-catch로 예외처리를 해야한다

 

코루틴 예외 핸들러 (CoroutineExceptionHandler)

launch/actor 같이 예외 발생 시 Handler을 등록해서 예외처리를 위한 Handler

Thread.uncaughtExceptionHandler를 지정하여 사용하는 것과 유사

CoroutineExceptionHanlder는 사용자에 의해 처리되지 않은 예외에 관해서만 호출되기 때문에

async / produce 빌더에 핸들러를 등록해도 아무런 효과가 없습니다

fun main(args: Array<String>) = runBlocking<Unit> {
	// CoroutineExceptionHandler인스턴스 생성, 사용하지 않는 인자는 _ 처리
    val handler = CoroutineExceptionHandler { _, exception ->
        println("Caught $exception")	// 예외 처리 구문
    }

    // launch 코루틴 생성, 요소(Element)로 handler 등록
    val job = GlobalScope.launch(handler) {
        throw AssertionError()	// 예외 발생
    }

    // async 코루틴 생성
    val deferred = GlobalScope.async(handler) {
        throw ArithmeticException() // 예외 발생(처리되지 않음)
	}

    joinAll(job, deferred)
}
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
// 출력 결과 (launch에서 발생한 예외만 호출되어 처리됨)
Caught java.lang.AssertionError

보시면 launch 와 async 빌더 둘다 handler를 등록하고 예외를 발생시켰지만

예외처리되어 실행된 부분은 launch에서만 실행된걸 볼 수 있습니다.

 

참조