이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.

Rust의 “Fearless Concurrency"는 컴파일 타임에 데이터 레이스를 방지합니다. Go의 고루틴과 채널에 익숙하다면, Rust의 동시성 모델이 어떻게 다른지 이해하는 것이 중요합니다.


8.1 Rust의 동시성 철학#

“Fearless Concurrency"란?#

Go의 철학: “Don’t communicate by sharing memory; share memory by communicating.” Rust의 철학: “Fearless Concurrency through ownership.”

Go Rust
런타임에 데이터 레이스 감지 (-race) 컴파일 타임에 데이터 레이스 방지
고루틴 + 채널 권장 소유권 + 타입 시스템으로 안전성 보장
공유 메모리도 가능 (주의 필요) 공유 메모리도 안전하게 사용 가능

데이터 레이스의 세 가지 조건#

  1. 두 스레드가 같은 데이터에 접근
  2. 적어도 하나가 쓰기 작업
  3. 접근이 동기화되지 않음

Rust는 소유권 규칙으로 이를 컴파일 타임에 방지합니다:

  • 불변 참조는 여러 개 가능 → 읽기만 하면 안전
  • 가변 참조는 하나만 → 쓰기가 배타적
// 이 코드는 컴파일되지 않음
use std::thread;

fn main() {
    let mut data = vec![1, 2, 3];
    
    thread::spawn(|| {
        data.push(4);  // 에러! data의 소유권/빌림 문제
    });
    
    data.push(5);  // 여기서도 수정 시도
}

8.2 스레드 기초#

std::thread::spawn#

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

fn main() {
    // 스레드 생성
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("spawned thread: {}", i);
            thread::sleep(Duration::from_millis(1));
        }
    });
    
    // 메인 스레드 작업
    for i in 1..5 {
        println!("main thread: {}", i);
        thread::sleep(Duration::from_millis(1));
    }
    
    // 스레드 완료 대기
    handle.join().unwrap();
}

Go goroutine과의 차이#

// Go: 경량 그린 스레드 (고루틴)
go func() {
    fmt.Println("goroutine")
}()
// 암묵적 스케줄러가 관리
// Rust: OS 스레드
thread::spawn(|| {
    println!("thread");
});
// 직접 OS 스레드 생성
측면 Go goroutine Rust thread
종류 그린 스레드 OS 스레드
스택 크기 작음 (2KB), 동적 증가 큼 (1-8MB), 고정
생성 비용 매우 낮음 상대적으로 높음
스케줄링 Go 런타임 OS
수량 수십만 개 가능 수천 개 정도

move 키워드와 소유권#

use std::thread;

fn main() {
    let v = vec![1, 2, 3];
    
    // move: 클로저가 환경의 소유권을 가져감
    let handle = thread::spawn(move || {
        println!("{:?}", v);
    });
    
    // v는 더 이상 사용 불가 (이동됨)
    // println!("{:?}", v);  // 에러!
    
    handle.join().unwrap();
}

JoinHandle과 반환값#

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        // 계산 수행
        let sum: i32 = (1..=100).sum();
        sum  // 반환
    });
    
    // 결과 받기
    let result = handle.join().unwrap();
    println!("Sum: {}", result);  // 5050
}

8.3 메시지 패싱#

채널 기초: std::sync::mpsc#

MPSC = Multiple Producer, Single Consumer

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

