코루틴의 CPS 구현 살펴보기
코루틴 분석하기 — CPS
코틀린의 코루틴은 내부적으로 CPS 를 이용해 구현됩니다. 이번 글에서는 코루틴이 과연 CPS 를 어떻게 구현하고 있을지를 알아보겠습니다.
이 글은 CPS 에 대한 기초적인 지식이 있다는 가정 하에 작성됐습니다. CPS 가 처음이신 분은 “Continuation-Passing Style” 글을 먼저 읽어주세요.
위 함수의 바이트코드를 자바로 디컴파일 해보면 아래와 같습니다.
가장 먼저 봐야 할 곳은 main 의 invokeSuspend 입니다.
invokeSuspend 부분을 통하여 각각 suspend 지점마다 label 분기점을 만들어 state machine 을 구현하고 있습니다. label 이 0 일땐 최초 실행을 의미하므로 main() 에서 첫 번째로 요청한 작업인 getDelayedValue()
를 호출하여, 결과를 var1000
에 저장하고 있습니다. 또한 getDelayedValue()
를 호출하기 전에 this.label = 1
부분을 통해 label을 다음 단계로 변경하는 걸 확인할 수 있습니다. 이후 invokeSusped
가 다시 호출된다면 label이 1인 상태이므로 var1000
값으로 System.out.print
를 진행하여 main()
함수가 끝나게 됩니다.
나머지 2개의 함수는 차례대로 인자로 받은 Continuation 을 Function2 로 캐스팅하여 반환해주고 있고, Continuation 을 Function2 로 캐스팅하여 invoke 해주고 있습니다. 이 2개의 함수는 추후 알아보도록 하고, getDelayedValue()
는 어떻게 자바로 바뀌었는지 보도록 하겠습니다.
먼저 suspend 키워드가 Continuation 으로 바뀌어 인자로 제공받고 있습니다. 이후 가장 먼저 continuation 변수를 초기화하는 절차가 시작됩니다.
continuation 변수 초기화를 위한 두 가지 분기를 가지고 있습니다. 첫 번째 분기는 기존 continuation 을 복원하기 위함입니다. 곧 자세히 알아보는 걸로 하고 두 번째 분기를 보겠습니다. 두 번째 분기에서는 new ContinuationImpl(var3)
으로 새로운 Continuation 을 만들어 주고 있습니다. 이때 생성자로 들어가는 var3
은 getDelayedValue()
의 인자로 받은 Continuation 이고, 이는 main() 함수의 Continuation 이 됩니다.
새로운 continuation 의 invokeSuspend 구현으로 result
와 label
을 업데이트 해주고 있고, delay 를 0 으로 해서 getDelayedValue()
를 반환하고 있습니다. getDelayedValue()
에서 딜레이에 사용하고 있는 delay()
함수는 딜레이 시간이 0 보다 크지 않다면 바로 return 하는 특징이 있습니다.
즉, 이 부분으로 추측해 봤을 때 새로운 Continuation 의 invokeSuspend 는 getDelayedValue()
에서 딜레이가 끝났을 때 호출될 것으로 보입니다. ContinuationImpl
의 구현도 추후 보도록 하고 이어서 밑에 나오는 코드를 보겠습니다.
가장 먼저 result
를 위에서 초기화한 continuation 에서 가져오고 있고( 이 시점에선 아무런 값이 없으므로 null 이 들어옵니다) label 이 0 일 때는 위 main() 과 동일하게 result
와 label
을 차례대로 변수에 저장 해주고 있습니다. 또한 getDelayedValue()
의 핵심 구현인 delay()
를 호출하고 만약 return 이 CoroutineSingletons.COROUTINE_SUSPEND
(var6
의 값) 이라면 똑같이 CoroutineSingletons.COROUTINE_SUSPEND
값으로 getDelayedValue()
를 return 하고 있습니다. 이후 label 이 1 일 때는 value
를 L$0
값으로 업데이트 해주고 value
를 return 하는 것으로 끝납니다.
label 이 0 일 때 코드를 보면 delay 를 지정하기 전에 label 을 1 로 업데이트하는 작업이 있습니다. 이 label 이 있는 continuation 은 getDelayedValue()
가 실행되면서 가장 첫 번째로 초기화되며, 만약 첫 번째 분기인 기존에 이미 구현된 continuation 을 만났을 때 return 하는 분기가 없이 두 번째 분기가 바로 실행됐다면 매 getDelayedValue()
실행하다 label 이 0 으로 초기화돼 resumeWith
가 성공적으로 진행될 수 없었을 것입니다.
지금까지 본 코드를 토대로 CPS 의 흐름을 예상해 보겠습니다.
- main 의 continuation 에서 getDelayedValue 로 continuation 이동
- getDelayedValue 의 continuation 에서 delay 요청 후 함수 종료(return)
- getDelayedValue 의 delay 가 끝나면 getDelayedValue 의 continuation 에
resumeWith(value)
호출 - getDelayedValue 의 continuation 이 resume 을 받아서 value 값으로 main 의 continuation 에
resumeWith(value)
호출 - main 의 state machine 의 두 번째 label 실행 -> main() 종료
이제 이 흐름을 전개로 두고 아까 건너뛰었던 ContinuationImpl 의 구현을 보겠습니다.
메인 생성자를 보면 Continuation 과 CoroutineContext 를 받고 있고, 서브 생성자를 보면 Continuation 하나만 받고 있습니다. 위 자바 코드에서는 서브 생성자가 사용되고 있었으니 서브 생성자를 보면 인자로 받은 Continuation 에서 context 를 뽑아와 ContinuationImpl 의 메인 생성자를 호출하고 있습니다. ContinuationImpl 메인 생성자의 Continuation 인자명을 보면 completion
으로 돼있습니다. 이를 통해 위 자바 코드에서 ContinuationImpl 의 인자로 main() 의 continuation 을 넘긴 이유가 CPS 흐름 예상의 “getDelayedValue 의 continuation 이 resume 을 받아서 value 값으로 main 의 continuation 에 resumeWith(value)
호출” 을 달성하기 위함임을 유추할 수 있습니다.
이어서 ContinuationImpl 이 상속하고 있는 BaseContinuationImpl 을 보겠습니다.
BaseContinuationImpl 에서 rsumeWith 를 구현하고 있으며 여기서 invokeSuspend 가 사용됐음이 중요합니다. resumeWith 의 구현을 보면 먼저 current
를 this
(Continuation) 로 설정하고, param
을 resumeWith 의 인자로 받은 result
로 설정하고 있습니다. 이어서 무한 반복을 진행하고 with(current)
를 통해 현재 continuation 의 스코프를 열어줍니다. 다음으로 invokeSuspend(param)
을 통해 현재 continuation 의 invokeSuspend 함수를 실행해 줍니다. 이 결과로 CoroutineSingletons.COROUTINE_SUSPEND
이 반환됐다면 현재 함수가 suspending 상태라는것을 의미하니 return 을 통해 더 이상 진행을 중지합니다. 만약 CoroutineSingletons.COROUTINE_SUSPEND
이 아닌 다른 값이 나왔다면 결과에 따라 Result<T>
로 래핑하여 outcome
변수로 설정해주고 있고, BaseContinuationImpl 의 인자로 받은 completion
가 BaseContinuationImpl
타입일 경우 current
로 업데이트, 그리고 outcome
을 param
으로 업데이트하여 루프를 다시 돌리게 됩니다. (이렇듯 코루틴에서 completion
네이밍은 다음에 실행할 continuation 을 의미합니다)
이 마지막 과정이 CPS 의 핵심 구현이 됩니다. 하지만 왜 completion
이 BaseContinuationImpl
일 때만 루프를 다시 돌리는 걸까요? 추후 알아보도록 하고 이 과정을 통해 위에서 예상한 CPS 흐름과 실제 구현이 일치한다는걸 확인할 수 있습니다.
- main 의 continuation 에서 getDelayedValue 로 continuation 이동
- getDelayedValue 의 continuation 에서 delay 요청 후 함수 종료(return)
- getDelayedValue 의 delay 가 끝나면 getDelayedValue 의 continuation 에
resumeWith(value)
호출 - getDelayedValue 의 continuation 이 resume 을 받아서 value 값으로 main 의 continuation 에
resumeWith(value)
호출 - main 의 state machine 의 두 번째 label 실행 -> main() 종료
2~3 번째 과정을 보면 함수를 return 해서 현재 흐름에서 벗어났음에도 불구하고 suspend 가 끝났을 때 해당 함수를 다시 호출하여 작업을 다시 전개하고 있습니다. 즉, 2번째 과정에서 return 으로 인해 함수가 돌아가는 환경인 메인 스레드 작업이 아예 종료됐다면 3번째 작업이 진행될 수 없음을 의미합니다. 이러한 이유로 JVM 환경에서 runBlocking 이 아닌 일반 CoroutineScope 로 코루틴을 진행하게 되면 suspend 에 resume 받지 못하고 suspend 이후 바로 main() 이 꺼지게 됩니다. 이를 해결하기 위해선 내부 suspend 작업이 다 끝날 때까지 현재 스레드를 blocking 하는 runBlocking 을 사용해야 합니다. 반면에 안드로이드 환경에서는 메인 스레드가 애플리케이션이 살아있는 동안 유지되기 때문에 suspend-resume 이 runBlocking 없이 작동하는 것입니다.
여기까지 모두 이해가 되셨다면 한 가지 의문점이 생길 수 있습니다. 과연 최초의 Continuation 은 어디서 생성되며 코루틴은 어떻게 시작되는 걸까요? BaseContinuationImpl
을 보면 어딘가에서 최초의 코루틴에 resumeWith
를 해줘야 할 것으로 보입니다. 이를 알아보기 위해 이 예제에서 코루틴 빌더로 사용한 CoroutineScope#launch
함수를 보겠습니다.
이 함수는 크게 3가지 작업으로 구현돼 있습니다.
- 6번 라인: receiver 에 지정된 CoroutineContext 와 인자로 넘어온 CoroutineContext 를 합칩니다.
- 7~9번 라인: CoroutineStart 전략에 따라 Continuation 을 만듭니다.
- 10번 라인: Continuation 을 시작합니다.
7~9번 라인을 보면 coroutine
이라는 이름으로 주어진 조건에 맞게 {Lazy}StandaloneContinuation
을 생성하고 있습니다. 이 예제에서 CoroutineStart 전략은 기본값인 CoroutineStart.DEFAULT
를 사용하므로 StandaloneCoroutine
을 보겠습니다.
StandaloneCoroutine
이 AbstractContinuation
을 상속하고 있습니다. Lazy 일 때 생성되는 LazyStandaloneCoroutine
도 동일하게 AbstractContinuation
을 상속하여 구현됩니다
AbstractContinuation
을 보면 Continuation
을 상속하고 있음을 볼 수 있습니다. 이렇게 coroutine
변수는 메인 continuation 을 생성하게 됩니다.
아까 launch 함수를 다시 보면 start 함수를 통해 continuation 을 시작하고 있었으므로 start 함수를 보겠습니다. launch 에서 start 를 호출하면서 receiver 인자로 coroutine
변수, 즉 자기 자신인 AbstractCoroutine (this)
를 넘기고 있었고, block 인자로는 launch 의 block 을 그대로 넘기게 됩니다.
다시 AbstractCoroutine#start
구현을 보면 start 인자를 함수처럼 사용하여 구현하고 있어서 혼동이 올 수 있는데 이는 CoroutineStart 에 invoke 가 정의돼 있기 때문입니다.
이 예제에서 사용하는데로 DEFAULT
분기를 보면 block 에 startCoroutineCancellable
을 호출하고 있습니다.
startCoroutineCancellable
은 내부에서 createCoroutineUnintercepted
를 하고 있고 이어서 intercepted 한 다음 마지막으로 resumeCancellableWith
를 하고 있습니다. 마지막 resumeCancellableWith
함수를 먼저 보겠습니다.
resumeCancellableWith
함수는 receiver 로 받은 Continuation 에 resuemWith
를 해주고 있습니다. 즉, 이 함수를 통해 최초의 Continuation 이 Result.success(Unit)
으로 시작(resumeWith)되고, 이 함수의 receiver 로 받는 Continuation 을 생성하고 있는 createCoroutineUnintercepted
가 최초의 Continuation 을 활성화 할 것으로 보입니다. createCoroutineUnintercepted
을 보겠습니다.
만약 receiver 로 받는 suspend 람다가 BaseContinuationImpl
이라면 create(receiver, completion)
을 하고 있습니다. (suspend R.() -> T) is BaseContinuation
이 성립하는 조건이 안나올 것이라고 저는 생각했지만 실제로는 항상 성립하고 있었습니다.
이 부분을 통해 예상해 보자면 suspend 람다는 항상 BaseContinuationImpl
로 구현되는 것 같습니다. 참고로 그 밑에 else 블럭에서 사용되고 있는 createCoroutineFromSuspendFunction
은 suspend 람다로 부터 RestrictedContinuationImpl
을 통해 Continuation 을 구현하고 있으며, 다음과 같은 상황에서 발생한다고 KDoc 에서 표시하고 있습니다.
- Callable reference to suspending function
- Suspending function reference implemented by Java code
다시 createCoroutineUnintercepted
로 와서 (suspend R.() -> T) is BaseContinuationImpl
분기에서 실행하고 있는 create
함수를 보겠습니다. 이 함수를 통해 메인 continuation 을 이용하여 launch
의 suspend block continuation 이 생성되고 이어서 바로 활성화됩니다. create
는 main() 의 자바 코드에서 아래와 같이 override 되고 있습니다.
이렇게 해서 코루틴의 기본적인 CPS 구현이 마무리 됩니다. BaseContinuationImpl
의 구현에서 한 가지 건너뛴 부분이 있습니다.
resumeWith
의 마지막 부분을 보면 completion
이 BaseContinuationImpl
타입일 때만 루프를 이어나간다는 것을 확인하였습니다. 이는 AbstractContinuation#start
에서 봤듯이 메인 continuation 을 completion 으로 설정하여 코루틴을 시작하고 있기 때문입니다. completion 의 분류 없이 모든 completion 에서 루프를 진행했다면 메인 continuation 차례에서 다시 메인 continuation 으로 resume 하느라 무한 루프가 발생했을 것으로 보입니다.
끝!
이렇게 해서 코루틴의 CPS 기본 구현에 대해 살펴보았습니다. 코루틴 내부 분석을 시도한지 1년만에 드디어 성공한 순간입니다. 이번에도 끝까지 읽어주셔서 감사합니다.
안드로이드 개발자 분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.