Skip to main content

Command Palette

Search for a command to run...

공평한 배타 제어

Updated
9 min read

공평한 배타 제어

여기서는 공평한 배타 제어에 대해 설명한다.

먼저 컨텐션(contention) 이라는 개념을 이해할 필요가 있다. 컨텐션이란 여러 스레드가 동시에 같은 락을 획득하려고 경쟁하는 상황을 말한다. 컨텐션이 높을수록 스레드들이 락을 기다리는 시간이 길어지고 성능이 저하된다.

이러한 컨텐션 상황은 시스템 아키텍처에 따라 더욱 복잡해질 수 있다. 특히 비균일 메모리 접근(Non-Uniform Memory Access, NUMA) 와 같은 환경에서는 메모리와 CPU의 위치에 따라 메모리에 대한 접근 속도가 다르다. 예를 들어, CPU가 자신과 가까운 메모리에 접근할 때는 빠르지만, 원격 노드의 메모리에 접근할 때는 상대적으로 느리다.

이런 접근 속도의 차이는 락을 획득하는 난이도에도 영향을 미친다. 락이 저장된 메모리 위치와 가까운 CPU에서 실행되는 스레드는 락을 더 빨리 획득할 수 있는 반면, 원격 노드의 스레드는 상대적으로 불리한 위치에 있게 된다. 결과적으로 특정 스레드가 락을 반복적으로 획득하는 편향이 발생할 수 있다.

이러한 문제를 해결하기 위해 공평한 락(fair lock) 이 필요하다. 컨텐션이 높은 환경에서 공평한 락은 모든 스레드가 순서대로 락을 획득할 수 있도록 보장함으로써, NUMA로 인한 불균형을 완화하고 시스템의 예측 가능성을 높이며 기아 현상을 방지하는 데 도움이 된다.

약한 공평성을 보장하는 락

약한 공평성을 보장하는 배타 제어 알고리즘은 다음과 같이 동작한다.

  1. 락을 우선적으로 획득할 수 있는 스레드가 설정됨

  2. 우선 스레드는 순서대로 바뀜

예를 들면 스레드 1이 우선 스레드로 설정되면 스레드 1은 반드시 락을 획득한다. 락이 해제되면 다음 스레드가 우선 스레드가 되도록 설정된다. 다음 스레드가 스레드 2라고 하자. 그런데 여기서 스레드 2가 락 획득을 시도하지 않으면 어떻게 될까? 나머지 스레드들이 락 획득을 경합하고 다음 스레드 3을 우선 스레드로 설정한다.

약한 공평성을 보장하는 알고리즘을 코드로 구현하면 다음과 같다.

use std::cell::UnsafeCell;
// use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering, fence};

// 스레드 최대 수를 사전에 결정
pub const NUM_LOCK: usize = 8;

//NUM_LOCK의 여분을 구하는 비트마스크
const MASK: usize = NUM_LOCK - 1;

// fair lock 구조체
pub struct FairLock<T> {
    // n번째 스레드가 락을 획득하려고 시행중인지 나타내는 벡터
    waiting: Vec<AtomicBool>,
    // 스핀락을 위한 변수
    lock: AtomicBool,
    // 락 획득을 우선해야 하는 스레드를 나타내는 변수
    turn: AtomicUsize,
    // 보호 대상 데이터
    data: UnsafeCell<T>,
}

// 락 자동 해제 및 보호 대상 데이터로의 접근을 수행하기 위한 타입
pub struct FairLockGuard<'a, T> {
    fair_lock: &'a FairLock<T>,
    idx: usize,
}

그리고 다음 코드로 초기화와 락을 수행할 수 있다.

impl<T> FairLock<T> {
    pub fn new(v: T) -> Self {
        let mut vec = Vec::new();
        for _ in 0..NUM_LOCK {
            vec.push(AtomicBool::new(false));
        }
        FairLock {
            waiting: vec,
            lock: AtomicBool::new(false),
            turn: AtomicUsize::new(0),
            data: UnsafeCell::new(v),
        }
    }

