Coroutine(Cancellation) - Chapter3

HEETAE HEO·2022년 7월 19일
0
post-thumbnail

이번 시간에는 Cancellation, Timeouts, and Exception Handling에 대해서 작성하는 시간을 가져보겠습니다.

Why would you cancel a Coroutine?

코루틴에서 cancel기능이 필요한 이유가 몇 가지 있습니다.

  • 더 이상 결과가 필요로 하지 않은 경우
  • 코루틴이 너무 오랫동안 반응을 하지 않을 때

다음과 같은 상황 또는 어떠한 일이 발생하였을 경우 cancel을 통해 Coroutine 작업을 취소할 수 있습니다.

이제 어떻게 cancel을 사용하는 방법에 대해 작성하겠습니다.
우선 코루틴을 취소하려면 협력적이여야 합니다. 이 글을 읽었을 때는 쉽게 이해가 되지않습니다. 그렇기에 코드로 설명해 드리겠습니다.

val job = launch {
	// the code has to be cooperative in order to get cancelled..
}

job.cancel() // if the coroutine is cooperative then cancel it
job.join()  // Waits for the coroutine to finish

job.cancel()를 통해 코루틴 작업을 취소할 수 있습니다. 하지만 코루틴의 상태에 따라 취소가 되지않을 수도 있습니다.

위와 같이 작성하게 된다면 cancel()작업이 된다면 join()기능는 동작하지 않습니다. 코루틴 작업이 취소되었기에 기다릴 이유가 없기 때문입니다. 반대로 cancel()작업이 정상적으로 동작하지 않는다면 join()기능이 동작하여 코루틴의 작업이 끝날 때 까지 기다리게 됩니다.

여기서 중요한 부분입니다. 이렇게 분리된 기능을 나란히 쓰는 것은 좋지않습니다. 그렇기에 다음과 같은 함수를 사용하여 위의 기능을 동작하도록 해줍니다. 바로

job.cancelAndJoin()입니다. 이 함수를 사용하면 cancel, Join을 상황에 맞게 동작하여 주기때문에 함수 두개를 분리해서 작성하는 것이 아닌 하나의 함수를 통해 동작하게 해줍니다.

What makes a coroutine Cooperative?

cooperative 그리고 협력적으로 동작해야한다고 하는데 이것의 정확한 의미가 무엇일까요?

코루틴을 Cooperative하게 만드는 방법으로는 2가지가 있습니다.

1. 취소 여부를 확인하는 suspending 함수를 주기적으로 호출하는 것입니다.

-> kotlinx.coroutines 패키지를 사용하는 것이 cooperative적으로 만들어줍니다.
-> 해당 패키지에는 delay(), yield(), withContext(), withTimeOut() 등이 있습니다.

