홈강좌Rust 입문·중급 47편 중 46편째
Rust async — 기다리는 일을 겹쳐서 처리한다 (tokio)
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 는 쓸 기능만 고르는 칸이다.
[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초씩 걸리는 일 둘을 한 번은 차례로, 한 번은 겹쳐서 해 보자.
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());
} 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: 1sasync fn job(…)은 async 함수다. 부르면 바로 실행되지 않고, "나중에 결과가 나올 일"(Future)을 돌려준다..await는 그 일이 끝날 때까지 기다린다. 기다리는 동안 런타임은 다른 일을 돌린다.sleep(…).await는 1초를 기다리는 일이다.#[tokio::main]은main을 tokio 런타임 위에서 돌린다. 그래서main도async fn이다.- 차례로
.await하면 1초 + 1초 = 2초.tokio::join!으로 둘을 동시에 기다리면 1초다.
04 await 를 빠뜨리면 아무 일도 없다
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");
}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 finishedjob("download") 을 불렀는데 download running 이 찍히지 않았다. 경고가 이유를 말한다. Future 는 .await 하거나 돌려 주기 전에는 아무것도 하지 않는다(futures do nothing unless you .await). 37편의 게으른 반복자와 같은 생각이다. 컴파일은 되니 경고를 꼭 읽는다.
05 실습 — 에코 서버와 동시 접속
받은 글자를 그대로 돌려주는 서버를 127.0.0.1:7878(이 컴퓨터 안에서만 닿는 주소)에 띄우고, 손님 셋이 동시에 접속하게 해 보자.
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}");
} 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 threeTcpListener::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판이다.