    // 락 함수
    pub fn lock(&self, idx: usize) -> FairLockGuard<T> {
        // idx가 최대 수 미만인지
        assert!(idx < NUM_LOCK);

        // 자신의 스레드를 락 획득 시행 중으로 설정
        self.waiting[idx].store(true, Ordering::Relaxed);
        loop {
            // 다른 스레드가 false를 설정한 경우면 락 획득
            if !self.waiting[idx].load(Ordering::Relaxed) {
                break;
            }
            // 공유 변수를 이용해 락 획득 테스트
            if !self.lock.load(Ordering::Relaxed) {
                if let Ok(_) = self.lock.compare_exchange_weak(
                    false,
                    true,
                    Ordering::Relaxed,
                    Ordering::Relaxed,
                ) {
                    break; // 락 획득 성공
                }
            }
        }
        fence(Ordering::Acquire);

        FairLockGuard {
            fair_lock: self,
            idx,
        }
    }

다음은 언락을 구현한 코드이다.

impl<'a, T> Drop for FairLockGuard<'a, T> {
    fn drop(&mut self) {
        let fl = self.fair_lock; // fair_lock 참조 획득
        // 자신의 스레드를 락 획득 시도 중이 아닌 상태로 설정
        fl.waiting[self.idx].store(false, Ordering::Relaxed);

        // 현재 우선 스레드가 자신이면 다음 스레드로 넘김
        let turn = fl.turn.load(Ordering::Relaxed);
        let next_turn = if turn == self.idx {
            (turn + 1) & MASK // 다음 스레드로 넘김
        } else {
            turn // 현재 스레드가 아니면 그대로 유지
        };

        if fl.waiting[next].load(Ordering::Relaxed) {
            // 다음 우선 스레드가 락 획득 중이면 전달
            fl.turn.store(next_turn, Ordering::Relaxed);
            fl.waiting[next].store(true, Ordering::Release);
        } else {
            // 다음 우선 스레드가 락 획득 중이 아니면
            // 그 다음 스레드를 우선 스레드로 설정하고 락 해제
            fl.turn.store((next + 1) & MASK, Ordering::Relaxed);
            fl.lock.store(false, Ordering::Release);
        }
    }
}

까지가 락 획득 및 해제 알고리즘이다.

다음은 FairLock과 FairLockGaurd 타입에서 구현해야 할 트레이트다.

// FairLock 타입은 스레드 사이에서 공유 가능하도록 설정
unsafe impl<T> Sync for FairLock<T> {}
unsafe impl<T> Send for FairLock<T> {}

// 보호 대상 데이터의 이뮤터블 참조 제외
impl<'a, T> Deref for FairLockGuard<'a, T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        unsafe { &*self.fair_lock.data.get() }
    }
}

// 보호 대상 데이터의 뮤터블 참조 제외
impl<'a, T> DerefMut for FairLockGuard<'a, T> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        unsafe { &mut *self.fair_lock.data.get() }
    }
}

Sync를 통해 스레드 사이에서 데이터를 공유할 수 있게 되었고, Send를 통해 스레드에서 소유권을 송수신할 수 있게 되었다.

공평한 락을 이용하는 예는 다음과 같다.

const NUM_LOOP: usize = 1000;
const NUM_THREADS: usize = 4;

fn main() {
    let lock = Arc::new(FairLock::new(0));
    let mut v = Vec::new();

    for i in 0..NUM_THREADS {
        let lock0 = lock.clone();
        let t = std::thread::spawn(move || {
            for _ in 0..NUM_LOOP {
                // * -> _, NUM*LOOP -> NUM_LOOP
                // 스레드 번호를 전달
                let mut data = lock0.lock(i);
                *data += 1;
            }
        });
        v.push(t);
    }

    for t in v {
        t.join().unwrap();
    }

    println!(
        "Count = {} (expected = {})",
        *lock.lock(0), // FairLockGuard를 사용하여 락을 획득하고 데이터에 접근
        NUM_THREADS * NUM_LOOP
    );
}

티켓락

약한 공평성을 보장하는 락은 유한 횟수 안에서 반드시 락을 획득한다. 그런데 반드시 락을 획득하는 것은 아니어도 경합을 줄이는 알고리즘도 존재하는데 티켓락도 그 중 하나이다.

티켓락의 구현은 다음과 같다.

use std::cell::UnsafeCell;
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicUsize, Ordering, fence};

// 티켓락 타입
pub struct TicketLock<T> {
    ticket: AtomicUsize, // 티켓
    turn: AtomicUsize,   // 실행 가능한 티켓
    data: UnsafeCell<T>,
}

// 락 해제, 보호 대상 데이터 접근
pub struct TicketLockGaurd<'a, T> {
    lock: &'a TicketLock<T>,
}

