asyncio에 대한 회의


Youngrok Pak at 1 year, 2 months ago.

파이썬의 asyncio에 회의가 조금씩 들고 있다. asyncio 자체는 훌륭하다. 이벤트 루프의 유행을 끌고 온 node.js나 과거 reactor 패턴에만 의존하던 twisted와 달리 async/await로 제법 세련되고 심플해보이는 프랙티스를 정립했다. 여기까지는 굿.

하지만, real world에서는 그리 간단치 않다. 기존의 수많은 라이브러리들과 별거해야 하기 때문이다. 이벤트 루프의 프로토콜에서 실행된 코드에서 단 하나라도 blocking IO를 하는 순간 이벤트 루프는 정지하고 concurrency는 망가진다. 그런데, 기존의 파이썬 라이브러리들은 모두 blocking IO로 되어 있다. 그럼 이런 라이브러리를 죄다 async로 포팅해서 쓰거나, 혹은 run_in_executor로 돌려야 한다. 여기까지는 필연이다.

여기서 전자를 선택하면 async world와 sync world의 격리가 일어난다. 기존 라이브러리를 모두 async로 포팅하는 일 자체는 그리 어렵지 않을 수 있다. django model을 좀 들여다보니 일주일 이내에 포팅을 할 수 있을 것 같아보인다. 하지만, 포팅하고 나면 어쩔 것인가? django model의 업그레이드에 발맞춰 갈 수 있나? 라이브러리 제작자는 늘 두 가지 버전을 유지해야 하나? 그렇다고 파이썬 2에서 파이썬 3로 옮겨갔듯이 파이썬 세상이 모두 async로 옮겨갈 것이라고 기대하기는 어렵다. synchronous 코드도 대부분의 상황에서 여전이 유효하기 때문이다. 이 격리는 바람직하지 않다. 우리가 파이썬으로 높은 생산성을 낼 수 있는 것은 파이썬이 좋은 언어이기 때문만은 아니다. 수많은 pythonic한 라이브러리들이 있기 때문이다.

포팅이 답이 아니라면 run_in_executor로 돌리는 것은 답인가? 이건 어느 정도는 그렇다고 할 수 있다.

그 전에 잠시 이벤트 루프의 성능 이슈 이야기를 해보자. 원래 이벤트 루프를 썼던 이유는 단순히 종합적인 성능을 높이기 위한 것이 아니라 concurrency를 높이기 위한 것이다. 기존의 threaded 모델은 high concurrency 상황에서 thread가 그만큼 늘어나야 하는데, thread는 메모리를 많이 먹기 때문에 메모리 바운드가 되서 무한정 늘릴 수 없었다. process prefork 모델 역시 마찬가지 문제를 안고 있기에 C10K Problem을 만나지도 못했다. 하지만 Node.js가 제시한 이벤트 루프는 메모리를 병목 자원에서 풀어내면서 훨씬 많은 커넥션을 열어둘 수 있게 되었다. 덕분에 CPU는 놀고 메모리만 먼저 차던 기존의 웹 애플리케이션 서버들과 달리 메모리를 훨씬 여유 있게 쓰면서 CPU도 더 쓸 수 있게 되어 서버의 자원을 더 효율적으로 활용하게 되었다. 이것이 웹 애플리케이션 서버 세상에 Node.js가 기여한 가장 큰 공로다.

그런데, 파이썬 asyncio로 작성한 웹 애플리케이션 서버가 커넥션은 async로 받고, 실제 작업은 threaded로 돌린다면 이것은 어떤가? 그냥 threaded 서버를 띄우는 것과 뭐가 다르냐고 할 수도 있겠으나, 다르긴 다르다. 앞서 이야기한 것처럼 이벤트 루프의 가장 큰 의의는 high concurrency다. 앞단에서 asyncio 서버가 커넥션을 받아주면 여전히 이벤트 루프의 장점을 누리면서 많은 커넥션을 받을 수 있고, 뒤에서 소수의 thread pool이 worker 개념으로 작업을 받아서 한다면 별 문제가 없다. threaded 서버는 아예 high concurrency를 감당할 수 없는 반면, worker의 thread는 커넥션의 개수만큼 있을 필요가 없기 때문에 CPU와 메모리 자원을 다 짜낼 정도로만 돌면 된다.

이 구조가 순수 async 코드로만 IO를 하는 구조에 비해 성능이 떨어질 것인가? 그럴 수도 있고 아닐 수도 있지만, 이론상으로는 큰 차이가 안 나야 하지 않을까 싶다. thread를 엄청나게 다수를 쓸 필요는 없으니까 thread로 인한 오버헤드는 그리 크지 않을 것이고, 코드 실행 퍼포먼스 자체는 thread나 이벤트 루프나 같고, IO 이벤트를 폴링하는 것도 같다. 

