락과 CAS 선택 기준
CAS 갱신 함수 호출 수와 재시도율을 관찰하고, 예약 상태의 상호 배제와 예외 복구를 구분해 락과 CAS 선택 기준을 정리합니다.
락은 공유 상태 접근을 직렬화하고, CAS 기반 갱신은 충돌 시 다시 시도할 수 있습니다.
충돌이 드물고 계산이 짧으면 CAS가 유리할 수 있습니다.
반대로 여러 스레드가 같은 값을 자주 갱신하거나 계산에 시간이 걸리면 반복 실패가 CPU와 계산량을 낭비합니다.
선택은 “락 프리라서 빠르다” 같은 이름이 아니라 충돌률, 임계 영역 길이, 대기 중 CPU 사용, 복합 불변식으로 판단합니다.
데이터베이스 호출처럼 긴 작업은 CAS 루프 안에 넣을 대상이 아닙니다.
CAS 갱신 함수의 호출 횟수
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
public final class ExpensiveCasUpdate {
private final AtomicInteger value = new AtomicInteger();
private final LongAdder calculations = new LongAdder();
void increment() {
value.updateAndGet(current -> {
calculations.increment();
long checksum = 0;
for (int i = 0; i < 10_000; i++) {
checksum += i;
}
if (checksum < 0) throw new AssertionError();
return current + 1;
});
}
public static void main(String[] args) throws InterruptedException {
ExpensiveCasUpdate demo = new ExpensiveCasUpdate();
Thread[] workers = new Thread[8];
for (int i = 0; i < workers.length; i++) {
workers[i] = Thread.ofPlatform().start(() -> {
for (int n = 0; n < 1_000; n++) {
demo.increment();
}
});
}
for (Thread worker : workers) {
worker.join();
}
System.out.println("updates=" + demo.value.get());
System.out.println("calculations=" + demo.calculations.sum());
}
}이번 실행은 updates=8000, calculations=49493을 출력했습니다. calculations는 갱신 함수 호출 수이며 CAS 실패 횟수를 직접 센 값은 아닙니다.
갱신 함수는 재호출될 수 있으므로 함수 안의 지표 변경은 설명용이며 제품 코드에서는 피합니다.
상수 checksum 루프는 최적화될 수 있고, 이 코드는 경과 시간·CPU 사용량·잠금 구현의 성능을 측정하지 않습니다.
측정할 항목과 외부 작업
- CAS 갱신 함수는 순수해야 하며 외부 I/O를 포함하지 않는다.
- 성능 결론은 처리량과 지연 백분위, 재시도 수를 같은 부하에서 측정한다.
잠금으로 묶은 좌석 제거와 매출 증가
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;
public final class LockedSeatInventory {
record Receipt(String seat, int charged) {}
private final ReentrantLock lock = new ReentrantLock();
private final Set<String> available = new HashSet<>(Set.of("A1", "A2"));
private int revenue;
Receipt reserve(String seat, int price) {
if (price <= 0) throw new IllegalArgumentException("price");
lock.lock();
try {
if (!available.remove(seat)) throw new IllegalStateException("unavailable: " + seat);
revenue = Math.addExact(revenue, price);
return new Receipt(seat, price);
} finally {
lock.unlock();
}
}
int revenue() {
lock.lock();
try { return revenue; }
finally { lock.unlock(); }
}
public static void main(String[] args) throws InterruptedException {
LockedSeatInventory inventory = new LockedSeatInventory();
Runnable task = () -> {
try { System.out.println(inventory.reserve("A1", 120)); }
catch (IllegalStateException e) { System.out.println("rejected"); }
};
Thread a = Thread.ofPlatform().start(task);
Thread b = Thread.ofPlatform().start(task);
a.join(); b.join();
System.out.println("revenue=" + inventory.revenue());
}
}LockedSeatInventory.reserve의 가격 검사, 좌석 제거, 합계 계산 순서에 따른 정상 반환과 세 예외 경로를 비교합니다.
| 분기 | available·revenue 변경 | 종료 방식 |
|---|---|---|
| 가격 거절: price ≤ 0 | remove 전 · 모두 유지 | IllegalArgumentException · 잠금 획득 전 |
| 좌석 없음 | remove가 false · 모두 유지 | IllegalStateException · finally에서 unlock |
| 정상 예약 | 좌석 제거 후 revenue + price 대입 | Receipt 반환 · finally에서 unlock |
| 합계 오버플로 | 좌석은 제거됨 · revenue는 이전 값 | ArithmeticException · finally에서 unlock |
- 가격 거절: price ≤ 0
- available·revenue 변경: remove 전 · 모두 유지종료 방식:
IllegalArgumentException· 잠금 획득 전 - 좌석 없음
- available·revenue 변경: remove가 false · 모두 유지종료 방식:
IllegalStateException· finally에서 unlock - 정상 예약
- available·revenue 변경: 좌석 제거 후
revenue + price대입종료 방식:Receipt반환 · finally에서 unlock - 합계 오버플로
- available·revenue 변경: 좌석은 제거됨 · revenue는 이전 값종료 방식:
ArithmeticException· finally에서 unlock
이번 실행은 rejected 뒤 Receipt[seat=A1, charged=120], revenue=120을 출력했습니다. 첫 두 줄의 출력 순서는 고정하지 않습니다. 가격 거절과 오버플로는 이 main에서 실행하지 않은 코드 경로입니다.
available과 revenue의 변경을 서로 다른 원자 연산으로 나누면 두 변경 사이의 상태가 노출될 수 있습니다. 공통 잠금의 상호 배제와 예외 발생 시 복구 정책은 별도로 설계해야 합니다.
불변 객체 CAS로 작은 복합 상태 바꾸기
import java.util.concurrent.atomic.AtomicReference;
public final class CasSeatCounter {
record State(int remaining, int revenue) {}
private final AtomicReference<State> state = new AtomicReference<>(new State(2, 0));
boolean reserve(int price) {
while (true) {
State observed = state.get();
if (observed.remaining() == 0) return false;
State candidate = new State(observed.remaining() - 1,
Math.addExact(observed.revenue(), price));
if (state.compareAndSet(observed, candidate)) return true;
}
}
public static void main(String[] args) throws InterruptedException {
CasSeatCounter counter = new CasSeatCounter();
Thread a = Thread.ofPlatform().start(() -> counter.reserve(70));
Thread b = Thread.ofPlatform().start(() -> counter.reserve(80));
Thread c = Thread.ofPlatform().start(() -> counter.reserve(90));
a.join();
b.join();
c.join();
System.out.println(counter.state.get());
}
}이 main은 가격 70·80·90 중 두 건을 수용하므로 remaining은 0이고 revenue는 150·160·170 중 하나입니다. 이번 출력은 State[remaining=0, revenue=150]이었습니다. 어떤 두 호출이 성공했는지와 각 reserve 반환값은 따로 출력하지 않습니다.
State는 작은 불변 값이므로 새 객체를 만든 뒤 참조 하나를 CAS로 교체합니다. AtomicReference가 내부 가변 객체의 임의 변경까지 보호하는 것은 아닙니다. 좌석 ID 집합 전체를 매번 복사해야 한다면 락 쪽이 단순할 수 있습니다.
이 reserve에는 잠금 예제와 달리 price가 양수인지 검사하는 코드가 없습니다. Math.addExact가 실패하면 candidate 구성 중에 예외가 발생하므로 공유 state 참조는 바뀌지 않습니다. 이 main의 세 양수 가격은 그 예외를 실행하지 않습니다.
복합 상태 크기로 락·CAS 선택
| 질문 | CAS에서 확인할 것 | 락에서 확인할 것 |
|---|---|---|
| 충돌률 | 낮을수록 실패 재시도 부담이 작음 | 경쟁이 높으면 획득·대기 관리 비용 측정 |
| 계산 길이 | 실패로 버릴 계산량이 작고 매우 짧은가 | 필요한 상태 변경을 짧은 임계 영역으로 묶을 수 있는가 |
| 상태 크기 | 작은 불변 값을 한 참조로 교체 | 큰 가변 구조의 공통 임계 영역 |
| 대기 정책 | 재시도 동안 CPU 사용 | park·조건 대기 활용 |
| 외부 작업 | 갱신 함수에서 I/O·부수 효과 제외 | I/O는 가능한 한 분리하고 상태 재검사·실패 정책 설계 |
연습 문제
입금만 지원하는 잔액에 CAS 실패와 성공을 세고, 실패율이 20퍼센트를 넘는지 출력하세요.
계산 함수에는 지표 변경을 넣지 말고 명시적 루프에서 실패만 기록합니다.
정답과 해설
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public final class RetryAwareBalanceSolution {
private final AtomicLong balance = new AtomicLong();
private final LongAdder successes = new LongAdder();
private final LongAdder failures = new LongAdder();
void deposit(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount");
while (true) {
long observed = balance.get();
if (balance.compareAndSet(observed, Math.addExact(observed, amount))) {
successes.increment();
return;
}
failures.increment();
}
}
public static void main(String[] args) throws InterruptedException {
RetryAwareBalanceSolution account = new RetryAwareBalanceSolution();
Thread[] workers = new Thread[4];
for (int i = 0; i < workers.length; i++) {
workers[i] = Thread.ofPlatform().start(() -> {
for (int n = 0; n < 20_000; n++) {
account.deposit(1);
}
});
}
for (Thread worker : workers) {
worker.join();
}
long success = account.successes.sum();
long failure = account.failures.sum();
double rate = failure / (double) (success + failure);
System.out.printf("balance=%d retryRate=%.3f warn=%s%n",
account.balance.get(), rate, rate > 0.20);
}
}이번 출력은 balance=80000 retryRate=0.233 warn=true였습니다. 재시도율은 실패 횟수 / (성공 횟수 + 실패 횟수)이며 실행마다 달라집니다. 세 지표는 모든 worker를 join한 뒤 읽습니다.
retryRate는 소수 셋째 자리로 표시하지만 warn은 반올림 전 rate가 0.20을 넘는지로 결정합니다. 표시가 0.200이라는 이유만으로 warn 값을 정할 수는 없습니다. 그 표시 경계는 이번 실행에서 관측하지 않았습니다.
이 값이 지속적으로 높다면 동일 부하에서 잠금 구현과 지연 분포를 비교합니다.
예약 상태 전이의 검증 결론
CAS는 경쟁을 제거하지 않고 충돌을 감지해 다시 계산합니다.
버리는 계산량과 CPU 대기를 측정하고, 상태가 크거나 임계 작업이 길면 잠금으로 직렬화하세요.
선택의 근거는 이름이 아니라 부하 특성입니다.