Hacker News

Principles for Fast Tokio Applications

빠른 Tokio 애플리케이션을 위한 원칙들 — RustConf Unconf에서 나온 비동기 성능 지침

dial9을 만드는 Russell이 RustConf Unconf의 비동기 디버깅·벤치마킹 논의를 바탕으로 정리한 Tokio 성능 원칙 모음입니다. '지연시간에는 양보(yield), 처리량에는 배칭'이라는 축을 중심으로, schedule latency 히스토그램으로 문제를 진단하고 공정성·전역 리소스·뮤텍스·워커 격리를 다루는 실전 지침을 담고 있습니다.

본문

저자는 Tokio 런타임 추적 도구 dial9을 개발하는 Russell로, RustConf의 Unconf 세션에서 나온 비동기 애플리케이션 디버깅·벤치마킹 논의와 자신의 경험을 정리해 '살아있는 문서'로 만들겠다고 밝힙니다. Tokio 성능에는 절대적 규칙이 거의 없고 대부분 '상황에 따라 다르다'는 전제에서 출발합니다. 워크로드의 성능은 그 순간 런타임에서 함께 돌아가는 다른 작업에 좌우되기 때문에, 많은 문제가 프로덕션에서만 드러난다고 지적합니다.

먼저 문제가 있는지 확인하라

붉은 깃발을 찾으려 하면 어떤 앱에서든 발견됩니다. Alice Ryhl이 권장하는 10~100마이크로초보다 훨씬 긴 poll(.await 지점 사이의 활성 구간)이 실제 앱 대부분에 존재하지만, 그것이 사용자 지표에 영향을 주지 않는 경우도 많습니다. 중요한 것은 개선하려는 실제 지표에서 거꾸로 추적하는 것입니다. 저자가 본 문제의 대부분은 Tokio가 아니라 애플리케이션 코드, 특히 분산 시스템 구성 요소 간의 상호작용에 있었고, dial9은 Tokio 문제를 찾는 만큼 '문제가 아님'을 증명해 다른 곳을 살필 자신감을 준다고 합니다. 진단에 가장 유용한 지표는 최근 추가된 schedule latency 히스토그램으로, 태스크가 실행 준비가 된 시점과 실제로 future가 poll되는 시점 사이의 지연을 측정합니다. 원인은 알려주지 않지만 코드와 런타임의 불협화음에서 가장 흔히 나타나는 증상입니다.

지연시간엔 양보, 처리량엔 배칭

Redis처럼 요청 파이프라이닝을 지원하는 서비스를 예로 듭니다. 순진한 구현은 커넥션에서 데이터가 있는 한 계속 읽는데, 파이프라인된 요청이 메모리 버퍼에 통째로 들어와 있으면 각 프레임이 Poll::Ready를 반복 반환하며 런타임으로 양보하지 않습니다. 이는 긴 poll과 클라이언트 간 불공정을 만듭니다. 처리량 영향은 작지만 지연시간은 한 파이프라인 전체가 다른 것 뒤에서 기다리면서 크게 나빠집니다. 각 요청 후 명시적으로 yield하면 이 예시에서 지연시간이 약 10배 개선되고, 연속된 즉시 준비 읽기 몇 회 후에만 양보하면 더 좋아집니다. 이 문제의 징후는 P99가 P50보다 훨씬 크고, poll이 내부 작업보다 오래 걸리며, 하나의 poll 안에 여러 span이 들어가는 것입니다.

반대로 공정성은 공짜가 아닙니다. 태스크 전환, poll, 워커 이동 같은 런타임 이벤트당 유용한 작업을 많이 할수록 효율적입니다. 대표 사례가 tokio::fs인데, 저자는 'tokio::fs는 유해하다고 간주된다'고까지 말합니다. io_uring 없이는 각 파일시스템 연산이 블로킹 풀에서 돌고, spawn_blocking 호출마다 비용이 듭니다. 일련의 블로킹 작업이 예상되면 가장 큰 합리적 단위로 묶고, 경우에 따라 전용 OS 스레드가 낫습니다. 태스크 생성도 마찬가지로, 10마이크로초짜리 작업을 별도 태스크로 올리는 것은 오히려 해롭습니다. 수백~수천 개의 태스크는 각각 스케줄링 지연에 노출될 기회를 늘립니다.

