알기쉬운 코루틴 이론

이번 포스팅에서는 코틀린의 코루틴(Coroutine)에 대해 알아보도록 하겠습니다.

코루틴의 개념

안드로이드에서는 AsyncTask를 이용해서 손쉽게 비동기 프로그래밍을 구현했었지만 구글이 다음과 같이 밝힌 것처럼 메모리 누수등 여러가지 문제가 있었습니다. 그래서 API 30부터는 AsyncTask를 deprecate 시키고 코루틴을 사용하도록 권장하고 있습니다.

AsyncTask was intended to enable proper and easy use of the UI thread. However, the most common use case was for integrating into UI, and that would cause Context leaks, missed callbacks, or crashes on configuration changes. It also has inconsistent behavior on different versions of the platform, swallows exceptions from {@code doInBackground}, and does not provide much utility over using {@link Executor}s directly.

코루틴은 1958년 멜빈 콘웨이가 처음 제창했다고 하는 개념으로 현재 여러 프로그래밍 언어에 구현되어 있으며 코틀린만의 고유한 개념은 아닙니다.

프로그래밍에서 루틴이란 프로그램의 흐름을 추상적으로 일컫는 말입니다. 루틴은 크게 메인루틴과 서브루틴으로 나눌 수 있는데요. 예를들어 main 함수에 의해 수행되는 프로그램의 흐름을 메인루틴이라고 한다면, main 함수 안에서 실행되는 개별 함수들에 의해 수행되는 흐름을 서브루틴이라 비유할 수 있겠습니다.

루틴의 한 종류인 함수는 호출을 함으로써 그 내부에 진입하여 내부에서 처리를 수행하고, 처리가 끝나면 함수도 종료되는 구조를 가지고 있기 때문에 보통은 루틴이 일직선적인 흐름을 가지고 있다고 할 수 있습니다.

그런데 코루틴의 특징은 이 일직선적인 흐름을 중간에 지연시켰다가(suspend) 다시 재시작하는(resume) 것이 가능합니다. 따라서 코루틴을 사용하면 루틴이 실행 되었더라도 더 바쁜 다른 루틴이 실행되는동안 잠시 멈췄다가, 바쁜게 끝나면 재시작하여 나머지 작업을 끝내는 형태의 비동기 프로그래밍이 가능하게 됩니다.

또 코루틴은 자기 스스로가 메인루틴이 되면서도 서브루틴이 될 수 있다는 특징도 있습니다.

코루틴과 스레드

구글에서는 AsyncTask를 코루틴으로 대체하여 쓰라고 하고 있기 때문에 얼핏 생각하면 코루틴은 AsyncTask의 약점인 메모리누수가 없는 스레드라고 생각하기 쉽습니다. 그러나 코루틴은 스레드가 아닙니다.

메모리 구조의 차이

ProgressDialog로 Thread와 Handler 이해하기 강의에서 다루었던 것처럼 프로세스속에서 실행되는 독립된 여러 흐름중의 하나를 스레드라고 합니다. 프로세스는 자기가 사용할 메모리영역(Heap)을 할당받는데, 이때 스레드는 Heap 안에서 다시 자신만이 사용할 수 있는 고유의 메모리영역(Stack)을 할당받게 됩니다.

그런데 앞에서 설명했듯이 코루틴은 스택을 할당받지 않고 프로세스의 Heap 메모리를 공유하여 사용하기 때문에 비동기 작업을 구현한다는 점에서는 스레드와 동일한 일을 하고 있지만, 스레드보다는 함수에 가까운 구조를 가지고 있습니다.

수행방식의 차이

코루틴은 비선점형 멀티태스킹(Non-preemptive Multitasking)이고 스레드는 선점형 멀티태스킹(Preemptive Multitasking)입니다. 스레드는 실제로 멀티코어를 사용함으로써 동시에 복수의 스레드를 처리할 수 있는데요 이것을 병행성이 있다고 합니다. 그런데 코루틴은 CPU를 시간분할하여 사용하기 때문에 실제로는 복수의 작업을 동시에 처리할 수 없어 병행성은 없습니다. 하지만 코루틴은 한번에 한개밖에 실행되지 않음에도 불구하고 전환속도가 빠르기 때문에 외부에서 볼 때는 마치 동시에 처리되는 것처럼 인식되므로 동시성이 있다고 합니다.

코루틴의 장점

예를들어 세개의 스레드를 사용해야 하는 작업을 코루틴 세개로 만들어 사용할 경우 스택을 따로 할당할 필요가 없으니 사용되는 메모리가 줄어들게 됩니다. 그리고 스레드끼리 처리순서를 조정할 때 메모리를 공유하지 않기 때문에 수행해야 했던 context switching을 하지 않게 되므로 작업전환시의 오버헤드도 줄어들게 됩니다. 한 개의 스레드 안에서 여러개의 코루틴이 돌아가도록 할 수 있으니 스레드를 불필요하게 많이 만들어야 할 필요도 없어지게 됩니다.

