코루틴 소개

코루틴은 suspend/resume 가능하다. 함수가 call/return되는 것과 비교하면 더 일반화된 형태라 할 수 있다. (함수는 suspend/resume이 빠진 코루틴인 셈이다.)

코루틴이란

caller가 함수를 call하고, 함수가 caller에게 값을 return하면서 종료하는 것에 더해 return하는 대신 suspend(혹은 yield)하면 caller가 나중에 resume하여 중단된 지점부터 실행을 이어갈 수 있다.

제너레이터

C#, JavaScript, Python 등이 제공하는 Generator가 여기에 해당한다.

Iterator를 쉽게 만들 수 있는 문법으로 잘 알려진 Generator는 사실 코루틴의 한 형태이다. Iterator 인터페이스를 이용하는 쪽에서 다음 값을 요청하면 Generator는 값을 계산하여 yield한다. Iterator 인터페이스를 직접 구현하는 것 보다 Generator를 이용하여 구현하는 것이 훨씬 편리하다.

function TreeNode(val, left = EMPTY, right = EMPTY) {
this.val = val
this.left = left
this.right = right
}
TreeNode.prototype[Symbol.iterator] = function* iterator() {
yield* this.left
yield this.val
yield* this.right
}

Async/Await

Generator 외에도 Async/await 코루틴이 있다.

async function getProcessedData(url) {
let v;
try {
v = await downloadData(url);
} catch (e) {
v = await downloadFallbackData(url);
}
return processDataInWorker(v);
}

Promise(혹은 Future, Task, Deferred…)라고 하는 콜백을 대체하는 객체들이 있다. Promise를 반환하는 비동기(async) 함수들은 그 결과를 얻어 새로운 연산을 수행하기 위해 Promise 객체에 promise.then(value => …) 처럼 콜백을 등록할 수도 있지만 await로 준비된 값을 꺼낼 수 있다. try/for/if 와 같은 제어문들을 그대로 사용할 수 있어서 코드를 이해하기 수월해진다.

Async 함수는 await에서 suspend되고, await 대상 Promise에서 값이 준비되면 resume되어 다시 실행을 이어간다.

제너레이터와 Async 비교

Generator 코루틴과 Async 코루틴의 차이점은 suspend된 코루틴이 일급객체냐 아니냐 하는 것이다. 각 언어들마다 약간의 차이가 있겠지만 대체로 비슷하다. Async 코루틴은 최종 결과를 나타내는 Promise를 반환할 뿐 suspend된 상태의 코루틴에 대한 핸들이 제공되지 않는다. Generator가 resume가능한 핸들을 제공하기 때문에 Async코루틴이 제공되지 않는 환경(혹은 적절하지 않은 경우)에서는 Generator 코루틴으로 Async 코루틴을 흉내내는 것이 가능하다.

일반화된 코루틴

비교적 뒤늦게 코루틴을 지원하기 시작한 Kotlin이나 C++등의 경우를 보면 좀더 일반화된 코루틴을 제공한다. (2017년 말 현재, 두 언어가 정식으로 코루틴을 지원하지는 않는다. “experimental”)

Kotlin이나 C++의 코루틴은 Generator나 Async 처럼 Suspend/resume의 행동이 미리 결정된 형태가 아닌, 최소한의 언어적 지원을 통해, 라이브러리로 Generator나 Async 효과를 구현할 수 있도록 한다. 추가로 다양한 형태의 코루틴을 언어 확장없이 추가할 수 있다.

Kotlin의 코루틴

예를 들어 Kotlin을 보면 buildSequence() 함수와 yield() 함수를 이용하여 Generator를 구현했다.

fun g() = buildSequence {
yield(1); yield(2);
}
for (v in g()) {
println(v)
}

코틀린에서는 suspend 키워드를 이용하여 코루틴을 정의할 수 있다. suspend로 마킹된 함수(혹은 람다)는 코루틴이며, 다른 suspend 함수를 호출할 수 있다. 다른 suspend 함수를 호출하는 지점이 (이름 그대로) 그 코루틴을 suspend 하는 지점이다. 모든 suspend 함수 호출을 따라가면 맨 하래에는 suspendCoroutine 이라는 low-level API 호출이 있으며, 이 함수를 호출하여 현재 코루틴의 실행을 멈춘다. 실행을 재개하는 것은 suspendCoroutine함수의 인자로 전달하는 콜백으로 시스템이 던져주는 Continuation 객체를 통해 이뤄진다. suspendCoroutineContinuation을 통해 suspend/resume이 가능하다. 그리고 이들을 이용하여 Promise 완료 통지에서 resume하는 것으로 await() 함수를 제공할 수 있으며, Iterator의 hasNext() 호출에서 resume하는 것으로 yield() 역시 함수로 제공할 수 있다.

