CAS 스핀 락
검사와 변경을 분리한 가짜 락을 깨뜨리고, CAS 소유권·`finally` 해제·짧은 임계 영역이라는 스핀 락의 제한을 실행으로 확인합니다.
불리언 필드를 보고 비어 있으면 true로 바꾸는 코드는 검사와 변경 사이에 경쟁 창이 있습니다.
CAS는 두 동작을 원자적으로 묶어 한 스레드만 false에서 true 전이에 성공하게 합니다.
실패한 스레드는 잠들지 않고 반복하므로 이것을 스핀 락이라고 부릅니다.
스핀은 대기가 매우 짧을 때 문맥 전환 비용을 피할 수 있지만 기다리는 동안 CPU를 계속 사용합니다. 네트워크나 파일 I/O를 임계 영역 안에 넣으면 락을 못 얻은 코어가 유용한 일을 하지 못합니다. 학습용 구현을 범용 Lock처럼 배포해서는 안 됩니다.
volatile 검사와 쓰기가 겹치는 가짜 락
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
falsetrue가 성공한 호출자 한 명에게만 주어진다. - 실패한 호출자는 공유 상태를 바꾸지 않고 다음 시도를 준비한다.
- 임계 영역은 예외가 나도
finally에서 해제되어야 한다. - 소유자가 아닌 스레드의
unlock을 막으려면 소유자 식별을 별도로 보관한다. - 대기가 길어질 가능성이 있으면
park기반 Lock으로 전환한다. - 재진입을 지원하지 않는 락에서 같은 스레드가 두 번 획득하면 자기 자신을 영원히 기다린다.
소유자 검사를 포함한 학습용 스핀 락
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을 제공하지 않습니다.
짧은 임계 영역만 허용하는 타이밍 탐침
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를 반환하게 만드세요.
정답과 해설
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를 쓸 때는 실패 경로와 소유권 해제를 먼저 확인합니다.