코틀린에서의 사용

코틀린의 코루틴 안에서 실행되는 함수에는 suspend 키워드를 붙여서 실행을 보류하거나 재개할수 있다는 표시를 하게 됩니다.

공식 가이드에 따르면 suspend 키워드로 마킹된 함수는 Continuation Passing Style로 변환되고, Coroutine Builder를 통해 적절한 스레드 상에서 시나리오에 따라 동작하도록 구성되게 됩니다.

코루틴 구조

코틀린의 코루틴은 크게 Coroutine Scope, Coroutine Context, Coroutine Builder의 세 부분으로 나눌 수 있습니다.

Coroutine Scope

코루틴의 동작하는 범위를 규정합니다. 스코프 내에서 실행되는 코루틴의 실행을 감시하거나 취소할 수 있습니다.

CoroutineScope

다음과 같은 인터페이스로 정의되며 특정한 dispatcher를 지정하여 동작이 실행될 스코프를 제한할 수 있습니다.

1
2
3
public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

GlobalScope

GlobalScope는 CoroutineScope의 한 종류입니다. 안드로이드에서 어플리케이션 라이프사이클을 따르며, 싱글톤으로 최상위 레벨에서 코루틴을 시작하기 때문에 필요할때 만들어 쓰고 버린다는 사용법이 불가능합니다. 일반적으로는 사용 자체를 추천하지 않습니다.

Coroutine Context

코루틴은 항상 Coroutine Context로 구성된 콘텍스트 안에서 실행되는데, 이 콘텍스트는 Dispatchers와 Job으로 구성됩니다.

Dispatchers

Dispatchers는 코루틴이 실행될 스레드를 지정하는 역할을 합니다. 네가지 타입이 있는데 특징을 간단하게 정리해보면 .Default는 주로 CPU에서 많은 연산이 필요한 처리, .IO는 파일IO나 네트워크 콜을 수행해야 할 때, 그리고 코루틴에서 처리된 값을 UI에 반영할 때는 .Main을 사용한다고 생각하면 될 것 같습니다. .Unconfined는 일반적인 용도에서는 사용하지 않기 때문에 무시하셔도 됩니다.

  • Dispatchers.Default

    • CPU자원을 많이 필요로하는 작업에 사용.
    • 공유 백그라운드 스레드의 common pool에서 동작.
    • 동시작업 가능한 최대 개수는 CPU코어수와 같으며 최소 2개임.
  • Dispatchers.IO

    • 파일 혹은 소켓IO등의 가볍고 빈번한 IO작업에 사용.
    • Blocking IO용 공유 스레드풀에서 동작.
    • 필요에 따라 스레드를 추가 생성하거나 없앨 수 있는데 64 or 코어 수 중 큰 수만큼 생성가능함.
    • Dispatchers.Default와 스레드를 공유하므로 withContext에서 Dispatcher변경시 context switching하지 않고 동일한 스레드에서 실행됨.
  • Dispatchers.Main

    • MainCoroutineDispatcher 클래스의 인스턴스이다
    • 안드로이드에서는 UI 오브젝트를 다루는 메인스레드에서 동작
    • 일반적으로 싱글스레드가 된다
  • Dispatchers.Unconfined

    • 첫번째 지연점까지만 실행된다.
    • 메인스레드에서 동작한다.
    • 일반적인 용도로는 사용하지 않는다.

Job & Deferred

코틀린에서는 코루틴 작업을 Job 혹은 Deferred라는 오브젝트로 만들어 다룹니다. Deferred는 결과값을 가지는 Job이므로 실제로는 둘 다 Job이라고 볼 수 있습니다.

코루틴이라는 것은 프로그램의 흐름이라고 하는 추상적인 개념인데요, 코루틴 한 덩어리를 한 개의 Job이라는 오브젝트로 만들게 되면 그 오브젝트에 대해 취소나 예외처리를 함으로써 용이하게 코루틴의 흐름제어를 할 수 있게 됩니다.

1
2
3
val job = scope.launch {
    // New coroutine
}

코루틴은 일시정지될수 있는 작업의 흐름이기 때문에 Job은 코루틴의 여러가지 상태를 반영할 수 있도록 다음과 같이 설계되었습니다.

State isActive isCompleted isCancelled
New (optional initial state) false false false
Active (default initial state) true false false
Completing (transient state) true false false
Cancelling (transient state) false false true
Cancelled (final state) false true true
Completed (final state) false true false

