Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

모두 함께 보기: 퓨처, 태스크, 스레드

16장에서 보았듯, 스레드는 동시성을 구현하는 한 가지 접근법입니다. 그리고 이 장에서는 퓨처와 스트림을 사용하는 async 라는 또 다른 접근을 살펴보았습니다. 언제 어느 쪽을 골라야 할지 궁금할 수 있는데, 대답은 “상황에 따라 다르다” 입니다. 그리고 많은 경우, 선택지는 스레드 혹은 async 가 아니라 스레드 그리고 async 입니다.

많은 운영체제는 수십 년 전부터 스레드 기반 동시성 모델을 제공해 왔고, 그 결과 많은 프로그래밍 언어도 이를 지원합니다. 하지만 이런 모델에도 분명한 트레이드오프가 있습니다. 많은 운영체제에서는 스레드 하나마다 적지 않은 메모리를 사용합니다. 또한 스레드는 운영체제와 하드웨어가 이를 지원할 때만 선택할 수 있습니다. 일반적인 데스크톱이나 모바일 컴퓨터와 달리, 일부 임베디드 시스템은 아예 운영체제가 없으므로 스레드도 없습니다.

async 모델은 이와 다른, 그리고 궁극적으로는 상호 보완적인 트레이드오프를 제공합니다. async 모델에서는 동시성 작업이 각자 전용 스레드를 가질 필요가 없습니다. 대신 스트림 절에서 trpl::spawn_task 로 작업을 시작했듯이, 태스크 위에서 실행될 수 있습니다. 태스크는 스레드와 비슷하지만 운영체제가 아니라 라이브러리 수준의 코드, 즉 런타임이 관리합니다.

스레드를 생성하는 API와 태스크를 생성하는 API가 비슷해 보이는 데는 이유가 있습니다. 스레드는 동기 연산 묶음에 대한 경계 역할을 하고, 동시성은 스레드 사이 에서 발생합니다. 태스크는 비동기 연산 묶음에 대한 경계 역할을 하고, 동시성은 태스크 사이 는 물론 태스크 내부 에서도 발생할 수 있습니다. 왜냐하면 하나의 태스크는 본문 안의 여러 퓨처 사이를 전환할 수 있기 때문입니다. 마지막으로, 퓨처는 러스트에서 가장 세밀한 동시성 단위이며, 하나의 퓨처는 다시 다른 퓨처들의 트리를 표현할 수 있습니다. 런타임, 정확히 말하면 executor 가 태스크를 관리하고, 태스크는 퓨처를 관리합니다. 이런 의미에서 태스크는 운영체제가 아니라 런타임이 관리하는, 더 가벼운 스레드와 비슷하되, 런타임 관리 덕분에 추가 기능을 가진다고 볼 수 있습니다.

그렇다고 async 태스크가 항상 스레드보다 낫다는 뜻은 아닙니다(혹은 그 반대도 아닙니다). 스레드 기반 동시성은 어떤 면에서는 async 동시성보다 더 단순한 프로그래밍 모델입니다. 이것은 장점일 수도 있고 단점일 수도 있습니다. 스레드는 어느 정도 “fire and forget” 성격이 있습니다. 퓨처에 해당하는 내장 개념이 없기 때문에, 운영체제에 의해 끊기지 않는 한 그냥 끝까지 실행됩니다.

흥미롭게도 스레드와 태스크는 종종 아주 잘 어울립니다. 어떤 런타임에서는 태스크를 스레드 사이로 옮길 수도 있기 때문입니다. 실제로 우리가 사용한 런타임의 내부, 즉 spawn_blockingspawn_task 를 포함한 런타임은 기본적으로 멀티스레드입니다! 많은 런타임은 work stealing 이라는 접근법을 사용해, 현재 스레드 사용 상태에 따라 태스크를 투명하게 스레드 사이로 이동시켜 전체 시스템 성능을 개선합니다. 이런 접근은 실제로 스레드 태스크, 그리고 퓨처를 모두 요구합니다.

어떤 상황에 어떤 방식을 써야 할지 고민할 때는, 다음과 같은 경험칙을 참고하면 좋습니다.

  • 작업이 매우 병렬화 가능 하다면(즉 CPU-bound 작업, 예를 들어 각 부분을 독립적으로 처리할 수 있는 대량의 데이터 처리) 스레드가 더 나은 선택입니다.
  • 작업이 매우 동시적 이라면(즉 I/O-bound 작업, 예를 들어 서로 다른 시점과 속도로 들어오는 여러 메시지를 다루는 경우) async 가 더 나은 선택입니다.

그리고 병렬성과 동시성이 둘 다 필요하다면, 스레드와 async 중 하나만 선택할 필요는 없습니다. 둘을 자유롭게 함께 써서, 각자가 가장 잘하는 역할을 맡기면 됩니다. 목록 17-25는 실제 러스트 코드에서 흔히 볼 수 있는 이런 조합의 간단한 예를 보여 줍니다.

Filename: src/main.rs
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}");
        }
    });
}
Listing 17-25: 블로킹 코드는 스레드에서 메시지를 보내고, async 블록은 그 메시지를 기다리기

우리는 먼저 async 채널을 만들고, 채널의 송신자 쪽 소유권을 move 키워드로 가져가는 스레드를 생성합니다. 그 스레드 안에서는 1부터 10까지 숫자를 보내고, 각 숫자 사이에 1초씩 쉽니다. 마지막으로, 이 장 내내 해 왔듯이 trpl::block_on 에 async 블록을 넘겨 그 안에서 메시지를 await 합니다.

이 장의 도입부에서 이야기했던 영상 인코딩 작업을 다시 떠올려 보면, 예를 들어 계산량이 큰 비디오 인코딩 작업은 전용 스레드에서 수행하고(비디오 인코딩은 CPU-bound 이기 때문), 그 작업이 끝났다는 사실은 async 채널을 통해 UI에 알려 주는 식으로 생각할 수 있습니다. 현실의 프로그램에는 이런 조합 예가 정말 많습니다.

정리

이 책에서 동시성을 보는 것은 아직 끝이 아닙니다. 21장 프로젝트에서는, 여기서 본 간단한 예제보다 훨씬 더 현실적인 상황에서 이 개념들을 적용하고, 스레드 기반 문제 해결과 태스크/퓨처 기반 문제 해결을 더 직접적으로 비교하게 됩니다.

여러분이 어느 접근을 택하든, 러스트는 안전하고 빠른 동시성 코드를 작성하는 데 필요한 도구를 제공합니다. 고성능 웹 서버를 만들든, 임베디드 운영체제를 만들든 마찬가지입니다.

다음 장에서는 러스트 프로그램이 더 커졌을 때 문제를 모델링하고 해결책을 구조화하는 관용적인 방법들을 살펴보겠습니다. 또한 러스트의 이런 관용구가 여러분이 객체 지향 프로그래밍에서 익숙할 수 있는 방식과는 어떻게 관계되는지도 이야기해 보겠습니다.