안동민 개발노트

본문 시작

CAS 스핀 락

검사와 변경을 분리한 가짜 락의 경쟁 가능성을 분석하고, CAS 소유권·finally 해제와 스핀 대기의 한계를 코드 및 실행 결과로 구분합니다.

불리언 필드를 보고 비어 있으면 true로 바꾸는 코드는 검사와 변경 사이에 경쟁 창이 있습니다.

CAS는 두 동작을 원자적으로 묶어 한 스레드만 false에서 true 전이에 성공하게 합니다.

실패한 스레드는 잠들지 않고 반복하므로 이것을 스핀 락이라고 부릅니다.

스핀은 대기가 매우 짧을 때 문맥 전환 비용을 피할 수 있지만 기다리는 동안 CPU를 계속 사용합니다.

네트워크나 파일 I/O를 임계 영역 안에 넣으면 락을 못 얻은 코어가 유용한 일을 하지 못합니다.

학습용 구현을 범용 Lock처럼 배포해서는 안 됩니다.


volatile 검사와 쓰기가 겹치는 가짜 락

bad/CheckThenSetLock.java
public final class CheckThenSetLock {
    private volatile boolean locked;
    private int entrants;

    void lock() {
        while (locked) {
            Thread.onSpinWait();
        }
        locked = true;
    }
    void unlock() { locked = false; }

    public static void main(String[] args) throws InterruptedException {
        CheckThenSetLock lock = new CheckThenSetLock();
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                lock.lock();
                lock.entrants++;
                lock.unlock();
            }
        };
        Thread a = Thread.ofPlatform().start(task);
        Thread b = Thread.ofPlatform().start(task);
        a.join(); b.join();
        System.out.println("expected=200000 actual=" + lock.entrants);
    }
}
두 호출자가 while을 지난 뒤 각각 true를 쓰는 가정

CheckThenSetLock의 분리된locked읽기와쓰기사이에두호출자가통과할수있는 가능한 순서입니다. 실제진입수측정이아닙니다.

두 호출자가 while을 지난 뒤 각각 true를 쓰는 가정
가정한 순서원문의 동작직후 locked와 진입 가능성
T1의 while 검사locked가 false라 반복을 벗어남false · 아직 true를 쓰지 않음
T2의 while 검사T1의 쓰기 전에 false를 읽고 벗어남false · 두 호출자가 모두 검사를 통과
T1의 쓰기locked = true 뒤 lock() 반환true · T1이 entrants를 증가시킬 수 있음
T2의 쓰기자신도 locked = true 뒤 lock() 반환true · T1이 해제하기 전에 함께 진입 가능
T1의 while 검사
원문의 동작: locked가 false라 반복을 벗어남
직후 locked와 진입 가능성: false · 아직 true를 쓰지 않음
T2의 while 검사
원문의 동작: T1의 쓰기 전에 false를 읽고 벗어남
직후 locked와 진입 가능성: false · 두 호출자가 모두 검사를 통과
T1의 쓰기
원문의 동작: locked = true 뒤 lock() 반환
직후 locked와 진입 가능성: true · T1이 entrants를 증가시킬 수 있음
T2의 쓰기
원문의 동작: 자신도 locked = true 뒤 lock() 반환
직후 locked와 진입 가능성: true · T1이 해제하기 전에 함께 진입 가능

이번 실행은 expected=200000, actual=195755를 출력했습니다. 표의 두 읽기 뒤 두 쓰기는 가능한 끼어듦의 예이며 실측 진입 순서가 아닙니다. 다른 실행에서 총량이 기대와 같아도 상호 배제를 증명하지는 않습니다.

volatile 읽기·쓰기는 동기화되지만, while의 검사와 뒤의 쓰기가 하나의 연산이 되지는 않습니다.


