rokdocs
v0.1

2.16. sync - 동시성 primitive

rok::core::sync 모듈은 공유 상태에 안전하게 접근할 수 있도록 도와주는 primitive 트레잇과 타입을 정의합니다. rok 의 GC 와 M:N 스케줄러 환경에서 multi-task 또는 multi-thread 사이의 데이터 공유를 처리할 때 사용합니다.

use rok::core::**; 가 묵시적으로 적용되므로 별도의 import 없이도 Atomic, Mutex, RwLock, Cell 을 곧바로 사용할 수 있습니다. 다만 동시성 코드임을 시각적으로 분명히 드러내고 싶은 자리에서는 다음과 같이 좁은 범위의 import 를 권장합니다.

use rok::core::sync::{Atomic, Mutex, RwLock, Once, Condvar, Cell};

2.16.1. 4 primitive 개요

네 가지 primitive 는 각각 서로 다른 동시성 시나리오를 담당합니다. 다음 표는 각 타입의 의미와 어울리는 사용 자리를 한눈에 정리한 것입니다.

타입의미사용 자리
Atomic<T>lock-free 원자 연산 (T = primitive)counter, flag, sequence number
Mutex<T>배타적 잠금 (single writer)shared mutable state
RwLock<T>다중 reader 또는 단일 writerread-heavy state
Cell<T>thread-local interior mutability (no lock)single-threaded mutable

2.16.2. Atomic<T> - lock-free 원자

가장 가벼운 primitive 인 Atomic<T> 부터 살펴봅니다. 내부적으로는 T 별 LLVM atomic 연산으로 구현되며, 다음과 같은 형태를 갖춥니다.

struct Atomic<T: AtomicPrimitive> {
    // 내부: AtomicU32 / AtomicU64 / AtomicPtr 등 (T 별 LLVM atomic ops)
}

trait AtomicPrimitive { /* marker - i32/i64/u32/u64/usize/isize/bool/Ptr */ }

impl<T: AtomicPrimitive> Atomic<T> {
    const fn new(value: T) -> Atomic<T>;

    fn load(self, ordering: AtomicOrdering = SeqCst) -> T;
    fn store(self, value: T, ordering: AtomicOrdering = SeqCst);
    fn swap(self, value: T, ordering: AtomicOrdering = SeqCst) -> T;
    fn compare_exchange(
        self, current: T, new: T,
        success: AtomicOrdering, failure: AtomicOrdering,
    ) -> Result<T, T>;                  // 실패 결과도 값 - 에러가 아님
    fn compare_exchange_weak(
        self, current: T, new: T,
        success: AtomicOrdering, failure: AtomicOrdering,
    ) -> Result<T, T>;                  // 실패 결과도 값 - 에러가 아님

    // 정수 전용
    fn fetch_add(self, value: T, ordering: AtomicOrdering = SeqCst) -> T;
    fn fetch_sub(self, value: T, ordering: AtomicOrdering = SeqCst) -> T;
    fn fetch_and(self, value: T, ordering: AtomicOrdering = SeqCst) -> T;
    fn fetch_or(self, value: T, ordering: AtomicOrdering = SeqCst) -> T;
    fn fetch_xor(self, value: T, ordering: AtomicOrdering = SeqCst) -> T;
}

impl Atomic<i32> {
    fn wait(self, expected: i32, timeout_ns: i64 = -1);   // 값이 expected 인 동안, 최대 timeout_ns (< 0 이면 무한) 대기
    fn wake(self);                                         // 이 cell 에서 wait 중인 스레드를 모두 깨움
}

wait 는 이유 없이 돌아올 수 있으므로 호출자가 조건을 다시 확인합니다 (futex 의미).

AtomicOrdering

원자 연산은 메모리 순서 보장 수준에 따라 다섯 가지로 나뉘며, 각 변형의 의미는 다음과 같습니다.

enum AtomicOrdering {
    Relaxed,        // 동기화 없음 - counter 등
    Acquire,        // load 후 동기화 보장
    Release,        // store 전 동기화 보장
    AcqRel,         // RMW 시 양방향
    SeqCst,         // 전역 순차 일관성 (default - 안전)
}

이 모델은 C++ 와 Rust 의 atomic ordering 모델과 동형이며, 기본값은 안전한 SeqCst 입니다. 이름이 AtomicOrdering 인 것은 암묵 core glob 아래에서 비교용 cmp::Ordering 과 겹치지 않게 하기 위함이며, 변형 (Relaxed, Acquire, Release, AcqRel, SeqCst) 은 접두 없이 쓸 수 있습니다. ordering 인자는 컴파일 타임 상수여야 합니다. 따라서 약한 ordering 은 성능이 critical 한 자리에서만 신중하게 사용하는 것이 좋습니다.

사용 예

가장 흔한 용도인 카운터 증가는 다음과 같이 작성합니다.

