I/O 집약적 워크로드에서 async-await, 협력적 스케줄링, 이벤트 루프, 그리고 Tokio 런타임의 아키텍처를 자세히 살펴봅니다.
스레드는 연산 집약적인 작업을 병렬화하도록 설계되었습니다. 하지만 오늘날에는 많은 애플리케이션(예: 네트워크 스캐너)이 I/O (Input / Output) 집약적입니다.
따라서 스레드에는 두 가지 큰 문제가 있습니다:
실제로 이는 스레드를 사용할 경우, 애플리케이션이 네트워크 요청이 완료되기를 기다리는 데 많은 시간을 보내고 필요 이상으로 훨씬 많은 리소스를 사용하게 된다는 뜻입니다.
async-await를 환영합시다.
프로그래머의 관점에서 async/await는 스레드와 같은 것들을 제공합니다: 동시성, 더 나은 하드웨어 활용, 향상된 속도. 하지만 I/O 바운드 워크로드에서는 훨씬 더 뛰어난 성능과 더 낮은 리소스 사용량을 제공합니다.
_I/O 바운드 워크로드_란 무엇일까요? 이는 대부분의 시간을 네트워크나 디스크 작업이 완료되기를 기다리는 데 쓰고, 프로세서의 연산 능력에 의해 제한되지 않는 작업들입니다.
스레드는 대부분의 컴퓨팅이 네트워크(웹) 관련 작업이 아니던 오래전에 설계되었기 때문에, 지나치게 많은 동시 I/O 작업에는 적합하지 않습니다.
| operation | async | thread |
|---|---|---|
| Creation | 0.3 microseconds | 17 microseconds |
| Context switch | 0.2 microseconds | 1.7 microseconds |
Jim Blandy가 수행한 이 측정 결과에서 볼 수 있듯이, 컨텍스트 스위칭은 Linux 스레드보다 async에서 대략 8.5배 더 빠르며 메모리는 약 20배 적게 사용합니다.
프로그래밍 언어 세계에서는 I/O 작업을 처리하는 주요 방식이 두 가지 있습니다: 선점형 스케줄링과 협력적 스케줄링입니다.
이 글에는 제 책 Black Hat Rust의 일부 내용이 포함되어 있습니다.
선점형 스케줄링은 작업의 스케줄링이 개발자의 통제 밖에 있으며, 전적으로 런타임에 의해 관리되는 방식입니다. 프로그래머가 동기 작업을 실행하든 비동기 작업을 실행하든 코드상 차이는 없습니다.
예를 들어 Go 프로그래밍은 선점형 스케줄링에 의존합니다.
이 방식의 장점은 배우기 더 쉽다는 것입니다: 개발자 입장에서는 동기 코드와 비동기 코드 사이에 차이가 없습니다. 또한 잘못 사용하기가 거의 불가능합니다. 런타임이 모든 것을 처리해 주기 때문입니다.
다음은 Go에서 HTTP 요청을 보내는 예시입니다:
resp, err := http.Get("https://kerkour.com")
이 코드 조각만 봐서는 http.Get이 I/O 집약적인지 연산 집약적인지 알 수 없습니다.
단점은 다음과 같습니다:
반면 협력적 스케줄링에서는 작업이 I/O를 기다리느라 시간을 보낼 것으로 예상될 때 개발자가 런타임에 알려줄 책임을 집니다. 기다린다고요? 맞습니다. 그것이 바로 await 키워드의 정확한 목적입니다. 이는 작업이 어떤 연산이 끝나기를 기다리느라 시간이 걸릴 것이며, 그동안 연산 자원을 다른 작업에 사용할 수 있다는 사실을 런타임(그리고 컴파일러)에 알려주는 표시입니다.
이 방식의 장점은 엄청나게 빠르다는 것입니다. 기본적으로 개발자와 런타임이 조화를 이루며 함께 협력하여 사용 가능한 연산 성능을 최대한 활용합니다.
협력적 스케줄링의 가장 큰 단점은 잘못 사용하기 더 쉽다는 것입니다: await를 빠뜨리거나(다행히 Rust 컴파일러가 경고를 표시합니다), 이벤트 루프가 몇 마이크로초 이상 블로킹되면(이벤트 루프가 무엇인지 궁금하신가요? 계속 읽어 보세요) 시스템 성능에 치명적인 영향을 줄 수 있습니다.
그에 따른 결론은 async 프로그램이 연산 집약적인 작업을 극도로 주의해서 다뤄야 한다는 것입니다.
다음은 Rust에서 HTTP 요청을 보내는 예시입니다:
let res = reqwest::get("https://www.rust-lang.org").await?;
.await 키워드는 reqwest::get 함수가 완료되기까지 어느 정도 시간이 걸릴 것으로 예상된다는 점을 알려줍니다.
이제 커리어 업그레이드를 할 때가 되었다는 느낌이 드시나요? 제 책 **Black Hat Rust**로 응용 암호학, 보안 엔지니어링, 그리고 안전하고 프로덕션 준비가 된 Rust 코드를 작성하는 방법을 배워 보세요. 이 책에서는 무엇보다도 Remote Access Tool을 제어하기 위한 종단간 암호화 프로토콜을 설계하고, 웹 서버를 만들며, 어셈블리 대신 no_std Rust로 셸코드와 익스플로잇을 작성하게 됩니다.
Rust는 Futures와 Streams를 실행하는 데 필요한 실행 컨텍스트를 제공하지 않습니다. 이 실행 컨텍스트를 런타임이라고 합니다. 런타임 없이는 async Rust 프로그램을 실행할 수 없습니다.
가장 인기 있는 3가지 런타임은 다음과 같습니다:
| Runtime | All-time downloads (July 2022) | Description |
|---|---|---|
| tokio | 59,048,636 | 비동기 I/O 기반 애플리케이션을 작성하기 위한 이벤트 기반의 논블로킹 I/O 플랫폼 |
| async-std | 8,002,852 | Rust 표준 라이브러리의 async 버전 |
| smol | 1,491,204 | 작고 빠른 async 런타임 |
하지만 문제가 있습니다: 오늘날 런타임들은 상호 운용되지 않으며, 코드에서 각 런타임의 특수성을 인지해야 합니다. 코드 1~2줄만 바꿔서 한 런타임을 다른 런타임으로 쉽게 교체할 수는 없습니다.
미래에 상호 운용성을 허용하기 위한 작업이 진행 중이지만, 오늘날 생태계는 파편화되어 있습니다. 하나를 선택하고 그대로 사용하는 수밖에 없습니다.
Tokio는 커뮤니티의 지원이 가장 큰 Rust async 런타임이며, Discord, Fly.io, Embark 같은 많은 스폰서의 후원을 받고 있습니다. 덕분에 유급 기여자도 보유하고 있습니다!
임베디드 개발을 하는 것이 아니라면, 여러분이 사용해야 할 런타임은 바로 이것입니다. 망설일 이유가 없습니다.
모든 async 런타임의 핵심(Rust, Node.js, 기타 언어를 막론하고)에는 이벤트 루프가 있습니다. 이를 프로세서라고도 부릅니다.

