[ 글의 목적: python 코루틴을 이해하기 위한 기본 동작 원리와 python 프로그래밍 패러타임 ]
기본적인 코루틴의 개념, 메인 루틴과 서브 루틴의 컨셉은 보았다. 실제로 잘 활용하기 위해 코루틴 저변에 깔려있는 이터레이터(Iterator)와 제네레이터(Generator)에 대해 더 살펴보고 python의 코루틴이 발전한 방향과 코루틴을 활용한 실용적인 예제를 체크해 보자!
🔥 앞 글과 이어지는 글입니다! 앞 글 먼저 읽어주시면 감사합니다!
이터레이터는 값을 순차적으로 꺼낼 수 있는 객체다. [1,2,3]
과 같은 list를 정의하면 3개의 원소 전부 메모리에 할당이 되는데 모든 값을 메모리에 할당하지 않기 위해 값이 필요한 시점마다 차례대로 반환할 수 있는 이터레이터 객체를 만들었다.
list와 같이 반복이 가능한 객체를 "iterable 객체" 라 하는데 string
, range()
, tuple
, dictionary
, sets
은 Iterable 객체다. 우리가 list나 range() 함수 같은 iterable한 객체를 for문으로 순차적으로 값을 받을 수 있는 것은 for문이 수행되는 동안 python 내부에서 iterator 객체로 자동 변환해주었기 때문 이다.
class Counter:
def __init__(self, limit):
self.limit = limit
def __iter__(self):
self.count = 0
return self
def __next__(self):
if self.count < self.limit:
self.count += 1
return self.count
else:
raise StopIteration
counter = Counter(3)
iterator = iter(counter)
print(next(iterator)) # Output: 1
print(next(iterator)) # Output: 2
print(next(iterator)) # Output: 3
python의 매직매서드 (magic method or dunder method)를 활용해 위와 같이 직접 class의 __iter__
와 __next__
를 활용해 만들 수 있다. __iter__()
메서드는 이터레이터 객체 자신을 반환하고, __next__()
메서드는 "다음 값을 반환" 한다. 이터레이터가 더 이상 반환할 값이 없을 때 __next__()
메서드는 StopIteration
예외를 발생시킨다.
위 2개의 매직매서드로 우리가 만든 class의 객체를 iterable하게 만들 수 있다.
for c in counter:
print(c)
# Output
# 1
# 2
# 3
Python 2.2 (PEP 255 – Simple Generator) 에서 Iterator를 더 편하게 사용하는 방법으로 Generator가 등장했다고 한다.
제너레이터는 "이터레이터를 생성하는 함수" 로, 일반 함수와는 달리 상태를 유지할 수 있다. 제너레이터는 yield
표현식을 사용하여 값을 반환하고, 다음 호출 시 마지막으로 실행된 yield
표현식 이후부터 실행을 재개한다.
__iter__()
를 "사용하지 않고" 이터레이터 객체를 생성하고 yield
로 "중단점" 을 만들어 값을 반환하고, 다시 재개할 수 있다. 이러한 방법을 lightweight coroutine 이라고 표현한다. 앞 글에서 살펴본 간단한 코루틴의 예시가 바로 제네레이터를 활용한 예시이다.
일반 함수가 return을 만나면 실행이 끝나버리지만, 제너레이터는 yield 구문에서 값을 외부로 내보낸 후 '일시 정지' 상태가 되었다가 필요할 때 다시 실행 흐름을 이어나갈 수 있다. 함수 내부에서 사용된 지역 변수 등이 메모리에 그대로 유지되어 있기 때문이다.
def counter(limit):
count = 0
while count < limit:
count += 1
yield count
gen_counter = counter(3)
print(next(gen_counter)) # Output: 1
print(next(gen_counter)) # Output: 2
print(next(gen_counter)) # Output: 3
이터레이터 예시에서 만든 Counter
class를 위와 같이 제네레이터 함수를 바탕으로 다시 만들 수 있다. python을 더 pythonic 하게 사용하기, 12가지 TIP 에서 실제 활용에 대한 예시를 간단하게 다루고 있다.
제너레이터는 이터레이터보다 구현이 간단하다. yield
로 값을 하나씩 생성하는 반면 이터레이터는 "모든 값을 메모리에 보관" 하기 때문에 메모리를 더 효율적으로 사용한다. yield
는 "양보하다" 라는 의미를 가지고 있고, 서브 루틴에서 메인 루틴에게 "양보한다" 라는 의미로 사용된다.
for 루프는 StopIteration
예외를 만나면 멈추기 때문에 역시 아래와 같은 방법으로 for - in
구문에서 사용이 가능하다.
for c in counter(3):
print(c)
# Output
# 1
# 2
# 3
특별한 동시성 프로그래밍을 하지 않았음에도 하나의 스레드 위에서 여러 개의 실행 흐름이 존재할 수 있게 된 것이다.
실제로 concurrent하지 않은 작업들을 마치 동시에 진행되는 것처럼 다룰 수 있게 하며, 무거운 연산을 뒤로 미루어 "실행 시간 내의 체감 퍼포먼스가 좋은 것처럼 보일 수 있게" 한다. 제너레이터의 특징인 지연 평가(lazy evaluation) 로 모든 값을 적재하지 않고 next()
로 실행마다 값을 하나씩 메모리에 적재한다.
이런 이점 덕분에 큰 데이터를 다룰때 메모리에 모두 올리지 않고 필요한 만큼만 처리해서 가져올 수 있다.
counter
를 다시 살펴보면, gen_counter = counter(3)
부분은 실제 counter
함수가 실행된 것이 아니라 "실행 함수를 담고있는 제네레이터 객체" 를 만든 것이다. gen_counter = counter(3)
print(gen_counter)
>>> <generator object counter at 0x7fab3579f040>
생성 즉시 그 내부의 코드가 실행되지 않으며, 외부에서 제너레이터가 시작되도록 액션을 취해야 한다. 이 역할을 next()
가 담당한다. 실제 next()
를 호출한 쪽에 메인 루틴이 되며 제네레이터는 서브 루틴이 된다. 서브 루틴에서 yield
를 만나면 그 제어권을 다시 메인 루틴에게 넘긴다.
결과적으로 두 개의 함수가 제어권을 주고 받고 있다. 서브에서 메인으로 값은 반환할 수 있지만 반대 방향으로는 값을 전달하지 못한다. 이를 보완하여 메인 루틴에서 값을 전달하는 방법이 python 2.5에 yield 기반으로 확장된 coroutine이다. (PEP 342 – Coroutines via Enhanced Generators)
이때부터 파이썬은 제네레이터를 확장해 단일 thread로 다수의 작업을 concurrent하게 실행할 수 있는 진정한 의미의 coroutine이 갖추어진 것이다. 이를 위하여 다음과 같은 핵심 사항이 추가되었다.
yield
표현식: 제너레이터가 값을 생성하는 것뿐만 아니라, 호출자로부터 값을 받을 수 있게 한다.def simple_coroutine():
print("Coroutine started")
x = yield
print("Coroutine received:", x)
yield
my_coro = simple_coroutine()
next(my_coro) # 코루틴을 시작합니다.
my_coro.send(42) # 코루틴에 값을 보냅니다.
# Output
>>> Coroutine started
>>> Coroutine received: 42
next()
를 호출해 simple_coroutine
를 시작하고 해당 서브 루틴에서는 x = yield
를 만나 다시 메인 루틴으로 돌아온다. 그리고 my_coro.send(42)
를 통해 값을 보내어 해당 함수를 마무리 한다. send()
메서드: 제너레이터의 yield 표현식에 값을 "보내는" 메서드이다.def generator_coroutine():
yield "Hello"
x = yield
yield x
gen_coro = generator_coroutine()
print(next(gen_coro)) # 첫 번째 yield까지 실행하고, "Hello"를 출력합니다.
next(gen_coro) # 두 번째 yield까지 실행합니다.
print(gen_coro.send("World")) # 세 번째 yield까지 실행하고, "World"를 출력합니다.
# Output
>>> Hello
>>> World
print(gen_coro.send("World"))
에서 gen_coro.send("World")
를 통해 yield x
가 실행되면서 x를 return 하게 된다. 그 값이 "World" 이고 print를 통해 return 된 값을 출력하게 되는 것이다.throw()
메서드: 제너레이터 내부의 yield 표현식에 예외를 던질 수 있게 한다.def exception_handling_coroutine():
print("Coroutine started")
try:
x = yield
except ValueError:
print("Caught ValueError!")
else:
print("Coroutine received:", x)
my_coro = exception_handling_coroutine()
next(my_coro) # 코루틴을 시작합니다.
my_coro.throw(ValueError) # ValueError 예외를 던집니다.
throw
를 통해 예외를 던지고 try - except
를 통해 받을 수 있게 되었다. def generator_with_return():
yield "Starting the generator"
return "Generator finished"
gen = generator_with_return()
print(next(gen)) # 출력: Starting the generator
# 제너레이터를 종료하고, 반환값을 얻습니다.
try:
next(gen)
except StopIteration as e:
print("Return value:", e.value) # 출력: Generator finished
# 제너레이터를 다시 시작합니다.
gen = generator_with_return()
print(next(gen)) # 출력: Starting the generator
# 제너레이터를 close() 메서드를 사용하여 종료합니다.
gen.close()
# 제너레이터가 종료되었기 때문에, 추가적인 next() 호출은 StopIteration 예외를 발생시킵니다.
try:
next(gen)
except StopIteration as e:
print("Generator is already closed:", e.value) # 출력: Generator is closed: None
이 예제에서 return
문은 제너레이터가 값을 반환하면서 종료되게 한다. 해당 값은 StopIteration
예외의 value
속성을 사용하여 반환값을 얻을 수 있다.
하지만 close()
메서드는 제너레이터를 강제로 종료한다. 제너레이터가 close()
메서드로 종료된 후에 next()
함수를 호출하면 StopIteration
예외가 발생한다. 이 경우 return이 있어도 value를 얻어올 수 없다.
이 두 기능을 함께 사용하면 제너레이터를 유연하게 관리하고, 필요에 따라 반환값을 얻거나 제너레이터를 강제로 종료할 수 있다.
yield from
은 제너레이터와 코루틴의 사용성과 표현력을 향상시키기 위해 만들어 졌으며 제너레이터를 다른 서브 제너레이터에 위임(delegate)을 쉽게 하는 방법이다. 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
gen = generator()
print(next(gen))
# ...생략...
print(next(gen))
# Output 은 0 부터 19까지 출력될 것이다.
generator
가 있다. 하지만 명시적이지는 않다. 확실하게 2개의 제네레이터를 체이닝하고 있는 제네레이터라고 명시하기 위해 아래와 같이 사용할 수 있다.def generator():
yield from generator2()
yield from generator3()
yield from
을 통해 2가지 제네레이터를 체이닝해서 사용하고 있는 것을 바로 인지할 수 있다. yield from
오른쪽에 들어갈 수 있는 것은 iterable
, iterator
, generator
객체이다.python 3.4 버전(PEP 3156 – Asynchronous IO Support Rebooted: the “asyncio” Module 에서 asyncio가 정식적으로 적용되었다.
asyncio
에서 말하는 '비동기 코루틴'도 제너레이터의 특성인 실행을 필요한 만큼 멈춰놓을 수 있는 함수라는 특성에서 발전한 아이디어다. I/O에 관여하는 작업이 있을 때에는 I/O 장치의 처리가 끝날 때까지 즉, 필요한 만큼 해당 함수의 동작을 잠시 멈춰두고 코루틴으로 다른 함수의 처리를 하도록 하는 것 이다. 코루틴의 특징만 활용해서 "멀티스레드처럼" I/O 관련 프로그램의 성능을 끌어 올릴 수 있게 된다.
asyncio는 비동기 I/O, 이벤트 루프, 코루틴, 태스크, 그리고 동시성 코드 실행을 위한 프레임워크를 제공한다. 특히 network I/O 관련된 코드에서 높은 성능을 보여준다. asyncio는 단일 스레드, 단일 프로세스 디자인 패러다임을 사용 하여, 동시성 코드를 쉽고 안전하게 작성할 수 있도록 만들었다.
# async/await 등장 이전
import asyncio
@asyncio.coroutine # 이 데코레이터는 Python 3.4에서 코루틴을 정의하는 데 사용되었다.
def my_coroutine():
print("Start Coroutine")
yield from asyncio.sleep(1) # 'await' 대신 'yield from'을 사용한다.
print("End Coroutine")
# 이벤트 루프를 가져오고, 코루틴을 실행한다.
loop = asyncio.get_event_loop()
loop.run_until_complete(my_coroutine())
loop.close()
# Output
>>> Start Coroutine
>>> (1초 뒤에) End Coroutine
python 3.5 버전(PEP 492 – Coroutines with async and await syntax) 에서 정식적으로 적용되었다. 이 async/await 의 등장으로 더욱 더 asyncio가 python의 비동기/동시성 생태계의 핵심 부분이 되었으며, 다양한 비동기 프레임워크와 라이브러리가 asyncio를 기반으로 구축되었다.
위 예시에서 yield from 대신 async/await 를 사용하면 아래와 같이 표현 가능하다. 같은 로직을 훨씬 더 가독성있게 작성할 수 있다.
import asyncio
async def my_coroutine():
print("Start Coroutine")
await asyncio.sleep(1)
print("End Coroutine")
# Python 3.7 이상에서는
asyncio.run(my_coroutine())
# Python 3.6 이하에서는 이벤트 루프를 직접 관리해야 합니다.
# loop = asyncio.get_event_loop()
# loop.run_until_complete(my_coroutine())
# loop.close()
# Output
>>> Start Coroutine
>>> (1초 뒤에) End Coroutine
파이썬은 기본적으로 싱글 스레드로 작동하며, GIL(Global Interpreter Lock) 때문에 한 번에 하나의 스레드만 실행할 수 있다. 이러한 제약 때문에 CPU 바운드 작업에서는 멀티 스레딩이 큰 이점을 가져오지 못하지만, I/O 바운드 작업에서는 여전히 비동기 프로그래밍과 이벤트 루프를 사용하여 높은 성능을 달성할 수 있다.
이런 이벤트 루프 컨셉은 js에서도 핵심 로직으로 활용된다. 간단하게 아래와 같은 흐름으로 동작한다.
비동기 작업 시작: 코루틴에서 비동기 작업(예: 네트워크 요청)을 시작하고, await
키워드를 사용하여 작업이 완료될 때까지 기다린다.
작업 일시 중지: await
키워드는 코루틴의 실행을 일시 중지하고, 제어권을 이벤트 루프에 반환한다. 이렇게 하면 이벤트 루프는 다른 코루틴 또는 이벤트를 처리할 수 있다.
이벤트 루프: 이벤트 루프는 다른 코루틴이나 콜백을 실행하면서, 앞서 시작한 비동기 작업이 완료되기를 기다린다.
비동기 작업 완료: 비동기 작업이 완료되면, 이벤트 루프는 해당 코루틴을 다시 스케줄링한다. 코루틴은 await
표현식 바로 다음 줄에서 실행을 재개한다.
이벤트의 흐름만 보자면 이벤트 디스패처가 이벤트를 감지하고, 이를 이벤트 큐에 추가한다. -> 이벤트 루프는 이벤트 큐에서 이벤트를 가져와 관련된 이벤트 핸들러를 호출한다. -> 이벤트 핸들러(콜백 함수)가 실행되고, 이벤트 처리가 완료된다. -> 이벤트 루프는 다음 이벤트를 기다리거나, 이미 큐에 있는 이벤트를 처리한다.
실제 이벤트 루프의 cpython의 구현 코드의 핵심은 아래와 같다. 하나 하나 뜯어보기에는 생각보다 양이 너무 방대하기 때문에 다음에 기회되면 글로 정리해보려고 한다.
import aiohttp
import asyncio
async def fetch_url(session, url):
async with session.get(url, ssl=False) as response:
print(f"Read {len(await response.text())} characters from {url}")
return response.status
async def main():
urls = [
"https://www.amazon.com",
"https://www.google.com",
"https://www.daum.net",
"https://www.naver.com",
]
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in urls]
await asyncio.gather(*tasks)
# Python 3.7+ 사용시
asyncio.run(main())
python의 기본 라이브러리 http.client
는 동기로 작성되어 있기 때문에 완벽한 비동기를 위해 aiohttp
라는 추가 라이브러리를 설치해야 한다. 해당 라이브러리를 바탕으로 4개의 url http request (get method)를 한 번에 요청할 수 있다.
http request 하나의 reponse 을 기다리고 다음으로 넘어가는 것이 아니라, signle thread임에도 불구하고 하나의 request를 던져놓고 바로 다음 request로 이어가면서 response를 받아오는 대로 나머지 비즈니스 로직을 처리하고 있다.
import requests
from multiprocessing import Pool
def fetch_url(url):
response = requests.get(url)
print(f"Read {len(response.text)} characters from {url}")
return response.status_code
def main():
urls = [
"https://www.amazon.com",
"https://www.google.com",
"https://www.daum.net",
"https://www.naver.com",
]
with Pool() as pool:
pool.map(fetch_url, urls)
if __name__ == "__main__":
main()
time
라이브러리의 start_time = time.time()
와 end_time = time.time()
시간차로 프로그램 실행 시간을 비교하고, 리눅스의 time 라이브러리를 활용해 리소스 사용량을 체크해 보자! logger 세팅
, request header 세팅
, 러닝 스크립트 작성
등은 생략했다. #!/bin/bash
# 결과를 저장할 파일 초기화
echo "" > time_results.txt
# http-req-multi.py 실행
for i in {1..1000}
do
echo "Running http-req-multi.py iteration $i" >> time_results.txt
/usr/bin/time -a -o time_results.txt python http-req-multi.py
sleep 1
done
# http-req-asyncio.py 실행
for i in {1..1000}
do
echo "Running http-req-asyncio.py iteration $i" >> time_results.txt
/usr/bin/time -a -o time_results.txt python http-req-asyncio.py
sleep 1
done
위 스크립트 바탕으로 1000회씩 러닝해서 linux time
커멘드의 결과값을 비교해보자. 어짜피 멀티프로세싱 개수를 억지로 무지막지하게 만들어내지 않는 한 전체 http response을 받아오는데 까지 시간은 거의 동일하다.
http-req-multi.py
http-req-asyncio.py
서버 스펙에 따라 그리고 network 상황에 따라 많이 달라질 수 있는 상대적인 데이터라 두 결과값의 상대적인 비교만 해보자. 우선 "코루틴 파일" 이 시스템 시간과 CPU사용률에서 이점이 보인다. 시스템 시간은 프로그램이 시스템(커널)에게 요청하여 수행한 CPU 작업양이다. 하지만 전체적인 메모리 사용량은 코루틴이 높다.
python이 멀프를 위해 fork를 하고 해당 과정에서 시스템 콜이 들어가고 오버헤드가 발생한다. 해당 오버헤드는 멀프개수가 늘어나면 늘어날 수록 비례하게 증가할 것이다. 하지만 해당 오버헤드는 최초 멀프세팅을 할 때만 발생한다.
코루틴은 시스템 콜없이 user level, 프로그램 자체 레벨에서 해당 부분을 해결한다. 그리고 코루틴은 "이벤트 루프"를 활용하고 이벤트 루프와 관련된 데이터 구조(예: 콜백, 퓨처, 작업 등)가 메모리를 사용한다. 또한, 각 코루틴은 자체 콜 스택을 가지고 있어, 실행 상태를 유지하는 데 추가 메모리를 사용한다.
사실 예상된 결과값이다. 결국 멀프는 코루틴에 비해 한정된 자원안에서 엄청나게 많은 network I/O를 처리한다면 코루틴에 비해 상대적으로 오버헤드가 크고 CPU 사용을 많이 사용할 것이다. 그렇기에 비동기IO는 I/O 바운드 작업 (예: HTTP 요청, 디스크 I/O) 는 코루틴 이, CPU 바운드 작업은 멀프가 유리하다.