static COUNTER: Atomic<i32> = Atomic::new(0);

task::spawn(async { COUNTER.fetch_add(1, Relaxed); });
task::spawn(async { COUNTER.fetch_add(1, Relaxed); });
// 결과: COUNTER == 2 (race 없음)

let v = COUNTER.load(SeqCst);     //→ 2

CAS (compare-and-swap)

좀 더 복잡한 lock-free 자료 구조는 compare-and-swap 루프 위에서 구성합니다. 다음은 lock-free 스택의 push 예시입니다.

fn lock_free_push(stack: Atomic<List<T> | none>, x: T) {
    loop {
        let cur = stack.load(Acquire);
        let new = match cur {
            v: List<T> => v.with_appended(x),
            none       => [x],
        };
        match stack.compare_exchange(cur, new, AcqRel, Acquire) {
            Ok(_)  => break,
            Err(_) => continue,                   // 다른 task 가 먼저 - 재시도
        }
    }
}

2.16.3. Mutex<T> - 배타적 잠금

원자 연산만으로 표현하기 어려운 공유 상태에는 Mutex<T> 를 사용합니다. 내부는 futex 와 payload 로 구성되며, using 으로 묶은 가드가 잠금을 해제합니다.

struct Mutex<T> {
    // 내부: futex + payload
}

impl<T> Mutex<T> {
    fn new(value: T) -> Mutex<T>;
    unsafe fn new_unshared(value: T) -> Mutex<T>;   // 넘김 복사 없는 new

    fn lock(self) -> MutexGuard<T>;            // 스레드를 block
    fn lock_async(self) -> LockFuture<T>;      // await m.lock_async() - task 만 중단 (Future<Output = MutexGuard<T>>)
    fn try_lock(self) -> MutexGuard<T> | none; // non-blocking
}

struct MutexGuard<T> { /* RAII guard */ }

impl<T> Drop for MutexGuard<T> {
    fn drop(ref self) {
        // futex_wake + 잠금 해제
    }
}

impl<T> MutexGuard<T> {
    fn get(self) -> T;                  // 보호된 값
    fn set(self, value: T);             // 값 교체
    fn update(self, f: fn(T) -> T);     // 읽고 바꿔 씀
}

사용 예

가드를 using 으로 묶으면 블록을 빠져나갈 때 drop 되어 잠금이 해제됩니다. using 없이 받은 가드는 자동으로 풀리지 않으므로 반드시 using 으로 묶습니다. 가드는 호출, 메서드 호출, await 에서 받습니다. 받은 가드는 바인딩 (let, 패턴, using) 을 초기화하거나, 변수에 대입하거나, 호출자에게 넘기는 (return, 함수의 값) 데에만 쓸 수 있습니다. 함수에 넘기거나, 그 위에서 메서드를 부르거나, 필드나 컬렉션에 저장하거나, 버리면 컴파일 에러입니다. using 으로 묶은 가드는 함수에 넘길 수 있으며, using 없이 묶은 가드는 넘긴다고 해서 해제되지 않습니다. using 으로 해제되지 않는 일반 let (또는 if let) 으로 가드를 묶거나, lock 이 돌려준 가드를 버리면 컴파일 에러입니다.

Mutex::new(value) 는 value 를 넘겨받습니다: 그 뒤에 호출자의 핸들을 쓰면 컴파일 에러이며, guard 의 using 블록이 끝난 뒤 get 이 돌려준 것이나 set 이 준 것을 쓰거나, guard 가 쥔 동안 그것을 넘기거나, 블록 안에서 그것을 돌려주거나, yield 하거나, 블록의 값으로 삼거나, 블록 뒤에 실행하는 코루틴에 주는 것도 그렇습니다 (2.13.9). using 블록 안에서 다루거나 clone() 을 남깁니다. guard 는 !Sync 이고 자기 using 블록 안에서만 쓸 수 있습니다: 블록이 끝난 뒤 guard 의 복사본 (컬렉션, 클로저, 필드 안의 것) 을 쓰면 컴파일 에러입니다. 다른 스레드에서 도는 클로저에서 값을 쓰려면 먼저 읽고 (let v = g.get();) v 를 잡습니다. 실행 중에는 new, set, update 가 값이 들여오는 컬렉션의 복사본을 저장합니다 (2.13.9). new_unshared 는 복사하지 않으며, 호출자가 value 의 컬렉션에 다른 것이 닿지 않음 (방금 만든 값) 을 보장합니다.

static STATE: Mutex<List<i32>> = Mutex::new([]);

task::spawn(async {
    using g = STATE.lock() {
        g.update(|xs| { xs.push(42); xs });
    }   // ← g.drop() → 잠금 해제 ([2.13 marker](213-marker.md))
});

deadlock 회피 - try_lock

