Rust 1.75에서 traits 안의 async fn이 도입된 이후, 왜 boxed future가 필요했는지, vtable과 동적 디스패치, dyn 호환성 제약, RPITIT(impl Trait in return position), 연관 타입/즉석 연관 타입, GATs, 수명과 Send 한정, 그리고 tower::Service를 예제로 한 설계 트레이드오프를 자세한 코드와 함께 짚어본다.
2023년 12월, 작은 기적이 일어났습니다: async fn in traits가 출시되었거든요.
As of Rust 1.39, we already had free-standing async functions:
pub async fn read_hosts() -> eyre::Result<Vec<u8>> {
// etc.
}
…and async functions in impl blocks:
impl HostReader {
pub async fn read_hosts(&self) -> eyre::Result<Vec<u8>> {
// etc.
}
}
하지만 traits 안의 async 함수는 없었습니다:
use std::io;
trait AsyncRead {
async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}
sansioex on main via 🦀 v1.82.0
❯ cargo +1.74.0 check --quiet
error[E0706]: functions in traits cannot be declared `async`
--> src/main.rs:9:5
|
9 | async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
| -----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| |
| `async` because of this
|
= note: `async` trait functions are not currently supported
= note: consider using the `async-trait` crate: https://crates.io/crates/async-trait
= note: see issue #91611 <https://github.com/rust-lang/rust/issues/91611> for more information
For more information about this error, try `rustc --explain E0706`.
error: could not compile `sansioex` (bin "sansioex") due to previous error
여기서의 cargo는 rustup이 제공하는 shim이기 때문에 cargo +channel 문법이 유효합니다.
유효한 채널 이름은 x.y.z, stable, beta, nightly 등입니다 — rust-toolchain.toml이나 다른 툴체인 오버라이드에서 보던 바로 그것들이죠.
오랫동안, traits 안에서 async fn을 쓰려면 async-trait 크레이트가 사실상의 표준이었습니다:
use std::io;
#[async_trait::async_trait]
trait AsyncRead {
async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}
잘 동작하긴 했지만, 트레이트 정의(그리고 모든 구현)를 고정(pinned)된, 박스(box)에 담긴 future를 반환하도록 바꿔버렸습니다.
박스드 futures?
그래요! 힙에 할당된 future들이죠.
왜요?
음, 왜냐하면 보세요, future — 즉 async 함수가 반환하는 값 — 는 크기가 제각각일 수 있거든요!
로컬 변수의 크기 ------------------ 다음 함수가 반환하는 future는:
async fn foo() {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
println!("done");
}
…이 함수가 반환하는 future보다 더 작습니다:
async fn bar() {
let mut a = [0u8; 72];
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
for _ in 0..10 {
a[0] += 1;
}
println!("done");
}
bar에는 그냥 더 많은 일이 — 더 많은 상태가 — 들어 있으니까요:
sansioex on main [!] via 🦀 v1.82.0
❯ cargo run --quiet
foo: 128
bar: 200
저 배열은 우리가 비동기적으로 잠들어 있는 동안 해제되지 않습니다 — 그 모든 게, 음, future 안에 저장되어 있어요.
그리고 이건 문제입니다. 보통 함수를 호출할 때, 반환값을 위해 얼마나 공간을 예약해야 하는지 알고 싶거든요: 우리는 그 반환값이 “크기가 정해져 있다(sized)”고 말하죠.
여기서 “우리”는 사실 컴파일러를 뜻해요 — 로컬 변수를 위한 스택 공간 예약은 함수가 호출될 때 하는 첫 작업 중 하나죠.
여기서는, 컴파일러가 스택에 step1, _foo, step2를 위한 공간을 예약합니다:
fn main() {
let step1: u64 = 0;
let _foo = foo();
let step2: u64 = 0;
// etc.
}
디스어셈블리에서 볼 수 있듯이:
sansioex on main [!] via 🦀 v1.83.0
❯ cargo asm sansioex::main --quiet --simplify --color | head -5
sansioex::main:
Lfunc_begin45:
sub sp, sp, #256
stp x20, x19, [sp, #224]
stp x29, x30, [sp, #240]
여기 “sub”가 총 256바이트를 예약하네요.
여기서 사용한 cargo asm 서브커맨드는 cargo-show-asm에서 옵니다. cargo install --locked --all-features cargo-show-asm로 설치했어요.
오리지널 cargo-asm 크레이트도 여전히 동작하지만 기능이 적고 2018년 이후로 업데이트되지 않았습니다.
컴파일러가 반드시 그래야 하는 건 아니지만, 우리 코드의 모든 로컬 변수들은…
fn main() {
let step1: u64 = 0;
let _foo = foo();
let step2: u64 = 0;
println!(
"distance in bytes between before and after: {:?}",
(&step2 as *const _ as u64) - (&step1 as *const _ as u64)
);
}
…서로 나란히 배치됩니다 — 그래서 무엇이 출력될지 예측할 수 있어요: 스택에서 step1과 step2 사이의 거리죠.
으으… 128? foo가 반환하는 future의 크기?
아주 근접!
sansioex on main [!] via 🦀 v1.82.0
❯ cargo run --quiet
distance in bytes between before and after: 136
step1과 step2 사이의 거리는 step1의 크기도 포함합니다. 우리의 스택은 대략 이렇게 생겼어요:
젠장, 맞네.
걱정 마, 곰이여. 누구나 펜스포스트 오류를 한 번쯤은 하니까.
박스로 싸버리기 -------------- 다시 이 AsyncRead 트레이트로 돌아가 봅시다:
use std::io;
trait AsyncRead {
async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}
만약 우리가 어떻게든 &dyn AsyncRead를 손에 넣었고, 거기에 read를 호출한다면:
async fn use_read(r: &mut dyn AsyncRead) {
let mut buf = [0; 1024];
// 이 로컬의 크기는 얼마일까요?
let fut = r.read(&mut buf);
fut.await;
}
…fut를 위해 얼마나 공간을 예약해야 할지 어떻게 알 수 있을까요?
음… “future의 크기”를 AsyncRead 트레이트의 일부로 만들 수 있을 것 같은데요.
그러면 먼저 그 크기를 질의하고, 그만큼 할당한 다음, 방금 예약한 공간의 포인터를 read에 넘겨서 호출할 수 있겠죠…
맞아요! unsized locals가 대략 그렇게 동작합니다 — 그리고 장기적으로도 그런 계획에 가깝고요.
하지만 당장, 임의의 future를 담는 유일한 방법은 박싱하는 겁니다!
두 future의 실제 크기는 다르더라도, 로컬 변수의 크기는 같습니다. 실제 future를 가리키는 포인터일 뿐이니까요. 그건… 8바이트죠.
fn main() {
let _foo: Pin<Box<dyn Future<Output = ()>>> = Box::pin(foo());
let _bar: Pin<Box<dyn Future<Output = ()>>> = Box::pin(bar());
println!("Size of foo: {} bytes", std::mem::size_of_val(&_foo));
println!("Size of bar: {} bytes", std::mem::size_of_val(&_bar));
}
sansioex on main [!] via 🦀 v1.82.0
❯ cargo run --quiet
Size of foo: 16 bytes
Size of bar: 16 bytes
그런데… 출력은 16바이트네요.
그렇죠! 방금 살짝 과장했어요.
자, 값을 좀 더 자세히 봅시다.
동적 디스패치 ----------------
프로그램을 LLDB로 실행해, main 끝나기 직전에 브레이크포인트를 걸어볼게요:
sansioex on main [!] via 🦀 v1.83.0
❯ cargo b --quiet && rust-lldb ./target/debug/sansioex -Q
Current executable set to '/Users/amos/bearcove/sansioex/target/debug/sansioex' (arm64).
(lldb) b main.rs:78
Breakpoint 1: where = sansioex`sansioex::main::h616d18632113fc9e + 496 at main.rs:78:39, address = 0x000000010000478c
(lldb) r
Process 41462 launched: '/Users/amos/bearcove/sansioex/target/debug/sansioex' (arm64)
Size of foo: 16 bytes
Process 41462 stopped
* thread #1, name = 'main', queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x000000010000478c sansioex`sansioex::main::h616d18632113fc9e at main.rs:78:39
75 let _bar: Pin<Box<dyn Future<Output = ()>>> = Box::pin(bar());
76
77 println!("Size of foo: {} bytes", std::mem::size_of_val(&_foo));
-> 78 println!("Size of bar: {} bytes", std::mem::size_of_val(&_bar));
79 }
Target 0: (sansioex) stopped.
LLDB는 Rust에 대한 지원이 제한적이지만, 그래도 로컬 _foo를 출력할 수는 있어요:
(lldb) p _foo
(core::pin::Pin<alloc::boxed::Box<dyn core::future::future::Future<Output=()>, alloc::alloc::Global> >) {
__pointer = {
pointer = 0x0000600001a24100
vtable = 0x0000000100084068
}
}
두 개의 포인터가 보이죠! 합쳐서 16바이트입니다.
vtable은 뭘 가리키나요?
음, pointer는 데이터(값)를, vtable은 코드(함수)를 가리킵니다: 들여다보면 64비트 값들이 주소처럼 보이네요…
(lldb) x/8gx .__pointer.vtable
0x100084068: 0x0000000100004ae4 0x0000000000000080
0x100084078: 0x0000000000000008 0x0000000100004c58
0x100084088: 0x0000000100004a60 0x00000000000000c8
0x100084098: 0x0000000000000008 0x0000000100004e50
그중 하나를 찾아보면, 정말로 우리가 만든 async 함수의 주소라는 걸 알 수 있어요:
(lldb) image lookup -a 0x0000000100004c58
Address: sansioex[0x0000000100004c58] (sansioex.__TEXT.__text + 3488)
Summary: sansioex`sansioex::foo::_$u7b$$u7b$closure$u7d$$u7d$::h3b04e16f55b57af9 at main.rs:59
정확히 말하면, async 함수 안의 클로저죠. 하지만 이건 구현 세부사항일 뿐입니다.
vtable의 다른 필드들은 다른 함수들을 가리킵니다: 보통 그중에는 어떤 타입의 Drop 구현도 포함돼요 — 우리가 들고 있는 값을 어떻게 해제할지 아는 건 중요하니까요!
모든 “박스”가 두 개의 포인터로 이뤄진 건 아닙니다. “박스드 트레이트 객체”만 그래요.
fn main() {
let s = String::from("I am on the heap AMA");
let b = Box::new(s);
print_type_name_and_size(&b);
let b: Box<dyn std::fmt::Display> = b;
print_type_name_and_size(&b);
}
fn print_type_name_and_size<T>(_: &T) {
println!(
"\x1b[1m{:45}\x1b[0m \x1b[32m{} bytes\x1b[0m",
std::any::type_name::<T>(),
std::mem::size_of::<T>(),
);
}
sansioex on main [!] via 🦀 v1.83.0
❯ cargo run --quiet
alloc::boxed::Box<alloc::string::String> 8 bytes
alloc::boxed::Box<dyn core::fmt::Display> 16 bytes
Box<dyn Trait>의 dyn — 즉 vtable을 통한 동적 디스패치가 바로 이것입니다.
그리고 async-trait 크레이트가 하는 일이 딱 이겁니다! 이걸:
#[async_trait::async_trait]
trait AsyncRead {
async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}
이렇게 바꿔버리죠:
trait AsyncRead {
#[must_use]
#[allow(
elided_named_lifetimes,
clippy::type_complexity,
clippy::type_repetition_in_bounds
)]
fn read<'life0, 'life1, 'async_trait>(
&'life0 mut self,
buf: &'life1 mut [u8],
) -> ::core::pin::Pin<
Box<
dyn ::core::future::Future<Output = io::Result<usize>>
+ ::core::marker::Send
+ 'async_trait,
>,
>
where
'life0: 'async_trait,
'life1: 'async_trait,
Self: 'async_trait;
}
와 장황하네요.
맞아요, 절차적 매크로의 출력은 대체로 읽기 어렵습니다 — 중요한 건 반환 타입이 Pin<Box<dyn Future>>라는 점이에요 — AsyncRead::read의 실제 구현이 무엇이든 이건 항상 16바이트입니다.
dyn-호환성 -------------------
하지만 잠시 async-trait 크레이트는 잊어봅시다. Rust 1.75부터는 트레이트 안의 async 함수가 네이티브로 지원되거든요!
use std::io;
trait AsyncRead {
async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}
이 트레이트는 우리가 직접 정의했으니(참고: 고아 규칙), 아무 타입에나 구현할 수 있습니다:
impl AsyncRead for () {
async fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
let a = [0u8; 72];
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(a[3] as _)
}
}
그리고 이번엔 futures가 박싱되지 않습니다:
fn main() {
let mut s = ();
let mut buf = [0u8; 72];
let fut = s.read(&mut buf);
print_type_name_and_size(&fut);
}
sansioex on main [!] via 🦀 v1.83.0
❯ cargo run --quiet
<() as sansioex::AsyncRead>::read::{{closure}} 224 bytes
박싱되었다면 16바이트가 찍혔겠죠.
좋네요. 그럼 모든 문제가 해결된 건가요?
아직 전부는 아닙니다. 이건 뭐가 출력될까요?
fn use_async_read(r: Box<dyn AsyncRead>) {
let mut buf = [0u8; 72];
let fut = r.read(&mut buf);
print_type_name_and_size(&fut);
}
방금 그 future가 224바이트라고 봤으니…
…그건 빈 튜플 타입 ()일 때만요!
우리 파라미터는 어떤 타입일 수도 있어요 — AsyncRead를 구현하는 어떤 타입이든요.
맞아요, 그래서 알 수 없습니다. “unsized locals” 문제가 또 나옵니다.
정확합니다:
error[E0038]: the trait `AsyncRead` cannot be made into an object
--> src/main.rs:51:17
|
51 | let fut = r.read(&mut buf);
| ^^^^ `AsyncRead` cannot be made into an object
|
note: for a trait to be "dyn-compatible" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety>
--> src/main.rs:36:14
|
35 | trait AsyncRead {
| --------- this trait cannot be made into an object...
36 | async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
| ^^^^ ...because method `read` is `async`
언젠가는, 트레이트 객체의 vtable에 “async 메서드가 반환하는 future의 크기”가 포함되어 traits 안의 async fn도 dyn-호환이 될지도 모릅니다.
하지만 지금은 아닙니다.
또한 dyn-비호환인 것들이 많습니다: 예를 들어 self를 값으로 받는 것 같은 경우요:
trait EatSelf {
fn nomnomnom(self) {}
}
sansioex on main [!] via 🦀 v1.83.0
❯ cargo c
Checking sansioex v0.1.0 (/Users/amos/bearcove/sansioex)
error[E0277]: the size for values of type `Self` cannot be known at compilation time
--> src/main.rs:34:18
|
34 | fn nomnomnom(self) {}
| ^^^^ doesn't have a size known at compile-time
|
help: consider further restricting `Self`
|
34 | fn nomnomnom(self) where Self: Sized {}
| +++++++++++++++++
help: function arguments must have a statically known size, borrowed types always have a known size
|
34 | fn nomnomnom(&self) {}
| +
For more information about this error, try `rustc --explain E0277`.
error: could not compile `sansioex` (bin "sansioex") due to 1 previous error
여기서도 Box로 해결할 수 있을까요?
네! 컴파일러가 여기선 직접 제안해 주진 않지만, Box<Self>를 받는 건 괜찮습니다. 포인터 하나일 뿐이라 크기가 예측 가능하거든요. 실제로 모든 스마트 포인터와 참조는 괜찮습니다:
// dyn-호환 메서드 예시
trait TraitMethods {
fn by_ref(self: &Self) {}
fn by_ref_mut(self: &mut Self) {}
fn by_box(self: Box<Self>) {}
fn by_rc(self: Rc<Self>) {}
fn by_arc(self: Arc<Self>) {}
fn by_pin(self: Pin<&Self>) {}
fn with_lifetime<'a>(self: &'a Self) {}
fn nested_pin(self: Pin<Arc<Self>>) {}
}
즉, dyn-호환성은 일반적으로 트레이트 전반의 이슈이지, async Rust만의 문제가 아니라는 뜻이에요.
그리고 우리의 트레이트가 dyn-호환이 아니라고 해서, 쓸 수 없는 건 아닙니다. 제 HTTP 구현체 loona에서는 API 전체를 이런 제약을 고려해 설계했거든요.
예를 들어 impl AsyncRead를 받을 수 있습니다 — 이건 괜찮아요.
fn use_reader(_reader: impl AsyncRead) {}
왜냐하면 이건 사실 다음의 축약형이기 때문이죠:
fn use_reader<R: AsyncRead>(_reader: R) {}
&impl AsyncRead나 &mut impl AsyncRead도 받을 수 있습니다.
하지만 &dyn AsyncRead는 받을 수 없습니다 — 우리의 트레이트가 현재의 dyn-호환성 제약을 위반하고 있고, 그 원인은 “암묵적 연관 타입(associated type)”이 있기 때문이죠.
연관 타입 ---------------- 트레이트 안에 async fn이 있으면:
trait AsyncRead {
async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}
실제로는 “Future를 구현하는 무언가”를 반환하는 fn이 있다는 뜻입니다:
trait AsyncRead {
fn read(&mut self, buf: &mut [u8]) -> impl Future<Output = io::Result<usize>>;
}
그리고 앞서 봤듯이, “impl Trait”이 인자 위치에 있으면 제네릭 타입 파라미터로 번역됩니다:
fn use_reader(_reader: impl AsyncRead) {}
// 는 다음과 동일
fn use_reader<R: AsyncRead>(_reader: R) {}
반면 반환 위치에서는, 연관 타입 파라미터로 번역됩니다:
trait AsyncRead {
fn read(&mut self, buf: &mut [u8]) -> impl Future<Output = io::Result<usize>>;
}
// 는 다음과 동일
trait AsyncRead {
type ReadFuture: Future<Output = io::Result<usize>>;
fn read(&mut self, buf: &mut [u8]) -> Self::ReadFuture;
}
사실, Rust nightly에서는 그 트레이트를 꽤 쉽게 구현할 수 있습니다 — 이 전체 프로그램은 컴파일되고 실행됩니다:
#![feature(impl_trait_in_assoc_type)]
use std::{future::Future, io};
trait AsyncRead {
type ReadFuture: Future<Output = io::Result<usize>>;
fn read(&mut self, buf: &mut [u8]) -> Self::ReadFuture;
}
impl AsyncRead for () {
// 여기 불안정한 부분: `Self::ReadFuture`는
// `<() as AsyncRead>::read` 본문에서 추론됩니다.
type ReadFuture = impl Future<Output = io::Result<usize>>;
fn read(&mut self, _buf: &mut [u8]) -> Self::ReadFuture {
async move {
let a = [0u8; 72];
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(a[3] as _)
}
}
}
fn main() {
let mut s: () = ();
let mut buf = [0u8; 72];
let fut = s.read(&mut buf[..]);
print_type_name_and_size(&fut);
}
fn print_type_name_and_size<T>(_: &T) {
println!(
"\x1b[1m{:45}\x1b[0m \x1b[32m{} bytes\x1b[0m",
std::any::type_name::<T>(),
std::mem::size_of::<T>(),
);
}
sansioex on main [!] via 🦀 v1.83.0
❯ cargo +nightly run --quiet
<() as sansioex::AsyncRead>::read::{{closure}} 200 bytes
tower 크레이트(예: hyper를 통해)를 오래 써보신 분들이라면 이 패턴이 익숙할 거예요: 그들의 Service 트레이트는 Future 연관 타입을 갖고 있거든요:
pub trait Service<Request> {
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;
// Required methods
fn poll_ready(
&mut self,
cx: &mut Context<'_>,
) -> Poll<Result<(), Self::Error>>;
fn call(&mut self, req: Request) -> Self::Future;
}
Service를 구현할 때는 대략 세 가지 선택지가 있습니다:
Future를 구현한다type Future를 박스드 future(Pin<Box<dyn Future<...>>>)로 둔다#![feature(impl_trait_in_assoc_type)]를 쓴다갱신된 Service 트레이트 ---------------------------
그런데 Rust 1.75 기준이라면, 더 단순한 트레이트를 상상해볼 수 있겠죠:
trait Service<Request> {
type Response;
type Error;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>;
}
구현은 비교적 사소합니다 — 여기엔 아무것도 안 하는 서비스가 있어요:
impl<Request> Service<Request> for () {
type Response = ();
type Error = ();
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
async fn call(&mut self, _request: Request) -> Result<Self::Response, Self::Error> {
Ok(())
}
}
그리고 들어오는 요청을 로그로 찍는 서비스도요:
impl<S, Request> Service<Request> for LogRequest<S>
where
S: Service<Request>,
Request: std::fmt::Debug,
{
type Response = S::Response;
type Error = S::Error;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error> {
println!("{:?}", request);
self.inner.call(request).await
}
}
이 미래들(futures)은 어느 것도 박싱되지 않았고, 전부 Rust 1.75 stable에서 잘 동작합니다:
#[tokio::main]
async fn main() {
let mut service = LogRequest { inner: () };
// (참고: 서비스가 준비되었다고 가정합니다)
let fut = service.call(());
print_type_name_and_size(&fut);
fut.await.unwrap();
}
sansioex on main [!] via 🦀 v1.83.0
❯ cargo +1.75 run --quiet
<sansioex::LogRequest<()> as sansioex::Service<()>>::call::{{closure}} 32 bytes
()
하지만 현재로서는 몇 가지 제한이 따라옵니다.
이름 붙일 수 없는 타입들 ----------------
먼저, 더 이상 Service::call의 반환 타입에 “이름”을 붙일 수 없습니다.
일부 tower 서비스는 여기에 의존합니다. 예를 들면 내장 Either 서비스가 그렇죠:
enum Either<A, B> {
Left(A),
Right(B),
}
// 기존 tower Service 트레이트
impl<A, B, Request> Service<Request> for Either<A, B>
where
A: Service<Request>,
B: Service<Request, Response = A::Response, Error = A::Error>,
{
type Response = A::Response;
type Error = A::Error;
type Future = EitherResponseFuture<A::Future, B::Future>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
match self {
Either::Left(service) => service.poll_ready(cx),
Either::Right(service) => service.poll_ready(cx),
}
}
fn call(&mut self, request: Request) -> Self::Future {
match self {
Either::Left(service) => EitherResponseFuture {
kind: Kind::Left {
inner: service.call(request),
},
},
Either::Right(service) => EitherResponseFuture {
kind: Kind::Right {
inner: service.call(request),
},
},
}
}
}
그런데 이 특정 시나리오는 문제가 되지 않습니다 — 우리 간소화된 Service 트레이트로 Either를 구현하는 게 더 단순하고 자연스러워요:
// 우리 간소화된 Service 트레이트
impl<A, B, Request> Service<Request> for Either<A, B>
where
A: Service<Request>,
B: Service<Request, Response = A::Response, Error = A::Error>,
{
type Response = A::Response;
type Error = A::Error;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
match self {
Either::Left(service) => service.poll_ready(cx),
Either::Right(service) => service.poll_ready(cx),
}
}
async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error> {
match self {
Either::Left(service) => service.call(request).await,
Either::Right(service) => service.call(request).await,
}
}
}
이건 “네이티브” 트레이트 안의 async fn에겐 이점입니다.
문제는 수명(lifetime)이나 Send 여부 같은 추가 제약을 지정하려 할 때 복잡해진다는 점이죠.
수명: 복습 ---------------------- Rust는 다른 언어와 달리 수명 표기를 해 줘야 합니다.
함수는 입력에서 빌린 값을 반환할 수 있지만, 그 사실을 명시해야 해요:
fn substring<'s>(input: &'s str, start: usize, end: usize) -> &'s str {
&input[start..end]
}
이 경우에는 수명 표기를 생략해도 됩니다. 걱정할 수명이 하나뿐이고, 따라서 반환값의 수명이 입력에 의존한다는 게 분명하니까요.
이 예시에서 t는 s에서 빌립니다. 둘이 가리키는 주소 범위를 보면 알 수 있죠.
fn main() {
let s = String::from("Hello, world!");
let t = substring(&s, 0, 5);
println!(
"{:?}\n{:?}",
s.as_bytes().as_ptr_range(),
t.as_bytes().as_ptr_range()
);
}
sansioex on main [!] via 🦀 v1.83.0
❯ cargo run --quiet
0x600003bcc040..0x600003bcc04d
0x600003bcc040..0x600003bcc045
컴파일러에게 그 정보를 주었기 때문에, 위험한 짓 — 예컨대 s를 해제한 뒤에 t를 사용하는 — 을 막아줄 수 있습니다:
fn main() {
let s = String::from("Hello, world!");
let t = substring(&s, 0, 5);
drop(s);
println!("{t}");
}
sansioex on main [!] via 🦀 v1.83.0
❯ cargo check
Checking sansioex v0.1.0 (/Users/amos/bearcove/sansioex)
error[E0505]: cannot move out of `s` because it is borrowed
--> src/main.rs:36:10
|
34 | let s = String::from("Hello, world!");
| - binding `s` declared here
35 | let t = substring(&s, 0, 5);
| -- borrow of `s` occurs here
36 | drop(s);
| ^ move out of `s` occurs here
37 | println!("{t}");
| --- borrow later used here
|
help: consider cloning the value if the performance cost is acceptable
|
35 | let t = substring(&s.clone(), 0, 5);
| ++++++++
For more information about this error, try `rustc --explain E0505`.
error: could not compile `sansioex` (bin "sansioex") due to 1 previous error
입력에서 빌리지 않는 값을 반환하는 것도 가능합니다. 다만 그 사실을 말해줘야 합니다:
fn substring(input: &str, start: usize, end: usize) -> String {
input[start..end].to_string()
}
여기서 반환값은 “소유”하고 있어서, 우리의 use-after-free는 더 이상 use-after-free가 아닙니다:
fn main() {
let s = String::from("Hello, world!");
let t = substring(&s, 0, 5);
println!(
"{:?}\n{:?}",
s.as_bytes().as_ptr_range(),
t.as_bytes().as_ptr_range()
);
// 이제 괜찮아요, `t`는 자기 메모리를 가리킵니다!
drop(s);
println!("{t}");
}
sansioex on main [!] via 🦀 v1.83.0
❯ cargo run
Compiling sansioex v0.1.0 (/Users/amos/bearcove/sansioex)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s
Running `target/debug/sansioex`
0x600000a54000..0x600000a5400d
0x600000a54010..0x600000a54015
Hello
숨은 캡처 --------------- Async 함수는 또 다른 층위를 더합니다. “부분 실행” 상태일 수 있거든요.
앞서, 이 둘을 같다고 했죠:
// 기존 tower Service 트레이트
trait Service<Request> {
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;
fn call(&mut self, request: Request) -> Self::Future;
}
이것과:
// 우리 간소화된 Service 트레이트
trait Service<Request> {
type Response;
type Error;
async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>;
}
하지만 둘은 동등하지 않습니다!
기존 tower Service 트레이트에서는, 반환되는 Future가 self에서 빌릴 수 없습니다!
i32 타입(요청도 i32)을 위한 다음 구현은, self를 더해서 반환하려 하지만 컴파일되지 않습니다:
#![feature(impl_trait_in_assoc_type)]
impl Service<i32> for i32 {
type Response = i32;
type Error = ();
type Future = impl Future<Output = Result<Self::Response, Self::Error>>;
fn call(&mut self, request: i32) -> Self::Future {
async move { Ok(*self + request) }
}
}
sansioex on main [✘!+] via 🦀 v1.85.0-nightly
❯ cargo +nightly check --quiet
error[E0700]: hidden type for `<i32 as Service<i32>>::Future` captures lifetime that does not appear in bounds
--> src/main.rs:19:9
|
16 | type Future = impl Future<Output = Result<Self::Response, Self::Error>>;
| --------------------------------------------------------- opaque type defined here
17 |
18 | fn call(&mut self, request: i32) -> Self::Future {
| --------- hidden type `{async block@src/main.rs:19:9: 19:19}` captures the anonymous lifetime defined here
19 | async move { Ok(*self + request) }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
For more information about this error, try `rustc --explain E0700`.
error: could not compile `sansioex` (bin "sansioex") due to 1 previous error
이걸 가능하게 하려면, 연관 타입을 제네릭(수명에 대해)으로 만들어야 합니다:
pub trait Service<Request> {
type Response;
type Error;
type Future<'a>: Future<Output = Result<Self::Response, Self::Error>> + 'a
where
Self: 'a;
fn call(&mut self, request: Request) -> Self::Future<'_>;
}
이 기능이 GATs(“generic associated types”)이고, Rust 1.65에서 도입되었습니다 — 그래서 tower의 Service 타입에는 아직 없죠.
만약 Service 타입이 위처럼 GATs를 썼다면, self에서 빌릴 수 있습니다. 예를 들면:
impl Service<i32> for i32 {
type Response = i32;
type Error = ();
type Future<'a> = impl Future<Output = Result<Self::Response, Self::Error>> + 'a;
fn call(&mut self, request: i32) -> Self::Future<'_> {
async move { Ok(*self + request) }
}
}
하지만 우리는 그럴 수 없습니다. tower의 Service 트레이트가 그런 식으로 정의되어 있지 않거든요. self에서 빌리지 않는 Future를 원합니다. 그래서 우리는 future를 반환하기 _전_에 self로 필요한 일을 다 해야만 하죠.
이 어색하고 억지 예시에서는, async 블록 전에 self를 역참조해야 합니다:
sansioex on main [!] via 🦀 v1.85.0-nightly
❯ gwd
diff --git a/src/main.rs b/src/main.rs
index 9fb8ffb..bcdbed2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -16,7 +16,8 @@ impl Service<i32> for i32 {
type Future = impl Future<Output = Result<Self::Response, Self::Error>>;
fn call(&mut self, request: i32) -> Self::Future {
- async move { Ok(*self + request) }
+ let this = *self;
+ async move { Ok(this + request) }
}
}
…그리고 이건 아직 안정화되지 않았습니다 — #![feature(impl_trait_in_assoc_type)]를 쓰고 있으니까요.
이에 비해, 우리 간소화된 Service 타입은 Rust 1.75 stable에서 동작하고, self에서 빌리는 것도 허용합니다:
trait Service<Request> {
type Response;
type Error;
async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>;
}
impl Service<i32> for i32 {
type Response = i32;
type Error = ();
async fn call(&mut self, request: i32) -> Result<Self::Response, Self::Error> {
Ok(*self + request)
}
}
#[tokio::main]
async fn main() {
let mut service: i32 = 1990;
let res = service.call(34).await.unwrap();
println!("Result: \x1b[1;32m{res}\x1b[0m");
}
sansioex on main [!+] via 🦀 v1.83.0
❯ cargo run --quiet
Result: 1990
하지만 이건 호환성 파괴적인 변화이기도 합니다. service.call()을 연달아 여러 번 호출해, 서로 분리된 소유된 futures를 얻은 뒤 executor에 스폰하는 것은 tower Service 트레이트의 기능이에요.
우리 간소화된 Service 트레이트로는, 동시에 여러 요청을 처리할 수 없습니다:
#[tokio::main]
async fn main() {
let mut service: i32 = 2024;
let fut1 = service.call(-34);
let fut2 = service.call(-25);
let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap();
println!("Got responses: {response1:?}, {response2:?}");
}
sansioex on main [!] via 🦀 v1.83.0
❯ cargo check --quiet
error[E0499]: cannot borrow `service` as mutable more than once at a time
--> src/main.rs:22:16
|
21 | let fut1 = service.call(-34);
| ------- first mutable borrow occurs here
22 | let fut2 = service.call(-25);
| ^^^^^^^ second mutable borrow occurs here
23 |
24 | let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap();
| ---- first borrow later used here
For more information about this error, try `rustc --explain E0499`.
error: could not compile `sansioex` (bin "sansioex") due to 1 previous error
수명 제한 완화 ------------------------
그리고 바로 그 이유로, 현재 rustc는 공개 트레이트에 async fn이 있으면 경고를 띄웁니다: 그 문법으로는 추가 제약을 지정할 수 없기 때문이죠.
예를 들어 우리 간소화된 Service 트레이트를 원래 tower 트레이트에 더 가깝게 만들고 싶다고 합시다: 반환 future가 'static이어야 한다고 할 수 있겠죠.
sansioex on main [!] via 🦀 v1.83.0
❯ gwd
diff --git a/src/main.rs b/src/main.rs
index 6d5223f..7947a70 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,8 +1,13 @@
+use std::future::Future;
+
pub trait Service<Request> {
type Response;
type Error;
- async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>;
+ fn call(
+ &mut self,
+ request: Request,
+ ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static;
}
impl Service<i32> for i32 {
하지만 그러면 구현이 깨집니다:
sansioex on main [+] via 🦀 v1.83.0
❯ cargo c
Checking sansioex v0.1.0 (/Users/amos/bearcove/sansioex)
error[E0477]: the type `impl Future<Output = Result<<i32 as Service<i32>>::Response, <i32 as Service<i32>>::Error>>` does not fulfill the required lifetime
--> src/main.rs:17:5
|
17 | async fn call(&mut self, request: i32) -> Result<Self::Response, Self::Error> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
note: type must satisfy the static lifetime as required by this binding
--> src/main.rs:10:70
|
10 | ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static;
| ^^^^^^^
For more information about this error, try `rustc --explain E0477`.
error: could not compile `sansioex` (bin "sansioex") due to 1 previous error
…왜 그런지는 딱히 명확하지 않죠.
제 생각에는, async fn 문법을 고수하면서 이 문제를 해결하긴 어려워 보입니다: 구현에서도 impl Future를 반환하도록 바꿔야 해요:
sansioex on main [!+] via 🦀 v1.83.0
❯ gwd
diff --git a/src/main.rs b/src/main.rs
index 7947a70..b57b520 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14,8 +14,12 @@ impl Service<i32> for i32 {
type Response = i32;
type Error = ();
- async fn call(&mut self, request: i32) -> Result<Self::Response, Self::Error> {
- Ok(*self + request)
+ fn call(
+ &mut self,
+ request: i32,
+ ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static {
+ let this = *self;
+ async move { Ok(this + request) }
}
}
이 변경을 하면 동시에 여러 요청을 처리할 수 있게 됩니다:
#[tokio::main]
async fn main() {
let mut service: i32 = 2024;
let fut1 = service.call(-34);
let fut2 = service.call(-25);
let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap();
println!("Got responses: {response1:?}, {response2:?}");
}
sansioex on main [!+] via 🦀 v1.83.0
❯ cargo run --quiet
Got responses: 1990, 1999
Send-성 -------- 게다가… 토키오 런타임에 스폰까지 할 수 있는 것처럼 보이네요?
sansioex on main [!] via 🦀 v1.83.0
❯ gwd
diff --git a/src/main.rs b/src/main.rs
index 16e6549..ae009d2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -27,8 +27,8 @@ impl Service<i32> for i32 {
async fn main() {
let mut service: i32 = 2024;
- let fut1 = service.call(-34);
- let fut2 = service.call(-25);
+ let fut1 = tokio::spawn(service.call(-34));
+ let fut2 = tokio::spawn(service.call(-25));
let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap();
println!("Got responses: {response1:?}, {response2:?}");
sansioex on main [!] via 🦀 v1.83.0
❯ cargo run --quiet
Got responses: Ok(1990), Ok(1999)
어라. 그런데 tokio::spawn은 Send가 필요하잖아요!
맞습니다, 필요하죠!
// tokio 소스에서 발췌
#[track_caller]
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
// ✂️
}
…그런데 Service 정의 어디에도 반환 future가 Send여야 한다는 말이 없는데요…
그렇죠! 무슨 일이 일어나고 있냐면, 컴파일러가 <i32 as Service<i32>>::call의 구체 타입을 보고 있는 겁니다.
으악. 설마 그냥 우연히 Send인 걸 알고 있는 건가요?
맞아요! 만약 그렇지 않았다면, 오류가 났을 겁니다:
sansioex on main [!] via 🦀 v1.83.0
❯ gwd
diff --git a/src/main.rs b/src/main.rs
index ae009d2..df060ba 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -19,7 +19,11 @@ impl Service<i32> for i32 {
request: i32,
) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static {
let this = *self;
- async move { Ok(this + request) }
+ let something_not_send = std::rc::Rc::new(());
+ async move {
+ let _woops = something_not_send;
+ Ok(this + request)
+ }
}
}
sansioex on main [!] via 🦀 v1.83.0
❯ cargo check --quiet
error: future cannot be sent between threads safely
--> src/main.rs:34:29
|
34 | let fut1 = tokio::spawn(service.call(-34));
| ^^^^^^^^^^^^^^^^^ future created by async block is not `Send`
|
= help: within `impl Future<Output = Result<<i32 as Service<i32>>::Response, <i32 as Service<i32>>::Error>> + 'static`, the trait `Send` is not implemented for `Rc<()>`, which is required by `impl Future<Output = Result<<i32 as Service<i32>>::Response, <i32 as Service<i32>>::Error>> + 'static: Send`
note: captured value is not `Send`
--> src/main.rs:24:26
|
24 | let _woops = something_not_send;
| ^^^^^^^^^^^^^^^^^^ has type `Rc<()>` which is not `Send`
note: required by a bound in `tokio::spawn`
--> /Users/amos/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.42.0/src/task/spawn.rs:168:21
|
166 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
| ----- required by a bound in this function
167 | where
168 | F: Future + Send + 'static,
| ^^^^ required by this bound in `spawn`
✂️
앞선 i32용 Service 구현(우연히 Send이긴 했죠)에서도 같은 문제를 볼 수 있습니다. 만약 어떤 Service든 받는 제네릭 함수 안에서 스폰을 한다면요:
#[tokio::main]
async fn main() {
let mut service: i32 = 2024;
do_the_spawning(&mut service).await;
}
async fn do_the_spawning<S>(service: &mut S)
where
S: Service<i32>,
{
let fut1 = tokio::spawn(service.call(-34));
let fut2 = tokio::spawn(service.call(-25));
let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap();
println!("Got responses: {response1:?}, {response2:?}");
}
Response와 Error에 경계를 더할 수는 있습니다:
@@ -32,6 +32,8 @@ async fn main() {
async fn do_the_spawning<S>(service: &mut S)
where
S: Service<i32>,
+ S::Response: Send + std::fmt::Debug + 'static,
+ S::Error: Send + std::fmt::Debug + 'static,
{
let fut1 = tokio::spawn(service.call(-34));
let fut2 = tokio::spawn(service.call(-25));
이러면 오류 수가 조금 줄긴 합니다… 하지만 핵심 문제는 그대로예요: 그 future가 Send라고 선언되어 있지 않다는 것.
error[E0277]: `impl Future<Output = Result<<S as Service<i32>>::Response, <S as Service<i32>>::Error>> + 'static` cannot be sent between threads safely
--> src/main.rs:38:29
|
38 | let fut1 = tokio::spawn(service.call(-34));
| ------------ ^^^^^^^^^^^^^^^^^ `impl Future<Output = Result<<S as Service<i32>>::Response, <S as Service<i32>>::Error>> + 'static` cannot be sent between threads safely
| |
| required by a bound introduced by this call
|
= help: the trait `Send` is not implemented for `impl Future<Output = Result<<S as Service<i32>>::Response, <S as Service<i32>>::Error>> + 'static`
note: required by a bound in `tokio::spawn`
--> /Users/amos/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.42.0/src/task/spawn.rs:168:21
|
166 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
| ----- required by a bound in this function
167 | where
168 | F: Future + Send + 'static,
| ^^^^ required by this bound in `spawn`
그리고 이건 호출부(예: do_the_spawning의 시그니처)에서 할 수 있는 일이 아닙니다.
그 결정은 트레이트 선언부에서 이루어져요. 다행히 반환 위치의 impl Trait이라면, 최소한 이렇게 고칠 수는 있습니다:
sansioex on main [!⇡] via 🦀 v1.83.0
❯ gwd
diff --git a/src/main.rs b/src/main.rs
index b32c487..3ff8f6f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,7 +7,7 @@ pub trait Service<Request> {
fn call(
&mut self,
request: Request,
- ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static;
+ ) -> impl Future<Output = Result<Self::Response, Self::Error>> + Send + 'static;
}
impl Service<i32> for i32 {
하지만 우리의 트레이트는 원래 tower Service 트레이트보다 분명 덜 유연합니다!
후기 --------- 저는 개인적으로 async Rust의 미래가 무척 기대됩니다.
async WG는 현재의 빈틈을 메우는 데 도움이 되는 크레이트들을 내놓고 있어요:
Send 버전과 non-Send 버전의 트레이트를 선언할 수 있게 해줍니다async fn이 있어도 동적 디스패치를 쓸 수 있게 해줍니다궁극적으로, 제가 손꼽아 기다리는 건 dyn async traits입니다 — 그게 착륙하는 순간, 저는 Bluesky나 Mastodon에서 제일 먼저 신나게 떠들고 있을 거예요!
(JavaScript가 있어야 보입니다. 아니면 제 사이트가 망가졌거나)
당신을 위한 또 다른 글: