[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();
}

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

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Rust starter입니다. 이번 글의 핵심 코드를 src/main.rs에 옮기고, cargo checkcargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.

Live Practice

Rust Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Rust startercargoterminal
  1. starter를 fork한 뒤 src/main.rs를 연다
  2. 본문 예제를 붙여 넣고 cargo check와 cargo run을 차례로 실행한다
  3. 타입, 값, 참조 흐름을 바꿔 컴파일 피드백과 출력 차이를 비교한다

Rust 실습은 브라우저 미리보기보다 터미널 피드백이 더 중요합니다. 여러 파일 구조나 추가 crate가 필요한 예제는 파일 배치를 조금 더 손봐야 할 수 있습니다.

연습 과제

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

마무리

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

💬 댓글

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