Skip to content

Split View: Rust 비동기: async/await와 Tokio 런타임

|

Rust 비동기: async/await와 Tokio 런타임

들어가며 — Rust의 async는 왜 다른가

많은 언어에서 비동기는 런타임에 내장되어 있습니다. 자바스크립트에는 이벤트 루프가 처음부터 돌고 있고, Go에는 고루틴을 스케줄링하는 런타임이 기본으로 딸려 옵니다. 하지만 Rust는 다릅니다. Rust는 async 문법을 언어 차원에서 제공하되, 그것을 실제로 굴리는 런타임은 표준 라이브러리에 넣지 않았습니다. 런타임은 별도의 크레이트(주로 Tokio)로 골라 쓰는 구조입니다.

이 선택이 처음에는 낯설지만, 여기엔 이유가 있습니다. Rust는 임베디드 기기부터 대규모 서버까지 두루 쓰이는데, 모든 상황에 맞는 단일 런타임은 존재하지 않습니다. 그래서 언어는 최소한의 뼈대(Future 트레이트와 async/await 문법)만 제공하고, 스케줄링 정책은 사용자가 고르게 합니다.

이 글은 그 뼈대부터 시작합니다. Future 가 무엇인지, .await 가 실제로 무슨 일을 하는지, 그 위에서 Tokio가 어떻게 태스크를 굴리는지, 그리고 실무에서 반드시 부딪히는 함정들(Send 제약, 블로킹 코드)까지 예제로 짚습니다.

Future는 게으른 상태 기계다

Rust에서 async fn 을 호출하면 함수 본문이 즉시 실행될 것 같지만, 아무 일도 일어나지 않습니다. async fn 은 호출 시점에 본문을 실행하는 대신, Future 라는 값을 하나 만들어 돌려줄 뿐입니다. 이 Future 는 "아직 완료되지 않은 계산"을 나타내는 값입니다.

async fn say_hello() {
    println!("hello");
}

fn main() {
    let fut = say_hello(); // 여기서는 "hello"가 출력되지 않는다!
    // fut는 그저 Future 값일 뿐, 아직 실행되지 않았다
}

이것이 Rust async의 핵심 성질, **게으름(laziness)**입니다. Future는 누군가가 그것을 **폴링(poll)**해 주기 전까지는 한 발짝도 나아가지 않습니다. 폴링이란 "지금 진행할 수 있니?"라고 물어보는 것입니다. Future는 이 물음에 두 가지로 답합니다. 계산이 끝났으면 Poll::Ready(값) 을, 아직 기다릴 게 있으면 Poll::Pending 을 돌려줍니다.

Future 트레이트의 뼈대는 대략 이렇게 생겼습니다.

use std::pin::Pin;
use std::task::{Context, Poll};

trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

컴파일러는 async 블록을 **상태 기계(state machine)**로 변환합니다. 각 .await 지점이 하나의 상태가 되고, 폴링될 때마다 이전에 멈췄던 지점에서 재개해 다음 .await 까지 진행합니다. 이 상태 기계 덕분에 스택을 쓰지 않고도 함수의 중간 상태를 보존할 수 있고, 그래서 Rust의 async는 힙 할당 없이도 동작할 만큼 가볍습니다.

.await — 중간에 양보하는 지점

.await 는 다른 Future가 완료되기를 기다리는 지점입니다. 하지만 "기다린다"고 해서 스레드를 붙잡고 멈춰 서는 것이 아닙니다. .await 의 진짜 의미는 **"이 Future가 아직 Pending이면, 제어권을 실행기에 양보하고 다른 일을 하게 하라"**입니다.

async fn fetch_and_process() {
    let data = fetch_data().await;     // 여기서 대기 중이면 다른 태스크에 양보
    let result = process(data).await;  // 완료되면 이어서 진행
    println!("{result}");
}

