메뉴
HN
Hacker News 13일 전

Tokio/Rayon의 함정과 Async/Await가 동시성에 실패하는 이유

IMP
8/10
핵심 요약

async/await 구문은 비동기 코드를 쉽게 작성할 수 있게 해주지만, I/O 작업과 동시성을 혼동하게 만들어 스레드 정체와 OOM(메모리 부족) 같은 심각한 생산 환경 장애를 유발합니다. 결국 개발자는 I/O와 CPU 연산 작업을 수동으로 분리하고 런타임을 관리해야 하는 부담을 떠안게 되어 근본적인 추상화에 실패하게 됩니다. 안정적인 시스템 운영을 위해 아키텍처의 구조적 복잡성을 이해하고 스케줄링 한계를 인지하는 것이 중요합니다.

번역된 본문

Tokio/Rayon의 함정과 Async/Await가 동시성에 실패하는 이유 2026년 4월 22일

지난 10년 동안 async/await는 사용이 매우 쉽기 때문에 비동기 프로그래밍의 패권을 쥐었습니다. 이를 통해 개발자들은 동기식 코드와 거의 동일해 보이는 비동기 코드를 작성할 수 있게 되었습니다. 하지만 이 친숙한 구문 이면에는 거대한 구조적 복잡성이 숨어 있습니다. 이는 제어 흐름을 감추고, 하드웨어의 실제 상황을 모호하게 만들며, 궁극적으로 스케줄링의 부담을 다시 개발자에게 전가합니다. Rich Hickey는 그의 강연 'Simple Made Easy'에서 이를 완벽하게 설명했습니다. "Easy(쉬움)"이란 친숙하고 손에 닿는 것을 의미하는 반면, "Simple(단순함)"이란 구조적으로 얽혀 있지 않은 것을 의미합니다. async/await는 작성하기는 쉽지만, 운영하기는 극도로 복잡합니다.

Rob Pike는 2023년 GopherConAU 기조연설에서 이러한 아키텍처의 전환에 대해 이야기했습니다. "고루틴(goroutines), 채널(channels), 셀렉트(select)와 비교했을 때, async/await는 언어 구현자가 빌드하기에 더 쉽고 작습니다... 하지만 이는 복잡성의 일부를 프로그래머에게 다시 떠넘기며, 흔히 Bob Nystrom이 '색칠된 함수(colored functions)'라고 불렀던 결과를 초래합니다. [...] 하지만 어떤 동시성 모델을 제공하든 정확히 한 번만 제공하는 것이 중요합니다. 왜냐하면 여러 동시성 구현을 제공하는 환경은 문제가 될 수 있기 때문입니다." Pike의 "여러 동시성 구현"에 대한 언급과 async/await는 바로 오늘날 실무 환경에서 실패하고 있는 부분입니다.

생산 환경의 함정: 동시성(Concurrency)과 비동기성(Asynchrony)의 혼동 async/await의 근본적인 함정은 비동기성(I/O를 기다리는 동안 실행을 양보하는 것)과 동시성(여러 작업을 한 번에 처리하는 것)을 혼동한다는 것입니다. 이 구문은 교차로 실행되는 상태 머신을 격리된 순차적 스레드인 것처럼 위장하기 때문에 하나의 함정이 됩니다. 이 환상에 빠진 개발자는 네트워크를 통해 데이터베이스 레코드를 가져온 직후 즉시 데이터를 처리하는 식으로, 블로킹 코드를 작성하듯 똑같이 async 함수를 작성합니다. 하지만 그 데이터 처리 작업이 10MB JSON 페이로드 파싱, 거대한 컬렉션 순회 또는 연산 집약적인 암호화 증명 실행과 관련된 경우 어떻게 될까요? 협력적 실행자(cooperative executor)가 정지됩니다.

[기대했던 상황] 빠른 I/O 작업들이 빠르게 양보됨. 높은 처리량. 수신되는 네트워크 트래픽 -> 작업 대기열 (안정적 / 낮음) 실행자 (1개의 OS 스레드) -> I/O 양보, I/O 양보

[현실: 컴퓨팅 함정] 단 하나의 CPU 작업이 실행자를 멈춤. 대기열 폭발. 수신되는 네트워크 트래픽 -> 작업 대기열 (무제한) -> OOM(메모리 부족) 크래시 실행자 (1개의 OS 스레드) -> 무거운 컴퓨팅 작업 (bcrypt 해싱, 파싱 등) -> 스레드 정지(THREAD HALTED)

