모든 것을 종합해 보자: Future, Task, 그리고 Thread
16장에서 살펴보았듯이, 스레드는 병행성(concurrency)을 구현하는 한 가지 방법이다. 이번 장에서는 또 다른 접근 방식인 async와 future, stream을 사용하는 방법을 배웠다. 어떤 방법을 선택해야 할지 고민된다면, 정답은 “상황에 따라 다르다“이다. 그리고 많은 경우, 스레드와 async 중 하나를 선택하는 것이 아니라 둘 다 함께 사용하는 것이 더 적합하다.
수십 년 동안 많은 운영체제가 스레드 기반의 병행성 모델을 제공해 왔고, 그 결과 많은 프로그래밍 언어가 이를 지원한다. 하지만 이 모델에는 단점도 있다. 많은 운영체제에서 각 스레드는 상당한 메모리를 사용하며, 시작과 종료 시 오버헤드가 발생한다. 또한 스레드는 운영체제와 하드웨어가 이를 지원할 때만 사용할 수 있다. 일반적인 데스크톱과 모바일 컴퓨터와 달리, 일부 임베디드 시스템에는 운영체제가 없기 때문에 스레드도 없다.
async 모델은 다른 방식의 장단점을 제공하며, 궁극적으로 스레드와 상호 보완적이다. async 모델에서는 병행 작업이 별도의 스레드를 필요로 하지 않는다. 대신, 스트림 섹션에서 trpl::spawn_task
를 사용해 동기 함수에서 작업을 시작한 것처럼, 작업(task)에서 실행될 수 있다. Task는 스레드와 유사하지만, 운영체제가 아닌 런타임이라는 라이브러리 수준의 코드에 의해 관리된다.
이전 섹션에서는 async 채널을 사용하고 동기 코드에서 호출할 수 있는 async 작업을 생성해 스트림을 구축하는 방법을 살펴보았다. 스레드를 사용해도 동일한 작업을 수행할 수 있다. 리스트 17-40에서는 trpl::spawn_task
와 trpl::sleep
을 사용했지만, 리스트 17-41에서는 get_intervals
함수에서 이를 표준 라이브러리의 thread::spawn
과 thread::sleep
API로 대체했다.
extern crate trpl; // required for mdbook test use std::{pin::pin, thread, time::Duration}; use trpl::{ReceiverStream, Stream, StreamExt}; fn main() { trpl::run(async { let messages = get_messages().timeout(Duration::from_millis(200)); let intervals = get_intervals() .map(|count| format!("Interval #{count}")) .throttle(Duration::from_millis(500)) .timeout(Duration::from_secs(10)); let merged = messages.merge(intervals).take(20); let mut stream = pin!(merged); while let Some(result) = stream.next().await { match result { Ok(item) => println!("{item}"), Err(reason) => eprintln!("Problem: {reason:?}"), } } }); } fn get_messages() -> impl Stream<Item = String> { let (tx, rx) = trpl::channel(); trpl::spawn_task(async move { let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]; for (index, message) in messages.into_iter().enumerate() { let time_to_sleep = if index % 2 == 0 { 100 } else { 300 }; trpl::sleep(Duration::from_millis(time_to_sleep)).await; if let Err(send_error) = tx.send(format!("Message: '{message}'")) { eprintln!("Cannot send message '{message}': {send_error}"); break; } } }); ReceiverStream::new(rx) } fn get_intervals() -> impl Stream<Item = u32> { let (tx, rx) = trpl::channel(); // This is *not* `trpl::spawn` but `std::thread::spawn`! thread::spawn(move || { let mut count = 0; loop { // Likewise, this is *not* `trpl::sleep` but `std::thread::sleep`! thread::sleep(Duration::from_millis(1)); count += 1; if let Err(send_error) = tx.send(count) { eprintln!("Could not send interval {count}: {send_error}"); break; }; } }); ReceiverStream::new(rx) }
get_intervals
함수에서 async trpl
API 대신 std::thread
API 사용이 코드를 실행하면 리스트 17-40과 동일한 결과가 출력된다. 호출 코드의 관점에서 얼마나 적은 변경이 필요한지 주목하라. 또한 한 함수는 런타임에 async 작업을 생성하고, 다른 함수는 OS 스레드를 생성했지만, 결과적으로 생성된 스트림은 이러한 차이에 영향을 받지 않았다.
두 접근 방식은 유사해 보이지만 실제로는 매우 다르게 동작한다. 단순한 예제에서는 이를 측정하기 어려울 수 있지만, 현대의 개인용 컴퓨터에서는 수백만 개의 async 작업을 생성할 수 있다. 반면 스레드로 동일한 작업을 시도하면 메모리가 부족해질 것이다.
그럼에도 불구하고 이 API들이 유사한 데는 이유가 있다. 스레드는 동기 작업의 경계 역할을 하며, 스레드 간에 병행성이 가능하다. Task는 비동기 작업의 경계 역할을 하며, task 내부와 task 간에 병행성이 가능하다. 이는 task가 본문에서 future 간에 전환할 수 있기 때문이다. Future는 Rust에서 가장 세분화된 병행성 단위이며, 각 future는 다른 future의 트리를 나타낼 수 있다. 런타임(특히 executor)은 task를 관리하고, task는 future를 관리한다. 이 점에서 task는 런타임에 의해 관리된다는 점에서 추가 기능을 가진 경량의 스레드와 유사하다.
이것이 async task가 항상 스레드보다 우수하다는 것을 의미하지는 않는다. 스레드를 사용한 병행성은 어떤 면에서는 async
를 사용한 병행성보다 더 단순한 프로그래밍 모델이다. 이는 장점이 될 수도 있고 단점이 될 수도 있다. 스레드는 “발사 후 잊어버리는” 방식으로 동작하며, future와 같은 네이티브 동등물이 없기 때문에 운영체제 자체에 의해 중단되지 않는 한 완료될 때까지 실행된다. 즉, future와 달리 스레드에는 _task 내부의 병행성_을 위한 내장 지원이 없다. Rust의 스레드는 또한 취소 메커니즘이 없는데, 이는 이 장에서 명시적으로 다루지는 않았지만 future가 종료될 때마다 그 상태가 올바르게 정리된다는 사실에서 암시되었다.
이러한 제약 사항은 스레드를 future보다 합성하기 어렵게 만든다. 예를 들어, 이 장의 앞부분에서 만든 timeout
과 throttle
같은 헬퍼를 스레드로 구현하는 것은 훨씬 더 어렵다. Future가 더 풍부한 데이터 구조라는 사실은 이를 더 자연스럽게 합성할 수 있게 한다.
Task는 future에 대한 추가적인 제어를 제공하여, 이를 그룹화할 위치와 방법을 선택할 수 있게 한다. 그리고 스레드와 task는 종종 매우 잘 함께 작동하는데, task는 (적어도 일부 런타임에서는) 스레드 간에 이동할 수 있기 때문이다. 실제로, 우리가 사용해 온 런타임(예: spawn_blocking
과 spawn_task
함수를 포함)은 기본적으로 멀티스레드이다! 많은 런타임은 _작업 도용(work stealing)_이라는 접근 방식을 사용하여 스레드 간에 task를 투명하게 이동시켜 시스템의 전반적인 성능을 향상시킨다. 이 접근 방식은 실제로 스레드와 task, 그리고 future가 모두 필요하다.
어떤 방법을 사용할지 고민할 때는 다음의 경험 법칙을 고려하라:
- 작업이 _매우 병렬화 가능_하다면, 예를 들어 각 부분을 별도로 처리할 수 있는 데이터를 처리하는 경우, 스레드가 더 나은 선택이다.
- 작업이 _매우 병행적_이라면, 예를 들어 다양한 간격이나 속도로 들어오는 여러 소스의 메시지를 처리하는 경우, async가 더 나은 선택이다.
그리고 병렬성과 병행성이 모두 필요한 경우, 스레드와 async 중 하나를 선택할 필요는 없다. 둘을 자유롭게 함께 사용하여 각각의 강점을 활용할 수 있다. 예를 들어, 리스트 17-42는 실제 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::run(async { while let Some(message) = rx.recv().await { println!("{message}"); } }); }
먼저 async 채널을 생성한 후, 채널의 송신 측을 소유하는 스레드를 생성한다. 스레드 내부에서는 1부터 10까지의 숫자를 보내며 각각 사이에 1초씩 대기한다. 마지막으로, 이 장에서 계속 사용해 온 것처럼 trpl::run
에 전달된 async 블록으로 생성된 future를 실행한다. 이 future에서는 이전에 본 메시지 전달 예제와 마찬가지로 이러한 메시지를 기다린다.
이 장의 시작 부분에서 언급한 시나리오로 돌아가면, 비디오 인코딩 작업을 전용 스레드에서 실행하고(비디오 인코딩은 계산 집약적이기 때문에) 작업이 완료되면 async 채널을 통해 UI에 알리는 것을 상상해 보라. 실제 사용 사례에서 이러한 조합은 무수히 많다.
요약
이 책에서 동시성에 대한 이야기는 여기서 끝나지 않는다. 21장의 프로젝트에서는 여기서 다룬 간단한 예제보다 더 현실적인 상황에서 이러한 개념을 적용하고, 스레딩과 태스크를 사용한 문제 해결 방식을 직접 비교해 볼 것이다.
여러분이 어떤 접근 방식을 선택하든, Rust는 고성능 웹 서버부터 임베디드 운영체제까지 안전하고 빠른 동시성 코드를 작성하는 데 필요한 도구를 제공한다.
다음 장에서는 Rust 프로그램이 커짐에 따라 문제를 모델링하고 해결책을 구조화하는 관용적인 방법에 대해 이야기할 것이다. 또한 Rust의 관용구가 객체 지향 프로그래밍에서 익숙할 수 있는 관용구와 어떻게 관련되는지 논의할 것이다.