fetch_data().await 에서 데이터가 아직 준비되지 않았다면, 이 함수는 그 자리에서 멈추고 제어권을 놓습니다. 그러면 실행기는 그 사이 다른 태스크를 굴립니다. 나중에 데이터가 준비되면 실행기가 이 Future를 다시 폴링하고, 함수는 멈췄던 .await 지점에서 재개합니다. 이 협력적 양보가 하나의 스레드에서 수많은 동시 작업을 처리할 수 있게 하는 비결입니다.

여기서 중요한 규칙 하나. .awaitasync 문맥(async fn 이나 async 블록) 안에서만 쓸 수 있습니다. 일반 동기 함수에서는 Future를 .await 할 수 없습니다. 그렇다면 최초의 Future는 누가 폴링을 시작할까요? 바로 런타임입니다.

실행기와 런타임 — Tokio

Future는 게으르므로, 누군가 폴링을 시작하고 계속 굴려 줘야 실제로 실행됩니다. 그 역할을 하는 것이 **실행기(executor)**이고, 실행기에 타이머·네트워크 I/O 같은 부가 기능을 더해 완성한 것이 **런타임(runtime)**입니다. Rust 생태계에서 사실상 표준은 Tokio입니다.

가장 간단한 시작은 #[tokio::main] 매크로입니다. 이 매크로가 main 함수를 감싸 런타임을 세우고, 여러분의 최상위 Future를 그 위에서 폴링해 완료할 때까지 굴립니다.

#[tokio::main]
async fn main() {
    println!("start");
    say_hello().await; // 이제 실제로 실행된다
    println!("end");
}

async fn say_hello() {
    println!("hello");
}

#[tokio::main] 은 사실 다음과 같은 코드로 펼쳐집니다. 동기 main 이 런타임을 만들고, block_on 으로 async 블록을 완료될 때까지 굴리는 것입니다.

fn main() {
    tokio::runtime::Runtime::new()
        .unwrap()
        .block_on(async {
            println!("start");
            say_hello().await;
            println!("end");
        });
}

Tokio는 기본적으로 멀티스레드 스케줄러를 씁니다. CPU 코어 수만큼 워커 스레드를 두고, 태스크들을 그 위에 분배해 실행합니다. 한 워커가 놀고 있으면 다른 워커의 태스크를 훔쳐 오는 작업 훔치기(work-stealing) 방식으로 부하를 고르게 나눕니다. 가벼운 단일 스레드 런타임이 필요하면 #[tokio::main(flavor = "current_thread")] 로 바꿀 수도 있습니다.

태스크와 spawn — 동시성의 단위

지금까지는 하나의 Future를 순차적으로 .await 했습니다. 하지만 비동기의 진짜 힘은 여러 작업을 동시에 굴릴 때 나옵니다. 그 단위가 **태스크(task)**이고, tokio::spawn 으로 만듭니다.

tokio::spawn 은 Future를 받아 런타임에 독립적인 태스크로 등록합니다. 이 태스크는 즉시 백그라운드에서 실행되기 시작하며, spawn 은 그 태스크의 핸들(JoinHandle)을 곧바로 돌려줍니다. 태스크는 운영체제 스레드보다 훨씬 가벼워서, 수만 개를 띄워도 부담이 적습니다.

#[tokio::main]
async fn main() {
    let mut handles = vec![];

    for i in 0..5 {
        let handle = tokio::spawn(async move {
            // 각 태스크가 독립적으로, 동시에 실행된다
            some_async_work(i).await
        });
        handles.push(handle);
    }

    // 모든 태스크가 끝나길 기다린다
    for handle in handles {
        let result = handle.await.unwrap();
        println!("got: {result}");
    }
}

async fn some_async_work(i: u32) -> u32 {
    i * 2
}

spawn 한 태스크는 부모가 .await 하지 않아도 알아서 진행되지만, 결과를 받으려면 JoinHandle.await 하면 됩니다. 태스크가 패닉했다면 .await 결과가 Err 로 돌아오므로 여기서 확인할 수 있습니다.

