Python 3, asyncio와 놀아보기

Python 3, asyncio와 놀아보기

사용자 삽입 이미지

Explicit and implicit concurrency in Python.

cooperative multitasking in Python.

Asyncio (PEP-3156)

Asyncio는 Python 3.4에 새로 추가된 라이브러리로 이름 그대로 파이썬에서 비동기 IO를 통해 조금 더 효율적으로 동시에 코드를 돌릴 수 있게 해주는 좋은 라이브러리입니다. (부: 본인이 생각하기에는 이 세상에는 동시란 없다고 생각합니다..

—보통 사람이 임의로 정해둔 시간 안에 같이 실행된 무언가가 있으면 동시라고 가정하기 때문에—, 프로젝트 이름은 Tulip 입니다.)

참고로 Python 3.3에서는 pip install asyncio로 사용하실 수 있고 그 아래버전은 지원하지 않습니다.

사실 백 번 글로 설명하는 것보다 한 번 코드로 설명해드리는 것이 가장 빠른 길일 것 같습니다.

Generator & Coroutine

Asyncio를 이해하기 위해서는 필수는 아니지만 제너레이터와 코루틴에 대한 이해가 필요합니다. (tornado와 같은 web framework를 사용해본 파이썬 유저라면 한 번 씩은 꼭 봤을거라 생각합니다.)

우선, 파이썬에는 yield라는 독특한 구문을 제공합니다. (여기서 독특하다는 이야기는 다른 언어에 없다는 이야기가 아니고, 처음 보는 분들에게 생소하게 다가올 수도 있다는 뜻입니다.)

만약 함수 내에서 코드가 돌다가 처음 yield 키워드를 만났을 때, 파이썬 인터프리터는 해당 함수와 해당 함수의 Context를 포함한 "제너레이터"라는 객체를 반환하게 됩니다. 이 "제너레이터" 객체는 "이터레이터" 프로토콜을 제공하며, 따라서 iterable 합니다. 쉽게 설명해서 파이썬에서 자주 쓰이는 list, range, dict(view)가 모두 iterable한 객체입니다. 정해진 순서대로 객체를 순회할 수 있게 해주는 것이죠.

쉽게 코드로 설명드리겠습니다.

위 코드를 보시면 우리가 흔히 자주 사용하는 range 함수를 제너레이터를 이용해 똑같이 구현한 예입니다. (참고: 실제로는 range 함수는 제너레이터 함수가 아니며 iter 함수를 통해 이터레이터를 얻을 수 있습니다.)

눈치 빠른 분들은 이미 이해하셨겠지만 yield 키워드에서 함수의 실행이 멈추고 next를 호출해줄 때마다 다시 해당 라인으로 진입을 하는 것을 알 수 있습니다. 이를 코루틴, 진입점이 여러 개인 함수라고 합니다. 이와 반대(? 보다는 그냥 다른 개념으로 접근하는 편이 좋겠습니다.)로 우리가 평소에 짜는 코드, 순서대로 쭉 내려오다가 return 코드를 만나고 최종 결과값을 리턴하는 방식을 서브루틴(subroutine)이라고 합니다.

코루틴을 이용하면 해당 함수가 실행 도중 중간 결과값을 리턴할 수 있고 함수 중간에 새로운 인자를 넘길 수도 있으며 해당 함수 내 Context를 그대로 가지고 있기 때문에 interactive한 함수 구현이 가능합니다.

제너레이터를 통해 yield 키워드가 있는 곳으로 보내기 위해서는 제너레이터 객체를 생성한 후에 처음 yield 키워드로 진입하기 위해 next 함수를 반드시 실행해줘야 합니다. 그 이후로 send를 할 때마다 값이 뜨는 것을 볼 수 있는데 None 을 입력하니 sendme 함수에 정의된 대로 StopIteration 예외를 뿜는 것을 볼 수 있습니다.

이와 같이 제너레이터는 정해진 이터레이션이 끝나면 StopIteration 을 뿜기(?) 때문에 따로 예외처리를 항상 해주어야 하며 첫 yield 구문으로 진입하기 위해 next를 미리 실행해주어야 합니다. (이를 asyncio에 있는 coroutine decorator가 알아서 해줍니다.)

Blocking IO

다음 코드를 먼저 봐보세요.

평소에 많이 본, 그리고 많이 쓴 익숙한 코드일 겁니다.