deadlock 을 피하고 싶을 때는 blocking 대신 try_lock 으로 비차단 시도를 할 수 있습니다.

match STATE.try_lock() {
    v: MutexGuard<...> => using g = v { g.push(1); },
    none => { log("locked, skip"); },
}

poisoning - 없음

rok 의 Mutex 는 Rust 의 PoisonError 모델과 다릅니다. panic 이 발생해도 잠금은 자동으로 해제되며, 다음 task 는 정상적으로 진입할 수 있습니다. 이렇게 설계한 이유는 두 가지입니다.

  1. panic 은 잡을 수 없고 프로세스를 종료하므로, panic 도중의 잠금 상태를 다른 task 가 관측하는 일이 없습니다. 에러가 throw 로 전파되어 using 블록을 벗어날 때에는 using 이 Drop 을 호출하므로, MutexGuard 의 drop 이 잠금 해제를 책임집니다.
  2. poison 의 의미는 보수적으로 보이지만 사용자 부담이 크고, 대부분의 경우 false positive 입니다.

에러로 빠져나가기 전에 일관성 복원이 정말로 필요한 자리에서는 다음과 같이 명시적으로 처리하면 됩니다.

using g = MUTEX.lock() {
    try unsafe_op(g) catch e {
        g.reset_to_known_good_state();
        throw e;
    };
}

2.16.4. RwLock<T> - read-write 분리

읽기가 압도적으로 많은 자리에는 RwLock<T> 가 더 적합합니다. 다중 reader 와 단일 writer 를 분리하여 throughput 을 끌어올립니다.

struct RwLock<T> { /* ... */ }

impl<T> RwLock<T> {
    fn new(value: T) -> RwLock<T>;

    fn read(self) -> RwLockReadGuard<T>;        // 다중 reader OK
    fn write(self) -> RwLockWriteGuard<T>;      // 단일 writer

    fn try_read(self) -> RwLockReadGuard<T> | none;
    fn try_write(self) -> RwLockWriteGuard<T> | none;

    fn read_async(self) -> ReadFuture<T>;       // await l.read_async() - task 만 중단
    fn write_async(self) -> WriteFuture<T>;
}

config 나 cache 처럼 read 가 압도적으로 많은 자리에서 사용하면 좋고, write 가 드문 워크로드에서는 Mutex 보다 throughput 이 우수합니다.

static CONFIG: RwLock<Config> = RwLock::new(Config::default());

using c = CONFIG.read() {        // 다른 reader 와 동시 OK
    process(c);
}   // ← read guard drop, lock 해제

using w = CONFIG.write() {       // 잠시 모든 reader 차단
    w.reload_from_file();
}

RwLock<T> 는 T 가 Send + Sync 일 때만 Sync 이므로 (2.16.5), 여기서 Config 는 Sync 여야 합니다.

Mutex 와 마찬가지로 RwLock::new(value) 는 value 를 넘겨받습니다: 그 뒤에 호출자의 핸들을 쓰면 컴파일 에러이며, using 블록이 끝난 뒤 guard 의 get 이 돌려준 것이나 set 이 준 것을 쓰거나, guard 가 쥔 동안 그것을 넘기거나, 블록 안에서 그것을 돌려주는 것도 그렇습니다 (2.13.9). using 블록 안에서 다루거나 clone() 을 남깁니다. Mutex 와 마찬가지로 new 와 쓰기 guard 의 set, update 는 복사본을 저장합니다.

RwLock 가드에도 같은 규칙이 적용됩니다: read / write 의 가드를 using 이 해제하지 않는 일반 let (또는 if let) 으로 묶거나 버리면 컴파일 에러입니다.

2.16.4a. Once / Condvar

Rust 와 같은 모양의 두 primitive 입니다. poisoning 은 없습니다.

struct Once { /* ... */ }
impl Once {
    const fn new() -> Once;
    fn call_once(self, f: fn());          // 처음 한 번만 f 실행, 동시 호출자는 끝날 때까지 대기
}

struct Condvar { /* ... */ }
impl Condvar {
    const fn new() -> Condvar;
    fn wait<T>(self, guard: MutexGuard<T>) -> MutexGuard<T>;   // 잠금을 풀고 대기, 깨면 다시 잠금
    fn wait_async<T>(self, guard: MutexGuard<T>) -> CondvarWait<T>;   // g = await cv.wait_async(g) - task 만 중단
    fn notify_one(self);
    fn notify_all(self);
}

wait 는 spurious wakeup 이 있을 수 있으므로 조건을 루프에서 다시 확인합니다. notify_one 은 적어도 하나의 waiter 를 깨우며, async waiter 는 둘 이상 깨울 수도 있습니다.

async 잠금과 취소

