6장 코루틴

이 장에서는 제너레이터를 설명하면서 갑자기 등장한 코루틴이 무엇인지 살펴보고, 개미 수열에 응용해 볼 것이다.

코루틴 이해하기

위키백과사전의 제너레이터 설명에서 우리는 코루틴(coroutine)이라는 새로운 개념을 만났다. 링크를 따라가보면 위키백과사전 코루틴 항목의 첫 문단은 다음과 같다.

코루틴은 비선점 멀티태스킹을 위해 서브루틴을 일반화한 컴퓨터 프로그램의 구성요소다. 실행을 중단하고 재시작할 수 있는 진입점을 여러 개 가질 수 있다. 코루틴은 협력적 멀티태스킹, 예외, 이벤트 루프, 이터레이터, 무한 리스트, 파이프와 같은 좀더 친숙한 프로그램 구성요소들을 구현하기에 적합하다.

위키백과사전 “코루틴”

다른 함수를 호출하면 해당 함수로 점프하면서 콜스택이 쌓이고, 호출된 함수가 종료되면 원래 호출했던 위치로 되돌아 온다는 것은 너무도 당연한 이야기다. 그런데 코루틴의 경우엔 다르다. A 코루틴이 a0 지점에서 B 코루틴을 호출했다가, B 코루틴의 b0 지점에서 A를 호출(혹은 resume)하면 a0 지점에서 실행이 재개되고, 다시 a1 지점에서 B 코루틴을 재개하면 b0 지점에서 실행을 이어간다. 어느 순간부터는 A가 상위 루틴인지 B가 상위 루틴인지 알 수 없다.

A와 B 코루틴의 실행

제너레이터는 코루틴과 달리 실행을 중단할 때 호출한 함수로만 되돌아간다. 하지만 일반적인 함수와는 달리 중단된 지점에서 실행을 재개할 수 있다. 이러한 의미에서 제너레이터는 반쪽짜리 코루틴이라고 볼 수 있는 것이다.

Go의 고루틴과 채널

그런데, 두 개의 함수가 함께 실행되면서 제어권을 주고 받는다는 코루틴의 설명을 따르는 프로그래밍 모델을 어디선가 본 적이 있다. 게다가 이름도 매우 비슷하다. 바로 Go의 고루틴(goroutine)이다.

Go에는 고루틴이라는 특별한 순차 처리 개념이 있다. 고루틴은 마치 쓰레드처럼 동작하며, 여러 개의 고루틴은 자체 스케줄러를 통해 더 적은 갯수의 쓰레드에서 실행된다. 고루틴은 채널을 매개로 하여 동기화하거나 데이터를 교환한다.1

채널로 값을 보내려는 고루틴은 다른 고루틴이 같은 채널에서 값을 가져갈 때까지 블록되며, 보내고 받는 것은 동기적으로 이뤄진다. 반대로 먼저 받으려 시도할 때에도 다른 고루틴이 값을 보낼 때까지 블록되었다가 동기적으로 보내기/받기가 이뤄지면서 실행을 이어간다.

즉, 두 개의 고루틴은 하나의 채널을 공유하여 데이터를 보내고 받는 것으로 마치 코루틴처럼 제어권을 주고 받을 수 있다.

채널과 고루틴의 Hello World (Go)
func main() {
    c := make(chan int)
    go func() {
        <-c
        print(" World")
    }()
    go func() {
        print("Hello")
        c <- 0
    }()
    time.Sleep(1000)
}

go 키워드로 시작되는 함수 호출은 새로운 고루틴을 만들어서 해당 함수를 실행한다. 위 코드에서 main() 함수는 “ World”를 출력하려는 고루틴과 “Hello”를 출력하려는 고루틴을 생성한다. “ World”를 출력하려는 고루틴이 먼저 시작된다 하더라도 채널에서 값을 읽을 때(<-c) 블록되어 더이상 진행하지 않는다. 이 블록이 해제되는 것은 두 번째 고루틴이 “Hello”를 출력한 뒤에 채널로 값을 하나 보낼 때(c <- 0)이다. 이로 인해 두 고루틴은 “Hello World”를 정상적으로 출력하게 된다. 동시에 실행되는 두 개의 고루틴이 채널 연산을 통해 협력적으로 동기화하는 모양새는 코루틴의 실행 모습과 매우 비슷하다.