메시지 큐나 워커 풀처럼 여러 생산자·소비자가 태스크로 오가며 통신하는 구조가 궁금하다면, 이 사이트의 메시지 큐 플레이그라운드에서 큐의 동작을 시각적으로 실험해 볼 수 있습니다. 비동기 태스크들이 채널을 통해 일을 주고받는 그림을 직관적으로 이해하는 데 도움이 됩니다.

Send와 정적 수명 — spawn의 제약

tokio::spawn 에 넘기는 Future에는 두 가지 제약이 붙습니다. 시그니처를 보면 이유가 드러납니다. 넘기는 Future는 Send 여야 하고 'static 수명이어야 합니다.

Send 제약부터 봅시다. 멀티스레드 런타임에서 태스크는 어느 워커 스레드에서든 실행될 수 있고, 심지어 .await 를 사이에 두고 다른 스레드로 옮겨질 수도 있습니다. 그러려면 Future가 담고 있는 모든 값이 스레드 간에 안전하게 이동할 수 있어야, 즉 Send 여야 합니다. 그래서 .await 를 가로질러 Send 가 아닌 값을 붙들고 있으면 컴파일 에러가 납니다. 대표적인 예가 앞선 스마트 포인터 글에서 본 Rc 입니다.

use std::rc::Rc;

async fn bad() {
    let data = Rc::new(5); // Rc는 Send가 아니다
    some_async_work().await; // 에러: .await를 가로질러 Rc를 붙들고 있음
    println!("{}", data);
}

이 경우 해결책은 Rc 대신 Arc 를 쓰는 것입니다. 또는 Send 아닌 값을 .await 이전에 스코프에서 떨궈, .await 지점을 가로지르지 않게 하는 방법도 있습니다.

'static 제약은 태스크의 수명이 부모 스코프보다 길어질 수 있기 때문입니다. spawn 한 태스크는 부모가 이미 반환한 뒤에도 살아 있을 수 있으므로, 부모 스코프의 참조를 빌려 가면 위험합니다. 그래서 Future는 빌린 참조가 아니라 값을 소유해야 합니다. 실무에서 이는 클로저에 move 를 붙이고, 필요한 데이터를 Arc 로 공유하는 형태로 흔히 해결됩니다.

비동기 트레이트 — async fn in traits

한동안 Rust에서 트레이트 안에 async fn 을 직접 두는 것은 불가능했습니다. async fn 이 반환하는 Future의 크기를 트레이트 수준에서 알 수 없었기 때문입니다. 그래서 오랫동안 async-trait 라는 크레이트가 이 틈을 메웠습니다. 이 크레이트는 매크로로 반환 타입을 Box<dyn Future> 로 바꿔, 힙 할당을 감수하는 대신 트레이트에 비동기 메서드를 넣을 수 있게 해 줍니다.

use async_trait::async_trait;

#[async_trait]
trait Repository {
    async fn find(&self, id: u64) -> Option<String>;
}

struct MyRepo;

#[async_trait]
impl Repository for MyRepo {
    async fn find(&self, id: u64) -> Option<String> {
        // 실제로는 DB를 비동기로 조회
        Some(format!("item {id}"))
    }
}

좋은 소식은, 비교적 최근의 Rust에서 트레이트 안의 async fn 이 언어 차원에서 지원되기 시작했다는 점입니다. 이제 많은 경우 async-trait 매크로 없이도 트레이트에 async fn 을 직접 쓸 수 있습니다. 다만 트레이트 객체(dyn)로 쓰거나 반환 Future에 Send 경계를 다는 등 일부 세부에서는 여전히 신경 쓸 부분이 있어, 프레임워크에 따라 async-trait 이 계속 쓰이기도 합니다.

select! — 여러 Future 중 먼저 끝나는 것

여러 Future를 동시에 기다리다가 가장 먼저 끝나는 하나에 반응하고 싶을 때가 있습니다. 대표적으로 "작업을 기다리되, 타임아웃이 먼저 오면 취소"하는 경우입니다. 이럴 때 tokio::select! 매크로를 씁니다.

