2P by GN⁺ | ★ favorite | 댓글 1개
  • Rust의 async/await는 수만 개 연결을 다루는 대규모 동시성을 겨냥하지만, 저수준 제어와 정적 수명 검증이라는 Rust의 목표와 충돌해 일반 Rust와 다른 개발 경험을 만듦
  • 스레드와 채널은 많은 소프트웨어에 충분하지만, C10K 같은 규모에서는 스레드당 연결 방식의 부담이 커져 사용자 공간 태스크와 런타임 스케줄링이 필요해짐
  • async Rust에서는 데이터가 Send로 이동되거나 'static 참조로 다뤄져야 하며, async의 전염성 때문에 이런 제약이 코드 전반에 반복됨
  • Arc는 컴파일 문제를 풀어도 객체와 리소스 수명을 흐리게 만들고, 재귀 async, future와 task의 차이, blocking 호출이 런타임 스레드를 막는 함정이 이어짐
  • Haskell이나 Go에서는 “async code”가 일반 코드처럼 동작하고 런타임과 GC가 차이를 숨기므로, 이런 종류의 프로그래밍에서는 Rust의 명시적 제어가 순수한 이점으로만 작동하지 않을 수 있음

동시성과 병렬성이 필요한 이유

  • 빠른 프로그램에는 두 가지 요구가 함께 걸려 있음
    • 여러 CPU 코어를 써서 컴퓨터 전체를 활용해야 함
    • 인터넷 메시지 전송이나 파일 열기처럼 느린 작업을 기다리는 동안 다른 일을 계속해야 함
  • 병렬성은 여러 CPU에서 코드를 동시에 실행하는 문제임
  • 동시성은 문제를 독립적인 부분으로 나누는 방식임
  • 둘은 같지 않지만, 프로그램을 동시적인 조각으로 나누면 그 조각들이 병렬로 실행되고 코어를 계속 바쁘게 만들 수 있음

프로세스, 스레드, 채널

  • 동시 시스템을 만드는 단순한 방법은 코드를 여러 프로세스로 나누는 것임
    • 운영체제 스케줄러가 실행 가능한 프로세스의 시간 조각을 사용 가능한 CPU 코어에서 실행함
    • 셸 명령을 파이프로 연결하는 경우에도 이 모델을 사용함
  • 프로세스 방식은 프로세스 간 통신 비용이 큼
    • 많은 구현에서 데이터를 OS 메모리로 복사했다가 다시 가져와야 함
    • 공유 메모리로 비용을 줄일 수 있지만, OS가 프로세스를 서로 격리해 주는 장점은 약해짐
  • 스레드는 같은 메모리를 공유해 이런 오버헤드를 피하지만, mutex, condition variable, semaphore 같은 동기화 도구를 잘못 쓰면 데이터 경쟁과 교착 상태가 생길 수 있음
  • Tony Hoare의 Communicating Sequential Processes 모델은 스레드를 큐 또는 채널로 연결함
    • 스레드는 메모리를 공유하지 않아 프로세스 같은 격리를 얻음
    • 각 스레드의 입력과 출력이 채널로 드러나 추론과 디버깅이 쉬워짐
    • 채널 자체가 동기화 역할을 하며, 비어 있으면 수신자가 기다리고 가득 차면 송신자가 기다림
  • Rust 표준 라이브러리에는 std::sync::mpsc::sync_channel이 있음
  • 많은 소프트웨어는 스레드와 채널, CPU 집약 루프를 병렬화하는 Rayon 같은 도구 조합으로 충분함

