Kotlin

Kotlin Coroutine 동작을 이해해보기 - CPS, Continuation 이란?

seokzoo 2023. 10. 24. 02:17
반응형

서론

이번 포스트를 이해하기 위해서는 어렵지만 알아야하는 개념들이 몇가지 있습니다 ㅠㅠ 어려워도 쉽게 풀어서 작성해볼테니 잘따라오시길 바랍니다.

 

코루틴 내부에서는 CPS를 사용합니다. 이는 Continuation Passing Style을 줄인 말로, 아래 예시를 보면 더 이해하기 쉽습니다.

 

간단한 CPS 와 Continuation

fun postItem(item: Item) {
    val token = requestToken()
    val post = createPost(token, item)
    processPost(post)
}

 

위와 같은 코드가 있다고 하고, (1) requestToken(), (2) createPost(), (3) processPost 모두 suspend point라고 가정하고, postItem을 suspend function으로 변경하면 아래와 같습니다. 

 

suspend fun postItem(item: Item) {
    val token = requestToken()
    val post = createPost(token, item)
    processPost(post)
}

 

이때, suspend function인 createPost를 살펴보겠습니다. 이 함수가 JVM에 의해 바이트코드로 컴파일 되면서 파라미터에 자동으로 Continuation이 추가가됩니다.

 

Object createPost(Token token, Item item, Continuation<Post> cont) { … }

 

이처럼 suspend function은 컴파일 타임에 파라미터가 추가됩니다.

이어서, postItem을 보면 코틀린 컴파일러가 내부적으로 switch문을 통해 라벨로 케이스를 나누어 놓습니다. 멈추고 실행할 곳을.

suspend fun postItem(item: Item) {
    switch (label) {
        case 0:
            val token = requestToken()
        case 1:
            val post = createPost(token, item)
        case 2:
            processPost(post)
    }
}

위와 같이 switch 문에 의한 라벨 분리가 완료 된 후에, CPS 로 변하게 됩니다.

fun postItem(item: Item, cont: Continuation) {
    val sm = object : CoroutineImpl { … }
    switch (sm.label) {
        case 0:
            val token = requestToken(sm)
        case 1:
            val post = createPost(token, item, sm)
        case 2:
            processPost(post)
    }
}

 

정리해보자면 coroutine은 suspend point가 존재할 때, switch문을 통해서 suspend point 마다 case로 나누어 함수 중간중간 실행하고 멈출 수 있게 되는 것입니다.(사실은 멈춘다기보단 함수를 나감)

 

우리는 이제 CPS 와 Continuation에 대해서 간단하게 알았습니다. 이제까지는 간단하게 알아보았으니 이제 더 자세하게 알아보도록 하겠습니다.

 

진짜 CPS

CPS, 즉 Continuation Passing Style은 정말 말그대로, Continuation을 Passing, 즉 Continuation을 넘겨주는 방식입니다. 위에서 "CPS와 Continuation을 사용해 switch문을 통해 함수를 나가고, 들어올 때 어디서 시작해야하는지 안다" 정도로만 이해를 했는데요, 그보다 더 자세하게 설명해보도록 하겠습니다.

 

이전 1편에서 Coroutine은 스택 프레임을 만들지 않기 위해 데이터를 공유하는 Heap을 사용한다고 말했습니다. 정확히 어떻게 Heap을 사용하는걸까요? 이는 Continuation과 관련이 있습니다. Coroutine은 데이터를 Continuation 객체에 저장을 합니다. 이전에서도 말했듯 new를 통한 객체 생성은 Heap에 데이터가 저장된다고 말했었죠??

 

위와 같이 짧게 축약한 suspend function이 아닌, 실제 suspend function을 보겠습니다. (사실 이것도 축약된 버전입니다. 마지막엔 실제로 제가 디컴파일한 코드를 보여드리겠습니다)

fun postItem(item: Item, cont: Continuation) {
    
    val sm = cont as? ThisSM ?: object : ThisSM {
        fun resume(…) {
            postItem(null, this)
        }
    }

    switch (sm.label) {
        case 0:
            sm.item = item
            sm.label = 1
            requestToken(sm)
        case 1:
            createPost(token, item, sm)
        …
    }
}

위와 같이 resume이라는 함수와 함께 아래 switch문에는 데이터를 저장하는 sm.item과 다음 라벨을 지정해주는 sm.label이 보입니다.

즉, stack을 사용하지 않고 객체를 생성하여 sm.item처럼 데이터를 sm(continuation 객체)에 저장하고, sm.label처럼 다음 실행할 위치의 정보 또한 같이 저장하여 꺼내 쓰는 것입니다.  (여기서 sm은 state machine의 줄임말로 상태를 저장하는 것을 의미합니다)

 

