본문 바로가기

Android/Tech

StateMachine 과 Stackless Coroutine

Unsplash, Mick haupt.

Coroutine

Coroutine(코루틴) 은 비선점형 멀티태스킹 솔루션입니다. 동시성 프로그래밍을 구현하기 위한 기법으로, Kotlin 은 언어차원에서 제공합니다. 코루틴에 관한 설명은 다음 포스트에 자세히 기록해두었습니다. 그린 스레드와의 비교는 덤이구요!

 

 

코루틴과 그린 스레드

Coroutine 코루틴은 작업 수행을 일시 정지 및 재개될 수 있도록 하는 프로그래밍 구성요소입니다. 코루틴은 서브루틴을 일반화합니다. 협력 작업, 예외, 이벤트 루프, 반복자, 무한 목록 및 파이프

blothhundr.tistory.com

 


코루틴 작동 원리

https://www.modernescpp.com/index.php/implementing-futures-with-coroutines

 

위는 코루틴의 작동 원리를 가장 간결하게 표현하는 이미지입니다. Routine 은 일반적으로 하나의 태스크, 함수를 의미하고, 접두사 Co- 는 '협력' 또는 '함께' 정도를 의미하므로, 코루틴은 협력 루틴 정도로 볼 수 있겠습니다.

 

일반적인 함수의 경우, Caller 는 함수를 실행하고 해당 함수로부터 결과값을 받을 때까지 기다립니다. 이는 동기적 방식입니다. 

 

안드로이드에서는 메인 스레드에서 DB 접근이나 네트워크 통신이 불가능하도록 설계되어 있습니다. DB 접근이나 네트워크 통신이 특정 결과를 반환하지 못하는 상황이 있을 수 있으며, 이로 인해 유저가 응답을 무한정 기다려야 할 수 있기 때문입니다.

 

코루틴을 사용하게 되면, 일반적인 함수를 호출하는 것처럼 비동기 작업을 수행할 수 있습니다. suspend 키워드가 붙은 함수는 실행 도중 작업 수행이 지연될 수 있기 때문입니다. 

 


어떻게 문제없이 멈추고, 어떻게 문제없이 재개될 수 있는가?

State Machine 이라는 컴포넌트가 그 열쇠입니다. 최근에 다녀왔던 찰스의 안드로이드 컨퍼런스에서 나왔던 연사 주제였는데, State Machine 은 생소하지만 꽤 이해하기 쉬운 방식으로 동작하므로, There is no magic 이라 소개되었습니다.

 

private fun showWeather() {
    val userId = getUserId()
    val userLocation = getUserLocation(userId)
    val weather = getWeather(userLocation)
    
    _state.update {
    	it.copy(weather = weather)
    }
}

 

위 코드는 getWeather() 메서드를 통해 유저 위치 기반의 날씨 정보를 얻어 온 뒤, State 로 전달해주는 역할을 합니다. 

 

이를 우리가 원하는 방식 (getUserId() 가 완료되면 getUserLocation() 호출, getUserLocation() 으로부터 응답이 오면 getWeather() 호출) 대로 작동하길 바란다면, 우린 코드를 어떻게 작성해야 할까요?

 

private fun setWeather() {
    getUserId { userId ->
        getUserLocation(userId) { location ->
            getWeather(location) { weather ->
                _state.update {
                    it.copy(weather = weather)
                }
            }
        }
    }
}

 

위와 같이 작성하는 방식을 CPS (Continuation-Passing Style) 라고 합니다. Continuation 자체를 직접 패싱하는 코드 작성법으로, 해당 방식은 대놓고 순차적인 비동기 코드를 작성할 수 있지만, 들여쓰기가 깊어지면서 이해하기 어려운 코드를 만들어 낼 수 있습니다.

 

이와 대비되는 방식으로, CDS (Coroutine Direct Style) 가 있는데요. 다음과 같습니다.

 

private suspend fun setWeather() {
    val userId = getUserId()
    val location = getUserLocation(userId)
    val weather = getWeather(location)
    _state.update { 
        it.copy(weather = weather)
    }
}

 

위와 같은 구조는 suspend 키워드가 붙어주어서 가능합니다. 한 눈에 보기에도 직관적이고, 들여쓰기도 없음을 알 수 있습니다. 코틀린의 코루틴은 CDS 로 작성된 코드를 CPS 로 변환합니다. 그렇다면 코루틴은 CDS 로 작성한 코드를 어떻게 처리할까요?

