안드로이드 코루틴 취소와 타임아웃, 서스펜딩함수, 코루틴컨텍스트와 디스패처, CHE와 슈퍼바이저잡 정리

SSY·2022년 12월 15일
0

Coroutine

목록 보기
2/7
post-thumbnail

목차
1. 취소와 타임아웃
2. 서스펜딩함수
3. 코루틴컨텍스트와 디스패처
4. CHE와 슈퍼바이저잡

1. 취소와 타임아웃

1. 취소

실행중인 코루틴을 취소시키는 방법은 간단하다. launch나 async로부터 반환받는 Job객체에 .cancle을 호출해주면 끝이다. 다음과 같이 말이다.

fun cancellingCoroutineExecution() = runBlocking {
    val job = launch {
        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.")
}

그리고 결과는 다음과 같이 나온다. 그리고 문제도 딱히 없다.

Log
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.
내용을 입력하세요.

하지만 이렇게 정말 쉬워보이는 코루틴 취소 작업에 문제가 생기는 경우가 있다. 다음의 코드를 한번 봐보자.

다음 코드 읽을 때 참고!
반복하는 launch블록 내부를 delay와 repeat를 사용하지 않고 똑같은 기능을 구현했다.

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

그리고 결과는 다음과 같이 나오는걸 확인할 수 있다.

Log
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm sleeping 3 ...
job: I'm sleeping 4 ...
job: I'm sleeping 5 ...
job: I'm sleeping 6 ...
job: I'm sleeping 7 ...
job: I'm sleeping 8 ...
job: I'm sleeping 9 ...
main: Now I can quit.

여기서 주목해야할 점은 바로, cancel을 호출해주었는데도 불구하고, launch블록 내부가 중단되지 않고 끝까지 실행되었다는 점이다.

뿐만 아니라 이러한 점은 단순히 코드 겉면만 보고는 무엇이 문제인지 진단하는 것은 불가능하다는 것이다. 겉보기엔 전혀 이상이 없다.

왜 이런 문제가 발생하는 걸까?

코틀린 공식 문서에 보면 다음과 같이 답을 내려주고 있다.

코피셜
However, if a coroutine is working in a computation and does not check for cancellation, then it cannot be cancelled

즉, 코루틴 cancel을 온전하게 진행하기 위해서는 코루틴 job의 상태를 판단할 수 있는 'isActive'를 통해서 검사를 진행해주거나 코루틴에서 제공해주는 suspend확장함수를 사용해야 한다는 것이다.(확장함수를 사용하면 그 내부에서 isActive'를 검사하기에 온전히 종료시킬 수 있는 것이다.) 너무 중요하여 다시 한번 더 반복하려 한다.

Coroutine 안전한 종료를 위한 필수 지식
1. isActive를 사용하여 검사를 진행하거나
2. suspend확장 함수를 사용해야 한다.

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

위 코드를 보면 알겠지만, 'isActive'를 사용하여 조건을 체크해주고 있다.

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

또는 suspend확장 함수인 yield를 사용했다. 이러더니 종료가 잘 된다

Log
// reulst
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

2. 타임아웃

내 생각
정확히 13 초 후에 코루틴을 종료시키고 싶다....!

라는 생각이 있을 때 다음 함수를 사용해봄직 하다.

  • withTimeout

  • withTimeoutOrNull

2.1. withTimeout

아래 코드를 보면 withTimeout인자값에 1300L을 넣었다. 이는 정확히 1.3초 이후에 해당 코루틴을 종료해달라는 뜻이다. 그리고 try - catch문으로 감싼 이유는, 해당 코루틴이 종료되면 'TimeoutCancellationException'을 리턴하기 때문이다.

runBlocking {
    try {
        withTimeout(1300L) {
            repeat(1000) { i ->
                Log.i("studyTest", "I'm sleeping $i ....")
                delay(500L)
            }
        }
    } catch (e: TimeoutCancellationException) { }
}

2.2. withTimeoutOrNull

withTimeout함수와 동작은 똑같다. 다만 다른점이 있다면, 해당 함수는 코루틴 작업이 취소된다 하더라도 Exception을 리턴하지 않는다는 것이다. 그리고 OrNull이름에 알맞게 null을 반환한다.

(그래서 try_catch문으로 감싸주지 않았다)

runBlocking {
    val result = withTimeout(1300L) {
        repeat(1000) { i ->
            Log.i("studyTest", "I'm sleeping $i ....")
            delay(500L)
        }
    }
}

2. 서스펜딩 함수

suspend함수는 ScopeBuilder 내에서 또는 suspend함수 내에서 사용되는 하나의 비동기 작업 단위라고 볼 수 있다. 다음과 같이 말이다.

2.1. 코루틴 빌더 내에서 suspend함수를 사용하는 경우)

CoroutineScope(Dispatchers.IO).launch {
    val userInfo = getUserInfo()
}

2.2. suspend함수 내에서 suspend함수를 사용하는 경우)

