deep-dive into Coroutines #Continuation — suspend and resume #1

choi jeong heon
16 min readOct 30, 2022

--

Japan Photo by me

목차로 가기

지난 포스팅 에 이어서 suspend function 과 Continuation 에 대해 좀 더 자세히 살펴보겠습니다.

이 suspend 함수에 대해 잠깐 살펴봤었는데요, 이번에는 실제로 디컴파일된 코드와 Continuation 과 Continuation 구현체들의 소스코드를 살펴보겠습니다.

postItem suspend function 을 디컴파일한 코드인데요, 복잡해 보이지만 그렇지 않습니다. 🤗 하나하나 살펴볼게요.

State Machine 초기화

state machine (Continuation) 이 초기화 되는 부분부터 보겠습니다.
label37 부분의 if 문이 보이는데요, 이 부분을 해석할 수 없어 굉장히 고민하던 중 코루틴 리드 개발자분의 영상 을 보고 이해할 수 있었습니다.(10분 40초 쯤에 나옵니다 😃)

suspend 함수는 다른 suspend 함수에서 호출하거나 CoroutineScope block 에서 호출해야한다는 사실을 알고 계실 텐데요, 그 이유는 Continuation 을 넘겨받아야 하기 때문입니다. 다른 suspend 함수에서 호출될 때는 해당 suspend 함수에서 생성된 state machine 을 넘겨받게 됩니다.
그리고 아래와 같이 CoroutineScope block 에서 호출 될 때는 코루틴 빌더 (launch 같은) 가 호출되면서 Continuation 을 하나 생성해서 넘겨주게 됩니다. (아래의 코드를 디컴파일 해보면 확인하실 수 있습니다. 😃 )

fun main() {
CoroutineScope(Dispatchers.Default).launch {
postItem(Item("1", 100))
}
}
suspend fun postItem(item: Item) {
val token = getToken()
val post = createPost(token, item)
processPost(post)
}

다시 postItem 함수로 돌아와서, 인자로 넘겨받은 var1 은 외부에서 생성된 Continuation 이고 $continuation 은 이 suspend 함수에서 생성될 Continuation 입니다. 그래서 6~11번 코드 라인은 var1 이 postItem 에서 생성한 Continuation 이 맞는지 확인하는 부분입니다. var1 이 외부에서 생성된 Continuation 이라면 14 번 라인을 실행하여 Continuation 객체를 생성하게 됩니다.

그런데 왜 이런 if 문이 필요할까요? 지난 포스팅에서 suspend 되면 posItem 함수가 반환되고 resume 될 때 postItem 함수가 다시 불린다고 설명했었는데요, postItem 함수가 다시 불릴 때는 var1 이 외부에서 생성한 Continuation 이 아니기 때문에 $continuation 이 다시 초기화 되지 않도록 이를 체크하는 것입니다. ( 왜 다시 초기화가 되지 않아야 하나면 Continuation 이 label, result 와 같은 상태 값을 가지기 때문입니다. 만약 label이 다시 0 으로 초기화 되면 case 0 만 계속 실행이 되겠죠 )

Suspend / Resume

21번 라인을 보시면 invokeSuspend 함수가 보이는데요, resume 될때 바로 이 함수가 호출됩니다. 따라서 24번 라인에서 postItem 호출시 Continuation 으로 this 를 넘기게 되는데 이는 외부에서 생성한 Continuation 이 아님을 확인할 수 있습니다.

internal abstract class ContinuationImpl(
completion: Continuation<Any?>?,
private val _context: CoroutineContext?
) : BaseContinuationImpl(completion) {
constructor(completion: Continuation<Any?>?) : this(completion, completion?.context)

//... 생략
}

실제 ContinuationImpl 의 소스코드 입니다. ( org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1 )

ContinuationImpl 이 BaseContinuationImpl 을 상속하고 있는데요, 바로 여기에 resume 함수와 invokeSuspend 함수가 있습니다.

BaseContinuationImpl

resumeWith 에서 invokeSuspend 함수를 호출해주고 있는 것을 확인할 수 있습니다. ( 그럼 이 resumeWith 은 어디서 호출되는 건지 궁금해하실 수 있을 것 같은데요, Coroutine Dispatcher 등에 대한 지식이 더 있어야 하므로 여기서 다루진 않겠습니다. )

여기서 눈여겨 볼 것은 invokeSuspend 호출 후에 결과 값인 outcome 을 COROUTINE_SUSPENDED 와 비교하는 부분입니다. postItem 의 디컴파일 소스코드 33번 라인을 보면 아래와 같이 되어있는데요,

getCOROUTINE_SUSPENDED() 를 호출하면 CoroutineSingletons.COROUTINE_SUSPENDED 를 반환하게 되어있습니다.

@kotlin.SinceKotlin @kotlin.PublishedApi 
internal final enum class
CoroutineSingletons private constructor() : kotlin.Enum<kotlin.coroutines.intrinsics.CoroutineSingletons> {
COROUTINE_SUSPENDED,

UNDECIDED,

RESUMED;
}

