Rust 비동기 실행기의 워크 스틸링 기본값과 '코어당 스레드' 아키텍처를 비교하며, 테일 레이턴시, 캐시 지역성, 데이터 이동과 구현 난이도 사이의 트레이드오프를 논한다.
지난 1년 남짓 러스트 커뮤니티를 휘감은 논쟁에 대해 이야기하고 싶다. 주요 비동기 “런타임”들이 기본값으로, 수많은 태스크 간의 작업을 동적으로 균형 맞추기 위해 워크 스틸링을 수행하는 멀티스레드 실행기(executor)를 선택했다는 점이다. 어떤 러스트 사용자들은 이 결정에 불만이 크고, 그 불만을 내가 보기엔 과장된 표현으로 드러낸다:
러스트 비동기 프로그래밍의 원죄는 기본을 멀티스레드로 만든 것이다. 성급한 최적화가 모든 악의 뿌리라면, 이것은 그중의 어머니격 성급한 최적화이며, 당신의 모든 코드에 불경스러운
Send + 'static, 더 나아가Send + Sync + 'static의 저주를 씌운다. 이는 실제로 러스트를 쓰는 즐거움을 완전히 말려버린다.
이런 식으로 쓰인 주장들이 기술적 비판으로 진지하게 받아들여지는 건 늘 거북하지만, 우리 업계는 대체로 그렇게 진지하지 않다.
(일부는 대신 단일 스레드 서버를 선호하며 어차피 “I/O 바운드”라고 주장한다. 여기서 그들이 I/O 바운드라고 말하는 건 사실 러스트로 작성했을 때 단일 코어도 포화시키지 못할 정도로 일이 적다는 뜻이다. 그렇다면 당연히 단일 스레드 시스템을 작성하면 된다. 여기서는 CPU 시간을 코어 여러 개에서 쓰고자 하는 경우를 가정한다.)
이들이 대신 옹호하는 것은 이들이 “코어당 스레드(thread-per-core)”라 부르는 대안적 아키텍처다. 그들은 이 아키텍처가 성능도 더 좋고 구현도 더 쉽다고 약속한다. 내 생각에 진실은 둘 중 하나일 수는 있어도 둘 다이진 않다.
‘코어당 스레드’의 가장 큰 문제 중 하나는 그 이름 자체다. 사람들이 비판하는 모든 멀티스레드 실행기 역시 코어당 스레드이기도 하다. 코어마다 하나의 OS 스레드를 만들고, 그 위에 (코어 수보다 훨씬 많을 것으로 예상되는) 가변 개수의 태스크를 스케줄한다는 점에서 그렇다. 내가 코어당 스레드에 대해 한 코멘트에 페카 엔버그(Pekka Enberg)가 이렇게 트윗했다:
코어당 스레드는 세 가지 큰 아이디어를 결합한다. (1) 비싼 커널 스레드 대신 사용자 공간에서 동시성을 처리해야 하고, (2) 코어별 스레드가 블로킹되지 않도록 I/O는 비동기여야 하며, (3) 동기화 비용과 CPU 캐시 간 데이터 이동을 없애기 위해 데이터를 CPU 코어 간에 분할해야 한다. (1)과 (2) 없이는 고처리량 시스템을 만들기 어렵지만, (3)은 아마 매우 큰 멀티코어 머신에서만 필요할 것이다.
엔버그의 성능에 관한 논문 “The Impact of Thread-Per-Core Architecture on Application Tail Latency”(잠시 뒤에 다시 다룬다)는 러스트 커뮤니티에서 ‘코어당 스레드’라는 용어가 쓰이게 된 출처다. 그가 이해하는 코어당 스레드의 정의는 여기서 중요하다. 그는 코어당 스레드 아키텍처의 세 가지 특징을 열거하면서, 그중 고처리량에 절대적으로 필요한 것은 두 가지뿐이라고 말한다. 이는 도움이 된다. 논쟁은 실은 앞의 두 가지가 아니라 세 번째에 관한 것이기 때문이다. 비동기 러스트를 사용한다면 앞의 두 가지 요건은 이미 충족하고 있다.
즉, 코어당 스레드 아키텍처를 갖춘 뒤 적용할 수 있는 두 가지 최적화 사이의 구분이자 긴장 관계다. 스레드 간 태스크를 워크 스틸링으로 훔칠 것인가, 아니면 스레드 간 공유 상태를 가능한 한 최소화할 것인가.
워크 스틸링의 요지는 모든 스레드가 항상 할 일이 있도록 보장해 테일 레이턴시를 개선하는 데 있다.
실제 시스템에서 나타나는 문제는 태스크마다 필요한 일의 양이 서로 다르게 귀결된다는 점이다. 예를 들어 어떤 HTTP 요청은 다른 요청보다 훨씬 많은 일을 요구할 수 있다. 그 결과, 처음에 스레드들 사이에 일을 균형 있게 나눠보려 해도 태스크 간 예측 불가한 차이 때문에 각 스레드가 수행하는 일의 양은 제각각이 될 수 있다.
최대 부하에서 이는 어떤 스레드는 감당할 수 있는 양보다 더 많은 일이 스케줄되고, 다른 스레드는 유휴 상태로 앉아 있게 됨을 의미한다. 이 문제가 얼마나 심각한지는 태스크들 간 작업량의 차이가 얼마나 큰지에 달려 있다. 워크 스틸링은 이를 완화하는 방법이다. 할 일이 없는 스레드가 과하게 바쁜 스레드로부터 일을 ‘훔쳐’ 와 유휴 상태를 피한다. tokio, async-std, smol은 모두 테일 레이턴시를 줄이고 CPU 활용도를 높이기 위해 워크 스틸링을 구현한다.
워크 스틸링의 문제는, 어떤 태스크가 한 스레드에서 실행되다가 멈춘 뒤 다른 스레드에서 다시 시작될 수 있다는 데 있다. 그게 바로 일이 ‘훔쳐지는’ 의미다. 따라서 그 태스크에서 yield 지점 사이로 넘어가는 모든 상태는 스레드 안전해야 한다. 러스트의 API에서는 이는 future가 Send 여야 한다는 형태로 드러난다. 시스템의 상태 모델이 엉성한 사람에게는 이를 보장하는 최선의 방법을 찾기가 어렵다. 그래서 워크 스틸링이 ‘더 어렵다’고 말해진다.
동시에, 상태가 한 스레드에서 다른 스레드로 이동하면 동기화 비용과 캐시 미스가 발생해, 각 CPU가 자신이 다루는 상태에 독점 접근하는 ‘상호 비공유(share-nothing)’ 아키텍처의 원칙을 위반하게 된다. 그래서 워크 스틸링이 ‘더 느리다’고도 말해진다.
상호 비공유의 요지는 데이터를 여러 코어가 공유하는 느린 캐시가 아니라 단일 CPU 코어에 속한 더 빠른 캐시에 머물게 해 테일 레이턴시를 개선하는 것이다.
엔버그의 논문으로 돌아가 보자. 그는 새로운 키-값 저장소(상호 비공유)를 멤캐시드(공유 상태)와 벤치마크 비교하여, 상호 비공유 아키텍처가 공유 상태 아키텍처보다 성능이 더 좋음을 보인다. 두 아키텍처 간 테일 레이턴시가 크게 개선된다. 나는 이 논문을 꽤 좋아하지만, 러스트 커뮤니티에서 이를 “71% 성능 향상!” 같은 캐치프레이즈로 소비해 온 방식은 피상적이고 도움이 되지 않는다고 생각한다.
상호 비공유 아키텍처를 달성하기 위해, 엔버그의 키/값 저장소는 해시 함수를 사용해 키스페이스를 스레드들에 분할하고, SO_REUSEPORT를 사용해 들어오는 TCP 연결도 스레드들에 분산한다. 그런 다음, 연결을 관리하는 스레드에서 해당 키스페이스 구역을 관리하는 스레드로 요청을 메시지 전달 채널을 통해 라우팅한다. 반대로 멤캐시드에서는 모든 스레드가 분할된 키스페이스의 소유권을 공유하고, 각 파티션은 뮤텍스로 보호된다.
엔버그의 논문은 뮤텍스 대신 채널을 사용하면 더 낮은 테일 레이턴시를 달성할 수 있음을 보여준다. 아마 반복 접근되는 각 파티션이 한 코어의 캐시에만 머물러 캐시 미스가 줄어들기 때문일 것이다. 하지만 엔버그의 아키텍처가 멤캐시드의 것보다 구현이 극적으로 더 쉽다는 주장에는 전혀 확신이 없다. 엔버그의 목표는 데이터 이동을 피하기 위해 고급 커널 기능과 치밀하게 설계된 아키텍처를 활용하는 것이다. 이것이 데이터를 뮤텍스에 감싸는 것보다 더 쉽다고 믿기 어렵다.
키-값 저장소는 애플리케이션 상태를 서로 다른 스레드에 분할하기가 비교적 사소하기 때문에 상호 비공유 아키텍처에 거의 최적의 사례다. 그러나 애플리케이션이 더 복잡하고, 여러 파티션의 상태를 트랜잭셔널하거나 원자적으로 변경해야 한다면, 올바르게 구현하려면 훨씬 더 많은 주의가 필요하다. 상호 비공유 아키텍처 옹호자들과, 10년 전 직렬 가능성을 보장하는 데이터베이스 대신 최종적 일관성 데이터베이스를 띄웠던 열풍 사이에는 강한 유사성이 있다. 맞다, 이것이 더 높은 성능을 낼 수는 있지만, 데이터 불일치에서 비롯되는 버그를 피하기 위해 세심한 고려가 필요하다는 대가가 따른다.
또한 엔버그의 구현도 멤캐시드도 워크 스틸링을 사용하지 않는다는 점은 중요하다. 이 때문에 엔버그 논문의 핵심 성능 주장을 러스트의 워크 스틸링 아키텍처와 직접 연결하기가 어렵다. 엔버그의 아키텍처와 멤캐시드에 워크 스틸링만 덧붙이면 결과가 어떨지 궁금해진다. 엔버그 쪽에서는 데이터 이동이 다소 늘겠지만, 그래도 CPU 활용을 극대화하는 방향일 수 있다. 멤캐시드에는 도움이 되는 것 말고 다른 효과를 상상하기 어렵다.
엔버그는 논문에서 키스페이스의 균형 잡힌 분할과 SO_REUSEPORT를 이용해 사전에 일을 고르게 분배하려고 구현을 세심하게 설계했다. 그럼에도 실전에서는 동적으로 불균형을 야기하는 요인이 여럿 생긴다:
내가 이해하기로 논문의 벤치마크 프레임워크는 현실에서 나타날 이러한 조건들을 재현하지 않았다. 각 연결은 무작위 키에 대해 일정한 양의 작업을 수행하여 이러한 불균형 원인을 회피한다. 이런 종류의 동적 불균형을 반영해 워크 스틸링을 추가한 벤치마크라면 어떤 결과가 나올지 궁금하다.
이러한 불균형을 완화할 수 있는 다른 상호 비공유 설계 방식도 상상할 수 있다(예: 핫 키를 추가 파티션에 캐싱). 또 상태 이동을 피하기 위해 일부 태스크를 특정 코어에 고정(pin)하더라도, 어떤 형태의 워크 스틸링은 그런 최적화가 될 수 있다.
CPU 캐시 간 데이터 이동을 피하도록 시스템을 세심하게 설계하면 그렇지 않은 경우보다 성능이 더 좋아진다는 데 이견은 없을 것이다. 하지만 제네릭에 Send 경계를 추가해야 한다는 점을 가장 큰 불만으로 삼는 사람이 그런 종류의 공학을 하고 있다고 믿기는 어렵다. 어차피 공유 상태를 쓸 거라면, 부하 시 워크 스틸링이 CPU 활용도를 높이지 못할 거라고 상상하기는 힘들다.