스크랩

Async Rust는 왜 어려운가? 스케줄러는 사라지지 않는다

Async Rust는 왜 어려운가? 스케줄러는 사라지지 않는다

한 줄 요약

async Rust가 복잡한 이유는 "async 때문"이 아닙니다. 동시성 자체의 어려움과 스케줄러를 언어가 아닌 라이브러리(크레이트)에 둔 설계 결정이 겹쳐 있기 때문입니다. async를 버리고 스레드를 쓴다고 해서 스케줄러가 없어지는 건 아니고, 그 역할이 Tokio에서 커널로 옮겨갈 뿐입니다.

async Rust에 쌓인 불만들

흔히 거론되는 불만은 5×5 빙고판을 채울 만큼 다양합니다. 그리고 이 문제들은 서로 얽혀 있습니다.

빌림, 함수 색상, 런타임 의존성

  • Scoped task trilemma: 동시성, 병렬성, 빌림 중 대략 둘만 고를 수 있습니다. 메모리 누수가 가능한 Rust에서는 소멸자 실행을 보장할 수 없고(leakpocalypse), 그래서 초기 scoped threads API는 데이터 경합을 허용했습니다.
  • 함수 색상(function coloring): Bob Nystrom의 글로 널리 알려졌지만, 용어의 의미 자체에도 합의가 없습니다.
  • async 클로저는 도입까지 약 6년이 걸렸습니다. 반환하는 Future가 클로저의 캡처를 빌릴 수 있어 "빌려주는(lending)" 방식이 필요했고, 논의는 고차 타입까지 번졌습니다.
  • Tokio 중심주의: Tokio만을 유의미한 런타임으로 취급하거나, Tokio에만 해당하는 특성을 async Rust 전체의 특성으로 오해하는 문제입니다.
  • **"그냥 스레드를 쓰라"**는 견해도 있습니다. 동시 연결 1만 개 이상을 처리하는 서버가 아니라면 스레드 풀로 충분하다는 입장이죠.

소멸, 실행, 생태계 분리

  • Async Drop 문제: Rust의 Drop은 동기식입니다. 정리 작업이 블로킹될 수 있는데, 비동기 문맥에서는 스레드를 막지 않도록 제어권을 양보할 수 있어야 합니다.
  • 지연 실행: Future는 .await하거나 태스크로 실행해야 진행합니다. 생성 직후 바로 실행되는 JavaScript의 Promise와 달라서 초보자가 자주 걸려 넘어집니다.
  • 그린 스레드 제거: Go의 goroutine처럼 언어가 자체 런타임을 제공하는 모델도 있지만, Rust는 RFC 230으로 기존 런타임을 제거했습니다.
  • 런타임 독립성은 보장되지 않습니다: 태스크 생성 방식과 트레이트에 런타임별 가정이 들어가서, 잘못 조합하면 "Tokio reactor가 없다"는 오류를 만납니다. 거기에 동기/비동기 라이브러리 분리까지 겹쳐 생태계가 단절됩니다.

취소 안전성과 타입 제약

  • select!의 취소 안전성은 대표적인 함정입니다. 분기에서 Future를 뜻하지 않게 드롭할 수 있습니다.
  • Send + Sync + 'static 제약이 코드 전반으로 퍼지는 것도 흔한 불만입니다.
  • Futurelock: Future A의 자원이 있어야 B가 진행하는데, 담당 태스크가 더 이상 A를 폴링하지 않아 생기는 교착 상태입니다.
  • 효과 시스템, keyword generics, maybe async는 동기/비동기 함수를 따로 작성하지 않으려는 접근입니다. 함수 하나를 쓰면 컴파일러가 두 버전을 만들고 호출 문맥에 맞게 고르는 구상인데, 적절한 해법인지에는 회의적인 시각도 있습니다.