단순한 Enum 인데요, SUSPEND 된 상태를 나타내는 Enum 입니다. 즉 suspend 함수가 suspended 되면 이 COROUTINE_SUSPENDED enum 을 반환하게 됩니다. 그래서 40 번 라인을 보면 suspend 되었을 경우 이 enum 을 리턴하는 것을 확인할 수 있습니다.

그렇게 되면 BaseContinuationImpl 에서 resumWith 도 return 이 되게 됩니다.

return when coroutine suspended

이후 다시 resume 되어 postItem 이 다시 호출되면 그때는 label 을 1로 설정했으므로 case 1 블록이 실행되게 됩니다.

그러면 suspend 가 되지 않는 경우, 즉 COROUTINE_SUSPENDED 가 반환되지 않는 경우에는 어떻게 동작할지 살펴보겠습니다.
getToken 이 원래 기대했던 token 값을 반환하고 var10000 에 token 값이 저장됩니다. 그다음 43번 라인, 즉 break 문을 만나 switch 를 빠져나오고 createPost 를 호출하는 과정을 거치게 됩니다.

여기서도 마찬가지로 label 을 설정하고 createPost 를 호출해주고, suspend 를 체크하게 됩니다.

Variable Spilling

여기까지 설명을 하면서 몇가지 넘어간 부분들이 있습니다, 그 중 하나는 $continuation.L$0 과 같은 컴파일러가 생성한 Continuation 구현체의 멤버변수의 정체인데요.

suspend function 이 suspend 될 때 상태들(지역변수 같은) 을 어딘가에 store 하고 resume 될 때 restore 해야합니다. 함수를 호출할 때 스택에 상태 값을 저장하는 거나 Context Switching 시 상태 값들을 저장하는 것들을 생각해보시면 이해하기 더 쉬울 것 같습니다.

postItem 예제 코드에서 item 을 case 0 에서 store 하고 case 1 에서 restore 하는 것을 확인할 수 있습니다.

참고로 L$0 이라는 이름은 naming 규칙이 있다고 합니다.

  • L : reference type
  • J : long
  • D : double
  • F : float
  • I : boolean, byte, char, short, int

뒤에 붙는 숫자는 Continuation 에 해당 타입이 여러개 일 수 있으니 이를 구분하기 위한 숫자라고 합니다.

Continuation Chain

코루틴에는 two world model 이라는 것이 있는데요, 앞에서 suspendable function 이 다른 suspendable function 에서만 호출할 수 있고 ordinary function 에서는 호출할 수 없다고 설명했습니다. 코루틴의 two world 는 suspendable 과 ordinary 를 의미합니다.

보통 프로그램의 시작점인 main 함수는 ordinary function 이고 suspendable function 을 호출하기 위해서는 이 두 world 를 이어주는 무엇인가가 필요합니다. 그 역할을 하는 것이 바로 launch, async 같은 Coroutine Builder 입니다. 빌더는 모든 코루틴의 Root Continuation을 만들어 두 world 를 이어줍니다.

좀 더 자세하게 살펴보겠습니다.

fun main() {
CoroutineScope(Dispatchers.Default).launch {
postItem(Item("1", 100))
}
}
suspend fun postItem(item: Item) {
val token = getToken()
val post = createPost(token, item)
processPost(post)
}

ordinary function 인 main 에서는 suspend function 인 postItem 을 호출할 수 없기 때문에 launch 빌더로 두 world 를 이어주었습니다. launch 내부 구현을 잠깐 살펴볼까요?

launch builder

먼저 인자로 넘어오는 block 은 suspend lambda ‘객체' 입니다. 컴파일러는 launch 를 호출할 때 suspend lambda 객체를 하나 생성해서 launch 함수에 전달하게 되는데요,

ContinuationImpl 을 상속한 abstract class 인 SuspendLambda 의 구현체를 생성하게 됩니다. 이때 completion 으로 넘어오는 Continuation 은 바로 부모 Continuation 입니다.
completion 으로 naming 을 정한 이유는 자식이 “완료(competion)" 되고 부모 코루틴이 호출되는데, “parent coroutine’s continuation” 이라는 이름은 너무 길기 때문에 완료를 의미하는 completion 으로 이름을 정했다고 하네요 😆 (개인적으로 저는 parentContinuation 이 더 좋은 것 같습니다)

launch 함수가 호출될 때 생성되는 SuspendLambda 는 parent Continuation 이 없기 때문에 completion 에 null 이 넘어오게 됩니다. 그러면 이때 생성되는 Continuation 이 Root 일까요? 그것은 아닙니다.
launch 내부 구현을 잘 보면 StandaloneCoroutine 객체를 생성하고 있는데요, 이 녀석은 BaseContinuationImpl 이 아닌 AbstractCoroutine 을 상속하고있습니다.