Rust의 Tokio나 Node.js와 같은 협력적 런타임에서는 await 지점에 도달할 때까지 스레드가 실행을 양보하지 않습니다. 함수 내에서 50밀리초가 걸리는 CPU 바운드 작업은 실행 스레드 전체를 멈추게 합니다. 순식간에 수많은 관련 없는 네트워크 요청의 지연 시간이 급증하고 시스템이 응답하지 않게 됩니다. 그 와중에 하드웨어는 거의 활용되지 않습니다.

깨진 약속: 인간 스케줄러 이러한 지연 시간 스파이크가 발생할 때마다 해결책은 항상 똑같습니다. 런타임을 분리하라는 것입니다. I/O에는 Tokio를 사용하고, CPU 바운드 작업은 Rayon과 같은 전용 스레드 풀로 보내는 식입니다. 최근의 장애 사후 분석 보고서들은 이로 인해 발생한 재앙을 강조합니다. PostHog와 Meilisearch의 엔지니어링 팀은 프로덕션 환경에서 이러한 복잡성을 해체하는 고통스러운 현실을 문서화했습니다. 개발자는 각 함수가 "I/O 풀"에 속하는지 "연산 풀"에 속하는지 결정하기 위해 모든 함수를 주의 깊게 분석한 다음, 그 경계 사이의 메시지 전달을 수동으로 조율해야 합니다. 만약 개발자가 I/O와 연산 작업을 수동으로 분할하고, 교착 상태를 막기 위해 경계를 엄격하게 감시하며, 두 가지 다른 사고방식을 가진 두 개의 다른 런타임 간에 데이터를 전달해야 한다면 async 추상화는 실패한 것입니다. 언어의 기능은 동시성의 복잡성을 숨기겠다고 약속했습니다. 그러나 오히려 애플리케이션 개발자를 스케줄링을 수동으로 개입해야 하는 '인간 스케줄러'로 만들어 버렸습니다.

기본값이 무제한(Unbounded)이라는 것은 기본값이 OOM이라는 뜻입니다 async/await 런타임의 두 번째 실패 방식은 무제한 용량을 너무나도 마찰 없이 만든다는 것입니다. tokio::spawn(...)을 호출하는 비용은 매우 저렴합니다. 트래픽 스파이크 동안 다운스트림 데이터베이스가 느려지면, 수신 네트워크 루프는 아무 문제 없이 계속해서 연결을 수락합니다... (이하 생략)