동적 디스패치, I/O, Future 크기

  • dyn 호환성 제약으로 박싱이 필요해 Pin<Box<dyn Future<Output = T> + Send>> 같은 긴 타입이 등장합니다.
  • io_uring(2019년 등장)은 epoll보다 성능이 좋거나 적어도 시스템 호출 수를 줄여줍니다. 그런데 작업이 끝날 때까지 커널이 버퍼를 소유하는 방식이라, 드롭으로 Future를 취소하는 모델과 충돌합니다. Tokio에서는 &mut 참조 대신 버퍼를 이동시키는 API가 필요하고(tokio-uring 설계), 완전한 지원은 아직 이뤄지지 않은 것으로 보입니다. API 호환성 보장도 이유 중 하나입니다.
  • 임베디드에서도 async는 필요합니다. Embassy가 그 증거이고, 내장 런타임이나 그린 스레드는 이 용도와 충돌합니다.
  • 중첩된 Future의 크기는 스택 오버플로를 일으킬 수 있습니다. Future가 비동기 함수의 상태를 담는 상태 머신이라 깊은 중첩이 문제이며, 힙에 직접 생성하는 기능이 없어서 Box::pin(future)로도 못 피하는 경우가 있습니다.

실행기 모델과 미완성 언어 기능

  • 코어당 스레드 vs 작업 훔치기(work stealing): 서로 다른 절충입니다. Tokio의 작업 훔치기는 태스크를 코어 사이로 옮겨 일을 배분하지만 Send 제약을 요구합니다. Glommio는 다른 모델을 택했습니다.
  • 표준 라이브러리에 실행기를 넣자는 제안은 다양한 실행기가 실제로 필요하다는 점과 작은 표준 라이브러리라는 목표에 부딪힙니다. 태스크 생성 API만 표준화하는 대안도 있지만, 작업 훔치기 런타임과 코어당 스레드 런타임의 요구가 달라 어렵습니다.
  • 제너레이터, yield, AsyncIterator, 코루틴은 아직 불안정하며 2026년 우선순위도 아닙니다. 비동기 반복자 트레이트의 형태와 빌려주는 반복자 문제가 얽혀 있어 Python식 yield 블록 이상의 설계가 필요합니다.
  • 블로킹 작업이 Tokio 작업자 스레드를 점유하면 다른 스레드가 일을 가져가 프로그램은 돌아가지만, 블로킹이 끝날 때까지 작업자 하나를 잃습니다. 트래픽이 적으면 발견하기 어렵죠(spawn_blocking이 관련 주제).
  • Pin 대신 제대로 된 Move 트레이트를 원한다는 논의도 계속됩니다.

런타임을 없애도 스케줄러는 사라지지 않는다

여기가 글의 핵심입니다.

  • 우선순위와 스케줄링의 복잡성은 async Rust만의 문제가 아니라 동시성 코드 일반의 문제입니다.
  • 스케줄링 알고리듬은 최적화 문제이고, 서로 충돌하는 목표 사이에서 절충점을 골라야 합니다.
  • async를 버리고 스레드를 쓰면 Tokio 대신 커널이 스케줄러가 됩니다. 직접 이벤트 루프를 짜면 직접 런타임을 유지보수하게 됩니다.

그래서 Rust가 이 복잡성을 드러낸다는 것 자체를 결함으로 보기는 어렵습니다. 저수준 제어, 고수준 추상화, 높은 성능을 동시에 추구하는 언어는 모든 사용 사례에 맞는 스케줄러를 대신 골라줄 수 없습니다. Go는 편의성을 위해 성능 일부를 교환하고 goroutine을 제공하지만, Rust는 같은 절충을 하기 어렵습니다.

참고로 "Rust에는 런타임이 없다"는 표현에도 예외가 있습니다. unwind와 패닉 스택 처리도 런타임으로 볼 수 있습니다.

제로 비용 Future가 항상 최소 크기는 아니다

Future가 "데이터를 담는 데 필요한 만큼만" 차지하는 최적 크기 상태 머신으로 컴파일된다는 믿음은 현실과 다릅니다. 설계 목표와 실제 구현 사이에 오래된 간극이 남아 있습니다.

  • 중단 지점을 넘어 유지되는 인수가 중복 공간을 차지합니다. [u8; 8192] 인수를 받고 wait().await 뒤에 drop(arg)를 호출하는 예제에서 기대 크기는 8,194바이트인데 실제는 16,386바이트입니다.
  • Future를 인수로 전달하면 크기가 지수적으로 증가하는 문제도 있습니다. 관련 수정은 진행 중입니다.

스케줄러를 크레이트에 둔 이유와 대가

동시성이 필요하고 런타임을 피할 수 없다면, 남는 질문은 하나입니다. 런타임이 어디에 존재하고, 누가 스택을 관리하는가?