Go의 제너레이터 패턴

Go는 이터레이터 혹은 제너레이터라는 개념을 따로 정의하지 않는다. 하지만 기본적으로 제공되는 채널과 고루틴이라는 동시성 기능을 이용하면 제너레이터를 쉽게 만들 수 있다. 이미 5장에서 본 Java 제너레이터 역시 쓰레드를 이용하여 구현된 것이었다. 쓰레드를 사용하여 스택이 넘치는 문제는 피하면서 대신 메모리 부족이라는 문제를 만났다. 다행히 Go의 고루틴은 쓰레드처럼 볼 수 있지만 그보다 훨씬 값싼 요소이다. 소개를 보면 Go의 고루틴을 수백만 개 만드는 것이 가능하다고 한다. 즉, Go의 채널과 고루틴으로 제너레이터 스타일의 개미 수열을 구현한다면 개미 수열의 10,000번 줄, 혹은 수백만 번째 줄을 출력할 수 있을 것 같다.

Go의 채널은 동기화 메커니즘이면서 실제로 데이터를 주고 받을 수 있는 매개이다. 채널을 통해 값을 보내거나 받을 수 있는데, 값을 받는 쪽에서 보자면 채널을 이터레이터처럼 사용할 수 있고, 값을 보내는 쪽에서 보자면 제너레이터처럼 사용할 수 있다. 롭 파이크의 “Go 동시성 패턴” 발표에서도 제너레이터를 첫 번째로 언급하고 있다.

boring() 함수는 제너레이터 (Go)
func main() {
    c := boring("boring!")
    for i := 0; i < 5; i++ {
        fmt.Printf("You say: %q\n", <-c)
    }
    fmt.Println("You're boring; I'm leaving.")
}

func boring(msg string) <-chan string {  // returns read-only channel
    c := make(chan string)
    go func() {
        for i := 0; ; i++ {
            c <- fmt.Sprintf("%s %d", msg, i)
            time.Sleep(time.Duration(rand.Intn(1e3)) * time.Millisecond)
        }
    }()
    return c
}

boring() 함수는 문자열을 읽을 수 있는 채널(<-chan string)을 반환한다. 이는 마치 JavaScript의 제너레이터 함수를 호출하여 제너레이터 객체를 반환하는 것과 비슷하다. main()에서는 루프를 돌면서 채널에서 값을 읽어서(<-c) 출력한다. 이 채널로 값을 전달하는 것은 boring() 함수가 내부에서 시작한 고루틴이다. main()을 실행 중인 고루틴과 boring() 내부에서 분기한 고루틴은 c 채널을 공유하며 데이터를 주고 받는다.

이렇게 읽기 채널을 반환하면서, 내부에서 따로 고루틴을 만들어 그 채널로 값을 보내는 함수 형태가 제너레이터 패턴이다.

Go #0 - 채널과 고루틴

개미 수열을 Go의 고루틴과 채널로 구현하자면 각 줄을 채널로 나타내고, 앞 줄을 읽어 다음 줄을 생성하는 제너레이터 패턴의 함수를 만들어서 n번 반복하면 된다. 즉, next() 함수는 n-1번 줄을 나타내는 채널을 입력으로 받아서 n번 줄을 나타내는 채널을 반환하고, 내부에서는 n-1번 채널의 값을 읽어서 n번 채널에 값을 쓰는 고루틴을 새로 시작해야 한다. n번째 줄을 얻으려면 n개의 고루틴이 있어야 한다. 개미 수열의 첫 줄에 해당하는 채널에는 1 하나만 쓰여진다. 이터레이터/제너레이터는 데이터가 더이상 없음을 알릴 수 있는데, Go에서는 채널을 닫는 것으로 끝을 알릴 수 있다.

먼저 n번 줄을 만들기 위한 ant() 함수는 첫 줄을 생성한 다음 채널을 next()로 n번 감싸준다. 여기서 이미 n개의 고루틴이 시작되고 그 고루틴들은 모두 채널로 연동된다.

