전부 한데 모으기: Futures, Tasks, Threads
전부 한데 모으기: Futures, Tasks, Threads
16장에서 봤듯이 스레드는 동시성에 대한 한 가지 접근법을 제공해요. 이번 장에서는 또 다른 접근법을 봤습니다. future와 스트림과 함께 async를 사용하는 거죠. 언제 한 방법을 다른 방법보다 고를지 궁금하다면, 대답은 "상황에 따라 다르다(it depends)!"입니다. 그리고 많은 경우 그 선택은 스레드냐 async냐가 아니라 스레드와 async 둘 다입니다.
출처: The Rust Book - Putting It All Together: Futures, Tasks, and Threads
많은 운영체제가 수십 년 동안 스레드 기반 동시성 모델을 제공해 왔고, 그 결과 많은 프로그래밍 언어가 그것을 지원해요. 하지만 이 모델들에는 트레이드오프가 없는 게 아닙니다. 많은 운영체제에서 스레드마다 꽤 많은 메모리를 사용해요. 스레드는 또한 당신의 운영체제와 하드웨어가 그것을 지원할 때만 선택할 수 있는 옵션이고요. 주류 데스크톱·모바일 컴퓨터와 달리 일부 임베디드 시스템에는 운영체제가 아예 없으므로, 스레드도 없습니다.
async 모델은 다른 — 그리고 궁극적으로 보완적인 — 트레이드오프 집합을 제공해요. async 모델에서 동시적인 연산은 각자 전용 스레드를 요구하지 않습니다. 대신 작업(task)에서 실행될 수 있어요. 스트림 절에서 동기 함수에서 작업을 시작할 때 trpl::spawn_task를 사용했던 것처럼요. 작업(task)은 스레드와 비슷하지만, 운영체제가 아니라 라이브러리 수준의 코드, 즉 런타임이 관리합니다.
스레드를 생성하는 API와 작업을 생성하는 API가 그렇게 비슷한 데는 이유가 있어요. 스레드는 일련의 동기 연산에 대한 경계(boundary) 역할을 하고, 스레드 사이에서 동시성이 가능해요. 작업은 일련의 비동기 연산에 대한 경계 역할을 하고, 작업 사이는 물론 작업 안에서도 동시성이 가능합니다. 작업은 자신의 본문에 있는 future들 사이에서 전환할 수 있으니까요. 마지막으로 future는 Rust에서 가장 세분화된 동시성 단위이고, 각 future는 다른 future들의 트리(tree)를 나타낼 수 있어요. 런타임 — 정확히는 그 executor — 이 작업을 관리하고, 작업은 future를 관리합니다. 그런 점에서 작업은 운영체제가 아니라 런타임이 관리한다는 점에서 나오는 추가 능력을 가진, 경량의 런타임 관리 스레드와 비슷합니다.
이것이 async 작업이 항상 스레드보다 낫다는(또는 그 반대라는) 뜻은 아니에요. 스레드로 동시성을 처리하는 것은 어떤 면에서 async로 동시성을 처리하는 것보다 더 단순한 프로그래밍 모델입니다. 그것은 장점이 될 수도 단점이 될 수도 있어요. 스레드는 다소 "던져두고 잊는(fire and forget)" 방식이에요. future에 해당하는 고유한 것이 없어서 운영체제 자신을 제외하고는 방해받지 않고 끝까지 실행될 뿐이죠.
그리고 스레드와 작업은 실제로 매우 잘 맞물리는 경우가 많다는 것이 밝혀졌습니다. 작업은 (적어도 일부 런타임에서는) 스레드 사이에서 이동될 수 있으니까요. 사실 우리가 사용해 온 런타임 — spawn_blocking과 spawn_task 함수를 포함해 — 은 내부적으로 기본적으로 멀티스레드예요! 많은 런타임이 작업 훔치기(work stealing) 라는 접근법을 사용해, 스레드가 현재 어떻게 활용되고 있는지에 따라 작업을 스레드 사이에서 투명하게 이동시키며 시스템의 전반적인 성능을 개선합니다. 그 접근법은 실제로 스레드와 작업, 따라서 future를 요구해요.
언제 어떤 방법을 쓸지 생각할 때, 이 경험칙(rule of thumb)들을 고려해 보세요:
- 작업이 매우 병렬화 가능하다면(즉 CPU 바운드), 예를 들어 각 부분을 따로 처리할 수 있는 데이터 덩어리를 처리하는 것이라면, 스레드가 더 나은 선택이다.
- 작업이 매우 동시적이라면(즉 I/O 바운드), 예를 들어 서로 다른 간격이나 속도로 올 수 있는 여러 소스의 메시지를 다루는 것이라면, async가 더 나은 선택이다.
그리고 병렬성과 동시성이 둘 다 필요하다면 스레드와 async 사이에서 고를 필요가 없어요. 각자 가장 잘하는 역할을 맡기면서 자유롭게 함께 쓸 수 있습니다. 예를 들어 Listing 17-25는 실제 세계 Rust 코드에서 흔히 볼 수 있는 이런 혼합의 꽤 전형적인 예시를 보여줍니다.
extern crate trpl; // for mdbook test
use std::{thread, time::Duration};
fn main() {
let (tx, mut rx) = trpl::channel();
thread::spawn(move || {
for i in 1..11 {
tx.send(i).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
trpl::block_on(async {
while let Some(message) = rx.recv().await {
println!("{message}");
}
});
}
async 채널을 만드는 것부터 시작해서, move 키워드로 채널의 송신자 측 소유권을 가져가는 스레드를 생성해요. 스레드 안에서 1부터 10까지의 숫자를 보내는데, 각 숫자 사이에 1초씩 잡니다. 마지막으로 이번 장 내내 해 온 것처럼 trpl::block_on에 넘긴 async 블록으로 만든 future를 실행합니다. 그 future에서 다른 메시지 전달 예시에서 봤던 것처럼 그 메시지들을 await해요.
이번 장을 시작하며 열었던 시나리오로 돌아가 보면, 전용 스레드(영상 인코딩은 연산 바운드니까)로 일련의 영상 인코딩 작업을 실행하면서, async 채널로 그 연산들이 끝났음을 UI에 알리는 경우를 상상해 보세요. 실제 사용 사례에서 이런 종류의 조합의 예는 셀 수 없이 많습니다.
요약 (Summary)
이 책에서 동시성을 이게 마지막이 아니에요. 21장의 프로젝트에서 이 개념들을 여기서 다룬 더 단순한 예시보다 더 현실적인 상황에 적용하고, 스레딩 대 작업·future로 문제를 푸는 것을 더 직접 비교할 거예요.
어떤 접근법을 고르든, Rust는 고처리량 웹 서버든 임베디드 운영체제든 안전하고 빠른 동시성 코드를 쓰는 데 필요한 도구를 제공해요.
다음으로는 Rust 프로그램이 커짐에 따라 문제를 모델링하고 해결책을 구조화하는 관용적인 방법에 대해 이야기할 거예요. 또한 Rust의 관용구가 객체 지향 프로그래밍에서 익숙할 수 있는 것들과 어떻게 관련되는지도 논의할 거예요.