[Python] 제너레이터와 코루틴

파이썬 제너레이터는 특별한 함수 객체로, yield 구문을 통해서 특정 값을 리턴한 후에도 제거되지 않고 방금 리턴한 그 자리에서부터 이어서 계산을 반복하고 다시 값을 내놓을 수 있다.

파이썬 2.5에서 제너레이터에 특별한 기능이 생겼는데, 바로 제너레이터 속으로 값을 전달하는 기능이다. (PEP342) 이는 매우 흥미로운 패턴으로 이어지게 되는데, 자신의 위치를 기억하고 있다가 다시 그 자리에서 실행이 가능한 제너레이터의 특성상, 두 개의 제너레이터가 번갈아가면서 제어권을 넘기는 형태의 flow를 생각할 수 있다. 이 때 두 제너레이터는 서브루틴과는 다른 개념으로 관계를 맺게 되고 이런 패턴을 코루틴이라 한다.

코루틴은 6~70년대에 기반이 닦여진 기술이나 이후 동시성 작업을 위한 새로운 기술들(스레드 등)이 나타나면서 거의 방치되고 있다가, 스레드가 야기할 수 있는 문제들 (자원경쟁이나, 데드락등의 문제로 인해 디버깅이 매우 까다롭다.) 때문에 새롭게 주목받고 있다.  이는 주 실행 흐름과는 독립적으로 운용가능한 함수가 존재한다는 뜻이며, 따라서 별도의 스레드 없이, 메인 스레드 상에서 번갈아가며 병렬처리와 유사한 동작을 수행할 수 있기 때문이다.

하지만 이런 특수한 상황이 아니더라도 제너레이터 혹은 코루틴은 파이썬 프로그래밍에서 매우 중요한 비중을 차지한다. 많은 경우에 제너레이터는 간단한 클래스를 대체할 수 있으며, 단위 작업을 제너레이터로 만들어서 일련의 작업에 대한 파이프라인을 구성하는 식으로 프로그램을 작성할 수 있다. 이 글에서는 제너레이터와 코루틴을 어떻게 만들고 활용할 수 있는지에 대해 살펴보도록 하겠다.

제너레이터

제너레이터는 유한 혹은 무한한 횟수만큼 어떤 값을 만들어 내놓는 객체이다. 제너레이터 함수는 이러한 제너레이터를 생성하는 함수로, 함수 내부에 yield 를 사용한다. yieldreturn 과 달리 값을 내놓은 후에도 함수 흐름이 종료되지 않고 중단된다. 다음 예는 주어진 n 값부터 1씩 감소하는 값을 만들어내는 제너레이터 함수이다.

def countdown(n):
    while n > 0:
        yield n
        n -= 1
for i in coutdown(5):
    print(i, end=" ")
### 5 4 3 2 1

제너레이터 함수를 호출하면 제너레이터 객체가 생성된다. 제너레이터 객체는 생성된 후에 바로 동작을 수행하지 않는다. 대신에 외부로부터 동작을 요청받으면 그 때부터 실행을 시작한다. 제너레이터 객체에 값을 요청하는 함수는 기본 함수 next() 이다. 이 함수로 넘겨진 제너레이터는 yield 문을 만날 때 까지 실행한 후 다시 멈추게 된다. (그리고 실행 흐름은 next()를 호출한쪽으로 돌아간다.)

다음은 tail -f 명령의 파이썬 구현이라 할 수 있다. 파일 핸들러를 받아서 파일의 끝으로 이동한 다음, 특정 주기마다 한 줄씩 읽어서 이를 내놓는다.

import time
def follow(thefile):
    thefile.seek(0, 2) #Go to the end of the file
    while True:
        line = thefile.readline()
        if not line:
            time.sleep(0.1)
            continue
        yield line

이 제너레이터를 이용해서 다음과 같이 웹서버 로그를 “지금 시점부터” 살펴볼 수 있다.

logfile = open('access-log')
for line in follow(logfile):
    print(line, end=' ')

