async/await와 Future

한 줄 요약

async는 함수를 즉시 실행하지 않고 Future를 반환하게 만들고, await는 그 Future가 완료될 때까지 양보한다.

왜 비동기가 필요한가

웹 서버가 10,000개의 동시 연결을 처리해야 한다고 하자.

스레드 방식:
- 연결당 1스레드 → 10,000개 OS 스레드
- 스레드당 8MB 스택 → 80GB 메모리
- 대부분의 시간은 I/O 대기 → CPU 낭비

비동기 방식:
- 소수의 스레드 (CPU 코어 수만큼)
- I/O 대기 중에는 다른 작업을 실행
- 메모리: 태스크당 수백 바이트 ~ 수 KB

비동기는 I/O 대기 시간을 활용하는 것이다.

다른 언어와 비교

언어비동기 모델런타임
JavaScript이벤트 루프 + PromiseV8 내장
Pythonasyncio + coroutineasyncio 내장
Go고루틴 (M:N 스케줄링)런타임 내장
C#Task + async/await.NET 런타임 내장
RustFuture + async/await런타임이 없음 (직접 선택)

러스트만의 차별점: 런타임이 언어에 포함되지 않는다. tokio, async-std 등 외부 런타임을 선택한다. 이 덕분에 임베디드부터 서버까지 환경에 맞는 런타임을 쓸 수 있다.

async fn — 비동기 함수

async fn fetch_data() -> String {
    // 비동기 작업
    "데이터".to_string()
}

async fn은 호출해도 즉시 실행되지 않는다. Future를 반환한다.

let future = fetch_data();  // 아직 실행 안 됨!
// future를 .await 해야 실행됨

await — Future 실행

async fn main_work() {
    let data = fetch_data().await;  // 여기서 실행되고, 완료될 때까지 양보
    println!("{}", data);
}

.await가 하는 일:

  1. Future를 폴링(poll)한다
  2. 아직 완료 안 됐으면 → 현재 태스크를 **양보(yield)**하고 다른 태스크 실행
  3. 완료되면 → 결과를 꺼내서 반환

Future 트레이트

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
 
pub enum Poll<T> {
    Ready(T),    // 완료됨, 결과는 T
    Pending,     // 아직 안 됨, 나중에 다시 폴링해줘
}

async fn은 이 Future 트레이트를 구현하는 상태 머신으로 변환된다.

컴파일러가 하는 일

// 이 코드는
async fn example() -> i32 {
    let a = step_one().await;
    let b = step_two(a).await;
    a + b
}
 
// 개념적으로 이런 상태 머신이 된다
enum ExampleFuture {
    State0,                          // 시작
    State1 { step_one_future: ... }, // step_one을 기다리는 중
    State2 { a: i32, step_two_future: ... }, // step_two를 기다리는 중
    Done,
}

.await 지점이 상태 전이 포인트다. Pending이면 현재 상태를 저장하고 양보, Ready면 다음 상태로 진행.

async 블록

함수 전체가 아니라 코드 블록만 비동기로 만들 수 있다.

let future = async {
    let data = fetch_data().await;
    data.len()
};
 
// future는 impl Future<Output = usize>

async move 블록

클로저의 move와 같다. 변수의 소유권을 블록 안으로 이동한다.

let name = String::from("ferris");
 
let future = async move {
    println!("hello, {}", name);  // name의 소유권 이동
};
 
// println!("{}", name);  // 에러! name은 async 블록이 소유

첫 번째 비동기 프로그램

러스트 표준 라이브러리에는 비동기 런타임이 없다. tokio를 사용하자.

[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};
 
async fn say_hello() {
    println!("hello...");
    sleep(Duration::from_secs(1)).await;  // 1초 비동기 대기
    println!("...world!");
}
 
#[tokio::main]
async fn main() {
    say_hello().await;
}

#[tokio::main]은 tokio 런타임을 생성하고 main 함수를 그 위에서 실행한다.

동시에 여러 작업

async fn task(name: &str, ms: u64) {
    println!("{} 시작", name);
    sleep(Duration::from_millis(ms)).await;
    println!("{} 완료 ({}ms)", name, ms);
}
 
#[tokio::main]
async fn main() {
    // 순차 실행: 총 3초
    task("A", 1000).await;
    task("B", 1000).await;
    task("C", 1000).await;
 
    // 동시 실행: 총 1초 (가장 느린 것 기준)
    tokio::join!(
        task("A", 1000),
        task("B", 500),
        task("C", 300),
    );
}

join!은 여러 Future를 동시에 폴링한다. 모든 Future가 완료되면 결과를 반환한다.

async fn의 반환 타입

// 이것은
async fn foo() -> i32 { 42 }
 
// 사실 이것과 같다
fn foo() -> impl Future<Output = i32> {
    async { 42 }
}

async fn의 실제 반환 타입은 컴파일러가 생성하는 익명 Future 타입이다.

트레이트에서 async fn

// Rust 1.75부터 트레이트에 async fn 가능
trait DataFetcher {
    async fn fetch(&self, url: &str) -> Result<String, Error>;
}
 
struct HttpFetcher;
 
impl DataFetcher for HttpFetcher {
    async fn fetch(&self, url: &str) -> Result<String, Error> {
        // reqwest 등으로 HTTP 요청
        Ok("response".into())
    }
}

.await는 어디서 쓸 수 있는가

async fn 또는 async 블록 안에서만 .await를 쓸 수 있다.

// OK
async fn example() {
    let data = fetch().await;
}
 
// 에러! 동기 함수에서 .await 불가
fn sync_function() {
    // let data = fetch().await;  // 컴파일 에러!
}

동기 함수에서 Future를 실행하려면 런타임을 직접 사용해야 한다.

fn sync_function() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let data = rt.block_on(fetch());
}

정리

개념설명
async fnFuture를 반환하는 함수 (즉시 실행 안 됨)
.awaitFuture를 폴링하고 완료될 때까지 양보
Future 트레이트poll()Ready 또는 Pending
async 블록코드 블록을 Future로 만듦
async move소유권을 Future 안으로 이동
런타임Future를 실행하는 엔진 (tokio 등)
  • async는 상태 머신으로 컴파일된다 (제로 코스트)
  • 런타임은 언어에 포함되지 않음 → 선택의 자유
  • I/O 대기 시간을 활용하는 것이 핵심 목적

다음 글에서는 런타임의 내부 동작과 tokio 를 깊이 다룬다.