fun main() = runBlocking {
	println("Main program starts : ${Thread.currentThread().name}") // main
    
    val job : Job = launch {
    	for (i in 0..500) {
        	print("$i.")
        }
    }
    
    job.join() // wait for coroutine to finish
    
    println("\n Main program ends: ${Thread.currentThread().name}) // main

다음과 같은 프로그램을 시작한다면 결과는 다음과 같을 것입니다.

Main program starts : main
0.1.2.3.4.5.6.7.8.9.10.......500
Main program ends : main

위의 코드를 약간 수정해보겠습니다.

fun main() = runBlocking {
	println("Main program starts : ${Thread.currentThread().name}") // main
    
    val job : Job = launch {
    	for (i in 0..500) {
        	print("$i.")
            Thread.sleep(50)
        }
    }
    
    job.join() // wait for coroutine to finish
    
    println("\n Main program ends: ${Thread.currentThread().name}) // main

이렇게 작성한다면 경과는 같겠지만 출력하는 것이 약간의 딜레이가 존재하게 됩니다. 전체를 다
출력하려면 걸리는 시간은 약 10초가 소요될 것입니다.

이러한 상황에서 다음과 같이

 val job : Job = launch {
    	for (i in 0..500) {
        	print("$i.")
            Thread.sleep(50)
        }
    }
    
    delay(200)
    job.cancel()
    job.join() // wait for coroutine to finish

이렇게 작성을 한다면 코루틴은 취소되지않고 계속 숫자를 출력하는 작업을 수행합니다. 이유가 뭘까요? 그 이유는 바로 Thread.sleep()을 사용하였기 때문에 코루틴의 cooperative가 되지않았기 때문입니다. Thread.sleep은 coroutine 패키지 함수가 아니기에 그렇습니다.

이번에는 delay함수를 사용해보겠습니다.

 val job : Job = launch {
    	for (i in 0..500) {
        	print("$i.")
            delay(50)
        }
    }
    
    delay(200)
    job.cancel()
    job.join() // wait for coroutine to finish

이렇게 한다면 결과는 다음과 같이 동작합니다.

Main program starts : main
0.1.2.3.
Main program ends : main

for문이 4번 동작하고 난 뒤 취소가 되어버린 것입니다. 코루틴의 작업을 이렇게 취소하는 것입니다.

이번에는 알려드린 것처럼 job.cancelAndJoin()를 사용해보겠습니다.

 val job : Job = launch {
    	for (i in 0..500) {
        	print("$i.")
            delay(50)
        }
    }
    
    delay(200)
    job.cancelAndJoin()

결과는 다음과 같이 나오게 됩니다.

Main program starts : main
0.1.2.3.
Main program ends : main

다음은 yield()를 사용하는 것을 보여드리겠습니다.

 val job : Job = launch {
    	for (i in 0..500) {
        	print("$i.")
            yield() // or use delay() or any other suspending function
            		// as per your need
        }
    }
    
    delay(10)
    job.cancelAndJoin()

결과는 다음과 같습니다.

Main program starts : main
0.1.
Main program ends : main

yield()의 경우 동작을 하다가 취소조건에 충족하게 된다면 coroutine을 종료하는데 도와주는 것입니다.

2. 코루틴 내에서 cancellation 상태를 확인하는 방법

when the coroutine is active : isActive = true를
when the coroutine is cancelled : isActive = false를 출력하게 됩니다.

 val job : Job = launch(Dispatchers.Default) {
    	for (i in 0..500) {
        	if(!isActive){
            	break
            }
        	print("$i.")
        }
    }
    
    delay(10)
    job.cancelAndJoin()

다음과 같이 작성을 하게된다면 결과는 다음과 같이 출력이 됩니다.

Main program starts : main
0.1.2.3.4.5.6.7.8.9.10.......500
Main program ends : main

cancel이 작동하고 있는데 왜 결과가 이렇게 출력이 됐나요?
그 이유는 코루틴의 실행속도가 매우 빨라서 cancelled 되기전에 출력을 다했기에 그렇습니다.

그렇기에 이렇게 수정해보겠습니다.

 val job : Job = launch(Dispatchers.Default) {
    	for (i in 0..500) {
        	if(!isActive){
            	break
            }
        	print("$i.")
            Thread.sleep(1)
        }
    }
    
    delay(10)
    job.cancelAndJoin()

수행한다면 결과는 출력하다가 끊기는 결과가 나오게 됩니다. 그 이유는 sleep을 통해 동작이 밀리게 되고 그러다 cancel 명령을 받고 !isActive 감지했기에 break가 동작하게 되기 때문입니다.

break대신에 return을 넣어보겠습니다.

 val job : Job = launch(Dispatchers.Default) {
    	for (i in 0..500) {
        	if(!isActive){
            	return@launch // break
            }
        	print("$i.")
            Thread.sleep(1)
        }
    }
    
    delay(10)
    job.cancelAndJoin()

이 방법 또한 출력하다가 끊기는 결과를 출력하게 해줍니다. 이렇게 isActive flag를 확인하는 방법을 통해서도 coroutine cancelled가 가능합니다.

이렇게 코루틴 Cancel하는 방법 2가지에 대해 알아봤습니다.

Handling Exceptions

yield()와 delay() 등과 같은 함수를 사용해서 CancellationException을 던지고 coroutine을 취소합니다.

아까 코드에서 coroutine 패키지만을 사용해 설명해드리겠습니다.

 val job : Job = launch(Dispatchers.Default) {
 		try {
    		for (i in 0..500) {
        		print("$i.")
            	delay(5)
     	   } 
  	  	} catch (ex : CancellationException) {
   				print("\n Exception caught safely")
	    } finally {
        	print("\n Close resources in finally")
        }
	}	
    delay(10)
    job.cancelAndJoin()

이 프로그램을 동작시킨다면 결과는 다음과 같습니다.

Main program starts : main
0.1.2.3.
Exception caught safely
Close resources in finally
Main program ends : main

for문을 수행하다 CancellationException이 발생해 해당되는 문자열을 print하고
finally의 문자열을 print한 것입니다. 이를 통해 cancel이 발생하면 CancellationException이 발생한다는 것을 알 수 있습니다.

delay()가 아닌 yield()를 통해 코루틴을 취소하더라도 똑같은 결과가 나오게 됩니다. 그 이유는 둘다 kotlinx.coroutines 패키지에 속한 함수이기 때문입니다.

다른 코드를 한번 보겠습니다.

 val job : Job = launch(Dispatchers.Default) {
 		try {
    		for (i in 0..500) {
        		print("$i.")
            	delay(5)
     	   } 
  	  	} catch (ex : CancellationException) {
   				print("\n Exception caught safely")
	    } finally {
        	delay(1000) // Generaly we don't use suspending function in finally
        	print("\n Close resources in finally")
        }
	}	
    delay(10)
    job.cancelAndJoin()

finally에 delay()를 넣어보았습니다.(일반적으로는 finally에 suspending 함수를 쓰지 않습니다.)

결과는

Main program starts : main
0.1.2.3.
Exception caught safely
Main program ends : main

finally 부분이 실행되지 않았습니다. 그 이유는 delay()를 사용하면서 다른 상태의 exception을 던졌기 때문입니다. 하지만 이렇게 작성한다면 위의 결과와 같게 동작하게 할 수 있습니다.

 val job : Job = launch(Dispatchers.Default) {
 		try {
    		for (i in 0..500) {
        		print("$i.")
            	delay(5)
     	   } 
  	  	} catch (ex : CancellationException) {
   				print("\n Exception caught safely")
	    } finally {
        	withContext(NonCancellable) {
        	delay(1000) // Generaly we don't use suspending function in finally
        	print("\n Close resources in finally")
        }
    }
}	
    delay(10)
    job.cancelAndJoin()
//결과
Main program starts : main
0.1.2.3.
Exception caught safely
Close resources in finally
Main program ends : main

withContext()는 무엇일까요? 바로 새로운 백그라운드 스레드를 생성하고 다른 코루틴을 launch하게 됩니다. 즉 withContext()는 다른 코루틴 빌더를 생성할 때 사용이 됩니다.

기존의 코루틴과의 cooperative가 맞지않아 수행되지않던 finally부분이 새로운 코루틴의 생성으로 finally부분만의 새로운 코루틴이 launch했고 delay() 및 print 부분이 동작한 것입니다.

try-catch를 통해서 별도의 동작을 수행하도록 하는 방법도 있지만 cencel(!!) !!부분에 메세지를 작성하는 방법 또한 있습니다.

 val job : Job = launch(Dispatchers.Default) {
 		try {
    		for (i in 0..500) {
        		print("$i.")
            	delay(5)
     	   } 
  	  	} catch (ex : CancellationException) {
   				print("\n Exception caught safely: ${ex.message}")
	    } finally {
        	withContext(NonCancellable) {
        	delay(1000) // Generaly we don't use suspending function in finally
        	print("\n Close resources in finally")
        }
    }
}	
    delay(10)
    job.cancel(CancellationException("My own crash message")
    jon.join()

해당 코드를 run하게 되면 다음과 같이 메세지가 출력되게 됩니다.

Main program starts : main
0.1.2.3.
Exception caught safely : My own crash message
Close resources in finally
Main program ends : main

이렇게 Exception을 다루는 법에 대해서 배웠습니다.

Timeouts

  • withTimeout

  • withTimeoutOrNull

이렇게 2가지의 함수는 기본적인 코루틴 빌더들입니다. 이 2개를 어떻게 사용하는지 코드를 통해서 보겠습니다.

fun main() = runBlocking {
	println("Main program starts : ${Thread.currentThread().name}") // main
    
	
    withTimeout(2000){
    
    	for( i in 0..500){
        	print("$i.")
            delay(500)
    }
    
    println("\n Main program ends: ${Thread.currentThread().name}) // main

결과는 다음과 같이 나오게됩니다.

Main program starts : main
0.1.2.3. Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException : 
Timed out waiting for 2000ms....

2000ms 이후에 Exception이 발생한 것입니다.

fun main() = runBlocking {
	println("Main program starts : ${Thread.currentThread().name}") // main
    
	
    withTimeout(2000){
    	try{
    		for( i in 0..500){
        		print("$i.")
            	delay(500)
    	} catch (ex : TimeoutCancellationException) {
        	// code
        } finally {
        	// code
        }
    }
    
    println("\n Main program ends: ${Thread.currentThread().name}) // main

이런식으로 작성해주어야지만이 그 전에 발생한 것과 같은 에러가 발생하지않습니다.

반면

fun main() = runBlocking {
	println("Main program starts : ${Thread.currentThread().name}") // main
    
	
   val result : String? = withTimeoutOrNull(2000){
 		for( i in 0..500){
        	print("$i.")
            delay(500)
    	}
        "I am done"
    }
    print("Reuslt : $result")
    println("\n Main program ends: ${Thread.currentThread().name}) // main

다음과 같이 사용할 수 있습니다. withTimeoutOrNull의 경우 새로운 코루틴을 생성하는 코루틴 빌더이기 때문입니다. 위의 코드를 실행하게 되면

Main program starts : main
0.1.2.3.Result: null
Main program ends : main

이 출력되게 됩니다. 그 이유는 이름에서 보는 것과 같이 nullable type이기 때문입니다. 그렇기에 null object가 저장되었고 다음과 같은 결과가 출력된 것입니다.

profile
Android 개발 잘하고 싶어요!!!

0개의 댓글