본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
21장 : 원자 연산과 동시성 컬렉션

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);
    }
}

두 스레드가 locked=false를 함께 본 뒤 모두 true를 쓸 수 있습니다.

volatile은 관찰을 최신으로 만들지만 검사와 쓰기를 하나로 결합하지 않습니다.


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

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

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

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]);
    }
}

최종값은 100,000입니다.

owner 기록과 locked 해제 순서는 간단한 학습을 위한 것이며 JVM의 완전한 Lock 규칙, 공정성, 인터럽트, Condition을 제공하지 않습니다.


짧은 임계 영역만 허용하는 타이밍 탐침

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());
    }
}

결과는 획득됨=false이고 실패 횟수는 머신마다 다릅니다.

시간 제한을 둬도 대기 중 CPU를 소모한다는 성질은 변하지 않습니다.

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


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

임계 영역스핀 적합성권장
수십 나노초 계산검토 가능검증된 원자 구조
밀리초 연산CPU 낭비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());
    }
}

실행 횟수는 1입니다.

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


스핀 락 실험의 종료 기준

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

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

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