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

안동민 개발노트

본문 시작
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를 쓸 때는 실패 경로와 소유권 해제를 먼저 확인합니다.