Label

private suspend fun setWeather() {
    val userId = getUserId()
    val location = getUserLocation(userId)
    val weather = getWeather(location)
}

 

해당 코드는 보다 상단의 코드에서 _state 를 업데이트하는 부분만 제외된 코드입니다. 이 코드를 자바 코드로 변환하면 다음과 같습니다.

 

private final Object setWeather(Continuation var1) {
   Object $continuation;
   label37: {
      if (var1 instanceof <undefinedtype>) {
         $continuation = (<undefinedtype>)var1;
         if ((((<undefinedtype>)$continuation).label & Integer.MIN_VALUE) != 0) {
            ((<undefinedtype>)$continuation).label -= Integer.MIN_VALUE;
            break label37;
         }
      }

      $continuation = new ContinuationImpl(var1) {
         // $FF: synthetic field
         Object result;
         int label;
         Object L$0;

         @Nullable
         public final Object invokeSuspend(@NotNull Object $result) {
            this.result = $result;
            this.label |= Integer.MIN_VALUE;
            return MainActivity.this.setWeather(this);
         }
      };
   }

   Object var10000;
   label31: {
      Object var7;
      label30: {
         Object $result = ((<undefinedtype>)$continuation).result;
         var7 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
         switch (((<undefinedtype>)$continuation).label) {
            case 0:
               ResultKt.throwOnFailure($result);
               ((<undefinedtype>)$continuation).L$0 = this;
               ((<undefinedtype>)$continuation).label = 1;
               var10000 = this.getUserId((Continuation)$continuation);
               if (var10000 == var7) {
                  return var7;
               }
               break;
            case 1:
               this = (MainActivity)((<undefinedtype>)$continuation).L$0;
               ResultKt.throwOnFailure($result);
               var10000 = $result;
               break;
            case 2:
               this = (MainActivity)((<undefinedtype>)$continuation).L$0;
               ResultKt.throwOnFailure($result);
               var10000 = $result;
               break label30;
            case 3:
               ResultKt.throwOnFailure($result);
               var10000 = $result;
               break label31;
            default:
               throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
         }

         int userId = ((Number)var10000).intValue();
         ((<undefinedtype>)$continuation).L$0 = this;
         ((<undefinedtype>)$continuation).label = 2;
         var10000 = this.getUserLocation(userId, (Continuation)$continuation);
         if (var10000 == var7) {
            return var7;
         }
      }

      Location location = (Location)var10000;
      ((<undefinedtype>)$continuation).L$0 = null;
      ((<undefinedtype>)$continuation).label = 3;
      var10000 = this.getWeather(location, (Continuation)$continuation);
      if (var10000 == var7) {
         return var7;
      }
   }

   Weather var4 = (Weather)var10000;
   return Unit.INSTANCE;
}

 

기나 긴 소스 코드 중, 우리가 집중해야 할 부분은 switch-case 문입니다.

 

switch (((<undefinedtype>)$continuation).label) {
   case 0:
      ResultKt.throwOnFailure($result);
      ((<undefinedtype>)$continuation).L$0 = this;
      ((<undefinedtype>)$continuation).label = 1;
      var10000 = this.getUserId((Continuation)$continuation);
      if (var10000 == var7) {
         return var7;
      }
      break;
   case 1:
      this = (MainActivity)((<undefinedtype>)$continuation).L$0;
      ResultKt.throwOnFailure($result);
      var10000 = $result;
      break;
   case 2:
      this = (MainActivity)((<undefinedtype>)$continuation).L$0;
      ResultKt.throwOnFailure($result);
      var10000 = $result;
      break label30;
   case 3:
      ResultKt.throwOnFailure($result);
      var10000 = $result;
      break label31;
   default:
      throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}

 

switch 에 label 이 들어가고, 해당 label 이 변화됨에 따라 임의의 함수를 실행하고, 결과값을 변수에 저장하는 구조입니다. 

 

즉, 코루틴의 Label 은 코루틴 내에서 루프나 조건문과 같은 제어 흐름 구조를 중지하거나 다시 시작하는 데 사용됩니다. Label 을 활용하는 루프나 조건문을 State Machine 이라 부릅니다. 위 코드에서는 switch-case 문 자체가 State Machine이 되는 것입니다.

 

