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

파이썬 제너레이터와 코루틴

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

따라서 제너레이터는 스택영역에 쌓이지 않고 별도의 스택을 만들어서 동작하(는 것으로 보이는)는 함수이다.

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

코루틴은 6~70년대에 기반이 닦여진 기술이나 이후 동시성 작업을 위한 새로운 기술들(스레드 등)이 나타나면서 거의 방치되고 있다가, 스레드가 야기할 수 있는 문제들 (자원경쟁이나, 데드락, 동기화 문제등2)때문에 새롭게 주목받고 있다. 특히 IO와 관련한 문제를 코루틴을 사용하여 처리하면 보다 깔끔하고 구현하기 쉬운 코드가 나오게 된다.

아무튼 제너레이터에 달린 이 기능으로 인해 코루틴에 대한 관심이 90년대에 되살아 났고, 파이썬 3.4에서는 asyncio 모듈에서 코루틴을 사용하여 동시성 작업을 처리하도록 하는 기능이 기본으로 내장되었다.

제너레이터

제너레이터는 일련의 값을 생성해내는 함수이다.

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

값을 리턴하는 대신, 이 함수는 yield 구문을 이용하여 값을 생성해낸다. 보통은 이를 루프에 연결해서 많이 사용한다.

제너레이터 함수는 엄밀히, 제너레이터 객체를 리턴한다. 그리고 이 객체는 자동으로 실행되지 않는다. .next()가 호출되면 그제서야 실행을 시작한다. 그리고 제너레이터가 리턴을 하게되면 순회가 끝난다. 순회가 끝난 제너레이터를 다시 실행하려고 하면 StopIteration 예외가 발생한다. (for 루프는 이 예외가 발생할 때까지 제너레이터를 돌리게 된다.)

사용예 1)

다음은 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,

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

  1. 파일을 열과 루프를 시작한다. 이 루프는 제너레이터가 내놓는 값을 순회한다.
  2. 제너레이터는 맨 처음에 파일의 끝으로 이동한 다음 한 줄을 읽는다. 이 줄을 내놓는다.
  3. 내놓은 값은 메인루프에 의해 화면에 출력된다.
  4. 메인루프의 끝에 다다르면 제너레이터로 컨트롤이 넘어간다.
  5. 제너레이터는 새 줄을 읽어들인다. 만약 새 줄이 없으면 0.1초 동안 잔다. 이 과정이 없으면 CPU 사용량이 치솟는다.
  6. 그러다 어느 순간 새 줄을 만나면 3으로 이동한다.

파이프라인

제너레이터의 가장 강력한 기능은 제너레이터 자체를 프로세스 파이프라인으로 만들 수 있다는 것이다. 이는 유닉스의 파이프 개념과 유사한데,

입력값 --> 제너레이터 --> 제너레이터 --> 제너레이터 --> for x in s:

일련의 제너레이터를 쌓아두고 이를 for 루프 내에서 값을 넣고 빼는데 사용할 수 있다.

파이프라인 예제

서버 로그에서 python이 들어간 라인을 모두 출력해보자.

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

def grep(pattern, lines):
    for line in lines:
        if pattern in line:
            yield line

## tail -f | grep python
logfile = open('access-log')
loglines = follow(logfile)
pylines = grep("python", loglines)
for line in pylines:
    print line,

이 루틴을 다시 잘 따라가보자.

  1. 먼저 파일을 열고 이 파일 핸들러로 follow 제너레이터를 만든다.
  2. 그리고 다시 follow로부터 pylines 제너레이터를 만든다.
  3. 이 때가지 제너레이터는 별도의 동작을 하지 않는다.
  4. 메인 루프에 진입한다. pylines에 라인을 요청한다.
  5. pylines는 그 내부의 루프로부터 follow에서 라인을 뽑아오려한다.
  6. follow는 맨처음에 파일의 맨 끝으로 가서 라인을 내보낸다.
  7. 제어권은 다시 grep으로 돌아오고 패턴이 있는지 검사한다.
  8. 패턴이 없으면 다시 grep의 루프가 한바퀴 완료되고 제어권은 follow로 넘어간다.
  9. 다시 5를 진행한다. 만약 한 줄이 추가로 더 들어오고 이 때 패턴에 매치되는 문자열이 있다면 그 문자열을 메인 루프로 내놓는다.
  10. 메인 루프는 해당 문자열을 출력하고 다시 pyline에 다음 라인을 요청, pyline은 이어서 다음 라인 입력을 기다린다.
    이런식으로 돌아가게 된다. 조금 복잡해 보이는 이 과정을 잘 이해하지 못하면 위 간단한 코드로 구현된 tail -f 기능이 마법처럼 보일 수 있다.

yield

파이썬 2.5에서 yield는 표현식의 기능을 겸하게 되었는데, 이는 값을 내놓는 것이 아니라 제너레이터로 보내진 값을 받는 형태로 변경되는 것을 의미한다. 이를 테면 다음과 같은 코드를 생각해볼 수 있겠다.

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

