- Rust async 런타임 논쟁은 “코어당 OS 스레드” 여부보다, 작업 훔치기(work-stealing) 로 부하를 맞출지 share-nothing으로 데이터 이동을 줄일지의 선택에 가까움
- async Rust는 이미 사용자 공간 동시성 처리와 비동기 I/O라는 thread-per-core의 핵심 조건을 충족하며, 남는 쟁점은 코어 간 상태 공유를 얼마나 피할 수 있느냐임
- 작업 훔치기는 스레드 유휴 시간을 줄여 꼬리 지연시간(tail latency)과 CPU 활용률을 개선하려 하지만, Rust에서는 yield 지점을 넘는 상태가 스레드 안전해야 해
Send제약이 따라옴 - share-nothing은 데이터를 특정 코어의 캐시에 머물게 해 지연시간을 낮출 수 있지만, 키-값 저장소처럼 상태 분할이 쉬운 경우와 트랜잭션·원자적 변경이 필요한 경우의 난이도가 크게 다름
- Enberg 논문은 share-nothing과 shared-state를 비교하지만 양쪽 모두 작업 훔치기를 쓰지 않으므로, 그 결과를 Rust의 작업 훔치기 실행기 비판으로 바로 연결하기는 어려움
Rust async 런타임 논쟁의 핵심
- Rust 커뮤니티에서는 tokio 같은 주요 async 런타임이 기본으로 멀티스레드 실행기를 쓰고, 여러 태스크를 작업 훔치기로 동적으로 균형화하는 선택을 두고 논쟁이 이어짐
- 일부 사용자는 이 기본값 때문에
Send + 'static또는Send + Sync + 'static제약이 코드 작성 경험을 해친다고 비판함 - 단일 스레드 서버를 선호하는 경우도 있지만, 여기서는 Rust 시스템이 하나 이상의 CPU 코어 시간을 쓰려는 상황을 전제로 함
- 대안으로 제시되는 “thread-per-core”는 더 빠르고 구현도 쉽다고 기대되지만, 성능과 구현 용이성을 동시에 얻기는 어려움
“thread-per-core”라는 이름이 만드는 혼동
- 기존 멀티스레드 async 실행기도 넓은 의미에서는 thread-per-core에 해당함
- 코어당 OS 스레드를 만들고
- 코어 수보다 훨씬 많은 태스크를 그 스레드들 위에 스케줄링함
- Pekka Enberg는 thread-per-core를 세 가지 아이디어의 결합으로 정리함
- 비싼 커널 스레드 대신 사용자 공간에서 동시성을 처리함
- 코어별 스레드가 블로킹되지 않도록 비동기 I/O를 사용함
- 동기화 비용과 CPU 캐시 간 데이터 이동을 없애기 위해 데이터를 CPU 코어 사이에 분할함
- Enberg는 높은 처리량 시스템을 만들기 위해 앞의 두 가지가 중요하지만, 세 번째는 매우 큰 멀티코어 머신에서만 필요할 수 있다고 봄
- async Rust를 사용한다면 사용자 공간 동시성과 비동기 I/O라는 앞의 두 조건은 이미 충족됨
- 따라서 논쟁의 중심은 thread-per-core 자체가 아니라, 작업 훔치기와 share-nothing 중 어떤 최적화를 택하느냐에 있음
작업 훔치기의 목적과 비용
- 작업 훔치기는 모든 스레드가 계속 할 일을 갖도록 만들어 꼬리 지연시간을 줄이는 최적화임
- 실제 시스템에서는 태스크마다 필요한 작업량이 다름
- 한 HTTP 요청이 다른 요청보다 훨씬 많은 작업을 요구할 수 있음
- 처음에 스레드별 작업을 균형 있게 나눠도, 예측 불가능한 태스크 차이 때문에 시간이 지나면 작업량이 달라질 수 있음
- 최대 부하에서는 어떤 스레드는 처리 가능한 양보다 많은 작업을 받고, 다른 스레드는 유휴 상태로 남을 수 있음
- tokio, async-std, smol은 모두 꼬리 지연시간 감소와 CPU 활용률 개선을 목표로 작업 훔치기를 구현함
- 비용은 태스크가 한 스레드에서 멈춘 뒤 다른 스레드에서 다시 시작될 수 있다는 점임
- yield 지점을 넘겨 사용되는 상태는 스레드 안전해야 함
- Rust API에서는 future가
Send여야 하는 형태로 나타남 - 시스템 상태를 명확히 파악하지 못하면 어떤 방식으로
Send를 보장할지 결정하기 어려움
- 상태가 다른 스레드로 이동하면 동기화 비용과 캐시 미스가 생기며, 각 CPU가 자기 상태만 다루는 share-nothing 원칙과 충돌함
share-nothing의 성능 논리
- share-nothing은 데이터를 여러 코어가 공유하는 느린 캐시가 아니라 특정 CPU 코어의 더 빠른 캐시에 머물게 해 꼬리 지연시간을 줄이려는 설계임
- Enberg의 논문 The Impact of Thread-Per-Core Architecture on Application Tail Latency는 share-nothing 키-값 저장소와 shared-state 구조의 memcached를 벤치마크해 꼬리 지연시간 개선을 보임
- Rust 커뮤니티에서 이 논문이 “71% 성능 개선”처럼 단순 인용되는 방식은 얕고 도움이 되지 않음
- Enberg의 키-값 저장소는 share-nothing 구조를 위해 상태와 연결을 나눔
- 해시 함수로 keyspace를 여러 스레드에 분할함
SO_REUSEPORT로 들어오는 TCP 연결을 스레드 사이에 분할함- 연결을 관리하는 스레드에서 해당 keyspace를 관리하는 스레드로 요청을 메시지 전달 채널로 라우팅함
- memcached는 모든 스레드가 분할된 keyspace의 소유권을 공유하고, 각 파티션은 mutex로 보호함
- Enberg의 결과는 mutex보다 채널을 쓰는 구조가 더 낮은 꼬리 지연시간을 달성할 수 있음을 보임
- 같은 파티션이 반복 접근되며 한 코어의 캐시에 머무르기 때문에 캐시 미스가 줄어드는 것으로 볼 수 있음
- 다만 고급 커널 기능과 신중하게 계획된 구조로 데이터 이동을 피하는 설계가, 데이터를 mutex 안에 감싸는 방식보다 구현이 더 쉽다고 보기는 어려움
상태 분할이 쉬운 경우와 어려운 경우
- 키-값 저장소는 애플리케이션 상태를 여러 스레드에 나누기 쉬워 share-nothing에 잘 맞는 사례임
- 더 복잡한 애플리케이션에서 여러 파티션의 상태를 트랜잭션 방식이나 원자적으로 변경해야 한다면, 올바르게 구현하기 위해 훨씬 더 많은 주의가 필요함
- share-nothing 옹호는 과거 eventual consistency 데이터베이스를 둘러싼 과열과 닮은 면이 있음
- 성능은 좋아질 수 있음
- 대신 데이터 불일치에서 생기는 버그를 피하려면 신중한 설계가 필요함
Enberg 논문과 Rust 작업 훔치기 논쟁의 거리
- Enberg의 구현과 memcached는 모두 작업 훔치기를 사용하지 않음
- 이 때문에 논문의 핵심 성능 결과를 Rust의 작업 훔치기 아키텍처와 직접 연결하기 어려움
- Enberg의 구조에 작업 훔치기를 추가하면 데이터 이동이 어느 정도 늘 수 있지만, CPU 활용률을 높이는 방식이 될 가능성도 있음
- memcached에 작업 훔치기를 추가하는 경우에는 도움이 되지 않을 이유를 상상하기 어렵다고 봄
- 논문 구현은 keyspace의 균형 분할과
SO_REUSEPORT로 작업을 미리 고르게 나누도록 설계됨 - 실제 환경에서는 동적 불균형이 나타날 수 있음
- hot key는 더 많은 읽기와 쓰기를 받아 해당 keyspace를 관리하는 스레드의 작업량을 늘림
- 일부 연결은 다른 연결보다 더 많은 요청을 수행해 해당 연결을 관리하는 스레드의 작업량을 키움
- 논문의 벤치마크는 각 연결이 무작위 키에 대해 일정한 작업량을 수행해 이런 불균형 조건을 재현하지 않은 것으로 이해됨
- hot key를 추가 파티션에 캐싱하는 방식처럼 share-nothing 시스템 안에서도 불균형을 완화하는 설계가 가능함
- 일부 태스크는 상태 이동을 피하기 위해 특정 코어에 고정하면서, 다른 형태의 작업 훔치기를 최적화로 사용할 수도 있음
실무적 결론
- CPU 캐시 사이의 데이터 이동을 피하도록 시스템을 신중하게 설계하면 그렇게 하지 않는 경우보다 더 나은 성능을 얻을 수 있음
- 하지만
Send제약을 generics에 추가하는 일을 가장 큰 불만으로 삼는 수준이라면, 그런 종류의 세밀한 성능 엔지니어링을 하고 있다고 보기 어려움 - 어차피 shared state를 사용할 시스템이라면, 부하 상황에서 작업 훔치기가 CPU 활용률을 개선하지 않는다고 보기는 어려움