실제로는 더 나은 성능을 위해 프로그램당 여러 개의 프로세서가 있는 경우가 많으며, 보통 CPU 코어마다 하나씩 존재합니다.
각 이벤트 루프는 완료를 await하고 있는 작업들의 자체 큐를 가지고 있습니다. Tokio는 워크 스틸링 런타임으로 잘 알려져 있습니다. 각 프로세서는 자신의 큐가 비어 있을 경우(즉, 할 일이 없어 유휴 상태로 "앉아" 있을 때) 다른 프로세서의 큐에 있는 작업을 훔쳐올 수 있습니다.
다양한 종류의 이벤트 루프에 대해 더 알아보려면 Carl Lerche의 훌륭한 글을 읽어 보세요: https://tokio.rs/blog/2019-10-scheduler.
프로세서가 실행하도록 작업을 런타임에 전달하고 싶다면, 그것을 spawn합니다. tokio의 tokio::spawn 함수를 사용하면 됩니다.
예를 들면 다음과 같습니다:
tokio::spawn(async move {
for port in MOST_COMMON_PORTS_100 {
let _ = input_tx.send(*port).await;
}
});
이 코드 조각은 하나의 작업을 스폰하며, 그 작업은 프로세서들 중 하나의 큐에 들어가게 됩니다. 각 프로세서는 자체 OS 스레드를 가지므로, 작업을 스폰하면 스레드를 직접 관리하지 않고도 머신의 모든 리소스를 활용할 수 있습니다. 스폰하지 않으면 모든 작업은 같은 프로세서, 따라서 같은 스레드에서 실행됩니다.
비교를 위해 말하자면, Go에서는 작업(고루틴이라고 부릅니다)을 스폰하기 위해 go 키워드를 사용합니다:
go doSomething()
이것이 반드시 기억해야 할 가장 중요한 내용입니다.
async-await 세계에서 기억해야 할 가장 중요한 규칙은 이벤트 루프를 블로킹하지 않는 것입니다.
이것이 무슨 뜻일까요? 10~100마이크로초보다 오래 실행될 수 있는 함수를 직접 호출하지 말라는 뜻입니다. 대신 spawn_blocking을 사용해야 합니다.
그렇다면 암호화, 이미지 인코딩, 파일 해싱과 같은 연산 집약적인 작업은 어떻게 실행할까요?
tokio는 결국 스스로 종료되는 블로킹 작업을 위해 tokio::task::spawn_blocking 함수를 제공합니다. 여기서 말하는 블로킹 작업이란 무한히 실행되는 백그라운드 작업이 아닌 것을 뜻합니다. 이런 종류의 작업에는 Rust Thread가 더 적합합니다.
다음은 spawn_blocking을 사용하는 애플리케이션의 예시입니다:
let is_code_valid = spawn_blocking(move || crypto::verify_password(&code, &code_hash)).await?;
실제로 crypto::verify_password 함수는 완료되기까지 수백 밀리초가 걸릴 것으로 예상되므로, 이벤트 루프를 블로킹하게 됩니다.
대신 spawn_blocking을 호출하면 해당 작업은 tokio의 블로킹 작업 스레드 풀로 전달됩니다.

