Iterator, Generator, Coroutine(코루틴)의 이해
파이썬에서 비동기 방식을 구현하기 위해서는 코루틴을 먼저 이해해야 한다.
그러나 코루틴의 저변에는 Iterator개념이 깔려 있기 때문에 Iterator와 Generator 객체를 함께 알아보고자 한다.
루틴(routine)과 서브루틴(subroutine)
함수가 정의되어 있다면 sub_rout()을 통해 함수를 호출할 수 있다. 이때 함수를 호출하는 쪽을 루틴이라고 부르고 호출된 함수(여기서는 sub_rout)를 서브 루틴이라고 부른다. 메인 루틴이 서브 루틴을 호출한 경우 서브 루틴이 결과를 반환할 때까지 루틴은 반환 값을 기다린다. 서브루틴이 종료되면 실행의 흐름(제어권)이 원래의 메인 루틴으로 돌아오며 이때 서브루틴이 리턴한 값을 루틴에서 사용하게 된다. 이렇게 순차적 처리 방식인 동기 방식으로 파이썬은 동작한다.
def sub_rout(a, b):
return a + b
rout = sub_rout(3, 4)
print(rout)
이와 같은 작동 방식은 한 번에 하나의 작업(함수)만 처리할 수 있다. 하지만 하나의 작업을 잠시 중단하고, 다른 작업을 실행하면서 서로 번갈아 작업을 처리하기 위한 방식으로 코루틴 개념이 등장하였다.
코루틴: 일시중단(suspend)과 재개(resume)를 할 수 있는 서브 루틴
코루틴에서 'Co'는 with 또는 together를 뜻한다. 즉, 메인 루틴과 서브 루틴이 서로 대등한 관계라는 것이다. 그래서 코루틴은 메인 루틴과 서로를 호출하면서 제어권(실행 권한)을 주고 받는다. 코루틴은 특정 시점에 자신의 실행 작업을 일시적으로 관련된 현재 상태를 어딘가에 저장한 뒤, 나중에 다시 실행을 재개할 수 있는 서브 루틴이다.
코루틴은 함수가 종료되지 않은 상태에서 메인 루틴의 코드를 실행한 뒤 다시 돌아와서 코루틴의 코드를 실행한다. 일반 함수를 호출하면 코드를 한 번만 실행할 수 있지만, 코루틴은 코드를 여러 번 실행할 수 있다. 참고로 함수의 코드를 실행하는 지점을 진입점(entry point)이라고 하는데, 코루틴은 진입점이 여러 개인 함수이다.
Iterator: 필요한 시점마다 값을 순차적으로 호출할 수 있는 객체
코루틴을 이해하려면 Iterator 객체부터 알면 도움이 된다. 이터레이터는 값을 순차적으로 꺼낼 수 있는 객체다. [1,2,3]과 같은 list를 정의하면 3개의 원소 전부 메모리에 할당이 되는데 모든 값을 메모리에 할당하지 않기 위해 값이 필요한 시점마다 차례대로 반환할 수 있는 이터레이터 객체를 만들었다.
list와 같이 반복이 가능한 객체를 iterable이라 하는데 String, range(), tuple, dictionary, sets은 Iterable 객체다. 우리가 list나 range() 함수 같은 iterable한 객체를 for문으로 순차적으로 값을 받을 수 있는 것은 for문이 수행되는 동안 python 내부에서 iterator 객체로 자동 변환해주었기 때문이다.
이터레이터 객체는 파이썬 내장 함수 또는 iterable 객체의 매직 메소드로 생성할 수 있다.
1) 파이썬 내장함수 iter()를 사용해 생성
>>> a = [1, 2, 3]
>>> a_iter = iter(a)
>>> type(a_iter)
<class 'list_iterator'>
2) iterable한 객체의 매직메소드 __iter__로 생성
iterable한 객체인지 알아보려면 dir 함수로 객체에 __iter__() 메소드가 있는지 확인해보면 된다.
dir([1,2,3])
>>> [... '__iter__', ...]
dir(range)
>>> [... '__iter__', ...]
[1, 2, 3].__iter__()
>>> <list_iterator at 0x17e2c7676d0>
>>> 'Hello, world!'.__iter__()
<str_iterator object at 0x03616770>
>>> {'a': 1, 'b': 2}.__iter__()
<dict_keyiterator object at 0x03870B10>
range(3).__iter__()
<range_iterator at 0x17e2c776bd0>
* 매직메소드
파이썬이 내부적으로 미리 구현해놓은 메소드. 두 개의 언더스코어 __로 시작해서 __로 끝나는 메소드
Iterator 객체라는 것은 __iter__()와 __next__() 메소드를 가진다.
- __iter__(): __next__() method를 가진 Iterator object 객체를 리턴
- __next__(): iterator 객체가 호출될 때마다 다음 값을 리턴
즉, iterable 객체에 __iter__()를 적용해서 iterator 객체를 얻고, __next__() 메소드를 이용해서 차례대로 값을 얻는다. iterator는 값을 반환한 시점의 상태를 기억하고 있기 때문에 next를 호출하면 다음 값을 반환할 수 있다.
x = [1,2,3]
y = iter(x)
next(y)
>>> 1
next(y)
>>> 2
next(y)
>>>3
next(y)
>>>
---------------------------------------------------------------------------
StopIteration Traceback (most recent call last)
<ipython-input-10-81b9d2f0f16a> in <module>
----> 1 next(y)
StopIteration:
그리고 Iterable 객체에 iter() 함수를 쓸 때마다 새로운 이터레이터가 생성된다. 이때 각 Iterator 객체는 서로 다른 상태를 유지하고 있어서 한 Iterator의 동작이 다른 Iterator의 동작에 영향을 미치지 않는다. 이를 통해 필요한 시점에 해당 객체를 호출하여 여러 개의 작업을 번갈아가면서 수행할 수 있게 된 것이다.
Generator: yield를 사용하여 Iterator를 생성해주는 객체
def func():
yield 값
func()
>> <generator object func at 0x0000029139D10EB0>
Python 2.2 (PEP 255 – Simple Generator)에서 Iterator를 더 편하게 사용하는 방법으로 Generator가 등장하였다.
이터레이터와 달리 __iter__()를 사용하지 않고 이터레이터 객체를 생성할 수 있으며, 다른 프로그래밍 언어처럼 yield를 이용하여 중단점을 만들어 값을 반환하고 다시 재개할 수 있다. 즉, lightweight coroutine을 지원하는 것이다.
일반 함수와 차이점은 yield이다. 일반 함수가 return을 만나면 실행이 끝나버리지만, 제너레이터는 yield 구문에서 값을 외부로 내보낸 후 '일시 정지' 상태가 되었다가 필요할 때 다시 실행 흐름을 이어나갈 수 있다. 함수 내부에서 사용된 지역 변수 등이 메모리에 그대로 유지되어 있기 때문이다.
yield 의미: '양보하다' -> 값을 메인 루틴으로 전달하고, 실행을 메인 루틴에 양보
yield 키워드로 현재 함수의 실행을 멈추고 값을 반환한 뒤, 필요할 때 다시 그 자리에서부터 실행이 가능하다. 두 개 이상의 제너레이터가 서로 값을 주고 받으면서 교차식으로 실행하는 것이 가능하기 때문에 제너레이터는 일반적인 함수 호출의 패턴인 메인 루틴/서브 루틴의 관계와 달리 두 개의 루틴이 번갈아 실행되는 일종의 코루틴(coroutine)이다.
def test_generator():
print('첫번째 실행')
yield 1
print('두번째 실행')
yield 2
print('세번째 실행')
yield 3
gen = test_generator()
>>> type(gen)
<class 'generator'>
next(gen)
>>> 첫번째 실행
1
next(gen)
>>> 두번째 실행
2
next(gen)
>>> 세번째 실행
3
>>> next(gen)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
멀티스레드와 같은 동시성이 아니더라도, 하나의 스레드 위에서 여러 개의 실행 흐름이 존재할 수 있게 된 것이다. 실제로는 concurrent하지 않은 작업들을 마치 동시에 진행되는 것처럼 다룰 수 있게 하며, 무거운 연산을 뒤로 미루어 실행 시간 내의 체감 퍼포먼스가 좋은 것처럼 보일 수 있게 한다. 제너레이터의 특징인 지연 평가(lazy evaluation)로 모든 값을 적재하지 않고 next()로 실행마다 값을 하나씩 메모리에 적재한다.
# 제네레이터 사용 X
def func(nums):
result = []
for i in nums:
result.append(i)
return result
f = func([1, 2, 3, 4, 5])
print(f)
>> [1, 4, 9, 16, 25] # 모든 값의 평가를 끝내고 메모리에 전부 적재되어 반환
# 제네레이터 사용
def generator(nums):
for i in nums:
yield i
gen = generator([1, 2, 3, 4, 5])
print(gen) # 값들이 메모리에 적재되어 있지 않고, 객체만 생성된 상태
>> <generator object generator at 0x00E35568>
print(next(gen)) # next()로 호출될 때만 값을 메모리에 적재
>> 1
asyncio에서 말하는 '비동기 코루틴'도 제너레이터의 특성인 실행을 필요한 만큼 멈춰놓을 수 있는 함수라는 특성에서 발전한 아이디어다. I/O에 관여하는 작업이 있을 때에는 I/O 장치의 처리가 끝날 때까지 즉, 필요한 만큼 해당 함수의 동작을 잠시 멈춰두고 코루틴으로 다른 함수의 처리를 하도록 하는 것이다. 따라서 코루틴의 특징만 활용해서 멀티스레드처럼 I/O 관련 프로그램의 성능을 끌어 올릴 수 있게 된다.
제너레이터 함수를 호출하면 제너레이터가 생성된다. 생성된 제너레이터는 실행가능한 코드를 담고 있는 상자에 비유할 수 있는데, 생성 즉시 그 내부의 코드가 실행되지 않으며, 외부에서 제너레이터가 시작되도록 액션을 취해야 한다. 이 역학을 next()가 담당하는 것이다. generator를 생성하거나 next()를 호출하는 곳을 caller라고 표기하겠다.
def callee():
yield 1
yield 2
x = callee()
a = next(x)
b = next(x)
print(a,b)
>> 1 2
- Caller는 제너레이터 객체를 생성하고, next()를 호출하는 시점에서 제어권을 generator로 넘긴다.
- generator 내의 코드는 yield를 만나는 순간 현재까지의 상태(스택, 실행 위치)를 저장한 뒤 실행을 중단하고, Caller에게 yield 키워드 뒤에 오는 값을 넘겨준다.
결과적으로 두 개의 함수가 제어권을 주고 받고 있다. 그러나 오른쪽에서 왼쪽으로 값은 반환할 수 있지만 반대 방향으로는 값을 전달하지 못한다. 이를 보완하여 메인 루틴에서 값을 전달하는 방법이 python 2.5에 yield 기반으로 확장된 coroutine이다. (PEP 342 – Coroutines via Enhanced Generators)
이때부터 파이썬은 단일 thread로 다수의 작업을 concurrent하게 실행할 수 있는 진정한 의미의 coroutine이 갖추어진 셈이다. 이를 위하여 다음과 같은 사항이 추가되었다.
- x = yield 1 과 같이 yield 키워드에서도 값을 받을 수 있다.
- next()가 아닌 send() 함수가 추가되어 메인 루틴은 coroutine의 yield에 값을 전달할 수 있다.
def gen_coroutine():
print('callee one')
x = yield 1 # 다음 호출 때, x 변수에 값을 받아온다.
print(f'callee two: {x}')
y = yield 2
print(f'callee three: {y}')
task = gen_coroutine()
a = next(task) # callee one 출력, 코루틴에서 받은 1은 a에 저장
b = task.send(10) # 코루틴에 10을 보내고 코루틴에서 2를 받아와 b에 저장
task.send(20) # callee three: 20 출력 후 StopIteration exception 발생
gen_coroutine()을 실행하면 generator 객체가 리턴된다. 이를 실행시키려면 마찬가지로 next(generator 객체)처럼 실행해준다. 그럼 yield를 만날 때까지 함수가 실행된다. 이때 'callee one' 문장이 출력되고, yield를 만나 next()로 값 1을 반환한다. 처음에 next()를 호출한 이후로 task.send()를 이용하면 next()와 유사하게 coroutine의 동작을 재개한다. next()와의 차이점은 메인 루틴에서 값을 coroutine 함수로 전달할 수 있다는 것이다. task.send(10)으로 아까 실행이 중단되었던 x=yield 1의 x 변수로 10을 전달한다. 'callee 2: 10'이 출력되고 다음 yield 2를 만나서 2를 반환하며 제어권을 넘긴다.
처음에 next()를 호출하지 않고, 시작이 아직 안된 제너레이터에는 None만 보낼 수 있다.
a = task.send(None)
Yield from: 제너레이터가 또다른 제너레이터에게 실행 양보, return 추가
python 3.3(PEP 380 – Syntax for Delegating to a Subgenerator)에 처음 소개된 문법이다. 제너레이터가 또다른 제너레이터를 실행시키는 구조가 될 수 있다. 즉, Coroutine이 다시 Sub Coroutine을 호출하는 것이다.
def generator():
for i in range(10):
yield i
for j in range(10, 20):
yield j
위와 같이 yield가 두 개로 나뉜 generator()는 순서대로 0부터 19까지의 수를 반환하도록 설계되어 있다. 그런데 yield i와 yield j부분의 코드를 재사용하고 싶다면 우리는 이 코드를 다음과 같이 재작성할 수 있다.
def generator2():
for i in range(10):
yield i
def generator3():
for j in range(10,20):
yield j
def generator():
for i in generator2():
yield i
for j in generator3():
yield j
코드를 재사용할 수 있게 만들었지만, 한눈에 봐도 명시적이지 않다. 이를 명시적으로 표현하기 위한 문법이 yield from.
def generator():
yield from generator2()
yield from generator3()
yield from 오른쪽에 들어갈 수 있는 것은 iterable, iterator, generator 객체이다. PEP 380에 하나 더 추가된 기능이 있는데, generator에서 return이다.
Generator의 역사를 보면 iterator에서부터 확장된 것이라, generator에서는 yield로 중간 값을 반환했지만, 실행이 종료되면 StopIteration exception이 발생하는 방식이었다. 그래서 모든 실행이 종료되면 return으로 최종 결과값까지 반환할 수 있도록 기능을 추가하였다.
def test():
yield 1
return "error message"
x = test()
next(x)
>>> 1
next(x)
>>> StopIteration: "error message"
Exception으로 최종 결과 값 "error message"이 리턴되었다. 위와 같은 코드는 실제로 다음과 같다고 보면 된다.
def test():
yield 1
e = StopIteration()
e.value = "error message"
raise e
위와 같이 return을 추가로 지원한 것은 yield from의 기능을 확장하기 위한 것이다. 이와 같이 return이 지원되면 다음과 같이 yield from에서 직접 값을 받을 수 있다. 이런 구조가 되면 lightweight thread 형태가 된다. 즉 sub coroutine은 필요한 만큼 돌다가 coroutine에 최종 결과값을 반환하는 방식이 된다.
def sum(n):
result = 0
for i in range(n):
result += i
yield result
return result
def coroutine():
result = yield from sum(3)
print(f'합계: {result}')
co = coroutine()
next(co)
>>> 0
next(co)
>>> 1
next(co)
>>> 3
next(co)
>>> 합계: 3
여기서 헷갈릴 수 있는데, sum()과 같은 sub coroutine에서 yield로 주는 값과 return되는 값은 용도가 다르다.
yield로 전달한 것은 중간 값을 caller에서 받는다. 하지만 sub coroutine의 모든 실행이 종료되면 return으로 최종 결과값을 caller가 아니라 중간의 coroutine로 보낼 수 있다. 즉, 두 가지는 사용하는 용도가 다르다.
* 중요
완전한 비동기를 구현하려고 하면 모든 코드베이스가 비동기여야 한다. 만약 호출한 함수가 동기 함수이면 작업하는 시간이 너무 많이 걸려 결국 이벤트 루프를 블로킹할 수도 있기 때문이다.
참고:
https://www.python.org/dev/peps/pep-0234/
https://www.python.org/dev/peps/pep-0255/
https://soooprmx.com/coroutine-generator/
https://dojang.io/mod/page/view.php?id=2418