n번 줄을 만들어주는 ant() (Go)
func ant(n int) <-chan int {
	c := gen(1)
	for i := 0; i < n; i++ {
		c = next(c)
	}
	return c
}

첫 줄은 1을 보내고 채널을 닫으면 된다. Go의 채널 연산은 기본적으로 동기적이기 때문에 다음 줄을 계산하는 고루틴이 값을 읽기 전에는 보내기 연산이 블록된다.

첫 줄을 출력하는 gen() (Go)
func gen(n int) <-chan int {
	c := make(chan int)
	go func() {
		c <- n
		close(c)
	}()
	return c
}

다음 줄을 출력하는 next() 함수는 기존의 for 구현이 그대로 사용되고 있다. 채널을 생성하고 고루틴을 실행시키는 기본적인 제너레이터 패턴을 따르고 있다.

다음 줄을 계산하는 next() (Go)
func next(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		prev, count := <-in, 1
		for value := range in {
			if value == prev {
				count++
			} else {
				out <- count
				out <- prev
				prev, count = value, 1
			}
		}
		out <- count
		out <- prev
		close(out)
	}()
	return out
}

이 Go 코드는 동시성 관점에서 보자면 5장에서 살펴본 쓰레드 제너레이터를 이용하는 Java 버전과 똑같다. 다만 고루틴이 쓰레드에 비해 생성 비용이 매우 낮기 때문에 이 Go 코드는 10,000번 줄, 심지어 1,000,000번 줄도 출력할 수 있다.

제너레이터 방식을 따르고 있기 때문에 구현은 단순해지면서, 고루틴들이 채널을 통해 모두 엮이면서, 마지막 채널(n번 줄)에서 값을 읽어가는 만큼만 계산이 이뤄진다. 지연 리스트의 효과도 그대로 얻은 것이다.

고루틴과 코루틴

고루틴은 사실 코루틴이 아니다. 하지만 Go에서 권장하는 고루틴들 간의 동기화 메커니즘이 동기적 채널 통신을 이용하고 있다는 점은 코루틴의 동작을 연상케 한다. 고루틴 두 개가 채널로 동기화되며 실행되는 모양을 보면 코루틴 두 개가 협력적으로 실행되는 모양새와 같다. 코루틴은 쓰레드를 이용하지 않으면서 협력적 멀티태스킹을 구현하는데 사용되며, yield/resume으로 제어 흐름을 주고 받는다. 고루틴이 채널을 통해 동기적으로 값을 주고 받는 과정은 yield/resume으로 볼 수 있다. 또, 여러 개의 고루틴이 더 적은 갯수의 쓰레드에 멀티플렉싱되어 실행된다는 점, 그래서 쓰레드 하나에서 동작할 수 있다는 점 또한 코루틴과 유사한 점이다.

5장에서 콜스택을 사용하는 제너레이터와 쓰레드를 사용하는 제너레이터는 개미 수열의 10,000번 줄을 출력하기에 역부족이었다. 고루틴 혹은 코루틴을 흉내낸다면 JavaScript나 Java에서도 10,000번 줄을 출력할 수 있을 것이다.

고루틴을 흉내낸다는 것은 단일 쓰레드에 멀티플렉싱되는 순차 프로세스를 표현하고, 채널을 이용하여 프로세스 간 데이터 교환을 동기화하는 것이다. 즉 프로세스들의 실행을 담당하는 간단한 스케줄러를 구현해야 한다는 얘기다. 코루틴을 흉내내는 것도 거의 비슷하다. 멈췄다가 재시작 가능한 프로세스를 구현하고, 프로세스를 실행시켜 줄 디스패쳐를 만들면 된다. 디스패쳐는 한 번에 하나의 프로세스를 실행하면서 하나가 멈추면 적절히 다음 프로세스를 선택하여 실행한다.

코루틴과 개미 수열