suspend fun suspendProcess_A(): String {
    return "A_Result"
}
suspend fun suspendProcess_B(value: String): String {
    return "B_Result"
}
suspend fun suspendProcess_C(value: String): String {
    return "C_Result"
}
suspend fun getUserInfo() {
    val result_a = suspendProcess_A()
    val result_b = suspendProcess_B(
        value = result_a
    )
    suspendProcess_C(
        value = result_b
    )
}

그리고 이러한 작업들은 '하나의 비동기 작업 단위'가 되어 순차적으로 처리될 수 있게 된다. 그 처리 방법은 일전에 정의해두었던 CPS방식으로 빌드가 된다.

(CPS가 뭔지 모르면 윗 게시글 클릭!)

3. CoroutineContext와 Dispatcher

3.1. CoroutineContext

CoroutineContext란, 코루틴이 어느 스레드에서 어떻게 실행될지를 알려주는 요소의 집합이다. 그리고 그러한 집합에는 ExceptionHandler가 들어갈 수도 있고, SupervisorJob과 같은 Job이 추가적으로 들어갈 수 있으며, Dispatcher들이 들어갈 수 있는 것이다. 즉, 다음과 같이 도식화할 수 있을 것이다.

그리고 위 그림에서 luaunch블럭 안에 여러 요소들이 들어있다. 디스패쳐, 코루틴 네임, 코루틴 익셉션 핸들러 등.. 이러한 요소들을 바로 ‘Element’들이라고 한다. 그럼 코루틴 컨텍스트를 좀 더 싶도깊게 이해하기 위해 코드를 직접 확인해보자.

public operator fun <E : Element> get(key: Key<E>): E?

public fun <R> fold(initial: R, operation: (R, Element) -> R): R

public operator fun plus(context: CoroutineContext): CoroutineContext = ...impl...

public fun minusKey(key: Key<*>): CoroutineContext

a. get()

이 함수는 오퍼레이터 함수로 이미 설정되어 있는 코루틴 컨텍스트를 반환하는 역할을 한다. 다음의 코드를 통해 쉽게 이해할거라 생각한다.

Activity: CoroutineScope {
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.IO +
                Job() +
                CoroutineName("hello_AOS_Study") +
                CoroutineExceptionHandler { _, exception ->
            Log.i("ExceptionLog", "코루틴 익셉션 발생!")
        }
}

위는 설명을 쉽게 하기 위해 만들어본 예제이다. CoroutineScope를 상속받고, 오퍼레이터 함수 get을 사용해서 위와 같이 코루틴 컨텍스트를 지정해줄 수 있다. 그리고 이러한 방식의 코루틴 콘텍스트는 보통 아래와 같은 방식으로 사용되기도 한다.

CoroutineScope(Dispatchers.IO).launch(
    Job() +
        CoroutineName("hello_AOS_Study") +
        CoroutineExceptionHandler { _, exception ->
            Log.i("ExceptionLog", "코루틴 익셉션 발생!")
        }
)