사용자 공간 동시성과 Rust async

  • 수만 명의 동시 사용자가 연결되는 웹 서버 같은 C10K 문제에서는 스레드 하나를 연결 하나에 붙이는 방식이 한계에 부딪힘
    • Linux에서 각 스레드는 4kB control block을 가지며, 스레드 전환은 운영체제 스케줄러로 들어가는 context switch를 필요로 함
  • 대규모 동시성을 위해 일부 언어는 태스크를 사용자 공간에서 만들고 관리함
    • 런타임이 태스크를 OS 스레드 풀에 스케줄링함
    • 보통 CPU 코어마다 스레드 하나가 배치되도록 풀을 구성해 병렬성을 극대화함
    • 이런 방식은 green thread, lightweight thread, lightweight process, fiber, coroutine 등으로 불림
  • Rust는 C#이나 Node.js에서 보이는 async/await 모델을 사용함
    • async fn은 직접 값을 반환하지 않고, .await로 결과를 얻는 future 또는 promise를 반환함
  • Rust future는 협력적 스케줄링과 stackless 설계 덕분에 매우 작고 빠름
  • Rust는 future 추상화를 제공하면서도 프로그래머에게 낮은 수준의 제어를 약속하려 함
    • 모든 객체와 참조의 수명을 컴파일 타임에 정적으로 검증하려 함
    • future는 코드와 그 코드가 참조하는 데이터를 수천 개 조각으로 나누고, 실행 시작 후에만 알 수 있는 조건에 따라 아무 때나 아무 스레드에서 실행될 수 있게 함
    • 클라이언트 데이터를 읽는 future는 해당 소켓에 읽을 데이터가 있을 때만 실행되어야 하지만, lifetime annotation은 그 시점을 알려주지 않음
  • Rust는 future 런타임을 언어에 내장하지 않고 Tokio 같은 라이브러리에 맡김
    • 사용자는 환경에 맞는 대안을 고를 자유를 얻음
    • 하지만 Tokio가 언어에 내장된 세계를 상상해도 같은 규칙이 적용되므로, 이 논점에서는 부차적 세부사항임

Send, 'static, Arc가 만드는 압박

  • 컴파일러를 설득하려면 데이터가 Send로 표시되어 이동되거나, 'static 수명을 가진 참조를 통해 전달되어야 함
  • async 코드에서는 여러 태스크가 공통 상태를 공유하는 일이 흔해, 복제 없이 데이터를 이동하는 방식이 자주 맞지 않음
  • 참조도 어렵고, future의 수명을 “영원히”보다 짧게 제한해 주는 thread::scope 같은 대응물이 없음
  • async전염성이 있어 async 함수를 호출하는 함수도 async가 되어야 함
    • 따라서 이런 수명과 이동성 문제를 일부 함수가 아니라 계속해서 해결해야 함
    • 런타임에 future 완료를 block_on으로 기다려 체인을 끊을 수는 있지만, 이런 방식은 조합 가능하지 않고 중첩되면 런타임이 panic할 수 있음
  • Arc는 동적 수명을 여러 스레드에 걸쳐 다루는 도구이며, borrow check를 통과하고 코드를 컴파일하게 해 줌
  • 하지만 Arc를 광범위하게 쓰면 객체와 리소스의 수명이 흐려짐
    • 메모리, 파일, 소켓 같은 리소스가 언제 해제될지 명확하지 않음
    • 실제 GC가 제공하는 allocation throughput, 낮은 fragmentation, cycle leak 회피 같은 장점 없이 GC와 비슷한 손실을 겪음

async Rust의 추가 함정

  • Rust coroutine은 stackless라서 컴파일러가 각 coroutine을 .await 지점까지 전진하는 상태 기계로 바꿈
    • 재귀 async 함수는 재귀적으로 정의된 타입이 됨
    • 단순히 자기 자신을 호출하려는 사용자는 수동으로 boxing하거나 async-recursion 같은 crate를 써야 함
  • future는 await되기 전까지 아무것도 하지 않음
  • task는 런타임의 스레드 풀에서 작업을 시작하고, 완료를 표시하는 future를 반환함
  • future 내부에서 blocking 코드를 호출하지 못하게 막는 장치는 없음
    • 그런 호출이 자신이 올라간 런타임 스레드를 막는 것도 막지 않음
    • 이는 async를 쓰는 핵심 목적과 충돌함

일반 Rust, Haskell, Go의 차이

  • async Rust는 “normal” Rust와 맛이 크게 다름
    • 더 많은 함정이 있음
    • 이해하고 가르치기 더 어려움
  • 사용자는 두 선택지 사이에 놓임
    • 추상화가 실제로 어떻게 작동하는지 깊게 이해하고 복잡한 코드를 작성함
    • Arc, Pin, 'static 같은 요소를 코드 곳곳에 뿌리고 잘 되기를 바람
  • 경험 많은 개발자 팀도 새 프로젝트에서 Rust를 쓰려다가 이런 세부사항에 발목이 잡힐 수 있음
  • Haskell이나 Go에서는 “async code”가 일반 코드임
    • 두 언어는 두꺼운 런타임 뒤에 blocking과 non-blocking 코드의 차이를 숨김
    • 수명 문제는 garbage collection으로 넘김
  • 이런 종류의 대규모 동시 사용자 공간 소프트웨어에서는 런타임과 GC가 차이를 숨기는 방식이 순수한 이점으로 작동함
  • Rust는 대규모 동시 사용자 공간 소프트웨어에는 좋은 도구가 아닐 수 있으며, 그런 요구가 없는 프로젝트에 쓰는 편이 나을 수 있음

