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 관련 트레이트를 더 자세히 살펴보기

이 장 전체에서 우리는 Future, Stream, StreamExt 트레이트를 여러 방식으로 사용해 왔습니다. 하지만 지금까지는 그것들이 정확히 어떻게 동작하고, 서로 어떻게 맞물리는지에 대해 너무 깊게 들어가지는 않았습니다. 일상적인 러스트 작업에서는 대부분 이 정도로도 충분합니다. 그러나 가끔은 Pin 타입과 Unpin 트레이트를 포함해, 이 트레이트들의 세부를 좀 더 이해해야 하는 상황을 만나게 됩니다. 이 절에서는 그런 상황에 도움이 되도록 필요한 만큼만 더 깊게 파고들고, 정말 깊은 세부는 다른 문서에 남겨 두겠습니다.

Future 트레이트

먼저 Future 트레이트가 실제로 어떻게 동작하는지 더 가까이서 봅시다. 러스트는 이를 다음과 같이 정의합니다.

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

이 트레이트 정의 안에는 새로운 타입도 많고, 아직 보지 못한 문법도 조금 있습니다. 하나씩 쪼개서 봅시다.

첫째, Future 의 연관 타입 Output 은 이 퓨처가 최종적으로 무엇으로 resolve 되는지를 말합니다. 이는 Iterator 트레이트의 연관 타입 Item 과 비슷합니다. 둘째, Future 에는 poll 이라는 메서드가 있는데, self 매개변수로 특별한 Pin 참조를 받고, Context 타입의 가변 참조를 받으며, Poll<Self::Output> 를 반환합니다. PinContext 는 잠시 뒤 더 자세히 다룹니다. 지금은 우선 이 메서드가 반환하는 Poll 타입에 집중해 봅시다.

#![allow(unused)]
fn main() {
pub enum Poll<T> {
    Ready(T),
    Pending,
}
}

Poll 타입은 Option 과 비슷합니다. 값이 있는 variant인 Ready(T) 와, 값이 없는 Pending 이 있습니다. 하지만 Poll 이 뜻하는 바는 Option 과 꽤 다릅니다. Pending variant는 퓨처가 아직 더 할 일이 남아 있다는 뜻이며, 따라서 호출하는 쪽이 나중에 다시 확인해야 한다는 의미입니다. Ready variant는 퓨처의 작업이 끝났고 값 T 를 이제 사용할 수 있다는 뜻입니다.

Note: 직접 poll 을 호출해야 하는 경우는 드물지만, 정말 그럴 일이 있다면 대부분의 퓨처는 한 번 Ready 를 반환한 뒤 다시 poll 하면 안 된다는 점을 기억하세요. 많은 퓨처가, 준비 상태가 된 뒤 다시 poll 하면 패닉합니다. 다시 poll 해도 안전한 퓨처라면 문서에서 그 사실을 명시해 둘 것입니다. 이는 Iterator::next 의 동작과 비슷합니다.

여러분이 await 를 사용하는 코드를 보면, 러스트는 그것을 내부적으로 poll 을 호출하는 코드로 컴파일합니다. 예를 들어 목록 17-4에서 단일 URL에 대한 페이지 제목을 출력했던 코드는, 러프하게 말하면(정확히 일치하지는 않지만) 다음과 비슷한 코드로 컴파일됩니다.

