---
title: "비동기 러스트, 아직 MVP 상태에서 못 벗어났다는 꽤 아픈 지적"
published: 2026-05-05T07:26:03.000Z
canonical: https://jeff.news/article/2183
---
# 비동기 러스트, 아직 MVP 상태에서 못 벗어났다는 꽤 아픈 지적

글쓴이는 비동기 러스트(async Rust)가 서버와 마이크로컨트롤러를 모두 커버하는 멋진 모델이지만, 컴파일러가 만드는 상태 기계가 아직 너무 비싸다고 지적한다. 특히 임베디드나 WASM처럼 바이너리 크기가 중요한 환경에서는 불필요한 panic 경로, 상태, 중복 MIR이 실제 비용으로 튄다.

## async Rust의 문제는 추상화가 아니라 ‘생성물’에 있음

- 글쓴이는 async Rust 자체를 싫어하는 게 아님. 오히려 서버부터 작은 마이크로컨트롤러까지 executor에 덜 묶인 비동기 코드를 쓸 수 있다는 점은 엄청 좋다고 봄
  - 문제는 “zero cost abstraction”이라고 부르기엔 컴파일러가 만드는 코드가 아직 꽤 비싸다는 것
  - 데스크톱이나 서버에서는 메모리와 CPU가 넉넉해서 덜 보이지만, 임베디드에서는 바이너리 한 바이트도 바로 비용임

- 예시로 든 코드는 아주 단순함
  - `foo()`는 `async { 5 }`를 반환함
  - `bar()`는 `foo().await + foo().await`를 계산함
  - 그런데 `bar`의 MIR은 360줄까지 늘어남. 같은 일을 비동기 없이 하면 23줄임. 네, 좀 세다

- async는 MIR 단계에서 상태 기계로 변환됨
  - `bar`에는 await가 두 번 있으니 `Suspend0`, `Suspend1` 같은 상태가 생기는 건 납득 가능함
  - 그런데 여기에 `Unresumed`, `Returned`, `Panicked` 상태까지 기본으로 따라붙음
  - 완료된 Future를 다시 `poll`하면 panic해야 하고, panic 후 `catch_unwind`로 잡힌 Future를 다시 poll하지 못하게 막기 위한 상태도 필요하다는 논리임

> [!IMPORTANT]
> 글쓴이가 본격적으로 찌르는 지점은 “안전성 때문에 UB만 피하면 되는데, 꼭 panic까지 해야 하냐”는 부분임. 이 panic 경로가 LLVM 최적화를 막고 바이너리 크기를 키운다는 게 핵심 주장임.

## panic 경로 하나가 생각보다 비쌈

- 현재 러스트 Future는 완료된 뒤 다시 poll되면 panic함
  - 하지만 `Future::poll`의 안전성 계약에서 중요한 건 UB를 만들지 않는 것임
  - 글쓴이는 release 빌드에서는 완료 후 다시 poll됐을 때 panic 대신 `Poll::Pending`을 반환하는 옵션을 제안함

- 직접 컴파일러를 고쳐 실험해보니 임베디드 펌웨어 바이너리 크기가 2-5% 줄었다고 함
  - 임베디드에서 2-5%면 그냥 미세 최적화가 아니라 꽤 큰 숫자임
  - `overflow-checks = false`처럼 debug에서는 잘못된 동작을 빨리 드러내고, release에서는 크기를 줄이는 스위치로 만들자는 제안임

- `panic=abort`일 때는 `Panicked` 상태 자체를 없앨 수 있을지도 본다고 함
  - unwind로 복구하는 모델이 아니라 바로 abort한다면, panic 이후 재poll 방지 상태가 필요 없을 수 있다는 얘기
  - 다만 이건 영향 범위를 더 봐야 한다고 선을 그음

## await가 없어도 상태 기계를 만드는 건 너무 과함