즉, 위와 같이 설정을 한 후, 코루틴 작업을 실행시키려 할때, 내부적으로 get오퍼레이터 함수를 호출함으로써 ‘현재 설정되어 있는 콘텍스트’를 가져다 사용하는 것이다.

b. fold()

CoroutineScope(Dispatchers.IO).launch(
    Job() +
        CoroutineName("hello_AOS_Study") +
        CoroutineExceptionHandler { _, exception ->
            Log.i("ExceptionLog", "코루틴 익셉션 발생!")
        }
)

위에선 CoroutineScope에 우선적으로 디스패쳐(콘텍스트에 포함되는 Element)를 설정해 주었다. 그리고 나서, 코루틴의 이름과 익셉션 설정 핸들러를 넣어주었다. 즉, 위처럼 초기 Element를 설정해줄 때 바로, 내부적으로 fold메소드를 호출해준다. 그리고 추가적으로 설정해준 콘텍스트를 합친 콘텍스트를 반환해 주는 것이다.

c. plus()

같은 예제를 사용하면 이해가 더욱 쉬워서 또 써볼까 한다.

CoroutineScope(Dispatchers.IO).launch(
    Job() +
        CoroutineName("hello_AOS_Study") +
        CoroutineExceptionHandler { _, exception ->
            Log.i("ExceptionLog", "코루틴 익셉션 발생!")
        }
)

plus오퍼레이터 함수는 직관적으로 알 수 있듯이, 코루틴 콘텍스트 Element들을 더해주는 함수이다. 위처럼 +연산자를 사용하면 CoroutineContext에선 내부적으로 각각의 Element들을 더해준다. 그리고 더해진 콘텍스트를 반환해준다.

d. minusKey()

이 또한 직관적으로 알 수 있듯이, 코루틴 콘텍스트 Element들을 제거해주는 녀석이다. 예제를 통해 알아보자.

val exceptionHandler = CoroutineExceptionHandler { _, exception ->
            Log.i("ExceptionLog", "코루틴 익셉션 발생!")
        }
        
val context = 
        Dispatchers.IO + 
        Job() +
        CoroutineName("hello_AOS_Study") +
        exceptionHandler

val resultContext = context.minusKey(exceptionHandler.key)

GlobalScope.launch(resultContext) {
    ...
}

즉, 마찬가지로 코루틴 콘텍스트 Element들에서 제거하고 싶은 부분만 제거할 수 있는 함수이다. 이렇게 코루틴콘텍스트에 정의되어 있는 4가지 함수들을 통해서 코루틴 콘텍스트에는 어떤 Element들이 있는지, 그리고 이러한 Element들을 서로 더해줄 수 있고, 뺴줄 수 있다는 것도 알았을 것이다.

3.2. CoroutineDispatcher

그렇다면 이제 Elememnt를 구성하는 요소 중 하나. 디스패쳐에 대해 알아볼까 한다. 디스패처란 무엇일까? 사전적 의미로 찾아보면, 디스패처는 '보내다'라는 뜻을 가지고 있다.

이 즉, 하나의 작업(=스레드)를 특정 스레드 또는 스레드풀로 보낸다는 것을 의미한다. 그러기에 코루틴 디스패처를 사용하여 컨텍스트를 구성하는 방법은 내가 정리해본 바론 크게 두 가지가 있다.

CoroutineDispatcher를 사용해 Context구성하는 방법?
a. 특정 스레드로 제한하기
b. 스레드풀을 만들고 그 곳에 제한하기

a. 특정 스레드로 제한하기

특정 스레드로 제한하기 위한 코루틴 디스패처들은 다음의 것들이 있다.

디스패처 종류
Dispatchers.IO,
Dispatchers.Main,
Dispatchers.Default,
Dispatchers.Unconfined

a.1. Dispatcher.Main