select! 는 나열된 여러 Future를 동시에 폴링하다가, 그중 하나가 먼저 완료되면 그 가지를 실행하고 나머지는 취소합니다.

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    tokio::select! {
        result = do_work() => {
            println!("작업 완료: {result}");
        }
        _ = sleep(Duration::from_secs(5)) => {
            println!("타임아웃! 작업을 포기한다");
        }
    }
}

async fn do_work() -> u32 {
    sleep(Duration::from_secs(10)).await;
    42
}

위 예에서 do_work 는 10초가 걸리지만 타임아웃은 5초이므로, 5초 뒤 타이머 가지가 먼저 완료되어 그쪽이 실행되고 do_work 는 취소됩니다. 여기서 Rust async의 중요한 성질이 드러납니다. Future를 폴링하지 않으면 그냥 취소된다는 점입니다. 진행 중이던 do_work 는 그저 더 이상 폴링되지 않아 자연스럽게 버려집니다. 별도의 취소 신호를 보낼 필요가 없습니다.

select! 를 루프 안에서 쓰면, 여러 이벤트 소스(채널 수신, 타이머, 종료 신호 등)를 하나의 루프에서 동시에 감시하는 이벤트 루프를 만들 수 있습니다. 이것이 비동기 서버의 심장부에서 흔히 보이는 패턴입니다.

블로킹 vs 비동기 — 섞으면 안 되는 이유

비동기 프로그래밍에서 가장 흔하고 치명적인 실수는, 비동기 문맥 안에서 블로킹 코드를 호출하는 것입니다. 여기서 "블로킹"이란 CPU를 오래 쓰는 무거운 계산이나, std::thread::sleep, 동기 파일 I/O, 동기 네트워크 호출처럼 스레드를 실제로 멈춰 세우는 작업을 말합니다.

왜 위험할까요? 비동기 태스크는 워커 스레드를 잠깐 빌려서 폴링되고, .await 에서 양보하며 스레드를 놓아 줍니다. 그런데 태스크가 블로킹 호출을 하면 양보하지 않고 워커 스레드를 통째로 붙잡아 버립니다. 그 워커에 배정된 다른 모든 태스크는 그동안 전혀 진행되지 못하고 굶습니다. 최악의 경우 몇 개의 블로킹 태스크가 모든 워커를 점유해 런타임 전체가 멈춥니다.

// 나쁜 예: 비동기 문맥에서 스레드를 통째로 블로킹
async fn bad() {
    std::thread::sleep(std::time::Duration::from_secs(5)); // 워커 스레드를 5초간 붙잡음!
}

// 좋은 예: 비동기 타이머로 양보
async fn good() {
    tokio::time::sleep(std::time::Duration::from_secs(5)).await; // 스레드를 놓아 준다
}

정리하면 두 가지 원칙입니다. 첫째, 잠들거나 기다리는 일은 반드시 비동기 버전을 쓰세요. std::thread::sleep 대신 tokio::time::sleep, 동기 HTTP 대신 비동기 HTTP 클라이언트처럼요. 둘째, CPU를 오래 쓰는 무거운 계산이나 어쩔 수 없이 동기인 라이브러리는 tokio::task::spawn_blocking 으로 전용 블로킹 스레드 풀에 떼어 놓으세요. 이렇게 하면 무거운 일이 async 워커를 굶기지 않습니다.

async fn heavy() {
    let result = tokio::task::spawn_blocking(|| {
        // CPU를 오래 쓰는 계산이나 동기 I/O를 여기서
        expensive_sync_computation()
    })
    .await
    .unwrap();
    println!("{result}");
}

fn expensive_sync_computation() -> u64 {
    (0..1_000_000).sum()
}

마치며