5장에서 JavaScript 제너레이터를 이용한 것이나 이 장에서 Go로 구현한 개미 수열의 동작은 거의 비슷하다. 코루틴 관점에서 실행 흐름을 다시 정리해 보자. 각 줄을 생성하는 함수가 코루틴으로 동작하고, n번 줄과 n-1번 줄은 입출력이 서로 연동된다. 파이프처럼 동작하는 코루틴들은 선후 관계가 있어서 다음 실행할 코루틴은 쉽게 결정된다.

읽기/쓰기로 연동된 코루틴
  1. n번 코루틴은 입력을 읽고자 할 때 yield한다.
  2. 디스패쳐는 n-1번 코루틴을 resume한다.
  3. n-1번 코루틴은 출력을 만들면서 yield한다.
  4. 디스패쳐는 n번 코루틴을 resume한다. (3번의 출력을 읽어간다.)

C #0 - 코루틴

위에서 설명한 과정을 C로 어떻게 구현할 수 있을까? yield하면서 실행 위치를 저장하고, resume할 때 저장해 둔 실행 위치를 넘겨 받아 점프하면 된다. 또, 개미 수열의 next() 함수에서 사용하던 지역 변수들은 코루틴이 재개할 때 유지되어야 하므로 힙에 상태를 따로 저장해 두어야 한다. 아래와 같은 구조체를 얻을 수 있다.

코루틴 상태를 저장하기 위한 구조체 (C)
typedef struct proc proc;
struct proc {
  int (*proc)(proc*); // function ptr to coroutine
  char ptr;           // ptr(label) to re-enter(resume)
  char next;          // value for read() result
  char prev;
  char count;
};

procptr은 모든 코루틴이 가져야 하는 속성이며, next, prev, countnext() 함수의 상태를 저장하기 위한 것이다.

개미 수열의 첫 줄 1을 출력하는 코루틴 함수는 간단하다. 사실 아래 함수는 상태를 저장할 필요가 없으므로 ptr 만 있어도 되지만 편의상 next()와 같은 구조체를 사용했다.

1을 출력하고 종료하는 코루틴 (C)
int init(proc *s) {
  switch (s->ptr) {
    case 0:
      s->ptr++;
      return 1;
    default:
      return 0;
  }
}

처음 호출될 때 ptr은 0이며, 1을 출력하고(return 1), 다음 진입 시(ptr이 1)에 코루틴을 종료한다(return 0). 코루틴을 제대로 구현하려면 yield하는 이유(read 혹은 write)와 출력하는 값을 따로 처리해야 한다. 하지만 개미 수열에서 모든 출력값(혹은 입력값)이 1, 2, 3 중 하나라는 사실을 이용하여 약간의 트릭을 사용했다. 값을 읽기 위해 yield하는 경우는 -1을 반환하고(선행 코루틴을 재개한다) 코루틴을 종료하는 경우에는 0을 반환하게 했다(후행 코루틴을 재개한다). 그 외의 값은 yield하면서 출력하는 값이다(후행 코루틴을 재개한다).

다음 줄을 계산하는 next() 코루틴은 값을 읽기도 하고 출력하기도 하므로 조금 복잡하다. return -1next = read()라고 읽으면 조금 따라가기 쉽다. 코루틴을 떠나기 전에 재진입 위치를 ptr에 저장하므로 ptr = 3goto 3으로 볼 수 있다.

개미 수열의 앞 줄을 읽어 다음 줄을 출력하는 코루틴 (C)
int next(proc *s) {
  int prev;
  switch (s->ptr) {
    case 0:
      s->ptr = 1;
      return -1;
    case 1:
      s->prev = s->next;
      s->count = 1;
      s->ptr = 2;
      return -1;
    case 2:                            // begin of loop
      if (s->next == 0) {
        s->ptr = 5;                    // exit loop
        return s->count;
      } else if (s->prev == s->next) {
        s->count++;
        return -1;
      } else {
        s->ptr = 3;
        return s->count;
      }
    case 3:
      prev = s->prev;
      s->prev = s->next;
      s->count = 1;
      s->ptr = 4;
      return prev;
    case 4:
      s->ptr = 2;
      return -1;                       // end of loop
    case 5:
      s->ptr = 6;
      return s->prev;
    case 6:
      return 0;                        // exit coroutine
    default:
      assert("Unreachable");
      return 0;
  }
}

