비동기 Rust가 초래할 수 있는 메모리, 성능, 코드 크기 문제의 원인과 이를 줄이기 위한 실용적인 방법을 살펴봅니다.
비동기 Rust는 놀랍지만, 결코 완벽하지는 않습니다. 이 글에서는 현재의 어려움과 가능한 해결책을 함께 살펴보겠습니다.
비동기 Rust는 정말 훌륭합니다. 수동으로 작성한 수십 개의 상태 머신을 직접 관리하지 않아도, 다른 코드와 동시에 실행될 수 있는 코드를 작성할 수 있게 해주기 때문입니다.
이것은 자원 사용 측면에서 매우 큰 장점입니다. 소켓에서 데이터를 기다리는 동안 코드가 바쁘게 돌거나 스레드를 막지 않습니다. 대신 다른 태스크가 실행되어 유용한 일을 할 수 있습니다. 그리고 실행할 태스크가 없다면, 프로세서는 아무 일도 할 필요가 없어 전력을 절약할 수 있습니다.
비동기 Rust는 숙련되게 다루려면 내부 동작을 알아야 하므로 때때로 꽤 어렵게 느껴질 수 있습니다. 물론 배우는 데 시간을 들이면, 결국 이 부분은 자연스럽게 해결됩니다.
그래서 비동기에 어느 정도 익숙해지면 여러 곳에서 이를 사용하기 시작합니다. 그러다 시간이 지나고, 특히 프로젝트가 커질수록 뭔가 이상하다는 점을 느끼게 됩니다.
그 "이상한 점"이 무엇인지는 무엇을 만들고 있는지에 따라 달라집니다.
이러한 흔한 문제들은 모두 같은 원인에서 비롯됩니다. 바로 비동기 팽창(async bloat)입니다.
이 문제들 중 일부는 예상 가능한 것입니다. 비동기가 더 느리거나 더 커지는 모든 상황이 실제 팽창은 아닙니다. async/await를 사용함으로써 컴파일러에게 상태 머신을 생성해 달라고 명시적으로 요청하고 있으므로, 비동기 코드가 블로킹 코드와 정확히 같은 특성을 가지기를 기대하는 것은 공정하지 않습니다.
예시로 비동기 코드를 하나 보고, 이를 손으로 작성한 상태 머신으로 바꿔 비교해보겠습니다.
async fn foo(num: i32) -> i32 {
bar().await;
let result = quux(num).await;
result * 2
}
여기에는 두 개의 await 지점이 있는 비동기 함수가 있습니다. 이를 enum 기반 상태 머신으로 모델링할 수 있습니다.
// 사용자가 호출하는 함수. future 인스턴스를 반환한다
fn foo(num: i32) -> FooFut {
// 우리는 future를 단지 구성만 한다. 실제 첫 작업은 첫 번째 poll 이후에만 일어난다
FooFut::Unresumed(num)
}
// 우리의 future 타입
enum FooFut {
Unresumed(i32),
Suspend0(i32, BarFut),
Suspend1(QuuxFut),
Returned,
}
impl Future for FooFut {
type Output = i32;
// 간결함을 위해 pinning은 무시한다
fn poll(self: &mut Self, cx: &mut Context<'_>) -> Poll<Self::Output> {
loop {
match self {
Self::Unresumed(num) => {
// bar future를 가져온다
let bar = bar();
*self = Self::Suspend0(num, bar);
}
Self::Suspend0(num, bar) => {
// bar.await
if bar.poll(cx).is_pending() {
return Poll::Pending;
}
// quux future를 가져온다
let quux = quux(num);
*self = Self::Suspend1(quux);
}
Self::Suspend1(quux) => {
// quux.await
return match quux.poll(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(result) => {
*self = Self::Returned;
// 끝났지만, 2를 곱하는 작업은 여기서 한다
Poll::Ready(result * 2)
},
};
}
Self::Returned => {
panic!("Polled after future has returned ready");
}
}
}
}
}
합리적인 구현을 기준으로 보더라도, 이것은 블로킹 버전보다 훨씬 많은 코드입니다. 상태를 관리해야 하고, bar와 quux를 여러 번(심지어 무한히) poll해야 할 수도 있습니다.
_비동기 Rust를 사용할 때 바로 이것을 요청하고 있는 것_입니다.
그렇다면 비동기를 사용하는 것이 매우 불리해질 수 있는 경우와, 어떤 대안이 있는지 살펴보겠습니다.
이 함수는 무엇을 할까요?
async fn foo() -> i32 {
5
}
5를 반환하죠?
사실 아닙니다. 이것은 완료될 때까지 poll하면 5를 반환하는 상태 머신을 반환합니다. 물론 이런 코드를 일부러 작성하는 사람은 거의 없을 것입니다. 그런데도 왜 실제 코드베이스에는 이런 것이 나타날까요?
이런 일은 어떤 추상화가 때로는 비동기여야 하지만 항상 그렇지는 않은 trait에서 특히 자주 발생합니다.
이를 디스크 또는 데이터베이스에서 설정을 불러올 수 있는 구성 예제로 설명해보겠습니다. 하지만 어떤 구현은 아무것도 로드하지 않고 단지 값을 반환할 수도 있습니다.
struct Config { /*...*/ }
trait ConfigLoader {
async fn load(&mut self) -> Result<Config, Error>;
}
impl ConfigLoader for FileLoader { /*...*/ }
impl ConfigLoader for DbLoader { /*...*/ }
impl ConfigLoader for DefaultLoader {
async fn load(&mut self) -> Result<Config, Error> {
Ok(Config::new())
}
}
async fn run_system(loader: impl ConfigLoader) -> Result<(), Error> {
// ...
let config = loader.load().await?;
// ...
}
즉 DefaultLoader가 run_system에 전달되더라도, 우리는 여전히 상태 머신을 poll하고 있는 것입니다. 이를 위해 더 많은 cpu, ram, 코드 크기가 소모됩니다.
그렇다면 이것을 어떻게 고칠 수 있을까요?
핵심은 상태 머신을 없애는 방법을 찾는 것입니다.
한 가지 방법은 loader 매개변수를 option으로 바꾸는 것입니다. loader가 없으면 함수는 기본값을 사용하면 됩니다. 또 다른 방법은 구조는 그대로 두되, 실제로는 상태 머신이 거의 없는 훨씬 단순한 future를 수동으로 제공하는 것입니다.
impl ConfigLoader for DefaultLoader {
// 반환 타입을 직접 제어하기 위해 수동으로 디슈가링한 async 함수
fn load(&mut self) -> impl Future<Output = Result<Config, Error>> {
// std의 `Ready` 타입 사용
std::future::ready(Ok(Config::new()))
}
}
ready는 poll되면 즉시 값을 반환합니다. 이것은 실제 상태 머신보다 훨씬 작습니다. 제 동료 Wouter가 PR을 올린 새로운 clippy lint도 바로 이런 경우를 제안합니다.
Rust에서는 추상화를 매우 좋아합니다. 정말 훌륭하죠! 임베디드에서도 하드웨어 추상화를 위해 이를 많이 사용합니다. 하지만 비동기 Rust에서는 그 대가가 따를 수 있습니다.
embedded-hal-async의 I2c trait를 살펴봅시다.
/// Async I2c.
pub trait I2c<A: AddressMode = SevenBitAddress>: ErrorType {
/* 제공되는 메서드는 생략 */
async fn transaction(
&mut self,
address: A,
operations: &mut [Operation<'_>],
) -> Result<(), Self::Error>;
}
이 trait는 거의 모든 하드웨어에서 동작하는 깔끔한 인터페이스입니다. 어떤 하드웨어에서든 I2c 트랜잭션을 수행할 수 있게 해줍니다. 모든 하드웨어 추상화 계층(HAL)은 각자의 I2c 드라이버에 대해 이 trait를 구현합니다. embassy-stm32의 구현은 다음과 같습니다(간결함을 위해 일부 수정).
impl<'d, IM: MasterMode> I2c for I2c<'d, Async, IM> {
async fn transaction(
&mut self,
address: u8,
operations: &mut [Operation<'_>],
) -> Result<(), Self::Error> {
self.transaction(address, operations).await
}
}
I2c 드라이버는 이미 transaction 메서드를 가지고 있고, trait 구현은 그 기능을 사용하기 위해 이를 호출합니다.
그렇다면 이 구현은 무엇을 할까요?
의사 코드:
enum TraitTransactionFut {
Unresumed(self, address, operations),
Polling(DriverTransactionFut), // 드라이버의 future를 poll 중
Returned,
}
그렇습니다. 우리가 요청한 그대로, 또 다른 상태 머신을 호출하는 상태 머신을 생성합니다. 필요한 것보다 상태 머신이 더 많습니다!
우리는 팽창을 줄이고 싶으므로, 상태 머신도 더 적어야 합니다. 따라서 컴파일러에게 우리가 실제로 원하는 것을 말해줘야 합니다. 이 함수는 async일 필요가 없고, 실제로 poll되어야 하는 상태 머신을 그대로 반환하면 됩니다.
impl<'d, IM: MasterMode> I2c for I2c<'d, Async, IM> {
// 이제 더 이상 async가 아님
// trait 쪽이 async를 사용하더라도 Rust에서는 이것이 허용된다
fn transaction(
&mut self,
address: u8,
operations: &mut [Operation<'_>],
) -> impl Future<Output = Result<(), Self::Error>> {
// ^^^^^^^^^^^ 이제 impl Future를 반환한다
self.transaction(address, operations)
// 더 이상 await가 없다 ^^^^^^
}
}
이제 우리는 단순히 future를 전달만 하므로 추가 상태 머신이 생성되지 않습니다.
완전한 전달만 하는 경우에는 단순하지만, 함수에 await 지점 전후로 실행되어야 하는 '전처리(preamble)' 또는 '후처리(postamble)'가 있으면 조금 더 어렵습니다.
예를 들어:
async fn foo() -> i32 {
let a = quux(); // <- 비동기 아님
let num = bar(a).await;
num * 2
}
여기서는 같은 기법을 정확히 똑같이 적용할 수는 없습니다. 적어도 널리 사용되는 futures 크레이트가 없다면 말입니다. 이 크레이트는 후처리에 도움이 되는 메서드를 제공합니다.
use futures::future::FutureExt;
fn foo() -> impl Future<Output = i32> {
let a = quux();
bar().map(|num| num * 2)
}
여기서도 다시 future를 전달하지만, 출력값을 map합니다. 이 덕분에 추가 상태 머신이 여전히 필요하지 않습니다.
FutureExt에는 비동기 팽창을 줄이는 데 도움이 되는 유용한 확장 메서드가 더 많이 있습니다.
다만 여기서는 전처리 실행의 동작이 바뀌었다는 점에 주의해야 합니다! 보통 future는 지연 평가됩니다. 즉 quux는 future가 poll되기 전까지 실행되지 않습니다. 하지만 변환된 버전에서는 quux가 즉시 실행됩니다. 거의 대부분의 경우 문제가 되지 않지만, 알아두어야 할 점입니다.
Rust 컴파일러의 백엔드인 LLVM은 매우 똑똑합니다. 그래서 많은 코드 패턴이 자동으로 최적화됩니다.
예를 들어:
pub fn process_command() {
match get_command() {
CommandId::A => send_response(123),
CommandId::B => send_response(456),
}
}
최적화를 켜고 컴파일하면, 컴파일러는 다음과 같은 어셈블리를 생성합니다.
; x86-64
process_command:
push rax
call qword ptr [rip + get_command@GOTPCREL]
test al, al ; enum에 대해 match
mov eax, 456 ; B 값 준비
mov edi, 123 ; A 값 준비
cmovne edi, eax ; discriminant가 1(B)이면 B 값 사용
pop rax
; 인자로 edi를 사용해 send_response 호출
jmp qword ptr [rip + send_response@GOTPCREL]
정말 좋은 코드입니다! 값을 미리 계산해두고, send_response는 한 번만 호출합니다.
이제 이것을 비동기로 바꾸고 살펴보겠습니다.
pub async fn process_command() {
match get_command().await {
CommandId::A => send_response(123).await,
CommandId::B => send_response(456).await,
}
}
이 코드는 동일하지만 비동기로 만들었습니다. 생성된 future의 poll 구현에 대한 어셈블리는 어떨까요? (더 명확하게 보기 위해 -O2)
example::run2::h225245dd7a7d0876:
push rbx
mov rbx, rdi
movzx eax, byte ptr [rdi]
lea rcx, [rip + .LJTI2_0] ; 점프 테이블 포인터 로드
movsxd rax, dword ptr [rcx + 4*rax] ; future 상태에 따라 점프 주소 로드
add rax, rcx
jmp rax ; 점프 수행
.LBB2_4: ; 시작 전 상태
mov byte ptr [rbx + 1], 0
.LBB2_5: ; get_command
lea rdi, [rbx + 1]
call example::get_command::{{closure}}::h8b5d742a2e42c0db
jmp .LBB2_8
.LBB2_7: ; send_response A 또는 B
lea rdi, [rbx + 4]
call example::send_response::{{closure}}::h3ca77514be95ddc4
jmp .LBB2_8
.LBB2_1:
; ... panic
.LBB2_2:
; ... panic
.LBB2_11: ; send_response B 또는 A
lea rdi, [rbx + 4]
call example::send_response::{{closure}}::h3ca77514be95ddc4
.LBB2_8: ; 일종의 손상 방지 가드
ud2
jmp .LBB2_10
jmp .LBB2_10
.LBB2_10: ; 일종의 손상 방지 가드
mov byte ptr [rbx], 2
mov rdi, rax
call _Unwind_Resume@PLT
.LJTI2_0: ; 점프 테이블
.long .LBB2_4-.LJTI2_0
.long .LBB2_1-.LJTI2_0
.long .LBB2_2-.LJTI2_0
.long .LBB2_5-.LJTI2_0
.long .LBB2_7-.LJTI2_0
.long .LBB2_11-.LJTI2_0
이런... 무슨 일이 일어나는지 이해하기 쉽도록 주석을 달고, 더 분명하게 보이도록 panic 경로는 제거했습니다.
어쨌든 점프 테이블을 보면 상태가 6개라는 것을 알 수 있습니다. 이 중 3개는 기본적으로 존재하므로, 나머지 3개는 우리 코드에서 추가된 것입니다. 즉 각 await 지점마다 상태가 하나씩 생겼고, send_response 호출도 중복되었습니다. 호출이 1번이 아니라 2번입니다.
대신 코드를 이렇게 바꿀 수 있습니다.
pub async fn process_command() {
let response = match get_command().await {
CommandId::A => 123,
CommandId::B => 456,
};
send_response(response).await;
}
이렇게 하면 생성되는 상태 중 하나를 없앨 수 있습니다. 생성되는 어셈블리는 매우 비슷하지만 상태가 하나 줄어듭니다.
별것 아닌 것처럼 들릴 수 있습니다. 원래 버전은 어셈블리 58줄이었고, 최적화한 버전은 52줄로 약 11.5% 감소했습니다. 하지만 최적화는 누적된다는 점을 기억해야 합니다. 이 최적화된 버전 덕분에 컴파일러가 다른 부분도 더 잘 최적화할 수 있다면 영향은 더 커질 수 있습니다. 특히 더 큰 비동기 함수에서는 더욱 그렇습니다.
코드는 동작하기 위해 데이터를 필요로 하므로, 생성된 상태 머신은 필요한 변수들을 저장해야 합니다.
다행히도 상태 머신에는 실제로 필요한 데이터만 캡처됩니다. 즉 처음에는 async 블록이 캡처한 변수나 async fn의 함수 매개변수가 들어갑니다. 그 이후에는 await 지점을 넘어서도 유지되어야 하는 변수만 상태 머신에 저장됩니다.
변수들이 상태 머신에 할당되는 방식은 꽤 비효율적이지만, 다행히 이 문제를 다루는 Rust PR이 진행 중입니다.
그래도 future 안에 저장하는 데이터는 우리가 계속 의식해야 합니다. 그렇지 않으면 일이 커질 수 있기 때문입니다(컴파일러가 최적으로 처리하더라도 마찬가지입니다). 이것 역시 '요청한 대로 얻는다'는 같은 맥락입니다.
다음 코드를 생각해봅시다.
async fn foo_big(mut buffer: [u8; 1024]) {
let result = fill_async(&mut buffer).await;
println!("{result}");
}
async fn foo_small(buffer: &mut [u8; 1024]) {
let result = fill_async(buffer).await;
println!("{result}");
}
async fn fill_async(buffer: &mut [u8]) -> u8 {
todo!()
}
fn main() {
println!(
"big: {}, small: {}",
std::mem::size_of_val(&foo_big([0; 1024])),
std::mem::size_of_val(&foo_small(&mut [0; 1024]))
)
}
출력이 무엇일 것 같나요?
64비트 Linux에서 Rust 1.94로 컴파일하면 다음이 출력됩니다: big: 2080, small: 40.
즉 buffer를 한 번만 저장하는 대신 두 번 저장할 공간이 할당된다는 뜻입니다.
두 비동기 함수는 결과적으로 같은 동작을 하지만, big 버전은 훨씬 더 많은 메모리를 사용합니다. 그뿐만 아니라, 그 메모리를 계속 들고 다녀야 합니다. 레지스터에 들어갈 데이터는 줄고, 더 많은 데이터를 memcpy해야 하며, 최적화기가 활약할 기회도 줄어듭니다.
이 예시는 바이트 배열을 사용하지만, 큰 struct에서도 같은 문제가 발생합니다.
따라서 future를 다룰 때는 큰 변수를 move로 넘기기보다 참조를 전달하세요.
지금까지 비동기 Rust가 가져올 수 있는 팽창에 맞서는 몇 가지 팁을 정리해봤습니다.
요약하면 다음과 같습니다.
-> impl Future 사용하기왜 컴파일러가 대신 해주지 않고 직접 비동기 코드의 군더더기를 줄여야 하느냐고 궁금하다면, 당신만 그런 것은 아닙니다! 이것은 이미 한동안 알려진 문제였습니다.
얼마 전 저는 비동기를 다루는 컴파일러 코드를 읽어볼 시간을 냈습니다. 놀랍게도 비동기 Rust는 MVP 상태를 한 번도 벗어난 적이 없다는 사실을 발견했습니다.
하지만 저는 계획을 세웠습니다! 그게 무엇인지는 다음 블로그 글에서 확인해 주세요.
그 계획을 실제로 실행할 기회를 얻기 전까지는, 적어도 이제 여러분도 자신의 코드에서 비동기 Rust를 어떻게 개선할 수 있는지 알게 되었습니다.
(우리의 서비스)
코드 크기 팽창은 시간이 지나면서 생길 수도 있고, 비동기 Rust의 복잡성 때문에 발생할 수도 있습니다.
팽창 때문에 프로젝트 속도가 느려지고 있다면, 저희가 도와드릴 수 있습니다!