Structured Concurrency

by Joongi Kim

나는 Backend.AI를 2015년부터 개발하고 asyncio와 관련한 삽질 이야기 및 그 과정에서 파생하여 만들게 된 유틸리티(aiotools) 및 RPC 라이브러리(Callosum)에 대한 소개를 PyCon KR에서 계속 발표해왔었다. 그 과정에서 asyncio 기반 프로그래밍에서 흔히 접하게 되는 여러 문제점과 고민들을 가지게 되었고, 검색을 통해 이미 많은 다른 사람들이 비슷한 고민을 하고 있다는 것을 알게 되었다. 그 고민을 한 마디의 용어로 표현하면 바로 구조적 병행성(Structured Concurrency)1다. (만약 Python의 비동기 프로그래밍 방식 자체에 생소하다면, 여러 글들을 모아 전반적인 소개를 다룬 이 블로그 글을 먼저 읽어보길 권한다.)

구조적 병행성이란 구조적 프로그래밍(structured programming)의 패러다임을 멀티쓰레딩이나 코루틴과 같은 병행 프로그래밍(concurrent programming)2에도 적용하고자 하는 것이다. 병행 프로그래밍의 가장 큰 특징은 명령어 하나가 실행된 다음 어떤 명령어를 실행할지 결정하는 제어 흐름이 1개가 아닌 여러 개가 된다는 점으로, 각 제어 흐름이 언제 어떻게 시작하고 언제 어떻게 끝나는지를 정확하게 표현하는 것이 가장 중요하다. 옛날에는 운영체제의 시분할 방식을 통해 병행성을 구현하였고 이제는 하드웨어의 발전으로 각 제어흐름을 실제 물리적으로 여러 개의 CPU 코어 또는 GPU 코어에서 돌리는 것이 쉽게 가능하지만, 여기서는 병행성을 어떻게 구현하는지보다는 어떻게 프로그램 언어와 코드로 추상화하는지에 집중한다. 참고로, 여기서 말하는 제어흐름을 좀더 저수준 용어로는 콜스택(호출스택, callstack)으로 부르기도 한다는 점을 알아두면 아래 링크한 다른 글들을 읽는 데 도움이 될 것이다.

Python에서는 3.4 버전에 처음 asyncio 패키지가 표준라이브러리에 시범 도입된 이후, 3.5 버전에서 async/await 문법이 추가되고, 3.6 버전에서 async generator가, 3.7 버전에서 context variable, 3.8 버전에서 CancelledErrorBaseException 전환3 등으로 계속 발전해나가고 있다. 3.4 버전에서는 provisional API로 추가되었고 3.6 버전에서 정식 API로 승인되었으나, 이후에도 asyncio.run()과 같은 API가 추가된다든지 asyncio.gather()의 세부 동작이 바뀐다든지 CancelledErrorBaseException으로 바뀐다든지 하는 등의 꽤 굵직한 변화들이 이어지고 있는데, 이는 좋게 말하면 지속적으로 발전하고 있는 것이고, 나쁘게 말하면 정식 API를 확정하기에는 여전히 빠져있는 부분이 많다는 뜻이기도 하다.

사실 asyncio를 예제코드 수준에서는 '오~ 좋구나~' 하면서 쓰기 쉬운데, 실제 장시간 동작하는 서버 데몬을 만들어야 하는 경우 예상치 못한 어려움에 맞닥뜨리게 된다. 서버 데몬들은 메모리 누수를 막는 내부 리소스 관리와 종료·재시작 동작 시 진행 중인 작업을 적절히 중단 또는 완료대기하면서 DB 연결을 끊어준다거나 하는 등의 리소스와 작업의 수명주기 관리가 매우 중요한데, asyncio를 쓰다보면 이런 면에서 오동작하는 코드를 작성하기가 매우 쉽다. 이에 대한 구체적인 사례들은 Lynn Root의 블로그 글을 참고하자. 한 가지 예를 인용하자면, asyncio.gather()를 기본 옵션으로 호출 시 SIGINT나 SIGTERM을 날렸는데 한번에 종료되지 않고 시그널을 두 번, 세 번 날려야 정상종료가 되는 경우들이 생긴다는 것이다. 이를 적절하게 처리하지 못하면 systemd 등으로 서비스화했을 때 서비스 종료 시 비정상적으로 오랜 대기시간(SIGTERM 날리고 일정 시간 동안 프로세스가 죽지 않으면 SIGKILL을 한다든지)을 겪게 되거나 그렇게 강제종료 처리가 발생하는 경우 진행 중이던 코루틴 작업이 외부 리소스를 inconsistent state로 남겨두는 불상사가 발생할 여지가 있다. 이 외에도 작년 내 PyCon KR 발표 또한 asyncio를 쓰면서 겪었던 예상치 못한 삽질들에 대한 소개를 담고 있으니 참고하면 좋겠다.