원문 보기
원문 보기 (영어)
The Tokio/Rayon Trap and Why Async/Await Fails Concurrency Apr 22, 2026 Over the last decade, async/await won the concurrency wars because it is exceptionally easy . It allows developers to write asynchronous code that looks virtually identical to synchronous code. But beneath that familiar syntax lies massive structural complexity. It hides control flow, obscures hardware realities, and ultimately pushes the burden of scheduling back onto the developer. Rich Hickey articulated this perfectly in his talk Simple Made Easy : “Easy” is what is familiar and close at hand, while “Simple” is what is structurally untangled 1 . async/await is easy to write, but it is fiercely complex to operate. Rob Pike talked about this architectural shift during his 2023 GopherConAU address: Compared to goroutines, channels and select, async/await is easier and smaller for language implementers to build… But it pushes some of the complexity back on the programmer, often resulting in what Bob Nystrom has called ‘colored functions’. […] It’s important, though, whatever concurrency model you do provide, you do it exactly once, because an environment providing multiple concurrency implementations can be problematic. 2 Pike’s remark about “ multiple concurrency implementations ” and async/await is exactly what is failing in production today. The Production Trap: Confusing Asynchrony with Concurrency The fundamental trap of async/await is that it conflates asynchrony (yielding while waiting for I/O) with concurrency (dealing with multiple things at once). The syntax is a trap because it disguises interleaved state machines as isolated, sequential threads. Lulled by this illusion, a developer writes an async function exactly as they would blocking code — fetching a database record over the network, then immediately crunching the data. But what happens when that data crunching involves parsing a 10MB JSON payload, traversing a massive collection, or executing a compute-heavy cryptographic proof? The cooperative executor halts. EXPECTATION: Fast I/O Tasks yield quickly. High throughput. Incoming Network Traffic Task Queue (Stable / Low) Executor (1 OS Thread) I/O yield I/O yield REALITY: The Compute Trap One CPU task stalls the executor. Queue explodes. Incoming Network Traffic Task Queue (Unbounded) OOM CRASH Executor (1 OS Thread) Heavy Compute Task (bcrypt, parsing, etc.) THREAD HALTED In a cooperative runtime like Rust’s Tokio or Node.js, the thread does not yield until it hits an await point. A 50-millisecond CPU-bound task in a function stalls the entire execution thread. Suddenly, thousands of unrelated network requests spike in latency and the system becomes unresponsive. Meanwhile the hardware is barely utilised. The Broken Promise: Human in the loop Scheduler When these latency spikes occur, the answer is always the same: separate your runtimes. Use Tokio for I/O, and send CPU-bound work to a dedicated thread pool like Rayon. Recent postmortems highlight the resulting disaster. Engineering teams at PostHog 3 and Meilisearch 4 have documented the painful reality of untangling these complexities in production. Developers must carefully analyse every function to decide if it belongs in the “I/O pool” or the “Compute pool,” and then manually orchestrate the message-passing boundary between them. If a developer must manually partition I/O and compute, strictly police the boundaries to prevent deadlocks, and ferry data between two different runtimes with two different mental models, the async abstraction has failed. The language feature promised to hide the complexity of concurrency. Instead, it turned the application developer into the human in the loop scheduler. Unbounded by Default is OOM by Default The second failure mode of async/await runtimes is how frictionless they make unbounded capacity. Calling tokio::spawn(...) is cheap. When a downstream database slows down during a traffic spike, the ingress network loop happily continues accepting connections and spawning tasks. Because async tasks and memory allocations are typically unbounded by default in these ecosystems, the system does not push back. In-flight tasks queue indefinitely. The application consumes RAM until the OS out-of-memory (OOM) killer violently terminates the process. Postmortems from major platforms consistently reveal the same root cause: queues do not fix overload, they simply delay the crash while making it catastrophic . Infinite capacity is a lie, and defaults that pretend otherwise are dangerous. The Work-Stealing Myth When systems hit these bottlenecks, developers often demand smarter, preemptive, work-stealing schedulers to distribute the load. The assumption is that if a core is idle, it should steal tasks from a busy core to guarantee fairness. But at massive scale, fairness is the enemy of throughput . Work-stealing destroys CPU cache locality. When WhatsApp pushed the Erlang BEAM virtual machine to its limits on 100+ core machines, the system choked. As detailed by Robin Morisset, idle threads trying to steal work spent all their CPU cycles fighting over the global runq_lock 5 — a lock used to synchronise access to a scheduler’s run queue. The Cost of Work-Stealing: Cache Locality Loss CPU Core 1 Overloaded L1 / L2 Cache (HOT) Task A State Data Task B State Data CPU Core 2 Idle L1 / L2 Cache (COLD) No Task B State Here Main Memory (DRAM) Shared across all cores Task B 1. Core 2 "Steals" Task B 2. Cache Miss! 3. ~100ns RAM Fetch Penalty Even with optimised locks, moving a state machine to a different CPU core means abandoning the L1 and L2 cache. Fairness does not matter if every stolen task incurs a 100+ nanosecond main memory fetch penalty. If you are already forced to manually partition threads for I/O versus CPU tasks to survive production, the generic work-stealing algorithm has already failed you. You understand your workload’s topology better than the runtime does. The Alternative I got tired of the complexities and traps of async/await . I wanted the rock-solid fault-tolerance of the BEAM, but without the opaque manoeuvres of garbage collection and global work-stealing. As Leslie Lamport has long argued, state machines are the mathematically sound foundation of concurrent programming. async/await is merely compiler magic that tries to hide the state machine from you, poorly. Instead of hiding the state machine, why not expose it and give the user better control primitives? The result is Project Tina : an opinionated, shared-nothing, thread-per-core concurrency framework. Tina: Thread-Per-Core (Shared-Nothing) No Work Stealing • No Mutexes • Strict Cache Locality SHARD 0 (Core 0) 1 OS Thread Scheduler Loop Isolate A (TCP Conn) Isolate B (HTTP) Isolate C (Worker) ↻ Strict Tick Sequence SHARD 1 (Core 1) 1 OS Thread Scheduler Loop Isolate X (Router) Isolate Y (TCP Conn) Isolate Z (Worker) ↻ Strict Tick Sequence Shard 1 --> Mailbox (Lock-Free) Ring -> Shard 1 --> Shard 0 --> Mailbox (Lock-Free) Ring -> Shard 0 --> NO SHARED MEMORY Tina embraces strict constraints to guarantee massive throughput and reliability: One Primitive. One Mental Model. There is no async or await , no Promises, and no Futures. You write an Isolate — a unit of concurrent work. The handler is a standard, synchronous function that reacts to a message and returns an Effect . Thread-Per-Core (Shared Nothing). Tina shards the workload across OS threads. There is no work stealing. Isolates never migrate. All cross-core communication occurs via the messaging subsystem. Strictly Bounded. Memory is pre-allocated at process boot. Mailboxes are strictly bounded. If a traffic spike hits and a mailbox is full, the caller is notified immediately. The system sheds load predictably rather than OOM-crashing the process. Architectural Determinism. In modern async runtimes, task polling order and thread-pool scheduling are opaque, non-deterministic sources of chaos. You rarely know exactly when or where your task will wake up. Tina