4P by GN⁺ | ★ favorite | 댓글 1개
  • Rust의 async/await는 스레드의 단순 대체물이 아니라, I/O 중심 동시성 코드를 조합 가능한 상태 머신으로 표현하는 프로그래밍 모델임
  • 웹 서버처럼 여러 연결을 동시에 다뤄야 하는 코드는 선형 실행만으로 한계가 생기며, 스레드는 thread::spawn으로 클라이언트 처리를 분리해 동시 처리를 가능하게 함
  • async/awaitawait 지점에서 실행을 양보하고, 실행기(executor)가 다른 작업을 이어 실행하게 만들어 많은 작업을 한 런타임 안에서 교차 실행함
  • 3초 타임아웃 같은 요구사항은 async에서 raceTimer 조합으로 붙일 수 있지만, 동기 스레드 코드에서는 TcpStream 전용 래퍼와 읽기/쓰기 타임아웃 설정이 필요해 범용성이 떨어짐
  • 성능 오버헤드만으로 async를 설명하면 CPU bound 작업에서 반례가 생기며, Rust async의 강점은 의미론적 표현력과 생태계 조합성에 있음

Rust 동시성 문제의 출발점

  • Rust의 일반 코드는 기본적으로 선형 실행 구조임
    • foo(), bar(), baz()처럼 한 작업이 끝난 뒤 다음 작업이 실행됨
  • 웹 서버처럼 여러 일을 동시에 처리해야 하는 경우 선형 구조는 빠르게 한계에 도달함
    • TcpListener::accept()로 클라이언트를 받고 handle_client()를 실행하는 구조에서는 첫 번째 클라이언트 처리 중 두 번째 클라이언트가 기다려야 함
    • handle_client()가 몇 밀리초 걸리고 동시 클라이언트가 2명이면 짧은 대기가 생김
    • 동시 클라이언트가 200만 명이면 큐 끝의 사용자는 몇 분을 기다릴 수 있음

스레드가 해결하는 방식

  • 운영체제 스레드는 레지스터 값과 프로그램 스택을 메모리에 저장하고, 다른 루틴을 실행한 뒤 나중에 원래 루틴을 재개할 수 있음
  • 웹 서버 코드는 thread::spawn(move || handle_client(client)) 형태로 클라이언트 처리를 별도 스레드에 맡김
    • 메인 스레드는 새 연결을 계속 accept()
    • 클라이언트 처리 스레드가 블로킹되면 OS가 메인 스레드로 돌아와 다음 연결을 받을 수 있음
    • 두 클라이언트는 몇 마이크로초 수준의 지연 뒤 병렬로 실행될 수 있음
  • 프로덕션급 웹 서버가 수십 개 CPU 코어를 가진 경우, OS는 스레드들이 동시에 실행되는 것처럼 보이게 할 뿐 아니라 실제로 여러 스레드를 동시에 실행할 수 있음

async/await가 동작하는 방식

  • 사용자 공간 동시성에는 이벤트 기반 프로그래밍, 액터, 코루틴 등 여러 모델이 있고, Rust가 선택한 방식은 async/await
  • 단순화하면 프로그램은 서로 독립적으로 실행 가능한 상태 머신 묶음으로 컴파일됨
    • async fn은 전통적인 함수가 아니라 상태 머신을 반환하는 함수임
    • await는 다른 상태 머신을 현재 상태 머신의 일부 단계로 포함함
    • 내부 함수가 새 연결 대기처럼 실행을 양보하면 전체 상태 머신이 상위 실행기(executor)에 제어권을 넘김
  • smol::Executor 같은 실행기는 현재 상태 머신 대신 spawn으로 생성된 다른 상태 머신을 실행함
    • async move { handle_client(client).await } 블록은 main과 독립된 새 상태 머신임
    • main이 양보하면 클라이언트 작업 중 하나가 실행되고, 그 작업이 다시 양보하면 다음 작업으로 순환함
  • 이 구조로 수백만 클라이언트를 동시에 다룰 수 있지만, 실행기, 태스크, 상태 머신 같은 개념이 추가되어 복잡도도 올라감

타임아웃 예시에서 드러나는 조합성

  • Rust의 강점 중 하나는 조합성
    • Iterator는 여러 조합자를 붙이고, 결과를 다시 Iterator를 받는 함수에 넘길 수 있음
    • mpsc::channel()recv.try_iter().filter(...).map(...)처럼 값을 필터링하고 변환해 리스트에 추가할 수 있음
  • async/await는 이런 조합성을 I/O bound 함수에도 적용하게 해줌
  • handle_client()read_to_end, do_something_with_data, write_allawait하는 비동기 함수라면, 3초 타임아웃은 두 Future를 조합해 구현할 수 있음
    • race는 두 Future를 동시에 실행함
    • Timer는 지정된 시간이 지난 뒤 반환함
    • 기존 연결 처리 코드를 async 블록으로 감싸고, 3초 후 TimedOut 오류를 반환하는 Future와 경쟁시키면 됨
  • 이 방식은 TcpStream에만 묶이지 않음
    • impl AsyncRead + AsyncWrite를 구현하는 대상이면 같은 패턴을 적용할 수 있음
    • 일반 스트림 위의 GZIP 스트림, Unix 소켓, 파일 같은 대상도 대체 가능함