이와 같은 문제들이 발생하는 근본적인 이유는 asyncio.gather()asyncio.wait() 같이 다중 코루틴을 기다리는 API가 개별 코루틴을 호출하여 끝나기만 기다릴 할 뿐 그 코루틴들 안에서 일어날 수 있는 일들에는 무관심하기 때문이다. 특히, 어느 한 코루틴들이 새로운 코루틴 작업(비동기 제어흐름)을 생성하고 예외가 발생하여 죽었다면, asyncio.gather()를 부른 쪽에서는 그러한 새 코루틴에 대한 정보를 전혀 알 수 없으며 garbage collection 시 동작하는 이벤트루프 자체의 예외처리를 제외하면 그 코루틴에 대한 제어를 완전히 잃어버리게 된다. 더군다나, 이벤트루프에서 해주는 예외처리는 garbage collector 동작 시 호출되므로 그 제어흐름은 asyncio.gather()를 부른 쪽과는 완전 무관하다. 사실 상 예외상황을 로그에 남기는 것 말고는 할 수 있는 게 없다. 이게 코루틴 한두 개로 이루어진 프로그램이라면 모든 제어흐름 분기 지점을 추적해서 일일이 코루틴이나 asyncio.Task 객체의 참조를 어딘가 보관하도록 처리를 하면 되지만, 프로그램이 조금만 복잡해지고(...Backend.AI 라든가...) 적절한 추상화가 없다면 이는 매우 반복적이고 버그가 발생하기 쉬운 지점이 된다.

이를 해결하기 위한 시도가 물론 없었던 것은 아니다. 특히 trio라는 asyncio의 대안 라이브러리를 직접 개발한 Nathaniel Smith의 블로그 글(한국어 번역)을 한번 읽어보길 바란다. trio에서는 nursery라는 추상화를 도입하여 asyncio.gather()를 대체하는데, 각 하위 코루틴들은 현재 nursery 객체에 대한 접근이 가능하여 새로운 중첩 코루틴을 생성할 때 그들에 대한 참조를 nursery 객체가 유지하도록 할 수 있다. nursery 객체는 이렇게 등록된 모든 코루틴들이 완전히 종료되었을 때만 제어흐름을 반환하므로, 이른바 고아가 되는 코루틴이 없어진다. 그래서 저자가 이름을 '탁아소(nursery)'라고 붙인 듯하다. 🍼👶

trio의 nursery 아이디어에는 많은 사람들이 공감하여, 이를 task group이라는 좀더 건조한 이름으로 표준라이브러리에 추가하고자 하는 시도가 있었다. 먼저 이 nursery API만 따로 떼어 표준 asyncio 용으로 포팅하고자 한 aionursery프로젝트가 있었다. 나도 한때 사용을 고려했으나, 어느 순간 보니 망했다. 심지어 저자가 직접 망한 이유를 아주 상세하게 설명해놨다. 한 마디로 요약하면, nursery를 구현하려면 하위 코루틴의 모든 await 구문에서 명시적으로 현재 nursery 및 코루틴의 취소 여부를 nursery를 통해 확인해야 하는데(이른바 "checkpoint"), 현재의 asyncio 구현은 그러한 공통 함수가 존재하지 않는 데다 내부에 다른 await 구문이 없는 비동기 함수를 호출 시에는 await 구문을 사용하더라도 일반적인 동기 함수처럼 동작하기 때문이다. 이에 따라 aionursery는 API는 trio의 nursery를 흉내내었으나 실질적으로 그 semantic을 구현할 수 없었던 것이다.

