저번에 Iterable 편에서는 제너레이터까지 알아봤다. 그런데 제너레이터에서 몇몇 키워드를 이용해서 코루틴처럼 사용이 가능하다고 한다. 그런데 코루틴이 정확히 뭐를 의미하는 걸까? 루틴은?
한번 정리를 해보자
루틴은 함수, 프로시저, 메소드라고 생각해도된다. 특정 작업을 수행할 수 있는 일반적인 실행 단위, 코드 덩어리 그 자체이다.
그리고 서브루틴은 메인 루틴에서 호출되어 실행을 마친 뒤 제어가 돌아오는 코드 블록, 즉 그냥 함수, 프로시저, 메소드라고 봐도 무방하다.
그러면 여기서 코루틴(coroutine)은 중간에 멈출 수 있는 서브루틴, 즉 함수를 실행하고 중지하고 중지한 상황에서 변수, 스택 등을 저장하고 외부에서 해당 함수를 제어할 수 있는 그런 루틴을 말한다.
중간에 멈출 수 있다? 이거 우리가 많이들 사용하는 제너레이터랑 비슷하지않은가? 함수의 특정부분까지 실행되고 멈출수 있지않은가? 이런 제너레이터의 특성을 이용해서 코루틴처럼 사용하는것을 generator-based coroutine이라고 한다.
yield, send(), throw(), close() 를 이용하여 구현하며
def my_coroutine():
print("Hello")
x = yield 1
print("World", x)
try:
gen = my_coroutine()
result = next(gen)
print("result:", result)
gen.send(123)
except StopIteration:
print("제너레이터가 끝났습니다.")
Hello
result: 1
World 123
제너레이터가 끝났습니다.
위와같이 사용할 수 있다.
현재는 내부는 generator-based coroutine으로 구현된 Native Coroutine인 async def, await 을쓴다
그러면 여기서 생각해보자. 위에서 설명한 코루틴은 어떤 문제를 해결하기 위해 사용하는 걸까? 우리가 async def, await를 사용해서 개발할 때는 보통 비동기를 구현하기 위해 사용한다.
비동기(asynchronous)란 무엇인가?
어떤 작업이 완료될 때까지 대기하지 않고, 그 사이에 다른 작업을 함께 진행하여 시스템 효율성을 높이는 방식
을 뜻한다. 그러면 여기서 “대기한다”라는 단어의 주체는 뭘까? 바로 대부분의 경우 CPU를 뜻한다.
우리가 개발할 때도 대부분의 경우 CPU 작업을 베이스로 돌아간다. 즉 위에 CPU를 넣어보면
CPU가 어떤 작업이 완료될 때까지 대기하지 않고, 그 사이에 다른 작업을 함께 진행한다.
라는 뜻이다.
CPU가 대기한다는 것은 CPU가 메인으로 일하지 않고 다른 처리 장치 혹은 외부와 송수신하는 결과를 기다린다는 말과 같다.
여기서 중요한 것은 CPU가 일해야하는 상황, 즉 CPU 바운드의 유무이다. 디스크를 읽고 쓰고, API를 요청해서 응답이 올때까지 대기하고 등의 일은 CPU의 역할이 적기 때문에 I/O 바운드 작업이라고 분류된다.
I/O 바운드라고 해도 CPU가 전혀 필요 없는 것은 아니지만, 주된 대기 시간이 디스크나 네트워크처럼 외부 장치 응답에 의해 결정되므로, 이동안 CPU가 놀게 된다.
위의 말을 정리해보면 우리가 비동기를 구현하기 위해서 async def, await를 사용하는 경우에는 I/O 바운드 작업이 이루어질때 다른 CPU가 필요한 작업을 함께 진행하기 위해 사용하는 것이라고 볼 수 있다.
짧은 기간 동안 집중적으로 어떤 자원(CPU/I/O)을 주로, 집중적으로 사용하는 구간 = 버스트
전체 작업시간동안 무엇에 의해 주로 지연(메인 병목)되는가?(전체 속도를 주로 결정하는 리소스) = 바운드
즉 async def, await를 사용하면 CPU 바운드 작업을 처리하는 중에 I/O 바운드 작업을 만나게 되면 잠시 해당 프로시저, 코루틴을 멈추고 다른 작업을 수행하고 끝나면 돌와서 처리하는 작업, 동시에 여러 작업을 처리하는 것처럼 보이는 동시성(Concurrency)을 구현하는 작업이라고 볼 수 있다.
그러면 동시성을 구현하는 대표적인 방법 중 하나인 파이썬의 멀티스레드도 코루틴인가?
아니다.
멀티스레드, 코루틴 둘 다 동시성을 구현하는 방식은 맞으나 동작하는 목적, 레벨이 다르다.
코루틴은 하나의 스레드에서 동시성을 구현하기 위한 기법으로 I/O 바운드 작업을 효율적으로 처리하는데 주로 사용한다.
파이썬의 멀티스레드는 OS단에서 스케쥴링을 통해 관리된다.
코루틴과 비슷하게 I/O 바운드 작업을 효율적으로 처리하기도 한다. GIL 때문에 멀티스레드가 CPU 병렬성을 얻기 힘들지만 C 익스텐션, Numpy등 라이브러리에서 GIL을 해제하고 CPU 병렬로 돌리는데 사용하기도 하고, GUI와 백그라운드 작업이 동시에 진행되는 것처럼 보이기 위할 때 사용한다.
동시성은 여러 작업이 겹쳐서(논리적으로 동시에) 진행되는 것처럼 보이는 상태를 말한다면
실제로 여러 작업이 동시에 실행되는 것은 병렬성(Parallelism)이라고 한다. 이는 파이썬에서 멀티프로세싱을 통해 구현한다.
그래서 이 비동기를 구현하기위해 async def, await를 사용한다고 해보자.
그럼 아래와 같은 코드 구조를 가지게 되는데
await에 아무거나 가져다 붙일 수 있는걸까?
import asyncio
import time
async def task1():
print("Task1 start")
await asyncio.sleep(1)
print("Task1 end")
async def task2():
print("Task2 start")
await asyncio.sleep(2)
print("Task2 end")
async def main():
await asyncio.gather(
task1(),
task2()
)
start_time = time.time()
asyncio.run(main())
print("Total time:", time.time() - start_time)
Task1 start
Task2 start
Task1 end
Task2 end
Total time: 2.0031168460845947
await은 해당 작업을 비동기로 기다리겠다는 의미이므로 “결과를 비동기로 기다릴 수 있다”리고 인식될 수 있는 타입이어야한다.
이를 awaitable한 객체라고 부르며 iterable과 같이 await 매직메소드가 구현된 객체거나 코루틴 객체 즉 async def로 정의된 함수가 반환하는 값이 필요하다.
아래 링크를 보면
https://docs.python.org/ko/3.8/c-api/typeobj.html#sub-slots
https://github.com/python/cpython/blob/main/Objects/genobject.c#L1196
python docs
cpython
제대로 await 매직메소드가 구현되어 있는것을 볼 수 있다. 즉 코루틴 객체도 await 매직메소드가 존재한다.
async def task2():
return 1
coroutine = task2()
print(coroutine.__await__())
<coroutine_wrapper object at 0x10370ebf0>
await() 메소드를 호출해보면 코루틴 래퍼로 정의된 객체가 반환되는것을 통해 await 매직메소드가 구현된 awaitable 객체라는 것을 확인할 수 있다.
아까 처음에 "코루틴(coroutine)은 중간에 멈출 수 있는 서브루틴, 즉 함수를 실행하고 중지하고 중지한 상황에서 변수, 스택 등을 저장하고 외부에서 해당 함수를 제어할 수 있는 그런 루틴" 이라고 했다.
그런데 함수 밖으로 나온 상황에서 변수, 스택 즉, 로컬 변수, 함수 실행 위치, 연산 스택을 어디엔가 저장하고 다시 복구를 시키는 작업이 존재한다는 것이다.
def task1():
print("task1 시작")
x = yield 1
print("task1 x :", x)
yield 2
print("task1 종료")
gen = task1()
print("초기 프레임:", gen.gi_frame)
print("초기 프레임 로컬 변수:", gen.gi_frame.f_locals)
val = next(gen)
print("yield 후 프레임:", gen.gi_frame)
print("yield 후 로컬 변수:", gen.gi_frame.f_locals)
val = gen.send(100)
print("send 후 프레임:", gen.gi_frame)
print("send 후 로컬 변수:", gen.gi_frame.f_locals)
초기 프레임: <frame at 0x100800930, file '/Users/aaaa/Downloads/async_test.py', line 1, code task1>
초기 프레임 로컬 변수: {}
task1 시작
yield 후 프레임: <frame at 0x100800930, file '/Users/aaaa/Downloads/async_test.py', line 3, code task1>
yield 후 로컬 변수: {}
task1 x : 100
send 후 프레임: <frame at 0x100800930, file '/Users/aaaa/Downloads/async_test.py', line 5, code task1>
send 후 로컬 변수: {'x': 100}
위 처럼 프레임 객체에 함수 실행 위치 및 그 시점의 로컬 변수가 저장되어 있는 것을 확인할 수 있다. 여기서 확인 못하는 것은 파이썬의 바이트 코드 레벨에서 사용되는 연산 스택(operand stack)인데 파이썬에서 이를 확인할 수 있는 API를 제공하지 않아 확인이 힘들다.
그리고 여기서 나온 프레임이란 개념은 추후 설명할 raise, exception, traceback이 어떻게 동작하는지에 대해서 설명할 때 나오게 된다.