동기 스레드 코드에서 같은 타임아웃을 구현할 때의 제약

  • 블로킹 코드에서는 일반적으로 readwrite 시스템 호출을 중단하기 어렵고, 파일 디스크립터를 닫는 식의 방법은 Rust에서 쓸 수 없음
  • TcpStreamset_read_timeoutset_write_timeout을 제공함
    • 읽기와 쓰기 각각에 타임아웃을 설정할 수 있음
    • 하지만 클라이언트가 2.9초마다 1바이트씩 보내면 단순 타임아웃은 계속 초기화될 수 있음
  • 이를 방어하려면 TcpStream을 감싼 DeadlineStream 같은 타입을 만들고, 전체 데드라인까지 남은 시간을 매번 계산해 읽기/쓰기 타임아웃에 설정해야 함
  • 이 접근은 동작할 수 있지만 제약이 큼
    • TcpStream에 묶임
    • Rust에는 set_read_timeoutset_write_timeout 사용을 추상화하는 trait가 없음
    • 범용 writer에 적용하려면 추가 작업이 많이 필요함
    • 타임아웃 설정을 위한 추가 시스템 호출이 들어감
    • 실제 웹 서버 로직에서는 사용이 더 번거로울 수 있음

Rust async 생태계의 사례

  • HTTP 생태계가 클라이언트까지 포함해 async/await를 주요 런타임 메커니즘으로 채택한 데에는 함수 조합성이 있음
    • HTTP 호출을 만드는 함수를 다양한 구멍과 사용 사례에 맞춰 끼워 넣을 수 있음
  • towerasync/await 조합성을 보여주는 대표 사례임
    • 서비스를 async 함수로 구현하면 타임아웃, 속도 제한, 로드 밸런싱, hedging, 백프레셔 처리를 붙일 수 있음
    • 어떤 런타임을 쓰는지, 서비스 내부가 무엇을 하는지와 무관하게 tower를 적용해 견고성을 높일 수 있음
  • macroquad는 Rust용 소형 게임 엔진이며, 메인 함수에 async/await를 사용해 엔진을 실행함
    • Rust에서 어떤 작업을 기다리기 위해 선형 함수를 멈춰야 하는 상황을 표현하는 데 async/await가 적합함
    • 같은 스레드에서 게임 서버 네트워크 연결과 GUI 프레임워크를 동시에 폴링하는 식의 구성이 가능함

성능만으로 async를 설명할 때의 한계

  • Rust Async Book은 OS 스레드가 프로그래밍 모델 변경 없이 동시성을 표현하기 쉽지만, 스레드 간 동기화가 어렵고 성능 오버헤드가 크며, 스레드 풀로도 대규모 I/O bound 워크로드를 충분히 지원하기 어렵다고 비교함
  • async 커뮤니티에서는 OS 스레드보다 왜 async를 쓰는지 묻는 질문에 “오버헤드가 낮고 나머지는 같다”는 식으로 답하는 경향이 있음
  • 웹 서버 작성자들이 async/await로 전환한 이유는 C10k problem을 해결하기 위한 것이었지만, 모든 사용자가 async/await를 선택할 이유가 성능일 필요는 없음
  • 성능 이점은 상황에 따라 사라질 수 있음
    • CPU bound 작업에서는 동등한 async 워크플로보다 스레드 기반 워크플로가 더 빠를 수 있음
    • Rust async의 일시적인 성능 이점은 과도하게 강조되고, 의미론적 이점은 과소평가되어 왔음
  • async/await는 틈새 사례용 도구가 아니라, 동기 Rust에서 수십 개 스레드와 채널 없이는 간결히 표현하기 어려운 패턴을 다루는 강력한 프로그래밍 모델임

sync Rust처럼 만들기보다 차이를 받아들이기

  • Rust 프로젝트 로드맵에는 async Rust 작성이 가끔 asyncawait 키워드를 쓰는 것 외에는 동기 코드 작성만큼 쉬워야 한다는 방향이 있음
  • 하지만 async Rust를 “sync Rust와 똑같이” 만드는 프레이밍은 근본적으로 어렵다는 시각도 있음
    • 99%까지 비슷하게 만들 수 있어도 평균적인 사용자가 차이를 알아차릴 수밖에 없음
  • Rust의 async/await 생태계는 동기 Rust와 같아지려 하기보다, 조합성표현력이라는 강점을 더 분명히 드러내야 함
  • 동시성이 필요할 때 async/await가 기본 선택지가 되도록 만들려면, 기술적 성능 이유보다 의미론적 이유로 이 모델을 설명해야 함