C++의 코루틴

C++의 경우엔 두 가지 형식의 코루틴이 표준 자리를 놓고 경쟁하고 있다. 이 중에서 C#을 통해 오랜 경험을 쌓은 마이크로소프트가 주도하는 방식이 Kotlin의 그것과 매우 비슷하다. 다만 C++는 co_await / co_yield 키워드로 suspension 지점을 명시적으로 나타내며, suspend 지점을 가지는 함수가 코루틴이 된다.

generator<int> f() {
co_yield 1; co_yield 2;
}
int main() {
auto g = f();
while (g.move_next()) std::cout << g.current_value() << std::endl;
}

여기서 코루틴 함수의 반환 타입 generator는 코루틴 컨텍스트 역할을 한다. 이 객체는 코루틴을 resume할 수 있는 핸들(coroutine_handle)을 인자로 하여 생성되므로, move_next()에서 resume하여 그 결과를 결정할 수 있다.

my_future<int> h();
my_future<void> g() {
co_await 10ms;
std::cout << "resumed\n";
co_await h();
}

co_await 대상 값은 Awaitable 특성의 객체로 변환되어 suspend 여부를 결정할 수 있고, suspend된다면 resume 할수 있는 코루틴 핸들이 주어진다.

상태 머신

Kotlin과 C++의 코루틴 방식이 비슷하다고 한 것은 둘 다 일반화된 suspend 지점과 resume 할수 있는 핸들 객체를 이용하며, 원래의 함수를 상태머신으로 변환한다는 점 때문이다.

코루틴 함수의 suspend 지점이 명확하므로 컴파일러는 본래의 함수를 상태 머신으로 변경할 수 있다. suspend(혹은 co_yield) 지점은 resume 될 포인트 이기도 하다. suspend/resume을 구현하려면

  • suspend될 때, 컨텍스트 객체에 다음 시작될 지점을 저장하고
  • resume될 때, 저장된 지점으로 점프하면 된다.
function co():
<before>
yield;
<after>
// compile 후
function co(context):
if (context.resumePoint == "L0") goto L0;
<before>
context.resumePoint = "L0";
return;
L0:
<after>

Symmetric/Asymmetric

지금까지 살펴본 Generator, Async, 그리고 상태머신으로 컴파일되는 Primitive 코루틴은 처음 살펴본 코루틴의 정의에 한가지 단서가 더 붙어있다.

Generator 코루틴은 suspend(즉 yield)할 때 제어권을 따로 지정하지 않으며, 자동으로 Caller에게 넘어간다. 이러한 코루틴을 Semi-coroutine 혹은 Asymmetric 코루틴이라고 한다.

반대의 개념, Symmetric 코루틴도 있다. Caller/Callee의 관계가 성립하지 않으며 A 코루틴이 B 코루틴으로 제어권을 넘겨서 B가 resume되면(A는 제어권을 넘기면서 suspend), B는 그 제어권을 꼭 A에게 넘길 필요가 없다. 또다른 코루틴 C에게 제어권을 전달하면서 suspend할 수 있다. 따라서 Symmetric 코루틴은 suspend할 때 제어권을 넘겨받을 코루틴을 지정하여야 한다.

하지만 흥미롭게도 asymmetric 코루틴을 symmetric 코루틴으로 확장하기는 쉽다. 때문에 대부분의 언어들이 Asymmetric 코루틴만을 지원하지만 이 제약이 문제가 되지는 않는다.

-- function to transfer control to a coroutine
function coro.transfer(co, val)
if coro.current ~= coro.main then
return coroutine.yield(co, val)
end
-- dispatching loop
while true do
coro.current = co
if co == coro.main then
return val
end
co, val = co(val)
end
end

Lua로 구현된 위 코드는 transfer라는 코루틴 제어권 전달 함수를 정의한다. Lua가 제공하는 기본 코루틴은 Asymmetric이지만 transfer를 이용하여 다른 코루틴으로 제어권을 넘길 수 있다. dispatching loop는 Trampolining하듯이 코루틴을 계속 resume해 준다.

다음 글: 코루틴을 구분해보자