hyunsu blog

홈강좌Rust 입문·중급 47편 중 46편째

Rust async — 기다리는 일을 겹쳐서 처리한다 (tokio)

목차 · 5
  1. 01왜 async 인가
  2. 02tokio 를 붙인다
  3. 03.await 로 기다리고, join! 으로 겹친다
  4. 04await 를 빠뜨리면 아무 일도 없다
  5. 05실습 — 에코 서버와 동시 접속

Rust 의 async fn 은 바로 실행되지 않고 Future 를 돌려주며, .await 를 붙여야 실행된다. 빠뜨리면 아무 일도 하지 않고 경고만 남는다. 실행은 tokio 같은 런타임이 맡고, tokio::join! 으로 여러 일을 겹치면 1초짜리 둘이 1초에 끝난다. 127.0.0.1 의 작은 에코 서버로 동시 접속을 확인한다.

01 왜 async 인가

43편의 스레드는 운영체제가 만드는 실행 흐름이라 하나하나가 무겁다. 그런데 네트워크·파일처럼 기다리는 시간이 대부분인 일은, 기다리는 동안 다른 일을 하면 스레드 하나로도 많은 일을 동시에 처리할 수 있다. 이것이 async 다. 기다리는 일을 겹친다.

02 tokio 를 붙인다

Rust 는 async 문법만 갖고 있고, 실제로 돌리는 런타임은 크레이트로 고른다. 가장 많이 쓰는 tokio 를 41편처럼 정확한 버전으로 넣는다. features 는 쓸 기능만 고르는 칸이다.

toml
[package]
name = "waiting"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "=1.54.1", features = ["rt-multi-thread", "macros", "time", "net", "io-util"] }

03 .await 로 기다리고, join! 으로 겹친다

1초씩 걸리는 일 둘을 한 번은 차례로, 한 번은 겹쳐서 해 보자.

rust
use std::time::{Duration, Instant};
use tokio::time::sleep;

async fn job(name: &str, secs: u64) -> String {
    sleep(Duration::from_secs(secs)).await;
    format!("{name} done")
}

#[tokio::main]
async fn main() {
    let start = Instant::now();
    let a = job("download", 1).await;
    let b = job("resize", 1).await;
    println!("{a}, {b} - one by one: {}s", start.elapsed().as_secs());

    let start = Instant::now();
    let (a, b) = tokio::join!(job("download", 1), job("resize", 1));
    println!("{a}, {b} - together: {}s", start.elapsed().as_secs());
}
text
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.67s
     Running `target/debug/waiting`
download done, resize done - one by one: 2s
download done, resize done - together: 1s
  • async fn job(…) 은 async 함수다. 부르면 바로 실행되지 않고, "나중에 결과가 나올 일"(Future)을 돌려준다.
  • .await 는 그 일이 끝날 때까지 기다린다. 기다리는 동안 런타임은 다른 일을 돌린다. sleep(…).await 는 1초를 기다리는 일이다.
  • #[tokio::main] 은 main 을 tokio 런타임 위에서 돌린다. 그래서 main 도 async fn 이다.
  • 차례로 .await 하면 1초 + 1초 = 2초. tokio::join! 으로 둘을 동시에 기다리면 1초다.

04 await 를 빠뜨리면 아무 일도 없다

rust
use std::time::Duration;
use tokio::time::sleep;

async fn job(name: &str) -> String {
    sleep(Duration::from_millis(100)).await;
    println!("{name} running");
    format!("{name} done")
}

#[tokio::main]
async fn main() {
    job("download");
    println!("main finished");
}
text
warning: unused implementer of `Future` that must be used
  --> src/main.rs:12:5
   |
12 |     job("download");
   |     ^^^^^^^^^^^^^^^
   |
   = note: futures do nothing unless you `.await` or poll them
   = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default

warning: `waiting` (bin "waiting") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.65s
     Running `target/debug/waiting`
main finished

job("download") 을 불렀는데 download running 이 찍히지 않았다. 경고가 이유를 말한다. Future 는 .await 하거나 돌려 주기 전에는 아무것도 하지 않는다(futures do nothing unless you .await). 37편의 게으른 반복자와 같은 생각이다. 컴파일은 되니 경고를 꼭 읽는다.

05 실습 — 에코 서버와 동시 접속

받은 글자를 그대로 돌려주는 서버를 127.0.0.1:7878(이 컴퓨터 안에서만 닿는 주소)에 띄우고, 손님 셋이 동시에 접속하게 해 보자.

rust
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};

async fn serve(listener: TcpListener) {
    loop {
        let (mut socket, _) = listener.accept().await.unwrap();
        tokio::spawn(async move {
            let mut buf = [0; 64];
            let n = socket.read(&mut buf).await.unwrap();
            socket.write_all(&buf[..n]).await.unwrap();
        });
    }
}

async fn ask(text: &str) -> String {
    let mut stream = TcpStream::connect("127.0.0.1:7878").await.unwrap();
    stream.write_all(text.as_bytes()).await.unwrap();
    let mut buf = [0; 64];
    let n = stream.read(&mut buf).await.unwrap();
    String::from_utf8_lossy(&buf[..n]).to_string()
}

#[tokio::main]
async fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").await.unwrap();
    tokio::spawn(serve(listener));

    let (a, b, c) = tokio::join!(ask("one"), ask("two"), ask("three"));
    println!("{a} {b} {c}");
}
text
   Compiling proc-macro2 v1.0.107
   Compiling quote v1.0.47
   Compiling libc v0.2.191
   Compiling unicode-ident v1.0.27
   Compiling pin-project-lite v0.2.17
   Compiling bytes v1.12.1
   Compiling syn v3.0.9
   Compiling mio v1.2.4
   Compiling socket2 v0.6.5
   Compiling tokio-macros v2.7.2
   Compiling tokio v1.54.1
   Compiling echo v0.1.0 (~/projects/echo)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.80s
     Running `target/debug/echo`
one two three
  • TcpListener::bind 로 주소를 열고, serve 는 loop 안에서 접속을 받을 때마다(accept().await) tokio::spawn 으로 손님 하나를 맡을 태스크를 띄운다. 태스크는 스레드보다 훨씬 가벼운 async 실행 단위다.
  • 태스크는 읽은 바이트(buf[..n], 21편의 슬라이스)를 그대로 다시 쓴다.
  • ask 는 손님이다. 접속해서 글자를 보내고 답을 읽는다. from_utf8_lossy 는 받은 바이트를 문자열로 바꾼다.
  • tokio::join! 으로 손님 셋을 동시에 보냈고, 셋 다 자기 글자를 돌려받았다.

처음 빌드에서 Compiling 이 열 줄 넘게 나온 것은 tokio 가 쓰는 크레이트들을 함께 컴파일해서다. async 는 이 편에서 본 것보다 깊은 주제다. 막는(blocking) 함수를 async 안에서 부르면 런타임 전체가 멈추는 함정 같은 것은 tokio 튜토리얼에서 이어 읽는다. 다음 편은 마지막 편, wordfreq 2판이다.