- `async { 5 }`처럼 await가 전혀 없는 Future도 현재는 상태 기계로 만들어짐
  - 수동으로 구현하면 `poll()`에서 그냥 `Poll::Ready(5)`를 반환하면 끝임
  - 하지만 컴파일러 출력에는 `Unresumed`, `Returned`, `Panicked` 상태와 discriminant switch가 들어감

- 글쓴이는 await 없는 async 블록에서는 상태 기계 없이 항상 Ready를 반환하도록 컴파일러를 고쳐봄
  - 결과는 임베디드 바이너리 크기 0.2% 절감
  - 크진 않지만 구현 난이도 대비 챙길 만한 최적화라는 평가

- 이 변경은 동작을 살짝 바꿈
  - 현재는 완료 후 다시 poll하면 panic함
  - 바뀐 모델에서는 계속 Ready를 반환함
  - 다만 spec을 지키지 않는 executor에서만 차이가 나는 케이스라고 봄

## LLVM이 알아서 치워줄 거라는 기대는 깨짐

- 단순한 코드와 `opt-level=3`에서는 LLVM이 어느 정도 정리해줌
  - 하지만 Future가 조금만 깊게 중첩되면 금방 한계가 옴
  - 임베디드나 WASM처럼 크기 최적화를 거는 환경에서는 더 잘 안 치워짐

- Godbolt 예시에서는 LLVM이 `foo`가 5를 반환한다는 걸 알면서도 `bar`를 10으로 접지 못함
  - `foo`의 poll 함수 호출도 남아 있음
  - 이유는 잠재적인 panic 경로를 컴파일러가 완전히 제거하지 못하기 때문

- panic branch를 IR에서 주석 처리하면 최적화가 더 잘 됨
  - 즉 문제는 “LLVM이 멍청하다”가 아니라 “LLVM에게 너무 지저분한 입력을 주고 있다”에 가까움
  - async 최적화는 MIR 단계에서 더 똑똑하게 해야 한다는 결론으로 이어짐

## Future 인라이닝과 상태 병합이 다음 큰 타깃

- 현재 생성된 Rust Future는 실질적으로 잘 인라인되지 않는다고 함
  - `async fn bar() { foo().await }` 같은 흔한 패턴에서도 `bar`용 상태 기계가 따로 생기고, 그 안에서 `foo` 상태 기계를 호출함
  - trait 구현에서 시그니처를 바꾸거나 얇은 래퍼를 만들 때 이런 패턴이 자주 나옴

- 글쓴이가 원하는 최적화는 단일 await Future를 더 과감하게 접는 것임
  - `bar`가 별도 상태를 가질 필요가 없다면 `foo` Future를 사실상 재사용할 수 있음
  - preamble과 postamble이 있어도, poll 시점에 필요한 변환만 얹고 내부 상태는 `foo`에 맡기는 구조가 가능하다고 봄

- 동일한 await 상태를 병합하는 예시도 나옴
  - `match get_command()`에서 A면 `send_response(123).await`, B면 `send_response(456).await`를 호출하는 코드는 자연스럽지만, 컴파일러는 거의 같은 상태를 2개 만듦
  - 수동으로 `response` 값을 먼저 고르고 `send_response(response).await`로 바꾸면 MIR이 456줄에서 302줄로 줄고 중복 상태도 사라짐

```mermaid
sequenceDiagram
    participant 개발자코드 as 개발자 코드
    participant 러스트컴파일러 as 러스트 컴파일러
    participant MIR as MIR 상태 기계
    participant LLVM as LLVM
    개발자코드->>러스트컴파일러: async 함수 작성
    러스트컴파일러->>MIR: await 지점별 상태 생성
    MIR->>MIR: Returned/Panicked/Suspend 상태 추가
    MIR->>LLVM: 최적화 가능한 코드로 전달
    LLVM-->>MIR: panic 경로 때문에 일부 최적화 실패
    MIR-->>개발자코드: 더 큰 바이너리와 추가 poll 비용
```

## 숫자로 보면 ‘감’이 아니라 실제 비용임