Rust의 async는 처음엔 낯설지만, 몇 가지 핵심만 잡으면 일관된 그림으로 이어집니다.

  • async fn게으른 Future를 만들 뿐, 폴링되기 전엔 아무 일도 하지 않습니다.
  • .await 는 스레드를 붙잡는 대신 제어권을 양보하는 지점입니다.
  • Future를 실제로 굴리려면 **런타임(Tokio)**이 필요하고, tokio::spawn 으로 동시 태스크를 띄웁니다.
  • spawn 한 Future는 Send + 'static 이어야 하므로, 스레드 간 이동과 수명을 염두에 둬야 합니다.
  • select! 로 여러 Future를 경쟁시키고, 폴링을 멈추면 Future는 자연히 취소됩니다.
  • 비동기 문맥에서는 블로킹을 피하고, 무거운 동기 작업은 spawn_blocking 으로 격리하세요.

이 원칙들을 몸에 익히면, Rust의 async는 안전성과 성능을 동시에 주는 강력한 도구가 됩니다. 언어가 주는 것은 뼈대뿐이지만, 그 뼈대가 명확하기에 여러분은 무슨 일이 벌어지는지 정확히 이해하며 코드를 짤 수 있습니다.

참고 자료

Async Rust: async/await and the Tokio Runtime

Introduction — Why Async Rust Is Different

In many languages, async is built into the runtime. JavaScript has an event loop spinning from the start, and Go ships with a runtime that schedules goroutines by default. Rust is different. Rust provides async syntax at the language level, but it doesn't put the runtime that actually drives it into the standard library. The runtime is a separate crate (usually Tokio) that you choose.

This choice feels odd at first, but there's a reason. Rust runs everywhere from embedded devices to large servers, and no single runtime fits every situation. So the language provides only the minimal skeleton — the Future trait and the async/await syntax — and leaves the scheduling policy to you.

This post starts from that skeleton. What a Future is, what .await actually does, how Tokio drives tasks on top of it, and the traps you're guaranteed to hit in practice (the Send constraint, blocking code) — all by example.

A Future Is a Lazy State Machine

When you call an async fn in Rust, it looks like the function body should run immediately, but nothing happens. Instead of running the body at the call site, an async fn merely builds and returns a value called a Future. That Future is a value representing "a computation that hasn't finished yet."

async fn say_hello() {
    println!("hello");
}

fn main() {
    let fut = say_hello(); // "hello" is NOT printed here!
    // fut is just a Future value; it hasn't run yet
}

This is the core property of async Rust: laziness. A Future doesn't move a single step until someone polls it. To poll is to ask, "can you make progress right now?" The Future answers in one of two ways: Poll::Ready(value) if the computation is done, or Poll::Pending if it's still waiting on something.

The skeleton of the Future trait looks roughly like this:

use std::pin::Pin;
use std::task::{Context, Poll};

trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

The compiler turns an async block into a state machine. Each .await point becomes a state, and every time it's polled, it resumes from where it last paused and runs to the next .await. That state machine lets it preserve the function's mid-flight state without using the stack, which is why async in Rust is light enough to run without heap allocation.

.await — The Point Where You Yield

.await is a point where you wait for another Future to complete. But "waiting" doesn't mean grabbing a thread and stalling on it. The real meaning of .await is: "if this Future is still Pending, yield control back to the executor and let it do other work."

async fn fetch_and_process() {
    let data = fetch_data().await;     // if waiting here, yield to another task
    let result = process(data).await;  // resume when it completes
    println!("{result}");
}

If, at fetch_data().await, the data isn't ready yet, this function pauses right there and gives up control. The executor then runs other tasks in the meantime. Later, when the data is ready, the executor polls this Future again and the function resumes at the .await where it paused. This cooperative yielding is the secret that lets a single thread handle a huge number of concurrent operations.

One important rule here: .await can only be used inside an async context (an async fn or an async block). You can't .await a Future from an ordinary synchronous function. So who starts polling the very first Future? The runtime.

The Executor and Runtime — Tokio

Because Futures are lazy, one only actually runs if someone starts polling it and keeps driving it. That's the job of the executor, and an executor rounded out with extras like timers and network I/O is a runtime. In the Rust ecosystem, the de facto standard is Tokio.