위 코드의 문제점은 다음과 같습니다:

  • 한 번에 한 URL밖에 받아오지 못합니다.
  • 따라서 한 작업이 끝나기 전까지 다음 작업을 할 수 없습니다.
  • URL이 늘어나면 늘어날수록 위 코드는 매우 비효율적입니다.
  • IO 작업이 끝나기 전까지 나머지 모든 코드는 블록됩니다.

그럼 스레드(Thread)를 쓰면 되지 않을까요? 라고 묻습니다. 네, 다음 코드를 봐보세요.

코드 흐름은 원하는 대로 돌아간 것 같습니다.

하지만 Python에서 제공하는 Native Thread(참고: 다른 스레드로는 Ruby에서 1.8 버전까지 제공하던 GreenThread 라는 게 있습니다.)는 다음과 같은 문제가 있습니다:

  • 스레드 갯수는 OS에 의해 한정되어 있습니다.
  • 공유 메모리 문제가 있습니다. 락(Mutex)을 사용해야 하죠.
  • 그런데 파이썬에는 **GIL**이라는 괴상한 놈이 있습니다. 파이썬 스레드를 스레드답게 쓰지 못하게(?) 해주는 놈인데요. 뭐 자세한 설명은 구글링을.. (이 글에서는 aio에 대해서만 다룹니다. ㅠㅠ)

그럼 이번에는 프로세스 풀에서 돌려봅시다. 파이썬은 참 좋은게 필요한게 이미 다 있어요. (concurrent.futures)

음. 시간이 오히려 더 늘었습니다. 왜 그럴까요?

  • 프로세스는 스레드와 비슷하지만 직접적으로 메모리를 공유하지 않습니다. (물론 일부 상황을 제외하고고 가능합니다)
  • GIL도 없습니다: CPU-bound 작업에 유리합니다.
  • 단 프로세스는 높은 cost를 요구합니다. 즉, 오버헤드가 큽니다. 시간을 잴 때 보면 알겠지만 프로세스 풀이 생성되고 작업이 끝나기 까지의 시간을 쟀습니다.

위에서 언급한 GIL을 조금 찾아보시면 아시겠지만 CPU-bound 작업에는 프로세스를 스폰하는 방법이 무조건 유리합니다. (AsyncIO도 동일합니다.)

Non-blocking IO (Event-driven IO)

소제목이 익숙하신 분들이 계실 수도 있습니다. 네, Node.JS의 그 이벤트 드리븐 맞습니다.

다음과 같은 Flow로 동작합니다.

사용자 삽입 이미지

그림이 어렵다면 그냥 이대로만 알아두시고 글을 계속 읽어주세요. 갑자기 이해되는 순간이 올겁니다.

일단 asyncio와 aiohttp로 위와 동일하게 구현한 코드를 봐주세요:

어라 속도차이 없는데요? 하기 전에 위 코드가 어떻게 동작하는지 생각해보세요.

  • asyncio에서 이벤트 루프를 끄집어오고 fetch_all 함수를 실행합니다. (코루틴 함수를 실행합니다.)
  • fetch_all 함수 내에서 fetch(url) 가 실행된 결과(제너레이터)를 asyncio.Task로 래핑해줍니다. 결과, asyncio에서는 위 이미지에 보이는 이벤트 루프 큐에 이 함수 제너레이터를 삽입하고 자동으로 스케쥴링을 합니다.
  • asyncio.gather 함수는 넘겨진 모든 코루틴 Task가 종료될 때까지 기다렸다가 종료되면 모든 결과를 모아서 리턴해줍니다.

자 그럼 위 코드에서 yield from은 어떤 키워드일까요?

위에서 간접적으로 설명했듯 yield 키워드는 제너레이터에서 어떠한 변수를 받을 수 있는 키워드, yield [variable] 구문은 제너레이터에서 원하는 값을 중간에 리턴할 수 있게 해주는 구문입니다.

yield from은 asyncio coroutine call에서 자주 쓰이는 키워드인데, 그 전에 Future라는 것부터 알아봅시다.

Future는 함수(제너레이터 함수)를 비동기로 실행하고 결과(때에 따라 오류나 콜백)를 보장해주는 객체입니다. asyncio.Task 클래스는 이 Future의 서브클래스이며 따라서 다음과 같은 흐름이 나올 수 있습니다.

task = asyncio.Task(co_function())

result = yield from task # task 가 끝날 때 까지 기다리고 제너레이터의 result 를 받아옵니다.

