Async Rust: 스케줄러는 어디에 있는가?
요약
본 기사는 Async Rust의 복잡성과 설계상의 문제점들을 심층 분석합니다. 런타임 의존성, 취소 안전성, 그리고 Future의 상태 머신 특성 등 다양한 난제들이 언급되며, Zig처럼 I/O 인터페이스를 명시적으로 전달하여 런타임 독립성을 확보하는 방안을 제시합니다.
핵심 포인트
- Async Rust는 동시성 문제와 라이브러리 설계 문제가 복합적입니다.
- Tokio의 암묵적 실행기 문맥은 의존성을 만들고 패닉 위험을 높일 수 있습니다.
- I/O 인터페이스를 명시적으로 전달하면 런타임 독립성을 확보할 가능성이 있습니다.
- Future는 상태 머신이므로, 깊은 중첩이나 크기 문제가 발생할 수 있습니다.
동시성에는 스케줄링을 담당할 런타임이 필요 하며, async 대신 스레드를 쓰면 그 역할이 Tokio에서 커널로 옮겨갈 뿐임
Rust가 런타임을 크레이트로 분리 한 것은 임베디드, no_std
, FFI 비용, 제로 비용 추상화라는 목표에 맞는 선택이지만, 런타임 간 호환성과 Send + 'static
제약 등의 부담을 낳음
async Rust의 복잡성은 동시성 자체의 문제 와 스케줄러를 라이브러리에 둔 설계의 문제 로 나눠야 하며, 모든 용도에 최적인 스케줄러나 항상 최소 크기인 Future를 기대할 수는 없음
Tokio의 암묵적 실행기 문맥 은 함수 시그니처에 드러나지 않는 의존성을 만들고, 같은 함수도 호출하는 스레드에 따라 런타임 패닉을 일으킬 수 있음
Zig처럼 I/O 인터페이스를 명시적 매개변수로 전달 하면 스케줄러 교체와 런타임 독립성을 확보할 가능성이 있지만, 아직 실전에서 검증되지 않았으며 Rust에 전면 도입하려면 표준 라이브러리 API 재설계가 필요함
async Rust에 쌓인 불만들
async Rust에 대한 흔한 불만은 5×5 빙고판 을 채울 만큼 다양하며, 언어 기능, 런타임, 메모리 관리, 취소 안전성 문제가 서로 얽혀 있음
빌림, 함수 색상, 런타임 의존성
소멸, 실행, 생태계 분리
복잡한 컴파일러 오류와 실행 추적 은 async Rust에서 가장 흔한 불만 중 하나임
Async Drop 문제는 Rust의 Drop
이 동기식 이라는 데서 발생함
정리 작업이 블로킹될 수 있지만, 비동기 문맥에서는 스레드를 막지 않도록 제어권을 양보할 수 있어야 함
Future는 지연 실행 되므로 생성만 해서는 진행하지 않으며, .await
하거나 태스크로 실행해야 함
생성 직후 실행을 시작하는 JavaScript Promise와 달라 초보자가 자주 실수함
그린 스레드와 스택을 가진 코루틴 은 언어가 자체 런타임을 제공하는 모델이며, Go의 goroutine이 그 예임
Rust는 RFC 230 으로 기존 런타임을 제거했으며, Graydon Hoare의 회고 에서도 초기 구상을 볼 수 있음
Future와 라이브러리가 항상 런타임 독립적인 것은 아님
취소 안전성과 타입 제약
동적 디스패치, I/O, Future 크기
APIT, RPIT, TAIT, RPITIT 는 서로 다른 위치에서 impl Trait
을 사용할 수 있게 하는 기능들 임
dyn 호환성과 동적 디스패치 에는 제약상 박싱이 필요할 수 있어 Pin<Box<dyn Future<Output = T> + Send>>
같은 긴 타입이 등장함
io_uring
은 2019년에 등장한 Linux 비동기 I/O 인터페이스로, 기존 epoll
보다 성능이 좋거나 적어도 시스템 호출 수를 줄일 수 있음
작업이 끝날 때까지 커널이 버퍼를 소유하는 방식은 드롭으로 Future를 취소하는 모델과 충돌함
Tokio에서는 &mut
참조 대신 버퍼를 이동시키는 API가 필요하며, tokio-uring 설계 가 이를 다룸
Tokio의 완전한 지원은 아직 이뤄지지 않은 것으로 보이며 , API 호환성 보장도 이유 중 하나임
다른 일부 런타임은 io_uring
을 지원함
임베디드에서의 async 필요성 은 Embassy 가 대표적으로 보여줌
Why Async Rust 의 논리처럼 내장 런타임이나 그린 스레드는 Rust의 임베디드 용도와 충돌함
중첩된 Future의 크기 는 스택 오버플로를 일으킬 수 있음
Future가 비동기 함수의 상태를 담는 상태 머신이기 때문에 깊은 중첩이 문제가 되며, 힙에 직접 생성하는 기능의 부재로 Box::pin(future)
도 이를 피하지 못할 수 있음
Future 크기에 관한 사례 에서 이 문제를 확인할 수 있음
실행기 모델과 미완성 언어 기능
코어당 스레드와 작업 훔치기(work stealing) 는 서로 다른 절충을 택함
Tokio의 작업 훔치기는 태스크를 CPU 코어 사이로 이동시켜 각 스레드에 일을 배분하지만 Send
제약을 요구함
로컬 async 논의 , 다른 모델을 택한 Glommio , Without Boats의 응답 에서 관련 논쟁을 볼 수 있음
표준 라이브러리에 실행기를 넣자는 제안 은 다양한 실행기가 실제로 필요하다는 점과 작은 표준 라이브러리라는 Rust의 목표에 부딪힘
태스크 생성 API를 표준화 해 런타임 간 호환성을 높이는 대안도 있지만, 작업 훔치기 런타임의 Send
요구와 코어당 스레드 런타임의 요구가 달라 어려움
제너레이터, yield
, AsyncIterator
, 코루틴 은 아직 불안정하며 2026년 우선순위도 아님
비동기 반복자 트레이트의 형태 와 빌려주는 반복자(lending iterator) 문제가 얽혀 있어 Python식 yield
블록 이상의 설계가 필요함
블로킹 작업 이 Tokio 작업자 스레드를 점유하면 다른 스레드가 일을 가져가 프로그램은 계속 움직일 수 있지만, 블로킹이 끝날 때까지 작업자 하나를 잃음
트래픽이 적으면 발견하기 어려우며, spawn_blocking
과 블로킹의 의미 가 관련 주제임
Pin
대신 적절한 Move
트레이트 를 원한다는 논의도 계속됨
Move 트레이트 목표 와 Without Boats의 Pin
해설 이 관련 자료임
런타임을 없애도 스케줄러는 사라지지 않음
우선순위와 스케줄링의 복잡성 은 async Rust만의 문제가 아니라 동시성 코드의 일반적인 문제임
스케줄링 알고리듬은 최적화 문제이며, 서로 충돌하는 목표 사이에서 절충점을 선택해야 함
async를 버리고 스레드를 쓰면 Tokio 대신 커널이 스케줄러가 되며, 직접 이벤트 루프를 작성하면 직접 런타임을 유지보수하게 됨
Rust가 복잡성을 노출하는 것 자체를 결함으로 볼 수는 없음
저수준 제어, 고수준 추상화, 높은 성능을 함께 추구하므로 모든 사용 사례에 맞는 스케줄러를 언어가 대신 선택할 수 없음
Go는 편의성을 위해 성능 일부를 교환하고 goroutine을 제공하지만 Rust는 같은 절충을 하기 어려움
Rust의 다른 기능에는 런타임이 없다는 표현에도 예외는 있으며, unwind
와 패닉 스택 처리 역시 런타임으로 볼 수 있음
제로 비용 Future가 항상 최소 크기는 아님
Future가 데이터를 담는 데 필요한 만큼만 차지하는 최적 크기 상태 머신 으로 컴파일된다는 믿음은 현실과 다름
중단 지점을 넘어 유지되는 인수가 중복 공간을 차지하는 문제 가 있음
[u8; 8192]
인수를 받고 wait().await
뒤에 drop(arg)
를 호출하는 예제에서는 기대 크기가 8,194바이트 , 실제 크기가 16,386바이트 임
Rust Playground 예제 에서는 drop(arg)
를 제거했을 때의 변화도 확인할 수 있음
Future를 인수로 전달하면 크기가 지수적으로 증가 하는 문제도 있으며, Ding 이 관련 수정 을 진행하고 있음
스케줄러를 크레이트에 둔 이유와 대가
동시성이 필요하고 런타임을 피할 수 없다면 , 남는 질문은 런타임이 어디에 존재하고 누가 스택을 관리하는가임
Rust의 현재 선택은 런타임을 크레이트로 가져와 초기화하고 Future를 스케줄링 하는 것임
스케줄러를 라이브러리에 둔 결과 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
에서 호출하면 정상 작동하지만, 같은 함수를 rayon::spawn
안에서 실행하면 패닉과 프로세스 중단이 발생함
이런 모델은 함수들이 숨겨진 인수와 동적 범위의 전역 상태 에 의존하는 것과 비슷하며, Rust의 황금률 의 취지와 충돌함
Rust에는 효과 시스템이 없어 패닉 가능성이나 부수 효과를 컴파일 시점에 추적하지 못한다는 한계도 있음
대안은 태스크를 생성하는 지점까지 실행기를 명시적으로 전달 하는 것임
Zig처럼 스케줄러를 매개변수로 전달하기
Zig의 새로운 async I/O 접근 은 I/O가 필요한 함수마다 인터페이스를 전달 함
비동기 실행과 I/O의 구체적 동작은 전달한 구현에 달려 있으며, 인터페이스가 런타임의 계약을 정의함
Loris Cro의 예제 에서 saveData(io: Io, data: []const u8)
는 파일 생성, 닫기, 쓰기, 표준 출력에 모두 io
를 전달함
모든 코드가 인터페이스 전달 원칙을 지킨다면 세 가지 효과 를 기대할 수 있음
블로킹 작업이 일어날 수 있는 위치를 정확히 추적할 수 있음
함수 색상을 별도 언어 기능이 아니라 함수 매개변수로 표현할 수 있음
다른 I/O 구현을 전달해 라이브러리를 실행기와 스케줄러에 독립적으로 만들고 생태계 분리를 피할 수 있음
함수 색상이 완전히 사라진다고 단정할 수는 없음
색상을 매개변수로 인코딩하는 방식이며, 이것도 함수 색상인지에 대해서는 여전히 의견이 갈릴 수 있음
스케줄러를 커널이나 암묵적 실행 문맥 대신 전달하고 교체할 수 있는 매개변수 로 두는 방식은 장황하지만 여러 문제를 함께 해결할 가능성이 있음
Zig는 아직 안정화되지 않았고 async 설계는 매우 어려우므로, 이 접근은 실전에서 검증된 해법이 아님
Zig가 이를 성공시킨다면 Rust에서도 비슷한 방향을 실험할 가치가 있음
Rust에서 전면 적용하려면 std::fs
등 표준 라이브러리 API를 다시 설계 해야 하므로 현실적으로 어렵지만, 크레이트 생태계에는 같은 제약이 없어 실험할 여지가 있음
댓글과 토론
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기