Arc, Mutex, 채널을 이용한 Rust 동시성 마스터하기
Wenhao Wang
Dev Intern · Leapcell

소개
고성능 및 반응형 애플리케이션을 추구함에 있어 동시성을 통해 여러 CPU 코어를 활용하는 것은 단순한 장점을 넘어 필수적인 요소가 되었습니다. 하지만 동시성은 종종 심각한 과제를 안고 옵니다. 바로 공유 상태와 병렬 실행 단위 간의 안전한 통신을 관리하는 것입니다. 많은 언어에서의 전통적인 접근 방식은 데이터 경합, 교착 상태, 메모리 손상과 같은 악명 높은 버그로 이어질 수 있어 동시성 프로그래밍을 어려운 작업으로 만듭니다. Rust는 강력한 소유권 및 타입 시스템을 통해 동시성에 대해 놀랍도록 다르고 견고한 접근 방식을 제공합니다. 런타임 검사나 프로그래머 오류에 취약한 복잡한 잠금 체계에 의존하는 대신, Rust는 컴파일 시 안전성을 강제하여 이러한 일반적인 문제점을 제거하는 것을 목표로 합니다. 이 글에서는 공유 소유권을 위한 Arc
, 공유 가변 상태를 위한 Mutex
, 안전한 통신을 위한 Channels
등 Rust가 제공하는 동시성 프로그래밍의 핵심 도구들을 살펴보고, 올바른 사용법과 함께 이러한 도구들이 어떻게 신뢰할 수 있고 효율적인 동시성 애플리케이션을 가능하게 하는지 보여줄 것입니다.
Rust의 기본 요소를 사용한 안전한 동시성
Rust의 동시성 철학은 종종 "두려움 없는 동시성"으로 요약됩니다. 이것은 단순한 마케팅 슬로건이 아닙니다. 설계 원칙의 직접적인 결과입니다. 구체적인 내용을 살펴보기 전에 Rust의 동시성 모델을 뒷받침하는 기본 개념을 이해해 봅시다.
스레드란 무엇인가?
기본적으로 스레드는 프로그램 내에서의 실행 순서입니다. 단일 프로그램은 여러 스레드를 동시에 실행할 수 있습니다. 각 스레드는 자체 호출 스택을 가지지만, 같은 프로세스 내의 스레드는 동일한 메모리 공간을 공유합니다. 이 공유 메모리는 정확히 위험이 존재하는 곳입니다. 여러 스레드가 동일한 데이터에 동시에 접근하여 읽고 쓰려고 하면 예측 불가능한 동작으로 이어질 수 있습니다.
Arc
: 원자적 참조 카운팅
여러 스레드가 동일한 데이터 조각을 소유하고 액세스해야 할 때 Arc
(Atomic Reference Count)가 도움이 됩니다. 이는 Rc
(Reference Count)의 스레드 안전 버전입니다. Rc
와 마찬가지로 Arc
는 T
에 대한 여러 포인터를 생성할 수 있으며, T
데이터는 해당 데이터에 대한 마지막 Arc
포인터가 범위를 벗어날 때만 해제됩니다. "원자적"이라는 부분은 중요합니다. 이는 참조 카운트가 원자적 연산을 사용하여 업데이트됨을 의미하며, 이는 다중 스레드 컨텍스트에서 안전함을 보장합니다. 원자적 연산이 없으면 공유 참조 카운트를 증감하는 것은 카운트가 부정확해질 수 있는 경합 상태로 이어져 잠재적으로 메모리 누수나 조기 해제를 초래할 수 있습니다.
여러 작업자 스레드가 공유 구성 객체에서 데이터를 처리해야 하는 시나리오를 생각해 봅시다. Arc
를 사용하면 각 스레드는 구성에 대한 자체 "소유권"을 가질 수 있습니다.
use std::sync::Arc; use std::thread; struct Config { processing_units: usize, timeout_seconds: u64, } fn main() { let app_config = Arc::new(Config { processing_units: 4, timeout_seconds: 30, }); let mut handles = vec![]; for i in 0..app_config.processing_units { // Arc를 복제하여 각 스레드에 대한 새로운 "소유자"를 생성합니다 let thread_config = Arc::clone(&app_config); handles.push(thread::spawn(move || { println!("Thread {} using config: units={}, timeout={}", i, thread_config.processing_units, thread_config.timeout_seconds); // 구성을 사용하는 작업 시뮬레이션 thread::sleep(std::time::Duration::from_millis(500)); })); } for handle in handles { handle.join().unwrap(); } println!("All threads finished."); }
이 예제에서 Arc::clone(&app_config)
는 참조 카운트를 증가시킵니다. 스레드가 완료되고 thread_config
가 범위를 벗어나면 참조 카운트가 감소합니다. app_config
(및 Config
데이터)는 모든 Arc
인스턴스가 사라질 때만 드롭됩니다.
Mutex
: 공유 가변 상태에 대한 상호 배제
Arc
는 여러 스레드가 데이터를 공유 소유할 수 있도록 하지만, 해당 데이터를 안전하게 변경하는 문제는 해결하지 못합니다. 여러 스레드가 동시에 동일한 공유 데이터에 쓰려고 하면 데이터 경합이 발생합니다. 이때 Mutex
(Mutual Exclusion)가 사용됩니다. 뮤텍스는 한 번에 하나의 스레드만 보호된 데이터에 액세스할 수 있도록 보장합니다. 스레드가 데이터에 액세스하려고 할 때 먼저 뮤텍스 잠금을 "획득"해야 합니다. 잠금이 이미 다른 스레드에 의해 보유 중인 경우, 요청 스레드는 잠금이 사용 가능해질 때까지 차단됩니다. 스레드가 데이터 작업을 마치면 잠금을 "해제"하여 다른 스레드가 잠금을 획득할 수 있도록 합니다.
Rust에서는 Mutex<T>
가 보호하는 T
데이터를 래핑합니다. 안에 있는 T
에 액세스하려면 .lock()
을 호출해야 하며, 이는 MutexGuard
를 반환합니다. 이 가드 객체는 Deref
를 &mut T
로, Drop
을 잠금 해제를 위해 구현합니다. 이 Drop
구현은 스레드가 패닉하더라도 잠금이 해제되도록 보장하므로 안전성과 편의성에 중요합니다.
Arc
와 Mutex
를 결합하여 스레드 간에 가변 카운터를 공유해 보겠습니다.
use std::sync::{Arc, Mutex}; use std::thread; fn main() { // Arc는 스레드 간에 공유 소유권을 위해 필요합니다. // Mutex는 카운터에 대한 가변 액세스를 위해 필요합니다. let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter_clone = Arc::clone(&counter); handles.push(thread::spawn(move || { // 잠금 획득. 다른 스레드가 잠금을 보유 중이면 이 호출은 차단됩니다. let mut num = counter_clone.lock().unwrap(); *num += 1; // 공유 데이터 수정 // `num`이 범위를 벗어날 때 (이 클로저의 끝에서) 잠금이 자동으로 해제됩니다. })); } for handle in handles { handle.join().unwrap(); } // 최종 값을 읽기 위해 마지막으로 잠금을 획득합니다. println!("Final counter value: {}", *counter.lock().unwrap()); }
이 코드는 여러 스레드에 걸쳐 가변 정수를 공유하는 Rust의 관용적인 방법인 Arc<Mutex<i32>>
입니다. 각 thread::spawn
클로저는 복제된 Arc
를 받습니다. 클로저 내부에서 counter_clone.lock().unwrap()
는 잠금을 획득하려고 시도합니다. 성공하면 MutexGuard
를 반환합니다(이는 &mut i32
로 역참조 됨). 이를 통해 카운터를 증가시킬 수 있습니다. num
이 범위를 벗어나면 MutexGuard
가 드롭되어 자동으로 잠금을 해제합니다.
채널: 메시지 전달을 통한 통신
Arc
와 Mutex
는 상태 공유에 매우 유용하지만, 때로는 상태를 직접 공유하는 것을 피하고 대신 메시지를 전달하여 스레드 간에 통신하는 것이 더 좋습니다. Rust의 표준 라이브러리는 std::sync::mpsc
(Multiple Producer, Single Consumer)를 통해 채널을 제공합니다. 이 모듈을 사용하면 송신자(Sender<T>
)와 수신자(Receiver<T>
)가 있는 "채널"을 만들 수 있습니다. 하나 이상의 송신자가 채널로 T
유형의 메시지를 보낼 수 있고, 단일 수신자가 해당 메시지를 받을 수 있습니다.
채널은 계산이 독립적이고 결과를 수집해야 하거나, 스레드가 직접 공유 메모리를 조작하지 않고 작업을 조정해야 하는 시나리오에 훌륭합니다.
메인 스레드가 여러 작업자 스레드로 작업을 보내고, 해당 작업자들이 완료된 결과를 다시 보내는 예제를 봅시다.
use std::sync::mpsc; use std::thread; use std::time::Duration; fn main() { // 채널 생성: (송신자, 수신자) let (tx, rx) = mpsc::channel(); let num_workers = 3; let mut handles = vec![]; for i in 0..num_workers { let tx_clone = tx.clone(); // 각 작업자를 위해 송신자 복제 handles.push(thread::spawn(move || { let task_id = i + 1; println!("Worker {} started.", task_id); // 작업 시뮬레이션 thread::sleep(Duration::from_millis(500 * task_id as u64)); let result = format!("Worker {} finished task.", task_id); // 결과를 메인 스레드로 다시 보냅니다. tx_clone.send(result).unwrap(); println!("Worker {} sent result.", task_id); })); } // 메인 스레드가 더 이상 메시지를 보내지 않음을 알리기 위해 원래 송신자 드롭. // 이는 수신자가 메시지를 기다리는 것을 언제 중지해야 하는지 알기 위해 중요합니다. drop(tx); // 수신자로부터 결과 수집 for received in rx { println!("Main thread received: {}", received); } // 모든 작업자 스레드가 완료될 때까지 대기 for handle in handles { handle.join().unwrap(); } println!("All workers and main thread finished processing messages."); }
이 예제에서는 다음과 같습니다.
mpsc::channel()
가 채널을 생성합니다.tx
(송신자)는 각 작업자 스레드에 대해 복제됩니다. 이것은 "다중 생산자" 측면을 보여줍니다.- 각 작업자는 일부 작업을 수행한 다음
tx_clone.send(result).unwrap()
로 채널에 메시지를 보냅니다. - 메인 스레드는
rx
(수신자)를 반복합니다. 이 루프는 메시지가 사용 가능할 때까지 차단되고 모든 송신자가 드롭될 때까지 계속됩니다(모든 작업자를 생성한 후drop(tx)
를 명시적으로 호출하고 작업자tx_clone
이 범위를 벗어날 때 암시적으로).
올바른 도구 선택
- 여러 스레드가 동일한 불변 데이터에 대한 소유권을 갖고 읽기 전용 액세스를 해야 할 때 **
Arc
**를 사용합니다. - 여러 스레드가 동일한 데이터에 대한 소유권을 갖고 가변 액세스를 해야 할 때는 **
Arc<Mutex<T>>
**를 사용합니다. 뮤텍스는 경쟁을 유발하며 임계 영역이 너무 길거나 자주 사용되면 성능을 저하시킬 수 있음을 기억하십시오. - 스레드가 메시지를 전달하여 통신해야 할 때, 특히 그들의 활동이 어느 정도 독립적이고 한 스레드가 다른 스레드가 소비하는 데이터를 생성할 때 **
Channels
**를 사용합니다. 이는 종종 공유 가변 상태를 피함으로써 더 간단하고 더 견고한 설계를 이끌어냅니다.
결론
Rust의 강력한 소유권 및 타입 시스템을 기반으로 구축된 동시성 접근 방식은 Arc
, Mutex
, 채널(mpsc
)과 같은 강력한 기본 요소들을 제공합니다. 이러한 도구들은 개발자들이 다른 언어에서 흔히 발생하는 데이터 경합과 교착 상태를 크게 제거하여 자신감을 갖고 고도의 동시성 애플리케이션을 구축할 수 있도록 합니다. 공유 소유권(Arc
)을 사용할 때, 가변 데이터에 대한 상호 배제를 제공할 때(Mutex
), 그리고 메시지 전달(Channels
)을 선택할 때를 이해함으로써, 최신 멀티 코어 프로세서의 성능을 진정으로 활용하는 효율적이고 안전하며 신뢰할 수 있는 동시성 시스템을 설계할 수 있습니다. Rust는 여러분이 복잡한 병렬 문제를 매우 다루기 쉽게 만들어, 두려움 없는 동시성을 달성할 수 있도록 합니다.