그리고 각 상태는 다음과 같이 전환되죠.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Job states cycle
                                          wait children
    +-----+ start  +--------+ complete   +-------------+  finish  +-----------+
    | New | -----> | Active | ---------> | Completing  | -------> | Completed |
    +-----+        +--------+            +-------------+          +-----------+
                     |  cancel / fail       |
                     |     +----------------+
                     |     |
                     V     V
                 +------------+                           finish  +-----------+
                 | Cancelling | --------------------------------> | Cancelled |
                 +------------+                                   +-----------+

Job 객체에 대해서는 cancel, join, start 등의 메소드가 정의되어 있습니다. cancelstart는 위의 다이어그램에서 표시되는 cancel과 start 동작을 구현하는 메소드입니다. join은 코루틴을 병렬처리하지 않고 현재 job에 정의된 작업을 수행하는동안 기다리도록 하는 메소드입니다.

Coroutine Builder

코루틴을 시작하는 방법에는 4가지가 있습니다.

  • launch : 메인 스레드를 블록하지 않는 코루틴 작업을 실행합니다. 결과를 반환할 필요가 없는 작업에 사용하며 Job 객체를 반환합니다.
  • async : 메인 스레드를 블록하지 않는 코루틴 작업을 실행합니다. 결과를 반환할 필요가 있는 작업에 사용하며 Deferred 객체를 반환합니다.
  • runBlocking : 메인 스레드를 블록하고 작업을 실행합니다. runBlocking은 테스트 용도등에나 사용하지, 코루틴을 위해서는 사용하지 말라고 권장하고 있습니다.
  • withContext : 예를들어 Dispatchers.Main으로 지정된 스코프 안에서 Dispatchers.IO가 필요한 처리를 해야할 일이 있을 수 있습니다. 이 때 Dispatchers 안에 다시 Dispatchers를 정의할 수도 있지만 withContext를 사용하면 Dispatchers를 간편하게 스위치할 수 있습니다. withContext를 이용한 스코프 전환은 OS에서 관리되므로 오버헤드가 적다고 알려져 있습니다.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
suspend fun fetchDocs() {                      // Dispatchers.Main
    val result = get("developer.android.com")  // Dispatchers.Main
    show(result)                               // Dispatchers.Main
}

suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

코루틴 지연

  • delay : milisecond단위로 루틴을 잠시 대기시킵니다. Thread.sleep은 스레드 자체를 정지시키는데 반해, delay는 코루틴이 멈추지 않고 대기상태에 들어간다는 점이 다릅니다.
  • join : Job의 실행이 끝날때까지 대기시킵니다.
  • await : Deferred의 실행이 끝날때까지 대기시키고 결과값을 반환합니다.

코루틴 취소

코루틴 실행을 취소하는데에는 몇가지 방법이 있습니다.

  • cancel : 위에서 설명한대로 job을 Cancelling (transient state)로 변화시킵니다.
  • cancelAndJoin : job을 캔슬하고 Cancelled (final state)가 될 때까지 기다립니다.
  • withTimeout : 제한시간을 설정하고 그때까지 처리가 끝나지 않았을 경우 블럭을 취소하고 TimeoutCancellationException을 throw합니다.
  • withTimeoutOrNull : withTimeout을 처리중 제한시간이 경과되었을 경우 예외 대신 null을 반환합니다.

예외 처리

코루틴의 예외는 다음과 같이 정리할 수 있습니다.

  • CoroutineExceptionHandler를 이용하여 코루틴 내부의 기본 catch block으로 사용할 수 있다.
  • launch, actor : exception발생 시 바로 예외가 발생.
  • async, produce : 중간에 exception이 발생해도 await를 만나야 비로소 exception이 발생.
  • Job.cancel()을 제외한 다른 exception이 발생하면 부모의 코루틴까지 모두 취소시킨다. 이는 structured concurrency를 유지하기 위함으로 CoroutineExceptionHandler를 설정해도 막을 수 없다.
  • 자식 코루틴에서 exception이 발생하면 다른 자식 코루틴 및 부모코루틴이 다 취소되버리기 때문에, 문제가 생긴 코루틴만 exception 처리할 수 있도록 하기 위해 CoroutineExceptionHandler를 설정한다. 단, CancellationException는 handler에서 무시된다.
  • 여러개의 exception이 발생하면 가장 먼저 발생한 exception이 handler로 전달되며 나머지는 무시된다.

코루틴 사용

심플하게 정리하면 스코프는 CoroutineScope를 사용합니다. CPU 작업이냐 IO 작업이냐를 판단하여 Dispatchers를 정의하고 마지막으로 코루틴 처리후 값이 나와야 되는가 아닌가에 따라 launchasync를 구별해서 사용하면 되겠습니다.

이렇게 해서 코틀린의 코루틴에 대해 알아보았습니다.

Built with Hugo
Theme Stack designed by Jimmy