스핀 락이 지켜야 하는 소유권 규칙

  • 획득은 compareAndSet false true가 성공한 호출자 한 명에게만 주어진다.
  • 실패한 호출자는 공유 상태를 바꾸지 않고 다음 시도를 준비한다.
  • 임계 영역은 예외가 나도 finally에서 해제되어야 한다.
  • 소유자가 아닌 스레드의 unlock을 막으려면 소유자 식별을 별도로 보관한다.
  • 대기가 길어질 가능성이 있으면 park 기반 Lock으로 전환한다.
  • 아래 구현은 같은 소유자의 재진입을 IllegalStateException으로 거부한다. 이런 검사가 없는 비재진입 스핀 락은 자기 자신을 계속 기다릴 수 있다.

소유자 검사를 포함한 학습용 스핀 락

src/OwnedSpinLock.java
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

public final class OwnedSpinLock {
    private final AtomicBoolean locked = new AtomicBoolean();
    private final AtomicReference<Thread> owner = new AtomicReference<>();

    void lock() {
        Thread current = Thread.currentThread();
        if (owner.get() == current) throw new IllegalStateException("not reentrant");
        while (!locked.compareAndSet(false, true)) {
            Thread.onSpinWait();
        }
        owner.set(current);
    }

    void unlock() {
        Thread current = Thread.currentThread();
        if (!owner.compareAndSet(current, null)) {
            throw new IllegalMonitorStateException("not owner");
        }
        locked.set(false);
    }

    public static void main(String[] args) throws InterruptedException {
        OwnedSpinLock lock = new OwnedSpinLock();
        int[] counter = {0};
        Runnable task = () -> {
            for (int i = 0; i < 50_000; i++) {
                lock.lock();
                try { counter[0]++; }
                finally { lock.unlock(); }
            }
        };
        Thread a = Thread.ofPlatform().start(task);
        Thread b = Thread.ofPlatform().start(task);
        a.join(); b.join();
        System.out.println("counter=" + counter[0]);
    }
}
소유자 경로의 locked와 owner를 순서대로 읽는다

OwnedSpinLock의획득CAS, owner설정, owner제거, locked해제를서로다른코드지점으로분리합니다.

소유자 경로의 locked와 owner를 순서대로 읽는다
성공한 소유자의 코드 지점lockedowner
false→true CAS 직후truenull · 다음 문장에서 소유자 기록
owner.set(current) 뒤 임계 영역true현재 소유 스레드
finally의 unlock에서 owner 제거 뒤true · 아직 다음 획득 불가null
locked.set(false)로 해제false · 다음 CAS가 획득 가능null
false→true CAS 직후
locked: true
owner: null · 다음 문장에서 소유자 기록
owner.set(current) 뒤 임계 영역
locked: true
owner: 현재 소유 스레드
finally의 unlock에서 owner 제거 뒤
locked: true · 아직 다음 획득 불가
owner: null
locked.set(false)로 해제
locked: false · 다음 CAS가 획득 가능
owner: null

CAS에 실패한 다른 호출자는 이 소유자 경로에 진입하지 않고 반복합니다. 두 필드의 표기는 코드 지점 설명이며, 다른 스레드가 한 번에 읽을 수 있는 원자 스냅샷은 아닙니다.

이번 최종 출력은 counter=100000입니다.

이 구현은 재진입을 IllegalStateException으로 거부하고, 비소유자의 unlock을 IllegalMonitorStateException으로 거부합니다. main은 이 예외 분기를 실행하지 않습니다. 획득 반복에 시간 제한이나 인터럽트 처리는 없으며 공정성·Condition도 제공하지 않습니다.


잠긴 gate에서 실패 횟수를 세는 탐침

src/SpinBudgetProbe.java
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.LongAdder;

public final class SpinBudgetProbe {
    private final AtomicBoolean gate = new AtomicBoolean();
    private final LongAdder failedAttempts = new LongAdder();

    boolean tryLockUntil(Duration budget) {
        long deadline = System.nanoTime() + budget.toNanos();
        do {
            if (gate.compareAndSet(false, true)) return true;
            failedAttempts.increment();
            Thread.onSpinWait();
        } while (System.nanoTime() < deadline);
        return false;
    }

    void unlock() { gate.set(false); }