match page_title(url).poll() {
    Ready(page_title) => match page_title {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
    Pending => {
        // But what goes here?
    }
}

그렇다면 퓨처가 아직 Pending 인 경우에는 무엇을 해야 할까요? 다시 확인하고, 또 다시 확인하고, 결국 준비될 때까지 반복해야 합니다. 즉, 루프가 필요합니다.

let mut page_title_fut = page_title(url);
loop {
    match page_title_fut.poll() {
        Ready(value) => match page_title {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
        Pending => {
            // continue
        }
    }
}

하지만 러스트가 정말로 이 코드 그대로 컴파일한다면, 모든 await 는 블로킹이 됩니다. 그것은 우리가 원하던 것과 정반대죠! 대신 러스트는 이 루프가 어떤 “다른 무언가”에게 제어권을 넘겨, 지금 퓨처 작업을 잠시 멈추고 다른 퓨처를 실행한 뒤 나중에 다시 이 퓨처를 확인하게 만듭니다. 지금까지 보았듯 그 “다른 무언가”가 바로 async 런타임이며, 이런 스케줄링과 조율이 런타임의 핵심 역할 중 하나입니다.

“메시지 전달로 두 태스크 사이 데이터 보내기” 절에서 우리는 rx.recv 를 기다렸습니다. recv 호출은 하나의 퓨처를 반환하고, 그 퓨처를 await 했습니다. 수신 메시지가 있으면 Some(message), 송신 쪽이 닫히면 None 으로 resolve 될 때까지 런타임이 그것을 멈춰 둔다고 설명했죠. 이제 Future 트레이트, 특히 Future::poll 을 더 잘 알게 되었으니 그 이유를 이해할 수 있습니다. pollPoll::Pending 을 반환하면, 런타임은 이 퓨처가 아직 준비되지 않았음을 압니다. 반대로 Poll::Ready(Some(message)) 또는 Poll::Ready(None) 을 반환하면, 런타임은 이 퓨처가 준비되었고 이제 다음 단계로 진행할 수 있음을 압니다.

런타임이 구체적으로 이를 어떻게 하는지는 이 책 범위를 벗어납니다. 중요한 것은 퓨처의 기본 동작 원리입니다. 런타임은 자신이 담당하는 각 퓨처를 poll 하며, 아직 준비되지 않은 퓨처는 다시 잠재워 둡니다.

Pin 타입과 Unpin 트레이트

목록 17-13에서는 trpl::join! 매크로로 세 개의 퓨처를 함께 기다렸습니다. 하지만 현실에서는 벡터 같은 컬렉션에, 런타임이 시작되기 전에는 몇 개가 될지 모르는 퓨처들이 들어 있는 경우도 흔합니다. 목록 17-23은 세 개의 퓨처를 벡터에 넣고 trpl::join_all 을 호출하는 코드입니다. 그런데 이 코드는 아직 컴파일되지 않습니다.

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

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            // --snip--
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Box<dyn Future<Output = ()>>> =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}
Listing 17-23: 컬렉션 안의 퓨처들 기다리기

우리는 각 퓨처를 Box 안에 넣어 트레이트 객체(trait objects) 로 만들었습니다. 12장의 [“run 에서 에러 반환하기”] 절에서 trait object를 잠깐 사용했었죠 (트레이트 객체는 18장에서 자세히 다룹니다). 트레이트 객체를 쓰면, async 블록 각각이 만드는 익명 퓨처 타입이 모두 달라도, 셋 다 Future 트레이트를 구현한다는 사실만을 이용해 하나의 동일한 타입처럼 다룰 수 있습니다.

놀라울 수도 있습니다. 사실 이 async 블록들은 아무 값도 반환하지 않으므로, 모두 Future<Output = ()> 를 만들어 냅니다. 하지만 Future 는 어디까지나 트레이트입니다. 그리고 컴파일러는 각 async 블록에 대해, 출력 타입이 같더라도 서로 다른 익명 enum 을 실제로 생성합니다. 우리가 손으로 서로 다른 구조체 두 개를 만들어서는 하나의 Vec 에 섞어 넣을 수 없는 것처럼, 컴파일러가 만든 서로 다른 enum들도 그대로는 섞을 수 없습니다.

그리고 이 퓨처 컬렉션을 trpl::join_all 함수에 넘기고 그 결과를 await 하려 하면, 다음과 같은 오류가 납니다.

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:48:33
   |
48 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

이 오류 메시지의 도움말은 값을 핀(pin) 해야 한다고 알려 줍니다. 즉 Pin 타입 안에 넣어 “메모리에서 움직이지 않는다”는 보장을 줘야 한다는 뜻입니다. 오류는 dyn Future<Output = ()>Unpin 트레이트를 구현하지 않았기 때문에 이 작업이 필요하다고 말합니다.

trpl::join_all 함수는 JoinAll 이라는 구조체를 반환하는데, 이 구조체는 타입 F 에 대해 제네릭이며 F: Future 라는 제약을 가집니다. 퓨처를 await 할 때는 러스트가 암묵적으로 그 퓨처를 pin 합니다. 그래서 보통은 우리가 pin! 을 직접 쓸 일이 없습니다.

하지만 여기서는 퓨처를 직접 await 하는 것이 아닙니다. 대신 퓨처들의 컬렉션을 join_all 함수에 넘겨, JoinAll 이라는 새 퓨처를 만들고 있습니다. 그리고 join_all 시그니처는 컬렉션 안의 항목 타입이 모두 Future 를 구현해야 한다고 요구하는데, Box<T>Future 를 구현하는 것은 오직 그 안의 TFuture 이면서 동시에 Unpin 도 구현할 때뿐입니다.

생각할 게 많아졌습니다. 이를 제대로 이해하려면, 퓨처 트레이트가 실제로 어떻게 동작하는지, 특히 pinning 과 관련해 조금 더 깊이 들어가야 합니다. 다시 Future 트레이트 정의를 봅시다.

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // Required method
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

cx 매개변수와 그 타입 Context 는 런타임이 어떤 퓨처를 언제 다시 확인해야 하는지 알아내는 핵심이지만, 이 장의 범위를 벗어납니다. 여러분이 직접 커스텀 Future 구현을 작성할 때쯤에야 깊이 신경 쓰게 될 것입니다. 여기서는 self 의 타입에 집중합시다. 이것은 우리가 처음 보는 “self 에 대한 타입 주석”입니다. self 에 타입 주석을 붙인다는 것은 다음 두 가지를 뜻합니다.

  • 이 메서드를 호출할 때 self 가 어떤 타입이어야 하는지를 러스트에게 알려 준다.
  • 하지만 완전히 임의의 타입일 수는 없다. 메서드가 구현되는 타입 그 자체, 그 타입에 대한 참조 또는 스마트 포인터, 혹은 그 참조를 Pin 으로 감싼 것만 가능하다.

이 문법은 18장에서 더 다룹니다. 지금은 어떤 퓨처가 Pending 인지 Ready(Output) 인지 직접 poll 해 보려면, 그 타입에 대한 Pin<&mut Self> 가 필요하다는 사실만 알면 됩니다.

Pin&, &mut, Box, Rc 같은 포인터 비슷한 타입을 감싸는 래퍼입니다. (정확히는 Deref 또는 DerefMut 를 구현한 타입과 함께 동작하지만, 사실상 참조와 스마트 포인터를 감싼다고 생각하면 됩니다.) Pin 자체가 포인터인 것은 아니며, RcArc 처럼 자체 동작을 가지지도 않습니다. 이것은 순전히 컴파일러가 포인터 사용에 대한 특정 제약을 강제하도록 도와주는 도구입니다.

await 가 결국 poll 호출로 구현된다는 사실을 떠올리면, 앞서 보았던 오류 메시지도 조금 더 이해됩니다. 그런데 오류는 Pin 이 아니라 Unpin 을 이야기하고 있었죠. 그렇다면 PinUnpin 은 정확히 어떤 관계이고, 왜 Futurepoll 을 호출할 때 selfPin 안에 감싼 형태로 요구할까요?

이 장 앞에서 설명했듯, async 코드 안의 여러 await 지점은 결국 상태 머신으로 컴파일됩니다. 그리고 컴파일러는 그 상태 머신도 러스트의 일반적인 소유권, 대여, 안전성 규칙을 지키도록 만들어 줍니다. 이를 위해 러스트는 한 await 지점과 다음 await 지점(또는 async 블록 끝) 사이에서 어떤 데이터가 필요한지를 추적하고, 각 상태를 표현하는 variant를 생성합니다. 각 variant는 해당 시점 코드가 필요로 하는 데이터에 대해, 소유권이든, 가변 참조든, 불변 참조든 필요한 접근 방식을 가집니다.

지금까지는 괜찮습니다. 어떤 async 블록 안의 소유권이나 참조 관계를 잘못 쓰면, 대여 검사기가 알려 줍니다. 하지만 그 블록에 해당하는 퓨처를 움직이고 싶어질 때 상황이 복잡해집니다. 예를 들어 join_all 에 넘기기 위해 Vec 안으로 퓨처를 넣거나, 함수에서 퓨처를 반환하는 경우처럼 말이죠.

퓨처를 움직인다는 것은 사실 컴파일러가 생성한 상태 머신 자체를 움직인다는 뜻입니다. 그런데 일반적인 대부분의 러스트 타입과 달리, async 블록에서 생성된 퓨처는 어떤 variant의 필드 안에 자기 자신을 참조하는 참조를 담게 될 수도 있습니다. 그림 17-4는 이를 단순화한 그림입니다.

A single-column, three-row table representing a future, fut1, which has data values 0 and 1 in the first two rows and an arrow pointing from the third row back to the second row, representing an internal reference within the future.
그림 17-4: 자기 자신을 참조하는 데이터 타입

문제는, 자기 자신을 참조하는 내부 참조가 있는 타입은 기본적으로 움직이면 안전하지 않다는 점입니다. 참조는 언제나 자신이 가리키는 값의 실제 메모리 주소를 가리키기 때문입니다. 그림 17-5처럼 데이터 구조 자체를 움직이면, 그 내부 참조는 옛 메모리 위치를 계속 가리키게 됩니다. 그런데 그 메모리 위치는 이제 더 이상 유효하지 않습니다. 하나는, 데이터 구조를 바꾸어도 거기 값이 갱신되지 않는다는 문제이고, 더 심각하게는 그 메모리를 이제 컴퓨터가 다른 목적으로 재사용할 수도 있다는 점입니다. 나중에 완전히 엉뚱한 데이터를 읽게 될 수도 있습니다.

Two tables, depicting two futures, fut1 and fut2, each of which has one column and three rows, representing the result of having moved a future out of fut1 into fut2. The first, fut1, is grayed out, with a question mark in each index, representing unknown memory. The second, fut2, has 0 and 1 in the first and second rows and an arrow pointing from its third row back to the second row of fut1, representing a pointer that is referencing the old location in memory of the future before it was moved.
그림 17-5: 자기 자신을 참조하는 데이터 타입을 움직였을 때의 위험한 결과

이론적으로는 러스트 컴파일러가 어떤 객체가 움직일 때마다 그 객체를 참조하는 모든 참조를 다시 고쳐 줄 수도 있겠지만, 그렇게 하면 성능 비용이 상당히 커질 수 있습니다. 특히 참조의 연결 구조가 복잡할수록 그렇겠죠. 대신, 해당 데이터 구조가 메모리에서 움직이지 않도록 만들 수 있다면, 참조를 하나도 갱신하지 않아도 됩니다. 이것이 바로 러스트의 대여 검사기가 하는 역할입니다. 안전한 코드 안에서는, 어떤 값에 활성 참조가 있을 때 그 값을 움직이지 못하게 막아 줍니다.

Pin 은 여기에 우리가 필요한 정확한 보장을 더해 줍니다. 어떤 값을 Pin 으로 감싸 pin 하면, 그 값은 더 이상 메모리에서 움직일 수 없습니다. 따라서 Pin<Box<SomeType>> 가 있다면, 실제로 pin 되는 것은 Box 포인터가 아니라 그 안의 SomeType 값입니다. 그림 17-6이 이를 보여 줍니다.

Three boxes laid out side by side. The first is labeled “Pin”, the second “b1”, and the third “pinned”. Within “pinned” is a table labeled “fut”, with a single column; it represents a future with cells for each part of the data structure. Its first cell has the value “0”, its second cell has an arrow coming out of it and pointing to the fourth and final cell, which has the value “1” in it, and the third cell has dashed lines and an ellipsis to indicate there may be other parts to the data structure. All together, the “fut” table represents a future which is self-referential. An arrow leaves the box labeled “Pin”, goes through the box labeled “b1” and terminates inside the “pinned” box at the “fut” table.
그림 17-6: 자기 자신을 참조하는 퓨처 타입을 가리키는 `Box` 를 pin 하기

사실 Box 포인터 자체는 여전히 자유롭게 움직일 수 있습니다. 중요한 것은 “최종적으로 참조되는 데이터”가 제자리에 남아 있는지입니다. 포인터가 움직여도 그 포인터가 가리키는 데이터가 같은 위치에 그대로 있다면, 그림 17-7처럼 아무 문제도 없습니다. (추가 연습으로, 타입 문서와 std::pin 모듈 문서를 보며, PinBox 를 감쌀 때 이것이 정확히 어떻게 가능한지 스스로 따져 보면 좋습니다.) 핵심은 자기 자신을 참조하는 타입 자체 가 움직이지 않아야 한다는 것입니다. 그것이 지금 pin 되어 있기 때문입니다.

Four boxes laid out in three rough columns, identical to the previous diagram with a change to the second column. Now there are two boxes in the second column, labeled “b1” and “b2”, “b1” is grayed out, and the arrow from “Pin” goes through “b2” instead of “b1”, indicating that the pointer has moved from “b1” to “b2”, but the data in “pinned” has not moved.
그림 17-7: 자기 자신을 참조하는 퓨처 타입을 가리키는 `Box` 포인터를 움직이기

하지만 대부분의 타입은, 심지어 Pin 뒤에 있더라도 그냥 움직여도 완전히 안전합니다. Pin을 신경 써야 하는 경우는, 내부 참조를 가진 타입일 때뿐입니다. 숫자나 불리언 같은 기본 값은 당연히 내부 참조를 가질 수 없습니다. 여러분이 보통 러스트에서 다루는 대부분의 타입도 마찬가지입니다. 예를 들어 Vec 는 자유롭게 움직여도 괜찮습니다. 지금까지 본 내용을 바탕으로 생각해 보면, 만약 Pin<Vec<String>> 가 있다면 Vec<String> 이 실제로는 움직여도 전혀 문제 없는 타입임에도 불구하고, Pin 이 제공하는 더 제한적인 API를 통해서만 다뤄야 할 것입니다. 이런 경우에 “이 타입은 움직여도 괜찮다”고 컴파일러에게 알려 줄 방법이 필요하고, 그게 바로 Unpin 입니다.

Unpin 은 16장에서 본 Send, Sync 와 비슷한 마커 트레이트이며, 자기만의 기능은 없습니다. 어떤 타입이 특정 문맥에서 안전하게 쓰여도 된다는 사실을 컴파일러에게 알려 주는 용도로만 존재합니다. Unpin 은 “이 타입은 값이 움직여도 괜찮다”고 컴파일러에게 알려 줍니다.

SendSync 처럼, 컴파일러는 안전하다고 증명할 수 있는 모든 타입에 대해 자동으로 Unpin 을 구현합니다. 그리고 여기서도 비슷한 특수 경우가 있는데, 어떤 타입에 대해서는 Unpin 이 아니라 !Unpin 으로 표기합니다. 문법은 impl !Unpin for SomeType 처럼 보이며, 이는 그 타입이 Pin 안에서 안전하게 사용되려면 “움직이지 않는다”는 보장을 지켜야 한다는 뜻입니다.

다시 말해 PinUnpin 관계에서 기억해야 할 점은 두 가지입니다. 첫째, Unpin 이 “보통의 기본 경우”이고, !Unpin 이 특수한 경우입니다. 둘째, 어떤 타입이 Unpin 인지 !Unpin 인지는 오직 Pin<&mut SomeType> 같은 형태의 pin된 포인터와 함께 그 타입을 사용할 때만 의미가 있습니다.

구체적으로 생각해 보기 위해 String 을 떠올려 보세요. String 은 길이와, 그 안의 유니코드 문자들을 가집니다. 그림 17-8처럼 StringPin 으로 감쌀 수는 있습니다. 하지만 String 은 러스트의 대부분 타입처럼 자동으로 Unpin 을 구현합니다.

A box labeled “Pin” on the left with an arrow going from it to a box labeled “String” on the right. The “String” box contains the data 5usize, representing the length of the string, and the letters “h”, “e”, “l”, “l”, and “o” representing the characters of the string “hello” stored in this String instance. A dotted rectangle surrounds the “String” box and its label, but not the “Pin” box.
그림 17-8: `String` 을 pin 하기. 점선은 `String` 이 `Unpin` 을 구현하므로 실제로는 pin 되지 않는다는 뜻이다

그 결과, 그림 17-9처럼 같은 메모리 위치에 있던 문자열 하나를 완전히 다른 문자열로 교체하는 일도 가능합니다. 이것은 Pin 계약을 깨지 않습니다. String 에는 값을 움직이면 위험해지는 내부 자기 참조가 없기 때문입니다. 그래서 String!Unpin 이 아니라 Unpin 을 구현하는 것입니다.

The same “hello” string data from the previous example, now labeled “s1” and grayed out. The “Pin” box from the previous example now points to a different String instance, one that is labeled “s2”, is valid, has a length of 7usize, and contains the characters of the string “goodbye”. s2 is surrounded by a dotted rectangle because it, too, implements the Unpin trait.
그림 17-9: 메모리 안의 `String` 을 완전히 다른 `String` 으로 교체하기

이제 우리는 목록 17-23의 join_all 오류를 이해할 만큼 충분히 배웠습니다. 우리는 async 블록이 만든 퓨처들을 Vec<Box<dyn Future<Output = ()>>> 안으로 움직이려 했는데, 방금 보았듯 그런 퓨처에는 내부 자기 참조가 있을 수 있으므로 자동으로 Unpin 을 구현하지 않습니다. 따라서 이 값들을 pin 한 뒤에야 Vec 안으로 넣을 수 있고, 그렇게 하면 그 퓨처 안의 데이터가 더 이상 움직이지 않는다는 사실을 믿을 수 있습니다. 목록 17-24는 각 퓨처를 정의할 때 pin! 매크로를 호출하고, trait object 타입도 조정해서 이 코드를 고치는 방법을 보여 줍니다.

extern crate trpl; // required for mdbook test

use std::pin::{Pin, pin};

// --snip--

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let rx_fut = pin!(async {
            // --snip--
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        });

        let tx_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let futures: Vec<Pin<&mut dyn Future<Output = ()>>> =
            vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}
