- Rust의
async/await는 스레드의 단순 대체물이 아니라, I/O 중심 동시성 코드를 조합 가능한 상태 머신으로 표현하는 프로그래밍 모델임 - 웹 서버처럼 여러 연결을 동시에 다뤄야 하는 코드는 선형 실행만으로 한계가 생기며, 스레드는
thread::spawn으로 클라이언트 처리를 분리해 동시 처리를 가능하게 함 async/await는await지점에서 실행을 양보하고, 실행기(executor)가 다른 작업을 이어 실행하게 만들어 많은 작업을 한 런타임 안에서 교차 실행함- 3초 타임아웃 같은 요구사항은
async에서race와Timer조합으로 붙일 수 있지만, 동기 스레드 코드에서는TcpStream전용 래퍼와 읽기/쓰기 타임아웃 설정이 필요해 범용성이 떨어짐 - 성능 오버헤드만으로
async를 설명하면 CPU bound 작업에서 반례가 생기며, Rustasync의 강점은 의미론적 표현력과 생태계 조합성에 있음
Rust 동시성 문제의 출발점
- Rust의 일반 코드는 기본적으로 선형 실행 구조임
foo(),bar(),baz()처럼 한 작업이 끝난 뒤 다음 작업이 실행됨
- 웹 서버처럼 여러 일을 동시에 처리해야 하는 경우 선형 구조는 빠르게 한계에 도달함
TcpListener::accept()로 클라이언트를 받고handle_client()를 실행하는 구조에서는 첫 번째 클라이언트 처리 중 두 번째 클라이언트가 기다려야 함handle_client()가 몇 밀리초 걸리고 동시 클라이언트가 2명이면 짧은 대기가 생김- 동시 클라이언트가 200만 명이면 큐 끝의 사용자는 몇 분을 기다릴 수 있음
스레드가 해결하는 방식
- 운영체제 스레드는 레지스터 값과 프로그램 스택을 메모리에 저장하고, 다른 루틴을 실행한 뒤 나중에 원래 루틴을 재개할 수 있음
- 웹 서버 코드는
thread::spawn(move || handle_client(client))형태로 클라이언트 처리를 별도 스레드에 맡김- 메인 스레드는 새 연결을 계속
accept()함 - 클라이언트 처리 스레드가 블로킹되면 OS가 메인 스레드로 돌아와 다음 연결을 받을 수 있음
- 두 클라이언트는 몇 마이크로초 수준의 지연 뒤 병렬로 실행될 수 있음
- 메인 스레드는 새 연결을 계속
- 프로덕션급 웹 서버가 수십 개 CPU 코어를 가진 경우, OS는 스레드들이 동시에 실행되는 것처럼 보이게 할 뿐 아니라 실제로 여러 스레드를 동시에 실행할 수 있음
async/await가 동작하는 방식
- 사용자 공간 동시성에는 이벤트 기반 프로그래밍, 액터, 코루틴 등 여러 모델이 있고, Rust가 선택한 방식은
async/await임 - 단순화하면 프로그램은 서로 독립적으로 실행 가능한 상태 머신 묶음으로 컴파일됨
async fn은 전통적인 함수가 아니라 상태 머신을 반환하는 함수임await는 다른 상태 머신을 현재 상태 머신의 일부 단계로 포함함- 내부 함수가 새 연결 대기처럼 실행을 양보하면 전체 상태 머신이 상위 실행기(executor)에 제어권을 넘김
smol::Executor같은 실행기는 현재 상태 머신 대신spawn으로 생성된 다른 상태 머신을 실행함async move { handle_client(client).await }블록은main과 독립된 새 상태 머신임main이 양보하면 클라이언트 작업 중 하나가 실행되고, 그 작업이 다시 양보하면 다음 작업으로 순환함
- 이 구조로 수백만 클라이언트를 동시에 다룰 수 있지만, 실행기, 태스크, 상태 머신 같은 개념이 추가되어 복잡도도 올라감
타임아웃 예시에서 드러나는 조합성
- Rust의 강점 중 하나는 조합성임
Iterator는 여러 조합자를 붙이고, 결과를 다시Iterator를 받는 함수에 넘길 수 있음mpsc::channel()의recv.try_iter().filter(...).map(...)처럼 값을 필터링하고 변환해 리스트에 추가할 수 있음
async/await는 이런 조합성을 I/O bound 함수에도 적용하게 해줌handle_client()가read_to_end,do_something_with_data,write_all을await하는 비동기 함수라면, 3초 타임아웃은 두 Future를 조합해 구현할 수 있음- 이 방식은
TcpStream에만 묶이지 않음impl AsyncRead + AsyncWrite를 구현하는 대상이면 같은 패턴을 적용할 수 있음- 일반 스트림 위의 GZIP 스트림, Unix 소켓, 파일 같은 대상도 대체 가능함
동기 스레드 코드에서 같은 타임아웃을 구현할 때의 제약
- 블로킹 코드에서는 일반적으로
read나write시스템 호출을 중단하기 어렵고, 파일 디스크립터를 닫는 식의 방법은 Rust에서 쓸 수 없음 TcpStream은set_read_timeout과set_write_timeout을 제공함- 읽기와 쓰기 각각에 타임아웃을 설정할 수 있음
- 하지만 클라이언트가 2.9초마다 1바이트씩 보내면 단순 타임아웃은 계속 초기화될 수 있음
- 이를 방어하려면
TcpStream을 감싼DeadlineStream같은 타입을 만들고, 전체 데드라인까지 남은 시간을 매번 계산해 읽기/쓰기 타임아웃에 설정해야 함 - 이 접근은 동작할 수 있지만 제약이 큼
TcpStream에 묶임- Rust에는
set_read_timeout과set_write_timeout사용을 추상화하는 trait가 없음 - 범용 writer에 적용하려면 추가 작업이 많이 필요함
- 타임아웃 설정을 위한 추가 시스템 호출이 들어감
- 실제 웹 서버 로직에서는 사용이 더 번거로울 수 있음
Rust async 생태계의 사례
- HTTP 생태계가 클라이언트까지 포함해
async/await를 주요 런타임 메커니즘으로 채택한 데에는 함수 조합성이 있음- HTTP 호출을 만드는 함수를 다양한 구멍과 사용 사례에 맞춰 끼워 넣을 수 있음
tower는async/await조합성을 보여주는 대표 사례임- 서비스를
async함수로 구현하면 타임아웃, 속도 제한, 로드 밸런싱, hedging, 백프레셔 처리를 붙일 수 있음 - 어떤 런타임을 쓰는지, 서비스 내부가 무엇을 하는지와 무관하게
tower를 적용해 견고성을 높일 수 있음
- 서비스를
macroquad는 Rust용 소형 게임 엔진이며, 메인 함수에async/await를 사용해 엔진을 실행함- Rust에서 어떤 작업을 기다리기 위해 선형 함수를 멈춰야 하는 상황을 표현하는 데
async/await가 적합함 - 같은 스레드에서 게임 서버 네트워크 연결과 GUI 프레임워크를 동시에 폴링하는 식의 구성이 가능함
- Rust에서 어떤 작업을 기다리기 위해 선형 함수를 멈춰야 하는 상황을 표현하는 데
성능만으로 async를 설명할 때의 한계
- Rust Async Book은 OS 스레드가 프로그래밍 모델 변경 없이 동시성을 표현하기 쉽지만, 스레드 간 동기화가 어렵고 성능 오버헤드가 크며, 스레드 풀로도 대규모 I/O bound 워크로드를 충분히 지원하기 어렵다고 비교함
async커뮤니티에서는 OS 스레드보다 왜async를 쓰는지 묻는 질문에 “오버헤드가 낮고 나머지는 같다”는 식으로 답하는 경향이 있음- 웹 서버 작성자들이
async/await로 전환한 이유는 C10k problem을 해결하기 위한 것이었지만, 모든 사용자가async/await를 선택할 이유가 성능일 필요는 없음 - 성능 이점은 상황에 따라 사라질 수 있음
- CPU bound 작업에서는 동등한
async워크플로보다 스레드 기반 워크플로가 더 빠를 수 있음 - Rust
async의 일시적인 성능 이점은 과도하게 강조되고, 의미론적 이점은 과소평가되어 왔음
- CPU bound 작업에서는 동등한
async/await는 틈새 사례용 도구가 아니라, 동기 Rust에서 수십 개 스레드와 채널 없이는 간결히 표현하기 어려운 패턴을 다루는 강력한 프로그래밍 모델임
sync Rust처럼 만들기보다 차이를 받아들이기
- Rust 프로젝트 로드맵에는
async Rust작성이 가끔async와await키워드를 쓰는 것 외에는 동기 코드 작성만큼 쉬워야 한다는 방향이 있음 - 하지만
async Rust를 “sync Rust와 똑같이” 만드는 프레이밍은 근본적으로 어렵다는 시각도 있음- 99%까지 비슷하게 만들 수 있어도 평균적인 사용자가 차이를 알아차릴 수밖에 없음
- Rust의
async/await생태계는 동기 Rust와 같아지려 하기보다, 조합성과 표현력이라는 강점을 더 분명히 드러내야 함 - 동시성이 필요할 때
async/await가 기본 선택지가 되도록 만들려면, 기술적 성능 이유보다 의미론적 이유로 이 모델을 설명해야 함