Computer Engineering/Fluent Python 정리

Fluent Python Chapter 16. 코루틴 (Coroutine)

jordan.bae 2022. 3. 18. 18:18

Introduction

16장에서는 주로 Coroutine에 대한 chapter입니다. 책에서는 Coroutine의 개념에 대해서 자세히 소개하기 보다는 어떻게 동작하는지 어떻게 구현하는지에 대해서 집중해서 설명합니다. 그래서 첨언을 넣자면 Coroutine은 Subroutine(일반 함수, 호출되면 return될 때까지 실행이된다.)과는 다르게 여러개의 진입점을 가지고 함수의 중간에 제어를 다시 호출자에게 넘기고, 호출자에게 다시 제어를 받아서 중간  부터 다시 일행 할 수 있습니다. 이런 특징이 있기 때문에 멀티 태스킹 협업을 구현하는데 많이 사용되고 있습니다. 파이썬에서는 yield 구문을 이용해서 coroutine을 만들 수 있는데 yield 구문이 어떻게 진화해왔는지와 어떻게 사용하는지에 대해서 살펴볼 것 입니다. 

 

책 정리를 시작한 이유

책 정리를 시작한 이유

Chapter1의 Introduction 부분에서 이야기 한 것처럼 지난 5년간 다양한 언어나 프레임워크 및 프로그램을 공부하고 이용하여 소프트웨어를 개발했는데 이것저것 하다 보니 자주 사용하는 언어임에도 불구하고 파이썬을 잘 활용하고 있느냐에 대한 답변을 자신 있게 하기 어렵다고 느껴서 Fluent Python이라는 책을 공부하며 정리하고 있습니다. 올해에는 새로운 기술들도 좋지만 기존에 활용하던 언어나 프레임워크 그리고 소프트웨어를 더 잘 사용할 수 있도록 깊게 공부할 수 있는 한 해를 만들고 싶은 소망을 가지고 있습니다. 21장까지 정리를 성공하고 맛있는 걸 먹으면서 스스로 축하할 날이 어서 왔으면 좋겠네요! 혹시, 제가 정리한 글이 괜찮으시면 구독 하시면 다음편이 나오면 바로 알림을 받으실 수 있습니다.

 

지난 chapter 정리한 포스팅

Fluent Python Chapter 1. 파이썬 데이터 모델 (Feat. 일관성 있는 언어)