이는 코루틴 작업을 메인스레드에서 작업하겠다는 의미이다. 즉, 안드로이드 관점으로 보자면 UI스레드에서 작업을 하겠다는 의미이다.

CoroutineScope(Dispatchers.Main).launch {
    println("현재 스레드 : [${Thread.currentThread().name}]")
}

또한 동일한 의미로 코루틴 빌더에 콘텍스트를 넘겨주지 않는 경우가 있다.(아래의 경우) 그런 경우엔 해당 코루틴을 호출한쪽의 스레드를 실행시키게 된다. (launch의 경우 runBlocking으로부터 실행되고 있다. 또한 runBlocking의 경우 Main으로부터 실행되고 있다. 즉 메인스레드에서 돌아가게 된다)

runBlocking {
    launch {
        println("현재 스레드 : [${Thread.currentThread().name}]")
    }
}

a.2. Dispatchers.IO

이는 코루틴 작업을 백그라운드에서 작업하겠다는 의미이다. 로컬DB작업, 서버 작업, 파일 입출력 작업을 할때 주로 사용한다. 결과는 예상 가능하니 코드는 첨부하지 않겠다.

a.3. Dispatchers.Default

해당 디스패쳐는 기기가 보유한 CPU의 코어수와 동일하다. 예를 들어, 6코어라면 최대 6개의 스레드만 돌아간다.

repeat(100) {
    CoroutineScope(Dispatchers.Default).launch {
        println("현재 스레드 : [${Thread.currentThread().name}]")
    }
}

Dispatcher.Default이상하면서도 신기방구한 점
이걸 내가 8코어 핸드폰으로 테스트를 했다. 근데.. 그보다 더 많은 스레드 갯수에서 돌아갔다.. (10개?) 실제 측정은 더 많은 스레드가 돌아가는게 보이긴 했지만 그럼에도 불구하고 실제 코딩할 땐 맥시멈 스레드 갯수를 낮게 잡아서 해야하겠다.

a.4. Dispatchers.Unconfined

이는 코루틴 콘텍스트를 한정하지 않는다는 뜻이다. 즉, 한정하지 않으니 콘텍스트가 바뀔 수 있다는 뜻이고, 이는 실행되는 스레드의 위치가 바뀔 수 있다는 뜻이다. (그래서 조심해야한다)

예를 들어, 처음에 Dispatchers.Unconfined를 지정하고 이를 호출한 스레드는 메인 스레드라고 가정해보자. 그리고 그 이후, 코루틴의 첫 번째 중단 지점(suspend)을 만나고 이때, context가 바뀌었을 때, 바뀐 콘텍스트의 스레드로 코루틴이 실행된다는 뜻이다. 코드를 통해 알아보도록 하자.

runBlocking {
    launch(Dispatchers.Unconfined) {
        // not confined -- will work with main thread
        println("Unconfined      : I'm working in thread ${Thread.currentThread().name}")
        delay(500)
        println("Unconfined      : After delay in thread ${Thread.currentThread().name}")
    }
    launch {
        // context of the parent, main runBlocking coroutine
        println("main runBlocking: I'm working in thread ${Thread.currentThread().name}")
        delay(1000)
        println("main runBlocking: After delay in thread ${Thread.currentThread().name}")
    }
}

위 코드를 보면 동시에 두개의 코루틴을 실행시켜주고 있다. 첫 번째의 코루틴은 콘텍스트를 Dispatchers.Unconfined로 지정했다. 그리고 이녀석을 통해 실행된 코루틴은 당연히 메인에서 처음엔 돌고 있다.

하지만, 첫 번째 중단 지점(delay)를 만난 직후, 작동하는 스레드 위치가 바뀐것을 확인할 수 있다, 여기서 delay함수는 DefaultExecutor스레드에서 돌게 되는데, 이녀석을 만나자 마자 바로 바뀌어버린 것이다. 위의 코드와 로그를 찬찬히 읽어보면 이해가 갈거라 생각한다.