이 코드는 다음과 같이 동작한다.

  1. 제너레이터를 생성하고, 한 라인의 문자열을 요청한다.
  2. 제너레이터는 맨 처음에 파일의 끝으로 이동한 다음 한 줄을 읽는다. 이 줄을 내놓는다.
  3. 내놓은 값은 메인루프에 의해 화면에 출력된다.
  4. 출력 후 메인루프의 끝에 다다르면 다시 제너레이터에게 다음 줄을 요청한다.
  5. 제너레이터는 새 줄을 읽어들인다. 만약 새 줄이 없으면 0.1초 동안 대기한 후 재시도한다. (이 과정이 없으면 CPU 사용량이 치솟는다.)
  6.  5의 과정을 반복하다가 새로운 라인이 들어오면 이를 내놓고 for 루프로 돌아간다.
  7. 이 과정은 명시적인 종료 지점이 없으므로 (외부 요인에 의한 예외가 발생하지 않는 이상) 계속 반복된다.

yield

파이썬 2.5에서 yield는 표현식의 기능을 겸하게 되었다. 이전까지 yield란 제너레이터가 어떤 루프 속에서 잠시 멈추면서 값을 내놓는지점을 설정하는 용도로 쓰였다. 그런데 표현식이 되었다는 것은 대입문의 우변이 될 수 있다는 말이며, 이는 곧, ‘값으로 평가된다’는 말이다. yield 문이 어떻게 값으로 평가될까? 알고보면 이는 매우 혁명적인 변화이다. 바로 외부에서 제너레이터로 어떤 값을 주입할 수 있고, yield는 재실행되는 시점에 입력값으로 평가된다. 이는 제너레이터가 단순히 일방적으로 정해진 규칙에 따라 값을 만들어내기 보다는 외부로부터 입력을 받는 함수(서브루틴)과 같이 동작할 수 있다는 것을 의미한다.

이런 관점에서 값을 입력 받을 수 있는 제너레이터를 코루틴이라고 부른다.

다음 예제를 보자.

def printer():
    ## 1
    while True:
        line = (yield)  ## 2
        print line,
prn = printer()

 

printer() 함수를 호출하여 코루틴을 생성했다. (실제로 이 제너레이터는 아무것도 내놓지 않으니 이 표현이 맞겠다.) 생성 직후의 코루틴의 실행위치는 #1 에 멈춰있다. 최초로 next() 를 호출하면 처음 yield 를 만나는 #2의 지점까지 실행된다. line = (yield) 를 보면 이는 단순한 바인딩 구문이다. 바인딩 구문은 등호의 우변을 먼저 평가한 후, 그 평가값을 좌변에 연결한다. 이 때 우변이 평가되는 시점에 코루틴이 한 번 멈추게 되고, 다시 실행될 때 외부에서 들여온 값으로 yield가 평가된다. 그리고 그 값이 line으로 들어간다.

제너레이터로 생성된 객체는 내부적으로 __next__(), send() 라는 두 개의 메소드를 갖게된다. __next__()next() 함수에 전달되었을 때 호출되는 메소드로 다음번 yield 구문까지 실행하라는 신호를 제너레이터에게 전달한다. send() 메소드는 __next__()와 거의 동일한데, 차이가 있다면 이 시점에서 코루틴 내부로 값을 밀어넣을 수 있다. 따라서 이렇게 만들어진 코루틴은 아래와 같이 사용한다.

next(prn) ## 첫번째 yield 까지 실행한 후
prn.send(1) ## 1이라는 값을 코루틴으로 밀어넣는다.
1 
## prn은 내부에서 print()문을 실행하고 다시 `yield` 위치까지 실행된다.

주의해야 할 사항은 코루틴은 제너레이터와 달리 생성 후에 무조건 next()를 한 번 실행해서, 값을 받을 수 있는 상태로 만들어주어야 한다는 것이다. 보통은 이 과정을 빼먹기 쉬우므로 시작되지 않은 제너레이터에 send를 호출했다는 에러를 만나는 실수를 범하기 쉽다. 코루틴은 생성 직후 무조건 next()를 한 번 호출해야 하니, 이 과정을 자동으로 처리할 수 있도록 데코레이터를 만들어두는 편이 편하겠다.