그리고 coroutine.start() 함수가 호출 되고 이후 몇개의 함수 호출 흐름을 따라가다보면

이 함수가 호출 되는데, 여기서 create(receiver, probeCompletion) 이 호출되고 바로 이 때 ContinuationImpl 이 또 하나 생성됩니다. 중요한 점은 이 Continuation 은 postItem suspend function 에서 생성하는 Continuation 이 아니라 launch 빌더 block 에 해당하는 Continuation 이라는 점입니다. 아직 postItem 이 호출되지 않았으므로 postItem 에 해당하는 Continuation 은 생성되지 않았습니다.
아무튼, 이때 생성되는 ContinuationImpl의 completion 에 바로 StandaloneCoroutine 이 넘어오게 됩니다 😄

이때 생성되는 Continuation 은 이 후 Interceptor 에 의해 DispatchedContinuation 으로 변환 되고 Dispatcher 와 스케줄러에 의해 이 Continuation 이 resume 되게 됩니다. (이 부분에 대한 자세한 내용은 Dispatcher 포스팅에서 다룰 것입니다)

해당 Continuation 이 resume 되면 우리가 위에서 살펴보았던 BaseContinuationImpl 의 resumeWith 함수가 호출되게 됩니다.

resumeWith 이 호출되는 순간에 breakPoint 를 걸고 디버깅으로 잘 쫓아가시면 위와 같은 장면?을 확인할 수 있습니다 :)

completion 에 StandaloneCoroutine 이 넘어오고 있는데요, launch 빌더에서 생성했던 Continuation 이고 Root Continuation 의 역할을 하게 됩니다.

Root Continuation 의 역할이란?

BaseContinuationImpl 에 다음과 같은 if 문이 있습니다. completion 이 BaseContinuationImpl 인지 체크하고 BaseContinuationImpl 이 아니면 top-level completion reached 로 인식합니다. 즉, completion 이 Root Continuation 이므로 root 를 resume 하고 return 하면서 무한 루프를 빠져나오게 됩니다.

completion 이 BaseContinuationImpl 인 case 를 살펴볼까요?
postItem 에서 호출하는 getToken 함수가 suspend function 이라고 가정해봅시다.
postItem 에서 getToken 을 호출하면 getToken 에서도 ContinuationImpl 이 생성되게 됩니다. 이때 completion 으로 postItem 의 Continuation 이 넘어오게 됩니다.지금까지의 Continuation 전달 과정을 도식화 하면 아래와 같습니다.

getToken 의 resumeWith 이 호출되면 마찬가지로 invokeSuspend 가 호출됩니다.

이때 val completion = completion!! 이 혼란스러울 수 있는데요, completion!! 은 current 의 completion 을 의미합니다. (this 의 completion이 아님)

이후 return 값과 함께 invokeSuspend 가 반환되면, outcome 에 Result<Return Type> 형태로 저장됩니다.

그리고 40 번 라인의 if 문을 만나게 되는데요, getToken 의 completion 은 postItem 의 Continuation 이므로 BaseContinuationImpl 입니다.
따라서 current = postItem의 Continuation 으로 설정되고 while 문을 다시 돕니다. 그렇게 되면 postItem 의 invokeSuspend 가 다시 호출되는데, 이때는 getToken 의 결과값이 담겨있는 param 과 함께 호출되게 됩니다.

Exception Propagation

자, 이제 마지막으로 살펴볼 것은 예외가 발생했을 때의 case 입니다.
BaseContinuationImpl 에서 invokeSuspend 를 try catch 로 감싸고 있는데요,

postItem 이 decompile 된 코드를 다시 살펴봅시다.

exception 전파를 위해 Result.throwOnFailure 가 각 case 마다 호출 됩니다. result 에 exception 이 있으면 바로 throw 해버립니다. 그렇게 되면 invokeSuspend 를 감싸는 try catch 에 걸리고 Result 에 exception 이 래핑되어 param 에 저장됩니다. 그 후 부모 Continuation (completion) 이 이 param 과 함께 invokeSuspend 됩니다. 그러면 Result.throwOnFailure($result) 에 의해 exception 이 다시 throw 되고 다시 try catch 에 걸리게 됩니다. 이런식으로 마지막 루트 Continuation 까지 전달되게 됩니다.

마무리 하며

지금까지 코루틴에서 CPS 가 어떻게 구현되었는지 자세히 살펴보았습니다. 우리는 간단하게 코드 몇 라인을 작성했을 뿐인데 내부적으로는 굉장히 많은 일 들이 일어나고 있었습니다. Dispatcher, Job, Context 등의 내용은 생략 했으므로 아직 코루틴의 시작점에 불과하다는 게 놀라울 따름입니다 🤭
다음 포스팅에서는 suspend and resume 두 번째 이야기로, 이 글에 대한 내용을 살펴보도록 하겠습니다.

긴 글 읽어주셔서 감사합니다🙇

참고 자료

--

--