네, 여기서 yield from을 보셨으면 이제 이해하셨어야 합니..(제가 설명을 잘 못해서..)

yield from 구문에 Future(Task 포함)가 넘어왔다면 해당 Future가 종료될 때까지 기다립니다.

yield from 구문에 asyncio.coroutine 데코레이터가 붙은 코루틴 함수가 넘어왔다면 이 작업은 끝날 때까지 일단 묵혀두고 다른 작업을 진행합니다. 즉, 이벤트 스케쥴링을 하는거고 asyncio의 이벤트 루프는 결국 스케쥴러가 되는거죠.

참고로 asyncio 문서에 나와있듯 Task는 한 번에 한 개만 실행될 수 있고(즉 GIL에서 완전히 벗어나지 못합니다.) 각각의 스레드 단위로 병렬로 작동하며, Future를 기다릴 동안 다른 Task를 실행하게 됩니다.

즉 엄청 엄청 쉽게 설명해서 yield from 구문을 사용하면 그냥 이 작업 제쳐두고 다른 일 한다고 보면 됩니다.

asyncio에 대한 설명은 위에 있는 내용이 전부입니다.

아, 잠시 asyncio가 하려는 목표가 무엇인지 설명드리면:

합니다.

자. 이야기는 다 끝났습니다.

그럼 왜 위 코드는 속도 차이가 거의 없는거고 저 코드의 장점이 도대체 뭘까요? 속도 차이 없는데 그냥 편하게 스레드 쓰면 되지?

다음 이미지는 웹 서버에 관심이 있다면 인터넷에서 많이 보셨을 법한 사진입니다:

사용자 삽입 이미지

가로축은 동시 연결 수, 세로 축은 메모리 사용량인데요. 동시 연결 수가 많으면 많을수록 아파치 웹 서버의 메모리 사용량은 엄청나게 늘어납니다. 하지만 엔진엑스는 거의 일정한 메모리를 유지하고 있습니다. (물론 php라면.. apache + mod php vs nginx + php-fpm 이라면 당연히 저럴 수 밖에 없습니다.)

아파치 2.4부터는 event mpm이 생겨서 조금 이야기가 다르겠지만. 위 그래프를 기준으로 설명드리면, 아파치는 새로운 연결 요청이 들어올 때마다 각 worker process에서 스레드를 새로 만듭니다, 쉽게 설명해서 process-driven 입니다. 연결 당 스레드를 할당해서 실행하는 거죠. 반면 엔진엑스는 event-driven 입니다. 새로운 프로세스 또는 스레드를 요청 당 만들 필요가 전혀 없습니다. 새로운 요청(이벤트)이 들어오면 worker process의 event loop에서 모든 작업을 알아서 스케쥴링해서 처리합니다. 따라서 CS(Context-switching) 오버헤드도 없고 메모리 사용량도 적습니다. 각 연결(스레드)이 각자의 stack을 가질 필요도 없습니다.

Conclusion

이 모든 것을 Reactor Pattern이라고 부릅니다. 뭐 사실 무슨 패턴이다 패턴이다 이런건 별로 관심이 없어서 이 글에서는 건너뜁니다.

다시 위 코드로 돌아가서 위 코드의 URL이 100개, 1000개가 된다고 생각해보세요. 그리고 그것을 Thread pool, Process pool, Event loop 위에서 각각 돌린다고 생각해보세요. 어떤 방식이 가장 효율적일 지에 대한 선택은 여러분 몫으로 남아있습니다. 특히 엔진엑스 사례를 보듯 서버를 만든다면 더더욱이 선택이 중요해지겠죠. (다만 저 위에서 언급했듯 CPU-bound 작업에는 절대로 적절하지 않습니다.)

그리고 시간이 된다면 그냥 글로만 보고 지나가지 마시고 한 번 꼭 써보셨으면 좋겠습니다. 한 번 제대로 써보면 머릿속에 깊게 남아요. :)

글에 나와있는 잘못된 내용에 대한 날카롭고 깊은 지적은 얼마든지 환영합니다!


Python 3.5에 대응하는 내용 추가

Python 3.5부터 async, await 문법을 통한 asyncio 사용이 가능합니다.

기존에는 이렇게 작성해야 했던 것을:

이렇게 작성할 수 있습니다:

단 이 부분의 경우 Python 3.7부터 정식으로 사용하기를 권장하고 있어 Python 3.6이 나올 쯤에 사용하는 것이 좋을 것이라 판단됩니다.