Listing 17-24: 벡터 안으로 움직일 수 있도록 퓨처를 pin 하기

이제 이 예제는 컴파일되고 실행되며, 런타임에 벡터 안 퓨처를 추가하거나 제거하고, 그 모두를 함께 join_all 로 기다릴 수 있습니다.

PinUnpin 은 일반적인 러스트 코드보다는, 저수준 라이브러리를 만들거나 런타임 자체를 구현할 때 훨씬 더 중요합니다. 하지만 오류 메시지에서 이 둘을 보게 되면, 이제는 어떻게 고쳐야 할지 훨씬 감을 잡을 수 있을 것입니다.

Note: PinUnpin 의 조합 덕분에, 그렇지 않았다면 자기 참조 때문에 다루기 어려웠을 복잡한 타입들의 전체 부류를 러스트에서 안전하게 구현할 수 있습니다. Pin 이 필요한 타입은 오늘날 async 러스트에서 가장 흔하지만, 가끔 다른 문맥에서도 등장할 수 있습니다.

PinUnpin 이 어떻게 동작하고 어떤 규칙을 지켜야 하는지는 std::pin API 문서에 매우 자세히 설명되어 있으므로, 더 알고 싶다면 거기서 시작하는 것이 좋습니다.

내부 동작을 더 깊이 이해하고 싶다면, [Asynchronous Programming in Rust] async-book2장4장 을 읽어 보세요.