fn main() {
    // 채널 생성
    let (tx, rx) = mpsc::channel();
    
    thread::spawn(move || {
        let val = String::from("hello");
        tx.send(val).unwrap();
        // val은 이동됨, 더 이상 사용 불가
    });
    
    // 수신 (블로킹)
    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

Go channel과의 비교#

// Go: 양방향 채널, 버퍼 크기 지정
ch := make(chan string, 10)  // 버퍼 크기 10

go func() {
    ch <- "hello"
}()

msg := <-ch
fmt.Println(msg)

close(ch)  // 명시적 닫기
// Rust: 송신자/수신자 분리
let (tx, rx) = mpsc::channel();  // 무한 버퍼

// 유한 버퍼
let (tx, rx) = mpsc::sync_channel(10);

thread::spawn(move || {
    tx.send("hello").unwrap();
    // drop(tx);  // 암묵적 닫기 (스코프 종료 시)
});

// 수신
match rx.recv() {
    Ok(msg) => println!("{}", msg),
    Err(_) => println!("Channel closed"),
}

여러 생산자#

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

fn main() {
    let (tx, rx) = mpsc::channel();
    
    // 송신자 복제
    let tx1 = tx.clone();
    let tx2 = tx.clone();
    
    thread::spawn(move || {
        tx1.send("from thread 1").unwrap();
    });
    
    thread::spawn(move || {
        tx2.send("from thread 2").unwrap();
    });
    
    // 원본 tx도 사용 가능
    tx.send("from main").unwrap();
    
    // 모든 송신자가 드롭되면 수신 종료
    drop(tx);
    
    // 이터레이터로 수신
    for msg in rx {
        println!("{}", msg);
    }
}

수신 메서드들#

// recv(): 블로킹, 채널 닫히면 Err
let msg = rx.recv().unwrap();

// try_recv(): 논블로킹, 즉시 반환
match rx.try_recv() {
    Ok(msg) => println!("{}", msg),
    Err(mpsc::TryRecvError::Empty) => println!("No message"),
    Err(mpsc::TryRecvError::Disconnected) => println!("Closed"),
}

// recv_timeout(): 타임아웃
match rx.recv_timeout(Duration::from_secs(1)) {
    Ok(msg) => println!("{}", msg),
    Err(mpsc::RecvTimeoutError::Timeout) => println!("Timeout"),
    Err(mpsc::RecvTimeoutError::Disconnected) => println!("Closed"),
}

// iter(): 채널이 닫힐 때까지 반복
for msg in rx.iter() {
    println!("{}", msg);
}

8.4 선택적 수신#

Go의 select와 유사한 기능은 crossbeam 크레이트에서 제공합니다:

[dependencies]
crossbeam = "0.8"
use crossbeam::channel::{select, unbounded, Receiver};
use std::time::Duration;

fn main() {
    let (tx1, rx1) = unbounded();
    let (tx2, rx2) = unbounded();
    
    // 송신 스레드들...
    
    loop {
        select! {
            recv(rx1) -> msg => {
                match msg {
                    Ok(m) => println!("rx1: {}", m),
                    Err(_) => break,
                }
            }
            recv(rx2) -> msg => {
                match msg {
                    Ok(m) => println!("rx2: {}", m),
                    Err(_) => break,
                }
            }
            default(Duration::from_secs(1)) => {
                println!("timeout");
            }
        }
    }
}

Go와 비교:

// Go select
select {
case msg := <-ch1:
    fmt.Println("ch1:", msg)
case msg := <-ch2:
    fmt.Println("ch2:", msg)
case <-time.After(time.Second):
    fmt.Println("timeout")
}

8.5 공유 상태 동시성#

Mutex#

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);
    
    {
        // lock()은 MutexGuard 반환
        let mut num = m.lock().unwrap();
        *num = 6;
    }  // MutexGuard 드롭 → 자동 언락
    
    println!("{:?}", m);  // Mutex { data: 6 }
}

Go sync.Mutex와 비교#

// Go: 별도의 락과 데이터
var mu sync.Mutex
var count int

mu.Lock()
count++
mu.Unlock()  // 수동 언락 필요!
// Rust: 락이 데이터를 감싸는 구조
let counter = Mutex::new(0);

{
    let mut num = counter.lock().unwrap();
    *num += 1;
}  // 자동 언락 (RAII)

Rust의 장점:

  1. 락 없이 데이터에 접근 불가능
  2. 언락을 잊을 수 없음 (RAII)
  3. 컴파일러가 안전성 검사

RwLock#

use std::sync::RwLock;

let lock = RwLock::new(5);

// 여러 읽기 동시 가능
{
    let r1 = lock.read().unwrap();
    let r2 = lock.read().unwrap();
    println!("{}, {}", r1, r2);
}

// 쓰기는 배타적
{
    let mut w = lock.write().unwrap();
    *w += 1;
}

데드락 방지 전략#

// 데드락 발생 가능한 코드
let a = Mutex::new(1);
let b = Mutex::new(2);

// 스레드 1
let _a = a.lock();
let _b = b.lock();  // 스레드 2가 b를 잡고 a를 기다리면 데드락

// 방지 전략:
// 1. 항상 같은 순서로 락
// 2. try_lock() 사용
// 3. 락 범위 최소화
// 4. 단일 락에 여러 데이터 그룹화

8.6 Arc: 스레드 안전한 참조 카운팅#

Rc vs Arc#

use std::rc::Rc;      // 단일 스레드
use std::sync::Arc;    // 멀티 스레드

// Rc: 스레드 간 공유 불가
let rc = Rc::new(5);
// thread::spawn(move || { rc; });  // 에러!

// Arc: 스레드 간 공유 가능
let arc = Arc::new(5);
let arc_clone = Arc::clone(&arc);
thread::spawn(move || {
    println!("{}", arc_clone);
});

Arc<Mutex> 패턴#

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

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];
    
    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
    
    println!("Result: {}", *counter.lock().unwrap());  // 10
}

Go 비교:

// Go: 직접 공유 + Mutex
var counter int
var mu sync.Mutex

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        mu.Lock()
        counter++
        mu.Unlock()
    }()
}
wg.Wait()
fmt.Println(counter)  // 10

8.7 Send와 Sync 트레이트#

컴파일 타임 동시성 안전성#

// Send: 스레드 간 소유권 전달 가능
// "T를 다른 스레드로 보낼 수 있다"
pub unsafe auto trait Send {}

// Sync: 스레드 간 참조 공유 가능
// "&T를 다른 스레드로 보낼 수 있다"
pub unsafe auto trait Sync {}

어떤 타입이 Send/Sync인가?#

