Hanbit the Developer
[Kotlin] Suspend Function Implementation 본문
배경
개인적으로 Retrofit2의 구현을 분석한 적이 있는데 이때 suspend fun이 디컴파일될 때 마지막 인자로 Continuation이 붙으면서 처리된다는 것을 알게 되었고, 원리를 직접 알아보고 싶었습니다.
예시 1: 간단한 코루틴
다음과 같은 간단한 예시를 작성하였습니다.
기존 코드
class SampleViewModel : ViewModel() {
fun logout() {
viewModelScope.launch {
val userId = getUserId()
logoutUser(userId)
}
}
private suspend fun getUserId(): Int = 0
private suspend fun logoutUser(userId: Int) {
Log.d("SampleViewModel", "User${userId} has been logged out")
}
}
decompiled java
위 코드를 decompile 하면 아래와 같은 코드가 나옵니다.(기존 디컴파일 코드의 함수를 재배치하고 변수를 리네이밍하여 가독성을 높였습니다.)
@Metadata(
mv = {1, 8, 0},
k = 1,
d1 = {"\\u0000\\u001c\\n\\u0002\\u0018\\u0002\\n\\u0002\\u0018\\u0002\\n\\u0002\\b\\u0002\\n\\u0002\\u0010\\b\\n\\u0002\\b\\u0002\\n\\u0002\\u0010\\u0002\\n\\u0002\\b\\u0004\\u0018\\u00002\\u00020\\u0001B\\u0005¢\\u0006\\u0002\\u0010\\u0002J\\u000e\\u0010\\u0003\\u001a\\u00020\\u0004H\\u0082@¢\\u0006\\u0002\\u0010\\u0005J\\u0006\\u0010\\u0006\\u001a\\u00020\\u0007J\\u0016\\u0010\\b\\u001a\\u00020\\u00072\\u0006\\u0010\\t\\u001a\\u00020\\u0004H\\u0082@¢\\u0006\\u0002\\u0010\\n¨\\u0006\\u000b"},
d2 = {"Lcom/hanbikan/nook/feature/phone/SampleViewModel;", "Landroidx/lifecycle/ViewModel;", "()V", "getUserId", "", "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", "logout", "", "logoutUser", "userId", "(ILkotlin/coroutines/Continuation;)Ljava/lang/Object;", "phone_debug"}
)
public final class SampleViewModel extends ViewModel {
public final void logout() {
BuildersKt.launch$default(ViewModelKt.getViewModelScope(this), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int label;
public final Object invoke(Object value, Object completion) {
return ((<undefinedtype>)this.create(value, (Continuation)completion)).invokeSuspend(Unit.INSTANCE);
}
@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 continuationWrapper = new <anonymous constructor>(completion);
return continuationWrapper;
}
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object coroutineSuspended = IntrinsicsKt.getCOROUTINE_SUSPENDED();
Object result;
SampleViewModel viewModelInstance;
switch (this.label) {
case 0:
ResultKt.throwOnFailure($result);
viewModelInstance = SampleViewModel.this;
this.label = 1;
result = viewModelInstance.getUserId(this);
if (result == coroutineSuspended) {
return coroutineSuspended;
}
break;
case 1:
ResultKt.throwOnFailure($result);
result = $result;
break;
case 2:
ResultKt.throwOnFailure($result);
return Unit.INSTANCE;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
int userId = ((Number)result).intValue();
viewModelInstance = SampleViewModel.this;
this.label = 2;
if (viewModelInstance.logoutUser(userId, this) == coroutineSuspended) {
return coroutineSuspended;
} else {
return Unit.INSTANCE;
}
}
}), 3, (Object)null);
}
private final Object getUserId(Continuation $completion) {
return Boxing.boxInt(0);
}
private final Object logoutUser(int userId, Continuation $completion) {
Log.d("SampleViewModel", "User" + userId + " has been logged out");
return Unit.INSTANCE;
}
}
- BuildersKt.launch$default는 코루틴을 시작하는 빌더 함수입니다. 이 함수는 viewModelScope에서 실행되며, viewModelScope는 ViewModel이 제거될 때 자동으로 취소되어 리소스 누수를 방지합니다.
- Continuation은 코루틴의 현재 상태를 나타내며, 비동기 실행의 결과를 받거나 코루틴의 실행을 다시 시작할 때 사용됩니다. suspend fun의 마지막 인자에 Continuation이 추가되는 것을 확인할 수 있습니다. 또한 Continuation 인터페이스는 다음과 같습니다.
/**
* Interface representing a continuation after a suspension point that returns a value of type `T`.
*/
@SinceKotlin("1.3")
public interface Continuation<in T> {
/**
* The context of the coroutine that corresponds to this continuation.
*/
public val context: CoroutineContext
/**
* Resumes the execution of the corresponding coroutine passing a successful or failed [result] as the
* return value of the last suspension point.
*/
public fun resumeWith(result: Result<T>)
}
- Function2: BuildersKt.launch$default() 함수 파라미터에서의 Function2 익명 클래스는, 코루틴의 진행 상태를 기억하는 ‘상태 머신’의 역할을 수행합니다.
- 아래 코드의 경우, label이 0일 때 getUserId()를 호출하고 네트워크 처리를 하는 동안 정지 상태(COROUTINE_SUSPENDED)가 되며(이해를 위해 getUserId() 호출 시 코루틴이 정지된다고 가정하겠습니다.) invokeSuspend() 함수에서 벗어나게 됩니다. 이 과정에서 label을 1로 세팅을 하기 때문에 다음 호출 시에는 case 1 블럭이 호출됩니다.
- 네트워크 처리가 끝나고 코루틴이 재개되면 invokeSuspend() 함수가 다시 호출됩니다. 이때 case 1을 거쳐 result에 네트워크 처리 결과(userId)가 삽입되고 이를 통해 logoutUser() 함수가 호출됩니다.
viewModelScope.launch {
val userId = getUserId()
logoutUser(userId)
}
예시 2: 예시 1 + delay()
이번에는 조금 더 어려운 예시를 살펴보겠습니다.
기존 코드
class SampleViewModel : ViewModel() {
fun logout() {
viewModelScope.launch {
val userId = getUserId()
logoutUser(userId)
}
}
private suspend fun getUserId(): Int {
delay(1000) // <- Added
return 0
}
private suspend fun logoutUser(userId: Int) {
Log.d("SampleViewModel", "User${userId} has been logged out")
}
}
decompiled java
@Metadata(
mv = {1, 8, 0},
k = 1,
d1 = {"\\u0000\\u001c\\n\\u0002\\u0018\\u0002\\n\\u0002\\u0018\\u0002\\n\\u0002\\b\\u0002\\n\\u0002\\u0010\\b\\n\\u0002\\b\\u0002\\n\\u0002\\u0010\\u0002\\n\\u0002\\b\\u0004\\u0018\\u00002\\u00020\\u0001B\\u0005¢\\u0006\\u0002\\u0010\\u0002J\\u000e\\u0010\\u0003\\u001a\\u00020\\u0004H\\u0082@¢\\u0006\\u0002\\u0010\\u0005J\\u0006\\u0010\\u0006\\u001a\\u00020\\u0007J\\u0016\\u0010\\b\\u001a\\u00020\\u00072\\u0006\\u0010\\t\\u001a\\u00020\\u0004H\\u0082@¢\\u0006\\u0002\\u0010\\n¨\\u0006\\u000b"},
d2 = {"Lcom/hanbikan/nook/feature/phone/SampleViewModel;", "Landroidx/lifecycle/ViewModel;", "()V", "getUserId", "", "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", "logout", "", "logoutUser", "userId", "(ILkotlin/coroutines/Continuation;)Ljava/lang/Object;", "phone_debug"}
)
public final class SampleViewModel extends ViewModel {
public final void logout() {
BuildersKt.launch$default(ViewModelKt.getViewModelScope(this), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int label;
public final Object invoke(Object value, Object completion) {
return ((<undefinedtype>)this.create(value, (Continuation)completion)).invokeSuspend(Unit.INSTANCE);
}
@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 continuationWrapper = new <anonymous constructor>(completion);
return continuationWrapper;
}
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object coroutineSuspended = IntrinsicsKt.getCOROUTINE_SUSPENDED();
Object result;
SampleViewModel viewModelInstance;
switch (this.label) {
case 0:
ResultKt.throwOnFailure($result);
viewModelInstance = SampleViewModel.this;
this.label = 1;
result = viewModelInstance.getUserId(this);
if (result == coroutineSuspended) {
return coroutineSuspended;
}
break;
case 1:
ResultKt.throwOnFailure($result);
result = $result;
break;
case 2:
ResultKt.throwOnFailure($result);
return Unit.INSTANCE;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
int userId = ((Number)result).intValue();
viewModelInstance = SampleViewModel.this;
this.label = 2;
if (viewModelInstance.logoutUser(userId, this) == coroutineSuspended) {
return coroutineSuspended;
} else {
return Unit.INSTANCE;
}
}
}), 3, (Object)null);
}
private final Object getUserId(Continuation continuation) {
Object $continuation;
label20: {
if (continuation instanceof <undefinedtype>) {
$continuation = (<undefinedtype>)continuation;
if ((((<undefinedtype>)$continuation).label & Integer.MIN_VALUE) != 0) {
((<undefinedtype>)$continuation).label -= Integer.MIN_VALUE;
break label20;
}
}
$continuation = new ContinuationImpl(continuation) {
// $FF: synthetic field
Object result;
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return SampleViewModel.this.getUserId(this);
}
};
}
Object $result = ((<undefinedtype>)$continuation).result;
Object coroutineSuspended = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch (((<undefinedtype>)$continuation).label) {
case 0:
ResultKt.throwOnFailure($result);
((<undefinedtype>)$continuation).label = 1;
if (DelayKt.delay(1000L, (Continuation)$continuation) == coroutineSuspended) {
return coroutineSuspended;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
return Boxing.boxInt(0);
}
private final Object logoutUser(int userId, Continuation $completion) {
Log.d("SampleViewModel", "User" + userId + " has been logged out");
return Unit.INSTANCE;
}
}
delay() 함수가 추가된 getUserId() 함수를 제외하면 코드가 같습니다. 따라서 getUserId() 함수만 보면 됩니다.
private final Object getUserId(Continuation continuation) {
Object $continuation;
label20: {
if (continuation instanceof <undefinedtype>) {
$continuation = (<undefinedtype>)continuation;
if ((((<undefinedtype>)$continuation).label & Integer.MIN_VALUE) != 0) {
((<undefinedtype>)$continuation).label -= Integer.MIN_VALUE;
break label20;
}
}
$continuation = new ContinuationImpl(continuation) {
// $FF: synthetic field
Object result;
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return SampleViewModel.this.getUserId(this);
}
};
}
Object $result = ((<undefinedtype>)$continuation).result;
Object coroutineSuspended = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch (((<undefinedtype>)$continuation).label) {
case 0:
ResultKt.throwOnFailure($result);
((<undefinedtype>)$continuation).label = 1;
if (DelayKt.delay(1000L, (Continuation)$continuation) == coroutineSuspended) {
return coroutineSuspended;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
return Boxing.boxInt(0);
}
- label20은 Java에서 사용되는 label 문법입니다. break label20;을 호출하면 해당 코드 블럭에서 탈출하는 식입니다.
- Continuation를 초기화합니다. if 문을 살펴보겠습니다. 2의 보수법으로 MIN_VALUE는 1000 0000 … 0000입니다. 즉 첫번째 자리가 1인 경우(음수인 경우) 조건문이 참이 됩니다. label이 음수인 경우 MIN_VALUE를 빼준 뒤 label20 블록에서 탈출합니다. MIN_VALUE를 빼주는 이유는, label20 블록에서 초기화되는 ContinuationImpl.invokeSuspend() 함수에서 label에 MIN_VALUE를 더하고 getUserId() 함수를 호출하기 때문입니다. MIN_VALUE를 더해주는 이유는 getUserId() 함수가 외부에서 호출되었는지 구분하기 위해서입니다. logout() 함수의 case 0에서 getUserId()를 호출할 때 logout() 함수에서의 continuation을 넣어주게 되는데, 이 경우 label은 양수이므로 continuation이 초기화됩니다. 즉 getUserId() 함수를 위한 새로운 상태 머신을 초기화하기 위함입니다.
- Continuation 초기화 이후, logout() 함수 때처럼 label을 기준으로 분기합니다.
- case 0에서 delay(1000L)을 호출하고 코루틴이 정지됩니다.
- 1초 뒤 코루틴이 재개되면 invokeSuspend() 함수가 호출됩니다. 이때 label은 1이었으나 위에서 설명한 대로 getUserId() 함수에는 MIN_VALUE가 더해진 채로 넘어가게 됩니다. 이 값은 음수이기 때문에 label20 부분에서 다시 원상복구됩니다. 이후 case 1로 넘어가서 최종적으로 user id를 반환하게 됩니다.
예시 3: 많은 분기점
이번에는 아래와 같이 정지 함수를 여러 번 호출했을 때입니다.
기존 코드
viewModelScope.launch {
getUserId()
getUserId()
getUserId()
getUserId()
}
decompiled java
@Metadata(
mv = {1, 8, 0},
k = 1,
d1 = {"\\u0000\\u001c\\n\\u0002\\u0018\\u0002\\n\\u0002\\u0018\\u0002\\n\\u0002\\b\\u0002\\n\\u0002\\u0010\\b\\n\\u0002\\b\\u0002\\n\\u0002\\u0010\\u0002\\n\\u0002\\b\\u0004\\u0018\\u00002\\u00020\\u0001B\\u0005¢\\u0006\\u0002\\u0010\\u0002J\\u000e\\u0010\\u0003\\u001a\\u00020\\u0004H\\u0082@¢\\u0006\\u0002\\u0010\\u0005J\\u0006\\u0010\\u0006\\u001a\\u00020\\u0007J\\u0016\\u0010\\b\\u001a\\u00020\\u00072\\u0006\\u0010\\t\\u001a\\u00020\\u0004H\\u0082@¢\\u0006\\u0002\\u0010\\n¨\\u0006\\u000b"},
d2 = {"Lcom/hanbikan/nook/feature/phone/SampleViewModel;", "Landroidx/lifecycle/ViewModel;", "()V", "getUserId", "", "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", "logout", "", "logoutUser", "userId", "(ILkotlin/coroutines/Continuation;)Ljava/lang/Object;", "phone_debug"}
)
public final class SampleViewModel extends ViewModel {
public final void logout() {
BuildersKt.launch$default(ViewModelKt.getViewModelScope(this), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
SampleViewModel var10000;
Object var2;
label34: {
label33: {
var2 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch (this.label) {
case 0:
ResultKt.throwOnFailure($result);
var10000 = SampleViewModel.this;
this.label = 1;
if (var10000.getUserId(this) == var2) {
return var2;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
case 2:
ResultKt.throwOnFailure($result);
break label33;
case 3:
ResultKt.throwOnFailure($result);
break label34;
case 4:
ResultKt.throwOnFailure($result);
return Unit.INSTANCE;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
var10000 = SampleViewModel.this;
this.label = 2;
if (var10000.getUserId(this) == var2) {
return var2;
}
}
var10000 = SampleViewModel.this;
this.label = 3;
if (var10000.getUserId(this) == var2) {
return var2;
}
}
var10000 = SampleViewModel.this;
this.label = 4;
if (var10000.getUserId(this) == var2) {
return var2;
} else {
return Unit.INSTANCE;
}
}
@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}
public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
}), 3, (Object)null);
}
private final Object getUserId(Continuation $completion) {
return Boxing.boxInt(0);
}
}
invokeSuspend() 함수의 호출에 따른 실행 흐름을 보겠습니다.
- 1번째 호출: case 0에서 label = 1, getUserId()를 호출하고 함수를 종료합니다.
- 2번째 호출: case 1에서 switch 문을 탈출하여 label = 2, getUserId()를 호출하고 함수를 종료합니다.
- 3번째 호출: case 2에서 label33을 탈출하여 label = 3, getUserId()를 호출하고 함수를 종료합니다.
- 4번째 호출: case 3에서 label34를 탈출하여 label = 4, getUserId()를 호출하고 함수를 종료합니다.
이때 핵심은 switch 문에서 각 분기점에 해당하는 label을 탈출하는 방식입니다.
마치며
상태 머신에서의 함수를 반복적으로 호출하면서 구현되어 있는 모습이 인상적이었습니다.
References
https://june0122.github.io/2021/06/09/coroutines-under-the-hood/
'Kotlin' 카테고리의 다른 글
Coroutines Flow | SharingStarted (1) | 2024.02.06 |
---|---|
cold stream vs hot stream in code (0) | 2024.02.06 |
Effective Kotlin | 8장. 효율적인 컬렉션 처리 (0) | 2023.09.11 |
Effective Kotlin | 7장. 비용 줄이기 (0) | 2023.09.11 |
Effective Kotlin | 6장. 클래스 설계 (0) | 2023.08.12 |