
두 스레드가 공유 벡터를 차례로 수정하면 최종 값은 [3, 5, 7]이나 [4, 6, 8]이 될 수 있다. 어느 스레드가 먼저 실행되는지는 정해져 있지 않다.
Rust 예제는 Mutex와 Arc를 함께 사용해 여러 스레드가 하나의 데이터를 공유하고 수정하는 방법을 보여준다. 공유 데이터에 한 번에 하나의 스레드만 접근하게 하려는 경우에 참고할 수 있다.
`Mutex`와 `Arc`는 무엇인가?
Mutex(상호 배제 잠금)는 여러 실행 흐름 가운데 한 번에 하나만 데이터에 접근하게 한다. 한 개의 공유 데이터를 지키는 문 하나를 떠올리면 쉽다. Arc(원자적 참조 카운팅)는 여러 스레드가 같은 Mutex를 가리키도록 참조를 관리한다.
예제에서는 벡터를 먼저 Mutex로 감싸고, 다시 Arc로 감싼다. 이렇게 하면 데이터 사본은 하나이고, 이를 가리키는 Arc를 복제해 여러 스레드에 넘길 수 있다.
use std::thread;
use std::{
sync::{Arc, Mutex},
time::Duration,
};
fn main() {
// create my data, wrap it in a mutex, then add atomic reference couting
let my_data = vec![1, 2, 3];
let my_data = Mutex::new(my_data);
let my_data = Arc::new(my_data);
// spawn a thread that will update the values
// a clone of our Arc will be moved into the thread
let thread_arc = my_data.clone();
let t1 = thread::spawn(move || {
println!("Thread 1 attempting to acquire lock...");
if let Ok(mut x) = thread_arc.lock() {
println!("Thread 1 acquired lock");
for num in x.iter_mut() {
*num += 1;
}
// simulate some long-running work
thread::sleep(Duration::from_millis(750));
};
println!("Thread 1 dropped lock");
// Do something else with the data
thread::sleep(Duration::from_millis(900));
});
let thread_arc = my_data.clone();
let t2 = thread::spawn(move || {
println!("Thread 2 attempting to acquire lock...");
if let Ok(mut x) = thread_arc.lock() {
println!("Thread 2 acquired lock");
for num in x.iter_mut() {
*num *= 2;
}
// simulate some long-running work
thread::sleep(Duration::from_millis(1250));
};
println!("Thread 2 dropped lock");
// Do something else with the data
thread::sleep(Duration::from_millis(1100));
});
t1.join().unwrap();
t2.join().unwrap();
let my_data = my_data.lock().unwrap();
println!("Values are: {:?}", my_data);
}
데이터를 만들고 두 겹으로 감싸는 과정은 한 표현식으로도 쓸 수 있다. 예제는 각 단계를 분명히 보이도록 변수 이름을 다시 사용하는 방식으로 나눈다.
let my_data = vec![1, 2, 3];
let my_data = Mutex::new(my_data);
let my_data = Arc::new(my_data);
스레드에 데이터를 어떻게 넘기나?
각 스레드를 만들기 전에 Arc<Mutex<Vec<i32>>>를 복제한다. 복제되는 것은 벡터나 Mutex가 아니라 Arc다. 따라서 복제본은 원래 데이터와 같은 Mutex와 벡터를 가리킨다. move를 사용해 복제한 Arc를 새 스레드로 옮기면, 그 변수는 메인 스레드에서 더는 사용할 수 없다.
// spawn a thread that will update the values
// a clone of our Arc will be moved into the thread
let thread_arc = my_data.clone();
스레드는 잠금을 얻으려 시도한다. 잠금을 얻으면 벡터를 수정하고, 다른 스레드가 이미 잠금을 사용 중이면 접근할 차례가 올 때까지 기다린다. 아래 코드는 첫 번째 스레드가 각 값에 1을 더하는 과정이다. 잠금을 잡은 동안 750밀리초를 기다리고, 잠금을 놓은 뒤에는 공유 데이터와 별도로 900밀리초를 기다린다.
let t1 = thread::spawn(move || {
println!("Thread 1 attempting to acquire lock...");
if let Ok(mut x) = thread_arc.lock() {
println!("Thread 1 acquired lock");
for num in x.iter_mut() {
*num += 1;
}
// simulate some long-running work
thread::sleep(Duration::from_millis(750));
};
println!("Thread 1 dropped lock");
// Do something else with the data
thread::sleep(Duration::from_millis(900));
});
두 번째 스레드는 각 값에 2를 곱한다. 두 스레드는 거의 동시에 시작하므로 실행 순서는 결정돼 있지 않다. 처음 값이 [1, 2, 3]일 때 첫 번째 스레드가 먼저 수정하면 [4, 6, 8]이 되고, 두 번째 스레드가 먼저 수정하면 [3, 5, 7]이 된다.
예제의 첫 코드에서는 두 스레드가 잠금 안에서 작업을 흉내 내는 대기만 한 뒤 끝난다. 글은 이를 지나치게 단순한 예라고 정정한다. 실제로는 잠금을 얻기 전이나 놓은 뒤에도 스레드가 할 일이 있어야 한다. 예를 들어 잠금을 얻기 전에 공유 자료 구조에 넣을 데이터를 준비하거나, 잠금 안에서 값을 갱신한 뒤 계산을 이어갈 수 있다. 잠금 안에서만 작업하는 구조라면 한 스레드가 잠금을 쓰는 동안 나머지 스레드는 차례로 기다리게 된다.
스레드가 끝날 때까지 기다리려면?
메인 스레드는 .join()으로 두 스레드가 모두 끝날 때까지 기다린다. 두 호출의 순서는 중요하지 않다. 둘 다 완료되기 전에는 다른 작업을 하지 않기 때문이다. .join()을 생략하면 메인 스레드가 작업 스레드가 시작되기 전에 잠금을 얻을 수도 있고, 작업 스레드가 실행될 기회를 얻기 전에 프로그램이 종료될 수도 있다. 스레드가 끝난 다음 메인 스레드는 잠금을 얻고 Result를 풀어 갱신된 데이터를 확인한다.
t1.join().unwrap();
t2.join().unwrap();
let my_data = my_data.lock().unwrap();
Arc의 원자적 연산은 일반적인 메모리 접근보다 비용이 크다는 점도 고려해야 한다. 다만 원문은 Arc를 사용하는 전체 비용이 지나치게 크지는 않다고 설명한다. 공유 데이터에 여러 스레드가 동시에 접근할 필요가 없거나, 잠금 안의 작업만 수행하는 구조라면 멀티스레딩이 적합하지 않을 수 있다.
이 예제에는 Rust 버전이나 성능 측정 조건이 제시돼 있지 않다.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요