Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

런타임에 제어권 넘기기

“첫 번째 async 프로그램” 절에서 보았듯, 각 await 지점마다 러스트는 런타임에게 “이 퓨처가 아직 준비되지 않았다면 이 태스크를 잠시 멈추고 다른 태스크로 전환할 기회”를 줍니다. 반대로 말하면, 러스트는 오직 await 지점에서만 async 블록을 멈추고 런타임에 제어권을 돌려줍니다. await 지점들 사이 의 코드는 모두 동기적으로 실행됩니다.

이 말은, async 블록 안에서 await 지점 없이 많은 일을 하면 그 퓨처가 다른 퓨처들의 진행을 모두 막아 버릴 수 있다는 뜻입니다. 이런 상황은 때때로 한 퓨처가 다른 퓨처들을 굶긴다(starving) 고도 표현합니다. 어떤 경우에는 그다지 문제가 아닐 수도 있습니다. 하지만 비용이 큰 초기화 작업이나 오래 걸리는 작업을 하거나, 어떤 퓨처가 특정 작업을 무기한 계속 수행해야 하는 경우에는 언제 어디서 런타임에게 제어권을 돌려줄지 신중하게 생각해야 합니다.

이런 starvation 문제를 보여 주기 위해 오래 걸리는 연산을 하나 흉내 내고, 이를 어떻게 해결할 수 있는지도 함께 보겠습니다. 목록 17-14는 slow 함수를 소개합니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        // We will call `slow` here later
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-14: 느린 연산을 흉내 내기 위해 thread::sleep 사용하기

이 코드는 trpl::sleep 대신 std::thread::sleep 을 사용하므로, slow 를 호출하면 현재 스레드를 지정한 밀리초 수만큼 블록하게 됩니다. 이렇게 하면 실제로는 오래 걸리면서도 블로킹되는 현실의 작업을 흉내 낼 수 있습니다.

목록 17-15에서는 두 개의 퓨처 안에서 이런 CPU-bound 작업을 흉내 내기 위해 slow 를 사용합니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            slow("a", 10);
            slow("a", 20);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            slow("b", 10);
            slow("b", 15);
            slow("b", 350);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'b' finished.");
        };

        trpl::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-15: 느린 작업을 흉내 내기 위해 slow 함수 호출하기

각 퓨처는 여러 개의 느린 연산을 수행한 뒤에야 런타임에 제어권을 돌려줍니다. 이 코드를 실행하면 다음과 같은 출력이 보일 것입니다.

'a' started.
'a' ran for 30ms
'a' ran for 10ms
'a' ran for 20ms
'b' started.
'b' ran for 75ms
'b' ran for 10ms
'b' ran for 15ms
'b' ran for 350ms
'a' finished.

목록 17-5에서 trpl::select 로 두 URL 요청 퓨처를 경주시켰을 때와 마찬가지로, 여기서도 selecta 가 끝나자마자 바로 완료됩니다. 하지만 두 퓨처 안의 slow 호출들은 서로 전혀 섞여 실행되지 않습니다. a 퓨처가 trpl::sleepawait 하기 전까지 자기 일을 전부 하고, 그 다음에야 b 퓨처가 자기 trpl::sleepawait 할 때까지 일을 하고, 마지막으로 다시 a 가 끝납니다. 두 퓨처가 느린 작업 사이사이에 조금씩 진행하게 하려면, 중간중간 await 지점을 넣어 런타임에 제어권을 돌려줘야 합니다. 즉, 우리가 await 할 무언가가 필요합니다!

목록 17-15에서도 이런 제어권 넘김의 흔적은 이미 볼 수 있습니다. 만약 a 퓨처 끝의 trpl::sleep 을 제거하면, b 퓨처는 전혀 실행되지 않은 채로 a 가 끝나게 됩니다. 그렇다면 제어권을 넘기는 출발점으로 trpl::sleep 함수를 사용해 보겠습니다. 목록 17-16을 보세요.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let one_ms = Duration::from_millis(1);

        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::sleep(one_ms).await;
            slow("a", 10);
            trpl::sleep(one_ms).await;
            slow("a", 20);
            trpl::sleep(one_ms).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::sleep(one_ms).await;
            slow("b", 10);
            trpl::sleep(one_ms).await;
            slow("b", 15);
            trpl::sleep(one_ms).await;
            slow("b", 350);
            trpl::sleep(one_ms).await;
            println!("'b' finished.");
        };

        trpl::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-16: 진행을 번갈아 하도록 trpl::sleep 사용하기