이제 init()next()를 이용하여 코루틴을 실행해 보자.

코루틴들을 실행시키는 main() (C)
  // prepare coroutines
  proc* procs = (proc*)calloc(n + 1, sizeof(proc));
  procs[0].proc = &init;
  for (int i = 1; i < n + 1; i++) {
    procs[i].proc = &next;
  }

  // dispatch loop
  int cur = n;
  while (cur < n + 1) {
    int result = procs[cur].proc(&procs[cur]);
    if (result == -1) {
      cur--;
    } else if (cur < n) {
      cur++;
      procs[cur].next = result;
    } else if (result != 0) {
      printf("%d", result);
    } else {
      printf("\n");
      break;
    }
  }

개미 수열의 코루틴은 의존성이 선형적이기 때문에 코루틴 데이터를 배열에 저장하고 디스패치 루프에서 인덱스를 증가/감소하는 것만으로 쉽게 다음 코루틴을 선택하여 실행을 재개할 수 있다. 제대로 구현하고자 한다면 코루틴이 yield하면서 실행시킬 코루틴을 지정해 주도록 하면 된다. Go의 고루틴 스케줄링 방식을 따르자면, 고루틴들 간의 의존성을 채널로 구체화시키고, 고루틴이 채널에 값을 쓰거나 읽는 동작에서 동작을 멈추고 해당 채널의 대기열에 멈춘 고루틴을 추가한다. (8장에서 직접 구현해 본다.)

이렇게 완성한 C 프로그램은 10,000번 줄 혹은 1,000,000번 줄도 출력할 수 있다. Java나 JavaScript 혹은 Haskell보다도 훨씬 빠르고 가볍게 실행된다. 역시 C다.

다만 프로그래머가 모든 디테일을 신경써 줘야 한다. next()의 일 부분을 함수로 추출하여 재사용하는 것조차 매우 힘들다. App-specific vs. General 관점에서 보자면 일반화 코드가 0이나 마찬가지다.

C #1 - 매크로 푸!

앞 절의 C 코드는 결과를 제대로 얻을 수는 있어도 가독성이 꽝이다. 하지만 C니까 매크로를 적당히 덧씌운다면 가독성을 꽤 획득할 수 있다.2

코루틴 함수를 위한 매크로들 (C)
#define crBegin    switch(s->ptr) { case 0:
#define crYield(x) do { s->ptr=__LINE__; return x; \
                         case __LINE__:; } while (0)
#define crEnd      } return 0;

위 매크로 세 개를 이용하면 next()는 아래와 같이 바뀐다. 꽤 읽기 쉽지 않은가? 몇가지 코딩 규칙만 지킨다면 훌륭하게 코루틴을 구현할 수 있다.

매크로를 이용한 코루틴 next() (C)
int next(proc *s) {
  crBegin;
  crYield(-1);
  s->prev = s->next;
  s->count = 1;
  while(1) {
    crYield(-1);
    if (s->next == 0) {
      break;
    } else if (s->prev == s->next) {
      s->count++;
    } else {
      crYield(s->count);
      crYield(s->prev);
      s->prev = s->next;
      s->count = 1;
    }
  }
  crYield(s->count);
  crYield(s->prev);
  crEnd;
}

JavaScript #5 - 제너레이터 코루틴

5장 뒷 부분의 제너레이터 이해하기에서 “제너레이터는 반쪽짜리 코루틴”이라는 표현을 봤었다. 제너레이터는 진입점/탈출점을 여럿 가질 수 있으므로 코루틴이라고 할 수 있다. 다만 차이점은 일반적인 코루틴이 중단하면서 재개할 코루틴을 지정할 수 있는데 반해, 제너레이터는 중단하면 호출한 위치로만 되돌아간다. 이러한 제너레이터 속성을 이용하여 디스패치 함수에서 제너레이터를 선택하여 실행하도록 함으로써 제너레이터를 코루틴처럼 사용할 수도 있다. 두 개 혹은 그 이상의 코루틴을 대상으로 협력적 동시성을 구현하고자 하는 목적이다. 이 절에서는 JavaScript의 제너레이터를 이용하여 코루틴 방식으로 동작하는 개미 수열을 구현하려고 한다.