타입 Send Sync
i32, bool, etc.
String, Vec<T>
Rc<T>
Arc<T>
Mutex<T>
Cell<T>
*const T (raw pointer)

컴파일러가 잡아주는 버그#

use std::rc::Rc;
use std::thread;

fn main() {
    let rc = Rc::new(5);
    
    thread::spawn(move || {
        println!("{}", rc);
    });
    // 컴파일 에러!
    // `Rc<i32>` cannot be sent between threads safely
    // the trait `Send` is not implemented for `Rc<i32>`
}

Go에서는 이런 실수가 런타임에만 발견됩니다:

// Go: 컴파일됨, 런타임에 데이터 레이스
counter := 0
for i := 0; i < 1000; i++ {
    go func() {
        counter++  // 데이터 레이스!
    }()
}
// go run -race 로만 감지

8.8 동시성 패턴#

Worker Pool 패턴#

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

type Job = Box<dyn FnOnce() + Send + 'static>;

struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl ThreadPool {
    fn new(size: usize) -> Self {
        let (sender, receiver) = mpsc::channel();
        let receiver = Arc::new(Mutex::new(receiver));
        
        let mut workers = Vec::with_capacity(size);
        for id in 0..size {
            let receiver = Arc::clone(&receiver);
            let thread = thread::spawn(move || loop {
                let job = receiver.lock().unwrap().recv();
                match job {
                    Ok(job) => {
                        println!("Worker {} executing", id);
                        job();
                    }
                    Err(_) => break,
                }
            });
            workers.push(Worker { id, thread });
        }
        
        ThreadPool { workers, sender }
    }
    
    fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        self.sender.send(Box::new(f)).unwrap();
    }
}

Fan-out/Fan-in 패턴#

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

fn main() {
    let (tx, rx) = mpsc::channel();
    
    // Fan-out: 여러 워커에게 작업 분배
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8];
    let chunk_size = 2;
    
    for chunk in numbers.chunks(chunk_size) {
        let tx = tx.clone();
        let chunk = chunk.to_vec();
        
        thread::spawn(move || {
            let sum: i32 = chunk.iter().sum();
            tx.send(sum).unwrap();
        });
    }
    drop(tx);  // 원본 송신자 드롭
    
    // Fan-in: 결과 수집
    let total: i32 = rx.iter().sum();
    println!("Total: {}", total);  // 36
}

8.9 Rayon: 데이터 병렬성#

[dependencies]
rayon = "1.10"

병렬 이터레이터#

use rayon::prelude::*;

fn main() {
    let numbers: Vec<i32> = (1..=1000000).collect();
    
    // 순차 처리
    let sum: i32 = numbers.iter().sum();
    
    // 병렬 처리 (단 한 글자 차이!)
    let sum: i32 = numbers.par_iter().sum();
    
    // 병렬 map + filter
    let result: Vec<i32> = numbers
        .par_iter()
        .filter(|&&x| x % 2 == 0)
        .map(|&x| x * 2)
        .collect();
}

Go errgroup과 비교#

// Go: errgroup으로 병렬 작업
import "golang.org/x/sync/errgroup"

var g errgroup.Group
for _, url := range urls {
    url := url
    g.Go(func() error {
        return fetch(url)
    })
}
if err := g.Wait(); err != nil {
    log.Fatal(err)
}
// Rust: Rayon
use rayon::prelude::*;

let results: Vec<Result<_, _>> = urls
    .par_iter()
    .map(|url| fetch(url))
    .collect();

8.10 요약#

Go vs Rust 동시성 비교#

측면 Go Rust
기본 단위 goroutine std::thread
스레드 종류 그린 스레드 OS 스레드
채널 내장 std::sync::mpsc
뮤텍스 sync.Mutex std::sync::Mutex
데이터 레이스 런타임 감지 (-race) 컴파일 타임 방지
경량 동시성 기본 지원 tokio/async-std

선택 가이드#

상황 권장 방식
CPU 바운드 병렬 처리 rayon
IO 바운드 동시성 tokio (Section 9)
공유 상태 Arc<Mutex<T>>
메시지 패싱 mpsc::channel 또는 crossbeam
단순한 병렬 작업 std::thread::spawn

연습 문제#

  1. 스레드 생성: 10개의 스레드를 생성하고 각 스레드가 자신의 ID를 출력하게 하세요.

  2. 채널 사용: 생산자-소비자 패턴을 구현하세요. 생산자는 1~100 숫자를 보내고, 소비자는 합계를 계산하세요.

  3. 공유 카운터: Arc<Mutex<i32>>를 사용하여 여러 스레드가 안전하게 증가시키는 카운터를 구현하세요.

  4. Worker Pool: 간단한 스레드 풀을 구현하고 여러 작업을 실행하세요.

  5. Rayon 활용: 대용량 벡터의 모든 요소를 병렬로 처리하여 합계, 최대값, 최소값을 구하세요.

  6. 데드락 방지: 두 개의 Mutex를 사용하는 코드에서 데드락이 발생하지 않도록 수정하세요.