JS, TS랑 비교하며 Rust 공부하기 (11)
동시성
스레드
러스트 표준 라이브러리는 1:1 스레드 모델을 사용한다.
새로운 스레드를 생성하려면 thread::spawn 함수에 인수로 클로저를 전달하면 된다.
thread::sleep은 현재 스레드의 실행을 지정한 시간 동안 멈추고, 그 동안 운영체제는 CPU를 다른 스레드나 프로세스에 할당할 수 있다.
스레드가 종료될 때까지 기다리려면 thread::spawn 함수의 반환값인 JoinHandle의 join 메서드를 호출하면 된다. join을 호출하면 해당 스레드가 종료될 때까지 현재 스레드가 블로킹된다. 이때 join 호출 위치에 따라 스레드가 동시에 실행되는지 여부가 달라진다.
러스트는 생성된 스레드의 실행 기간을 예측할 수 없으므로, 참조자가 항상 유효함을 보장할 수 없다. 따라서 move 키워드를 사용해 클로저가 필요한 값의 소유권을 가져가도록 해야 한다. 이렇게 하면 스레드가 실행되는 동안 값이 메인 스레드에서 먼저 해제되어 참조자가 무효화되는 문제를 방지할 수 있다.
use std::thread;
use std::time::Duration;
let handle = thread::spawn(|| {
for i in 1..=5 {
println!("스레드: {}", i);
thread::sleep(Duration::from_millis(200)); // 현재 스레드 잠시 정지
}
});
// 메인 스레드 작업
for i in 1..=3 {
println!("메인: {}", i);
thread::sleep(Duration::from_millis(300));
}
// 스레드 종료 대기
handle.join().unwrap();
메시지 패싱
채널은 mpsc::channel 함수를 사용해 생성하며, 복수 생산자·단일 소비자 모델을 따른다. 즉, 한 채널은 여러 송신 단말을 가질 수 있지만, 수신 단말은 단 하나만 가진다. 송신 단말은 clone으로 복제할 수 있다.
mpsc::channel 함수는 (송신 단말, 수신 단말) 형태의 튜플을 반환한다.
송신 단말은 send 메서드로 값을 전송하며, 이때 값의 소유권이 수신자에게 이전된다. send는 Result<T, E>를 반환하며, 수신 단말이 이미 닫혀 있으면 에러를 반환한다.
수신 단말에는 두 가지 메서드가 있다.
recv: 메시지가 도착할 때까지 블로킹하고, 값이 오면Result<T, E>로 반환한다. 모든 송신 단말이 drop되면 에러를 반환한다.try_recv: 블로킹하지 않고 즉시 결과를 반환한다. 메시지가 없으면 에러를 반환하므로, 메시지를 기다리는 동안 다른 작업을 수행할 수 있다.
또한, for msg in rx는 내부적으로 recv를 반복 호출하는 것과 같다. 메시지가 도착할 때까지 블로킹하며, 모든 송신 단말이 drop되면 루프가 자동으로 종료된다.
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
let (tx, rx) = mpsc::channel();
// 메시지 전송 스레드
thread::spawn(move || {
for i in 1..=3 {
tx.send(format!("메시지 {}", i)).unwrap();
thread::sleep(Duration::from_millis(500));
}
});
println!("--- for 루프 (recv 반복 호출) ---");
for msg in rx.iter().take(2) { // 2개만 받기
println!("수신: {}", msg);
}
println!("--- recv 메서드 ---");
match rx.recv() { // 메시지 1개 더 대기 (블로킹)
Ok(msg) => println!("recv로 수신: {}", msg),
Err(e) => println!("recv 에러: {}", e),
}
println!("--- try_recv 메서드 ---");
match rx.try_recv() { // 비블로킹: 남은 메시지가 없으므로 에러
Ok(msg) => println!("try_recv로 수신: {}", msg),
Err(e) => println!("try_recv 에러: {}", e),
}
공유 상태 - Mutex<T>, Arc<T>
뮤텍스는 한 번에 하나의 스레드만 데이터에 접근할 수 있도록 제한한다. 뮤텍스 안에 있는 값에 접근하려면 먼저 락(lock)을 얻어야 하고, 사용이 끝나면 반드시 언락(unlock)을 해야 한다.
Mutex<T>를 생성하려면 new를 사용하면 된다. 그리고 락을 얻기 위해서는 lock 메서드를 호출하는데, 이 호출은 다른 스레드가 락을 가지고 있으면 대기 상태가 된다. 만약 락을 가지고 있던 스레드가 패닉으로 종료되었다면, 락을 얻으려는 시도는 실패할 수 있다.
락을 얻으면 MutexGuard라는 스마트 포인터가 반환되는데, 이는 내부 데이터를 마치 가변 참조자처럼 다룰 수 있게 한다. 또한 MutexGuard는 스코프를 벗어나면 자동으로 해제되므로, 락을 수동으로 풀어줄 필요가 없다.
use std::sync::Mutex;
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {:?}", m);
뮤텍스만으로는 여러 스레드가 데이터를 공유하기에 충분하지 않다. 러스트의 소유권 규칙상 하나의 값은 하나의 소유자만 가질 수 있는데, 여러 스레드가 동시에 접근하려면 참조 카운팅이 필요하다. 이때 사용하는 것이 Arc<T>이다.
Arc<T>는 원자적으로 참조를 세는 타입으로, 여러 스레드가 동시에 같은 데이터를 가리켜도 참조 카운트가 안전하게 유지된다. 이를 통해 값의 소유권을 여러 스레드에 나눠줄 수 있다. 하지만 Arc<T>는 성능 비용이 크기 때문에 필요할 때만 사용해야 한다.
멀티스레드 환경에서 공유 상태를 만들고 싶다면 Arc<Mutex<T>>라는 조합을 사용하면 된다. Arc는 여러 스레드에 안전하게 소유권을 공유할 수 있게 해주고, Mutex는 그 안의 데이터에 동시에 접근할 때 상호 배제를 보장해준다.
use std::sync::{Arc, Mutex};
use std::thread;
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());
Send와 Sync 트레이트
Send 트레이트는 어떤 타입의 소유권을 스레드 사이에서 안전하게 이동시킬 수 있음을 나타낸다. 대부분의 러스트 타입은 기본적으로 Send를 구현하고 있지만, 예외도 있다. 예를 들어 Rc<T>는 스레드 안전하지 않기 때문에 Send가 아니다.
전체가 Send 타입으로만 구성된 복합 타입은 자동으로 Send로 마킹된다. 원시 포인터를 제외하면 거의 모든 기본 타입이 Send이다.
Sync 트레이트는 어떤 타입이 여러 스레드에서 동시에 참조되더라도 안전함을 나타낸다. 즉 &T가 Send이면, T는 Sync이다. 따라서 불변 참조자가 스레드 간에 안전하게 공유될 수 있다면, 그 타입 자체가 Sync라고 볼 수 있다.
기본 타입들은 대부분 Sync이며, 전체가 Sync 타입으로 이루어진 타입도 자동으로 Sync가 된다.
Send와 Sync는 모두 마커 트레이트로, 직접 구현할 필요도 없고 구현해야 할 메서드도 없다. 다만 이 두 트레이트는 타입이 동시성 환경에서 어떻게 사용될 수 있는지를 보장하는 중요한 불변성을 제공한다.