lock_async / read_async / write_async / wait_async 의 future 는 취소된 poll 에서 잠금을 잡지 않습니다 (2.18.4). 취소된 wait_async 는 다음과 같이 끝납니다.

  • mutex 가 비어 있으면 다시 잡습니다. 그래서 호출자의 using guard 가 평소처럼 풉니다.
  • 다른 task 가 잡고 있으면 잡지 않고 떠나며, 버려지는 guard 는 그 mutex 를 풀지 않습니다.

2.16.4b. Frozen<T> - 읽기 전용 공유

한 번 만들고 그 뒤로는 읽기만 하는 값 (설정, 신뢰 앵커, 표) 은 Frozen<T> 로 공유합니다. 어느 스레드든 잠금 없이 읽을 수 있습니다.

struct Frozen<T> { /* ... */ }   // T: Send 이면 Sync

impl<T> Frozen<T> {
    const fn new(value: T) -> Frozen<T>;   // 다른 것이 닿지 않는 복사본을 둡니다
    fn get(self) -> T;                       // 읽는 쪽만의 복사본
}

new 는 넘김과 같이 value 가 닿는 컬렉션의 복사본을 두므로 (2.13.9), 바깥의 어떤 것도 그것이 쥔 것에 닿지 않습니다. get 은 읽는 쪽마다 자기 복사본을 줍니다: 그것을 바꿔도 다른 읽는 쪽이 보는 것은 바뀌지 않습니다. Sync 인 구성 요소 (Mutex, 스스로 잠그는 캐시, 바뀌지 않는 Sync 타입의 값) 는 복사하지 않고 공유하므로, get 은 값의 나머지 컬렉션을 복사하는 만큼만 듭니다. 큰 읽기 전용 부분은 그 복사에서 빼 둡니다: Sync 타입으로 두거나, 필요한 곳에서 읽는 자기만의 Frozen 필드에 둡니다.

struct Settings {
    name: str;
    limits: List<i64>;               // get 마다 복사: 작게
    table: Frozen<List<Entry>>;      // 공유: 필요한 곳에서 table.get()
}

static SETTINGS: Frozen<Settings> = Frozen::new(load());

표준 라이브러리의 TLS Certificate 가 그런 타입입니다: 파싱한 뒤로 바뀌지 않고 접근자가 복사본을 주므로, Frozen<ClientConfig> 의 get 은 신뢰 앵커를 공유합니다.

2.16.5. Cell<T> - single-threaded interior

마지막으로 Cell<T> 는 단일 task 안에서만 동작하는 interior mutability 를 제공합니다.

struct Cell<T> { /* ... */ }

impl<T> Cell<T> {
    fn new(value: T) -> Cell<T>;
    fn get(self) -> T            where T: Copy;
    fn set(ref self, value: T);
    fn replace(ref self, value: T) -> T;
    fn take(ref self) -> T       where T: Default;
}

Cell<T> 는 thread-local interior mutability 를 의미하므로 multi-thread 에서는 안전하지 않습니다. 단일 task 안에서만 공유할 수 있고, 잠금이 없으므로 매우 빠르다는 특징이 있습니다.

let c: Cell<i32> = Cell::new(0);
c.set(42);
let v = c.get();          //→ 42

// task 간 공유 - 컴파일 에러 (Cell 은 Send / Sync 가 정의되지 않음)
task::spawn(async { c.set(100) });       // ERROR - Cell 은 cross-task 안전 X

Send / Sync 마커

Send / Sync 는 2.13.9 의 자동 marker 트레잇입니다. 이 장의 타입은 다음 정책을 따릅니다.

  • Atomic<T>: Send + Sync
  • Mutex<T>: T: Send 이면 Send + Sync
  • RwLock<T>: T: Send 이면 Send, T: Send + Sync 이면 Sync - 여러 reader 가 값을 동시에 공유하므로, 컬렉션 (!Sync) 의 RwLock 은 스레드 사이에서 공유할 수 없습니다. Mutex<T> 를 씁니다.
  • MutexGuard<T>, RwLockReadGuard<T>, RwLockWriteGuard<T>: Send, !Sync - guard 는 자기 using 블록 안에서만 씁니다
  • Frozen<T>: T: Send 이면 Sync (2.16.4b)
  • Cell<T>: 없음 (single-task only) - 위 task::spawn 이 에러인 이유는 async 블록이 Cell 을 캡처해 Send 가 아니기 때문입니다

2.16.6. 모듈 매핑

각 primitive 의 구현은 다음과 같이 매핑됩니다.

타입모듈내부 구조
Atomic<T>rok::core::sync::atomicLLVM atomic ops (rok.atomic.*)
Mutex<T>rok::core::sync::mutexfutex / wait queue (target 별)
RwLock<T>rok::core::sync::rwlockfutex + reader counter
Cell<T>rok::core::sync::cell단순 wrapper, no lock
MutexGuardrok::core::sync::mutexRAII via Drop

GitHub에서 이 페이지 고치기