[Rust 시리즈 18편] 동시성 기초

English version

이번 글에서 다룰 내용

Rust의 동시성 모델은 "데이터 경쟁을 컴파일 단계에서 막자"는 목표를 따릅니다. 이번 편에서는 표준 라이브러리에서 바로 쓸 수 있는 스레드채널, 그리고 [[send-sync|SendSync]] 트레이트가 의미하는 바를 직관적으로 정리합니다.

스레드 생성과 조인

std::thread 모듈은 OS 스레드를 직접 생성합니다. spawn은 클로저를 받아 새 스레드를 실행하고, join은 실행이 끝날 때까지 기다립니다.

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=5 {
            println!("worker {i}");
            thread::sleep(Duration::from_millis(20));
        }
    });

    println!("main thread continues");
    handle.join().expect("thread panicked");
}

스레드에서 panic!이 발생하면 join 시점에 Err로 전달되므로, expectmatch로 에러를 처리합니다.

move 클로저와 소유권

새 스레드에 데이터를 넘길 때는 move 클로저를 사용해 명시적으로 소유권을 이동시켜야 합니다.

fn main() {
    let numbers = vec![1, 2, 3];
    let handle = std::thread::spawn(move || {
        println!("sum = {}", numbers.iter().sum::<i32>());
    });

    handle.join().unwrap();
}

move를 붙이지 않으면 스레드가 끝나기 전에 numbers를 다시 사용하려 할 수 있으므로 컴파일 오류가 발생합니다. 이 규칙 덕분에 데이터 경쟁이 줄어듭니다.

즉, thread를 만들 때 move가 자주 붙는 이유는 "새 스레드가 사용할 데이터를 명확히 넘겨준다"는 뜻이라고 이해하면 쉽습니다.

채널로 메시지 전달하기

스레드 간 데이터를 공유하는 대신, 소유권을 메시지로 전달하는 것이 안전합니다. std::sync::mpsc 채널을 사용하면 송신자와 수신자를 나눌 수 있습니다.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let msgs = vec!["prepare", "compile", "ship"];
        for msg in msgs {
            tx.send(msg.to_string()).unwrap();
        }
    });

    for received in rx {
        println!("got: {received}");
    }
}

수신자 rx는 이터레이터처럼 동작해 송신자가 닫힐 때까지 값을 받습니다. 송신 측이 복수 필요하면 tx.clone()을 사용하면 됩니다.

Send와 Sync 직관

  • Send: 값을 다른 스레드로 이동(move)해도 안전하다는 뜻입니다. 대부분의 기본 타입과 Vec, String은 자동으로 Send입니다.
  • Sync: &T 참조를 여러 스레드가 동시에 읽어도 안전하다는 뜻입니다. Sync 타입의 참조는 Send로 간주됩니다.

컴파일러는 스레드를 생성하거나 채널로 값을 보낼 때 해당 타입이 Send인지 확인합니다. 예를 들어 [[rc|Rc<T>]]는 참조 카운트를 원자적으로 관리하지 않기 때문에 Send가 아니므로 [[arc|Arc<T>]]를 사용해야 합니다. 마찬가지로 내부 가변성을 스레드 간 공유하려면 [[mutex|Mutex<T>]]와 같은 동기화 도구가 필요합니다.

가장 자주 보는 공유 패턴은 아래 조합입니다.

use std::sync::{Arc, Mutex};

fn main() {
    let count = Arc::new(Mutex::new(0));
    let worker = Arc::clone(&count);

    std::thread::spawn(move || {
        *worker.lock().unwrap() += 1;
    }).join().unwrap();

    println!("count = {}", *count.lock().unwrap());
}

여기서 [[arc|Arc]]는 여러 스레드가 소유권을 공유하게 하고, [[mutex|Mutex]]는 한 번에 한 스레드만 값을 바꾸게 합니다.

스레드 + 채널 예제: 병렬 텍스트 처리

간단한 로그 처리 작업을 병렬로 나누어 보겠습니다.

use std::sync::mpsc;
use std::thread;

fn main() {
    let lines = vec![
        "INFO - start", "WARN - retry", "INFO - done",
    ];

    let (tx, rx) = mpsc::channel();
    let producer = thread::spawn(move || {
        for line in lines {
            tx.send(line.to_string()).unwrap();
        }
    });

    let consumer = thread::spawn(move || {
        let mut warn_count = 0;
        for msg in rx {
            if msg.contains("WARN") {
                warn_count += 1;
            }
        }
        println!("warn count = {warn_count}");
    });

    producer.join().unwrap();
    consumer.join().unwrap();
}

메시지를 통해 데이터 소유권을 넘겨서, 공유 뮤텍스 없이도 작업을 안전하게 분리할 수 있습니다.

연습 과제

  1. tx.clone()으로 두 개의 생산 스레드를 만들고 채널에서 메시지를 구분해 보세요.
  2. std::thread::sleep을 이용해 생산자와 소비자의 속도를 달리해 보고, 채널이 어떻게 버퍼링하는지 로그로 관찰해 보세요.
  3. Rc<T> 대신 Arc<T>를 사용해야 하는 간단한 예시를 작성해 차이를 체감해 보세요.

마무리

Rust의 동시성은 스레드 자체보다 데이터 소유권을 어떻게 지키느냐에 초점을 맞춥니다. spawnjoin으로 스레드를 만들고, 채널과 Send/Sync 개념으로 안전한 데이터 전달 구조를 익혀 두면 이후 async와 고수준 런타임을 배우기 쉬워집니다. 다음 편에서는 async/await를 이용해 비동기 흐름을 경험해 봅니다.

💬 댓글

이 글에 대한 의견을 남겨주세요