스핀락 구현

Jaemyeong Lee·2024년 12월 25일

게임 서버1

목록 보기
115/220

스핀락이란?

정의

  • 스핀락은 락이 풀릴 때까지 스레드가 잠들지 않고 반복 시도(busy-wait) 하는 락입니다.
  • 커널 스케줄링 전환 비용 없이 빠르게 재시도할 수 있어, 매우 짧은 임계 영역에서 유리할 수 있습니다.

언제 문제가 되는가

  • 락 보유 시간이 길거나 경합이 높으면 CPU를 계속 태워 오히려 성능이 떨어집니다.
  • 특히 스레드 수가 코어 수를 크게 초과하면 spin은 대기 비용이 더 커집니다.

잘못된 구현과 실패 원인

잘못된 코드

bool flag = false;

void lock() {
    while (flag) {}  // 대기
    flag = true;     // 획득 시도
}

void unlock() {
    flag = false;
}

왜 깨지는가

  • while(flag) 확인과 flag=true 설정이 분리되어 있어 원자적이지 않습니다.
  • 두 스레드가 거의 동시에 통과하면 둘 다 락을 획득한 것처럼 동작할 수 있습니다.
  • flag가 비원자 공유 변수라 데이터 레이스 자체로 UB(정의되지 않은 동작) 입니다.

올바른 구현 1 - atomic_flag

최소 구현

#include <atomic>
#include <thread>

class SpinLock {
public:
    void lock() {
        while (flag_.test_and_set(std::memory_order_acquire)) {
            std::this_thread::yield();  // 과열 방지용 힌트
        }
    }

    void unlock() {
        flag_.clear(std::memory_order_release);
    }

private:
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};

메모리 오더 핵심

  • acquire : lock 이후의 읽기/쓰기가 lock 이전으로 재배치되지 않게 보장
  • release : unlock 이전의 읽기/쓰기가 unlock 이후로 밀리지 않게 보장
  • 즉, 임계 영역 내 변경이 다른 스레드에게 올바르게 관측됩니다.

올바른 구현 2 - CAS(atomic<bool>)

CAS 기반 구현

std::atomic<bool> flag{false};

void lock() {
    bool expected = false;
    while (!flag.compare_exchange_weak(expected, true,
                                       std::memory_order_acquire,
                                       std::memory_order_relaxed)) {
        expected = false;      // 실패 시 expected가 현재 값으로 바뀌므로 초기화
        std::this_thread::yield();
    }
}

void unlock() {
    flag.store(false, std::memory_order_release);
}

weak vs strong

  • 반복 루프에서는 weak를 많이 사용합니다(실패 가능성 포함해도 재시도 구조와 잘 맞음).
  • 단발 비교에서 불필요한 재시도를 줄이고 싶다면 strong을 고려합니다.

실무 성능 팁 (Backoff)

단순 spin의 한계

  • 경합이 높은 구간에서 while만 돌면 캐시 라인 경쟁과 전력 소모가 커집니다.

점진 백오프 예시

void lock_with_backoff() {
    int spins = 0;
    while (flag_.test_and_set(std::memory_order_acquire)) {
        if (spins < 10) {
            ++spins;                    // 짧게 spin
        } else {
            std::this_thread::yield();  // 이후 양보
        }
    }
}
  • 경합이 짧을 때는 빠르게 획득하고, 길어지면 CPU 양보로 손실을 줄입니다.
  • 플랫폼별로 pause 명령(x86 _mm_pause)을 추가하면 스핀 효율이 더 좋아질 수 있습니다.

언제 쓰고 언제 피할까

상황스핀락 적합성이유
임계 영역이 매우 짧음(수십~수백 ns)높음커널 전환 없이 빠른 재시도
코어 여유가 충분함높음바쁜 대기가 덜 치명적
긴 작업/블로킹 I/O 포함낮음대기 중 CPU 낭비 심함
스레드 과구독(threads >> cores)낮음스케줄링 지연과 경합 악화
단일 코어 환경낮음holder가 실행되지 못해 대기 악순환

MMO 관점

  • 매우 짧은 락(예: 작은 상태 플래그, 짧은 큐 헤더 보호)에서는 유리할 수 있습니다.
  • 게임 로직/DB/I/O처럼 오래 걸릴 수 있는 구간에는 mutex 또는 큐 기반 분리가 더 안전합니다.

강의 시 유의사항

강조 포인트

  • 스핀락의 본질은 "빠른 락"이 아니라 "짧은 임계 영역에서만 유효한 선택지"입니다.
  • 구현 시 반드시 acquire/release 의미를 함께 설명하세요.
  • naive 구현이 왜 UB인지(비원자 공유 변수)까지 짚어야 학습 완성도가 올라갑니다.

자주 하는 오해

오해바로잡기
스핀락은 무조건 mutex보다 빠르다경합/락 보유 시간이 길면 오히려 더 느림
CAS만 쓰면 안전하다memory order와 사용 맥락까지 맞아야 안전
yield()는 필요 없다고경합에서 CPU 과열/낭비를 줄이는 데 유용

체크 질문 (스스로 답해보기)

  • 스핀락 unlock()memory_order_release가 필요한 이유는?
  • 코어 수보다 스레드가 많은 환경에서 스핀락이 왜 불리한가?
  • 당신의 코드에서 스핀락을 써도 되는 임계 영역 길이는 어느 정도인가?

profile
李家네_공부방

0개의 댓글