Hanbit the Developer

[Kotlin] Suspend Function Implementation 본문

Mobile/Kotlin

[Kotlin] Suspend Function Implementation

hanbikan 2024. 3. 9. 17:45

배경

개인적으로 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을 기준으로 분기합니다.
      1. case 0에서 delay(1000L)을 호출하고 코루틴이 정지됩니다.
      2. 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/