Loading
2023. 5. 7. 01:32 - lazykuna

Coroutines in C

오늘은 Coroutine 관해 꽤 재미있는 글을 읽어서 정리해 둡니다.

Coroutine이란?

일반적으로 함수는 한번 실행하면 끝까지 종료하게 되어 있습니다. 하지만 함수의 일부분만 수행하게 하고 리턴 한 후, 다시 수행 시 남은 부분을 수행하고 싶을 경우가 있습니다. 이럴 때 사용하는 개념이 “Coroutine”으로서, 실행을 일시 중단한 후 다시 수행할 수 있도록 하는 것을 의미합니다.

Naive solution

다만 여기서 문제는, 일시 중단하고 다시 실행하기 위해서는 “맥락(Context)”가 필요합니다. 그래서 이를 어떻게든 구겨 넣어야 하는데, 그렇게 만들고 나면 코드가 아주 더러워진다는 문제가 있습니다. 이를테면, (잊을만 하면 보는) producer/consumer 문제가 있습니다.

/* Decompression code */
    while (1) {
        c = getchar();
        if (c == EOF)
            break;
        if (c == 0xFF) {
            len = getchar();
            c = getchar();
            while (len--)
                emit(c);
        } else
            emit(c);
    }
    emit(EOF);

이와 같은 코드가 있고, 여기서 emit 된 char을 꾸준히 소비하는 코드(consumer)가 있다고 가정합시다. 일반적인 접근 방법이라면 PIPE 나 stack+cond_var을 사용할 수 있겠지만, 여기서는 다소 열악한, 비용으로 최소화해야 하는 단일 스레드 조건이라고 가정합시다. 그러면, emit 되자마자 consumer 이 호출되어야 하는데, 이말인 즉슨 해당 함수(Producer)을 부를 때마다 함수가 실행되었던 위치에서 다시 실행이 되어야 한다는 이야기입니다.

그래서, 간단하게 이렇게 state를 추가하는 방식으로 문제를 해결해 볼 수 있습니다.

int decompressor(void) {
    static int repchar;
    static int replen;
    if (replen > 0) {
        replen--;
        return repchar;
    }
    c = getchar();
    if (c == EOF)
        return EOF;
    if (c == 0xFF) {
        replen = getchar();
        repchar = getchar();
        replen--;
        return repchar;
    } else
        return c;
}

static int 의 상태 변수를 추가해서, 기존 상태에 해당되는 값이 있으면 저 앞에서 뱉어주도록 수정하여 coroutine을 만든 모습이지만, 가독성이 훨씬 떨어집니다. 게다가 이 경우는 비교적 단순한 함수라 간단하게 구현이 되는 편이지만, 복잡한 함수의 coroutine은 정말 지옥과도 같습니다… 😇

Solution

여기서 꽤 재미있는 방법이 있습니다. 바로 switchmacro 를 이용하는 것인데, 기본적인 원리는 상태를 static int 에 저장하는 것은 같지만, switch case 구문을 통해 마지막 상태에 해당되는 곳으로 바로 GOTO를 시켜버리는 것입니다. 사실상 GOTO와 같지만, 코드가 비교적 깔끔하다는 데 장점이 있습니다.

int function(void) {
    static int i, state = 0;
    switch (state) {
        case 0: /* start of function */
        for (i = 0; i < 10; i++) {
            state = 1; /* so we will come back to "case 1" */
            return i;
            case 1:; /* resume control straight after the return */
        }
    }
}

예시 코드를 보면, func를 처음 실행할 때는 case 0 을 타고 리턴하게 되고, 다음에 실행할 때는 case 1 부터 시작하게 됩니다. switch를 저렇게 쓸 생각을 해본 적 없다 보니 굉장히 신선하게 보입니다.

여기에서 몇가지 macro를 더 활용하면, 코드가 말도 안되게 깔끔해져 버립니다. 다만, GOTO와 유사한 방식으로 동작하니 이 점은 유의해야 합니다.

#define crBegin static int state=0; switch(state) { case 0:
#define crReturn(x) do { state=__LINE__; return x; \
                         case __LINE__:; } while (0)
#define crFinish }
int function(void) {
    static int i;
    crBegin;
    for (i = 0; i < 10; i++)
        crReturn(1, i);
    crFinish;
}

int decompressor(void) {
    static int c, len;
    crBegin;
    while (1) {
        c = getchar();
        if (c == EOF)
            break;
        if (c == 0xFF) {
            len = getchar();
            c = getchar();
            while (len--)
            crReturn(c);
        } else
        crReturn(c);
    }
    crReturn(EOF);
    crFinish;
}

C++에서의 Coroutine?

의외로 C++ 20에서 새로 추가된 기능 중 하나가 Coroutine 지원이더라고요. 꽤 재밌어 보여서 간단하게 적고 갑니다.

일단 C++에서의 coroutine은 co_awaitco_yield, co_return 의 키워드로 지원을 합니다. 아주 간단한 예시로

generator<int> iota(int n) {
  while(true)
    co_yield n++;
}

같은 것이 있습니다.

하지만 코루틴의 고질적 문제는 함수가 다시 시작할 때 “상태 관리”에 있습니다. 더군다나 C++은 생성자까지 얽혀 있어 이러한 상태 관리에 있어 훨씬 복잡합니다. 해결책은 간단합니다. 코루틴을 객체로 여기고, 코루틴 객체 생성시에 아예 상태(로컬 변수 포함) 자체를 메모리에 할당해 버립니다. 그리고, 코루틴은 promise 객체여야 합니다. 값을 돌려받거나 예외 처리 또한 해당 객체로 처리하면 모든게 깔끔하고, 그렇게 만들어져 있네요. 다만 왜인지 C의 메서드 직접 호출보다 훨씬 더러워 보이는 건 어쩔 수 없나 …

혹시, Promise 객체에 익숙하지 않다면, 해당 정의부터 보시고 오는 것을 추천드립니다. 이 글에서는 깊게 다루지는 않겠습니다.

여담

  • 유명한 python의 yield 키워드도 사실은 coroutine 입니다.

출처