이제 우리는 각 slow 호출 사이에 trpl::sleep 과 await 지점을 넣었습니다. 그 결과 두 퓨처의 작업이 서로 섞여 실행됩니다.

'a' started.
'a' ran for 30ms
'b' started.
'b' ran for 75ms
'a' ran for 10ms
'b' ran for 10ms
'a' ran for 20ms
'b' ran for 15ms
'a' finished.

a 퓨처는 처음 trpl::sleep 을 만나기 전까지는 여전히 혼자 조금 더 실행되지만, 그 뒤부터는 두 퓨처가 await 지점을 만날 때마다 번갈아 제어권을 주고받습니다. 이 예제에서는 slow 호출마다 한 번씩 제어권을 넘겼지만, 실제로는 여러분이 적절하다고 생각하는 단위로 작업을 쪼개면 됩니다.

하지만 여기서 우리가 진짜 원하는 것은 잠드는 것 이 아니라, 가능한 한 빨리 진행하되 잠시 런타임에게 제어권만 넘기는 것입니다. 그래서 직접 trpl::yield_now 함수를 사용할 수 있습니다. 목록 17-17은 모든 trpl::sleep 호출을 trpl::yield_now 로 바꾼 예입니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::yield_now().await;
            slow("a", 10);
            trpl::yield_now().await;
            slow("a", 20);
            trpl::yield_now().await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::yield_now().await;
            slow("b", 10);
            trpl::yield_now().await;
            slow("b", 15);
            trpl::yield_now().await;
            slow("b", 350);
            trpl::yield_now().await;
            println!("'b' finished.");
        };

        trpl::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-17: 진행을 번갈아 하도록 yield_now 사용하기

이 코드는 의도를 더 분명하게 드러낼 뿐 아니라, sleep 을 사용하는 것보다 훨씬 빠를 수도 있습니다. sleep 이 사용하는 타이머는 보통 최소 해상도 제한이 있기 때문입니다. 예를 들어 우리가 쓰는 sleep 구현은 Duration 으로 1나노초를 넘겨도 적어도 1밀리초는 잠듭니다. 다시 말하지만 현대 컴퓨터는 아주 빠르기 때문에, 1밀리초 동안도 상당히 많은 일을 할 수 있습니다!

이 말은, async가 프로그램이 하는 다른 일에 따라서는 CPU-bound 작업에도 유용할 수 있다는 뜻입니다. async 상태 머신이라는 오버헤드가 있긴 하지만, 프로그램 안의 여러 부분이 서로 어떤 관계를 갖는지를 구조화하는 데 유용한 도구가 되기 때문입니다. 이것은 각 퓨처가 await 지점을 통해 언제 제어권을 넘길지 직접 결정하는 형태의 협력적 멀티태스킹(cooperative multitasking) 입니다. 따라서 각 퓨처는 너무 오래 블로킹하지 않도록 스스로 책임도 져야 합니다. 어떤 러스트 기반 임베디드 운영체제에서는, 이 방식이 유일한 멀티태스킹 방법이기도 합니다!

물론 현실의 코드에서는 매 한 줄마다 함수 호출과 await 지점을 교대로 넣는 식으로 작성하지는 않습니다. 이렇게 제어권을 넘기는 비용이 비교적 작긴 해도 0 은 아니기 때문입니다. 어떤 경우에는 CPU-bound 작업을 억지로 잘게 쪼개는 것이 오히려 성능을 크게 떨어뜨릴 수도 있습니다. 그래서 전체 성능을 위해서는 잠시 블로킹하게 두는 편이 낫기도 합니다. 언제나 실제 코드의 병목이 무엇인지 측정 해야 합니다. 하지만 “분명 동시적으로 돌 거라고 생각한 코드가 줄줄이 직렬로 돌아간다”는 징후가 보일 때는, 지금의 이 동작 원리를 꼭 떠올려야 합니다.

우리만의 async 추상화 만들기

퓨처끼리 조합해 새로운 패턴을 만들 수도 있습니다. 예를 들어 지금까지 배운 async 구성 요소만으로 timeout 함수를 직접 만들 수 있습니다. 완성된 결과는 또 하나의 작은 빌딩 블록이 되고, 그 위에 더 많은 async 추상화를 쌓아 올릴 수 있습니다.