impl<T> TicketLock<T> {
    pub fn new(v: T) -> Self {
        TicketLock {
            ticket: AtomicUsize::new(0),
            turn: AtomicUsize::new(0),
            data: UnsafeCell::new(v),
        }
    }
    pub fn lock(&self) -> TicketLockGaurd<T> {
        // 티켓 획득
        let t = self.ticket.fetch_add(1, Ordering::SeqCst);
        // 소유권 티켓의 순서가 될 때까지 스핀
        while self.turn.load(Ordering::Relaxed) != t {}
        fence(Ordering::Acquire);
        TicketLockGaurd { lock: self }
    }
}

// 락 획득 후 자동으로 해제된다.
impl<'a, T> Drop for TicketLockGuard<'a, T> {
    fn drop(&mut self) {
        // 다음 티켓 실행 가능하도록 설정
        self.ticket_lock.turn.fetch_add(1, Ordering::Release);
    }
}

정리하자면 티켓락은 ticket과 turn이라는 2개 공유 변수가 있는데, ticket은 다음 티켓의 번호를 기억하는 변수이고, turn은 현재 실행이 허가된 티켓 번호다.

구현된 티켓 락의 함수에서, ticket획득 시 스핀을 수행하지 않는 것을 볼 수 있다. 이것이 컨텐션을 줄이는 역할을 한다.

MCS 락

티켓락에서는 아토믹 명령으로 접근하는 변수와 스핀 도중 접근하는 변수가 같았다. 그런데 메모리에 아토믹하게 접근하면 해당 메모리의 CPU 캐시가 배타적으로 설정되므로 대기 스레드의 해당 메모리를 읽을 떄 오버헤드가 발생할 가능성이 있다.

MCS락에서는 CAS 명령으로 큐의 가장 마지막에 스레드용 노드를 추가하고 스핀으로 감시할 변수는 별도로 준비한다. 즉, CAS에서 접근하는 변수는 아토믹하게 업데이트하지만 스핀으로 접근하는 변수는 일반적인 메모리 접근 명령을 이용한다.

CAS가 MCS에서 갖는 의미

MCS락에서 CAS는 두 가지 중요한 역할을 한다.

  1. swap 연산: self.last.swap(ptr, Ordering::Relaxed)를 통해 자신을 큐의 마지막에 아토믹하게 추가한다. 이때 이전 마지막 노드의 포인터를 받아온다.

  2. compare_exchange 연산: 락을 해제할 때 self.mcs_lock.last.compare_exchange(...)를 사용한다. 이는 "현재 자신이 마지막 노드인가?"를 확인하고, 맞다면 큐를 비우는 작업을 아토믹하게 수행한다.

핵심은 이런 CAS 연산은 큐 구조를 변경할 때만 사용하고, 각 스레드가 대기할 때는 자신의 locked 변수만 감시한다는 점이다. 이렇게 해서 캐시 경합을 최소화한다.

구현하자면 다음과 같은데

mcs.rs:

use std::cell::UnsafeCell;
use std::ops::{Deref, DerefMut};
use std::ptr::null_mut;
use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering, fence};

pub struct MCSLock<T> {
    // ❶
    last: AtomicPtr<MCSNode<T>>, // 큐의 맨 마지막
    data: UnsafeCell<T>,         // 보호 대상 데이터
}

pub struct MCSNode<T> {
    // ❷
    next: AtomicPtr<MCSNode<T>>, // 다음 노드
    locked: AtomicBool,          // true이면 록 획득 중
}

pub struct MCSLockGuard<'a, T> {
    node: &'a mut MCSNode<T>, // 자신의 스레드 노드
    mcs_lock: &'a MCSLock<T>, // 큐의 가장 마지막과 보호 대상 데이터로의 참조
}

// 스레드끼리의 데이터 공유, 및 채널을 이용한 송수신 가능 설정
unsafe impl<T> Sync for MCSLock<T> {}
unsafe impl<T> Send for MCSLock<T> {}

impl<T> MCSNode<T> {
    pub fn new() -> Self {
        MCSNode {
            // MCSNodeの初期化
            next: AtomicPtr::new(null_mut()),
            locked: AtomicBool::new(false),
        }
    }
}

// 보호 대상 데이터의 이뮤터블한 참조 제외
impl<'a, T> Deref for MCSLockGuard<'a, T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        unsafe { &*self.mcs_lock.data.get() }
    }
}

// 보호 대상 데이터의 뮤터블한 참조 제외
impl<'a, T> DerefMut for MCSLockGuard<'a, T> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        unsafe { &mut *self.mcs_lock.data.get() }
    }
}