내부적으로 tokio는 두 개의 스레드 풀을 유지합니다.
하나는 async 작업을 실행하는 실행기(이벤트 루프, 프로세서)를 위한 고정 크기 스레드 풀입니다. async 작업은 tokio::spawn을 사용해 이 스레드 풀로 전달할 수 있습니다.
또 하나는 블로킹 작업을 위한 동적 크기이지만 크기에 상한이 있는 스레드 풀입니다. 기본적으로 후자는 최대 512개 스레드까지 확장됩니다. 이 스레드 풀은 실행 중인 블로킹 작업 수에 따라 확장되거나 축소됩니다. 블로킹 작업은 tokio::task::spawn_blocking을 사용해 이 스레드 풀로 전달할 수 있습니다. 세부적으로 구성하는 방법은 tokio 문서에서 더 자세히 읽어볼 수 있습니다.
이 때문에 async-await는 "Green threads" 또는 "M:N threads"라고도 알려져 있습니다. 사용자(프로그래머)에게는 스레드처럼 보이지만, spawning 비용이 더 저렴하고 런타임이 내부적으로 사용할 실제 OS 스레드 수보다 훨씬 더 많은 그린 스레드를 만들 수 있습니다.
이제 Rust를 어떻게 배우고, 너무 늦기 전에 현대 소프트웨어 개발의 흐름에 올라탈 수 있을지, 그리고 누군가가 여러분의 꿈의 직업을 가져가기 전에 어떻게 준비할 수 있을지 궁금하실지도 모르겠습니다. 좋은 소식이 있습니다!
SIMD 프로그래밍을 배우고 싶다면 SIMD programming in pure Rust를 살펴보세요.
Rust로 백엔드 개발을 배우고 싶다면 제 글 Architecting and building medium-sized web services in Rust with Axum, SQLx and PostgreSQL을 읽어 보세요.
임베디드 개발을 배우고 싶다면 Introduction to embedded development with Rust: Overview of the ecosystem을 살펴보세요.
응용 암호학을 배우고 싶다면 Cryptographic Right Answers: Post Quantum and Rust Edition부터 시작해 보세요.
마지막으로 제 책 Black hat Rust에서는 Rust가 무엇인지(제네릭, 트레이트, 이터레이터 등이 무엇인지)뿐만 아니라 Rust를 어떻게 해야 하는지도 배우게 됩니다: Rust 프로젝트를 어떻게 설계해야 하는지, Rust에서 어떤 패턴을 사용해야 하고 어떤 패턴은 피해야 하는지 말입니다. Black hat Rust에서는 이론에서 실전으로 넘어가 웹 서버를 만들고, 종단간 암호화된 Remote Access Tool (RAT)을 구축하고, 어셈블리 대신 #![no_std]를 사용해 Rust로 셸코드를 작성하는 등 다양한 실전 프로젝트를 직접 수행하며 배웁니다. 이런 프로젝트들은 단 며칠 또는 몇 주의 읽기와 코딩만으로도 여러분에게 프로덕션 수준의 Rust 실력을 안겨줄 것입니다.
이 블로그의 Rust 태그 아래에서도 힘들게 얻은 많은 교훈을 찾을 수 있습니다.
즐겁게 읽으셨으면 좋겠습니다 :)