    public static void main(String[] args) throws InterruptedException {
        SpinBudgetProbe probe = new SpinBudgetProbe();
        probe.gate.set(true);
        Thread contender = Thread.ofPlatform().start(() ->
                System.out.println("acquired=" + probe.tryLockUntil(Duration.ofMillis(2))));
        contender.join();
        probe.unlock();
        System.out.println("failedAttempts=" + probe.failedAttempts.sum());
    }
}
join이 끝날 때까지 gate를 열지 않는 탐침

SpinBudgetProbe main의gate=true설정, contender종료회수, 해제순서로획득실패의이유를설명합니다.

join이 끝날 때까지 gate를 열지 않는 탐침
원문의 순서gate 상태경쟁자의 결과 또는 main의 다음 동작
main이 시작 전에 설정true경쟁자는 잠긴 gate로 시작
경쟁자의 do-while계속 trueCAS는 실패하고 failedAttempts가 증가
main의 contender.join() 반환 뒤그제야 false로 해제경쟁자의 false 반환이 이미 결정된 뒤
main이 시작 전에 설정
gate 상태: true
경쟁자의 결과 또는 main의 다음 동작: 경쟁자는 잠긴 gate로 시작
경쟁자의 do-while
gate 상태: 계속 true
경쟁자의 결과 또는 main의 다음 동작: CAS는 실패하고 failedAttempts가 증가
main의 contender.join() 반환 뒤
gate 상태: 그제야 false로 해제
경쟁자의 결과 또는 main의 다음 동작: 경쟁자의 false 반환이 이미 결정된 뒤

이번 출력은 acquired=false, failedAttempts=1863입니다. do-while은 예산 검사 전에 적어도 한 번 시도합니다. 실패 횟수는 2ms의 실제 경과 시간이나 CPU 사용량을 뜻하지 않습니다.

2ms가 전체 반환 시간의 상한은 아니며, nanoTime 절대값 비교의 순환 경계까지 처리한 일반 시간 제한 구현도 아닙니다.

실제 잠금에는 ReentrantLock.tryLock을 사용합니다.


대기 길이와 공정성으로 스핀 판단

임계 영역스핀 적합성권장
매우 짧은 계산부하와 경쟁을 측정한 뒤 검토검증된 원자 구조
대기가 길어지는 연산반복 시도 비용 고려ReentrantLock
외부 I/O 포함부적합락 밖 처리
공정성·취소 필요직접 구현 부족표준 Lock

연습 문제

여러 스레드가 동시에 초기화를 요청해도 실제 초기화 본문은 한 번만 실행되어야 합니다.

실패한 호출자는 스핀하지 않고 false를 반환하게 만드세요.

정답과 해설
exercise/InitializeOnceSolution.java
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

public final class InitializeOnceSolution {
    private final AtomicBoolean claimed = new AtomicBoolean();
    private final AtomicInteger runs = new AtomicInteger();

    boolean initialize() {
        if (!claimed.compareAndSet(false, true)) return false;
        runs.incrementAndGet();
        return true;
    }

    public static void main(String[] args) throws InterruptedException {
        InitializeOnceSolution service = new InitializeOnceSolution();
        Thread[] threads = new Thread[20];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = Thread.ofPlatform().start(service::initialize);
        }
        for (Thread thread : threads) {
            thread.join();
        }
        System.out.println("runs=" + service.runs.get());
    }
}

이번 출력은 runs=1입니다. false는 다른 호출자가 초기화 권한을 선점했다는 뜻이며, 그 호출자의 초기화가 끝났다는 신호는 아닙니다. 이 main은 모든 join 뒤에 runs만 출력합니다.

초기화가 실패했을 때 재시도를 허용하려면 상태를 CLAIMED, READY, FAILED로 확장하고 실패 공개 정책을 설계해야 합니다.


스핀 락 실험의 종료 기준

CAS 스핀 락은 원리를 보여 주지만 범용 잠금의 대체품은 아닙니다.

기다림이 짧다는 근거가 없거나 취소와 공정성이 필요하면 표준 Lock을 선택하세요.

직접 CAS를 쓸 때는 실패 경로와 소유권 해제를 먼저 확인합니다.