impl<T> MCSLock<T> {
    pub fn new(v: T) -> Self {
        MCSLock {
            last: AtomicPtr::new(null_mut()),
            data: UnsafeCell::new(v),
        }
    }

    pub fn lock<'a>(&'a self, node: &'a mut MCSNode<T>) -> MCSLockGuard<T> {
        // 자기 스레드용 노드를 초기화 ❶
        node.next = AtomicPtr::new(null_mut());
        node.locked = AtomicBool::new(false);

        let guard = MCSLockGuard {
            node,
            mcs_lock: self,
        };

        // 자신을 큐의 맨 마지막으로 한다 ❷
        let ptr = guard.node as *mut MCSNode<T>;
        let prev = self.last.swap(ptr, Ordering::Relaxed);

        // 맨 마지막이 null이면 나무도 록을 획득하려 하지 않는 것이므로 록을 획득
        // null이 아닌 경우에는 자신을 큐의 맨 끝에 추가
        if prev != null_mut() {
            // ❸
            // 록 획득 중이라고 설정
            guard.node.locked.store(true, Ordering::Relaxed); // ❹

            // 자신을 큐의 맨 끝에 추가 ❺
            let prev = unsafe { &*prev };
            prev.next.store(ptr, Ordering::Relaxed);

            // 다른 스레드로부터 false로 설정될 때까지 스핀 >❻
            while guard.node.locked.load(Ordering::Relaxed) {}
        }

        fence(Ordering::Acquire);
        guard
    }
}

impl<'a, T> Drop for MCSLockGuard<'a, T> {
    fn drop(&mut self) {
        // 자신의 다음 노트가 null이고 자신이 맨 끝의 노드이면 , 맨 끝을 null로 설정한다 ❶
        if self.node.next.load(Ordering::Relaxed) == null_mut() {
            let ptr = self.node as *mut MCSNode<T>;
            if let Ok(_) = self.mcs_lock.last.compare_exchange(
                // ❷
                ptr,
                null_mut(),
                Ordering::Release,
                Ordering::Relaxed,
            ) {
                return;
            }
        }

        // 자신의 다음 스레드가 lock 함수 실행 중이므로, 종료될 떄까지 대기한다 ❸
        while self.node.next.load(Ordering::Relaxed) == null_mut() {}

        // 자신의 다음 스레드를 실행 가능하게 설정 ❹
        let next = unsafe { &mut *self.node.next.load(Ordering::Relaxed) };
        next.locked.store(false, Ordering::Release);
    }
}

lock 함수의 동작 과정

  1. 노드 초기화 (❶): 각 스레드는 자신의 노드를 깨끗한 상태로 만든다.

  2. CAS로 큐에 추가 (❷): swap 연산으로 자신을 큐의 마지막에 원자적으로 추가한다. 이전 마지막 노드의 포인터를 받아온다.

  3. 대기 여부 결정 (❸): 이전 노드가 있으면 대기해야 한다.

  4. 대기 상태 설정 (❹): 자신의 locked를 true로 설정한다.

  5. 큐 연결 (❺): 이전 노드의 next가 자신을 가리키게 한다.

  6. 스핀 대기 (❻): 자신의 locked가 false가 될 때까지 대기한다. 여기서는 CAS가 아닌 일반 load를 사용한다!

drop 함수의 동작 과정

  1. 마지막 노드 확인 (❶): 다음 노드가 없으면 자신이 마지막일 가능성이 있다.

  2. CAS로 큐 비우기 (❷): compare_exchange로 "자신이 여전히 마지막이면 큐를 비운다"를 원자적으로 수행한다. 이게 실패하면 새로운 노드가 추가되는 중이란 뜻이다.

  3. 다음 노드 대기 (❸): 다음 노드가 아직 큐 연결을 완료하지 않았다면 대기한다.

  4. 다음 노드 깨우기 (❹): 다음 노드의 locked를 false로 설정해서 실행 가능하게 한다.

main.rs:

use std::sync::Arc;

const NUM_LOOP: usize = 100000;
const NUM_THREADS: usize = 4;

mod mcs;

fn main() {
    let n = Arc::new(mcs::MCSLock::new(0));
    let mut v = Vec::new();

    for _ in 0..NUM_THREADS {
        let n0 = n.clone();
        let t = std::thread::spawn(move || {
            // 노드를 작성하고 록
            let mut node = mcs::MCSNode::new();
            for _ in 0..NUM_LOOP {
                let mut r = n0.lock(&mut node);
                *r += 1;
            }
        });

        v.push(t);
    }

    for t in v {
        t.join().unwrap();
    }

    // 노드를 작성하고 록
    let mut node = mcs::MCSNode::new();
    let r = n.lock(&mut node);
    println!("COUNT = {} (expected = {})", *r, NUM_LOOP * NUM_THREADS);
}