또한 requestToken(), createPost() 함수 둘다 끝에 sm이라는 continuation 객체를 전달합니다. 위에서도 말했듯 suspend function의 경우 마지막 파라미터에 continuation이 생성된다고 말했습니다.

 

마지막에 전달해준 sm을 통해, resume 함수를 호출합니다. 즉, postItem 안에서 postItem을 한번 더 호출합니다.

 

다만 이전과 다른점이 있다면, 이번에는 label이 1로 증가했다는 점입니다.

즉, 이전(case 0) 과는 다른 case1을 실행합니다.

 

이해가 가시나요?! https://www.youtube.com/watch?v=YrrUCSi72E8&t=110s 이 영상에 등장하는 예제를 살펴보았습니다. 해당 영상에서는 Continuation을 Callback과 같이 생각하라고 합니다. Callback + 기타 실행 정보들(label, 데이터)이라고 생각하면 더 이해하기가 쉬울 듯 합니다.

실제 Decompile 한 ByteCode

아래 코드는 제가 안드로이드 프로젝트에서 사용한 코드를 가져왔습니다.

위와 같이 간단하게 collect 하는 함수를 decompile 해봤습니다.

getBookList 안에 label 또한 변경해주고, invoke() 를 수행해주며, 마지막 인자로 continuation이 추가된 것이 보입니다.

this에 커서를 올리면 나오는 Contination type

이처럼 suspend function의 마지막엔 Continuation이 붙습니다. usecase, impl 모두에게 붙고, 따라가다보면 마지막 datasource 까지 도달하게 됩니다. (안드로이드 개발자라면 무슨 말씀인지 아시죠?)

 

그렇다면 그다음 부터는 어떻게 될까요?

 

정답은 Retrofit에 있습니다. Retrofit에서는 KotlinExtensions.kt라는 파일이 있습니다. 이곳에서 위에서 말했듯 전달받은 continuation에서 응답이 왔을 때 resume을 통해 값을 전달하며 함수를 한번 더 실행시켜줍니다. (아마 제 추측이지만 ContinuationImpl된 함수에서 resumeWith을 통해 invokeSuspend가 재 실행되고, 전달된 sm의 label에 따라 case문을 실행하는 것 같습니다)

KotlinExtensions.kt

onResponse에 보면, body가 null이 아닐 때 continuation.resume(body)를 통해 resume 함수를 실행시켜줍니다. 즉, 우리가 create()로 만든 함수는 await() 함수가 불릴 때 전달된 continuation의 정보를 통해 resume을 진행합니다. 어렵네요 정말~

 

다시 위의 사진을 가져와, 위에서 resume을 통해 continuation의 resume을 해주면, invokeSuspend가 재실행 될텐데, 이땐 label이 이미 1로 증가했기 때문에 case 1이 실행됩니다. 다만 retrofit 통신 후 위에서 실패하여 resumeWithException이 실행되면 case 1에서 var10000에 값을 저장하지 못하고 ResultKt.throwOnFailure() 를 실행합니다. 

 

참고로 위의 함수는 아래와 같이 구성되어 있는데요, Result가 Failure면 그대로 exception을 던집니다.

즉, enqueue의 결과가 성공이면 값을 저장, 실패면 그대로 exception을 던집니다.

 

어렵지만 이해가 가시나요? 참고로 Continuation은 아래와 같이 CoroutineContext와 resumeWith라는 함수만 존재하고,

런타임에 해당 Continuation을 구현하는 Impl 클래스를 통해 위의 코루틴 동작을 수행합니다. 아까 말한 resume은 사실 resumeWith를 실행하기 위한 도구였습니다.

이제 이 enqueue에서 실행해준 resume이 어떤 뜻인지 아시겠죠?

 

정리해보자면, "Retrofit 내부에서 enqueue의 결과로 온 결과 값을 통해 Continuation의 resume(resumeWith)으로 전달하고 생성된 ContinuationImpl에 label정보, 결과값 저장을 한 후, Impl에서 사용하는 resumeWith로 함수를 재실행 시켜, 다시 함수를 실행하지만 이전에 변경된 label에 따라 이전과는 다른 동작(case 0 -> case 1과 같이..)을 한다" 입니다.

 

참고 및 공부한 블로그 : https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/-continuation/

https://www.youtube.com/watch?v=YrrUCSi72E8&t=110s

https://june0122.github.io/2021/06/09/coroutines-under-the-hood/

https://www.youtube.com/watch?v=DOXyH1RtMC0

https://devroach.tistory.com/163

https://devroach.tistory.com/149#%EB%--%A-%EC%--%B-%EA%B-%--%EA%B-%B-%EC%--%--%--%EC%--%-E%EC%--%-C

https://devroach.tistory.com/153

반응형