Python에서 async/await의 한계와 구조적 동시성, 가상 스레드 기반의 더 나은 스레딩 API를 논의하며, 스레드 그룹, 취소, 뮤텍스·세마포어, futures 등의 설계 방향을 제안한다.
작성일: 2025년 7월 26일
지난해 11월, 스레드의 프로그래밍 인터페이스가 async/await의 인터페이스를 어떻게 능가하는지에 대한 글을 썼다(https://lucumr.pocoo.org/2024/11/18/threads-beat-async-await/). 5월에는 Mark Shannon이 Python 포럼에 Python에 가상 스레드를 도입하자는 제안을 올렸고(https://discuss.python.org/t/add-virtual-threads-to-python/91403), 그 글에서 내가 쓴 그 글도 언급했다. EuroPython에서 그 주제로 이야기를 나눴고, 그러다 보니 그 글의 2부를 아직 쓰지 않았다는 사실이 떠올랐다.
가장 먼저 짚을 점은 async/await가 실제로 Python에 한 가지 매우 좋은 결과를 가져왔다는 것이다. 바로 더 많은 사람들에게 동시성 프로그래밍을 노출했다는 점이다. 언어 차원의 문법 요소를 도입함으로써 동시성 프로그래밍의 문제가 더 많은 사람들에게 드러났다. 불행히도 그 부작용은 사용자에게까지 새어 나오는 매우 복잡한 내부 기계를 요구하고, 색깔 있는 함수를 필요로 한다는 점이다.
반면 스레드는 많은 면에서 훨씬 단순한 개념이지만, 지난 몇 세대를 거치며 여기저기 퍼진 스레딩 API들은 아쉬운 점이 많다. 의심의 여지 없이 async/await는 여러 면에서 그것을 개선했다.
Python에서 async/await가 동작하는 핵심은 await를 호출하기 전에는 아무 일도 일어나지 않는다는 것이다. 즉, 일시 중단되지 않음이 보장된다. 안타깝게도 최근의 프리 스레딩(free-threading) 변화로 이 보장은 상당히 무의미해졌다. 여전히 다른 스레드를 염두에 두고 코드를 작성해야 하기 때문에, 이제는 항상 async 생태계와 스레딩 시스템 양쪽의 복잡성을 모두 껴안게 되었다.
이는 스레드를 전면적으로 수용하는 쪽이 더 나은 길인지 다시 생각해 볼 좋은 시점이다.
Python의 async에서 또 하나 긍정적인 점은 API의 사용성을 개선하기 위한 실험이 많이 이루어졌다는 것이다. 그중 가장 중요한 혁신은 구조적 동시성의 개념이었다. 구조적 동시성은 한 태스크가 부모보다 오래 살아남지 못하도록 금지하는 아이디어다. 이는 예컨대 태스크가 부모 태스크와 관계를 맺을 수 있게 해 주기에 매우 좋은 기능이다. 덕분에 컨텍스트 변수 같은 정보의 흐름이 전통적인 스레드와 스레드 로컬 변수에서보다 훨씬 명확해진다. 전통적인 스레드에서는 스레드가 사실상 부모와의 관계가 없다시피 하기 때문이다.
불행히도 태스크 그룹(파이썬에서 구조적 동시성을 구현하는 방식)은 비교적 최근에 추가된 기능이고, 엄격한 취소 요건이 많은 라이브러리에서 충분히 구현되지 않았다. 왜 이것이 중요한지 이해하려면, 구조적 동시성이 어떻게 작동하는지 알아야 한다. 기본적으로 어떤 태스크를 다른 태스크의 자식으로 생성하면, 인접한 태스크 중 하나가 실패할 때 나머지 태스크들도 모두 취소된다. 이는 견고한 취소를 요구한다.
그리고 일부 태스크가 실제 스레드를 포함할 때 견고한 취소를 구현하기가 매우 어렵다. 예를 들어 매우 인기 있는 aiofiles 라이브러리는 표준 파일에서 진정한 비동기 I/O 동작을 얻을 수 있는 좋은 방법이 플랫폼마다 달리 마땅치 않기 때문에, I/O 작업을 I/O 스레드로 옮기기 위해 스레드 풀을 사용한다. 하지만 취소는 지원하지 않는다. 이로 인해 문제가 발생한다. 여러 태스크를 생성했는데 그중 일부는 읽기(aiofiles)에서 블록 중이며, 그 읽기가 성공하려면 다른 태스크 하나가 완료되어야 할 경우, 취소가 개입되면 실제로 스스로 데드락에 빠질 수 있다. 이는 가설적인 문제가 아니다. 실제로 태스크 그룹에 aiofiles가 존재할 때 인터프리터가 제대로 종료되지 못하는 상황에 빠지는 경우가 여럿 있다. 더 나쁜 점은, 태스크 그룹이 실제로 포착한 예외가 다른 스레드 풀의 블로킹 읽기가 키보드 인터럽트 같은 시그널로 중단될 때까지 눈에 보이지 않는다는 것이다. 이는 개발자 경험으로서 꽤 실망스럽다.
여러 면에서 우리가 정말 원하는 것은 처음으로 돌아가 “더 나은 API의 스레드만을 썼다면 세상은 어떻게 보일까?”라고 말하는 것이다.
스레드만 사용한다면, asyncio를 동기부여했던 성능 문제로 다시 돌아오게 된다. 이를 해결하는 방법은 가상 스레드를 포함할 것이다. 이에 대해서는 이전 글에서 자세히 읽을 수 있다.
가상 스레드를 가능케 하는 핵심 중 하나는 런타임이 비동기 I/O의 여러 도전을 직접 처리하겠다는 약속이다. 즉, 블로킹 연산이 있다면, 해당 가상 스레드를 스케줄러로 되돌리고 다른 스레드가 실행 기회를 얻도록 보장해야 한다.
하지만 이것만으로는 약간의 퇴보처럼 느껴질 것이다. 구조적 동시성을 잃지 않도록 해야 하기 때문이다.
간단히 시작해 보자. 임의 개수의 URL을 순차적으로 내려받는 Python 코드는 어떻게 생겼을까? 대략 이렇게 보일 것이다:
def download_all(urls):
results = {}
for url in urls:
results[url] = fetch_url(url)
return results
아니, 의도적으로 async나 await를 쓰지 않았다. 우리가 원하는 것이 아니기 때문이다. 우리가 원하는 것은 가장 단순한 것, 즉 블로킹 API다.
일반적인 동작은 매우 단순하다. 여러 URL을 내려받는데, 그중 하나라도 실패하면 기본적으로 중단하고 예외를 던지며, 나머지는 더 이상 내려받지 않는다. 그 시점까지 모은 결과는 사라진다.
그렇다면 이 방식을 유지하면서 병렬성을 도입하려면 어떻게 해야 할까? 한 언어가 구조적 동시성과 가상 스레드를 지원한다면, 다음과 같은 상상의 문법으로 비슷한 일을 할 수 있을 것이다:
def download_all(urls):
results = {}
await:
for url in urls:
async:
results[url] = fetch_url(url)
return results
여기서는 의도적으로 await와 async를 쓰고 있지만, 사용법을 보면 오늘날의 것과는 사실상 반대로 되어 있음을 알 수 있다. 이것이 하는 일은 다음과 같다:
내부적으로는 다음과 비슷한 일이 일어난다:
from functools import partial
def download_all(urls):
results = {}
with ThreadGroup():
def _thread(url):
results[url] = fetch_url(url)
for url in urls:
ThreadGroup.current.spawn(partial(_thread, url))
return results
여기 있는 모든 스레드는 가상 스레드다. 스레드처럼 동작하지만 서로 다른 커널 스레드에 스케줄될 수 있다. 생성된 스레드 중 하나라도 실패하면 스레드 그룹 자체가 실패하고, 추가 생성도 막는다. 실패한 스레드 그룹에서는 더 이상 spawn을 호출할 수 없다.
큰 그림에서 보면, 이는 사실 꽤 아름답다. 안타깝게도 Python과는 그리 잘 맞지 않는다. 숨은 함수 선언이라는 개념이 Python에는 실질적으로 없기 때문에 이 문법은 예상 밖이다. 또한 Python의 스코핑은 이 방식과 그리 잘 맞지 않는다. Python에는 변수 선언 문법이 없기 때문에, 실제로 함수에는 단일 스코프만 존재한다. 이는 꽤 아쉬운데, 예를 들어 루프 본문에서 선언한 헬퍼가 루프의 반복 변수를 제대로 클로저로 포착할 수 없음을 의미하기 때문이다.
그럼에도 불구하고 여기서 가져가야 할 중요한 점은, 이런 유형의 프로그래밍에는 퓨처(future)를 생각할 필요가 없다는 것이다. 물론 퓨처를 지원할 수는 있겠지만, 그런 추상화에 의존하지 않고도 아주 많은 프로그램 코드를 표현할 수 있다.
그 결과, 이런 시스템을 다룰 때 고려해야 할 개념이 훨씬 줄어든다. 프로그래머에게 퓨처나 프라미스, 비동기 태스크 같은 개념을 노출할 필요가 없다.
이런 특정 문법이 Python에 잘 맞는다고는 생각하지 않는다. 그리고 자동 스레드 그룹이 정답인지도 다소 논쟁의 여지가 있다. async/await에서 하던 방식처럼 스레드 그룹을 명시적으로 만드는 식으로 모델링할 수도 있다:
from functools import partial
def download_and_store(results, url):
results[url] = fetch_url(url)
def download_all(urls):
results = {}
with ThreadGroup() as g:
for url in urls:
g.spawn(partial(download_and_store, results, url))
return results
전반적인 동작은 여전히 거의 동일하지만, 좀 더 명시적인 연산을 사용하고 헬퍼 함수를 더 만들어야 한다. 그래도 여전히 프라미스나 퓨처를 다루지 않아도 된다.
이 개념에서 매우 중요한 점은 동시성 프로그래밍의 많은 복잡성을 제자리, 즉 인터프리터와 내부 API로 옮긴다는 것이다. 예를 들어 위의 results 딕셔너리는 이 방식이 작동하려면 잠금이 필요하다. 마찬가지로 fetch_url이 사용하는 API는 취소를 지원해야 하고, I/O 계층은 가상 스레드를 일시 중단한 뒤 스케줄러로 되돌려 보내야 한다. 하지만 대부분의 프로그래머에게는 이 모든 것이 감춰진다.
또한 일부 API는 잘 동작하는 동시성 시스템을 지원하기에는 세월이 흐르며 많이 낡았다고 생각한다. 예를 들어, 나는 러스트가 값 자체를 뮤텍스에 포위(encapsulate)하는 아이디어가, 어딘가에 뮤텍스를 따로 들고 다니는 방식보다 훨씬 마음에 든다.
그리고 세마포어는 동시성을 제한하고 더 안정적인 시스템을 만드는 데 매우 강력한 수단이다. 이런 기능을 스레드 그룹의 일부로 포함해, 동시에 발생할 수 있는 spawn의 개수를 직접 제한할 수도 있을 것이다.
from functools import partial
def download_and_store(results_mutex, url):
result = fetch_url(url)
with results_mutex.lock() as results:
results.store(url, result)
def download_all(urls):
results = Mutex(MyResultStore())
with ThreadGroup(max_concurrency=8) as g:
for url in urls:
g.spawn(partial(download_and_store, results, url))
return results
퓨처를 써야 할 이유는 여전히 많고, 그것들은 계속 남아 있을 것이다. 퓨처를 얻는 한 가지 방법은 spawn 메서드의 반환값을 보관하는 것이다:
def download_and_store(results, url):
results[url] = fetch_url(url)
def download_all(urls):
futures = []
with ThreadGroup() as g:
for url in urls:
futures.append((url, g.spawn(lambda: fetch_url(url))))
return {url: future.result() for (url, future) in futures}
큰 질문 하나는 스레드 그룹이 없을 때도 spawn이 동작해야 하느냐이다. 예를 들어 Python의 비동기 라이브러리인 Trio에서는, 작업을 생성하려면 반드시 스레드 그룹에 해당하는 개체—그들은 이를 nursery라고 부른다—가 항상 있어야 한다고 결정했다. 이는 매우 합리적인 정책이라고 생각하지만, 그렇게 할 수 없는 상황도 있다. 예를 들어 백그라운드 태스크를 위한 기본 스레드 그룹을 두고, 프로세스 종료 시 암묵적으로 조인되도록 하는 등 다양한 대안을 상상할 수 있다. 그러나 가장 중요한 점은 의도한 동작의 상당 부분을 기본 API에 최대한 담아내는 것이라고 생각한다.
이 시스템 안에서 async/await의 미래는 무엇일까? 논의할 여지가 있지만, 기존 코드의 비동기 기능을 계속 유지할 방법을 찾는 것은 꽤 합리적으로 보인다. 다만 앞으로의 코드에는 전혀 필요하지 않을 것이라고 생각한다.
이 글을 가상 스레드에 대한 대화의 출발점으로 받아들여 주기 바란다. 이는 완전히 다듬어진 아이디어라기보다 탐색에 가깝다. 특히 Python 맥락에서 열려 있는 질문이 많지만, 더 이상 색깔 있는 함수를 다루지 않아도 된다는 생각은 내게 매우 매력적이며, 함께 더 탐구해 볼 수 있기를 바란다.
이 글의 태그: async, javascript, python, thoughts
markdown로 복사 / 보기 markdown