정리

공평한 배타 제어는 컨텐션이 높은 환경에서 모든 스레드가 균등하게 락을 획득할 수 있도록 보장하는 방법이다. 특히 NUMA 같은 환경에서는 메모리 접근 속도 차이로 인한 불균형이 발생할 수 있는데, 공평한 락은 이런 문제를 완화한다.

약한 공평성을 보장하는 락은 우선 스레드를 순서대로 지정해서 공평성을 제공한다. 티켓락은 티켓 번호를 발급받고 자신의 차례를 기다리는 방식으로 동작하며, 티켓 획득 시 스핀을 수행하지 않아 컨텐션을 줄인다.

MCS락은 더 나아가 캐시 경합 문제까지 해결한다. 핵심은 CAS 연산을 큐 구조 변경에만 사용하고, 각 스레드가 대기할 때는 자신만의 변수를 감시하는 것이다. 이렇게 해서 아토믹 연산으로 인한 캐시 무효화를 최소화하고, 많은 스레드가 경쟁하는 환경에서도 좋은 성능을 유지할 수 있다.

결과적으로 이 세 가지 락은 각각 다른 수준의 공평성과 성능 특성을 제공한다. 시스템의 요구사항과 환경에 따라 적절한 락을 선택하는 것이 중요하다.

21 views

More from this blog

락프리 데이터 구조와 알고리즘

여기서는 락프리 데이터 구조를 설명한다. 락프리(lock-free) 란 배타락을 이용하지 않고 처리를 수행하는 데이터 구조 및 그에 대한 조작 알고리즘을 총칭한다. 왜 락프리인가? 전통적인 동시성 제어 방법인 뮤텍스나 세마포어는 여러 문제점을 가지고 있다: 성능 저하: 락 경합(lock contention)으로 인한 대기 시간 데드락: 여러 스레드가 서로의 락을 기다리는 상황 우선순위 역전: 낮은 우선순위 스레드가 높은 우선순위 스레드를 ...

Jul 27, 20257 min read121

소프트웨어 트랜잭셔널 메모리

소프트웨어 트랜잭셔널 메모리 동시성 프로그래밍에서 공유 자원에 대한 안전한 접근은 항상 중요한 과제다. 전통적으로 뮤텍스 락과 같은 비관적 락(Negative Lock) 방식을 사용해왔다. 이 방식은 크리티컬 섹션에 진입하기 전에 반드시 락을 획득해야 하며, 락을 얻지 못하면 코드 실행 자체가 블록된다. 하지만 이와는 다른 접근 방식이 있다. 바로 낙관적 락(Optimistic Lock) 방식인데, 이는 "일단 실행하고 나중에 검증하자"는 철학...

Jul 20, 202517 min read262

KernelSnitch[논문 리뷰]

Paper 1. Intro 이 글은 NDSS 2025에서 발표된 KernelSnitch 논문을 소개이다. 이 연구는 커널의 평범한 데이터 구조체들이 가진 본질적인 특성이 어떻게 심각한 보안 취약점이 되는지를 보여준다. 핵심은 이러하다: "데이터 구조체의 크기에 따른 접근 시간 차이를 이용해 커널의 비밀 정보를 유출할 수 있다" 여기서는 커널 힙 포인터 유출에 집중해서 설명한다. 이 공격이 성공하면 KASLR을 우회하고 더 심각한 커널 익스플로...

Jul 11, 20257 min read128

멀티태스크와 액터 모델

멀티태스크 협조적/비협조적 멀티태스크 선점: 프로세스와의 협조 없이 수행하는 컨택스트 스위칭이라고는 하나, 결국 뺏어오는 게 가능하냐의 문제다. 협조적 멀티태스크(비선점형, cooperative): 각각의 프로세스가 자발적으로 컨택스트 스위칭을 수행하는 멀티태스크 방식. 장점: 멀티태스크 매커니즘을 구현하기 쉽다. 단점: 프로세스가 자발적으로 컨텍스트 스위칭을 해야하는데, 만약 버그가 발생하여 프로세스가 무한 루프에 빠지거나 정지하게 되면 그 ...

Jul 6, 20252 min read25
M

MaxLog

35 posts