async/.await

첫 장에서 우리는 async/.await을 짧게 다뤘습니다. 이제 async 코드가 어떻게 동작하고, 어떻게 전형적인 러스트 프로그램과 다른지 더 자세히 들여다봅시다.

async/.await는 현재 실행중인 스레드를 블로킹하지 않고 제어권을 놓아줄 수 있게 만들어주는 특별한 러스트 문법입니다. 이를 통해 어떤 명령이 완성될 때까지 기다리면서(역주 : 예를 들어, 소켓에 데이터가 들어오기를 기다린다던가) 다른 코드들이 실행될 수 있습니다.

async를 다루는 주 방법에는 두 가지, async fnasync 블록이 있습니다. 각각 Future 트레잇을 구현한 값을 반환합니다.


// `foo()`는 `Future<Output = u8>`을 구현한 타입을 반환합니다.
// `foo().await`의 결과는 `u8` 타입의 값입니다.
async fn foo() -> u8 { 5 }

fn bar() -> impl Future<Output = u8> {
    // 이 `async` 블록은`Future<Output = u8>`을 구현한
    // 타입을 반환합니다.
    async {
        let x: u8 = foo().await;
        x + 5
    }
}

첫 장에서 봤듯이, async 안쪽과 그 밖의 future 구현체는 게으릅니다. 즉 실행될 때까지 아무 일도 안 합니다. Future를 실행하는 통상적인 방법은 Future.await를 사용하는 것입니다. Future.await되면, 그 Future가 완료될 때까지 실행하려고 할 것입니다. 하지만, Future가 실행 도중에 블록되면 현재 스레드의 제어를 놓아줍니다. 그리고 Future가 더 진행이 될 수 있는 상황이 돌아오면, Future는 실행자에게 선택돼 실행을 재개할 것이고, 따라서 .await이 완료될 수도 있을 것입니다.(역주 : 물론 Future가 다시 블록될 수도 있다)

async한 수명

참조나 기타 'static하지 않은 인자를 취하는 async fn은 다른 전형적인 함수들과 달리 인자의 수명에 묶인(bound) Future를 반환합니다.

// 이 함수는
async fn foo(x: &u8) -> u8 { *x }

// 이 함수와 같습니다.
fn foo_expanded<'a>(x: &'a u8) -> impl Future<Output = u8> + 'a {
    async move { *x }
}

즉, async fn에서 반환된 future는 자신이 받은 'static이 아닌 인자가 유효한 상태에서만(역주 : 즉, 참조의 수명 범위내에서만) .await되어야 합니다. 보통 foo(&x).await와 같이 함수를 호출하고 바로 future를 .await할 때는 문제가 없습니다. 허나 future를 또다른 작업이나 스레드로 저장하거나 보내면 문제가 생길 수 있습니다.

참조를 인자로 가진 async fn'static한 future로 바꾸는 유용한 방법은 바로 async fn의 호출과 인자를 하나의 async 블록 안에 두는 것입니다.

fn bad() -> impl Future<Output = u8> {
    let x = 5;
    borrow_x(&x) // ERROR: `x`의 수명이 충분하지 않습니다.
}

fn good() -> impl Future<Output = u8> {
    async {
        let x = 5;
        borrow_x(&x).await
    }
}

인자를 async 블록 안으로 옮기면, 그 인자의 수명을 늘리기 때문에 good을 호출하여 반환되는 Future의 수명과 일치시킬 수 있습니다.

async move

async 블록과 클로저는 보통 클로저처럼 move 키워드를 허용합니다. async move 블록은 자신이 참조하는 변수의 소유권을 획득하면서, 그 변수를 원래의 스코프 바깥에서도 유효하게 만들 것이지만, 다른 코드는 그 변수를 사용할 수 없게 됩니다.

/// `async` 블록:
///
/// 서로 다른 여러 개의 `async` 블록들은, 어떤 지역 변수의 스코프 안에서
/// 실행되는 한, 그 지역 변수에 함께 접근할 수 있습니다.
async fn blocks() {
    let my_string = "foo".to_string();

    let future_one = async {
        // ...
        println!("{my_string}");
    };

    let future_two = async {
        // ...
        println!("{my_string}");
    };

    // 두 future를 완전히 실행해 "foo"를 두 번 출력합니다:
    let ((), ()) = futures::join!(future_one, future_two);
}

/// `async move` 블록:
///
/// 오직 한 개의 `async move` 블록만 (역주: 그 `async move` 블록이) 캡쳐한
/// 변수에 접근할 수 있습니다. 왜냐하면, 캡쳐된 변수는 `async move` 블록이
/// 생성한 `Future`의 안으로 이동하기 때문입니다.
///
/// 반면에, 이렇게 함으로써 `Future`가 그 변수의 원래 스코프 밖에서도 실행될 수
/// 있게 됩니다.
fn move_block() -> impl Future<Output = ()> {
    let my_string = "foo".to_string();
    async move {
        // ...
        println!("{my_string}");
    }
}

멀티스레드 실행자 상에서 .await하기

멀티스레드 용 Future 실행자를 사용하면, 모든 .await은 종종 새로운 스레드에서 Future를 실행시킬 수도 있습니다. 따라서 Future가 스레드를 갈아타는 상황이 생길 것이고, 당연히 async 블록 안쪽에서 쓰인 모든 변수도 반드시 스레드를 갈아탈 수 있어야 합니다.

그 말인 즉슨 Sync 트레잇을 구현하지 않는 타입에 대한 참조를 포함, Send 트레잇을 구현하지 않는 Rc, &RefCell 등 어떠한 타입도 안전하게 사용할 수 없다는 뜻입니다.

(주의: 이러한 타입들이 .await을 호출하는 스코프 안에 존재하지 않는다면, 사용할 수 있긴 있습니다.)

비슷하게, future를 고려하지 않고 만들어진 전통적인 락(lock)을 .await을 사이에 두고 사용하는 것은 좋지 않습니다. 왜냐하면 스레드풀을 잠궈버릴 수도 있기 때문입니다. 예를 들어, 한 태스크가 락 하나를 가져온 상태에서 .await하고 실행자에게 (역주: 현재 실행중인 스레드를) 내어준 경우, 다른 태스크가 다시 그 락을 가지려고 하면 데드락이 발생합니다. 이런 상황을 방지하기 위해, 이런 문제를 방지하기 위해 std::sync말고 futures::lock에 있는 Mutex를 사용하시기 바랍니다.