댓글과 토론

Hacker News 의견들
  • 단일 스레드 async/await는 단순하고 잘 알려진 모델이며, JavaScript가 그 방식임
    스레드는 여러 CPU를 문제 해결에 투입하게 해 주고 Rust는 잠금 관리를 도와줌. 우선순위가 다른 스레드도 둘 수 있어서 계산 바운드 작업에는 필요할 수 있음
    반면 다중 스레드 async/await는 지저분해짐. 계산 바운드 구간이 진지하게 들어가면, 다른 작업과 공유하는 스레드를 사실상 막아 버리므로 모델이 무너지기 쉬움
    Rust의 계산 바운드 다중 스레드는 기대만큼 잘 동작하지 않음. 저장소 할당자에서 여러 스레드가 같은 잠금을 두드리며 futex 혼잡 붕괴가 생길 수 있고, 특히 버퍼 확장 중 전체 할당자를 잠근 채 재복사가 일어나면 매우 비싸짐. Wine의 Microsoft 라이브러리 에뮬레이션 .DLL 안 라이브러리 할당자는 이 문제에 취약해서 CPU 시간이 스핀락에 다 들어가며 성능이 두 자릿수 배로 떨어졌고, Microsoft 구현은 그렇지 않았음
    또 표준 Mutexcrossbeam-channel 채널은 불공정 mutex 기아가 생길 수 있음. 여러 스레드가 자원을 잠그고 작업하고 풀고를 반복하면 한 스레드가 계속 이기고 나머지는 밀려날 수 있음. 공정 mutex가 필요하면 parking-lot이 있지만, 표준 mutex가 주는 스레드 패닉 시 poisoning 안전성은 없음
    I/O 바운드가 아니라면 훨씬 복잡해짐
    https://users.rust-lang.org/t/mutex-starvation/89080

    • 맞음. 주로 I/O 바운드 계산만 다뤘지만 거기서도 경합 문제가 생김
      I/O 처리량이 다시 한계라면 코루틴 백만 개가 무슨 의미가 있나 싶음. 크기 10짜리 DB 연결 풀을 바로 고갈시키면 코루틴이 구해 주지 못하고, 디버깅과 우회만 더 어렵고 추론하기 힘들게 만듦
    • 이 문제는 결국 하드웨어 수준에서 다시 생각해야 할지도 모른다는 생각이 듦
      CPU 바운드 문제는 체계적인 인터럽트/재개로 귀결되는 것 같고, 진행 중인 실행 스레드 n개에 대해 공정하고 효율적인 큐 기반 문맥 전환이 가능하다면, 예컨대 진행 중 문맥 n개를 가진 CPU라면 문제는 자원 할당 문제가 될 수 있지 않을까 궁금함
    • 협력적 멀티태스킹의 어려움이 왜 계속 재발견되는지 모르겠음
      책임감 있게 설계된 언어라고 보는 Go도 처음엔 협력적으로 갔다가 결국 선점형으로 바꿀 수밖에 없었음. 협력적 멀티태스킹이 쓸모없다는 뜻은 아니지만, 경고 라벨이 붙어야 하고 더 나아가 특정 종류의 코드는 정적으로 실행을 막는 편이 나을 수 있음
      관련 글로 “What color is your function”을 붙여 둠
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
    • 이건 구현 세부사항임을 계속 반복하게 됨
      async/await의 다중 스레드 실행기는 기아를 충분히 처리할 수 있고, .NET 구현은 블로킹 호출과 비동기를 섞은 아주 나쁜 코드도 버텨 냄
      https://news.ycombinator.com/item?id=39530435
      https://news.ycombinator.com/item?id=39786142
      https://news.ycombinator.com/item?id=39721626
    • async/await가 만드는 색깔 있는 함수 때문에 소프트웨어 개발과 유지보수 비용도 추가됨
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
      높은 확장성이 필요한 소프트웨어가 아니라면 async의 절충이 그만한 가치가 없을 수 있음
  • async/await와 스레드 논쟁의 핵심은 어느 쪽이 더 복잡한지가 아니라, 생태계를 둘로 쪼개고 한쪽이 2등 시민이 되어 프로젝트에 잘못 고르면 마찰이 생긴다는 데 있음
    섞어 쓸 수는 있지만 필요할 때 해키하고 비효율적임. 지금 Rust 생태계는 I/O가 들어가면 사실상 전부 async/await 생태계에 묶이도록 결정된 상태고, Rust에서 하려는 거의 모든 일은 예외가 드물게 I/O를 포함하므로 애플리케이션의 나머지가 async를 원하든 아니든 비동기 아닌 라이브러리는 대체로 무시해야 함
    Rust가 async/await보다 더 조합 가능한 추상화를 썼고 그 조합성이 다른 것까지 async/await로 만들기를 요구하지 않았다면, 불만 대부분은 사라졌을 것 같음

    • 진단에 동의함. 내 Rust async 글[0]에서도 같은 결론을 냈음
      더 나쁜 점은 생태계가 둘로 갈라지는 데서 그치지 않고, async 코드 안에서도 보통 실행기, 대개 Tokio에 강하게 묶인다는 것임. 함수 색깔 문제를 확장해 파랑(비 I/O), 초록(블로킹 I/O), 빨강(async I/O) 대신 실제로는 파랑, 초록, 빨강(Tokio), 보라(async-std), 주황(smol)처럼 됨
      이 문제에는 sans-I/O 패턴이 가장 좋은 해결책이라고 봄. 모든 파란 코드를 분리하고 I/O와 시간에는 제어 역전을 쓰면, 핵심 프로토콜 로직이 I/O를 모르도록 만들 수 있고 여러 I/O 형태로 감싸기 쉬워짐
      0: https://hugotunius.se/2024/03/08/on-async-rust.html
    • 선택 가능한 두 라이브러리가 있고 나머지가 동일할 때만 비동기 아닌 라이브러리를 무시하면 됨. 그런 경우는 드묾
      async 애플리케이션에서 블로킹 코드를 쓰는 게 기대만큼 매끄럽지는 않지만 어렵지도 않음. foo() 대신 tokio::spawn_blocking(foo).await를 쓰면 새 코드를 별도 스레드에서 실행하고 그 스레드가 끝나면 완료되는 future를 돌려줌
    • C#도 기본적으로 비슷함
      I/O에 비동기 아닌 선택지가 있긴 하지만, 비동기 선택지를 쓰면 사실상 Main()까지 전부 async가 되도록 강제됨. 동기 메서드에서 async 메서드를 안전하게 부르는 방법도 있지만 디버깅을 극단적으로 어렵게 만듦
  • 글에서 빠진 부분이 많음
    async/await는 한 스레드 문맥에서 실행되므로 잠금이나 동기화가 필요 없지만, CPU 코어를 활용하려고 여러 스레드에서 async/await를 실행하면 잠금과 동기화가 다시 필요해짐. 이 복잡성은 외부 코드에 숨겨질 수 있음. 예를 들어 단일 DB 연결 접근을 동기화하는 대신 async 작업마다 DB 연결을 하나씩 여는 편이 쉽지만, SQLite나 PostgreSQL에서는 성능에 영향을 줄 수 있음
    async/await에서 오류 전파는 명확하지 않음. 특히 async 작업을 묶으려 할 때 그렇고, Happy Eyeballs가 전형적인 예임
    네트워크 I/O를 말한다면 백프레셔도 함께 다뤄야 함. CPython의 async/await 구현은 네트워크 백프레셔가 부족하기로 악명 높고, 그로 인한 문제가 있음

    • async/await에 여러 문제가 있지만, 가장 큰 불만은 이거임
      Gang of Four의 “Design Patterns”는 대부분 C++ 결함을 우회하는 요리책이었는데, 사람들은 그런 결함이 없는 언어에도 그 패턴을 적용했음
      Rust는 JavaScript가 아니며 여러 스레드를 잘 실행할 수 있음. 그래서 꼭 async/await를 쓸 필요는 없었고, 시스템 언어답게 다른 해법들을 시도할 수 있었음
      하지만 JavaScript 프로그래머에게 Rust를 밀어 넣으려면 async/await가 필요했음. without.boats는 “Rust의 생존이 이 기능에 달려 있다는 가정의 성실한 열정으로 async/await를 밀었다”고 썼음
      https://without.boats/blog/why-async-rust/
      async/await가 Rust에 기술적으로 잘 맞는지는 중요하지 않았고, JavaScript 프로그래머가 async/await에 익숙했으니 Rust도 async/await를 가져야 했다는 식으로 보임
    • async/await도 스레드처럼 동시성 메커니즘이고, 공유 메모리에 접근할 때는 항상 잠금이 필요함. 잠금이 필요 없다는 말이 어디서 나온 건지 모르겠음
    • async는 잠금 면에서 더 무서울 수 있음
      어떤 코드 블록이 독점 접근에 의존하고 있었고 await가 없어서 그게 보장됐는데, 중간에 await를 추가하면 코드가 깨짐. 스레딩은 최소한 무엇에 독점 접근이 필요한지를 코드로 명시하게 만듦
      async는 자기 스레드 스케줄링을 직접 관리하겠다는 뜻이기도 함. I/O가 많고 CPU 바운드 코드가 짧으면 괜찮지만, CPU 바운드 코드가 있거나 가끔이라도 있으면 스케줄러 놀이를 하게 됨
    • Node.js를 쓰는 팀에 합류했을 때 백프레셔 문제를 겪었음
      여러 서비스가 그냥 ABEND로 죽었고, Java에서 온 입장에서는 이런 누락이 놀라웠음. 팀에 수정 방식을 설명하기도 어려웠음
      오류 전파 때문에 async/await는 선택권이 있다면 쓰지 않을 것임. 혼자 하는 프로젝트라면 maybe지만, 다른 사람과 함께하고 라이브러리를 쓰며 모두를 같은 이해로 맞춰야 한다면 절대 싫음
      언어 수준의 구조적 동시성을 아직 본격적으로 써 보진 않았지만 Java의 Loom Project에 기대를 걸고 있음. 보기에는 이 논쟁을 무의미하게 만들 듯함
    • Rust에서는 async/await가 한 스레드 문맥에서만 실행되는 게 아님
  • 글의 문제점이 있음
    예제가 웹 서버 하나뿐이고, 스레드 쪽 해법은 잘못 풀었음. 또 질문이 마치 사람들이 async/await 대신 OS 스레드를 원한다고 가정함
    프로그래머가 원하는 건 개념적·의미론적 스레드임. 순차 논리를 쓰고 async 같은 이상한 주석을 쓰지 않는 것임. async/await가 그렇게 좋다면 모든 함수를 암묵적으로 async로 만들고 await 대신 일반 함수 호출을 쓰면 되지 않나. 그러면 사실상 스레드로 프로그래밍하는 셈임
    OS 스레드는 정적으로 할당된 스택 때문에 비싸고, 우리가 원하는 건 단일 CPU에서 수백만 개를 돌릴 수 있는 저렴한 스레드임. 다만 투박한 async/await 단어 없이 말임. wait는 이벤트나 다른 스레드 완료를 기다리는 고전적 의미의 블로킹 대기에는 남아도 되지만, 함수 호출에는 원치 않음
    웹 서버 예제로 돌아가면, driver.race(timeout).await로 타임아웃을 구현했을 때 race가 타임아웃 오류를 알린 뒤 클라이언트 소켓은 어떻게 되나. 열린 채 연결된 상태로 누수되는 건 아닌가
    스레드 버전의 타임아웃도 threaded_race(client_thread, timeout).wait처럼 async/await와 거의 비슷하게 만들 수 있음. threaded_race는 타이머로 스레드와 병렬로 타임아웃을 추적하고, 시간이 되면 Java 방식으로 client_thread.interrupt()를 호출함. Thread.interrupt()는 스레드가 막혀 있지 않으면 플래그만 세우고, I/O 호출에서 막혀 있으면 InterruptedException을 던짐. 체크 예외라 컴파일러가 client.read_to_end(&mut data)를 try/catch로 감싸거나 handle_client에 예외를 선언하게 강제하므로, 프로그래머가 클라이언트 소켓 닫기를 잊지 않음

    • race() 내부 값들은 Drop되고 driver 자체는 남음
      그대로 타입을 쓰면 Rust가 Result를 처리하지 않는다고 불평할 것이고, future 안에서 새 소켓이 로컬로 만들어졌다면 정리됨
      Rust future의 좋은 점은 그 주변 동작을 모두 정의할 수 있다는 데 있음. 모든 함수가 블로킹이라는 모델과 달리, Rust는 작업 큐의 다음 작업으로 실행을 미룰 시점을 지정할 수 있고 명시적으로 보관된 상태(Future 구조체)를 가지고 작업을 임의로 빠르게 폴링함. 그래서 스레드처럼 양보하려고 sleep()할 필요가 없어 빠르고 추론하기 쉬움
      Java의 Thread.interrupt도 결국 sleep 루프에 가까우며 대부분의 애플리케이션에는 괜찮을 수 있음. 하지만 Rust는 시스템 언어라 임베디드 시스템에서는 그런 방식을 둘 수 없고, 커널이나 저지연 애플리케이션에도 바람직하지 않음
    • 어떤 프로그래머는 순차 논리를 원하지만, 많은 사람은 정반대를 원함
      대부분의 경우 OS 블로킹 시스템 호출인지 비블로킹 시스템 호출인지는 별로 신경 쓰지 않지만, 읽고 있는 프로그램의 제어 흐름을 이해하고 어디서 기다리는지, 어떻게 병렬로 돌릴 수 있는지는 알고 싶음
      오히려 블로킹 함수로 작업할 때 blocking/block 키워드 쌍이 있었으면 좋겠음. 블로킹 호출은 은근히 전체를 느리게 만들 수 있고, UI 스레드에 블로킹 시스템 호출이 들어가 답답하게 느린 앱을 너무 많이 봤음
    • 모든 함수를 암묵적 async로 만들고 일반 함수 호출처럼 쓰는 방식은 지난 수십 년간 여러 번 시도됐음. “RPC”를 찾아보면 됨
      동기와 비동기를 통합하려는 시도는 모두 실패했음. 한 스레드 안에서 실행되는 코드, 스레드 사이에서 실행되는 코드, 심지어 컴퓨터 사이에서 실행되는 코드에는 큰 의미론적 차이가 있음. 그걸 추상화하려 하면 결국 부족해지므로 처음부터 제대로 배우는 편이 낫음
    • withoutboats가 어떤 글에서 진짜 답은 C와의 호환성이라고 했던 기억이 남
    • poll()select()로 코드를 쓰는 방법도 있는데, 그건 또 별개의 방식임
  • async/await 체면을 살리려는 거의 마케팅 같은 캠페인을 보는 게 흥미로움
    내 경험상 이건 기술적 실수였을 뿐 아니라 커뮤니티에도 큰 비용을 치르게 했음. 실제로 유용한 언어 기능에 집중하는 대신 Rust의 노력이 이 혼란에 옆길로 샜음
    그래도 언어에는 여전히 큰 기대가 있고, 현재 가진 것 중에는 최고라고 봄. 다만 이 싸움이 영원히 이어질까 걱정됨
    추신: AsyncWrite/AsyncRead 예제는 그럴듯해 보이지만, 사실 *nix로 제한하면 스레드와 파일 디스크립터로도 같은 일을 할 수 있음

    • 펌웨어에서 async를 써 본 적이 있는데 생명의 은인이었음
      그런 일반화는 근거가 부족하고 특정 작업 부하에 치우친 듯함
    • Rust를 잘 알지는 못해서 맞는지는 모르겠지만, 경험적으로 요즘 HN/reddit에서 보는 Rust 논의 10개 중 9개는 async를 둘러싼 것 같음
      async에는 전혀 관심이 없고 Rust 이야기를 읽고 싶은 입장에서는 꽤 별로임
    • 스레드가 poll()보다 빠르다고 생각한다면 어떤 사용 사례인지 알고 싶음. 평생 한 번도 그런 경우를 본 적이 없음
    • 기술적 실수가 아니라 초저지연 async 코드가 필요할 때는 훌륭한 해법임
      실수는 그런 게 필요 없는 대다수 사용 사례에까지 밀어붙인 데 있음
    • async 작업이 다른 유용한 기능을 빼앗았다는 주장에 근거가 있는지 궁금함
      많은 주요 Rust 프로젝트는 스레드 기반 대안보다 큰 성능 개선 때문만이 아니라 설계 특성 때문에 async에 의존함. 주요 I/O 바운드 작업 부하에서는 이런 이점이 쉽게 보임. 현실 문제를 푸는 똑똑한 사람들이 주요 크레이트에서 async를 널리 채택했다는 건 async가 실제로 유용한 언어 기능이라는 강한 신호라고 봄
      싸움은 주로 Hacker News와 reddit에서, async가 필요 없는 사람들이 쓰는 I/O 크레이트들이 이제 async를 원한다는 이유로 화내는 형태로 벌어짐. 재미없는 상황이라는 건 이해하고, async에 실제 문제가 있으며 아직 해결 중인 것도 맞음. 완벽하지 않음. 하지만 포럼에서 보이는 async 분열이 실제 프로젝트에서만큼 넓거나 극적이지는 않은 느낌임
  • 크게 빠진 건 취소
    future는 취소하기 매우 쉬움. 반면 스레드 취소는 두더지 잡기처럼 지저분하고, 강제 스레드 중단은 잠금이 잠긴 채 남을 위험 때문에 신뢰할 수 없음
    Rust의 async 모델에서는 모든 future에 외부에서 타임아웃을 붙일 수 있음. 말단 I/O 함수마다 타임아웃 옵션을 지원할 필요도 없고, 그 타임아웃을 전체 호출 스택에 전달할 필요도 없음
    진행 중 상태 관리에 Rust의 모범 관행인 Drop 가드를 함께 쓰면, 크고 복잡한 작업도 쉽고 신뢰성 있게 취소할 수 있음

    • 모든 future를 취소하기 쉬운 게 아니라, 모든 future를 취소한 척하기 쉬운 것임
      예를 들어 spawn_blocking을 쓰는 무언가를 취소(drop)해도 백그라운드에서는 계속 실행되고, 사용자는 모를 수 있음. 스레드 풀로 구현된 async 파일 시스템 작업도 취소해도 계속 실행됨
      이런 건 “아무것도 그 파일을 쓰지 않는다고 확신하는데 왜 서비스가 파일 사용 중이라 실패하지?” 같은 이해하기 어려운 버그로 이어질 수 있음
    • async future를 구현할 수 있었다면, 대신 취소 가능한 스레드도 구현할 수 있었을 것임
      문제는 꽤 동형적임. 시스템 호출은 어렵지만, 스레드에서 하든 async future에서 하든 동일한 시스템 호출을 하면 정확히 같은 취소 문제가 생김
    • 스레드 취소가 왜 어려운지 모르겠음
      모든 스레드가 접근할 수 있는 플래그 같은 상태를 두고, 작업 루프에서 그 플래그를 확인하게 하면 됨. false면 반환하고 스레드를 join하면 끝임
    • 내 경험상 취소는 크게 걱정할 가치가 없었음
      어떤 작업이 더 이상 유용하지 않다면 그 정보가 결국 그 작업을 대신해 호출된 함수에 보이면 충분함. RPC 시작처럼 아주 비싼 일을 하기 직전이 아니라면 굳이 확인하지 않음
  • 더 나은 질문은 “왜 fiber가 아니라 async/await인가?”라고 봄
    Rust가 1.0 이전에 그린 스레드를 가졌고 의도적으로 제거했다는 건 알지만, 언어에 무거운 런타임을 내장하지 않아도 되는 방식 등 fiber 기반 동시성 구현에는 여러 접근이 있음
    글을 제대로 이해했다면 future를 아무 때나 drop할 수 있다는 점을 주로 칭찬하는 듯함. 스레드에서는 명백한 이유로 비슷한 일을 할 수 없고, 기술적으로 가능해도 극도로 안전하지 않음. 하지만 이 능력에는 엄청난 비용이 따름. io-uring 같은 완료 기반 실행기와 스택 기반 배열을 같이 쓰거나 하위 작업을 다른 실행기 스레드에서 실행할 수 없을 뿐 아니라, 동기 Rust를 쓰다가 보면 매우 불쾌한 놀라움이 되는 미묘한 함정과 신뢰성 문제도 생김
    https://smallcultfollowing.com/babysteps/blog/2022/06/13/asy...
    작업 취소는 근본적으로 협력적 취소여야 하고, 비협력적 취소는 표면적으로 편리하지만 아래에 깊은 문제가 있는 잘못된 기능에 가깝다고 봄
    또한 async/await의 조합성을 칭찬하는 것도 이상함. proper effect system이 없는 현재 Rust에서는 전염성 때문에 조합 가능성과 거리가 멂. 예를 들어 표준 라이브러리의 map 메서드와 async 클로저를 같이 써 보거나, 표준 io::Read/Write 트레이트를 써 보면 됨

    • Rust에서 fiber를 사용자 공간의 협력적 동시성 추상화로 넣으려면 여러 설계 결정을 강제하게 됨
      스택을 스파게티 스택으로 구현할지, 프로세스 수준 메모리 매핑 라이브러리를 요구할지, 아니면 고정 크기 스택으로 제한할지 같은 선택이 필요함
      세 방식 모두 다른 ABI를 가진 언어 코드와 상호작용할 때 문제가 됨. 예를 들어 한 fiber에서 C 코드를 호출했는데 그 C 코드가 다른 fiber를 재개하려고 하면 꽤 복잡해질 수 있음
      async/await의 장점 중 하나는 await 키워드 자체임. 명시적 대기 지점 덕분에 동시성 프로그램의 상호작용을 실제로 추론할 수 있음
      yield하는 fiber는 동시성 세계의 goto와 비슷함. 어떤 메서드를 호출할 때 부작용으로 처리가 멈출지, 이어질 때 세상 상태가 바뀌어 있을지 알 수 없음. 외부 세계와 접할 때 방어적으로 작성해야 하므로 fiber는 고립되어 실행되고 완료로 통신하는 작업에 더 잘 맞음
      그린 스레드, fiber, 코루틴은 여기서 같은 문제를 공유함. 사용자 공간 협력적 동시성은 동시성의 어려운 부분을 해결한다기보다 책상 위 서류를 옮기는 것에 가까움. Rust async/await는 더 명시적이라서 다른 메커니즘이 숨기는 부작용을 숨기지 않음
    • fiber가 취소 문제를 어떻게 해결하는지 모르겠음. 거의 동등하지 않나?
      fiber 기반 코드는 진행 중인 스레드를 머릿속에서 추적해야 해서 따라가기 어렵다고 느낀다. 적어도 내게는 완료될 값을 추적하는 편이 훨씬 쉬움
    • 스택 있는 fiber는 저수준 코드에 별로임
      C++ 위원회를 위한 Gor Nishanov의 리뷰 http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136...를 보면 됨. https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=10...에서도 링크됨. 요약도 명확함: DO NOT USE FIBERS!
    • Ruby라서 스레드/GVL 상황은 Rust와 다르지만, 이런 종류를 말하는 건가 싶음
      https://m.youtube.com/watch?v=qKQcUDEo-ZI
      async/await가 전염성 있고 어색하며, fiber가 적어도 Ruby 구현에서는 훨씬 나은 패러다임이라는 근거를 꽤 잘 보여 준다고 봄
    • 수학 문제에서는 협력적 취소가 꽤 짜증날 수 있음
      최적화 알고리즘이 근 찾기 문제를 호출하고, 그게 ODE 적분기를 호출하는 식으로 중첩될 수 있는데, 어느 단계든 아주 오래 돌 수 있음. 취소 토큰을 곳곳에 전달해야 하지만 수치 계산 프레임워크는 보통 지원하지 않음
      모든 알고리즘에 반복 횟수 제한을 둘 수 있고 그래야 하지만, 중첩 알고리즘에서는 올해 안에는 멈춘다는 정도만 보장할 뿐 5초 안에 멈춘다고 보장하기 어려움
      이런 문제에서는 내가 하는 일이 대량의 수학 계산, 할당과 그에 따른 페이지 폴트, I/O 없음, 취소하지 않을 Main 스레드에서 처리할 표준 라이브러리 Queue 객체에 로그 문자열 쓰기뿐이라고 보장할 수 있음. 필요한 다른 기능도 Queue로 메인 스레드에 돌려보낼 수 있음
      21세기에는 취소 토큰을 모든 곳에 억지로 전달하고, 토큰 확인 없이 오래 도는 코드를 방어적으로 작성하게 만들지 않고도 이 문제가 풀릴 수 있어야 한다고 느낌
  • 또 하나의 async/await 논의에서 사람들이 async/await를 이해하지 못하고, 왜 단일 스레드에서 동시성 메커니즘이 필요한지 상상하지 못하며 아무도 필요 없다고 가정함
    UI 프로그래밍, GPU와의 통신, 런타임 간 통신이 좋은 예이고 더 있을 것임
    스레드는 그린 스레드든 아니든 이런 경우에 맞지 않지만 async/await는 맞음

    • GUI에서 스레드를 쉽게 쓸 수 있고, 과거에 스레드를 꽤 효과적으로 활용한 GUI 앱을 여러 개 작성했음
    • 단일 스레드에서 여러 작업을 명시적으로 관리할 수 있는 건 확실히 중요함
      같은 바이너리를 만들면서 언어 사용자에게 덜 귀찮은 다른 언어 기능 구현이 가능하다면 그 얘기도 해 볼 만함
  • Rust async/await의 주요 장점 중 하나는 스레드나 동적 메모리가 없는 상황에서도 동작할 수 있다는 점임
    마이크로컨트롤러에서 인터럽트가 어떤 버퍼로 들어오는 I2C 데이터를 읽을 때까지 기다리는 간결한 코드를 작성하는 데도 충분히 쓸 수 있음. 하부 런타임과의 상호작용을 많이 드러내지 않고 동시성을 사용할 수 있게 해 주는 더 높은 수준의 추상화임
    내가 작업한 모든 주요 소프트웨어는 어떤 형태로든 이런 것을 구현했음. 현대적 C++ 코루틴 개념이 없는 코드에서도 Apple Grand Central Dispatch, Intel Threading Building Blocks 등을 썼음. 그렇지 않으면 비즈니스 로직이 I/O에서 매우 비효율적으로 막히거나, 스레드가 엄청나게 많아 개발과 디버깅이 지옥이 되거나, 하부 런타임 구현 세부사항으로 뒤덮이거나, 세 가지가 섞임
    언어 자체나 라이브러리의 기존 추상화를 쓰지 않으면 결국 직접 만들게 되고, 그건 어렵고 널리 쓰이는 것보다 전반적으로 열등할 가능성이 큼. 과거에 C++용으로 직접 만든 적도 있음: https://github.com/goto-opensource/asyncly

  • 저자가 두 가지를 혼동하는 것 같음
    하나는 사용자 공간 스레드/그린 스레드이고, 다른 하나는 구조적 동시성
    전자는 async/await의 장점이지만 고유한 장점은 아님. Go나 Java Loom처럼 함수 색깔 문제 없이 가능한 예가 있음
    후자는 OS 스레드와 그린 스레드 양쪽으로 구현할 수 있음. Java의 Structured Concurrency JEP를 보면 됨
    https://openjdk.org/jeps/462