async Rust에서 일을 멈추는 방법은 하나뿐이다. 그 일을 하던 퓨처를 버린다. 버리면 퓨처는 멈춰 있던 .await 자리에서 다시는 깨어나지 않고, 들고 있던 값은 정리된다. 시간 제한(timeout)과 “먼저 끝나는 쪽만 고르기”(select!)도 모두 이 방법으로 나머지를 멈춘다. 그래서 어느 .await에서 멈춰도 데이터를 잃지 않게 짜야 하고, .await 없이 도는 코드는 아무도 멈출 수 없다.
예시로 보기
외부 API를 부르는데 가끔 응답이 오지 않는다. 기다림에 끝이 없으면 요청 하나가 연결과 메모리를 영원히 붙잡는다. 그래서 “1초 안에 안 오면 포기”가 필요하다. 스레드였다면 다른 스레드를 밖에서 강제로 멈출 안전한 방법이 마땅치 않다. 퓨처는 다르다. 퓨처는 누군가 poll해 줘야만 움직이므로, 그만 poll하고 버리면 그것으로 멈춘다12.
match timeout(Duration::from_secs(1), call_api()).await {
Ok(v) => v, // 1초 안에 끝났다
Err(_elapsed) => fallback(), // 시간이 지나 call_api() 퓨처를 버렸다
}
버린 퓨처 안에서는 무슨 일이 생길까. 200ms를 기다리는 일에 20ms 제한을 걸고, 일 안에 “버려지면 깃발을 세우는” 값을 넣어 확인했다.
async fn slow_job(dropped: Arc<AtomicBool>, after_await: Arc<AtomicBool>) -> u32 {
let _g = Guard(dropped); // Drop 때 깃발을 세운다
sleep(Duration::from_millis(200)).await; // ← 여기서 멈춘 채 버려진다
after_await.store(true, Ordering::SeqCst); // 끝내 돌지 않는다
1
}
출력은 timeout 결과 is_err=true, Guard 정리됨=true, .await 뒤 코드 실행됨=false였다. 퓨처 안의 지역 변수는 보통 값처럼 Drop으로 정리되고, 멈춘 .await 뒤의 코드는 실행되지 않는다. Java라면 finally에 둘 정리 코드를 Rust에서는 Drop에 둔다(try-catch-finally).
Ok인 것, .await 없는 100ms 계산에 10ms 제한이 듣지 않는 것, select!의 무작위·biased 순서(1000번), 취소에 안전한 읽기와 아닌 읽기의 메시지 손실, CancellationToken의 부모·자식 관계를 확인했다 — 38_cancellation-timeouts-select_impl.rs퓨처를 멈추는 네 가지 길
Async Book은 퓨처를 취소하는 길을 이렇게 꼽는다2. 모두 결국 퓨처를 버리는 것이다.
| 방법 | 누가 버리나 | 쓰는 곳 |
|---|---|---|
퓨처를 그냥 drop |
내가 | 가장 단순한 경우 |
tokio::time::timeout(d, fut) |
시간이 지나면 timeout이 | 외부 호출 시간 제한 |
tokio::select! |
먼저 끝난 가지가 나머지를 | 여러 일 중 먼저 오는 것 |
JoinHandle::abort() |
런타임이 그 태스크를 | 띄워 둔 태스크 멈추기 |
CancellationToken |
태스크가 신호를 보고 스스로 | 정리할 시간을 주며 끝내기 |
마지막 줄만 성격이 다르다. 앞의 넷은 밖에서 버린다. 토큰은 “멈춰 달라”는 신호만 보내고, 태스크가 그 신호를 보고 정리한 뒤 스스로 끝난다2.
timeout
timeout(d, fut)는 Result<T, Elapsed>를 주는 퓨처다. 시간이 지나면 안쪽 퓨처를 버리고 Err(Elapsed)를 낸다3. 경계에서의 동작이 둘 있다.
- 이미 끝난 퓨처는 시간이 0이어도
Ok다. timeout은 안쪽 퓨처를 먼저poll하고 나서 시계를 본다3.timeout(Duration::ZERO, async { 5 }).await는Ok(5)였다. .await없이 도는 코드는 끊지 못한다. 같은 이유로, 안쪽 퓨처가 한 번의poll안에서 오래 돌면 timeout은 그동안 끼어들 기회가 없다3.std::thread::sleep(100ms)를 하는 퓨처에 10ms 제한을 걸었더니 결과가Ok("다 돌았다")였다.
select!
tokio::select!는 여러 퓨처를 같은 태스크에서 함께 poll하다가, 처음 끝난 가지의 코드를 돌리고 나머지 퓨처는 버린다4.
tokio::select! {
m = rx.recv() => handle(m), // 메시지가 먼저 오면
_ = sleep(Duration::from_secs(5)) => { // 5초가 먼저 지나면 recv() 퓨처는 버려진다
heartbeat();
}
}
- 모든 가지가 같은 태스크에서 돈다. 그래서
spawn과 달리 지역 변수를 빌려도 된다1. - 둘 이상이 동시에 준비돼 있으면 무작위로 고른다. 한 가지가 늘 준비돼 있어도 다른 가지가 굶지 않게 하려는 것이다4. 맨 위에
biased;를 쓰면 위에서부터 차례로 본다. 이때 공정함은 내 책임이고, 종료 신호처럼 놓치면 안 되는 가지를 위에 둔다4. - 가지에
, if 조건을 붙이면 조건이 거짓일 때 그 가지를 끈다. 모든 가지가 꺼졌는데else가지가 없으면 panic한다4.
두 가지가 모두 바로 준비된 select!를 1000번 돌려 첫 가지가 뽑힌 횟수를 셌다. 기본은 528번, biased는 1000번이었다.
취소 안전성
select!를 루프 안에서 돌리면 지는 가지의 퓨처가 매번 버려진다. 그 퓨처가 일을 반쯤 해 둔 상태였다면 그 반은 사라진다. tokio 문서는 기준을 이렇게 정한다. 아직 끝나지 않은 퓨처를 버렸다가 다시 만들어도 아무 일이 없으면 그 퓨처는 취소에 안전하다(cancel safe)4.
30ms마다 메시지를 하나씩 보내는 생산자에게서 6개를 받으며, 매번 10ms 타이머와 경쟁시켰다.
// 안전하지 않다: 첫 메시지를 지역 변수에 받아 둔 채 둘째를 기다린다
async fn read_two(rx: &mut mpsc::Receiver<u32>) -> Option<(u32, u32)> {
let a = rx.recv().await?;
let b = rx.recv().await?; // 여기서 버려지면 a는 사라진다
Some((a, b))
}
// 안전하다: 받아 둔 첫 메시지를 퓨처 밖에 둔다
async fn read_two_safe(rx: &mut mpsc::Receiver<u32>, pending: &mut Option<u32>) -> Option<(u32, u32)> {
if pending.is_none() {
*pending = Some(rx.recv().await?);
}
let b = rx.recv().await?;
Some((pending.take().unwrap(), b))
}
select! 가지 |
받은 메시지 |
|---|---|
rx.recv() |
[1, 2, 3, 4, 5, 6] |
read_two(&mut rx) |
[] (모두 잃었다) |
read_two_safe(&mut rx, &mut pending) |
[1, 2, 3, 4, 5, 6] |
read_two는 첫 메시지를 받고 30ms 뒤의 둘째를 기다리는 사이, 10ms 타이머가 이겨 퓨처가 버려진다. 그 안의 a가 함께 사라진다. 매번 같은 일이 생겨 하나도 받지 못했다.
| 취소에 안전하다 | 안전하지 않다 |
|---|---|
mpsc::Receiver::recv, watch::Receiver::changed, TcpListener::accept, AsyncReadExt::read, JoinSet::join_next, CancellationToken::cancelled |
AsyncReadExt::read_exact·read_to_end·read_to_string, AsyncWriteExt::write_all(데이터를 잃을 수 있다), mpsc::Sender::send(보내려던 값이 버려진다), Mutex::lock·Semaphore::acquire(줄 선 자리를 잃는다) |
안전하지 않은 퓨처를 루프에서 계속 쓰려면 두 방법이 있다. 퓨처를 루프 밖에서 한 번 만들고 tokio::pin!으로 고정한 뒤 &mut fut로 select!에 넣어 다음 바퀴에도 같은 퓨처를 이어 쓴다1. 또는 read_two_safe처럼 중간 상태를 퓨처 밖에 둔다. 종료 직전처럼 반쯤 읽은 데이터를 잃어도 괜찮은 때라면 안전하지 않은 퓨처를 버려도 된다4.
CancellationToken
tokio_util::sync::CancellationToken은 “이제 그만”을 여러 태스크에 알리는 깃발이다6.
cancel(): 토큰과 그 자식 토큰을 모두 취소하고, 기다리던 태스크를 깨운다.cancelled(): 취소되면 끝나는 퓨처. 취소에 안전하다.child_token(): 부모가 취소되면 같이 취소된다. 자식을 취소해도 부모는 그대로다.run_until_cancelled(fut):fut가 먼저 끝나면Some(값), 토큰이 먼저 취소되면fut를 버리고None.
let worker = tokio::spawn(async move {
loop {
tokio::select! {
biased; // 종료 신호를 먼저 본다
_ = token.cancelled() => break,
_ = do_one_unit_of_work() => {}
}
}
flush_buffers().await; // 버려지는 대신 스스로 정리한다
});
abort()는 태스크를 멈춘 .await 자리에서 그냥 버린다. 토큰은 태스크가 정리 코드를 돌릴 기회를 준다. 여러 태스크를 질서 있게 끝내는 방법은 안전한 종료에서 잇는다.
보장하는 것과 보장하지 않는 것
| 보장한다 | 보장하지 않는다 |
|---|---|
버린 퓨처는 다시 poll되지 않는다. 멈춘 .await 뒤 코드는 돌지 않는다 |
버린 퓨처가 시작해 둔 바깥 일(다른 태스크, 이미 보낸 요청)이 멈추는 것 |
퓨처 안의 지역 변수는 Drop으로 정리된다 |
.await 사이에서 도는 코드가 멈추는 것. timeout도 abort도 다음 .await까지 기다린다 |
select!는 한 가지의 코드만 돌린다 |
기본 select!에서 어느 가지가 이길지. 둘 다 준비돼 있으면 무작위다 |
| 취소에 안전한 퓨처는 버려도 데이터를 잃지 않는다 | 취소에 안전하지 않은 퓨처의 반쯤 한 일 |
실패 장면 — 바깥 일은 남는다. 요청 핸들러가 tokio::spawn(write_audit_log())를 띄우고 .await 중에 timeout으로 버려졌다. 핸들러 퓨처는 사라졌지만 띄운 태스크는 계속 돈다(tokio 런타임과 태스크에서 JoinHandle을 버린 태스크가 끝까지 돈 것과 같다). 함께 멈춰야 한다면 CancellationToken을 넘기거나 JoinSet에 넣어 둔다. tokio 튜토리얼은 oneshot::Receiver가 버려질 때 Sender에 알리는 기능을 써서 바깥 일을 멈추는 예를 든다1.
스스로 설명해 보기
read_two가 메시지를 잃는 과정을 시간 순서로 말해 보라. 어느 줄에서 무엇이 사라지나?
0ms에 select!가 read_two와 10ms 타이머를 함께 poll한다. 30ms쯤 메시지 1이 와서 let a = …가 끝나고 a = 1이 퓨처 안에 저장된다. 둘째 recv().await에서 다시 멈춘다. 그 새 바퀴의 타이머가 40ms쯤 먼저 끝나 select!가 read_two 퓨처를 버린다. 퓨처 안의 a = 1이 함께 버려진다. 다음 바퀴의 새 read_two는 메시지 2부터 받는다. 같은 일이 되풀이된다.
read_two_safe는 무엇을 바꿔서 안전해졌나? 이 방법을 쓸 수 있는 다른 상황은?
반쯤 한 일(받아 둔 첫 메시지)을 퓨처 안이 아니라 호출한 쪽의 변수 pending에 둔다. 퓨처가 버려져도 pending은 남으므로 다시 만든 퓨처가 이어서 쓴다. 바이트를 조금씩 읽어 한 줄을 모으는 파서, 여러 번에 나눠 보내는 쓰기도 진행 상태를 퓨처 밖 버퍼에 두면 같은 방식으로 안전해진다.
10ms 제한을 건 timeout이 100ms짜리 std::thread::sleep을 끊지 못한 근거는?
timeout은 안쪽 퓨처를 poll하고 돌아온 뒤에야 시계를 본다. 안쪽 퓨처는 poll 한 번 안에서 100ms를 자고 Ready를 낸다. timeout 쪽에 차례가 돌아왔을 때는 이미 결과가 나와 있으므로 Ok를 낸다. 퓨처를 버리는 일은 퓨처가 Pending으로 멈춰 있을 때만 의미가 있다.
활용
- 외부 호출에는 항상 시간 제한. HTTP 클라이언트, DB 질의, 다른 서비스 호출을
timeout으로 감싼다. 서버 쪽에서는 tower의TimeoutLayer나 tower-http의 timeout 미들웨어로 요청 전체에 건다(axum으로 HTTP 서비스 만들기). select!루프. 메시지 받기, 주기적 작업, 종료 신호를 한 루프에서 다룬다. 종료 가지는biased;로 맨 위에 둔다.- 테스트의 시간.
tokio::time::pause()나#[tokio::test(start_paused = true)]로 가짜 시계를 쓰면 실제로 기다리지 않고 시간 관련 코드를 시험할 수 있다.test-util기능과 current_thread 런타임이 필요하다7. 이 과목의 harness에는test-util이 없어 검증 코드는 실제 시간을 넉넉한 간격으로 쓴다. - 흔한 실수.
- 루프 안
select!에send,read_exact,lock처럼 취소에 안전하지 않은 퓨처를 넣는다. - timeout을 걸었으니 CPU를 오래 쓰는 계산도 끊긴다고 믿는다.
.await가 없으면 끊지 못한다. 계산은spawn_blocking으로 보내고 그JoinHandle에 timeout을 건다. 이때도 시간이 지나면 기다리기만 그만둘 뿐, 블로킹 스레드의 계산은 계속 돈다. select!에서biased;없이 종료 가지를 두고, 종료가 곧바로 처리되리라 믿는다. 무작위라 다른 가지가 먼저 뽑힐 수 있다.
- 루프 안
연결
- 선수: tokio 런타임과 태스크, 채널
- 바탕: 퓨처는
poll되어야만 움직인다(Future와 async·await). 그래서 버리면 멈춘다. - 다음: 백프레셔와 큐 제한, 안전한 종료
자주 하는 오해
틀렸다. 그럴듯한 이유는 이름이 “시간 초과”이고, 대부분의 실험에서 정말 바로 멈추기 때문이다. 실제로는 timeout이 할 수 있는 일은 안쪽 퓨처를 버리는 것뿐이고, 버리는 일은 퓨처가 .await에서 멈춰 있을 때만 효과가 있다3. .await 없이 100ms를 자는 퓨처에 10ms 제한을 걸었더니 100ms를 다 돌고 Ok가 나왔다. 그리고 퓨처가 띄워 둔 다른 태스크나 spawn_blocking 작업은 퓨처를 버려도 계속 돈다.
틀렸다. 진 쪽 퓨처는 버려지고, 다음 바퀴에서는 새 퓨처가 처음부터 시작한다4. recv()처럼 아직 아무것도 하지 않은 상태로 버려지는 퓨처는 괜찮지만, 반쯤 일한 퓨처는 그 반을 잃는다. read_two는 6개 중 하나도 받지 못했다. 이어서 돌게 하려면 퓨처를 루프 밖에서 만들어 &mut로 넣는다.
확인 문제
C1 다음 두 줄의 결과를 예측하라.
let a = timeout(Duration::ZERO, async { 5 }).await;
let b = timeout(Duration::from_millis(10), async {
std::thread::sleep(Duration::from_millis(100));
"다 돌았다"
}).await;
답: a는 Ok(5), b는 Ok("다 돌았다").
이유: timeout은 안쪽 퓨처를 먼저 poll한다. 둘 다 첫 poll에서 Ready를 내므로 시계를 볼 필요도 없이 Ok다. b는 .await가 없어 그 사이에 끼어들 수 없다. 검증 코드 출력과 같다.
C2 “select! 루프에서 퓨처가 버려져도 데이터는 잃지 않는다”는 주장의 반례를 만들라.
답: 첫 메시지를 받아 지역 변수에 둔 뒤 둘째를 기다리는 read_two를 짧은 타이머와 함께 select! 루프에 넣는다. 메시지가 30ms 간격, 타이머가 10ms면 첫 메시지를 받은 뒤 타이머가 이겨 그 메시지가 사라진다. 실측으로 6개 중 0개를 받았다.
이유: 퓨처를 버리면 퓨처 안에 저장된 지역 변수도 버려진다. 취소에 안전하려면 버리고 다시 만들어도 아무 일이 없어야 한다.
C3 작업 태스크를 멈추고 싶다. 작업 중이던 버퍼를 디스크에 써야 한다. JoinHandle::abort()와 CancellationToken 중 무엇을 쓰나?
답: CancellationToken. 태스크가 select!에서 token.cancelled()를 보고 루프를 빠져나와 버퍼를 쓴 뒤 스스로 끝난다.
이유: abort()는 태스크를 멈춘 .await 자리에서 버린다. Drop은 돌지만 async 정리 코드(디스크에 .await로 쓰기)는 돌릴 수 없다.
흔한 오답: “abort해도 Drop이 도니 거기서 쓰면 된다.” Drop은 동기 함수라 .await를 쓸 수 없고, 거기서 블로킹 입출력을 하면 실행기를 막는다.
-
Tokio Tutorial, Select — Cancellation, Borrowing, Loops, Resuming an async operation, Per-task concurrency — https://tokio.rs/tokio/tutorial/select ↩ ↩2 ↩3 ↩4
-
Asynchronous Programming in Rust (Async Book), More async/await topics — Cancellation — https://rust-lang.github.io/async-book/part-guide/more-async-await.html ↩ ↩2 ↩3
-
tokio 문서, Function
tokio::time::timeout— Cancellation, Panics 위 본문(즉시 완료, 양보하지 않는 퓨처) — https://docs.rs/tokio/latest/tokio/time/fn.timeout.html ↩ ↩2 ↩3 ↩4 -
tokio 문서, Macro
tokio::select— Fairness, Cancellation safety — https://docs.rs/tokio/latest/tokio/macro.select.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 -
tokio 문서, Struct
tokio::sync::mpsc::Sender—send의 Cancel safety,reserve— https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html ↩ -
tokio-util 문서, Struct
tokio_util::sync::CancellationToken—cancel,cancelled,child_token,run_until_cancelled— https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html ↩ -
tokio 문서, Function
tokio::time::pause— https://docs.rs/tokio/latest/tokio/time/fn.pause.html ↩