위 코드에서 prnyield 문법을 사용하여 생성하는 제너레이터3이다. 제너레이터의 일종이기 때문에 이 객체는 생성된 직후에는 아무런 일도 하지 않는다. 컨슈머 객체는 최초에 .send(None) 값을 받을 때 함수의 첫 라인부터 line = (yield)를 만날 때까지 실행된다. 이 부분은 .next()를 호출하는 것과 동일하다. 즉, 제너레이터든, 컨슈머든 최초에 .next()를 한 번 호출해서 실행을 시작해 주어야 한다.

이 과정을 빼먹기 쉬우므로 아예 데코레이터로 만들어 두는 편이 좋겠다.

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

그러면 다음과 같은 코드로 사용하면 된다.

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

방금 만들어본 tail -f를 코루틴을 사용하여 구현해보자. 이는 실질적으로 파이프라인을 구성하는 것이다. 이는 다음과 같이 그려진다.

파일입력 --> follow --> grep --> printer 

이 때 코루틴은 grep, printer 두 개의 함수이며, grep은 입력을 처리하여 printer로 값을 보내고, printer는 이 값을 출력한다.4

제너레이터 닫기

제너레이터는 리턴할 때 GeneratorExit 예외를 일으킨다. 따라서 코루틴을 만드는 함수에서는 이 예외를 잡아서 리소스 정리등의 작업을 해야한다. 이 예외는 비켜갈 수 없다.

프린터 코루틴은 다음과 같이 정리된다.

@coroutine
def printer():
    print "Open a new printer"
    try:
        while True:
            line = (yield)
            print line,
    except GeneratorExit:
        print "Closing..."

이번에는 들어온 라인을 필터링 하는 grep 함수를 보자. 이 함수는 메시지를 전달할 다음 타깃을 인자로 받는다.

@coroutine
def grep(pattern, target):
    print "Looking for %s" % pattern
    try:
        while True:
            line = (yield)
            if pattern in line:
                target.send(line)
    except GeneratorExit:
        print "Going away..."

이제 follow 함수를 보자. 이제 follow는 더 이상 제너레이터일 필요가 없다. (값의 소진을 메인 루프가 하는 것이 아니라 파이프라인으로 밀어넣는 것이다.) 또 yield 구문을 쓰지 않기 때문에 제너레이터나 코루틴도 아니다.

def follow(theFile, target):
    theFile.seek(0, 2)
    while True:
        line = theFile.readline()
        if not line:
            time.sleep(0.1)
            continue
        elif ":exit:" in line:
            print "meet :exit:"
            return
        target.send(line)

이제 이 함수들을 이용해서 파이프라인을 건설해보자. 이 과정은 다분히 함수형 언어 스타일을 따른다.

if __name___ == "__main__":
    import sys
    with open(sys.argv[1], 'r') as f:
        follow(f, grep('python', printer()))

어떤가 훨씬 깔끔하지 않은가?

중간요약

  • 제너레이터와 코루틴은 yield 문법 및 next() 등으로 시동을 걸어야 한다는 점등에서는 유사하지만 그 컨셉은 완전히 다르다.
  • 제너레이터를 사용하는 것은 루프를 통해서 제너레이터로부터 값을 뽑아내는 것이다.
  • 반대로 코루틴은 send()를 통해 값을 밀어넣어서 사용한다.

브로드캐스팅

코루틴을 이용하면 브로드캐스팅도 쉽게 구현할 수 있다. 예를 들어서 입력 받은 라인을 각각 다른 패턴으로 찾아서 출력하려고 한다면 위 코드에서 새로운 코루틴을 하나 추가하면 된다.

@coroutine
def broadcast(targets):
    '''
    broadcast message to all targets
    :type targets: list
    '''
    print "Start broadcasting..."
    try:
        while True:
            message = (yield)
            for target in targets:
                target.send(message)
    except GeneratorExit:
        print "Exit broadcasting..."

이제 다음과 같이 고치면…

if __name__ == "__main__":
    import sys
    logfile = open(sys.argv[1], 'r')
    follow(logfile, broadcast([
        grep('python', printer()),
        grep('hello', printer()),
        grep('ply', printer())
    ]))

가 되는데, 출력하는 싱크는 사실 여러 개일 필요가 없으므로

if __name__ == "__main__":
    import sys
    logfile = open(sys.argv[1], 'r')
    prn = printer()
    follow(logfile, broadcast([
        grep('python', prn),
        grep('hello', prn),
        grep('ply', prn)
    ]))

과 같이 간단히 쓸 수 있다.

코루틴 vs 객체

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

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

  1. raiseStopIteraion을 낼 때까지 반복되며, 이를 이용해서 이터레이터와 비슷하게 for 루프에 사용될 수 있다. 
  2. 유의하여 사용하면 피할 수 있는 것들이라고는 하나, 문제가 터졌을 때 이러한 concurrency 관련 버그는 디버깅이 극히 어렵다. 
  3. 실제로 이 객체는 기본적으로 제너레이터처럼 동작하지만, 값을 받아서 소비하는 컨슈머에 해당한다. 
  4. 배수대라는 의미로 파이프라인을 구성하는 각 프로세스는 최종적으로 데이터를 다른 곳에 전달하지 않고 소모하는 종점이 있어야 한다.(보통은 파일디스크립터나 표준출력이된다.) 이 종점을 싱크라고 한다. 
  5. 클래스를 정의하는 경우, __init__과 각 객체의 담당 기능을 구현하는 함수, 두 개를 따로 정의해야 한다.