JavaScript의 제너레이터를 복습해보자. 제너레이터 객체는 제너레이터 함수를 호출하여 생성되며, 이터레이터 객체처럼 next() 메쏘드를 가진다. next() 메쏘드를 호출하면 제너레이터 함수를 실행하여 yield하는 값을 반환한다. 제너레이터 함수는 yield에서 실행을 멈췄다가 다음 next()가 호출되었을 때 재개한다. 그런데 제너레이터 객체의 next() 메쏘드는 제너레이터로부터 호출자에게 데이터를 전달하는 것과 더불어 next(value)처럼 호출함으로써 제너레이터를 재개하면서 값을 전달하는 것도 가능하다. 이 경우 제너레이터는 yield를 통해 호출자로부터 값을 받아오게 된다. (코루틴이라는 이름이 붙을만하다.)

앞서 C로 코루틴의 동작을 구현할 때 구조체를 이용하여 재시작 위치와 지역 변수들을 대신할 상태를 저장해야 했다. JavaScript의 제너레이터는 yield/resume 기능을 제공하므로 재시작 위치를 저장하거나 상태를 따로 저장해 둘 필요가 없다.

제너레이터를 이용한 코루틴 next() (JavaScript)
function* next() {
  let prev = yield READ;
  let count = 1;
  let value;
  while ((value = yield READ)) {
    if (prev === value) {
      count++;
    } else {
      yield WRITE(count);
      yield WRITE(prev);
      prev = value;
      count = 1;
    }
  }
  yield WRITE(count);
  yield WRITE(prev);
}

5장에서 제너레이터를 이용하여 이터레이터를 만들었을 때와 달라진 점은 yield를 사용하는 부분이다. 원래 yield는 값을 하나씩 전달하였지만 여기서는 연산 혹은 요청의 형태로 전달한다. READWRITE()는 아래처럼 정의되었다.

const READ = { type: "read" };
const WRITE = value => ({ type: "write", value });

C와 비교하면 가독성이 훨씬 좋아졌다. Go에서 구현했던 next()와 흡사해졌다. Go의 next()에서 inout 채널을 이용하여 값을 읽고 쓰고 했던 부분이 yield를 이용하여 값을 주고 받는 것으로 바뀌었다. 이 때문에 일반적인 제너레이터가 이터레이터로 사용되는 것과 달리 코루틴 스타일로 구현된 제너레이터는 코루틴들을 동작시켜 주는 디스패쳐가 필요하다. 디스패치 함수는 코루틴들을 실행하면서 코루틴이 yield하는 연산을 처리한다.

선형적 의존성을 가진 코루틴들을 실행시켜주는 dispatch() (JavaScript)
function* dispatch(procs) {
  let value;
  let cur = n;
  while (true) {
    let next = procs[cur].next(value);
    if (next.done) {
      if (cur === n) {
        return;
      } else {
        value = undefined;
        cur++;
      }
    } else {
      if (next.value.type === "read") {
        cur--;
      } else if (cur === n) {
        yield next.value.value;
      } else {
        value = next.value.value;
        cur++;
      }
    }
  }
}

개미 수열의 n번 줄을 출력하는 ant() 함수는 코루틴들을 준비하여 dispatch()를 호출하면 된다. ant()는 다시 일반적인 제너레이터로 래핑되어 있기 때문에 for문에서 사용할 수 있다.

코루틴을 이용하는 ant() (JavaScript)
function* ant(n) {
  let procs = new Array(n + 1);
  procs[0] = (function*() {
    yield WRITE(1);
  })();
  for (let i = 1; i < n + 1; i++) {
    procs[i] = next();
  }
  yield* dispatch(procs);
}

JavaScript #6 - js-csp