전역 리소스와 뮤텍스에 주의하라

블로킹 풀은 현재 전역 리소스입니다. 저자는 32코어 호스트에서 초당 약 5만 개의 블로킹 태스크에서 성능 악영향을 관찰했고, spawn_blocking이 플레임그래프에 보이기 시작하면 병목입니다. 전역 태스크 큐는 로컬 큐 오버플로나 비Tokio 스레드에서의 스케줄링으로 채워지며, 건강한 앱에서는 거의 비어 있어야 합니다. 뮤텍스는 런타임 전체를 멈추게 하는 가장 쉬운 방법입니다. 뮤텍스 뒤의 메트릭 레지스트리가 대표적 위험 요소로, flush가 락을 잡은 채 비싼 작업을 하면 모든 워커가 같은 락에서 막혀 work stealing이 불가능해집니다. 임계 구역은 해시맵 갱신 한 번 수준으로 짧게 유지하고, 락을 잡은 채 I/O나 await를 하지 말아야 합니다. RWLock은 읽기 경로에서도 아토믹 경합을 만들어 거의 적절하지 않고, tokio::sync::Mutex는 락 비용이 훨씬 커서 임계 구역이 수 밀리초 이상일 때만 적합합니다. 징후는 백그라운드 작업 주기에 맞춘 규칙적 P99 스파이크와, 다수 태스크가 동시에 off-CPU로 막히는 현상입니다.

병렬성 제한과 워커 격리

Tokio는 시스템이 감당할 수 있는 것보다 훨씬 많은 태스크를 기꺼이 생성합니다. 제한 없는 팬아웃으로 S3에 동시 연결 3,000개를 여는 사고가 흔하며, 답은 따분하지만 Semaphore로 동시성을 제한하는 것입니다. 또한 OS가 바쁘면 Tokio가 워커를 깨워도 커널이 스케줄하기까지 10~20ms 이상 걸릴 수 있습니다. 저자는 Amazon에서 Java에서 Rust로 점진 마이그레이션할 때 Java 프로세스의 일이 줄수록 Rust 프로세스가 더 많은 일을 하면서도 빨라지는 현상을 관찰했다고 합니다. 해법은 cgroups 등으로 Tokio 워커와 다른 코드를 별도 코어에 고정하는 것이며, tracing_appender 같은 백그라운드 스레드가 100ms 넘게 CPU를 양보하지 않는 경우에도 동일하게 적용됩니다.

아는 사람만 쓰는 트릭

경부하에서는 work stealing이 긴 poll을 보완하므로 executor를 막는 것이 괜찮을 때도 있지만, 런타임이나 OS가 포화되면 I/O 구동 같은 핵심 유지 작업까지 지연됩니다. 단, tokio::join!·select! 같은 태스크 내 동시성에는 stealing이 없어 블로킹 시 같은 태스크의 다른 작업이 전부 멈춥니다. 최강의 격리는 우선순위별로 런타임을 나누고 코어에 고정하는 것으로, TokioConf 발표자 대부분이 결국 두 개 이상의 런타임으로 수렴했다는 인상을 전합니다. 마이크로초 단위 지연을 쫓는 고급 기법으로, 양보 대신 50마이크로초 정도 의도적으로 스핀하는 방법도 소개되지만 코어 하나를 소비하고 주변 워크로드를 해치므로 대부분의 앱에는 맞지 않습니다.

Hacker News 반응

  • @Tsarp — 에이전트 코딩의 좋은 활용 중 하나가 이런 최적화에 도움이 되는 세밀한 트레이싱 계측을 추가하는 것입니다.
  • @jeffbee — 앱이 관측 가능성 오버헤드에 대부분의 시간을 쓰게 만드는 좋은 방법이기도 합니다. 본문이 언급한 지연시간 히스토그램조차 엄청나게 비쌉니다.
  • @MomsAVoxell — eBPF로 앱을 추적하지 않으면 잘못하고 있는 것입니다.
  • @jeffbee — eBPF 트레이싱의 낮은 비용도 또 하나의 신화입니다.
  • @MomsAVoxell — 신화가 아니며 잘못 쓸 수는 있지만, 인앱 텔레메트리보다는 훨씬 나은 경로입니다.

원문: Hacker News / 번역·요약: Trawling