from functools import wraps

def coroutine(func):
    @wraps(func)
    def start(*args, **kwarg):
        cr = func(*args, **kwargs)
        cr.next()
        return cr
    return start

이제 다음과 같이 바로 사용하는 것이 문제 없다.

>> prn = printer()
>> prn.send('hello world')
hello world
>>>

 

파이프라인

코루틴으로 값을 밀어넣는 방법을 알게 되었으니 제너레이터/코루틴을 서로 연결하는 것을 만들어보자. 위에서 작성한 제너레이터 follow를 다음과 같이 수정하고 테스트해보자.

def follow(thefile, target=None):
    ## target은 여기서 생성한 결과를 사용할 컨슈머 코루틴이다.
    thefile.seek(0, 2) #Go to the end of the file
    while True:
        line = thefile.readline()
        if not line:
            time.sleep(0.1)
            continue
        ## target이 있다면 라인을 타겟에게 넘기고
        ## 그렇지 않다면 반환한다.
        if target:
          yield target.send(line)
        else:
          yield line

## tail -f | grep python
logfile = open('access-log')
loglines = follow(logfile, prn)
for _ in loglines:
  pass

수정된 코드에 대한 설명이다.

  1.  follow 의 기본 동작은 기존과 동일하다. 대신 타깃 코루틴이 있으면 단순히 yield 로 값을 출력하는 대신에 타깃에게 값을 전달한다.
  2. 그리고 follow(logfile, prn)과 같이 획득되는 로그 파일의 각 라인을 프린터 객체에게 넘겨주도록 설정한다.
  3. for _ in loglines: pass 는 아무일도 하지 않는 것 같지만, 매 라인이 들어오면 그 라인을 prn 에게 넘겨주기 때문에 이 코드는 기존과 똑같이 실행된다.

파이프라인을 구성하는 것의 장점은 간단한 코루틴을 라인의 중간에 삽입하여 기존 코드를 수정하지 않고 실행 흐름을 조작할 수 있다는 것이다. 만약 서버 로그의 각 라인에 python이라는 단어가 들어간 라인만 출력한다고 해보자. 그냥 함수를 쓴다면 follow()의 코드를 수정해야 겠지만, 이런 코루틴을 생각해보자.

@coroutine
def grepper(word, target=None):
  line = yield
  while True:
    if word in line:
      if target:
        line = yield target.send(line)
      else:
        line = yield line

이 코루틴은 처음에 지정한 단어가 매 입력에 있으면 이를 내놓거나 타깃으로 전달한다. 그렇지 않은 경우에는 무시해버리는 기능을 수행한다. 이 코루틴이 있다면 파이프 라인을 다음과 같이 만들 수 있다.

follow[매라인입력] --> grepper('python') --> printer()

실제로 코드는 이 부분만 수정한다.

prn = printer()
## 수정
grep = grepper('python', prn)
log_lines = follow(log_file, grep)

for _ in log_lines:
  pass

브로드캐스팅

 

브로드캐스팅(Braodcasting)은 하나의 입력을 다수의 타깃에게 전달하는 것을 말한다. 조금 전에 우리는 파이프라이닝을 통해서 특정 로그를 추출하는 제너레이터와, 단어를 필터링하는 코루틴, 그리고 입력을 출력해주는 코루틴을 작성해보았다.

만약 필터링하고자 하는 단어를 여러 개 사용한다면 어떨까? 물론 grepper의 코드를 변경해서 필터 단어 하나가 아닌 단어 목록으로 받게끔 할 수도 있겠다. 대신에 메시지를 브로드캐스팅할 수 있는 코루틴이 있다면,  단지 여러 개의 grepper를 만들어서 메시지를 퍼뜨리면 되지 않을까?

@coroutine
def broadcast(targets):
  while True:
    msg = yield
    for t in targets:
      t.send(msg)