Stream 트레이트

이제 Future, Pin, Unpin 을 더 잘 이해하게 되었으니, 이제 Stream 트레이트로 시선을 돌려 봅시다. 이 장 앞에서 배웠듯, 스트림은 비동기적인 반복자와 비슷합니다. 하지만 IteratorFuture 와 달리, 적어도 이 글을 쓰는 시점에는 Stream 이 아직 표준 라이브러리에 정의되어 있지 않습니다. 대신 생태계 전반에서 널리 쓰이는 사실상의 표준 정의가 futures 크레이트에 존재합니다.

StreamIteratorFuture 를 어떻게 합치는지 보기 전에, 먼저 두 트레이트 정의를 다시 떠올려 봅시다. Iterator 에서는 next 메서드가 Option<Self::Item> 을 제공하며, 이는 시퀀스의 다음 항목이라는 개념을 표현합니다. Future 에서는 poll 메서드가 Poll<Self::Output> 을 제공하며, 이는 시간에 따라 “준비되었는지” 를 나타냅니다. 시간에 따라 준비되는 항목의 시퀀스를 표현하려면, 이 둘을 합친 Stream 트레이트를 정의할 수 있습니다.

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}
}

Stream 트레이트는 Item 이라는 연관 타입을 정의하는데, 이는 스트림이 만들어 내는 항목 타입입니다. 이 점은 0개에서 여러 개까지 항목이 있을 수 있다는 점에서 Iterator 와 비슷하고, unit 타입 () 이더라도 언제나 출력값이 하나뿐인 Future 와는 다릅니다.