asyncio의 주요 개발자 중 하나인 Yury Selivanov은 2년 전 트윗을 통해 nursery의 표준화 버전인 asyncio.TaskGroup API를 개발 중임을 알렸으나, Python 3.9 rc2가 나온 글 작성 시점 현재까지도 표준 라이브러리에 들어갈 기미가 없는 상태다. 그 이유로는 aionursery를 제대로 구현할 수 없었던 이유도 있지만, MultiError를 어떻게 표준화된 API로 만들 것인지에 대한 고민이 (처음 nursery API를 제안한 Nathaniel Smith 본인조차) 아직 정리되지 않은 탓으로, 현재로서는 3rd party library로 두는 쪽으로 의견이 모아지고 있다.

대안으로 제시된 anyio는 curio, trio, asyncio 중 아무거나 하나를 골라서 백엔드로 사용할 수 있도록 task, network I/O, subprocess, synchronization primitive API를 모두 한 단계 더 감싸고, asyncio에서는 nursery와 같은 기능의 대체물을 제공하는 방식으로 만들어진 라이브러리이다. 언뜻 보면 그냥 이걸 쓰면 문제가 해결될 것 같지만, checkpoint를 모든 비동기 API에서 강제해야 한다는 점에서 내가 사용하는 다른 asyncio 기반 라이브러리 코드들도 모두 anyio를 거쳐서 asyncio를 사용하도록 변경해야만 제대로 된 구현이 가능하다. (실제로 anyio의 소스코드를 보면 asyncio 백엔드도 asyncio 함수의 proxy가 아니라 decorator처럼 한 단계 감싸서 checkpoint를 넣어주고 있다.) 즉, 이미 asyncio 위에서 쓰여진 코드에는 쓸 수 없는 물건이다.

사실 이러한 라이브러리 간의 비호환성(incompatibility) 문제는 async/await 문법이 태동했을 때부터—정확히는 C#에서 처음 도입—어느 정도 예견된 것이기도 했다. 비동기 함수는 이벤트루프 없이는 context switch가 발생했을 때 무슨 일을 어떻게 할 것인가에 대한 시맨틱을 구현할 수 없기 때문에 필연적으로 이벤트루프와 같은 언어별 내장 구현 디테일 또는 이벤트루프 라이브러리의 구현 디테일에 그 시맨틱이 결합(coupling)될 수밖에 없다. 이런 문제를 함수 컬러링(function coloring) 문제로 묘사한 재미있는 블로그 글을 참고해보기 바란다.

현재로서는, Go 언어에서는 context 객체를 활용하고 C#에서는 CancellationToken을 이용하여 nursery와 유사한 기능을 구현하고 있고, Julia4라든지 Javascript5, Rust67와 같은 언어들도 유사한 API를 도입하고자 하는 논의들이 있는 상황이다. 안타깝게도 Python은 아직 이에 대한 특별한 대책이 표준라이브러리 차원에서 존재하지 않으며, 3.7에서 도입된 contextvars 표준 패키지가 nursery API를 구현하는 데 약간의 도움이 될 수는 있겠지만 위에서 언급한 대로 checkpoint 없이 완전한 구현은 어려운 상태다.