The simplest way to start is the #[tokio::main] macro. It wraps your main function, stands up a runtime, and drives your top-level Future on it until it completes.

#[tokio::main]
async fn main() {
    println!("start");
    say_hello().await; // now it actually runs
    println!("end");
}

async fn say_hello() {
    println!("hello");
}

#[tokio::main] in fact expands into roughly the following: a synchronous main creates a runtime and uses block_on to drive the async block to completion.

fn main() {
    tokio::runtime::Runtime::new()
        .unwrap()
        .block_on(async {
            println!("start");
            say_hello().await;
            println!("end");
        });
}

By default Tokio uses a multi-threaded scheduler. It keeps as many worker threads as CPU cores and distributes tasks across them. When one worker is idle, it steals tasks from another worker — the work-stealing approach — to balance the load evenly. If you need a lighter single-threaded runtime, you can switch to #[tokio::main(flavor = "current_thread")].

Tasks and spawn — The Unit of Concurrency

So far we've .awaited a single Future sequentially. But the real power of async shows up when you drive several operations at once. That unit is a task, created with tokio::spawn.

tokio::spawn takes a Future and registers it with the runtime as an independent task. The task starts running in the background immediately, and spawn hands you the task's handle (JoinHandle) right away. Tasks are far lighter than OS threads, so spawning tens of thousands of them is cheap.

#[tokio::main]
async fn main() {
    let mut handles = vec![];

    for i in 0..5 {
        let handle = tokio::spawn(async move {
            // each task runs independently and concurrently
            some_async_work(i).await
        });
        handles.push(handle);
    }

    // wait for all tasks to finish
    for handle in handles {
        let result = handle.await.unwrap();
        println!("got: {result}");
    }
}

async fn some_async_work(i: u32) -> u32 {
    i * 2
}

A spawned task makes progress on its own even if the parent doesn't .await it, but to collect its result you .await the JoinHandle. If the task panicked, the .await result comes back as Err, so you can detect it there.

If you're curious how a setup with multiple producers and consumers passing work as tasks communicates — like a message queue or a worker pool — you can experiment with queue behavior visually in the Message Queue Playground on this site. It helps build intuition for how async tasks hand work to one another through channels.

Send and 'static — The Constraints on spawn

The Future you hand to tokio::spawn carries two constraints. Its signature reveals why: the Future must be Send and must have a 'static lifetime.

Start with the Send constraint. On a multi-threaded runtime, a task can run on any worker thread, and can even be moved to a different thread across an .await. For that, every value the Future holds must be safe to move between threads — that is, Send. So holding onto a non-Send value across an .await is a compile error. The classic example is Rc, from the earlier smart-pointers post.

use std::rc::Rc;

async fn bad() {
    let data = Rc::new(5); // Rc is not Send
    some_async_work().await; // error: holding Rc across an .await
    println!("{}", data);
}

The fix here is to use Arc instead of Rc. Alternatively, you can drop the non-Send value out of scope before the .await so it doesn't straddle the .await point.

The 'static constraint exists because a task's lifetime can outlive the parent scope. A spawned task may still be alive after the parent has already returned, so borrowing a reference from the parent scope would be dangerous. The Future must therefore own its data rather than borrow it. In practice this is usually solved by putting move on the closure and sharing the data you need through an Arc.

Async Traits — async fn in Traits

For a long time, putting an async fn directly in a trait was impossible in Rust, because the size of the Future an async fn returns wasn't known at the trait level. So the async-trait crate filled that gap for years. It uses a macro to change the return type into a Box<dyn Future>, letting you put async methods in a trait at the cost of a heap allocation.

use async_trait::async_trait;

#[async_trait]
trait Repository {
    async fn find(&self, id: u64) -> Option<String>;
}

struct MyRepo;

