Daybreakin Things
이번 주 릴리즈 예정인 Python 3.4의 주요 변화사항으로 asyncio 라이브러리가 추가된 점을 꼽을 수 있다. 개인적으로는 unicode 대통합 이후 가장 반기는 변화라서 따로 글로 남겨본다. 우선 asyncio 라이브러리가 비동기 처리를 구현하는 핵심 구성요소인 coroutine 개념에 대해 썰을 좀 풀어보겠다.
프로그래밍을 할 때 동시에 2가지 이상의 작업을 처리하기 위해 사용하는 방법으로 대표적인 것이 multi-threading이다. process 또는 thread와 같이 운영체제 스케줄링의 기본 단위가 되는 실행 단위를 여러 개 만들어 각각이 서로 다른 작업을 처리하게 하는 것으로, 물리적으로 여러 개의 CPU 코어가 있는 경우 프로그래밍을 "잘"(lock을 가능하면 쓰지 않는다든지 shared data를 최소화한다든지) 하고 처리하고자 하는 연산이 입력데이터를 쪼개 처리할 수 있는 경우 높은 성능 향상을 볼 수 있다. 그러나 대부분의 프로그램은 single thread로 작성되므로 이처럼 입력 데이터를 쪼개 동일한 일을 하는 여러 개의 thread로 나눠주는 방식이 아니라면 큰 성능 향상을 보기 어렵고 데이터를 어떻게 쪼개고 어떻게 나눠주는지에 관한 구조를 모두 신경써야 하므로 프로그래밍도 복잡해지는 문제가 있다.
그 다음으로 많이 볼 수 있는 동시성 구현 방법은 event-driven programming이다. 구현 방법에 따라서는 멀티코어 CPU를 활용하도록 만들 수도 있지만 기본적인 컨셉은 하나의 thread 안에서 여러 개의 작업을 어떻게 잘 나누어 scheduling할 것인가에 초점을 맞춘다. Event-driven programming은 말 그대로 작업 별로 입력 이벤트가 발생하였을 때( = 연산할 꺼리가 생겼을 때) thread를 깨워서 그 작업을 처리하게 만드는 것이다. 따라서 이벤트를 여러 개 등록하고 각 이벤트를 모니터링하는 메커니즘이 필요한데, 최근의 운영체제에서는 epoll (Linux), kqueue (BSD), IOCP (Windows)와 같은 API들을 제공하고 있어 user process가 하기 어려운 blocking 작업과 IO event 모니터링을 효율적으로 구현할 수 있게 도와준다.
이와 달리, Coroutine은 동시성에 대한 접근 방법이 좀더 특이하다. 여기서는 일반적으로 우리가 함수(function 혹은 method)라고 부르는, 프로그램의 가장 작은 실행단위(routine)를 쪼개어 여러 각 subroutine들이 '번갈아' 실행되도록 한다. 쪼개는 지점은 프로그래머가 직접 정해주는데, 그렇게 만들어진 여러 개의 각 진입점을 그때그때 돌아가면서 혹은 특정 이벤트가 발생했을 때 coroutine scheduler가 원하는 순서대로 호출한다. 비유적으로 표현하면, 기존의 multi-threading이나 event-driven programming은 언제 "깨어날 지"를 운영체제나 라이브러리가 결정해주는 데 반해 coroutine에서는 언제 "잠들 지"를 프로그래머 스스로 결정하는 구조이다. "Cooperative routines"라는 이름에서 알 수 있듯이 스스로 제어를 양보(yield)하고, 이때 coroutine scheduler는 바로 다음 시점에 block하고 시스템의 이벤트를 기다릴 것인지 아니면 다른 coroutine을 실행할 것인지 선택한다.
Event-driven programming은 CPU 자원을 필요할 때만 쓴다는 점에서 효율적이지만 프로그래머의 관점에서는 헬게이트에 가깝다. 그 이유는 개별 이벤트가 독립적으로 처리된다는 가정을 바탕으로 하기 때문에, 여러 개의 이벤트가 하나의 일련 작업으로 이어져야 하는 경우 내가 "몇 번째 단계"에 있는지(state) 프로그래머가 스스로 tracking해야 하기 때문이다. 대부분의 프로그래머는 socket programming을 배울 때 "순서대로" socket을 열고 connect하고 recv/send를 번갈아 호출하고 할일이 끝나면 close하는 방식의 사고에 익숙할 것이고, 저 과정을 매번 다른 이벤트로 처리하고 특정 순서에 잘못된 이벤트가 오지는 않을까 노심초사해야 한다면 벌써부터 머리가 복잡해질 것이다. 이때 빛을 발휘하는 것이 바로 coroutine이다. 프로그래머는 그냥 원래 익숙한 순서대로 routine을 짜되, blocking call이 발생하는 부분마다 yield하도록 표시를 해두면 coroutine scheduler가 각 yield 후 알아서 connect가 완료되었을 때, recv/send가 완료되었을 때, close가 완료되었을 때 coroutine을 이어서 진행해 줄 수 있는 것이다. 이러한 coroutine이 여러 개 있다면? 각각을 그때그때 이어서 실행하면 되니까 자연스럽게 동시성 구현이 가능하다. 하지만 coroutine 방식에서는 "비협조적인" 코드를 강제로 context switch시키지 않으므로 모든 코드가 coroutine을 염두에 두고 작성되어야 한다는 주의사항이 있다.
그렇다면 coroutine을 실제로 프로그래밍에 사용하려면 무엇이 필요할까? 첫번째는 함수를 중간에 "멈출 수 있는" 프로그래밍 언어의 문법적 지원이 필요하고, 두번째로는 기존의 blocking call들이 coroutine scheduler에게 완료 통지를 해줄 수 있어야 한다. 첫번째 조건의 사례로는 Python에서는 generator delegation이라고도 불리우는 yield from 명령어를 통해 가능하며 C#에서는 await 키워드가 같은 역할을 한다. Java나 C++처럼 언어적인 지원이 없는 경우 future 패턴과 callback을 통해 비슷한 구현이 가능하지만 coroutine에서 block하는 지점이 많아질수록 중첩된 callback을 많이 만들어야 하므로 코드를 깔끔하게 유지하기 어렵다. 두번째 조건을 만족시키려면 기존의 socket, threading, queue 등의 blocking call을 제공하는 라이브러리가 모두 통째로 coroutine을 지원하도록 바뀌어야 한다! Python에서는 그래서 gevent와 같은 3rd party library들이 표준 라이브러리를 런타임에 구현체를 바꿔치기하는 monkey-patch 방식을 이용해서 구현된 경우가 많았다.
그런데, Python 3.4에서는 드디어 이러한 coroutine 지원 라이브러리가 표준 라이브러리에 포함된 것이다. asyncio라는 이름으로 말이다. 최초 API는 Python 창시자 Guido van Rossum이 2012년 12월 PEP-3156을 통해 제안했고 reference implementation으로 Tulip 프로젝트를 진행하다가 이제 완성도가 충분하다고 판단하였는지 표준 라이브러리에 그대로 집어넣었다. 기존에 tulip으로 작성된 코드가 있다면 "tulip" 패키지 이름을 "asyncio"로 치환하기만 해도 코드가 동작할 것이다. 특히 Python 3.3에서 추가된 yield from
구문을 이용하면 C#의 await
키워드와 거의 똑같은 느낌으로 함수 호출을 비동기적으로 한다고 편리하게 명시할 수 있다. 예를 들면 time.sleep(1)
은 yield from asyncio.sleep(1)
로 바뀌는 식이다.
나름 Python 얼리어답터라고 자부하는 내가 이걸 보고 그냥 지나칠 수가 없었다. 그래서 하루 동안 삽질해서 나온 결과물이 asyncio 기반의 Minecraft-IRC 중계 봇! 원래 구현은 bug-prone한 단일 루프로 구성되어 있었는데 이걸 완전히 갈아엎어서 IRC 통신, Minecraft 통신, tick 타이머 요렇게 3개의 루프로 나누고 각 루프를 coroutine으로 만들었더니 마치 single thread 프로그래밍하는 것처럼 직관적인 코드를 유지하면서도 동시성을 보장하는 아름다운 코드가 되었다. Python의 asyncio 패키지는 나중에 stdout과 network/cpu를 동시 모니터링하는 실험스크립트 작성에도 유용하게 쓰일 것이다.
아무튼 이렇게 Python 3로 넘어올 이유가 하나 더 생겼다. 다들 넘어오시라. ㅋㅋㅋㅋㅋㅋ (사실 Python 2.x용으로 Tulip을 backport한 Trollius 프로젝트가 있기는 하다... -_-)
※ update 3/10: 일부 문장 흐름 및 내용 연결 자연스럽게 함.