현재 Python asyncio에서 겪게 되는 문제점(과 해결을 위해 넘어야 할 산)들을 다시 요약하면 다음과 같다:

  • function "coloring" 문제 : async 함수는 호출 시 특별한 처리(이벤트루프 API로 감싸든지, 다른 async 함수 안에서 await 하든지)가 필요하다. sync 함수는 아무데서나 부를 수 있다. 이로 인해 한번 코드를 async로 짜기 시작하면 모든 외부 리소스 사용과 관계된 라이브러리를 전부 async 버전을 구해서 쓰든지 thread pool executor로 감싸야 한다. 이걸 일종의 "plague"에 비유하기도 한다.
  • nested callstack의 cancellation 문제 : nursery (task-group) API가 제시되었으나 "MultiError"의 표준 API를 확정하는 문제로 진행이 더딘 상황이다. (python-trio/trio#611)
    • 어떤 라이브러리의 API가 내부적으로 nursery를 사용(=background task를 생성)하는 경우, 안에서 발생한 예외들은 모두 MultiError로 감싸지는데, 기존에 사용하던 API인 경우 MultiError 도입 시 갑자기 하위호환성이 깨지는 문제가 발생할 수 있다.
    • 이에 대한 대안으로, nursery를 생성하는 쪽에서 '나는 내부에서 발생하는 예외를 모두 잘 처리하였다'는 것을 보장할 수 있다면 해당 API는 MultiError를 리턴하지 않는다고 사용자 쪽에서 가정하게 하면 좋을까 아닐까?
    • SystemExit이나 KeyboardInterrupt의 경우 Python 인터프리터 차원에서 특별한 핸들링이 필요한데(exit code 변경이나 SIGINT 발생 여부 검사 등) 이것을 MultiError에서 어떻게 녹여낼 것인가?
    • nursery와 MultiError를 "상황에 따라" 다른 시맨틱으로 여러 버전을 구현할 필요가 있을 것인가? (python-trio/trio#1521)
  • back pressure (flow control) 문제 : 이 글에서는 논외로 보고 따로 언급하지 않았는데, Armin Ronacher가 지적한 대로 aiohttp를 비롯하여 많은 asyncio 기반 라이브러리들이 그 발전 과정에서 프로덕션 레벨로 갔을 때 겪게 되는 문제들 중 하나이다. (aiohttp 3.x에서는 해결되었다) TCP socket을 쓴다면 대개 운영체제에서 알아서 해줄 것을 기대하지만, 스케줄러를 직접 짠다든지 할 때는 나름 고민해봐야 하는 부분이다. 참고로 Backend.AI에서 tus.io 프로토콜 핸들러나 자체적인 대용량 전송을 구현할 때도 in-flight chunk 개수를 queue로 제한하는 방식으로 이에 대한 대응을 하였다. 다른 문제와 마찬가지로, 언어나 프레임워크 차원의 적절한 추상화 지원이 있다면 좋을 것이다.

일단 내가 여기까지 보고 내린 결론은, asyncio 프로그램은 이벤트 루프 자체를 root scope로 하는, 다층적 cancellation scope으로 구성되어야 한다는 점이다. Python에 최종적으로 반영될 구체적인 구현체가 nursery 형식이 될지 Go/C#에 있는 context 형태가 될지 그 둘의 혼합이 될지는 모르겠지만, 무엇이 되었든 필요한 상황이다. Backend.AI에서도 여전히 orphan task가 발생할 수 있는 여지들이 있고, 내가 Callosum 같은 라이브러리를 직접 만들면서도 그런 여지를 없애는 것이 일부러 신경써야만 제대로 할 수 있는 매우 귀찮은 일이었으며 사실 지금도 뭔가 관련된 버그가 없다고 완전히 보장할 수 없다. MultiError는 nursery나 cancellation scope과는 약간 orthogonal한 내용일 수도 있지만 마침 그 논의 과정에서 파생된 것으로, 그 자체로서 꽤 흥미로운 PL 문제가 아닐까 싶다. 이 부분에 대해서는 내 주변의 PL 전문가들과 한번 이야기하는 자리를 만들어보고 싶은데, 이후 혹시 관련된 논의에 진전이 있다면 추가적으로 블로그(혹은 논문?!)를 작성해보고자 한다.


  1. 보통은 병행성보다는 동시성으로 번역하는 경우가 많은데, 글 작성 시점 현재 한국어 위키백과에서는 병행성으로 번역을 하고 있어 일단 해당 번역을 따랐다. 혹시 병행성과 동시성 양측으로 번역이 갈리게 된 배경이나 뉘앙스 차이를 알고 있다면 피드백을 주시면 좋겠다.

  2. 병행(concurrent) 프로그래밍과 병렬(parallel) 프로그래밍의 차이점은, 병행 프로그래밍은 서로 같은 일을 하든 다른 일을 하든 제어흐름이 여러 개 있다는 의미이고(여러 개의 제어흐름을 실제로 동시에 실행하는지는 그리 중요하지 않으나 그렇다고 가정했을 때 발생할 수 있는 다양한 상황을 잘 다루는 것이 중요), 병렬 프로그래밍은 하나의 큰 작업을 여러 개의 작은 작업으로 나눠 이를 연산자원의 도움으로 동시에 여러 개를 실행하는 것(예를 들면 여러 개의 데이터 요소를 처리하기 위해 데이터 요소를 n개의 묶음으로 나눠서 n개의 프로세서로 처리)을 뜻한다. 병렬 프로그래밍을 실제 프로그래밍 언어로 표현할 때 병렬 프로그래밍의 요소들을 활용하게 되는 경우가 많아 개념적으로 헷갈리기 쉽다.

  3. Python에서 try-except 구문을 사용할 때 except 문 뒤에는 잡아내고자 하는 예외 클래스의 목록이 온다. 이때 아무것도 쓰지 않으면 BaseException을 잡게 되는데, 역사적으로 Python에서는 catch-all-exception인 경우를 표현하기 위해서 except: 대신 except Exception:을 권장해왔다(PEP-8 문서의 Programming Recommendations 항목 참조). 그 이유는 BaseException의 파생 클래스로 Exception, KeyboardInterrupt, SystemExit 3가지가 있었는데, 일반적인 런타임 예외는 모두 Exception 아래에 있고 프로그램의 중단 조건을 뜻하는 KeyboardInterrupt(SIGINT를 받으면 발생)와 SystemExit은 '예외'가 아닌 '중단 조건'으로서 특별하게 취급될 필요가 있었기 때문이다. 그런데 asyncio에 와서 CancelledError가 처음에는 Exception의 파생클래스였다보니, cancellation이 일반적인 의미의 런타임 예외라기보다는 중단 조건을 표현하는 목적으로 사용된다는 점과 배치되었다. 즉, asyncio를 사용하고 있을 때 어떤 3rd party 패키지의 함수나 메소드에서 except Exception: 구문을 쓴 경우, 그 함수나 메소드를 부른 코루틴을 정상적으로 cancel할 수 없게 된다. 이미 Python 3.6에서 정식 API로 확정된 상황에서 이를 바꾸는 것을 처음에는 핵심 개발자들이 반대하였으나, 이후 논의를 통해 Python 3.8에서 CancelledErrorBaseException으로 바꾸게 된다. 예외를 오류 조건이 아닌 중단 조건을 표현하기 위해 사용하는 유사한 경우로는 GeneratorExit, StopIteration, StopAsyncIteration가 있으나, 해당 예외 클래스들은 사용자 코드에서 직접적으로 사용되기보다는 이미 for 문법이 이를 내부적으로 잡아서 처리해주고 있기 때문에(사실상 Python의 for 문은 syntactic sugar에 가깝다) 이런 문제가 없다.

  4. JuliaLang/julia#33248

  5. Javascript에서는 this 객체의 특별함 때문에, 일반적인 함수 문법으로 nested callback이나 Promise chain을 기술할 경우 매 함수 끝마다 .bind(this)를 붙여주거나 var _this = this 같은 특수목적 참조를 만들어야 한다. ES6에서 arrow function의 등장으로 이 문제가 완화되기는 하였지만, Javascript를 처음 배우는 입장에서는 왜 같은 일을 하기 위해서 이런 상이한 문법과 시맨틱이 혼재하는지 더 이해하기 어려워졌다.

  6. tokio-rs/tokio#1879

  7. task_scope