또한 Streampoll_next 라는 메서드를 정의합니다. 이름에서 보이듯, 이 메서드는 Future::poll 처럼 “폴링” 되며, Iterator::next 처럼 “다음 항목”을 만들어 냅니다. 그래서 반환 타입이 Poll<Option<Self::Item>> 으로 두 개를 결합한 형태가 됩니다. 바깥 타입이 Poll 인 이유는, 퓨처처럼 아직 준비되지 않았을 수도 있기 때문입니다. 안쪽 타입이 Option 인 이유는, 반복자처럼 더 이상 항목이 없는 경우를 표현해야 하기 때문입니다.

이와 매우 비슷한 정의가 언젠가는 표준 라이브러리 안으로 들어갈 가능성이 큽니다. 그 전까지는 이것이 대부분 런타임이 사용하는 사실상의 도구이며, 우리가 여기서 설명하는 내용도 대체로 그대로 적용됩니다.

하지만 “스트림: 순차적으로 이어지는 퓨처” 절의 예제에서는 직접 poll_nextStream 을 사용하지 않았고, 대신 nextStreamExt 를 사용했습니다. 물론 우리가 직접 상태 머신을 써서 poll_next 기반으로 코드를 짜는 것도 가능합니다. 퓨처에 대해 직접 poll 을 쓰는 것이 가능했던 것과 같은 이치입니다. 하지만 await 를 쓰는 편이 훨씬 편하고, StreamExt 트레이트는 바로 그런 방식으로 쓸 수 있게 next 메서드를 제공합니다.

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;
    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Self::Item>>;
}

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // other methods...
}
}