Go처럼 의존 관계를 채널로 명시적으로 드러내고자 한다면 yield에서 채널 객체와 채널 연산(read/write)을 지정해주고, dispatch()에서 해당 연산에 따라 다음 실행될 코루틴을 선택하면 된다. 이를 위해서는 채널 객체마다 read/write 큐를 연관지어 놓아야 한다.

js-csp 라이브러리는 Go의 채널과 고루틴을 구현하고 있으며, 이 라이브러리를 이용하면 Go와 거의 동일하게 구현할 수 있다. js-csp 라이브러리가 제너레이터를 이용할 것이라는 것은 쉽게 예상할 수 있다.

js-csp를 이용하는 next() (JavaScript)
const { chan, go, put, CLOSED } = require("js-csp");

function next(i) {
  let o = chan();
  go(function*() {
    let prev = yield i;
    let count = 1;
    for (let value = yield i; value !== CLOSED; value = yield i) {
      if (value === prev) {
        count++;
      } else {
        yield put(o, count);
        yield put(o, prev);
        prev = value;
        count = 1;
      }
    }
    yield put(o, count);
    yield put(o, prev);
    o.close();
  });
  return o;
}

Go의 next()와 비교하면 차이가 없다. JavaScript의 제너레이터를 이용하는 모양은 앞에서 구현한 코루틴과도 거의 비슷하다.

개미 수열 복잡도

이 장에서 C로 구현하면서 모든 추상화를 걷어내고 나니 개미 수열 문제의 복잡도를 계산할 수 있게 되었다. n번 줄의 m번째 글자를 계산하기 위한 복잡도를 따져보자.

공간 복잡도
n개의 코루틴이 필요하며, 각 코루틴은 고정된 크기의 메모리만 사용한다. 따라서 O(n)이다.
시간 복잡도
n번 줄의 m번째 글자를 계산하기 위해서는, n-1번 줄까지는 m보다 적게 계산해도 된다. 정확하게는 m부터 30%씩 줄어드는 등비 수열의 합만큼 계산해야 한다. 줄어드는 등비 수열의 합은 초항 크기에 비례하므로 결국 O(m)임을 알 수 있다. 그런데, m이 줄어들어 최소 너비가 되는 높이는 O(log m)이고 일반적으로 n이 이보다 크다고 보면 높이 n만큼은 탐색을 해야 한다. 따라서 전체 시간 복잡도는 O(n + m)이다.
개미 수열의 복잡도

n과 m이 충분히 클 때, 값을 계산해야 하는 탐색 공간은 가는 L 모양이 되므로 직관적으로 O(n + m)으로 추정할 수도 있을 것이다.

n번째 줄 n번째 글자까지 탐색

코루틴 구현 리뷰

코루틴은 개미 수열 문제의 n이 커졌을 때 발생하는 스택오버플로 혹은 메모리 문제를 해결해줬다. Go의 고루틴과 채널을 이용하여 개미 수열 문제 혹은 지연 리스트를 동시성 관점에서 바라볼 수 있었다. 이러한 협력적 멀티태스킹을 구현하기 위해 코루틴의 동작(멈춤/재개)을 살펴본 뒤 C와 JavaScript로 구현하여 개미 수열의 n을 1,000,000까지 확대할 수 있었다.

그러나 석연치 않은 구석이 여전히 남았다.

  • Go는 다행히 쓰레드보다 가벼운 고루틴이라는 도구를 제공해줬다. 고루틴 같은 도구를 사용할 수 없는 환경에서는 어떻게 할 수 있을까?
  • C로 구현했다고는 하지만, 딱 이 문제만 해결할 뿐이다. 추상화 수준이 너무 낮아서 다른 문제에 이러한 방식을 적용하기란 거의 불가능할 것 같다.
  • JavaScript는 제너레이터를 직접 이용하거나 js-csp 같은 라이브러리를 이용하여 Go의 모델을 따를 수 있었다. Java는 제너레이터를 지원하지 않는데… (5장에서 살펴본 쓰레드를 이용하는 제너레이터는 한계가 있다.)
  • Haskell도 빼먹을 수 없다. Haskell의 그 한 줄은 코루틴과 어떤 관계가 있길래 1,000,000번 줄을 출력할 수 있는 것일까?