- Rust의
async/await는 수만 개 연결을 다루는 대규모 동시성을 겨냥하지만, 저수준 제어와 정적 수명 검증이라는 Rust의 목표와 충돌해 일반 Rust와 다른 개발 경험을 만듦 - 스레드와 채널은 많은 소프트웨어에 충분하지만, C10K 같은 규모에서는 스레드당 연결 방식의 부담이 커져 사용자 공간 태스크와 런타임 스케줄링이 필요해짐
asyncRust에서는 데이터가Send로 이동되거나'static참조로 다뤄져야 하며,async의 전염성 때문에 이런 제약이 코드 전반에 반복됨Arc는 컴파일 문제를 풀어도 객체와 리소스 수명을 흐리게 만들고, 재귀async, future와 task의 차이, blocking 호출이 런타임 스레드를 막는 함정이 이어짐- Haskell이나 Go에서는 “async code”가 일반 코드처럼 동작하고 런타임과 GC가 차이를 숨기므로, 이런 종류의 프로그래밍에서는 Rust의 명시적 제어가 순수한 이점으로만 작동하지 않을 수 있음
동시성과 병렬성이 필요한 이유
- 빠른 프로그램에는 두 가지 요구가 함께 걸려 있음
- 여러 CPU 코어를 써서 컴퓨터 전체를 활용해야 함
- 인터넷 메시지 전송이나 파일 열기처럼 느린 작업을 기다리는 동안 다른 일을 계속해야 함
- 병렬성은 여러 CPU에서 코드를 동시에 실행하는 문제임
- 동시성은 문제를 독립적인 부분으로 나누는 방식임
- 둘은 같지 않지만, 프로그램을 동시적인 조각으로 나누면 그 조각들이 병렬로 실행되고 코어를 계속 바쁘게 만들 수 있음
프로세스, 스레드, 채널
- 동시 시스템을 만드는 단순한 방법은 코드를 여러 프로세스로 나누는 것임
- 운영체제 스케줄러가 실행 가능한 프로세스의 시간 조각을 사용 가능한 CPU 코어에서 실행함
- 셸 명령을 파이프로 연결하는 경우에도 이 모델을 사용함
- 프로세스 방식은 프로세스 간 통신 비용이 큼
- 많은 구현에서 데이터를 OS 메모리로 복사했다가 다시 가져와야 함
- 공유 메모리로 비용을 줄일 수 있지만, OS가 프로세스를 서로 격리해 주는 장점은 약해짐
- 스레드는 같은 메모리를 공유해 이런 오버헤드를 피하지만, mutex, condition variable, semaphore 같은 동기화 도구를 잘못 쓰면 데이터 경쟁과 교착 상태가 생길 수 있음
- Tony Hoare의 Communicating Sequential Processes 모델은 스레드를 큐 또는 채널로 연결함
- 스레드는 메모리를 공유하지 않아 프로세스 같은 격리를 얻음
- 각 스레드의 입력과 출력이 채널로 드러나 추론과 디버깅이 쉬워짐
- 채널 자체가 동기화 역할을 하며, 비어 있으면 수신자가 기다리고 가득 차면 송신자가 기다림
- Rust 표준 라이브러리에는 std::sync::mpsc::sync_channel이 있음
- 많은 소프트웨어는 스레드와 채널, CPU 집약 루프를 병렬화하는 Rayon 같은 도구 조합으로 충분함
사용자 공간 동시성과 Rust async
- 수만 명의 동시 사용자가 연결되는 웹 서버 같은 C10K 문제에서는 스레드 하나를 연결 하나에 붙이는 방식이 한계에 부딪힘
- Linux에서 각 스레드는 4kB control block을 가지며, 스레드 전환은 운영체제 스케줄러로 들어가는 context switch를 필요로 함
- 대규모 동시성을 위해 일부 언어는 태스크를 사용자 공간에서 만들고 관리함
- 런타임이 태스크를 OS 스레드 풀에 스케줄링함
- 보통 CPU 코어마다 스레드 하나가 배치되도록 풀을 구성해 병렬성을 극대화함
- 이런 방식은 green thread, lightweight thread, lightweight process, fiber, coroutine 등으로 불림
- Rust는 C#이나 Node.js에서 보이는
async/await모델을 사용함async fn은 직접 값을 반환하지 않고,.await로 결과를 얻는 future 또는 promise를 반환함
- Rust future는 협력적 스케줄링과 stackless 설계 덕분에 매우 작고 빠름
- Rust는 future 추상화를 제공하면서도 프로그래머에게 낮은 수준의 제어를 약속하려 함
- 모든 객체와 참조의 수명을 컴파일 타임에 정적으로 검증하려 함
- future는 코드와 그 코드가 참조하는 데이터를 수천 개 조각으로 나누고, 실행 시작 후에만 알 수 있는 조건에 따라 아무 때나 아무 스레드에서 실행될 수 있게 함
- 클라이언트 데이터를 읽는 future는 해당 소켓에 읽을 데이터가 있을 때만 실행되어야 하지만, lifetime annotation은 그 시점을 알려주지 않음
- Rust는 future 런타임을 언어에 내장하지 않고 Tokio 같은 라이브러리에 맡김
- 사용자는 환경에 맞는 대안을 고를 자유를 얻음
- 하지만 Tokio가 언어에 내장된 세계를 상상해도 같은 규칙이 적용되므로, 이 논점에서는 부차적 세부사항임
Send, 'static, Arc가 만드는 압박
- 컴파일러를 설득하려면 데이터가
Send로 표시되어 이동되거나,'static수명을 가진 참조를 통해 전달되어야 함 async코드에서는 여러 태스크가 공통 상태를 공유하는 일이 흔해, 복제 없이 데이터를 이동하는 방식이 자주 맞지 않음- 참조도 어렵고, future의 수명을 “영원히”보다 짧게 제한해 주는
thread::scope같은 대응물이 없음 async는 전염성이 있어async함수를 호출하는 함수도async가 되어야 함- 따라서 이런 수명과 이동성 문제를 일부 함수가 아니라 계속해서 해결해야 함
- 런타임에 future 완료를
block_on으로 기다려 체인을 끊을 수는 있지만, 이런 방식은 조합 가능하지 않고 중첩되면 런타임이 panic할 수 있음
- Arc는 동적 수명을 여러 스레드에 걸쳐 다루는 도구이며, borrow check를 통과하고 코드를 컴파일하게 해 줌
- 하지만
Arc를 광범위하게 쓰면 객체와 리소스의 수명이 흐려짐- 메모리, 파일, 소켓 같은 리소스가 언제 해제될지 명확하지 않음
- 실제 GC가 제공하는 allocation throughput, 낮은 fragmentation, cycle leak 회피 같은 장점 없이 GC와 비슷한 손실을 겪음
async Rust의 추가 함정
- Rust coroutine은 stackless라서 컴파일러가 각 coroutine을
.await지점까지 전진하는 상태 기계로 바꿈- 재귀
async함수는 재귀적으로 정의된 타입이 됨 - 단순히 자기 자신을 호출하려는 사용자는 수동으로 boxing하거나 async-recursion 같은 crate를 써야 함
- 재귀
- future는 await되기 전까지 아무것도 하지 않음
- task는 런타임의 스레드 풀에서 작업을 시작하고, 완료를 표시하는 future를 반환함
- future 내부에서 blocking 코드를 호출하지 못하게 막는 장치는 없음
- 그런 호출이 자신이 올라간 런타임 스레드를 막는 것도 막지 않음
- 이는
async를 쓰는 핵심 목적과 충돌함
일반 Rust, Haskell, Go의 차이
asyncRust는 “normal” Rust와 맛이 크게 다름- 더 많은 함정이 있음
- 이해하고 가르치기 더 어려움
- 사용자는 두 선택지 사이에 놓임
- 추상화가 실제로 어떻게 작동하는지 깊게 이해하고 복잡한 코드를 작성함
Arc,Pin,'static같은 요소를 코드 곳곳에 뿌리고 잘 되기를 바람
- 경험 많은 개발자 팀도 새 프로젝트에서 Rust를 쓰려다가 이런 세부사항에 발목이 잡힐 수 있음
- Haskell이나 Go에서는 “async code”가 일반 코드임
- 두 언어는 두꺼운 런타임 뒤에 blocking과 non-blocking 코드의 차이를 숨김
- 수명 문제는 garbage collection으로 넘김
- 이런 종류의 대규모 동시 사용자 공간 소프트웨어에서는 런타임과 GC가 차이를 숨기는 방식이 순수한 이점으로 작동함
- Rust는 대규모 동시 사용자 공간 소프트웨어에는 좋은 도구가 아닐 수 있으며, 그런 요구가 없는 프로젝트에 쓰는 편이 나을 수 있음