Fluent Python Chapter 2-1. Sequence (Sequence의 분류, listcomp, generator, tuple, slicing

Fluent Python Chapter 2-2. Sequence (bisect, deque, memoryview)

Fluent Python Chapter 3. Dictionary (feat. hashtable)

Fluent Python Chapter 4. 텍스트와 바이트 (feat. 깨지지마라..)

Fluent Python Chapter 5. 일급 함수

Fluent Python Chapter 6. 일급 함수 디자인 패턴

Fluent Python Chapter 7. 함수 데커레이터와 클로저 (feat. 메타프로그래밍)

Fluent Python Chapter 8. 객체 잠조, 가변성, 재활용

Fluent Python Chapter 9. 파이썬스러운 객체

Fluent Python Chapter 10. 시퀀스 해킹, 해시, 슬라이스

Fluent Python Chapter 11. 인터페이스: 프로토콜에서 ABC까지

Fluent Python Chapter 12. 내장 자료형 상속과 다중 상속

Fluent Python Chapter 13. 연산자 오버로딩(feat. 제대로 하기)

Fluent Python Chapter 14. 반복형, 반복자, 제너레이터

Fluent Python Chapter 15. Context manager와 else 블록

 

 

Generate에서 Corutine으로의 진화

전 chapter에서 yield 구문을 사용해서 generate를 구현 했는데, coroutine도 yield 구문을 이용해서 구현한다고 말했다. generate가 어떻게 corutine으로도 사용할 수 있게 됐는지 살펴보자.

파이썬 2.5에서 PEP 342- 향상된 제너레이터를 통한 코루틴 제안서가 통관되면서 send() 메서드가 Generator의 API에 추가되었다. send() 메서드에서 coroutine을 호출하는 호출자에서 데이터를 보낼 수 있게 되면서 서로 제어를 주고 받을 수 있게 되었다. 또한, throw(), close() 메서드가 추가되었다.

- throw(): Generator 내부에서 처리할 예외를 호출자가 발생시킬 수 있게 하는 메서드

- close(): Generator를 종료시킴.

 

이후에 파이썬 3.3에서는 PEP 380 - 하위 제너레이터에 위임하기 위한 구문 제안서를 통해 

yield from 구문이 추가되고 Generator가 값을 반환할 수 있게 되었다.

 

 

Coroutine으로 사용되는 Generator의 기본 동작

함수 내에서 yield구문을 가지고 있으면 Generator이기 때문에 Coroutine도 Generator의 하나의 종류라고 할 수 있습니다.

코드를 보면서 코루틴의 동작방식을 살펴보겠습니다.

import inspect


def simple_coroutine():
    print("coroutine start")
    x = yield
    print("coroutine received:",x)
    
my_coro = simple_coroutine()

# 코루틴의 상태를 확인할 수 있는 함수.
inspect.getgeneratorstate(my_coro)
'GEN_CREATED'

# yield문까지 실행시켜서 데이터를 전송할 수 있는 상태를 만든다.
# 코루틴을 기동(priming)한다고도 표현한다.
next(my_coro)
coroutine start

inspect.getgeneratorstate(my_coro)
'GEN_SUSPENDED'

# yield의 값으로 17이 전달되고 다음 yield문까지 실행시키려고 했으나 없어서 StopIteration이 발생.
my_coro.send(17)
coroutine received: 17
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Input In [10], in <module>
----> 1 my_coro.send(17)

StopIteration:

# 상태는 coroutine이 종료된것을 확인할 수 있음.
inspect.getgeneratorstate(my_coro)
'GEN_CLOSED'

중요한 부분은 yield 구문까지 실행된 후에 기다리고 send 메서드를 통해 받은 값을 yield의 값으로 사용해서 x에 할당하는 부분이다.

 

아래과 같은 함수 있으면 다음과 같이 나눠진다. 첫 번째 빨간색 라인이 next(generator instance)를 실행했을 때고 generator_instance.send(x)를 실행했을 때는 그 다음 빨간색 라인 부분까지 실행이된다.

 

 

Coroutine을 이용해서 Context를 유지하면서 이동 평균 계산하기

전 Chapter에서 closure를 통해서 함수에 free variable을 할당해 이동 평균을 구할 때 매번 sum을 다시 구하는 부분을 개선했었다.

Coroutine을 이용해서 효율적으로 구현할 수 있다. 왜냐하면 total, count를 지역 변수로 사용할 수 있기 때문이다.

def averager():
    total = 0.0
    count = 0
    average = None
    while True:  # <1>
        term = yield average  # <2>
        total += term
        count += 1
        average = total/count
        
coro_averager = averager()
# None 반환
next(coro_averager)

coro_averager.send(10)
10.0

coro_averager.send(30)
20.0

 

 

코루틴을 기동하기 위한 Decorator

위에 예제들에서 살펴본 것 처럼 coroutine으로 활용하기 위해서는 기동(next 호출)단계가 필요합니다. coroutine을 편리하게 사용할 수 있도록 decoartor를 사용하면 기동 부분을 생략할 수 있습니다.

 

from functools import wraps

def coroutine(func):
    """Decorator: primes `func` by advancing to first `yield`"""
    @wraps(func)
    def primer(*args,**kwargs):  # <1>
        gen = func(*args,**kwargs)  # <2>
        next(gen)  # <3>
        return gen  # <4>
    return primer
 
@coroutine  # <5>
def averager():  # <6>
    total = 0.0
    count = 0
    average = None
    while True:
        term = yield average
        total += term
        count += 1
        average = total/count

 

<3> 코드에서 next를 미리 호출 한 후에 generator 객체를 반환하는 것을 확인 할 수 있다.

 

 

코루틴 종료과 예외 처리

위의 이동 평균을 구하는 코루틴에 예외가 발생할 수 있다. 예를 들면 send 메서드의 인자로 글자가 들어간다고 생각해보자.

coro_avg = averager()

coro_avg.send(10)
10.0

coro_avg.send('text')
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [24], in <module>
----> 1 coro_avg.send('text')

Input In [20], in averager()
     17 while True:
     18     term = yield average
---> 19     total += term
     20     count += 1
     21     average = total/count

TypeError: unsupported operand type(s) for +=: 'float' and 'str'

inspect.getgeneratorstate(coro_avg)
'GEN_CLOSED'

coro_avg.send(10)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Input In [26], in <module>
----> 1 coro_avg.send(10)

StopIteration:

예외가 발생해서 coroutine이 종료된 것을 확인할 수 있다.  예외를 사전에 핸들링해서 던져 줄 수 있다.

coro_avg = averager()

coro_avg.send(10)
received: 10
[output]10.0

# coroutine이 종료되지 않음.
coro_avg.throw(TypeError)
type error: data should be number

coro_avg.send(15)
received: 15
[output]12.5

# corutine 종료
coro_avg.close()

그리고 위에서 확인한 것 처럼 close()메서드를 이용해서 coroutine을 종료할 수 있다.

 

 

코루틴 값 반환

위에서 이야기 한 것 처럼 파이썬 3.3에서 코루틴의 값을 반환할 수 있도록 업데이트 되었다. 예를 들면, 위에서 매번 데이터를 보낼 때마다 평균 값을 보내지 않고 최종 평균 값만 필요한 케이스를 고려해보면 반환 값이 필요할 수 있다. yield는 None을 return할 것이다 마지막에 return문이 존재할 것 이다.

from collections import namedtuple

Result = namedtuple('Result', 'count average')


def averager():
    total = 0.0
    count = 0
    average = None
    while True:
        term = yield
        if term is None:
            break  # <1>
        total += term
        count += 1
        average = total/count
    return Result(count, average)  # <2>

위의 코루틴을 호출자에서 None을 보내면 while문을 빠져나와서 return하고 다음 yield문이 없으므로 StopIteration이 발생할 것 이다.

my_coro3 = averager()

next(my_coro3)
# None을 return
my_coro3.send(10)
my_coro3.send(10)
my_coro3.send(10)

# 예외의 속성에 반환값이 포함. 
my_coro3.send(None)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Input In [45], in <module>
----> 1 my_coro3.send(None)

StopIteration: Result(count=3, average=10.0)

yield from은 해당 StopIteration 예외를 처리하고 반환값을 전달한다. for문하고 예외를 처리하는 방법은 비슷하다.

 

 

yield from

yield from을 정확히 이해하는 것은 쉽지 않다. 하지만, corotine을 이용해서 비동기 작업을 구현하거나 할 때 아주 중요한 개념이기 때문에 반복해서 공부하여 이해하는 것이 좋다고 생각한다.

 

전 chapter에서 yield from이 for 루프 안의 yield에 대한 단축문으로 사용이 가능하다고 설명했다.

def chain(*iterables):
    for it in iterables:
        for i in it:
            yield i


list(chain([1,2,3], 'as'))
[1, 2, 3, 'a', 's']


def chain2(*iterables):
    for i in iterables:
        yield from i
 

list(chain2([1,2,3], 'as'))
[1, 2, 3, 'a', 's']

yield from에서 for 문과 비슷하게 StopIteration을 처리해주는 부분이 있기 때문에 가능한 부분이다. 하지만 이것은 yield from의 중요한 부분은 아니다. 중요한 부분은 가장 바깥쪽 호출자와 가장 안쪽에 있는 하위 generator 사이에 양방향 채널을 영어준다는 것이다. 바깥쪽 호출자와 하위 Generator가 직접 값을 주고 받기 때문에 중간 generator가 예외 처리 코드를 구현할 필요가 없다. 

 

PEP 380에는 주요 용어가 잘 정리되어 있다.

 

위임 제너레이터 delegating generator
yield from <반복형> 을 가지고 있는 제너레이터 함수


하위 제너레이터 subgenerator
yield from <반복형> 에서 <반복형>에서 가져오는 제너레이터.


호출자 caller
위임 제너레이터를 호출하는 코드. '호출' 또는 '클라이언트'라고 부름.

 

조금 긴 코드지만 코드를 보면서 각 부분을 살펴보겠습니다.

from collections import namedtuple

Result = namedtuple('Result', 'count average')


# the subgenerator
def averager():  # <1>
    total = 0.0
    count = 0
    average = None
    while True:
        term = yield  # <2>
        if term is None:  # <3>
            break
        total += term
        count += 1
        average = total/count
    return Result(count, average)  # <4>


# the delegating generator
def grouper(results, key):  # <5>
    while True:  # <6>
        results[key] = yield from averager()  # <7>


# the client code, a.k.a. the caller
def main(data):  # <8>
    results = {}
    for key, values in data.items():
        group = grouper(results, key)  # <9>
        next(group)  # <10>
        for value in values:
            group.send(value)  # <11>
        group.send(None)  # important! <12>

    # print(results)  # uncomment to debug
    report(results)


# output report
def report(results):
    for key, result in sorted(results.items()):
        group, unit = key.split(';')
        print('{:2} {:5} averaging {:.2f}{}'.format(
              result.count, group, result.average, unit))


data = {
    'girls;kg':
        [40.9, 38.5, 44.3, 42.2, 45.2, 41.7, 44.5, 38.0, 40.6, 44.5],
    'girls;m':
        [1.6, 1.51, 1.4, 1.3, 1.41, 1.39, 1.33, 1.46, 1.45, 1.43],
    'boys;kg':
        [39.0, 40.8, 43.2, 40.8, 43.1, 38.6, 41.4, 40.6, 36.3],
    'boys;m':
        [1.38, 1.5, 1.32, 1.25, 1.37, 1.48, 1.25, 1.49, 1.46],
}


if __name__ == '__main__':
    main(data)

 

위에 써져있는 것 처럼 main 함수는 호출자로 grouper 제너레이터 함수를 이용해서 generator를 만들고 데이터를 전송해서 통신합니다. 이 때 grouper는 데이터를 하위 제너레이터인 averager()에게 전달하는 통로 역할만 하게 됩니다. 

 

results[key] = yield from averager() # <7>

위의 라인에서 yeid from은 generator를 실행 시키다가 마지막에 None을 받아서 StopIteration 예외를 처리하고 반환값을 result[key]에 전달합니다. 그 이후에 새로운 grouper() 객체가 생성되면 group 변수에 바인딩되면 더 이상 기존 grouper()객체는 참조하는 곳이 없어서 가비지 컬렉트 되게 됩니다.

 

코루틴 사용 사례: 이산 이벤트 시물레이션

귀도 반 로섬, 필립 J 이바이

코루틴은 시물레이션, 게임, 비동기 입출력, 그 외 이벤트 주도 프로그래밍이나 협업적 멀티태스킹 등의 알고리즘을 자연스럽게 표현한다.

https://github.com/fluentpython/example-code/blob/master/16-coroutine/taxi_sim0.py

 

GitHub - fluentpython/example-code: Example code for the book Fluent Python, 1st Edition (O'Reilly, 2015)

Example code for the book Fluent Python, 1st Edition (O'Reilly, 2015) - GitHub - fluentpython/example-code: Example code for the book Fluent Python, 1st Edition (O'Reilly, 2015)

github.com

위의 예제에서는 여러대의 택시가 출발/승차/하차/복귀 등의 이벤트를 시간 순서대로 발생시킵니다. 각 택시는 Coroutine으로 구현이 되어 있기 때문에 각 이벤트 후에 제어를 호출자에게 넘기고 호출자는 다음 이벤트를 확인해서 코루틴에게 다시 제어를 넘겨서 동시에 여러 대의 택시의 이벤트들을 처리할 수 있게 됩니다.  핵심 loop 인 Simulator의 run 메서드를 직접 코드를 따라서 쳐보시면서 이해해시는 것을 추천드립니다.

 

정리

이번 장은 코루틴에 대해서 살펴봤습니다. 동시성 프로그래밍이나 비동기 작업을 지원하기 위한 하나의 방법으로 코루틴은 자주 사용하는 프로그래밍 방법입니다. Corutine을 통해서 컨텍스트를 유지하면서 클라이언트와 통신할 수도 있고 main routine에서 여러 coroutine들과 협업할 수 있게 합니다. 

 

Reference

- Fluent Python Chapter 16

- https://github.com/fluentpython/example-code/tree/master/16-coroutine

 

GitHub - fluentpython/example-code: Example code for the book Fluent Python, 1st Edition (O'Reilly, 2015)

Example code for the book Fluent Python, 1st Edition (O'Reilly, 2015) - GitHub - fluentpython/example-code: Example code for the book Fluent Python, 1st Edition (O'Reilly, 2015)

github.com

 

반응형