그렇다면, Label 은 어떻게 생성될까요? 이는 코루틴을 시작하는 부분을 확인해보면 알 수 있습니다.

 

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    CoroutineScope(Dispatchers.IO).launch {
        setWeather()
    }
}

 

onCreate() 에서 코루틴을 생성하고 시작하도록 하였습니다. 이는 자바 코드로 다음과 같이 표현됩니다.

 

protected void onCreate(@Nullable Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   this.setContentView(1300000);
   BuildersKt.launch$default(CoroutineScopeKt.CoroutineScope((CoroutineContext)Dispatchers.getIO()), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
      int label;

      @Nullable
      public final Object invokeSuspend(@NotNull Object $result) {
         Object var2 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
         switch (this.label) {
            case 0:
               ResultKt.throwOnFailure($result);
               MainActivity var10000 = MainActivity.this;
               this.label = 1;
               if (var10000.setWeather(this) == var2) {
                  return var2;
               }
               break;
            case 1:
               ResultKt.throwOnFailure($result);
               break;
            default:
               throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
         }

         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);
}

 

호출된 부분에 label 변수가 생성됨을 확인할 수 있습니다. 해당 label 은 코루틴 내에서 사용됩니다.

 

StateMachine

코루틴은 실행 중, 일시 중단, 완료, 취소 등 여러 개의 상태를 가집니다. 코루틴의 동작은 이벤트에 따라 상태가 변화합니다. 이러한 상태들 사이에서 유연하게 중단-재시작 작업을 수행하는 것은 Continuation 의 구현체인 ContinuationImpl 객체이며, Continuation 의 정의는 다음과 같습니다.

 

public interface Continuation<in T> {
    public val context: CoroutineContext

    public fun resumeWith(result: Result<T>)
}

 

Result<T> 를 파라미터로 넘겨주며 코루틴 내에서 작동을 이어가는 방식입니다.

코루틴 내의 여러 suspendable 함수가 있을 때, State Machine 은 해당 suspendable 함수들을 각각의 블록으로 구분하여 switch-case 문에 할당할 수 있도록 라벨을 붙입니다.

 

그리고 해당 label 에 따라 어느 지점에서 재시작할지를 선정하는 일을 ContinuationImpl 객체가 수행하는 것입니다.

 

      label30: {
            Object $result = ((<undefinedtype>)$continuation).result;
            var7 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
            switch (((<undefinedtype>)$continuation).label) {
               case 0:
                  ResultKt.throwOnFailure($result);
                  ((<undefinedtype>)$continuation).L$0 = this;
                  ((<undefinedtype>)$continuation).label = 1;
                  var10000 = this.getUserId((Continuation)$continuation);
                  if (var10000 == var7) {
                     return var7;
                  }
                  break;
               case 1:
                  this = (MainActivity)((<undefinedtype>)$continuation).L$0;
                  ResultKt.throwOnFailure($result);
                  var10000 = $result;
                  break;
               case 2:
                  this = (MainActivity)((<undefinedtype>)$continuation).L$0;
                  ResultKt.throwOnFailure($result);
                  var10000 = $result;
                  break label30;
               case 3:
                  ResultKt.throwOnFailure($result);
                  var10000 = $result;
                  break label31;
               default:
                  throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
            }

            int userId = ((Number)var10000).intValue();
            ((<undefinedtype>)$continuation).L$0 = this;
            ((<undefinedtype>)$continuation).label = 2;
            var10000 = this.getUserLocation(userId, (Continuation)$continuation);
            if (var10000 == var7) {
               return var7;
            }
         }

 

위 코드를 보면 각 case 마다 Continuation 이 사용되는 것을 확인할 수 있습니다.

 


찰스 안드로이드 컨퍼런스 참가를 신청했을 때, 가장 기대가 되었던 발표 주제였습니다. 실제로 궁금했던 부분들에 대한 해소도 충분했구요.

 

현재는 대구에 살고 있기 때문에, 다양한 컨퍼런스에 참여하기가 쉽지 않지만, 추후에 거처를 옮기게 된다면 최대한 많이 참가하면서 좋은 경험들을 많이 해보고 싶다는 생각이 듭니다.