비동기 Rust의 현재 상태를 돌아보며, Future 취소, Send 의미 변화, 플로우 제어, 태스크 남발과 Arc-화, 프로그램 관측, tokio와 async-std, no-std 호환성 등 실전에서 겪은 문제와 실용적 해결책을 공유한다.
읽는 데 15분
2021년 3월 19일
2013년에 Rust 프로그래밍 언어를 알게 되었고, 곧바로 배우기로 결심해 제 주력 언어로 삼았습니다.
2017년 베를린으로 이주해 Parity에서 Rust 개발자로 합류했습니다. 첫 몇 달 동안 맡은 일은 비동기 Rust로 된 P2P 라이브러리인 rust-libp2p(현재 약 8만 9천 줄)를 만드는 것이었습니다. 그 후에는 이를 Substrate(약 40만 줄)에 통합했고, 그때부터 코드 베이스의 네트워킹 부분을 유지보수하고 있습니다.
최근의 이 블로그 글과 이 트위터 상호작용을 보고, 그동안 경험을 통해 겪은 몇 가지 문제를 정리해 두면 좋겠다고 생각했습니다.
주의: 이 글은 제 고용주인 Parity를 대변해 작성한 것이 아닙니다. Parity 관련 프로젝트 중 비동기성이 특히 강한 부분을 작업하며 겪은 개인적 피드백일 뿐이며, 게시 전에 누구에게도 보여주지 않았고 회사의 다른 개발자들이 말할 내용과 다를 수도 있습니다.
최근과 그 이전에 비동기 Rust를 둘러싼 논쟁과 논의가 있었기에, 서문을 덧붙여야 할 의무감을 느낍니다.
무엇보다, 비동기 Rust는 전반적으로 매우 잘 되어 있다고 말하고 싶습니다. 이 글은 비동기 Rust에 이미 익숙한 프로그래머를 주된 대상으로, 설계가 더 나아질 수 있는 방향에 대한 제 의견을 전달하기 위한 것입니다.
Rust 프로그래머가 아니고, 비동기 프로젝트에 Rust를 써야 할지 판단하기 위해 이 글을 읽고 있다면, 오해하지 마세요. 저는 Rust를 강하게 옹호합니다. 제가 아는 어느 다른 언어도 근접하지 못합니다.
문제에 초점을 맞추긴 하지만, 잘못된 인상을 주지 않기 위해 글 제목을 “비동기 Rust의 문제들”로 하지는 않았습니다.
비동기 Rust가 만들어지던 수년 동안 커뮤니티에는 많은 긴장이 있었습니다. 나는 압도적으로 많은 의견의 흐름을 다루고 논쟁에 에너지를 쏟은 분들을 존중합니다. 바로 그 이유로 지난 4–5년간 저는 Rust 커뮤니티에서 한발 물러나 있었고, 여기서 비판할 생각은 전혀 없습니다.
과거( futures 0.1과 0.3 )에 대해 너무 집중하기보다는, 이 피드백의 목적이 결국 앞으로 나아가는 데 있으므로 현재 상태에 더 초점을 맞추겠습니다.
이제 서론은 이쯤에서 마치고, 문제적인 주제들로 들어가겠습니다.
현재 비동기 Rust에서 가장 문제가 되는 이슈라고 생각하는 것부터 시작하겠습니다. 바로, 어떤 Future를 버려도(drop) 버그가 생기지 않는지 아는 문제입니다.
예시로 설명하겠습니다. 파일에서 데이터를 읽고, 파싱한 뒤, 항목들을 채널로 보내는 비동기 함수를 작성한다고 해봅시다. 예를 들어(의사 코드):
rustasync fn read_send(file: &mut File, channel: &mut Sender<...>) { loop { let data = read_next(file).await; let items = parse(&data); for item in items { channel.send(item).await; } } }
비동기 코드에서 각 await 지점은 실행이 중단될 수 있는 순간이며, 제어권이 이 Future의 사용자에게 넘어갈 수 있습니다. 사용자는 원한다면 이 시점에서 Future를 drop하여 실행을 완전히 멈출 수 있습니다.
사용자가 read_send 함수를 호출하고 두 번째 await 지점(채널로 전송)까지 poll한 다음 read_send Future를 파기하면, 모든 지역 변수(data, items, item)는 조용히 드롭됩니다. 이렇게 되면 사용자는 file에서 데이터를 읽어 왔지만, 그 데이터를 channel로 보내지 않은 꼴이 됩니다. 데이터가 그냥 사라지는 것이죠.
왜 사용자가 이렇게 할까요? 왜 Future를 조금만 poll한 다음 끝나기 전에 파기할까요? 바로 futures::select! 매크로가 정확히 그렇게 할 수 있기 때문입니다.
rustlet mut file = ...; let mut channel = ...; loop { futures::select! { _ => read_send(&mut file, &mut channel) => {}, some_data => socket.read_packet() => { // ... } } }
이 두 번째 코드에서, 사용자는 read_send를 호출해 poll하지만, socket이 패킷을 받으면 read_send Future는 파기되고 다음 반복에서 다시 생성됩니다. 앞서 설명했듯, 이는 파일에서 읽어 온 데이터를 채널로 보내지 못한 채 잃게 만들 수 있습니다. 여기서 사용자가 의도한 바는 아마 이게 아닐 것입니다.
명확히 하자면: 이것은 설계대로 동작하고 있습니다. 문제는 무엇이 일어나는지가 아니라, 일어난 일이 사용자의 의도와 다르다는 점입니다. 어떤 상황에서는 사용자가 read_send가 완전히 멈추기를 원할 수도 있고, 그런 상황은 허용되어야 합니다. 문제는 여기에선 우리가 그걸 원하지 않는다는 것입니다.
이 문제에 대한 해결책은 제가 보기엔 네 가지가 있습니다(글을 너무 무겁게 만들지 않기 위해 설명 대신 플레이그라운드 링크만 제공합니다).
select!를 다시 작성합니다. 예시. 해당 상황에서는 논쟁의 여지 없이 가장 좋은 방법이지만, 예컨대 소켓이 메시지를 받았을 때 다른 File로 Future를 다시 만들어야 한다면 복잡성이 커질 수 있습니다.read_send가 읽기와 전송을 원자적으로 수행하도록 보장합니다. 예시. 제 생각엔 전반적으로 가장 좋은 해결책이지만, 항상 가능한 것도 아니고, 복잡한 상황에서는 오버헤드를 유발할 수 있습니다.read_send의 API를 바꿔서 yield 지점에 걸친 어떤 지역 변수도 갖지 않게 만듭니다. 예시. 실전 예시. 이것도 좋은 해법이지만, 수동으로 Future를 작성하는 것과 위험할 정도로 가까워져 코드 작성이 어려울 수 있습니다.select!를 쓰지 말고 읽기를 수행하는 백그라운드 태스크를 스폰하세요. 필요하다면 채널로 백그라운드 태스크와 통신하세요. 채널에서 아이템을 끌어오는 것은 취소에 탄력적입니다. 예시. 대개 최고의 해결책이지만, 지연(latency)을 추가하고 file과 channel에 다시는 접근할 수 없게 만듭니다.이 문제는 어떻게든 항상 해결할 수 있습니다. 하지만 제가 강조하고 싶은 더 큰 문제는, 이런 종류의 취소 이슈는 발견하고 디버그하기가 매우 어렵다는 점입니다. 문제의 예에서 관찰되는 것은 가끔 아주 드물게 파일의 일부가 건너뛰어지는 것뿐일 것입니다. 패닉도, 문제의 원인을 가리키는 분명한 징후도 없을 것입니다. 이는 당신이 마주칠 수 있는 최악의 종류의 버그입니다.
여러 개발자가 같은 코드 베이스에서 작업하는 경우엔 더 문제입니다. 어떤 개발자는 어떤 Future가 취소에 안전하다고 생각할 수 있지만 사실은 아닐 수 있습니다. 문서는 오래되었을 수 있습니다. 개발자가 Future 구현을 리팩터링하다가 우연히 취소에 안전하지 않게 만들 수도 있고, select! 부분을 리팩터링하면서 Future가 이전과 다른 타이밍에 파기되게 만들 수도 있습니다. Future가 파기되었다 다시 만들어져도 제대로 동작하는지 확인하는 단위 테스트를 작성하는 일은 극도로 고됩니다.
저는 보통 다음 가이드를 제시합니다. 당신의 비동기 코드가 백그라운드 태스크로 스폰될 것이 확실하다면, 원하는 대로 하세요. 확실하지 않다면 취소에 안전하게 만드세요. 그게 너무 어렵다면, 백그라운드 태스크를 스폰하도록 코드를 리팩터링하세요. 이 가이드는 Future의 구현과 그 사용자가 아마 서로 다른 개발자일 것이라는 사실을 염두에 둡니다. 작은 예제에서는 흔히 무시되는 상황이죠.
Rust 언어 자체를 개선하는 방안으로는, 안타깝지만 제게 구체적인 해법은 없습니다. yield 지점을 가로지르는 지역 변수를 금지하는 clippy 린트가 제안되었지만, 단지 장수하는 이벤트 루프에서 태스크가 스폰된 경우까지 감지하긴 어려울 것입니다. select!에 InterruptibleFuture 트레이트를 요구하는 방안도 상상할 수 있겠지만, 비동기 Rust의 접근성을 더 해칠 가능성이 큽니다.
Rust에서 Send 트레이트는 해당 타입이 스레드 간에 이동 가능함을 의미합니다. 일상적으로 다루는 대부분의 타입들, 예컨대 String, Vec, 정수 등은 이 트레이트를 구현합니다. 사실 Send를 구현하지 않는 타입을 나열하는 것이 더 쉬운데, 그중 하나가 Rc입니다.
Send를 구현하지 않는 타입은 대개 더 빠른 대안입니다. Rc는 Arc와 같지만 더 빠릅니다. RefCell은 Mutex와 같지만 더 빠릅니다. 이를 통해 프로그래머는 자신이 하는 일이 단일 스레드에 국한될 때 최적화를 선택할 수 있습니다.
하지만 비동기 함수는 이 생각을 어느 정도 깨버렸습니다.
아래 함수가 백그라운드 스레드에서 실행된다고 가정하고, 이를 비동기 Rust로 다시 쓰고 싶다고 해봅시다:
rustfn background_task() { let rc = std::rc::Rc::new(5); let rc2 = rc.clone(); bar(); }
여기에 그냥 async와 await를 적당히 붙이고 싶어질 수 있습니다:
rustasync fn background_task() { let rc = std::rc::Rc::new(5); let rc2 = rc.clone(); bar().await; }
하지만 background_task()가 반환하는 Future가 Send를 구현하지 않기 때문에, 이를 이벤트 루프에 스폰하려 하면 벽에 부딪힙니다. Future 자체가 스레드 사이를 이동할 수 있기 때문에 그 요구 사항이 생기는 것이죠. 그러나 이 코드는 이론적으로 완전히 건전합니다. Rc가 태스크를 벗어나지 않는 한, 그 클론들은 한 번에 하나씩만 복제되거나 드롭될 수 있음이 보장되며, 바로 그 지점에서만 잠재적 비안전성이 존재합니다.
이 작가의 글을 메일로 받아보세요. 무료로 Medium에 가입하세요.
논쟁의 여지가 있겠지만, Send 트레이트를 “스레드 또는 태스크 경계를 넘어 이동 가능한 객체”로 의미를 바꿀 수도 있습니다. 하지만 그렇게 하면 OpenGL이나 GTK처럼 스레드가 중요한 FFI 관련 용례에서 !Send에 의존하는 코드들이 깨질 것입니다.
Substrate를 포함한 많은 비동기 프로그램은 일반적으로 액터 모델이라 부르는 설계에 기반합니다. 이 설계에서는 태스크들이 백그라운드에서 병렬로 실행되며 서로 메시지를 교환합니다. 할 일이 없으면 태스크는 잠듭니다. 저는 비동기 Rust 생태계가 이런 방식으로 프로그램을 설계하도록 장려한다고 생각합니다.
태스크 A가 무제한 채널을 통해 태스크 B로 계속 메시지를 보내고, 태스크 B가 이 메시지를 처리하는 속도가 태스크 A가 보내는 속도보다 느리다면, 채널의 아이템 수는 영원히 증가하고 사실상 메모리 누수가 됩니다.
이 상황을 피하려면 유한 버퍼(바운디드) 채널을 쓰고 싶어질 겁니다. 태스크 B가 태스크 A보다 느리면 채널의 버퍼가 차오르고, 꽉 차면 태스크 A는 진행 전에 빈 슬롯을 기다려야 하므로 속도가 느려집니다. 이 메커니즘을 역압(back-pressure)이라 합니다. 그러나 태스크 B도 의도적이든 아니든 태스크 A로 메시지를 보낸다면, 반대 방향의 두 유한 채널을 사용하면 둘 다 가득 찼을 때 교착상태가 됩니다.
더 복잡한 경우: 태스크 A가 태스크 B로, B가 C로, C가 다시 A로 메시지를 보내며 모두 유한 채널을 쓴다면, 채널들이 모두 가득 차며 교착될 수 있습니다. 이런 문제를 감지하는 것은 거의 불가능하며, 해결하는 유일한 방법은 이 우려를 인지한 올바른 코드 아키텍처를 갖추는 것입니다.
이 유한 채널 관련 교착의 최악의 부분은, 처음에는 발생하지 않다가, 어떤 무관한 코드 변경이 태스크의 CPU 프로파일을 바꿨을 때에만 드러날 수 있다는 점입니다. 예를 들어, 보통은 태스크 B가 A보다 빠르지만, 누군가 어떤 기능을 추가해 A가 더 빨라지면 교착이 드러나며 갑자기 일이 악몽이 됩니다.
이 문제는 Rust에만 국한된 것이 아닙니다. 네트워킹과 액터 모델 전반의 근본적인 문제입니다. 다만 강조하고 싶은 것은, Rust는 비동기 프로그램 작성이 쉬운 듯한 불운한 착시를 주지만 실제로는 전혀 그렇지 않다는 점입니다.
Rust 철학의 근본적 강점 중 하나는, 초보자에게 코드를 쓰게 해도 최악의 경우 패닉이 나거나 동작하지 않을 뿐이라는 점입니다(C/C++과 달리 공격자에게 컴퓨터의 제어권을 넘겨줄 수 있죠). 초보자가 실수로 뮤텍스 기반 교착을 도입할 수는 있지만, 이런 종류의 교착은 대체로 국소적이며, 보통은 뮤텍스를 제거하거나 작은 코드 재구성으로 피할 수 있습니다. 실전에서도 큰 문제가 되지 않는 것이 증명되었습니다.
하지만 올바른 흐름 제어를 작성하는 일은 진짜 문제이며, 초보자 친화적이지 않습니다. 실제로 지난 몇 주 동안 Polkadot 코드 베이스에서 잘못된 흐름 제어로 인한 교착을 디버깅하는 데 많은 에너지를 썼습니다. 이는 해당 주제에 깊이 익숙하지 않은 프로그래머가 도입했거나, 아마 더 문제적인 것은 코드 리팩터링 도중 우연히 도입된 것이었습니다. 여기서는 두려움 없는 리팩터링이라는 유명한 약속이 그다지 지켜지지 않습니다.
실용적 해법으로, 우리는 Substrate에 “모니터링되는 무제한 채널”을 도입했습니다. 각 채널로 들어가고 나가는 아이템 수를 Prometheus 클라이언트에 보고하고, 두 수의 차이가 특정 임계를 넘으면 경보를 발생시킵니다. 유한 채널도 사용하지만, 흐름 제어가 실제로 중요한 곳(예: 네트워킹)에만 씁니다. 이는 실용적 접근으로서 효과가 있음을 증명했습니다.
생태계가 건전하다고 알려진 비동기 코드 패턴(예: 메시지를 받고 응답을 돌려주는 백그라운드 태스크, 혹은 tokio의 브로드캐스트 채널)에 대한 헬퍼를 제공한다면 상황이 크게 개선될 것 같습니다. 합리적인 모든 사용 사례에 대한 대안이 생긴 뒤에는, 일반적인 유한 채널에 경고를 붙일 수도 있겠습니다.
“Future 취소 문제” 절에서 설명했듯, 취소 문제의 가장 직관적인 해결책은 추가 백그라운드 태스크를 스폰하는 것입니다. 특히 2~3개 이상의 Future를 병렬로 poll해야 할 때, 추가 백그라운드 태스크를 스폰하면 문제들이 대체로 해결됩니다.
백그라운드 태스크가 있으면, 태스크들 사이에서 데이터를 공유하는 일은 채널 또는 Arc(필요 시 Mutex 포함/미포함)를 통해 이루어집니다. 이는 코드의 이른바 ‘Arc-화’로 이어지는 경향이 있습니다. 스택에 객체를 두는 대신, 모든 것을 Arc로 감싸 서로 전달합니다.
모든 것을 Arc에 넣는 것 자체가 문제는 아닙니다. 문제는… 이러면 마치 더 이상 Rust를 쓰는 느낌이 아니라는 것입니다.
예컨대 String 대신 &str처럼 참조로 데이터를 전달하라고 권장하는 언어가, 한편으로는 코드를 작은 태스크들로 쪼개 서로 주고받는 모든 데이터를 복제(clone)하게끔 장려합니다. 가비지 컬렉터가 필요 없도록 복잡한 수명 시스템을 제공하는 언어가, 이제는 모든 것을 Arc에 넣으라고 권장하기도 합니다.
제로 코스트 추상화의 관점에서, 프로그래머는 태스크가 병렬로 실행되도록 하고 싶을 때에만 코드를 태스크로 나눠야 합니다. 하지만 비동기 Rust의 일반적 분위기는, 프로그램 로직상 동시에 실행될 수 없음이 사전에 알려진 태스크들까지 포함해 수백, 수천 개의 태스크를 스폰하는 쪽으로 기울어 있습니다.
이게 정말 문제라고 생각하지는 않지만, 이제 하나의 언어 안에 두 개의 언어가 있는 느낌이 듭니다. CPU 전용 연산을 위한 하위 수준 언어(참조를 사용하고 모든 소유권을 정밀 추적)와, I/O를 위한 상위 수준 언어(모든 문제를 데이터 복제나 Arc로 싸서 해결) 말이죠.
모든 것을 동기에서 비동기로 전환하면서 생긴 불행한 결과이자, Substrate를 작성할 때 충분히 예상하지 못했던 점은, 유닉스 세계의 모든 디버깅 도구가 스레드를 전제로 한다는 것입니다.
메모리 누수의 원인을 찾고 싶나요? 각 스레드에서 코드가 얼마나 메모리를 할당/해제했는지 보여주는 도구가 있습니다. 어떤 코드 조각이 얼마나 바쁜지 알고 싶나요? 각 스레드가 시간에 따라 얼마나 CPU를 쓰는지 보여주는 도구가 있습니다. 이 모든 도구는 훌륭하지만, 코드가 스레드 사이를 수시로 점프하는 상황에서는 쓸모가 없습니다.
앞서 언급한 “모니터링되는 무제한 채널”과 비슷하게, 우리는 모든 장수 태스크를 래퍼로 감싸 태스크가 몇 번 poll되었고 얼마나 오래 실행되었는지를 Prometheus 클라이언트에 보고하도록 했습니다.
엔터를 누르거나 이미지를 클릭해 전체 크기로 보기

각 태스크가 얼마나 CPU를 사용하는지 보여주는 예시 그래프
이는 처음 생각보다 더 유용했습니다. CPU 사용량을 넘어, 예컨대 장수 태스크가 오랫동안 poll되지 않았을 때(예: 교착), 혹은 태스크가 poll되기 시작했지만 끝나지 않을 때(예: 무한 루프에 갇힘)를 감지할 수 있게 해줍니다.
이런 측정이 현실적이라는 점은, 유저 공간이 태스크가 언제 어떻게 실행되는지를 통제하기 때문입니다. 제 경험은 부족하지만, 동기 세계에서는 이만큼 쉽지 않을 것이라 상상합니다. 이 점에서는 비동기 Rust의 승리입니다.
이 주제는 마지막으로 남겼습니다.
생태계가 tokio와 async-std로 갈라진 문제에 관해 많은 논의가 있었습니다. 두 라이브러리 모두 자신들의 타입과 트레이트를 정의하고, 애플리케이션 작성자든 라이브러리 작성자든 둘 중 하나를 선택해야 합니다. 예를 들어 libp2p에는 TcpConfig와 TokioTcpConfig가 둘 다 있습니다.
기술적 차이는 절충에서 옵니다. tokio는 같은 스레드를 사용해 커널 이벤트를 poll하고 비동기 태스크를 실행합니다. 그 대가로 스레드 로컬 기반의 다크 매직이 있습니다. 반면 async-std는 커널을 poll하는 스레드를 별도로 띄우므로 더 느립니다.
속도 면에서 tokio가 우위지만, 주된 단점은 tokio 소켓이나 타이머를 poll하는 코드가 tokio 실행기(executor) 안에서만 동작한다는 점입니다(아니면 패닉). 이는 소켓을 poll하거나 타이머를 실행하는 코드와 태스크를 실행하는 코드 사이에 어떤 암묵적 관계를 추가합니다. 이 둘은 보통 아주 멀리 떨어져 있으며(심지어 같은 저장소에 있지 않을 수도 있음) 말이죠.
이 점은 futures 0.1에서 0.3으로 생태계가 전환되던 시기에 아주 현실적인 문제로 드러났습니다. 상황은 매우 혼란스러웠고, 어떤 라이브러리는 이미 전환을 마쳤지만 다른 것들은 그렇지 않았습니다. Substrate처럼 코드가 수십만 줄에 이르면, tokio 소켓과 타이머는 같은 버전의 tokio 실행기 안에서만 poll되어야 한다는 요구 사항을 보장하는 것은 거의 불가능했습니다. 그 시기에는 이벤트 루프를 하나에서 세 개까지 병행 실행했습니다. 우리는 설계상 그 문제 전체를 회피하는 async-std로 전환했지만, 현재도 레거시 라이브러리 때문에 tokio 0.1 이벤트 루프를 병렬로 돌리고 있습니다.
생태계 분열을 피하기 위해, 과거에는 비동기 친화 라이브러리가 AsyncRead/AsyncWrite 트레이트 구현에 대해 추상화하고, 사용자에게 tokio나 async-std 중 하나를 꽂아 넣을 수 있게 하자는 제안이 있었습니다. 문제는 tokio와 async-std가 이 두 트레이트의 정의조차 합의하지 못한다는 점입니다.
설사 두 라이브러리가 같은 트레이트 정의를 쓴다고 하더라도, 아무도 주목하지 않는 근본 문제가 하나 더 있습니다. AsyncRead와 AsyncWrite는 API에 std::io::Error를 사용하기 때문에 no-std 호환이 아닙니다. 이 트레이트를 사용하는 어느 라이브러리든 no_std 플랫폼에서는 컴파일할 수 없습니다.
API에 std::io::Error를 쓰면 거의 항상 그 API가 과소 지정(underspecified)되는 결과를 낳는데, 이 두 트레이트도 그렇습니다. libp2p 서브스트림에 AsyncRead와 AsyncWrite를 구현할 때, 아주 실용적인 질문들을 해결해야 했습니다. 예컨대: poll_write가 에러를 반환하면, 그래도 poll_close를 호출해야 하나요? poll_read가 에러를 반환한다면, 같은 객체에 나중에 다시 호출하면 futures처럼 패닉이 날까요? AsyncRead와 AsyncWrite 구현이 “호환”되도록 하기 위해, 우리는 다양한 async-std와 tokio의 콤비네이터 소스 코드를 읽어 이 트레이트 메서드들을 어떻게 호출하는지 가늠해야 했습니다.
저는 std::io::Error와 그 모호함 때문에, 현재 형태의 AsyncRead와 AsyncWrite에 대한 추상화가 좋은 생각이라고 보지 않습니다.
그럼 어떻게 해결할까요? 제가 이끄는 Parity 프로젝트인 smoldot 라이브러리는 좀 더 “투박한” API를 씁니다. 트레이트 대신, 함수에 읽고 채울 수 있는 데이터 버퍼를 직접 전달합니다(문서가 부족한 점 양해 바랍니다). 어떤 이에게는 한 발 물러선 것처럼 보일 수 있지만, 제 생각엔 가장 유연한 접근입니다. 왜 그렇게 생각하느냐고요? 아무 추상화를 쓰지 않기 때문입니다. 이 API는 쓰기 어렵지만, 어려운 문제엔 어려운 해법이 따릅니다.
벌써 내용이 많네요. InfiniteStream 트레이트가 얼마나 갖고 싶은지, rustfmt가 select! 내부 코드를 포맷하지 못한다는 점, 생태계에 no-std 친화적인 비동기 Mutex가 없다는 점 같은 소소한 문제들에는 깊이 들어가지 않았습니다. 아마 다른 분들이 모두 다뤄 주실 거라 생각합니다.
저는 규모를 키울 때 맞닥뜨리는 문제들에 초점을 맞췄습니다. 현재 상태에서는 많은 사람이 작은 비동기 프로젝트는 시도해봤을지 몰라도, 대규모 프로젝트를 해본 사람은 많지 않을 거라 생각합니다.
부정적인 면에도 불구하고, 저는 Rust가 채택한 poll 기반 접근을 전반적으로 정말 좋아합니다. 겪은 문제들의 대부분은 실수가 원인이 아니라, 다른 어느 언어도 이 원칙을 이렇게 멀리 밀고 나가지 않았기 때문에 생긴 것입니다. 프로그래밍 언어 설계는 무엇보다 “예술적” 활동이지 기술적 활동이 아니며, 설계 선택의 결과를 예측하는 것은 거의 불가능합니다.
Rust가 언어 설계의 경계를 실제로 밀어붙이고, 방관하지 않고 반복(iterate)하고 있다는 점이 반갑습니다. 설계의 모든 흠에는 분명 해법이 있을 것이며, 이 기능의 밝은 미래(future, 하하)가 보입니다.
읽어 주셔서 감사합니다.