a.5. newSingleThreadContext

위처럼 디스패처를 사용해서 스레드를 지정할 수도 있고, context에 newSingleThreadContext를 넣어줌으로써 새로운 스레드를 지정할 수도 있다. 다음과 같이 말이다.

fun main() {
    runBlocking {

        val dispatcher = newSingleThreadContext(name = "ServiceCall")
        val task = GlobalScope.launch(dispatcher) {
            printCurrentThread()
        }
        task.join()
    }
}

a.6. newFixedThreadPoolContext

위의 경우는 우리가 어떤 스레드에서 코루틴을 시작할지를 결정할 수 있었다. 하지만 우리가 작동시킬 스레드풀의 갯수를 지정하고 그 안에서 코루틴을 실행시키는 작업 또한 가능하다. 아래와 같이 말이다. 또한 이는 부하분산을 알아서 해주기에 따로 할 작업은 없다

val dispatcher = newFixedThreadPoolContext(4, "mypool")
repeat(100) {
    GlobalScope.launch(dispatcher) {
        Log.i("스레드테스트", "start in ${Thread.currentThread().name}")
        delay(1000)
        Log.i("스레드테스트", "resume in ${Thread.currentThread().name}")
    }
}

위 사진을 봐서 알겠지만, 최대 스레드풀을 4개로 한정해주었다. 그리고 100번 반복문을 돌렸다. 예상했듯, 스레드풀은 4개안에서만 돌아간다는걸 알 수 있다.

만약 위 스레드풀 갯수를 4개에서 8개로 올리면?
가용스레드풀은 8까지만 로그에 찍힐 것이다.

4. CEH와 슈퍼바이저잡

4.1. CHE

CEH의 뜻은 Coroutine Exception Handling이란 뜻이다. 이름에 맞게 코루틴을 사용하다 오류가 났을 때 에러를 핸들링한다는 뜻이다. 다음과 같은 구조의 코루틴이 있다고 해보자

위와 같은 상황에서 자식 코루틴에 에러가 생기면 어떻게 될까? Child Coroutine1에 에러가 생긴다고 가정하면, 그 에러는 Parent Coroutine에게 전달된다. 그리고 부모는 이 에러를 모든 자식들에게 전파시키게 된다. 그 결과 전체 코루틴은 멈추게 되는 것이다. 아래와 같이 말이다.

suspend fun main() {
    CoroutineScope(Dispatchers.IO).launch {
        val firstChildJob = launch(Dispatchers.IO) {
            throw AssertionError("첫 째 Job이 AssertionError로 인해 취소됩니다.")
        }

        val secondChildJob = launch(Dispatchers.Default) {
            delay(1000)
            println("둘 째 Job이 살아있습니다.")
        }

        firstChildJob.join()
        secondChildJob.join()
    }.join()
}

그러므로, 자식 코루틴에서 에러가 생긴다고 해도, 전체 코루틴을 멈추지 않게 하기 위한 작업이 필요하다. 그 작업을 하기 위해선 SupervisorJob을 알 필요가 있다.

4.2. SupervisorJob

SupervisorJob을 사용하면 해당 자식 코루틴의 에러는 부모로 전파되지 않는다. 그리고 이는 코루틴 빌더(async, launcher, withContext...)에 context를 지정하는 자리에 함께 쓰일 수 있다. 아래와 같이 말이다.

suspend fun main() {
    val supervisor = SupervisorJob()

    CoroutineScope(Dispatchers.IO).launch {
        val firstChildJob = launch(Dispatchers.IO + supervisor) {
            throw AssertionError("첫 째 Job이 AssertionError로 인해 취소됩니다.")
        }

        val secondChildJob = launch(Dispatchers.Default) {
            delay(1000)
            println("둘 째 Job이 살아있습니다.")
        }

        firstChildJob.join()
        secondChildJob.join()
    }.join()
}

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글