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

안동민 개발노트

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

락과 CAS 선택 기준

긴 작업에 CAS를 적용했을 때의 재시도 낭비를 계측하고, 복합 상태 전이는 잠금으로 묶는 선택 기준을 실제 예약 예제로 정리합니다.

락은 경쟁에서 진 스레드를 대기시키고, CAS는 실패한 호출자가 계산을 다시 시도하게 합니다. 충돌이 드물고 계산이 짧으면 CAS가 유리할 수 있습니다. 반대로 여러 스레드가 같은 값을 자주 갱신하거나 계산에 시간이 걸리면 반복 실패가 CPU와 계산량을 낭비합니다.

선택은 “락 프리라서 빠르다” 같은 이름이 아니라 충돌률, 임계 영역 길이, 대기 중 CPU 사용, 복합 불변식으로 판단합니다. 데이터베이스 호출처럼 긴 작업은 CAS 루프 안에 넣을 대상이 아닙니다.

CAS 재시도에 따른 고비용 계산 반복

bad/ExpensiveCasUpdate.java
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는 8,000이지만 calculations는 그보다 클 수 있습니다. 갱신 함수의 부수 효과도 충돌마다 중복되므로 계측 자체를 함수 안에 넣는 것은 설명용일 뿐 제품 코드에서는 피합니다.

선택을 지배하는 요소

  • 충돌 가능성이 낮으면 CAS 실패 재시도 비용도 작다.
  • 임계 계산이 길면 한 번의 CAS 실패가 버리는 작업량이 커진다.
  • 잠금은 대기 스레드를 park할 수 있어 긴 기다림의 CPU 소모를 줄인다.
  • 여러 컬렉션과 필드를 동시에 바꾸는 불변식은 공통 임계 영역이 읽기 쉽다.
  • CAS 갱신 함수는 순수해야 하며 외부 I/O를 포함하지 않는다.
  • 성능 결론은 처리량과 지연 백분위, 재시도 수를 같은 부하에서 측정한다.

좌석·금액의 원자적 예약

src/LockedSeatInventory.java
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());
    }
}

좌석 제거와 매출 증가가 같은 임계 영역에 있어 한 건만 성공하고 매출은 120입니다. 두 필드를 각각 원자 변수로 바꾸면 중간 상태가 노출되거나 한쪽만 성공하는 보상 문제가 생깁니다.

불변 객체 CAS로 작은 복합 상태 바꾸기

src/CasSeatCounter.java
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());
    }
}

남은 값은 0이고 revenue는 성공한 두 가격의 합입니다. 상태가 작고 계산이 짧아 불변 객체 교체가 가능하지만 좌석 ID 집합 전체를 매번 복사한다면 락 쪽이 단순할 수 있습니다.

복합 상태 크기로 락·CAS 선택

질문CAS 쪽락 쪽
충돌률낮음높음
계산 길이매우 짧음길거나 I/O 포함
상태 크기작은 불변 값큰 가변 구조
대기 정책짧은 재시도park·조건 필요

연습 문제

입금만 지원하는 잔액에 CAS 실패와 성공을 세고, 실패율이 20퍼센트를 넘는지 출력하세요. 계산 함수에는 지표 변경을 넣지 말고 명시적 루프에서 실패만 기록합니다.

정답과 해설
exercise/RetryAwareBalanceSolution.java
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);
    }
}

잔액은 80,000이고 재시도율은 환경마다 달라집니다. 이 값이 지속적으로 높다면 동일 부하에서 잠금 구현과 지연 분포를 비교합니다.

예약 상태 전이의 검증 결론

CAS는 경쟁을 제거하지 않고 충돌을 감지해 다시 계산합니다. 버리는 계산량과 CPU 대기를 측정하고, 상태가 크거나 임계 작업이 길면 잠금으로 직렬화하세요. 선택의 근거는 이름이 아니라 부하 특성입니다.