공유 참조 뒤의 값을 바꿔야 한다고 해서 곧바로 잠금이 필요한 것은 아닙니다. RefCell<T>, Mutex<T>, RwLock<T>는 모두 공유 접근 중 변경을 허용하지만 충돌을 검사하는 시점과 충돌이 났을 때의 동작이 서로 다릅니다. 선택 기준은 성능에 대한 막연한 예상이 아니라 공유 범위와 실패 정책입니다.
1. 가변 참조가 기본
한 소유자가 값을 바꾸는 일반적인 코드라면 T를 직접 소유하고 &mut T로 수정하는 방식부터 선택합니다. 컴파일러가 “가변 참조 하나 또는 불변 참조 여러 개”라는 규칙을 실행 전에 확인하므로 별도의 런타임 빌림 상태나 잠금이 필요하지 않습니다. 호출자가 가변 참조를 건넬 수 있는데도 셀이나 잠금을 먼저 씌우면 실제 소유권 관계가 흐려집니다.
내부 가변성은 바깥 API가 &self나 &T를 유지해야 하는데 내부 상태는 바뀌어야 할 때 필요합니다. 이때 세 가지를 먼저 묻는 편이 좋습니다. 접근이 한 스레드 안에 머무는지, 여러 읽기를 동시에 허용해야 하는지, 충돌을 panic·Result·대기 중 어느 방식으로 처리할지 확인합니다.
| 접근 방식 | 충돌 검사 | 충돌 시 동작 | 접근을 유지하는 값 |
|---|---|---|---|
&mut T |
컴파일 시점 | 컴파일 오류 | 가변 참조 |
RefCell<T> |
런타임 빌림 상태 | borrow*는 panic, try_borrow*는 Result |
Ref<T>·RefMut<T> |
Mutex<T> |
잠금 획득 시점 | lock은 대기, try_lock은 즉시 보고 |
MutexGuard<T> |
RwLock<T> |
읽기·쓰기 잠금 획득 시점 | read·write는 대기, try_*는 즉시 보고 |
읽기·쓰기 guard |
18편에서는 Rc와 Arc가 누가 값을 소유하는지 다뤘습니다. 이번에는 공유된 payload에 지금 누가 접근할 수 있는지를 구분합니다. 스레드 생성과 Send·Sync의 자세한 규칙은 21편, 채널과 공유 상태 중 아키텍처를 고르는 문제는 22편의 범위입니다.
2. RefCell과 런타임 빌림
RefCell<T>는 한 스레드 안에서 빌림 규칙을 런타임에 검사합니다. 불변 빌림은 여러 개 존재할 수 있지만 가변 빌림과 겹칠 수 없고 가변 빌림은 하나만 허용됩니다. 내부 가변성은 규칙을 없애지 않습니다. 검사 시점만 컴파일 시점에서 실행 시점으로 옮깁니다.
예제의 LocalEndpointState는 공유 참조를 받는 record_success(&self) 안에서 카운터를 갱신합니다. 충돌을 호출자에게 돌려주기 위해 try_borrow_mut를 사용합니다.
pub struct LocalEndpointState {
successful_checks: RefCell<usize>,
}
impl LocalEndpointState {
#[must_use]
pub fn new() -> Self {
Self {
successful_checks: RefCell::new(0),
}
}
pub fn record_success(&self) -> Result<(), BorrowMutError> {
*self.successful_checks.try_borrow_mut()? += 1;
Ok(())
}
#[must_use]
pub fn read(&self) -> Ref<'_, usize> {
self.successful_checks.borrow()
}
}
borrow()와 borrow_mut()는 요청한 빌림이 현재 빌림과 충돌하면 panic을 일으킵니다. 충돌이 프로그래머 오류라는 정책에 맞습니다. 같은 조건을 정상적으로 처리해야 한다면 try_borrow()와 try_borrow_mut()를 사용해 Result를 받습니다. 모든 borrow() 호출이 panic을 일으키는 것은 아니며 활성 가변 빌림과 겹칠 때만 실패합니다.
반환된 Ref나 RefMut가 살아 있는 동안 빌림도 유지됩니다. 예제는 읽기 guard를 잡은 채 변경을 시도해 오류를 확인하고 drop(read_guard) 뒤 다시 변경합니다. 예상보다 넓은 변수 범위에 guard를 두면 충돌도 그만큼 오래 지속됩니다.
RefCell<T>는 동기화 도구가 아니며 Sync를 구현하지 않습니다. Arc<RefCell<T>>로 감싸도 여러 스레드가 안전하게 공유할 수 있는 타입으로 바뀌지 않습니다. 다음 소스는 그 경계를 일부러 넘으며 컴파일에 실패합니다.
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;
fn main() {
let checks = Arc::new(RefCell::new(0_usize));
let worker_checks = Arc::clone(&checks);
thread::spawn(move || {
*worker_checks.borrow_mut() += 1;
})
.join()
.expect("worker should finish");
}
크레이트 디렉터리에서 실행한 명령은 다음과 같습니다.
rustc --edition=2024 --error-format=short fixtures/refcell_across_thread.rs
short 형식으로 저장한 E0277 진단 전문입니다.
fixtures/refcell_across_thread.rs:9:19: error[E0277]: `RefCell<usize>` cannot be shared between threads safely: `RefCell<usize>` cannot be shared between threads safely
error: aborting due to 1 previous error
3. UnsafeCell의 경계
UnsafeCell<T>는 Rust 내부 가변성의 저수준 원시 타입입니다. RefCell<T> 같은 안전한 추상화가 내부에서 이를 사용해 공유 참조 뒤의 값을 제한된 방식으로 변경합니다. 여기서 완화되는 것은 공유 참조의 불변성 보장입니다. &mut T의 유일성 보장은 그대로이며 UnsafeCell<T> 자체는 스레드를 동기화하거나 데이터 경합을 막지 않습니다.
따라서 UnsafeCell<T>을 “빌림 검사기를 끄는 타입”으로 이해하면 안 됩니다. 안전한 API를 직접 구현하려면 별칭과 동시성에 관한 추가 불변식을 작성자가 증명해야 합니다. 이 글과 예제는 개념적 경계만 짚고 unsafe 코드나 사용자 정의 셀을 만들지 않습니다.
4. Mutex와 단독 접근
Mutex<T>는 보호한 T에 한 번에 하나의 guard만 접근하게 합니다. lock()은 다른 guard가 잠금을 해제할 때까지 현재 스레드를 막을 수 있고 성공하면 MutexGuard<T>를 돌려줍니다. guard가 scope를 벗어나거나 명시적으로 drop되면 잠금이 풀리는 RAII 방식입니다.
예제에서는 세 작업자가 같은 결과 벡터의 소유권을 Arc로 나누고 각 작업자가 Mutex를 잠근 뒤 결과 하나를 추가합니다. 스케줄 순서는 보장하지 않으므로 join 뒤 이름으로 정렬해 출력을 고정합니다.
#[must_use]
pub fn aggregate_results(inputs: Vec<EndpointResult>) -> Aggregation {
let completed = Arc::new(Mutex::new(Vec::new()));
let handles: Vec<_> = inputs
.into_iter()
.map(|result| {
let completed = Arc::clone(&completed);
thread::spawn(move || {
completed
.lock()
.expect("worker result mutex should not be poisoned")
.push((thread::current().id(), result));
})
})
.collect();
for handle in handles {
handle.join().expect("endpoint worker should finish");
}
let mut completed = Arc::try_unwrap(completed)
.expect("all worker owners should be joined")
.into_inner()
.expect("worker result mutex should not be poisoned");
let worker_threads = completed
.iter()
.map(|(thread_id, _)| *thread_id)
.collect::<HashSet<_>>()
.len();
let mut results: Vec<_> = completed.drain(..).map(|(_, result)| result).collect();
results.sort_by(|left, right| left.name.cmp(&right.name));
Aggregation {
results,
worker_threads,
}
}
잠금 획득 결과는 LockResult<MutexGuard<_>>, 즉 Result<_, PoisonError<_>>입니다. 스레드가 mutex guard를 가진 채 panic하면 이후 획득에서 poison을 관찰할 수 있습니다. 다만 poisoning은 자문적 신호이며 완전한 무결성 장벽이 아닙니다. poison이 없다는 사실만으로 데이터가 올바르다고 단정해서도 안 됩니다.
recover_poisoned_monitor의 정책은 범위를 좁혀 읽어야 합니다. 작업자 panic 뒤 PoisonError::into_inner로 guard에 명시적으로 접근하고 저장된 41에 1을 더해 42를 관찰합니다. 이 함수는 clear_poison을 호출하지 않으며 알려진 정상 상태를 복원하거나 불변식 수리를 증명하지도 않습니다. 실제 서비스가 복구를 택한다면 먼저 데이터를 검증하거나 교체해 불변식을 회복하고 정책상 필요할 때에만 poison을 의도적으로 지워야 합니다. 오류를 상위로 전달하는 정책도 가능합니다.
#[must_use]
pub fn recover_poisoned_monitor() -> PoisonRecovery {
let monitor = Arc::new(Mutex::new(0_usize));
let worker_monitor = Arc::clone(&monitor);
let worker = thread::spawn(move || {
let mut checks = worker_monitor.lock().expect("mutex starts healthy");
*checks = 41;
std::panic::resume_unwind(Box::new("deliberate worker failure"));
});
assert!(worker.join().is_err(), "worker should report its panic");
let mut recovered = match monitor.lock() {
Ok(_) => panic!("worker panic should poison the mutex"),
Err(poisoned) => poisoned.into_inner(),
};
*recovered += 1;
let recovered_checks = *recovered;
PoisonRecovery {
poison_observed: true,
recovered_checks,
}
}
try_lock()은 기다리지 않고 현재 획득 가능 여부를 보고할 때 쓸 수 있습니다. 반면 이미 같은 스레드가 guard를 가진 mutex에 다시 lock()을 호출하는 동작은 재귀 잠금 계약이 아닙니다. 두 번째 호출은 반환하지 않을 수 있으며 panic이나 교착 상태가 될 수 있습니다. 특정 실패 형태에 의존하지 말고 기존 guard를 먼저 drop해야 합니다.
5. RwLock의 읽기와 쓰기
RwLock<T>는 여러 읽기 guard 또는 하나의 쓰기 guard를 허용합니다. read()는 공유 접근을, write()는 배타적 가변 접근을 제공합니다. 쓰기 guard가 있으면 다른 읽기와 쓰기가 모두 기다려야 하고 읽기 guard가 하나라도 있으면 쓰기는 들어갈 수 없습니다.
예제는 스케줄링에 기대지 않고 try_read()와 try_write()로 이 호환 규칙만 검사합니다. 두 reader가 동시에 들어간 상태에서 쓰기가 WouldBlock인지 확인하고 reader를 drop한 뒤 writer로 값을 바꿉니다. writer가 살아 있는 동안에는 읽기와 다른 쓰기가 모두 WouldBlock입니다.
#[must_use]
pub fn observe_rwlock_access() -> RwLockTrace {
let status = RwLock::new(1_usize);
let first_reader = status.read().expect("rwlock should be healthy");
let second_reader = status.try_read().expect("another reader should enter");
let two_readers_overlap = *first_reader == *second_reader;
let write_would_block_for_readers = matches!(status.try_write(), Err(TryLockError::WouldBlock));
drop((first_reader, second_reader));
let mut writer = status
.write()
.expect("writer should enter after readers leave");
*writer += 1;
let value_after_write = *writer;
let read_would_block_for_writer = matches!(status.try_read(), Err(TryLockError::WouldBlock));
let write_would_block_for_writer = matches!(status.try_write(), Err(TryLockError::WouldBlock));
drop(writer);
RwLockTrace {
two_readers_overlap,
write_would_block_for_readers,
value_after_write,
read_would_block_for_writer,
write_would_block_for_writer,
}
}
이 검사는 공존 가능한 접근 종류를 증명할 뿐 처리량이나 공정성을 증명하지 않습니다. 표준 RwLock의 reader·writer 우선순위는 기반 운영체제 구현에 달려 있으며 특정 정책이 보장되지 않습니다. 코드가 FIFO 순서, writer 우선, starvation 방지를 전제로 삼으면 이식 가능한 계약을 벗어납니다.
Poison 범위도 Mutex와 조금 다릅니다. 표준 RwLock은 배타적인 쓰기 guard를 가진 실행이 panic할 때만 poison될 수 있습니다. 읽기 guard 아래의 panic은 lock을 poison하지 않습니다. 여기서도 poisoning은 자문적 신호라는 제한은 같습니다.
6. 소유권과 잠금 범위
Arc<T>와 잠금은 서로 다른 결정을 표현합니다. Arc는 여러 소유자가 같은 할당을 살려 두게 하고 내부 Mutex나 RwLock이 현재 payload에 접근할 수 있는 주체를 조정합니다. 그래서 Arc<Mutex<T>>는 교차 스레드 공동 소유와 단독 변경을 조합합니다. Arc만으로 임의의 T에 동기화가 생기지는 않습니다.
Guard는 보호된 데이터에 필요한 작업만 마칠 만큼 좁게 유지합니다. 다른 잠금을 획득하거나 작업자 종료를 기다리거나 무관한 계산을 하기 전에 inner block을 끝내거나 drop(guard)를 호출합니다. RAII는 정상 경로에서 unlock 호출을 잊는 문제를 막지만 잠금 순서가 뒤집힌 순환이나 같은 mutex의 재잠금까지 제거하지는 않습니다. 잠금이 여러 개라면 하나의 순서를 정해 지켜야 하며 그래도 임의의 코드가 교착 상태에 빠지지 않는다는 증명이 되지는 않습니다.
전체 Rust 2024 예제에는 외부 의존성이 없습니다. 다음 명령은 프로젝트 디렉터리에서 정확히 이 크레이트 디렉터리로 이동해 실행합니다. 마지막 rustc 명령은 E0277로 실패해야 정상입니다.
cd examples/article-19-interior-mutability-sync
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
rustc --edition=2024 --error-format=short fixtures/refcell_across_thread.rs
Rust 1.98.1과 Cargo 1.98.1에서 다섯 Cargo 명령은 종료 코드 0을 반환해야 합니다. 통합 테스트 6개에는 compile-fail 검사도 포함됩니다. 실행 출력은 다음과 같이 고정됩니다.
local: checks=2 conflict=borrow-error
workers: count=3 results=admin:up,api:down,status:up
rwlock: readers=2 reader-write=blocked value=2 writer-read=blocked writer-write=blocked
poison: observed=true recovered=42
선택 순서는 단순합니다. 직접 소유한 값과 &mut T로 충분하면 그대로 둡니다. 한 스레드의 공유 참조 API에서 동적 빌림이 필요하면 RefCell<T>를, 한 번에 하나의 접근만 허용해야 하면 Mutex<T>를, 여러 읽기와 하나의 쓰기를 API 계약으로 구분해야 하면 RwLock<T>를 검토합니다. 여러 스레드 소유자가 같은 할당을 유지해야 할 때만 Arc를 더합니다. 잠금 선택은 22편에서 다룰 채널 대 공유 상태의 전체 설계를 대신하지 않습니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 19 프로젝트에서 확인할 수 있습니다.
출처
- The Rust Programming Language: RefCell<T> and the Interior Mutability Pattern
- The Rust Programming Language: Shared-State Concurrency
- Rust 표준 라이브러리 1.98.1: UnsafeCell
- Rust 표준 라이브러리 1.98.1: RefCell
- Rust 표준 라이브러리 1.98.1: Mutex
- Rust 표준 라이브러리 1.98.1: LockResult
- Rust 표준 라이브러리 1.98.1: RwLock
- Rust 표준 라이브러리 1.98.1: Arc
답글 남기기