Skip to content

필사 모드: Async Rust: async/await and the Tokio Runtime

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

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

현재 단락 (1/150)

In many languages, async is built into the runtime. JavaScript has an event loop spinning from the s...

작성 글자: 0원문 글자: 11,397작성 단락: 0/150