그럼 된 거 아닌가? 괜찮은데 왜 회의가 드는 것인가?

약간은 코드 미학적 느낌인 것 같다. async def로 시작된 코루틴에서 await로 계속 코루틴 체인을 부르는 코드는 매끄럽지만, loop를 어디선가 받아와서 run_in_executor로 thread에 일을 떠넘기는 코드는 그다지 아름답지 않다. synchronous code에서 다시 protocol 인스턴스의 어떤 코루틴을 호출하고 싶을 때도 답이 없다. 이 어색한 async world와 sync world의 공존은 명확한 코드의 경계선을 필요로 하고, 이것은 refactorability(이런 말이 있나?)를 떨어뜨린다.

성능상으로도 완전한 async 코드보다는 뒤질 것으로 추정된다. 여러 쓰레드에서 IO 이벤트를 폴링하는 것보다는 한 곳에서 폴링하는 게 폴링의 비효율이 적겠지. 몇 배씩 차이가 나진 않겠지만 10%~50% 정도의 차이는 날 수 있지 않을까?

이런 이유들로 이 지극히 실용적인 타협안이 그다지 맘에 들지 않는다.

그래서, lightweight thread 쪽을 좀 찾아보기도 했다. 본래 파이썬 thread는 GIL로 인해 동시 실행이 되지 않지만, IO 이벤트를 폴링하는 동안 스위칭은 되기 때문에 IO bound task에 한해서는 타 언어의 thread와 별 차이가 없다. thread라는 껍데기를 빼고 실행되는 코드의 순서만 생각한다면 IO 이벤트를 폴링하다가 한 번에 하나의 코드만 실행된다는 점에서 이벤트 루프와 별 차이가 없을 수도 있다. 그렇다면 thread의 오버헤드만 제거할 수 있다면, thread처럼 동작하지만 진짜 thread가 아니라면 high concurrency를 달성할 수 있지 않을까?

이런 생각에서 나온 것이 아마 greenlet이 아닐까 싶다. 이외에도 다양한 언어에서 비슷한 시도들이 있었다. 하지만 파이썬의 greenlet은 asyncio만큼 높은 성능을 증명하지 못했다. 이와 유사한 시도들도 대체로 성능이 그다지 탁월하지 않은 것으로 추정된다.

그래서 방황하던 찰나에 오늘 rabbitmq를 직접 클라이언트에 연결해서 채팅을 구현했다는 이야기를 들었다. 처음 들었을 땐 조금 황당한 느낌이었는데, 가만 생각해보니 이론적으로는 안될 이유가 없었다. 심지어 websocket bridge도 제공하기에 웹 브라우저에서도 쓸 수 있다. 여러 가지 단점들이 예상되긴 하나, concurrency에서 증명된 rabbitmq라면 asyncio보다 나으면 나았지 못하지는 않을 것이다.

그런 생각으로 rabbitmq를 개발한 언어인 erlang에서 사용하는 erlang process에 대해 찾아보니 이것도 lightweight thread와 비슷한 접근법 같다. 수백만 개의 erlang process를 띄워놓을 수도 있다고 하니 event loop 저리 가라 할 정도다. 그렇다면 이렇게 high concurrency를 보장하면서 subscribe/publish 모델까지 구현된 rabbitmq를 채팅 서버로 사용하는 것이 나쁜 생각은 아니다. 만약 파이썬에서도 asyncio 기반 서버가 아니라 uwsgi로 서버를 띄우고 threaded로 일반적인 http 요청을 처리하면서 채팅 연결만 rabbitmq에 아웃소싱한다면 굳이 async world와 공존할 필요 없이 파이썬은 sync world로만 살아갈 수 있지 않을까?

물론 현실적인 문제는 매우 많을 것이다. 커넥션 사이에 로직을 끼워넣기도 간단치 않을 것이고, 인증 문제도 좀더 고민해야 할 것이고, rabbitmq websocket bridge 연결도 nginx에서 한 번 받아서 proxy로 넘겨줘야 하는 등 잡다한 걸림돌이 많다.

근데, 이렇게 커넥션만 아웃소싱하는 것도 결국 성능 관점으로 본다면 run_in_executor를 통한 공존과 크게 다르지 않을 수 있다. 어쨋든 worker가 threaded로 돌아야 하고 그 코드를 실행해야 한다는 것은 변함이 없으니까.

 

 


Comments




Wiki at WikiNamu