#[async_trait]
impl Repository for MyRepo {
    async fn find(&self, id: u64) -> Option<String> {
        // in reality, query a DB asynchronously
        Some(format!("item {id}"))
    }
}

The good news is that in relatively recent Rust, async fn in traits is now supported at the language level. In many cases you can now write an async fn directly in a trait without the async-trait macro. That said, some details still need care — using it as a trait object (dyn), or putting a Send bound on the returned Future — so async-trait still sees use depending on the framework.

select! — Whichever Future Finishes First

Sometimes you want to wait on several Futures at once and react to whichever finishes first. The classic case is "wait for a job, but cancel if a timeout comes first." For that you use the tokio::select! macro.

select! polls the listed Futures concurrently, and when one of them completes first, it runs that branch and cancels the rest.

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    tokio::select! {
        result = do_work() => {
            println!("work done: {result}");
        }
        _ = sleep(Duration::from_secs(5)) => {
            println!("timeout! giving up the work");
        }
    }
}

async fn do_work() -> u32 {
    sleep(Duration::from_secs(10)).await;
    42
}

In the example above, do_work takes 10 seconds but the timeout is 5, so after 5 seconds the timer branch completes first, that branch runs, and do_work is cancelled. An important property of async Rust shows up here: a Future that isn't polled is simply cancelled. The in-flight do_work is just no longer polled and is dropped naturally. There's no need to send a separate cancellation signal.

Used inside a loop, select! lets you build an event loop that watches several event sources (channel receives, timers, shutdown signals, and so on) concurrently in one loop. This is the pattern you commonly find at the heart of an async server.

Blocking vs Async — Why You Must Not Mix Them

The most common and most damaging mistake in async programming is calling blocking code inside an async context. Here "blocking" means heavy computation that uses the CPU for a long time, or work that actually stalls the thread — like std::thread::sleep, synchronous file I/O, or synchronous network calls.

Why is it dangerous? An async task briefly borrows a worker thread to be polled, and releases the thread by yielding at an .await. But if the task makes a blocking call, it never yields and hogs the whole worker thread. Every other task assigned to that worker starves, making no progress in the meantime. In the worst case, a handful of blocking tasks occupy all the workers and the entire runtime grinds to a halt.

// Bad: blocking the whole thread inside an async context
async fn bad() {
    std::thread::sleep(std::time::Duration::from_secs(5)); // hogs the worker thread for 5s!
}

// Good: yield with an async timer
async fn good() {
    tokio::time::sleep(std::time::Duration::from_secs(5)).await; // releases the thread
}

Two principles, then. First, for anything that sleeps or waits, always use the async versiontokio::time::sleep instead of std::thread::sleep, an async HTTP client instead of a synchronous one. Second, offload heavy CPU-bound computation or unavoidably synchronous libraries to a dedicated blocking thread pool with tokio::task::spawn_blocking. That keeps the heavy work from starving the async workers.

async fn heavy() {
    let result = tokio::task::spawn_blocking(|| {
        // CPU-heavy computation or synchronous I/O goes here
        expensive_sync_computation()
    })
    .await
    .unwrap();
    println!("{result}");
}

fn expensive_sync_computation() -> u64 {
    (0..1_000_000).sum()
}

Wrapping Up

Async Rust feels unfamiliar at first, but once you grasp a few essentials, it all connects into a coherent picture.

  • An async fn only builds a lazy Future, and does nothing until it's polled.
  • .await is a point where you yield control instead of grabbing a thread.
  • To actually drive Futures you need a runtime (Tokio), and you spawn concurrent tasks with tokio::spawn.
  • A spawned Future must be Send + 'static, so you keep cross-thread movement and lifetime in mind.
  • With select! you race several Futures, and a Future is cancelled naturally when you stop polling it.
  • In an async context, avoid blocking, and isolate heavy synchronous work with spawn_blocking.

Internalize these principles and async Rust becomes a powerful tool that gives you safety and performance at once. The language provides only the skeleton, but because that skeleton is explicit, you get to write code while understanding exactly what's happening.

References