지나공 : 지식을 나누는 공간

코루틴(coroutine)에 대하여. 3편. 코루틴을 cancel하자 본문

Tech/Kotlin & Coroutine

코루틴(coroutine)에 대하여. 3편. 코루틴을 cancel하자

해리리_ 2021. 7. 24. 12:55

코루틴의 취소에 대해 알아본다. 코루틴의 취소를 알아야 리소스도 해제할테니 잘 알아두자!

Canceling coroutine execution

fun maini() = runBlocking {
	val job = luanch{
    	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")
}

launch는 코루틴 job을 반환하는데 이 job은 취소하는 기능도 가지고 있다.

1000번을 반복하면서 0.5초 간격으로 숫자를 출력하고, 1.3초 지났을 때 i'm tired of waiting이라면서 코루틴을 cancel해버린다.

그 다음 앱이 끝나 버린다. cancel을 하면 코루틴이 종료된다. join을 통해 끝날 때까지 기다리기로 했는데, 코루틴을 중간에 미리 취소해버린 것이다.

 

Cancellation is cooperative

이번에는 delay 없는 코루틴에 대해서 코루틴을 cancel 해보자. 과연 cancel이 될 것인가?

fun main() = runBlocking{
	val startTime = System.currentTimeMills()
    val job = launch(Dispatchers.Default) {
    	var nextPrintTime = startTime
        var i = 0
        while(i < 5) {
        	if(System.currentTimeMills() >= nextPrintTime) {
            	//delay(1L) 또는 yield()
            	println("job: I'm sleeping $(i++) ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L)
    println("main: I'm tired of waiting!")
    job.cancelAndJoin()
    println("Now I can quit)
}

/*
launch(Dispatchers.Default){...}는 GlobalScope.launch{...}와 동일한 디스패처를 사용함.
Dipsatcher.Default는 코루틴이 GlobalScope에서 실행될 경우에 사용되고 
공통으로 사용되는 백그라운드 스레드 풀을 사용한다.
*/

cancelAndJoin은 그냥 cancel과 join을 순차적으로 호출하는 함수다.

여기서는 코루틴이 끝나지 않고 0 1 2 3 4를 모두 출력한다. 

이유는 delay같은 suspend 함수를 호출한게 아니기 때문이다. delay를 호출하면 이걸 호출한 애가 일시중단될 수 있지만, 그렇지 않았기때문에 중단되지 않고 코루틴이 실행된다.

 

여기에 다시 delay를 넣으면 우리가 의도하는 대로 중간에 0 1 2 까지 출력되고 코루틴이 끝난다.

 

job에 캔슬을 하면 코루틴 내부에서 suspend 되었다가 다시 재개되는데 재개되는 시점이 suspend function이 cancellation exception을 날린다.

exception을 한번 try-catch로 잡아보자.

fun main() = runBlocking{
	val startTime = System.currentTimeMills()
    val job = launch(Dispatchers.Default) {
    	try {
    		var nextPrintTime = startTime
        	var i = 0
        	while(i < 5) {
        		if(System.currentTimeMills() >= nextPrintTime) {
            		//delay(1L) 또는 yield()
                    yield() //delay같은 suspend 함수
            		println("job: I'm sleeping $(i++) ...")
                	nextPrintTime += 500L
            	}
        	}
    	}
    } catch(e: Exception) {
    	kotlin.io.println("Exception [$e]")
    }    
}

코루틴이 종료되기 위해서는 코루틴 스스로가 협조적이어야 한다. 캔슬을 체크해야 한다고.

코루틴이 협조적이지 않으면 job.cancel을 해도 종료되지 않는다.

 

Making computation code cancellable

코루틴이 취소되기 위해 협조적이게 만드는 방법에는 크게 두가지가 있다.

 

1. to periodically invoke a suspending

위처럼 주기적으로 suspend fun을 호출하면서, 이게 호출됐다가 다시 재개될 때 cancel을 체크하는 것.

 

2. expilicitly check the cancellation status(isActive)

명시적으로 형태를 체크해서 상태가 isActive인지를 확인하고 그게 아니라면 이 코루틴을 종료시키는 방법.

 

1번은 위에서 봤으니, 2번을 보자.

fun main() = runBlocking{

	val startTime = System.currentTimeMills()
    val job = launch(Dispatchers.Default) {
    	var nextPrintTime = startTime
        var i = 0
        while(isActive) {
        	if(System.currentTimeMills() >= nextPrintTime) {
            	println("job: I'm sleeping $(i++) ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L)
    println("main: I'm tired of waiting!")
    job.cancelAndJoin()
    println("Now I can quit)
}
//신기하게도 종료가 된다!

kotlin.io.println("isActive $isActive...")를 while문 들어가기 전과 나온 뒤에 적어서 확인해보면, while문 들어가기 전에는 true, 나온 뒤에는 false가 찍힌다.

 

결론적으로 1번보다 2번 방법이 더 유연하다. 1번은 exception을 반환하지만 2번은 그걸 반환하지 않고 그냥 상태 체크를 통해 스스로 종료된다.

 

1번은 일시중단 됐다가 재개되는 시점이 exception을 던지지만 2번은 던지지 않는다.

2번은 try-catch를 통해 exception을 체크하더라도 던져진 exception이 없다.

 

*isActive의 정체는?

코루틴에 isActive라는 프로퍼티가 있고, 이건 코루틴의 잡이 종료되었는지를 체크하는 역할을 한다.

 

Closing resources with finally

코루틴을 종료할 때 어떻게 리소스도 종료할 것인가?

fun main() = runBlocking{

    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("Now I can quit)
}
/*
0
1
2
tired of waiting
running finally
i can quit
*/

위 코드의 구조를 살펴보자.

launch를 통해 코루틴이 시작된다. 일단 이 코루틴은 delay가 있기 때문에 협조적이다. 그래서 delay를 만나면 일시중단이 되고 다시 재개되는 시점에 코루틴이 cancel 되었음을 인지하고 exception을 날린다. exception을 날리고 난 뒤 finally를 실행하면서 완전히 코루틴이 종료될 것이다.

 

Run non-cancellable bloc

매우 rare한 케이스임.

cancel을 실행을 해서 이미 취소된 코루틴 내부에서 suspend fun을 불러서 다시 코루틴을 실행하는 경우도 있다.

fun main() = runBlocking{

    val job = launch {
    	try {
        	repeat(1000) { i ->
            	println("job: I'm sleeping $i ...")
                delay(500L)
            }
        } finally{
        	withContext(NonCancellale){
            	println("job : I'm running finally")
                delay(1000L)
                println("job: And I've just delayed for a 1 sec because I'm non-cancellable")
            }    
        }    
    }
    delay(1300L)
    println("main: I'm tired of waiting!")
    job.cancelAndJoin()
    println("Now I can quit)
}
/*
0
1
2
tired of waiting
running finally
(1초 기다림)
i've just delayed for~
i can quit
*/

1000번을 찍어야 하는데 3번만 찍히고 cancel이 되고 재개했을 때 delay 는 이걸 인지해서 exception을 날린다.

그런데 finally 블록 안에서 다시 코루틴이 시작되고 다시 delay로 새로 만들어진 코루틴도 중단이 되었다가, 마지막에 출력된다.

 

Timeout

이전 예제에서는 코루틴 스스로가 외부에서의 cancel을 확인해서 종료하는 방법을 봤는데, 이 방법 말고도 어떤 실행된 코루틴의 잡을 취소시키는게 아닌 이 시간이 지나면 코루틴이 종료되게 하는 방법이 있다. 

fun main() = runBlocking{
	withTimeout(1300L){
    	repeat(1000){ i ->
        	println("I'm sleeping $i ...")
            delay(500L)
        }
    }
}

근데 이 방법은 1.3초 뒤에 종료는 되지만 cancellationException이 반환된다.

그래서 이걸 withTimeoutOrNull로 변경하면 exception을 주지 않고 null을 반환하게 되어서 위의 문제를 해결할 수 있다.

 

정리

  • Job
    • cancel : 잡을 취소시킬 수 있다. 그러나 그냥 취소가 되진 않고 코루틴이 협조적이어야 하겠지?
  • Cancellation is cooperative
    • way1 : to periodically invoke a suspending
    • way2 : explicitly check the cancellation status(isActive)
  • Timeout
    • withTimeout
    • withTimeoutOrNull
728x90
Comments