목록 17-18은 느린 퓨처와 함께 사용할 때 이 timeout 이 어떻게 동작하길 기대하는지를 보여 줍니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}
Listing 17-18: 시간 제한과 함께 느린 작업을 실행하기 위해 우리가 상상한 timeout 사용하기

이제 실제로 구현해 봅시다! 먼저 timeout 의 API를 생각해 보면 다음과 같습니다.

  • 우리가 await 할 수 있어야 하므로, timeout 자체도 async 함수여야 한다.
  • 첫 번째 매개변수는 실행할 퓨처여야 한다. 어떤 퓨처와도 동작하게 하려면 제네릭으로 만들 수 있다.
  • 두 번째 매개변수는 최대 대기 시간이어야 한다. Duration 을 사용하면 trpl::sleep 에 바로 넘기기 쉽다.
  • 반환 타입은 Result 여야 한다. 퓨처가 성공적으로 끝나면 Ok 안에 퓨처의 결과를 넣고, 타임아웃이 먼저 끝나면 기다린 시간 Duration 을 담은 Err 를 반환한다.

목록 17-19는 이런 요구를 만족하는 선언을 보여 줍니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    // Here is where our implementation will go!
}
Listing 17-19: timeout 의 시그니처 정의하기

이제 타입은 원하는 대로 맞췄습니다. 다음으로는 동작 을 생각해야 합니다. 우리가 원하는 것은 “전달받은 퓨처”와 “지연 시간”을 서로 경주시키는 것입니다. trpl::sleep 을 사용해 Duration 으로부터 타이머 퓨처를 만들고, trpl::select 를 사용해 이 타이머와 호출자가 넘겨 준 퓨처를 함께 실행하면 됩니다.

목록 17-20에서는 trpl::select 의 결과를 await 하고, 그 결과를 match 하여 timeout 을 구현합니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

use trpl::Either;

// --snip--

fn main() {
    trpl::block_on(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    match trpl::select(future_to_try, trpl::sleep(max_time)).await {
        Either::Left(output) => Ok(output),
        Either::Right(_) => Err(max_time),
    }
}
Listing 17-20: selectsleep 으로 timeout 구현하기

trpl::select 의 구현은 공정하지 않습니다. 인수를 전달된 순서대로 항상 폴링합니다 (다른 select 구현은 첫 번째로 폴링할 인수를 무작위로 고르기도 합니다). 따라서 max_time 이 아주 짧더라도 future_to_try 가 먼저 끝날 기회를 갖게 하려면, future_to_tryselect 의 첫 번째 인수로 넘겨야 합니다. future_to_try 가 먼저 끝나면 select 는 그 출력값을 담은 Left 를 반환합니다. 반대로 타이머가 먼저 끝나면 타이머 출력인 () 를 담은 Right 를 반환합니다.

future_to_try 가 성공해 Left(output) 를 얻으면 우리는 Ok(output) 을 반환합니다. 반대로 타이머가 먼저 끝나 Right(()) 를 얻으면, ()_ 로 무시하고 Err(max_time) 을 반환합니다.

이제 우리는 이미 갖고 있던 async 도우미 두 개를 조합해 완전히 동작하는 timeout 을 만들었습니다. 코드를 실행하면, 타임아웃이 끝난 뒤 실패 모드를 출력하게 됩니다.

Failed after 2 seconds

퓨처는 다른 퓨처들과 자유롭게 조합될 수 있기 때문에, 작은 async 구성 요소들을 이용해 정말 강력한 도구를 만들 수 있습니다. 예를 들어 같은 방식으로 timeout과 retry를 결합하고, 다시 그것을 네트워크 호출(목록 17-5 같은)과 함께 쓸 수도 있습니다.

실무에서는 대부분 asyncawait 를 직접 사용하고, 그 다음 단계에서 select 함수나 join! 매크로처럼 “가장 바깥 퓨처를 어떻게 실행할지”를 제어하는 도구들을 사용하게 됩니다.

지금까지 우리는 여러 퓨처를 동시에 다루는 여러 방법을 살펴보았습니다. 다음으로는 스트림 을 사용해 여러 퓨처를 시간의 흐름 속 시퀀스로 다루는 방법을 보겠습니다.