Async를 위한 트레이트 가까이 들여다보기
Async를 위한 트레이트 가까이 들여다보기 (A Closer Look at the Traits for Async)
이번 장 전체에서 우리는 Future·Stream·StreamExt 트레이트를 여러 방식으로 사용했어요. 하지만 지금까지는 그것들이 어떻게 동작하고 어떻게 서로 맞물리는지 세부 사항을 깊이 파고드는 것을 피했는데, 일상적인 Rust 작업에서는 대부분 그 정도로 충분해요. 그런데 때로는 이런 트레이트의 세부 사항 몇 가지를, 함께 Pin 타입과 Unpin 트레이트까지 이해해야 하는 상황을 만나기도 합니다. 이번 절에서는 그런 시나리오를 돕기에 충분한 정도만 파고들 거예요. 정말 깊은 다이브는 다른 문서에 맡겨두면서요.
Future 트레이트
먼저 Future 트레이트가 어떻게 동작하는지 자세히 보는 것부터 시작할게요. Rust가 이를 정의하는 방식은 이렇습니다:
#![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은 future가 무엇으로 해석되는지를 말해 줍니다. 이는 Iterator 트레이트의 Item 연관 타입과 유사해요. 둘째, Future에는 poll 메서드가 있는데, self 매개변수로 특별한 Pin 참조자를, Context 타입에 대한 가변 참조자를 받고 Poll<Self::Output>을 반환합니다. Pin과 Context에 대해서는 잠시 후에 더 이야기할 거예요. 지금은 메서드가 반환하는 것, Poll 타입에 집중해 봅시다:
#![allow(unused)]
fn main() {
pub enum Poll<T> {
Ready(T),
Pending,
}
}
이 Poll 타입은 Option과 비슷합니다. 값이 있는 변형 Ready(T)와 값이 없는 변형 Pending이 하나씩 있죠. 하지만 Poll은 Option과 꽤 다른 의미를 가집니다! Pending 변형은 future가 아직 해야 할 일이 남아 있어서 호출자가 나중에 다시 확인해야 함을 나타냅니다. Ready 변형은 Future가 작업을 끝냈고 T 값이 사용 가능함을 나타내요.
참고: poll을 직접 호출해야 하는 일은 드물지만, 필요하다면 이 점을 기억하세요. 대부분의 future에서 호출자는 future가 Ready를 반환한 뒤에 poll을 다시 호출해서는 안 됩니다. 많은 future는 준비된 후 다시 폴링되면 패닉해요. 다시 폴링해도 안전한 future는 문서에 그렇게 명시적으로 적어둡니다. 이는 Iterator::next가 동작하는 방식과 비슷합니다.
await를 사용하는 코드를 볼 때, Rust는 내부적으로 그것을 poll을 호출하는 코드로 컴파일합니다. Listing 17-4(단일 URL에 대해 페이지 제목이 해석되면 출력한 코드)를 다시 보면, Rust는 그것을 대략(정확히는 아니지만) 이렇게 컴파일해요:
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?
}
}
future가 여전히 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
}
}
}
하지만 Rust가 정확히 그런 코드로 컴파일한다면 모든 await가 차단(blocking)이 되겠죠 — 바로 우리가 피하려고 했던 것의 정반대입니다! 대신 Rust는 루프가 제어를 넘겨줄 수 있는 무언가가 있도록 보장합니다. 즉 이 future에 대한 작업을 일시정지하고 다른 future 작업을 하다가 나중에 이 future를 다시 확인할 수 있는 존재 말이에요. 우리가 봤듯이 그 존재는 async 런타임이고, 이런 스케줄링·조정 작업이 런타임의 주요 임무 중 하나입니다.
"메시지 전달로 두 작업 사이 데이터 보내기" 절에서 우리는 rx.recv를 기다리는 것에 대해 설명했어요. recv 호출은 future를 반환하고, 그 future를 await하면 폴링합니다. 우리는 런타임이 채널이 Some(message) 또는 None으로 준비될 때까지 future를 일시정지한다고 언급했죠. Future 트레이트, 특히 Future::poll에 대한 더 깊은 이해로 이제 그것이 어떻게 동작하는지 볼 수 있어요. 런타임은 future가 Poll::Pending을 반환하면 준비되지 않았다는 것을 압니다. 반대로 poll이 Poll::Ready(Some(message))나 Poll::Ready(None)을 반환하면 런타임은 future가 준비되었고 그것을 진행시킨다는 것을 알아요.
런타임이 그렇게 하는 정확한 세부 사항은 이 책의 범위를 벗어나지만, 핵심은 future의 기본 역학을 보는 것입니다. 런타임은 자신이 책임지는 각 future를 폴링하고, 아직 준비되지 않았으면 그 future를 다시 잠재웁니다.
Pin 타입과 Unpin 트레이트
Listing 17-13에서 우리는 trpl::join! 매크로로 세 개의 future를 await했어요. 하지만 런타임까지 그 수를 알 수 없는 몇 개의 future를 담은 벡터 같은 컬렉션을 갖는 것은 흔한 일입니다. Listing 17-13을, 세 future를 벡터에 넣고 trpl::join_all 함수를 호출하는 Listing 17-23의 코드로 바꿔 봅시다. 아직 컴파일되지 않아요.
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;
});
}
12장의 "run에서 오류 반환하기" 절에서 했던 것처럼, 각 future를 Box에 넣어 트레이트 객체(trait object)로 만들었어요. (트레이트 객체는 18장에서 자세히 다룰 거예요.) 트레이트 객체를 사용하면 이 타입들이 만들어 낸 각 익명 future를 같은 타입으로 취급할 수 있어요. 모두 Future 트레이트를 구현하니까요.
이것은 놀라울 수 있어요. 결국 어떤 async 블록도 아무것도 반환하지 않으므로, 각각 Future<Output = ()>을 만들어 냅니다. 하지만 Future는 트레이트이고, 컴파일러가 각 async 블록을 위해 고유한 enum을 만든다는 것을 기억하세요. 출력 타입이 동일하더라도 말이에요. 손으로 쓴 서로 다른 두 struct를 Vec에 넣을 수 없듯이, 컴파일러가 만든 enum도 섞을 수 없습니다.
그런 다음 future 컬렉션을 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`
이 오류 메시지의 참고(note)는 pin! 매크로를 사용해 값을 고정(pin)하라고 알려줍니다. 고정한다는 것은 값을 Pin 타입 안에 넣어 값을 메모리에서 이동하지 못하게 보장하는 것을 뜻해요. 오류 메시지는 dyn Future<Output = ()>이 Unpin 트레이트를 구현해야 하는데 현재는 구현하지 않기 때문에 고정이 필요하다고 말해요.
trpl::join_all 함수는 JoinAll이라는 struct를 반환합니다. 그 struct는 Future 트레이트를 구현하도록 제한된 타입 F에 대해 제네릭이에요. await로 future를 직접 await하면 암묵적으로 그 future를 고정(pin)합니다. 그래서 우리는 future를 await하고 싶을 때마다 pin!을 사용할 필요가 없는 거예요.
하지만 여기서는 future를 직접 await하는 게 아니에요. 대신 future 컬렉션을 join_all 함수에 넘겨서 JoinAll이라는 새 future를 만듭니다. join_all의 시그니처는 컬렉션의 항목 타입이 모두 Future 트레이트를 구현해야 한다고 요구하고, Box<T>는 감싼 T가 Unpin 트레이트를 구현하는 future일 때만 Future를 구현합니다.
흡수할 것이 많죠! 정말 이해하려면 Future 트레이트가 실제로 어떻게 동작하는지, 특히 고정(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 타입은 런타임이 여전히 lazy하면서도 어떤 주어진 future를 언제 확인해야 하는지 실제로 아는 방법의 핵심이에요. 그게 어떻게 동작하는지의 세부 사항도 이번 장의 범위를 벗어나고, 커스텀 Future 구현을 작성할 때만 보통 생각하면 됩니다. 우리는 대신 self의 타입에 초점을 맞출게요. 메서드에서 self가 타입 주석을 갖는 것을 처음 보기 때문이에요. self에 대한 타입 주석은 다른 함수 매개변수의 타입 주석처럼 동작하지만, 두 가지 핵심 차이가 있습니다:
- Rust에게 메서드가 호출되기 위해
self가 어떤 타입이어야 하는지 알려준다. - 아무 타입이나 될 수 없다. 메서드가 구현된 타입, 그 타입에 대한 참조자나 스마트 포인터, 또는 그 타입에 대한 참조자를 감싼
Pin으로 제한된다.
이 문법에 대해서는 18장에서 더 볼 거예요. 지금은 future를 폴링해 Pending인지 Ready(Output)인지 확인하려면, 그 타입에 대한 Pin으로 감싼 가변 참조자가 필요하다는 것만 알면 됩니다.
Pin은 &·&mut·Box·Rc 같은 포인터류 타입을 위한 래퍼입니다. (기술적으로 Pin은 Deref나 DerefMut 트레이트를 구현하는 타입과 동작하지만, 이는 실질적으로 참조자와 스마트 포인터만으로 작업하는 것과 동등해요.) Pin은 그 자체가 포인터가 아니고, Rc와 Arc가 참조 카운팅으로 하는 것 같은 자체 동작도 없습니다. 순전히 컴파일러가 포인터 사용에 제약을 강제하는 데 쓸 수 있는 도구예요.
await이 poll 호출로 구현된다는 점을 떠올리면 앞서 본 오류 메시지를 설명하기 시작하지만, 그 메시지는 Pin이 아니라 Unpin의 관점이었어요. 그럼 Pin은 정확히 어떻게 Unpin과 관련되고, 왜 Future는 poll을 호출하려면 self가 Pin 타입에 있어야 할까요?
이번 장 앞부분에서 future의 일련의 await 지점이 상태 기계로 컴파일되고, 컴파일러가 그 상태 기계가 빌림·소유권을 포함한 Rust의 모든 일반적인 안전 규칙을 따르도록 보장한다는 것을 기억하세요. 그렇게 하기 위해 Rust는 한 await 지점에서 다음 await 지점 또는 async 블록의 끝 사이에 필요한 데이터가 무엇인지 봅니다. 그런 다음 컴파일된 상태 기계에 해당하는 변형(variant)을 만듭니다. 각 변형은 그 소스 코드 구간에서 사용될 데이터에 대한 접근을 얻는데, 그 데이터의 소유권을 취하거나 그것에 대한 가변·불변 참조자를 얻는 방식으로요.
지금까지는 좋아요. 주어진 async 블록에서 소유권이나 참조자를 잘못 쓰면 빌림 검사기가 알려줄 테니까요. 그 블록에 해당하는 future를 이동시키고 싶을 때 — 예를 들어 join_all에 넘길 벡터로 옮길 때 — 일이 까다로워집니다.
future를 이동시키면 — 데이터 구조에 밀어 넣어 join_all과 함께 iterator로 쓰든, 함수에서 반환하든 — 실제로는 Rust가 우리를 위해 만든 상태 기계를 이동시키는 것입니다. 그리고 Rust의 대부분의 다른 타입과 달리, async 블록을 위해 Rust가 만드는 future는 어떤 주어진 변형의 필드에 자기 자신에 대한 참조자가 들어갈 수 있어요. 그림 17-4의 단순화된 예시처럼요.
하지만 기본적으로 자기 자신에 대한 참조자가 있는 객체는 이동해도 안전하지 않아요. 참조자는 항상 그것이 가리키는 무엇이든 실제 메모리 주소를 가리키기 때문입니다(그림 17-5 참고). 데이터 구조 자체를 이동시키면 그 내부 참조자들은 옛 위치를 가리키고 남게 돼요. 하지만 그 메모리 위치는 이제 유효하지 않습니다. 우선 데이터 구조를 변경해도 그 값이 갱신되지 않을 거예요. 더 중요한 두 번째 이유로, 컴퓨터는 이제 그 메모리를 다른 목적으로 재사용할 자유가 있어요! 나중에 완전히 무관한 데이터를 읽게 될 수도 있습니다.
이론상으로 Rust 컴파일러는 객체가 이동될 때마다 객체에 대한 모든 참조자를 갱신하려 할 수 있겠지만, 특히 참조자의 웹 전체를 갱신해야 한다면 성능 오버헤드가 커질 수 있어요. 만약 문제의 데이터 구조가 메모리에서 이동하지 않도록 만들 수 있다면 어떤 참조자도 갱신할 필요가 없겠죠. 이것이 바로 Rust의 빈림 검사기의 용도입니다. 안전한 코드에서 활성 참조자가 있는 어떤 항목을 이동하는 것을 막아 주거든요.
Pin은 그 위에 우리에게 필요한 정확한 보장을 제공해요. 값에 대한 포인터를 Pin으로 감싸 값을 고정하면, 그 값은 더 이상 이동할 수 없습니다. 따라서 Pin<Box<SomeType>>이 있다면, 실제로 고정되는 것은 SomeType 값이지 Box 포인터가 아니에요. 그림 17-6이 이 과정을 보여줍니다.
실제로 Box 포인터는 여전히 자유롭게 이동할 수 있어요. 기억하세요. 우리가 신경 쓰는 것은 궁극적으로 참조되는 데이터가 제자리에 머무는지입니다. 포인터가 이동하더라도 그것이 가리키는 데이터가 같은 장소에 있다면(그림 17-7처럼), 잠재적 문제가 없어요. (독립적인 연습으로, 그 타입들과 std::pin 모듈에 대한 문서를 보며 Box를 감싼 Pin으로 이걸 어떻게 할지 생각해 보세요.) 핵심은 자기 참조적 타입 자체가 여전히 고정되어 있으므로 이동할 수 없다는 점입니다.
하지만 대부분의 타입은 Pin 포인터 뒤에 있더라도 이동해도 완벽히 안전해요. 항목에 내부 참조자가 있을 때만 고정에 대해 생각하면 됩니다. 숫자나 Boolean 같은 기본 값은 당연히 내부 참조자가 없으니 안전해요. Rust에서 보통 다루는 대부분의 타입도 마찬가지예요. 예를 들어 Vec은 걱정 없이 여기저기 옮길 수 있습니다. 지금까지 본 것을 고려하면, Pin<Vec<String>>이 있다면 Vec<String>은 다른 참조자가 없으면 항상 이동해도 안전한데도 Pin이 제공하는 안전하지만 제한적인 API로만 모든 걸 해야 해요. 이런 경우 항목을 옮겨도 괜찮다고 컴파일러에게 알려줄 방법이 필요해요 — 그리고 바로 여기서 Unpin이 등장합니다.
Unpin은 16장에서 봤던 Send·Sync 트레이트와 비슷한 마커 트레이트로, 따라서 자체 기능은 없어요. 마커 트레이트는 어떤 특정 문맥에서 주어진 트레이트를 구현하는 타입을 쓰는 것이 안전하다고 컴파일러에게 알려주기 위해서만 존재합니다. Unpin은 주어진 타입이, 문제의 값을 안전하게 이동할 수 있는지에 대한 어떤 보장도 유지할 필요가 없다고 컴파일러에 알려줍니다.
Send·Sync와 마찬가지로 컴파일러는 안전함을 증명할 수 있는 모든 타입에 대해 Unpin을 자동으로 구현합니다. Send·Sync와 또 비슷한 특별한 경우는 Unpin이 타입에 대해 구현되지 않는 경우입니다. 이것의 표기는 impl !Unpin for SomeType인데, SomeType은 Pin에서 그 타입에 대한 포인터를 사용할 때마다 안전하게 하려면 그러한 보장을 유지해야 하는 타입의 이름이에요.
다시 말해, Pin과 Unpin의 관계에 대해 기억할 점이 두 가지 있습니다. 첫째, Unpin이 "정상적인" 경우이고 !Unpin이 특별한 경우예요. 둘째, 타입이 Unpin을 구현하는지 !Unpin을 구현하는지는 그 타입에 대한 고정된 포인터(Pin<&mut SomeType> 같은)를 사용할 때만 중요합니다.
구체적으로 만들기 위해 String을 생각해 봅시다. 그것은 길이와 그것을 이루는 유니코드 문자들을 갖고 있어요. String을 그림 17-8에서처럼 Pin으로 감쌀 수 있습니다. 하지만 String은 Rust의 대부분의 다른 타입처럼 자동으로 Unpin을 구현합니다.
결과적으로 우리는 String이 대신 !Unpin을 구현했다면 불법일 일들을 할 수 있어요. 예를 들어 그림 17-9처럼 정확히 같은 메모리 위치의 문자열을 다른 문자열로 교체하는 것 말이죠. 이것은 Pin 계약을 위반하지 않아요. String은 이동을 안전하지 않게 만드는 내부 참조자가 없으니까요. 그게 바로 !Unpin이 아니라 Unpin을 구현하는 정확한 이유입니다.
이제 Listing 17-23의 join_all 호출에 대해 보고된 오류를 이해할 만큼 알게 됐어요. 우리는 원래 async 블록이 만든 future들을 Vec<Box<dyn Future<Output = ()>>>로 이동시키려 했지만, 우리가 봤듯이 그 future들은 내부 참조자를 가질 수 있어서 자동으로 Unpin을 구현하지 않아요. 일단 고정하면, 결과 Pin 타입을 Vec에 넘길 수 있어요. future 밑바탕의 데이터가 이동되지 않을 거라는 확신을 갖고요. Listing 17-24는 세 future 각각이 정의된 곳에서 pin! 매크로를 호출하고 트레이트 객체 타입을 조정해서 코드를 고치는 방법을 보여줍니다.
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;
});
}
이 예시는 이제 컴파일되고 실행되며, 런타임에 벡터에서 future를 추가하거나 제거하고 그것들을 모두 합칠 수 있어요.
Pin과 Unpin은 일상적인 Rust 코드보다는 저수준 라이브러리를 만들거나 런타임 자체를 만들 때 주로 중요합니다. 이 트레이트들을 오류 메시지에서 보면, 이제 코드를 어떻게 고칠지 더 잘 알게 될 거예요!
참고: 이 Pin과 Unpin의 조합 덕분에 Rust에서 자체 참조적이라 까다로울 뻔한 복잡한 타입의 한 부류를 안전하게 구현할 수 있어요. Pin이 필요한 타입은 오늘날 async Rust에서 가장 흔히 볼 수 있지만, 가끔은 다른 맥락에서도 볼 수 있습니다.
Pin과 Unpin이 동작하는 구체적인 방식과 그것들이 유지해야 하는 규칙들은 std::pin의 API 문서에 광범위하게 다뤄져 있어요. 더 배우고 싶다면 그 문서가 시작하기 좋은 곳입니다.
내부적으로 일이 어떻게 돌아가는지 더욱 자세히 이해하고 싶다면, Asynchronous Programming in Rust의 2장과 4장을 보세요.
Stream 트레이트
이제 Future·Pin·Unpin 트레이트에 더 깊은 이해가 생겼으니, Stream 트레이트로 시선을 돌릴 수 있어요. 이번 장 앞부분에서 배웠듯이, 스트림은 비동기 iterator와 비슷합니다. 하지만 Iterator·Future와 달리 Stream은 이 글을 쓰는 시점 기준으로 표준 라이브러리에 정의가 없어요. 그 대신 생태계 전반에서 쓰이는 futures 크레이트의 매우 흔한 정의가 있습니다.
Stream 트레이트가 그 둘을 어떻게 합칠 수 있을지 보기 전에 Iterator와 Future 트레이트의 정의를 다시 살펴볼게요. Iterator에서는 시퀀스(sequence)라는 아이디어가 있고, 그 next 메서드는 Option<Self::Item>을 제공해요. Future에서는 시간에 걸친 준비 상태(readiness)라는 아이디어가 있고, 그 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이라는 연관 타입을 정의합니다. 이는 Iterator와 비슷한데, 항목이 0개에서 많게 있을 수 있고, 항상 단일 Output(심지어 단위 타입 ()이어도)이 있는 Future와는 다릅니다.
Stream은 그 항목들을 얻는 메서드도 정의합니다. 우리는 그것을 poll_next라고 부르는데, Future::poll과 같은 방식으로 폴링하고 Iterator::next와 같은 방식으로 일련의 항목을 만들어 낸다는 것을 명확히 하기 위해서예요. 반환 타입은 Poll과 Option을 결합합니다. 바깥 타입은 Poll인데, future처럼 준비 상태를 확인해야 하기 때문이에요. 안쪽 타입은 Option인데, iterator처럼 더 많은 메시지가 있는지 알려줘야 하기 때문입니다.
이 정의와 아주 비슷한 무언가가 결국 Rust 표준 라이브러리의 일부가 될 가능성이 높아요. 그동안은 대부분의 런타임의 도구 키트의 일부이므로, 그것에 의존할 수 있고 다음에 다루는 모든 것이 대체로 적용될 것입니다!
하지만 "스트림: 순서대로의 Future" 절에서 봤던 예시들에서는 poll_next나 Stream을 사용하지 않고, 대신 next와 StreamExt를 사용했어요. 물론 poll_next API를 직접 써서 우리 자신의 Stream 상태 기계를 손으로 작성할 수도 있어요. poll 메서드를 통해 future를 직접 다룰 수 있는 것과 마찬가지죠. 하지만 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...
}
}
참고: 이번 장 앞부분에서 실제로 사용한 정의는 이것과 조금 다르게 생겼어요. 왜냐하면 그것은 트레이트에서 async 함수를 아직 지원하지 않던 Rust 버전들을 지원하기 때문이에요. 그래서 이렇게 생겼죠:
fn next(&mut self) -> Next<'_, Self> where Self: Unpin;
그 Next 타입은 Future를 구현하는 struct로, Next<'_, Self>로 self 참조자의 수명을 이름 붙일 수 있게 해 줘서 await가 이 메서드와 함께 동작할 수 있게 합니다.
StreamExt 트레이트는 스트림과 함께 쓸 수 있는 모든 흥미로운 메서드들의 본거지이기도 해요. StreamExt는 Stream을 구현하는 모든 타입에 자동으로 구현되지만, 이 두 트레이트는 분리되어 정의되는데, 커뮤니티가 기초 트레이트에 영향을 주지 않고 편의 API를 반복·개선할 수 있도록 하기 위해서예요.
trpl 크레이트에 사용된 StreamExt 버전에서, 그 트레이트는 next 메서드를 정의할 뿐만 아니라 Stream::poll_next 호출의 세부 사항을 올바르게 처리하는 next의 기본 구현도 제공합니다. 이는 자신만의 스트리밍 데이터 타입을 작성해야 할 때조차 Stream만 구현하면, 그 후에는 당신의 데이터 타입을 사용하는 누구든 StreamExt와 그 메서드들을 자동으로 쓸 수 있다는 뜻이에요.
이 트레이트들의 저수준 세부 사항으로 다룰 것은 여기까지예요. 마무리하자면, future(스트림 포함)·task·thread가 모두 어떻게 맞물리는지 생각해 봅시다!