댓글과 토론

Hacker News 의견들
  • 고성능 메타버스 클라이언트를 Rust로 작성 중이고, 현재 약 4만 줄 규모임
    데모 영상은 https://video.hardlimit.com/w/tp9mLAQoHaFR32YAVKVDrz에 있음
    제대로 된 메타버스라면 사용자가 만든 콘텐츠를 거의 실시간으로 처리해야 해서, 비슷한 게임보다 VRAM이 2~3배 필요하고, 서버에서 에셋을 불러오려면 수백 Mbps 대역폭과 여러 CPU, 렌더링과 GPU 업로드를 병행할 Vulkan이 필요함
    이건 “웹 규모” 동시성처럼 같은 주소 공간에서 작은 서버들이 따로 도는 구조가 아니라, 높은 우선순위 렌더 스레드, 네트워크 이벤트 갱신 스레드, 에셋 로딩·압축 해제 스레드, 이동 객체·LOD·캐시 정리 등을 맡는 여러 스레드가 함께 움직이는 구조임
    Rust에서는 상수 외에 전역 상태 없이 상당한 잠금을 쓰고, 채널은 맞는 곳에 쓰며, 주요 객체 트리는 단일 소유권으로 갱신 스레드가 주로 다룸. 그래픽 객체 연결은 Arc 참조 카운트로 관리하고, Rend3/WGPU/Vulkan을 통해 메시와 텍스처를 GPU로 올림
    C++로 했다면 계속 크래시와 싸웠을 텐데, Rust에서는 메모리 관련 크래시가 1년에 한 번 정도이고 그것도 대개 남의 unsafe 코드였음. 내 코드는 unsafe를 금지해 두었고, 컴파일하기는 어렵지만 일단 되면 “그냥 동작”하는 편이라 동시성 디버깅보다 훨씬 낫다고 봄
    불만도 있음. Rust는 데이터 경합에는 강하지만 교착 상태는 막지 못하므로, 호출 경로를 따라 잠금 순서를 추적하는 정적 분석기가 필요함. async는 계산 중심 작업과 여러 우선순위 스레드에는 맞지 않는데 자꾸 의존성으로 스며듦. 단일 소유권에 역참조가 붙는 흔한 구조는 RcWeak 없이는 너무 어렵고, 특성 시스템도 복잡해 객체 지향이 자연스러운 에셋 처리 부분에서는 중복 코드가 생김
    그래픽 핵심 크레이트도 아직 완성도가 낮음. “Rust에는 게임 5개와 게임 엔진 50개가 있다”는 건 언어 문제가 아니라 생태계 문제이고, https://gamedev.rs/와 비교해도 Rust로 진지한 게임 개발이 아직 부족해 보임. 일정이 있는 프로 게임 개발이라면 Rust 게임 생태계는 아직 준비되지 않았고, 대략 5명이 1년쯤 더 일해야 할 수준으로 봄

    • 지난 3년간 로봇 시뮬레이터를 Rust로 만들었는데 거의 같은 경험임. 3년 동안 실제 런타임 버그는 5개쯤이었고, Rust와 async에 문제가 있긴 해도 전체적으로 장점이 훨씬 큼
    • 잠금 순서를 따라 잠재적 교착 상태를 찾는 건 좋은 아이디어로 보임
      Linux의 lockdep처럼 어떤 잠금을 잡은 상태에서 다른 잠금을 잡는지 분석하고, 실제로 멈추기 전에도 위험한 조합을 알려줄 수 있음. 복잡한 잠금에서는 “이 잠금 클래스는 항상 주소 순서대로 잡는다” 같은 주석이 필요하겠지만 구현 가능해 보임
    • MMO에서 거의 같은 일을 Java로 하고 있는데 JDK가 아주 쉽게 만들어 줌. 네트워크에서 모델 생성, UI 스레드로 객체를 동시 큐로 옮기면 되고, 꽤 지루할 정도로 단순하면서도 빠름
    • Rust는 경합 조건이 없는 게 아니라 데이터 경합이 없는 것임
      데이터 접근 바깥에서는 여전히 경합 조건이 생길 수 있음: https://news.ycombinator.com/item?id=23599598
    • 우선순위 문제는 비교적 쉽게 풀 수 있음
      여러 스레드 풀을 만들고 future를 알맞게 라우팅하거나, 직접 이벤트 루프를 작성해 우선순위가 다른 여러 이벤트 큐에서 가져오면 됨. 두 번째 방식은 작업 실행 시간이 제한되어 있다면 CPU 100% 상황에서도 낮은 우선순위 작업을 진행하면서 높은 우선순위 작업에 소프트 실시간 보장을 줄 수 있음
  • async Rust에 대해서는 묘한 지점에 있음
    Arc, RwLock, 공유 상태를 잔뜩 쓰면 지저분해지고, 특히 'static이 여기저기 퍼지기 시작하면 색깔 함수처럼 모든 걸 감염시킨다는 지적은 맞음. 예전에는 Arc를 붙이고 수명 대여를 똑똑하게 처리하려다 엉망이 됐음
    하지만 Rust에는 채널도 있음. 지금 작성하는 코드는 대부분 몇 개의 작업이 채널을 서비스하고, 들어온 메시지를 보고 필요하면 다른 작업으로 보낼 메시지를 적절한 채널에 넣는 구조임. 객체 공유는 하지 않음. 여러 작업이 큰 객체를 필요로 하면, 관련 질의 결과를 메시지로 보내는 작업 안에 두거나 각 작업이 메시지 흐름에서 자기 복사본을 만들게 함
    그런데도 Arc를 어떻게 쓰고 수명을 어떻게 처리할지 다루는 글이 너무 많음. async 런타임을 구현한다면 필요하겠지만, 평균적인 라이브러리 사용자가 왜 여기에 그렇게 집중해야 하는지는 잘 모르겠음

    • 비판이 조금 이상하게 느껴짐. async가 곧 다중 스레드를 뜻하지는 않고, 같은 스레드 안의 async라면 공유가 없기 때문에 공유 대상에 마법 같은 키워드를 전부 붙일 필요도 없음
      스레드 사이를 오갈 때는 공유 상태를 잔뜩 두기보다 채널로 신호를 보냄. 꼭 필요한 전역 상태가 있으면 Arc/RwLock 같은 배타 접근 메커니즘을 감싼 작은 구조체를 만들고, 호출자 입장에서는 단순한 함수 호출처럼 보이게 함
      Send+Sync에 대한 걱정도 잘 이해되지 않음. 경험상 대부분은 쉽게 Send+Sync이고, 아닌 것들은 그렇게 되어서는 안 되거나 될 수 없는 것들이었음. 세부를 생각하지 않고 코드를 쓰고 싶을 때도 있지만, 효율적인 동시성·병렬성이 필요하다면 마이크로초와 처리량이 중요해지고, 그때는 실제 컴퓨터 코드를 제대로 써야 함
    • 메시지 전달 패러다임은 정말 좋고, Erlang 같은 언어가 분산 시스템에서 훌륭한 선택임을 보여줬음
      하지만 이런 식의 코딩은 동기 코드에 그린 스레드를 붙인 것처럼 느껴지는 async JavaScript와는 많이 다름. 사람들은 익숙한 방식으로 코드를 쓰려 하다 보니 Rust에서 ArcRwLock 경로로 빠지는 것 같음
    • Smalltalk와 진짜 객체 지향의 꿈은 아직 살아 있음
    • 대학 때 교수에게서 이런 조언을 배웠는데 정말 큰 도움이 됐음
      문제를 작업 사이로 흐르는 데이터로 구조화하고 큐로 연결하며 공유 상태를 피하는 방식은, 어떤 언어를 쓰든 다중 스레드를 다루는 더 나은 방법임
    • 현명한 프로그래머가 말했듯이, “메모리를 공유해서 통신하지 말고, 통신을 통해 메모리를 공유하라”
  • async는 사실상 훨씬 어려운 Rust이고, 실제로 필요한 프로젝트는 1%쯤일 텐데 모두에게 강요되다시피 된 건 아쉬움
    다만 그 1%에서는 정말 훌륭함. linkerd나 nginx처럼 대량의 네트워크 호출을 핵심으로 처리하는 서비스, 게임에서 엄청난 수의 가벼운 작업을 돌리는 경우, 임베디드에서 협력적 동시성이 필요한 경우에는 async Rust가 강력한 무기가 됨
    대부분의 시스템·애플리케이션 수준 코드는 비동기 입출력이 필요 없음. REST 앱은 스레드 풀로 충분하고, async가 필요해도 네트워크 같은 작은 부분에 한정하고 나머지는 스레드와 채널로 연결하는 혼합 모델이 보통 맞음
    Rust 커뮤니티는 async를 너무 무분별하게 모든 곳에 써서, 사용자 경험이 더 나은 블로킹 입출력 Rust가 생태계에서 2등 시민이 되어버렸음. 웹 프레임워크도 Axum, Warp 같은 잘 설계된 비동기 프레임워크는 여럿인데, 블로킹 쪽은 tiny_http, rouille, astra처럼 선택지가 훨씬 제한적임

    • 핵심은 Rust가 코루틴을 잘못 구현했다는 데 있음
      스택 없는 코루틴을 택하면서 async/await와 색깔 함수 문제가 생겼고, 글에서 말한 마찰이 생김. Go는 스택 있는 코루틴을 쓰기 때문에 이런 문제가 없음
      Rust도 처음에는 스택 있는 코루틴을 고려했지만, 코루틴 선점 런타임이 필요하고 비용이 크다고 보고 스택 없는 모델로 갔음. 하지만 대부분은 런타임 없는 async Rust를 쓰지 않고 Tokio를 쓰며, Tokio는 피하려던 런타임이 하던 일을 사실상 다 함
      그래서 다수의 async Rust 사용자는 양쪽의 나쁜 점을 모두 갖게 됨. 임베디드 쪽에서는 아주 얇은 런타임으로 async Rust를 쓰기도 하지만, 그 수도 적고 그들조차 완전히 설득된 것은 아님
    • 내 프로그램에 Tokio가 또 의존성으로 끌려 들어온 걸 봤음. 직접 쓰지도 않는데, 어떤 크레이트의 내가 쓰지 않는 함수가 reqwest를 가져오고, 그게 h2를 가져오고, 다시 tokio를 가져옴
    • 플랫폼이 가상 스레드를 지원한다면 async를 쓸 이유가 있는지 궁금함
      Java를 쓰는 입장에서, 비동기 패러다임 전체를 버리고 블로킹이 괜찮은 가상 스레드 위의 블로킹 모델로 코드를 다시 작성하려는 중임
  • async가 너무 많은 크레이트에 퍼져 있어서, 프로그램 전체가 async가 되거나 최소한 많은 일에 Tokio에 의존하게 됨
    웹 서버를 원하면 async + tokio 아니면 꺼지라는 식이고, SQL 커넥터도 비동기를 원하지 않으면 직접 써야 할 분위기임. 각자 async가 가져오는 문제를 다른 방식으로 풀고 있고, async 클로저 같은 건 컴파일러에 지옥문을 여는 느낌임
    Rust 자체와 컴파일러가 문제 해결을 도와주는 점은 좋지만, 생태계가 “async 아니면 직접 만들어라”에 가까운 건 충분하지 않음

    • 표준 라이브러리나 futures 크레이트에 더 나은 비동기 기본 요소가 있었다면 고통을 많이 줄일 수 있었음
      실행기가 구현해야 하는 특성이나, 동기 코드에서 비동기 코드를 실행할 기본 블로킹 실행기 같은 것이 필요함. 지금은 여러 비동기 런타임을 지원하는 라이브러리를 만드는 것만 해도 고역이라, 결국 Tokio만 지원하거나 기껏해야 async-std를 추가하는 식이 됨
  • async Rust 전문가는 아니지만 이번 달에 동기 Rust를 몇천 줄 작성하면서 느낀 건, rustc가 어떤 접근을 어렵게 만들 때는 보통 그럴 만한 이유가 있고 비슷한 결과를 더 나은 방식으로 얻는 길이 있다는 것임
    언어를 배우는 중이라면 먼저 평범한 동기 코드, 반복문과 조건문, 대여 규칙에 익숙해지는 편을 권함. async는 구현뿐 아니라 “비동기란 무엇이고 사용자에게 어떻게 보여야 하는가”라는 철학적 수준에서도 아직 많이 발전 중임
    컴파일러는 특성에 크게 의존하지만, 특성이 async를 다루는 기능은 안정화되지 않았음. 예를 들어 https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-i... 같은 작업이 있음
    특성의 비동기 기능이 안정화되지 않았다면, Rust의 비동기 코드가 아직 예쁘지 않다고 공격하는 건 결국 완성될 책의 초기 초안을 비판하는 것과 비슷함

    • “좋은 비동기 API 설계”가 무엇인지 궁금함. 완전히 비동기 중심으로 가면서 확장 가능하고 유지보수 가능하며 이해하기 쉬운 서버를 설계한다면 어떤 모습이어야 할까
      비동기가 코드베이스 전체로 퍼지는 것도 어떻게 막을 수 있을지 고민됨
      현재 구상은 입출력 스레드가 liburing이나 epoll의 시스템 이벤트를 “submit”과 “handle” 두 단계로 나눠 다른 컴포넌트로 보내는 구조임. 예를 들어 tcp-connection을 만들면 “쓰기 준비됨”, “읽기 준비됨” 같은 비동기 이벤트를 구독할 수 있고, 쓰기 준비 이벤트는 일반 mutex로 채워진 버퍼에서 데이터를 꺼내 EPOLLOUT/io_uring_prep_writev로 보냄
      스레드 간 이벤트 전달에는 LMAX Disruptor 패턴의 다중 생산자·다중 소비자 링 버퍼를 쓸 수 있음. 애플리케이션 스레드나 스레드 풀은 각자 이벤트 루프를 가지고 이 링 버퍼를 처리함
      비동기 이벤트 발화 순서를 표현하는 문법도 작업 중인데, Bash 파이프라인처럼 생겼고 statelines라고 부름: initialstate1 initialstate2 = state1 | {state1a state1b state1c} {state2a state2b state2d} | state3
    • 안정화되지 않았다면 프로덕션에서도 쓰면 안 됨
    • Rust 초보라고 가정하는 댓글들이 재미있음. 오히려 글쓴이의 경험이 그들보다 많을 수도 있음
  • Arc의 수명은 알 수 없는 게 아니라 어디서 어떻게 보유하느냐로 결정됨
    이 글의 괴리는 작성자가 Rust를 배워 언어에 맞춰 일하기보다, 가비지 컬렉션 같은 이전의 사고 모델을 Rust에 억지로 적용하려는 데서 오는 것 같음. 새 언어를 배울 때 흔한 함정이지만 Rust는 특히 더 자주 걸려 넘어지게 함

    • 그런 의미라면 가비지 컬렉션 시스템의 객체 수명도 “참조되는 동안”이라는 하한은 있음
      하지만 그건 컴파일 시간에 객체 수명을 정적으로 제한하려는 대여 검사기의 목표와 거의 반대임
      실제로는 반대에 가까웠음. C, C++, Rust로 시스템 프로그래밍을 약 10년 하다가 현재 직장에서 Haskell을 많이 쓰게 됐고, 큰 언어 런타임과 가비지 컬렉션이 어떤 문제 영역에서는 괴물이 아니라는 점이 꽤 눈을 뜨게 했음
    • 비판의 상당 부분이 그런 느낌임. async 변환이 비동기 아닌 코드에서 컴파일러가 할 수 있는 최적화를 어떻게 방해하는지에 대한 글일 줄 알았음
      Weak와 씨름한다는 대목은 복잡한 소유권 구조를 만들려는 것으로 보이는데, 이는 Rust 전반에서 쉽지 않은 일임. 나는 약한 스마트 포인터를 매우 드물게 씀
      채널은 거의 언급되지 않는데, 비동기 코드나 비동기·동기 코드 사이를 연결할 때 프로그램의 서로 다른 부분을 통신시키는 주된 도구임. Notify, 세마포어 같은 신호 추상화도 있음
      mutex는 느리고 병목이 되기 쉽고, 공유 상태는 빠르게 복잡해짐. 이는 오래전부터 알려진 사실임. 문제는 애초에 BIG_GLOBAL_STATIC_REF_OR_SIMILAR_HORROR 같은 구조에 있을 수 있음
      비동기 컨텍스트에서 블로킹 코드를 호출하는 걸 막지 못한다는 지적은 타당하지만, 필요하면 tokio::spawn_blocking 같은 것으로 비교적 관리 가능함
    • 참조 카운팅도 가비지 컬렉션의 한 종류임 https://en.wikipedia.org/wiki/Garbage_collection_(computer_s...
      작성자는 Arc가 무엇이고 어떻게 동작하는지 알고 있을 가능성이 높고, 요지는 Rust async에서는 동기 코드보다 일반 RAII 대신 Arc를 훨씬 자주 쓰게 된다는 것에 가까움
      프로그램 객체의 90%가 참조 카운팅된다면, 많은 작은 힙 할당·해제와 원자 연산 비용을 치르느니 추적 가비지 컬렉션을 쓰는 편이 나을 수도 있음. Tokio 튜토리얼 예제도 비슷한 방향을 보임: https://tokio.rs/tokio/tutorial/shared-state
      Rust에서 실제 추적 가비지 컬렉션이 HTTP 서버 같은 일반적인 비동기 애플리케이션을 의미 있게 빠르게 만들 수 있을지 궁금함: https://manishearth.github.io/blog/2015/09/01/designing-a-gc...
    • Arc의 수명은 무작위가 아니라 정적으로 알 수 없는 것임
    • Rust의 Arc는 이동하거나 빌릴 수 있고, 참조 카운트를 건드리지 않고 사용할 수도 있음
      많은 경우 암시적 참조 카운팅 언어의 객체보다 훨씬 저렴함
  • Rust는 좋아하지만 async는 난장판이고, 동기 코드를 쓰듯 비동기 코드를 쓸 수는 없음
    둘을 섞는 건 나쁜 생각 같다는 확신이 점점 커지고 있고, 모든 것을 동기식으로 두고 async 채널 원시 요소 하나만 제공하는 Go식 접근이 맞을 수도 있음
    지금 Future를 구현하는 구조체에서 동기 메서드를 호출하도록 로직을 배관 중인데 꽤 흥미로운 도전임. 무비용 비동기 추상화를 사용자에게 어느 정도 쉽게 만들 수는 있어도, 고통은 라이브러리 개발자가 떠안게 됨

    • 마지막에는 동의하지 않음. async는 최종 사용자에게도 확실히 고통스럽고, Rust의 핵심 기능인 수명과 명시적 타입이 빠진 별도 언어를 쓰는 느낌에 Pin이 잔뜩 뿌려진 것 같음
      범위가 있는 파이버를 실행할 수 없어 결국 Arc를 잔뜩 붙이게 되고, Pinunsafe 없이는 쓰기 어려우며, 비동기 함수의 아주 작은 변경이 코드베이스 전체의 future를 !Send로 만들 수 있음
    • 라이브러리 개발자는 사용자보다 복잡성을 감당할 여력이 더 큼. 기본 인프라를 만드는 숙련된 개발자에게 그런 작업을 맡기는 건 맞는 방향임
    • Rust용 wasm VM 중 투명한 M:N 스케줄링처럼 보이는 것을 제공하는 사례를 봤고, 그런 방식이면 대부분의 async 어려움을 풀 수 있을 듯함. 어떻게 발전할지 지켜봐야 함
  • Async Everything은 나쁜 언어임
    async/await는 JavaScript에 제대로 된 블로킹 스레드가 없는 문제를 고치려던 끔찍한 아이디어였고, 이제 모든 언어에 덧붙여지고 있음. 언어와 라이브러리 생태계를 둘로 갈라 앞으로도 오랫동안 고통을 만들 것임
    JavaScript 바깥에서 다중 스레딩을 해 본 사람이라면, 액터나 통신 순차 프로세스가 다중 스레딩에 가장 좋은 방식이라는 걸 알고 있음
    Joe Armstrong의 논문에서도 다중 스레드 프로그램을 이해하는 유일한 방법은 각 스레드마다 엄격히 순차적인 코드를 작성하고, 여러 스레드의 코드를 한곳에 뒤섞지 않는 것이라고 설명함. 문제의 실제 동시 활동 하나가 프로그래밍 언어의 동시 프로세스 하나에 정확히 대응해야 개념적 간극이 최소화됨: https://erlang.org/download/armstrong_thesis_2003.pdf
    Java의 Project Loom을 구현한 Ron Pressler의 async/await 비판도 좋음: https://www.youtube.com/watch?v=oNnITaBseYQ

    • JavaScript를 싫어하는 건 재미있지만, Ryan Dahl이 Node.js를 처음 소개한 발표를 다시 보면 흥미로움: https://www.youtube.com/watch?v=EeYvFl7li9E
      그는 JavaScript 자체에는 꽤 양가적이었고, 주된 목표는 epoll() 입출력 이벤트 루프를 눈을 찌르고 싶지 않게 다룰 추상화를 찾는 것이었음. 그 전에 다른 방식도 많이 시도했음
    • async/await는 실제로 JavaScript가 아니라 C# 에서 시작됐음
      C#의 Anders Hejlsberg는 TypeScript도 만들었고, TypeScript의 클래스, 화살표 함수, async/await 같은 기능이 결국 ES6+로 들어갔음
      단일 스레드 이벤트 루프인 JS/TS에서는 훌륭한 해법이었다고 봄. 하지만 언어가 저수준일수록 추상화로서 더 나빠지므로, 여기서 나온 async Rust 비판은 대부분 타당함
  • 글은 async Rust의 복잡성과 어려움을 잘 설명하지만, Rust의 핵심 철학 중 하나가 성능을 희생하지 않는 메모리 안전성이라는 점도 중요함
    Rust의 비동기 패턴, 특히 컴파일러가 데이터 안전성을 보장하도록 만드는 방식은 이 철학을 잘 보여줌. 복잡성은 있지만, 개발자가 데이터와 실행 흐름을 깊이 생각하게 하는 더 안전한 동시성 모델이라는 가치가 있음
    모든 대규모 동시 사용자 공간 애플리케이션에 Rust가 정답은 아닐 수 있지만, 견고성과 안전성이 최우선인 시스템에서는 절충이 정당화될 수 있음. 생태계가 발전하면서 이런 고통을 줄이는 추상화와 라이브러리도 더 나올 가능성이 큼

  • async 기반의 락 프리 Rust를 많이 작성하고 있음. 주요 문제는 Tokio future가 'static이라는 점이고, 이는 Rust 생태계 깊숙이 박힌 설계 실수, 즉 메모리 누수가 안전하다는 결정에서 옴
    이 때문에 future가 제대로 정리된다는 것을 정적으로 보장할 수 없음. 어떤 비동기 작업을 생성했을 때 누군가 std::mem::forget으로 future를 잊어버리면, 그 future가 전이적으로 넘긴 참조가 여전히 살아 있다는 걸 대여 검사기가 알 수 없음
    Arc를 여기저기 뿌리기보다는 이런 unsafe 크레이트를 사용함: https://docs.rs/async-scoped/latest/async_scoped/
    이 정도면 C++에서 만들었을 버그의 99%를 잡아주므로 합리적인 절충임. 안전한 방식의 비-'static future 구현 작업도 진행 중이라 성공하길 바람
    또 다른 큰 문제는 async trait가 현재 박싱된 future를 요구해서 함수 호출 경계마다 malloc/free가 추가된다는 것인데, 올해 수정 로드맵에 있음
    “그냥 채널을 쓰라”는 조언도 큰 코드베이스에서는 제어 흐름을 사방으로 흩뜨림. 채널은 현대판 GOTO처럼 느껴지고, 나도 쓰긴 하지만 몇 가지 일을 병렬로 실행한 뒤 완료를 기다리기만 하는 경우에는 잘 쓰지 않음

    • 중요한 구분은 Tokio future 자체가 'static인 게 아니라, 런타임의 동시성을 활용해 spawn할 수 있는 future가 'static뿐이라는 점임
      future는 poll()되려면 Pin되어야 하고, PinT: !Unpin은 결국 Drop을 호출해야 함: https://doc.rust-lang.org/std/pin/#drop-guarantee
      컴파일러의 async 기능으로 생성된 future는 이런 성질을 가지며, 수동 future에도 PhantomPinned을 넣을 수 있음. 이 덕분에 poll()된 뒤 mem::forget 장난은 미정의 동작으로 가정할 수 있고, 침투형·자기참조 future 라이브러리도 가능해짐: https://docs.rs/futures-intrusive/latest/futures_intrusive/
      future가 Arc/Rc에 의해 계속 살아남아 누수될 수는 있지만, 라이브러리 개발자 입장에서는 정상 사용과 합리적으로 구분할 수 없거나 신경 쓸 필요가 크지 않음
    • 메모리 누수가 안전하다는 점을 설계 실수로 본다면, 내부 가변성을 없애거나 Rc를 없애거나, 아니면 감염성 unsafe 특성 경계를 붙이는 쪽을 선호하는지 궁금함