Go to Rust: #8. 동시성 프로그래밍
이 글은 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) |
컴파일 타임에 데이터 레이스 방지 |
| 고루틴 + 채널 권장 | 소유권 + 타입 시스템으로 안전성 보장 |
| 공유 메모리도 가능 (주의 필요) | 공유 메모리도 안전하게 사용 가능 |
데이터 레이스의 세 가지 조건#
- 두 스레드가 같은 데이터에 접근
- 적어도 하나가 쓰기 작업
- 접근이 동기화되지 않음
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의 장점:
- 락 없이 데이터에 접근 불가능
- 언락을 잊을 수 없음 (RAII)
- 컴파일러가 안전성 검사
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 |
연습 문제#
-
스레드 생성: 10개의 스레드를 생성하고 각 스레드가 자신의 ID를 출력하게 하세요.
-
채널 사용: 생산자-소비자 패턴을 구현하세요. 생산자는 1~100 숫자를 보내고, 소비자는 합계를 계산하세요.
-
공유 카운터:
Arc<Mutex<i32>>를 사용하여 여러 스레드가 안전하게 증가시키는 카운터를 구현하세요. -
Worker Pool: 간단한 스레드 풀을 구현하고 여러 작업을 실행하세요.
-
Rayon 활용: 대용량 벡터의 모든 요소를 병렬로 처리하여 합계, 최대값, 최소값을 구하세요.
-
데드락 방지: 두 개의 Mutex를 사용하는 코드에서 데드락이 발생하지 않도록 수정하세요.