- 글쓴이의 실험 결과는 꽤 구체적임
  - 완료 후 poll panic을 `Pending` 반환으로 바꾸면 임베디드 바이너리 크기 2-5% 절감
  - await 없는 async 블록에서 상태 기계를 제거하면 0.2% 절감
  - 두 변경을 합치면 x86 합성 벤치마크에서 `smol` executor 기준 약 3% 성능 향상

- 아직 Future 인라이닝은 직접 테스트하지 않았지만, 더 큰 효과가 날 수 있다고 봄
  - async Rust 코드는 Future가 깊게 중첩되는 패턴이 많아서 인라이닝이 먹히면 파급이 큼
  - 특히 trait abstraction을 많이 쓰는 코드베이스에서 체감 가능성이 있음

- 글쓴이는 이 작업을 Rust Project Goal로 제출했고, 컴파일러 쪽에서 실제로 다뤄보고 싶다고 함
  - 제안 항목은 release에서 Returned panic 제거, await 없는 async 블록의 상태 기계 제거, 단일 await Future 인라이닝, 동일 상태 병합
  - 마지막에는 funding이 필요하다고 솔직하게 적음. 오픈소스 컴파일러 최적화도 결국 시간과 돈 문제라는 현실 엔딩임

---
## 기술 맥락

- async Rust는 개발자가 보기엔 `await` 몇 개지만, 컴파일러 입장에서는 “어디서 멈췄고 무엇을 저장해야 하는가”를 기억하는 상태 기계를 만들어야 해요. 그래서 await가 많거나 Future가 중첩되면 코드 크기가 자연스럽게 불어나요.

- 이 글의 핵심은 상태 기계가 필요한 건 맞지만, 현재 rustc가 너무 보수적으로 만든다는 점이에요. 완료 후 재poll을 막기 위한 panic 경로와 panic 이후 상태까지 넣다 보니, 실제로는 일어나지 않을 경로 때문에 최적화가 막히거든요.

- LLVM에 맡기면 되지 않냐는 질문이 당연히 나오는데, 글쓴이는 그게 한계가 있다고 봐요. async 변환은 MIR에서 일어나고, LLVM은 이미 펼쳐진 상태 기계와 panic 경로를 받아서 최적화해야 하니 원래 의도를 복원하기 어렵거든요.

- 임베디드와 WASM에서 이 문제가 더 크게 보이는 이유는 바이너리 크기 자체가 제품 제약이기 때문이에요. 서버에서는 2-5%가 티 안 날 수 있지만, 펌웨어에서는 메모리 한계나 업데이트 크기 때문에 바로 의사결정 포인트가 돼요.

- 그래서 제안도 런타임 해킹이 아니라 컴파일러 최적화에 가까워요. await가 없는 async는 상태 기계를 만들지 않고, 동일한 await 상태는 합치고, 단일 await 래퍼 Future는 인라인해서 애초에 LLVM에 더 좋은 입력을 주자는 흐름이에요.

## 핵심 포인트

- async 블록은 await가 없어도 Returned, Panicked 같은 기본 상태를 가진 상태 기계로 변환됨
- 완료된 Future를 다시 poll할 때 panic하는 경로가 최적화를 방해하고 바이너리 크기를 키움
- 글쓴이의 컴파일러 실험에서는 release에서 완료 후 Pending 반환만으로 임베디드 펌웨어 바이너리 크기가 2-5% 줄었음
- await가 없는 async 블록에서 상태 기계를 제거하면 0.2% 크기 절감, 합치면 x86 합성 벤치마크에서 약 3% 성능 향상이 나옴
- 단일 await Future 인라이닝과 동일 상태 병합은 아직 실험 전이지만 더 큰 효과가 예상됨

## 인사이트

이 글의 포인트는 ‘async Rust가 느리다’가 아니라 ‘컴파일러가 너무 보수적인 상태 기계를 만들고, 그 비용을 LLVM이 나중에 다 치워주길 기대하고 있다’는 데 있음. 서버에서는 티가 덜 나도, 임베디드와 WASM에서는 이런 몇 퍼센트가 바로 제품 제약이 됨.