| 선택 | 대가 | | --- | --- | | 직접 이벤트 루프 | 자체 런타임을 운영·유지보수해야 함 | | 커널 스레드 | 특화된 Tokio 태스크보다 훨씬 무거움 | | 내장 런타임(Go 방식) | 다른 환경에 내장하기 어렵고 FFI 경계에 비용 발생 | | 크레이트 런타임(Rust의 선택) | Tokio 중심주의, 런타임 종속성, Send + 'static 제약 |

언어는 런타임이 태스크를 스레드 사이로 옮기는지 알 수 없으므로, Send 제약이 제네릭 코드 전반에 퍼집니다.

그린 스레드 제거는 Rust를 C++ 대체 언어, 임베디드 no_std 환경, 제로 비용 추상화 쪽으로 확정한 설계 결정이었습니다. 덕분에 C#, D, Swift와 달리 가비지 수집이나 참조 카운팅을 암묵적으로 도입하지 않는 메모리 안전 C++ 대안이라는 차별성을 얻었습니다. 그린 스레드를 유지했다면 Rust가 Linux 커널에 들어갔을지는 알 수 없지만, 기술적·정치적으로 가능성은 낮았을 것으로 추측합니다.

더 저렴한 커널 스레드라는 대안

커널 스레드와 스케줄링을 개선하거나 새로운 종류의 스레드를 도입해서, "그냥 스레드를 쓰는" 방식 자체를 효율적으로 만들 수는 없을까요?

  • goroutine이 커널 위에서 저렴하고 빠르게 동작한다면, 더 낮은 계층인 커널에서도 유사한 기능을 지원할 여지가 있습니다.
  • M:N 사용자 수준 스레딩 시도는 이미 있었지만, 어떤 프로그램이 이익을 얻고 어떤 한계가 있는지는 추가 검토가 필요합니다.
  • 커널은 언어별 런타임만큼 코드의 특성을 알지 못합니다. 필요한 추가 정보를 커널에 전달하지 않는 한, 언어별 런타임이 계속 유리할 가능성이 큽니다.
  • 이상적으로 성공해도 "모든 일을 스레드로 처리하던 모델을 더 빠르게 만드는" 셈이고, 그게 더 나은지는 단정할 수 없습니다.

Tokio의 암묵적 실행기 의존성

마지막으로, 설계가 실제로 사람을 물어버리는 지점입니다.

tokio::spawn은 스레드 로컬 실행 문맥에 암묵적으로 연결됩니다. 해당 문맥이 없으면 런타임 패닉이 발생합니다. 예를 들어 checksum 함수가 내부에서 record_metric을 거쳐 tokio::spawn을 호출한다고 해봅시다. Tokio의 main에서 호출하면 잘 작동하지만, 똑같은 함수를 다른 스레드에서 호출하면 패닉입니다.

문제는 이 의존성이 함수 시그니처에 전혀 드러나지 않는다는 점입니다. 타입만 봐서는 "이 함수는 Tokio 런타임 안에서만 호출 가능"이라는 사실을 알 수 없습니다.

대안으로 Zig처럼 I/O 인터페이스를 명시적 매개변수로 전달하는 방식이 거론됩니다. 스케줄러 교체와 런타임 독립성을 확보할 가능성이 있지만, 아직 실전에서 검증되지 않았고 Rust에 전면 도입하려면 표준 라이브러리 API 재설계가 필요합니다.

정리하며

async Rust를 비판하거나 옹호하기 전에, 먼저 둘을 나눠야 합니다.

  1. 동시성 고유의 문제 — 어떤 언어를 쓰든 피할 수 없습니다.
  2. 크레이트형 런타임의 문제 — Rust의 설계 선택에서 비롯된 것이고, 개선 여지가 있습니다.

모든 문제를 없애는 미발견 추상화가 있을지는 불확실합니다. 다만 "스케줄러가 어디에 있는가"라는 질문을 던져보면, async Rust의 불편함 중 어디까지가 필연이고 어디부터가 개선 가능한 영역인지 훨씬 선명해집니다. 사용 편의성 부족은 2024년 하반기 목표와 2026년 목표에서도 계속 개선 대상으로 남아 있습니다.

원문 출처: GeekNews - 개발/기술/스타트업 뉴스 서비스


원문: https://news.hada.io/topic?id=34882