## apple, python, banana로 필터링하기
## 파이프라인 구성은 다음과 같다.
follow -> caster -> grep 'apple' -> printer()
                 \> grep 'python' /
                 \> grep 'banana' /

prn = printer()
keywords = ('apple', 'python', 'banana')
filters = [grepper(keyword, prn) for keyword in keywords]
caster = broadcast(filters)
for _ in follow(log_file, caster):
  pass

코루틴 vs 객체

코루틴은 하나의 작업 단위를 처리하여 다음 코루틴으로 연결하는 프로세스 파이프를 구성하는 단위를 만들기에 적합하다. 물론 위에서 설명한 이 모든 구현은 코루틴을 쓰는 대신, 각 코루틴을 클래스로 정의하여 (객체 인스턴스는 상태를 유지할 수 있으므로) 구현할 수도 있다. 단, 코루틴과 객체를 사용한 구현은 다음과 같은 차이를 보인다.

  1. 객체의 경우, 클래스를 개별적으로 정의해야 한다. 클래스에서는 최소 2개의 메소드를 정의할 필요가 있다. 이에 비해 코루틴은 하나의 함수만 정의하면 된다. 즉, 코드 수가 현저히 적어진다.
  2. 파이프라인을 구성하는 경우 객체로 정의하는 경우에는 self.target의 메소드를 출하기 위해 self를 lookup 해야 한다. target으로 메시지가 전달된 경우에 해당 타깃 객체 역시 자신의 보유 기능을 실행하기 위해서는 마찬가지로 self lookup이 필요하다. 이에 따른 시간 지연을 감안하면 코루틴 구현의 성능이 조금 더 낫다. (프로세스 파이프가 단순 기능의 연결이라는 점에서 이는 체감 가능한 수준의 성능차를 보일 수도 있다.)

보너스 : 비동기 코루틴

파일로부터 한 줄씩 읽어서 출력하는 코루틴 A가 있다고 하자. 그런데 파일에서 한 줄의 텍스트를 읽는데에는 사정상(?) 0.1 초가 걸린다. 그런데 출력해야 할 파일이 100개라면, 100개의 파일에 대해서 한줄씩 번갈아가면서 읽고, 출력하고를 반복해야 한다. 첫줄을 출력하는데 10초가 걸릴 것이며, 이 때 대부분의 시간은 file.readline()을 호출했을 때의 지연시간이다.

그런데 파일 액세스를 하는 0.1 초는 CPU에게는 매우 긴 시간이다. 그러니 첫 줄을 모두 출력하는 사이클동안 CPU는 대부분의 시간을 파일을 읽는데 소비한다. 이 때 시간 흐름은 다음과 같다.

f[0].readline() -> (0.1초) -> print -> f[1].readline() -> (0.1초) -> print() -> ....

비동기 코루틴은 메인스레드가 이런 ‘대기시간’동안 쉬면서 기다리지 않고 다른 처리 가능한 일을 먼저 처리하도록 하는 것이다.

f[0].readline() ->
  f[1].readline() ->
     f[2].realine() ->
      ...
| 출력은 먼저 읽은 것 부터 
print() ## 첫번째 파일의 첫줄 출력
print() ## 두번째 파일의 첫줄 출력
print() ## 열 세번째 파일의 첫줄 출력
      ...f[78].readline() ->

이렇게되면 마법처럼 전체 수행시간이 줄어든다. 0.1초만 기다리면 100줄을 출력할 수 있다. 0.1 초씩 100번 기다리는게 아니라 100개의 파일읽기 작업을 한꺼번에 기다리는 것이다. 이는 비동기 코루틴에 의해서 실현할 수 있고 (Python 3.4 부터 지원한다.) 읽는 동안 잠시 멈추었다가 다시 활성화되는 코루틴의 특징을 잘 활용하는 것이다.

  • asyncio 문서 찾다가 우연히 들리게 됬는데 좋은글들이 많네요.!!
    감사히 정독하겠습니다. 감사합니다.

  • Luke Lee

    좋은글 감사합니다.

  • KIJEONG

    써주신 코드로 재현이 잘 안되네요… 초짜라…