Note: 우리가 이 장 앞에서 사용한 실제 StreamExt 정의는 이 코드와 조금 다릅니다. 그 이유는 아직 “트레이트 안 async 함수”를 지원하지 않던 옛 버전의 러스트도 지원해야 했기 때문입니다. 그래서 실제 정의는 다음과 같이 생겼습니다.

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

여기의 Next 타입은 Future 를 구현하는 구조체이며, Next<'_, Self> 형태로 self 참조의 라이프타임을 이름 붙일 수 있게 해 줍니다. 그래야 await 와 함께 자연스럽게 사용할 수 있습니다.

StreamExt 트레이트는 또한 스트림과 함께 사용할 수 있는 흥미로운 메서드들의 본거지이기도 합니다. StreamExtStream 을 구현하는 모든 타입에 대해 자동 구현되지만, 이 둘이 별도 트레이트로 분리되어 있는 이유는, 기초 트레이트를 바꾸지 않고도 생태계가 편의 API를 계속 확장·발전시킬 수 있게 하기 위해서입니다.

우리가 trpl 에서 사용한 StreamExt 버전은 next 메서드를 정의할 뿐 아니라, Stream::poll_next 를 올바르게 호출하는 기본 구현까지 제공합니다. 즉, 여러분이 직접 스트리밍 데이터 타입을 만들어야 하는 경우에도, 구현해야 하는 것은 Stream 하나뿐이고, 그 타입을 사용하는 사람은 StreamExt 와 그 메서드들을 자동으로 함께 쓸 수 있습니다.

이 트레이트들의 더 저수준 세부는 여기까지만 다루겠습니다. 이제 마무리로, 퓨처 (스트림 포함), 태스크, 스레드가 서로 어떻게 들어맞는지 함께 생각해 봅시다!