본문 바로가기

CAS와 재시도 루프

compareAndSet이 언제 성공하고 언제 실패하는지 확인하고, 실패하면 최신 값을 다시 읽어 재시도하는 루프를 만들며, 그 루프 안에 부수 효과를 넣으면 안 되는 이유를 배웁니다.

앞 글에서 AtomicInteger의 incrementAndGet()과 updateAndGet()이 읽기, 계산, 쓰기를 하나의 원자 연산으로 처리하는 것을 보았습니다. 그런데 락을 쓰지 않는데 어떻게 다른 스레드가 중간에 끼어들지 못하게 할 수 있을까요? 비결은 '쓰기 직전에 값이 내가 읽었을 때와 같은지 확인하고, 같을 때만 쓴다'는 연산에 있습니다. 이 연산을 비교 후 교체, 영어로 CAS(compare-and-swap 또는 compare-and-set)라고 부릅니다. CPU가 이 비교와 쓰기를 다른 코어가 끼어들 수 없는 한 동작으로 처리하는 방법은 운영체제 교재의 하드웨어 동기화와 뮤텍스에서 다룹니다.

이 글에서는 CAS를 직접 호출해 보면서, CAS가 실패했을 때 무엇을 해야 하는지 알아봅니다. 실패를 무시하면 어떤 일이 생기는지 확인한 다음, 실패할 때마다 최신 값을 다시 읽어 재시도하는 반복문을 만듭니다. 마지막으로 이 반복문 안에 넣으면 안 되는 일과 CAS가 알맞지 않은 상황을 정리합니다. 실행 결과는 앞 글과 같은 코어 2개짜리 컴퓨터에서 JDK 21로 얻었습니다.

CAS는 값이 그대로일 때만 바꾼다

AtomicInteger의 compareAndSet(expected, newValue)는 현재 값이 expected와 같으면 값을 newValue로 바꾸고 true를 돌려줍니다. 같지 않으면 아무것도 쓰지 않고 false를 돌려줍니다. 비교와 쓰기가 하나의 원자 연산이라서, 비교를 통과한 뒤 쓰기 전에 다른 스레드가 값을 바꾸는 일은 일어나지 않습니다.

src/CasBasics.java
import java.util.concurrent.atomic.AtomicInteger;

public final class CasBasics {
    public static void main(String[] args) {
        AtomicInteger value = new AtomicInteger(5);

        boolean first = value.compareAndSet(5, 6);
        System.out.println("CAS(5, 6) -> " + first + ", value=" + value.get());

        boolean second = value.compareAndSet(5, 7);
        System.out.println("CAS(5, 7) -> " + second + ", value=" + value.get());
    }
}
CAS(5, 6) -> true, value=6
CAS(5, 7) -> false, value=6

첫 번째 호출은 현재 값 5가 기대값 5와 같으므로 값을 6으로 바꾸고 true를 돌려줍니다. 두 번째 호출도 기대값으로 5를 넘겼지만 현재 값은 이미 6입니다. 그래서 값은 6 그대로 남고 false가 돌아옵니다.

여러 스레드가 있을 때 이 동작이 쓸모 있는 이유는 기대값을 '내가 아까 읽은 값'으로 쓰기 때문입니다. 값을 읽고 새 값을 계산한 다음 compareAndSet(읽은 값, 새 값)을 호출하면, 그사이에 다른 스레드가 값을 바꿨을 때는 CAS가 실패합니다. 내 계산은 이미 낡은 값을 바탕으로 했으니 쓰지 않는 것이 맞습니다. 즉 CAS의 false는 오류가 아니라 '네가 읽은 뒤에 다른 스레드가 먼저 바꿨다'는 정상적인 경쟁 결과이고, 예외도 발생하지 않습니다.

실패한 CAS를 무시하면 증가가 사라진다

CAS가 원자적이라는 말은 모든 시도가 성공한다는 뜻이 아닙니다. 다음 프로그램은 값을 읽어 1을 더한 값으로 CAS를 한 번만 시도하고, 결과는 확인하지 않습니다. 스레드 8개가 10만 번씩 증가시키므로 증가가 하나도 사라지지 않는다면 80만이 출력되어야 합니다.

bad/OneShotCasIncrement.java
import java.util.concurrent.atomic.AtomicInteger;

public final class OneShotCasIncrement {
    private final AtomicInteger value = new AtomicInteger();

    void increment() {
        int observed = value.get();
        value.compareAndSet(observed, observed + 1);
    }

    public static void main(String[] args) throws InterruptedException {
        OneShotCasIncrement counter = new OneShotCasIncrement();
        Thread[] workers = new Thread[8];
        for (int i = 0; i < workers.length; i++) {
            workers[i] = Thread.ofPlatform().start(() -> {
                for (int n = 0; n < 100_000; n++) {
                    counter.increment();
                }
            });
        }
        for (Thread worker : workers) {
            worker.join();
        }
        System.out.println("expected=800000 actual=" + counter.value.get());
    }
}

30번 실행했더니 30번 모두 80만보다 작은 값이 나왔습니다. 가장 작은 값은 715,227, 가장 큰 값은 789,117이었고, 첫 번째 실행의 출력은 다음과 같습니다. 사라지는 양은 실행마다 다르며, 스레드들이 거의 부딪히지 않은 실행에서는 80만에 아주 가까운 값이나 80만이 나올 수도 있습니다.

expected=800000 actual=781373

두 스레드가 같은 observed를 읽으면 둘 다 같은 기대값으로 CAS를 시도합니다. 먼저 시도한 쪽은 성공하고, 나중에 시도한 쪽은 값이 이미 바뀌었으므로 실패합니다. 실패한 쪽은 결과를 확인하지 않고 메서드를 끝내므로 그 증가는 사라집니다.

앞 글의 volatile 카운터와 비교하면 사라지는 방식이 다릅니다. volatile 카운터에서는 낡은 값으로 계산한 결과가 다른 스레드의 결과를 덮어썼습니다. 여기서는 CAS가 낡은 계산을 거부하므로 덮어쓰기는 한 번도 일어나지 않습니다. 성공한 CAS는 언제나 그 순간의 값에 정확히 1을 더하므로, 출력된 값은 성공한 CAS의 개수와 같습니다. 증가가 사라진 원인은 CAS가 아니라, 실패를 알려 줬는데도 무시한 호출자에게 있습니다. CAS에서 진 호출자는 다시 시도할지, 포기할지, 실패를 호출한 쪽에 알릴지를 직접 정해야 합니다.

실패하면 다시 읽고 다시 시도하기

증가처럼 반드시 반영되어야 하는 연산이라면, CAS가 실패할 때마다 최신 값을 다시 읽어 처음부터 다시 시도합니다. 이런 반복문을 재시도 루프(retry loop)라고 하며, 모양은 언제나 비슷합니다.

  1. 현재 값을 읽어 observed에 담습니다.
  2. observed만 가지고 새 값 candidate를 계산합니다.
  3. compareAndSet(observed, candidate)를 호출합니다.
  4. 성공하면 끝냅니다. 실패하면 1번으로 돌아갑니다.

total이 0일 때 두 스레드가 이 루프로 1씩 더하면 다음과 같이 진행됩니다. 처음 두 줄은 앞 글의 volatile 카운터와 똑같이 두 스레드가 같은 0을 읽는 순서입니다.

순서T1T2total
1observed로 0을 읽음0
2observed로 0을 읽음0
3CAS(0, 1) 성공, 반환1
4CAS(0, 1) 실패1
5observed로 1을 다시 읽음1
6CAS(1, 2) 성공, 반환2

volatile 카운터에서는 4번 순서에서 T2가 낡은 0으로 계산한 1을 덮어써 결과가 1이 되었습니다. CAS는 이 쓰기를 거부하고, T2는 바뀐 값 1을 다시 읽어 2를 만듭니다. 두 번의 증가가 모두 반영되었습니다.

다음 프로그램은 이 루프로 덧셈을 하면서, CAS가 몇 번 실패했는지도 셉니다.

src/MeasuredCasAccumulator.java
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;

public final class MeasuredCasAccumulator {
    private final AtomicInteger total = new AtomicInteger();
    private final LongAdder retries = new LongAdder();

    void add(int delta) {
        if (delta < 0) throw new IllegalArgumentException("delta");
        while (true) {
            int observed = total.get();
            int candidate = Math.addExact(observed, delta);
            if (total.compareAndSet(observed, candidate)) return;
            retries.increment();
            Thread.onSpinWait();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MeasuredCasAccumulator accumulator = new MeasuredCasAccumulator();
        Thread[] workers = new Thread[4];
        for (int i = 0; i < workers.length; i++) {
            workers[i] = Thread.ofPlatform().start(() -> {
                for (int n = 0; n < 50_000; n++) {
                    accumulator.add(1);
                }
            });
        }
        for (Thread worker : workers) {
            worker.join();
        }
        System.out.println("total=" + accumulator.total.get());
        System.out.println("retries=" + accumulator.retries.sum());
    }
}

add()의 각 줄이 하는 일을 보겠습니다. Math.addExact()는 덧셈 결과가 int 범위를 넘으면 ArithmeticException을 던집니다. 이 예외는 CAS를 시도하기 전에 일어나므로 공유 값은 바뀌지 않습니다. delta가 음수이면 처음부터 거부하므로, 이 누적기가 값을 줄이는 일은 없습니다. CAS가 실패하면 retries를 1 늘리고 Thread.onSpinWait()를 호출한 뒤 다시 읽습니다. retries는 여러 스레드가 동시에 더하고 마지막에 한 번만 읽는 통계 값이므로 앞 글에서 소개한 LongAdder를 썼습니다.

CAS 반환 뒤 두 구현이 하는 일을 비교한다

CAS 실패 후 한 번 구현의 종료와 반복 구현의 다시 읽기·재시도가 다릅니다. 관측·후보 계산·CAS 결과·다음 동작을 단계로 보여 재시도 주체를 구별합니다.

CAS 반환 뒤 두 구현이 하는 일을 비교한다

OneShotCasIncrement는 실패를무시하고 MeasuredCasAccumulator는실패를센뒤새값을읽는다는 원문 차이를 비교합니다.

CAS 반환 · 구현의 재시도

CAS 반환 뒤 두 구현이 하는 일을 비교한다

CAS 실패 뒤 종료할지, 다시 읽고 시도할지는 코드의 반복문이 정합니다.

CAS 결과 뒤 종료와 재시도의 갈림각구현은후보를계산한뒤CAS를시도합니다. OneShot은실패해도종료하지만Accumulator는실패횟수를증가시키고다시get하여새후보로재시도합니다. 성공한CAS만값을반영합니다.현재 값 읽기 · 후보 계산OneShotget 1회CAS반복 구현get 매회CASfalse 무시메서드 종료falseretries++다시 읽기true증가·종료true증가·return
CAS 결과 뒤 종료와 재시도의 갈림각구현은후보를계산한뒤CAS를시도합니다. OneShot은실패해도종료하지만Accumulator는실패횟수를증가시키고다시get하여새후보로재시도합니다. 성공한CAS만값을반영합니다.현재 값 읽기 · 후보 계산OneShotget 1회CAS반복 구현get 매회CASfalse 무시메서드종료falseretries++다시 읽기true증가·종료true증가·return
설명과 원래 예시 값

OneShotCasIncrement

후보 계산
get() 한 번 뒤 observed + 1
CAS가 true
증가가 반영되고 메서드 종료
CAS가 false
반환값을 버리고 메서드 종료

MeasuredCasAccumulator

후보 계산
반복마다 get() 뒤 Math.addExact(observed, delta)
CAS가 true
증가가 반영되면 즉시 return
CAS가 false
retries 증가 · 스핀 힌트 뒤 다음 반복에서 다시 읽음

한 번의 CAS가 자동으로 재시도하는 것은 아닙니다. retries는 실패한 CAS 횟수이며, CPU 사용량이나 경과 시간을 측정한 값은 아닙니다.

이 예의 조건

  • OneShotCasIncrement와 MeasuredCasAccumulator의 한 시도를 같은 관찰 단계마다 비교합니다.
  • Math.addExact의 overflow 예외는 후보 계산에서 끝나 CAS에 도달하지 않습니다.
  • retries는 실패한 CAS 횟수이며 경과 시간·CPU 사용량이 아닙니다. onSpinWait은 힌트이고 park·양보 보장이 없습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2OneShotCasIncrement는 실패를 무시하고 MeasuredCasAccumulator는 실패를 센 뒤 새 값을 읽는다는 원문 차이를 비교합니다.
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보CAS 반환 뒤 두 구현이 하는 일을 비교한다 CAS 반환 뒤 두 구현이 하는 일을 비교한다 OneShotCasIncrement는 실패를 무시하고 MeasuredCasAccumulator는 실패를 센 뒤 새 값을 읽는다는 원문 차이를 비교합니다. CAS 반환 · 구현의 재시도 CAS 반환 뒤 두 구현이 하는 일을 비교한다 CAS 실패 뒤 종료할지, 다시 읽고 시도할지는 코드의 반복문이 정합니다. OneShotCasIncrement 후보 계산 get() 한 번 뒤 observed + 1 CAS가 true 증가가 반영되고 메서드 종료 CAS가 false 반환값을 버리고 메서드 종료 MeasuredCasAccumulator 후보 계산 반복마다 get() 뒤 Math.addExact(observed, delta) CAS가 true 증가가 반영되면 즉시 return CAS가 false retries 증가 · 스핀 힌트 뒤 다음 반복에서 다시 읽음 한 번의 CAS가 자동으로 재시도하는 것은 아닙니다. retries는 실패한 CAS 횟수이며, CPU 사용량이나 경과 시간을 측정한 값은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
두 구현의 후보 계산

각 구현이 현재 값을 읽고 자신의 변경 후보를 만듭니다.

  • OneShot: get 한 번 · observed+1
  • Accumulator: 반복마다 get · Math.addExact(observed,delta)
  • 실제 상태: 후보 계산만으로 변경 안 됨
  • 각 후보가 뒤 CAS의 입력이 되고 addExact 예외가 나면 해당 시도는 계산 단계에서 끝납니다.
CAS의 두 판정

같은 시도에서 CAS true 또는 false 중 하나의 결과를 받습니다.

  • true: 해당 후보가 원자적으로 반영됨
  • false: 해당 후보는 반영되지 않음
  • 적용 범위: 두 구현 모두 같은 성공·실패 의미
  • CAS 결과가 반영 여부를 결정하며 성공 뒤 실패를 연이어 실행하는 경로가 아닙니다.
결과별 제어 책임

성공은 종료하고 실패 뒤 재시도 여부는 구현의 반복문이 정합니다.

  • true: OneShot 메서드 종료 / Accumulator 즉시 return
  • false OneShot: 결과를 버리고 종료 · 재시도 없음
  • false Accumulator: retries++ · spin 힌트 · 다음 반복에서 get
  • 한 번의 CAS는 자동 재시도하지 않으며 Accumulator의 실패 경로만 반복문을 통해 새 값을 읽습니다.

CAS 반환 뒤 두 구현이 하는 일을 비교한다

CAS 실패 후 한 번 구현의 종료와 반복 구현의 다시 읽기·재시도가 다릅니다. 관측·후보 계산·CAS 결과·다음 동작을 단계로 보여 재시도 주체를 구별합니다.

CAS 반환 뒤 두 구현이 하는 일을 비교한다

OneShotCasIncrement는 실패를무시하고 MeasuredCasAccumulator는실패를센뒤새값을읽는다는 원문 차이를 비교합니다.

CAS 반환 · 구현의 재시도

CAS 반환 뒤 두 구현이 하는 일을 비교한다

CAS 실패 뒤 종료할지, 다시 읽고 시도할지는 코드의 반복문이 정합니다.

CAS 결과 뒤 종료와 재시도의 갈림각구현은후보를계산한뒤CAS를시도합니다. OneShot은실패해도종료하지만Accumulator는실패횟수를증가시키고다시get하여새후보로재시도합니다. 성공한CAS만값을반영합니다.현재 값 읽기 · 후보 계산OneShotget 1회CAS반복 구현get 매회CASfalse 무시메서드 종료falseretries++다시 읽기true증가·종료true증가·return
CAS 결과 뒤 종료와 재시도의 갈림각구현은후보를계산한뒤CAS를시도합니다. OneShot은실패해도종료하지만Accumulator는실패횟수를증가시키고다시get하여새후보로재시도합니다. 성공한CAS만값을반영합니다.현재 값 읽기 · 후보 계산OneShotget 1회CAS반복 구현get 매회CASfalse 무시메서드종료falseretries++다시 읽기true증가·종료true증가·return
설명과 원래 예시 값

OneShotCasIncrement

후보 계산
get() 한 번 뒤 observed + 1
CAS가 true
증가가 반영되고 메서드 종료
CAS가 false
반환값을 버리고 메서드 종료

MeasuredCasAccumulator

후보 계산
반복마다 get() 뒤 Math.addExact(observed, delta)
CAS가 true
증가가 반영되면 즉시 return
CAS가 false
retries 증가 · 스핀 힌트 뒤 다음 반복에서 다시 읽음

한 번의 CAS가 자동으로 재시도하는 것은 아닙니다. retries는 실패한 CAS 횟수이며, CPU 사용량이나 경과 시간을 측정한 값은 아닙니다.

이 예의 조건

  • OneShotCasIncrement와 MeasuredCasAccumulator의 한 시도를 같은 관찰 단계마다 비교합니다.
  • Math.addExact의 overflow 예외는 후보 계산에서 끝나 CAS에 도달하지 않습니다.
  • retries는 실패한 CAS 횟수이며 경과 시간·CPU 사용량이 아닙니다. onSpinWait은 힌트이고 park·양보 보장이 없습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2OneShotCasIncrement는 실패를 무시하고 MeasuredCasAccumulator는 실패를 센 뒤 새 값을 읽는다는 원문 차이를 비교합니다.
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보CAS 반환 뒤 두 구현이 하는 일을 비교한다 CAS 반환 뒤 두 구현이 하는 일을 비교한다 OneShotCasIncrement는 실패를 무시하고 MeasuredCasAccumulator는 실패를 센 뒤 새 값을 읽는다는 원문 차이를 비교합니다. CAS 반환 · 구현의 재시도 CAS 반환 뒤 두 구현이 하는 일을 비교한다 CAS 실패 뒤 종료할지, 다시 읽고 시도할지는 코드의 반복문이 정합니다. OneShotCasIncrement 후보 계산 get() 한 번 뒤 observed + 1 CAS가 true 증가가 반영되고 메서드 종료 CAS가 false 반환값을 버리고 메서드 종료 MeasuredCasAccumulator 후보 계산 반복마다 get() 뒤 Math.addExact(observed, delta) CAS가 true 증가가 반영되면 즉시 return CAS가 false retries 증가 · 스핀 힌트 뒤 다음 반복에서 다시 읽음 한 번의 CAS가 자동으로 재시도하는 것은 아닙니다. retries는 실패한 CAS 횟수이며, CPU 사용량이나 경과 시간을 측정한 값은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
두 구현의 후보 계산

각 구현이 현재 값을 읽고 자신의 변경 후보를 만듭니다.

  • OneShot: get 한 번 · observed+1
  • Accumulator: 반복마다 get · Math.addExact(observed,delta)
  • 실제 상태: 후보 계산만으로 변경 안 됨
  • 각 후보가 뒤 CAS의 입력이 되고 addExact 예외가 나면 해당 시도는 계산 단계에서 끝납니다.
CAS의 두 판정

같은 시도에서 CAS true 또는 false 중 하나의 결과를 받습니다.

  • true: 해당 후보가 원자적으로 반영됨
  • false: 해당 후보는 반영되지 않음
  • 적용 범위: 두 구현 모두 같은 성공·실패 의미
  • CAS 결과가 반영 여부를 결정하며 성공 뒤 실패를 연이어 실행하는 경로가 아닙니다.
결과별 제어 책임

성공은 종료하고 실패 뒤 재시도 여부는 구현의 반복문이 정합니다.

  • true: OneShot 메서드 종료 / Accumulator 즉시 return
  • false OneShot: 결과를 버리고 종료 · 재시도 없음
  • false Accumulator: retries++ · spin 힌트 · 다음 반복에서 get
  • 한 번의 CAS는 자동 재시도하지 않으며 Accumulator의 실패 경로만 반복문을 통해 새 값을 읽습니다.

그림은 같은 CAS 결과를 받은 뒤 두 구현이 하는 일을 비교합니다. CAS가 true면 두 구현 모두 값이 반영된 채 끝납니다. false일 때 OneShotCasIncrement는 그대로 끝나고, MeasuredCasAccumulator는 실패를 센 뒤 다음 반복에서 값을 다시 읽습니다. CAS 자체가 알아서 재시도해 주지는 않으므로, 재시도는 이렇게 코드의 반복문이 맡아야 합니다.

실행 결과의 첫 줄은 언제나 같습니다. add()는 CAS가 성공해야만 반환하고, 성공한 CAS는 정확히 1을 더합니다. 그러니 20만 번의 add(1)이 모두 끝나면 값은 20만이고, main은 모든 작업자를 join()한 뒤에 읽습니다. 반면 둘째 줄의 재시도 횟수는 스레드들이 얼마나 자주 부딪혔는지에 따라 달라집니다. 20번 실행했을 때 가장 적은 때는 3번, 가장 많은 때는 9,648번이었고, 따로 실행했을 때 한 번도 부딪히지 않아 0이 나온 적도 있습니다. 컴퓨터와 실행 상황에 따라서는 훨씬 많아질 수도 있습니다. 다음은 그중 한 번의 출력입니다.

total=200000
retries=1036

Thread.onSpinWait()는 '지금 반복문을 돌며 기다리는 중'이라고 JVM에 알려 주는 힌트입니다. JVM은 CPU에 따라 이 힌트를 이용해 반복문의 효율을 높일 수 있지만, 스레드를 재우거나 다른 스레드에 차례를 넘긴다는 보장은 없습니다. 이 루프에는 park()나 조건 대기도 없으므로, 재시도하는 동안 스레드는 CPU를 계속 씁니다. 경합이 심하게 이어지면 재시도 횟수가 크게 늘고, 그만큼 CPU 시간이 실패한 계산에 쓰입니다.

재시도 루프에서 지켜야 할 규칙

재시도 루프가 올바르게 동작하려면 몇 가지를 지켜야 합니다. 먼저 observed는 읽은 순간의 값일 뿐이고, CAS를 호출할 때까지 그대로라고 가정하면 안 됩니다. 그 가정을 확인하는 일이 바로 CAS입니다. 그래서 candidate는 observed만으로 계산해야 합니다. 계산 중에 다른 공유 값을 읽어 섞으면, CAS가 확인하는 것은 observed가 그대로인지뿐이므로 그 다른 값이 그사이에 바뀌었는지는 확인되지 않습니다.

다음으로, 루프 안의 계산은 여러 번 실행될 수 있습니다. CAS가 실패할 때마다 처음부터 다시 계산하기 때문입니다. 그러므로 계산에는 외부에 영향을 남기는 부수 효과가 없어야 합니다. 결제 요청이나 로그 전송 같은 일을 루프 안에 넣으면, 상태는 한 번만 바뀌어도 그 일은 재시도 횟수만큼 반복됩니다. 앞 글의 updateAndGet()에 넘기는 함수에 부수 효과가 없어야 하는 것도 같은 이유입니다. 이 메서드들도 내부에서 같은 방식으로 재시도하기 때문입니다.

마지막으로, 재시도는 공짜가 아닙니다. 실패한 시도마다 계산 한 번이 버려지므로, 계산이 오래 걸리거나 충돌이 잦으면 버리는 양이 많아집니다. 이럴 때는 락으로 한 스레드씩 차례로 실행하는 편이 오히려 나을 수 있습니다. 운영 환경에서 이를 판단하려면 위 예제처럼 재시도 횟수를 세어 두는 것이 도움이 됩니다.

더 깊이: 락 없이도 전체는 앞으로 나아간다

위 누적기에서 total을 바꾸는 방법은 성공한 CAS뿐입니다. 그러니 어떤 스레드의 CAS가 실패했다면, 그 스레드가 값을 읽은 뒤 다른 스레드의 CAS가 성공했다는 뜻입니다. 즉 실패는 언제나 다른 누군가의 성공과 짝을 이루므로, 스레드들 전체로 보면 작업이 멈추지 않고 진행됩니다. 이런 성질을 락 프리(lock-free)라고 합니다. 락을 쓰는 코드에서는 락을 가진 스레드가 운영체제에 의해 멈추면 나머지가 모두 기다려야 하지만, 락 프리 코드에서는 한 스레드가 멈춰도 다른 스레드는 계속 성공할 수 있습니다. 다만 락 프리는 전체의 진행을 약속할 뿐이고, 특정 스레드가 매번 지는 일까지 막지는 않습니다. 공정성이 필요하다면 락 프리라는 이유만으로 고르면 안 됩니다.

CAS가 성공하는 순간은 그 연산이 다른 스레드에게 '일어난' 시점이기도 합니다. 성공하기 전까지는 어떤 스레드도 새 값을 볼 수 없고, 성공한 뒤에 읽는 스레드는 새 값이나 그 뒤에 다시 바뀐 값을 봅니다. 동시성 이론에서는 이렇게 연산 전체가 한순간에 일어난 것처럼 보이게 하는 지점을 선형화 지점(linearization point)이라고 부릅니다. CAS 기반 코드가 올바른지 따질 때는 이 지점이 어디인지부터 찾으면 편합니다.

AtomicReference의 CAS는 참조를 비교한다

재시도 루프는 숫자뿐 아니라 객체 상태에도 쓸 수 있습니다. 다음 프로그램은 세 번까지만 허용하는 할당량(quota)을 레코드 Quota로 표현합니다. remaining은 남은 횟수, used는 사용한 횟수입니다. 레코드의 컴팩트 생성자(compact constructor)는 두 값 가운데 하나라도 음수이면 객체 생성을 거부합니다. 스레드 다섯 개가 동시에 한 번씩 할당량을 얻으려고 합니다.

src/AtomicQuota.java
import java.util.concurrent.atomic.AtomicReference;

public final class AtomicQuota {
    record Quota(int remaining, int used) {
        Quota {
            if (remaining < 0 || used < 0) throw new IllegalArgumentException();
        }
    }

    private final AtomicReference<Quota> state =
            new AtomicReference<>(new Quota(3, 0));

    boolean tryAcquire() {
        while (true) {
            Quota observed = state.get();
            if (observed.remaining() == 0) return false;
            Quota candidate = new Quota(observed.remaining() - 1, observed.used() + 1);
            if (state.compareAndSet(observed, candidate)) return true;
        }
    }

    Quota snapshot() { return state.get(); }

    public static void main(String[] args) throws InterruptedException {
        AtomicQuota quota = new AtomicQuota();
        Thread[] callers = new Thread[5];
        for (int i = 0; i < callers.length; i++) {
            callers[i] = Thread.ofPlatform().start(() -> System.out.println(quota.tryAcquire()));
        }
        for (Thread caller : callers) {
            caller.join();
        }
        System.out.println(quota.snapshot());
    }
}

30번 실행했더니 25번은 다음과 같이 출력되었습니다. 앞의 다섯 줄은 다섯 스레드가 각자 출력한 tryAcquire()의 결과이고, 마지막 줄은 모든 스레드가 끝난 뒤의 할당량 상태입니다.

true
true
true
false
false
Quota[remaining=0, used=3]

나머지 5번은 true true false true false처럼 true와 false가 섞인 순서로 출력되었습니다. 각 스레드가 결과를 얻은 순서와 println을 실행한 순서가 다를 수 있기 때문입니다. 하지만 어떤 실행에서도 true는 세 번, false는 두 번이었고, 마지막 상태는 언제나 Quota[remaining=0, used=3]이었습니다.

true가 세 번을 넘지 않는 이유는 남은 횟수 확인과 차감이 같은 CAS로 묶여 있기 때문입니다. 한 스레드가 remaining이 1인 상태를 읽은 뒤 다른 스레드가 마지막 하나를 먼저 가져가면, 처음 스레드의 CAS는 실패합니다. 다시 읽은 상태는 remaining이 0이므로 이번에는 false를 돌려줍니다. 확인한 상태가 바뀌었으면 확인부터 다시 하는 것입니다.

이 코드에서 중요한 점이 하나 더 있습니다. AtomicReference의 compareAndSet은 공식 문서에 적힌 대로 현재 참조와 기대값을 equals()가 아니라 ==로 비교합니다. 즉 내용이 같은지가 아니라 같은 객체인지를 봅니다. 그래서 기대값에는 반드시 state.get()이 돌려준 바로 그 객체를 넘겨야 합니다. 다음 프로그램으로 차이를 확인할 수 있습니다.

src/ReferenceCasPitfall.java
import java.util.concurrent.atomic.AtomicReference;

public final class ReferenceCasPitfall {
    record Quota(int remaining, int used) {}

    public static void main(String[] args) {
        AtomicReference<Quota> state = new AtomicReference<>(new Quota(3, 0));

        Quota sameContent = new Quota(3, 0);
        System.out.println("equals: " + state.get().equals(sameContent));
        System.out.println("CAS with new object: " + state.compareAndSet(sameContent, new Quota(2, 1)));

        Quota observed = state.get();
        System.out.println("CAS with observed: " + state.compareAndSet(observed, new Quota(2, 1)));
        System.out.println(state.get());
    }
}
equals: true
CAS with new object: false
CAS with observed: true
Quota[remaining=2, used=1]

레코드는 필드 값으로 equals()를 비교하므로 첫 줄은 true입니다. 하지만 sameContent는 현재 상태와 내용만 같은 다른 객체이므로, 이것을 기대값으로 넘긴 CAS는 실패합니다. state.get()으로 얻은 observed를 넘겨야 CAS가 성공합니다. 만약 tryAcquire()에서 기대값을 new Quota(observed.remaining(), observed.used())처럼 새로 만들었다면, CAS는 한 번도 성공하지 못하고 루프는 끝나지 않습니다. 실제로 시도 횟수를 100만 번으로 제한해 실행해 보니 100만 번 모두 실패했고 상태는 처음 그대로였습니다. candidate는 교체할 새 객체이고 기대값과는 역할이 다르니, 둘을 혼동하지 않도록 합니다.

더 깊이: 값이 같다는 것과 아무 일도 없었다는 것

CAS가 확인하는 것은 '지금 값이 기대값과 같은가'이지 '읽은 뒤 아무 일도 없었는가'가 아닙니다. 값이 A에서 B로 바뀌었다가 다시 A로 돌아오면 CAS는 아무 일도 없었던 것처럼 성공합니다. 이를 ABA 문제라고 합니다. 위의 카운터나 AtomicQuota처럼 새 값을 현재 값만으로 계산하는 코드에서는 문제가 되지 않습니다. 값이 A로 돌아왔다면 A에서 계산한 결과가 지금도 맞기 때문입니다. 반면 노드 객체를 재사용하는 락 프리 스택처럼, 같은 객체가 다시 들어올 수 있고 그 사이에 일어난 변화에 정확성이 달려 있는 자료구조에서는 문제가 됩니다. 이런 경우에는 참조와 함께 버전 번호를 비교하는 AtomicStampedReference를 쓸 수 있습니다. Java에서는 observed가 객체를 붙잡고 있는 동안 그 객체가 회수되지 않으므로, 새로 만든 객체가 우연히 같은 참조가 되는 일은 없습니다. 그래서 AtomicReference라면 같은 객체를 다시 넣는 코드가 있을 때만 이 문제를 따지면 됩니다.

부수 효과는 CAS가 성공한 뒤 루프 밖에서

할당량을 얻은 뒤 외부 서버에 요청을 보내야 한다고 해 봅시다. 요청을 루프 안, CAS 앞에 넣으면 CAS가 실패할 때마다 요청이 다시 나갑니다. 할당량은 한 번 차감되었는데 요청은 여러 번 보낸 셈이 됩니다. 외부 작업은 CAS가 성공해 할당량을 확실히 얻은 뒤, 루프 밖에서 한 번만 실행합니다.

if (quota.tryAcquire()) {
    sendRequest(); // 할당량을 얻은 호출에서만, 한 번 실행된다
}

이렇게 나눠도 모든 문제가 해결되지는 않습니다. sendRequest()가 실패하면 할당량은 이미 차감된 상태입니다. 실패한 요청의 할당량을 돌려줘야 한다면, remaining을 다시 늘리는 별도의 CAS 갱신을 만드는 식으로 되돌리는 방법을 직접 정해야 합니다. CAS가 보호하는 것은 메모리 안의 상태 하나뿐이고, 외부 작업까지 포함한 전체를 한 번에 처리해 주지는 않습니다.

CAS가 알맞은 경우와 그렇지 않은 경우

CAS 재시도 루프는 계산이 짧고 충돌이 드물 때 가장 잘 맞습니다. 상황에 따라 다음과 같이 판단합니다.

상황CAS 재시도 루프다른 선택
계산이 짧고 충돌이 드묾알맞음원자 클래스의 메서드를 그대로 쓰거나 직접 루프를 씀
계산이 길고 충돌이 잦음실패할 때마다 계산을 버리고 다시 함락
여러 스레드가 아주 자주 더하는 통계 합계같은 값에서 충돌이 잦음LongAdder (갱신 중의 sum()은 원자 스냅샷이 아님)
값이 바뀔 때까지 기다려야 함기다리는 내내 CPU를 씀Condition, BlockingQueue 같은 조건 대기
외부 부수 효과가 있음재시도할 때 부수 효과가 반복됨성공한 뒤 루프 밖에서 실행하거나 락으로 묶음

네 번째 줄은 '남은 할당량이 생길 때까지 기다린다'처럼 다른 스레드의 동작을 기다려야 하는 경우입니다. CAS 루프로 이런 대기를 하면 기다리는 내내 CPU를 쓰므로, 20장에서 배운 조건 대기를 쓰는 편이 낫습니다. 락과 CAS를 같은 작업으로 비교하는 일은 락과 CAS를 비교하는 글에서 이어집니다.

정리

  • compareAndSet(expected, newValue)는 현재 값이 기대값과 같을 때만 새 값을 쓰고 true를 돌려줍니다. 다르면 아무것도 쓰지 않고 false를 돌려주며, 예외는 발생하지 않습니다.
  • CAS가 실패한 증가를 무시하면 그 증가는 사라집니다. 덮어쓰기는 일어나지 않지만, 실패를 어떻게 처리할지는 호출자가 정해야 합니다.
  • 재시도 루프는 값을 읽고, 읽은 값만으로 새 값을 계산하고, CAS를 시도해 실패하면 처음부터 반복합니다.
  • 루프 안의 계산은 여러 번 실행될 수 있으므로 부수 효과가 없어야 합니다. 외부 작업은 CAS가 성공한 뒤 루프 밖에서 실행합니다.
  • AtomicReference의 CAS는 ==로 참조를 비교합니다. 기대값으로는 get()이 돌려준 객체를 그대로 넘깁니다.
  • 계산이 길거나 충돌이 잦으면 재시도 비용이 커지므로, 재시도 횟수를 세어 보고 락과 비교합니다.

연습 문제

  1. 다음 코드가 출력하는 세 줄을 예측하세요.

    AtomicInteger v = new AtomicInteger(10);
    System.out.println(v.compareAndSet(10, 20) + " " + v.get());
    System.out.println(v.compareAndSet(10, 30) + " " + v.get());
    System.out.println(v.compareAndSet(20, 30) + " " + v.get());
  2. OneShotCasIncrement의 결과가 80만보다 커지는 일이 있을 수 있나요? 출력된 값이 정확히 무엇의 개수인지와 함께 설명하세요.

  3. 여러 스레드가 측정값을 제출합니다. 지금까지의 최댓값보다 큰 값만 저장하고, 저장했으면 true, 저장하지 않았으면 false를 돌려주는 record(int candidate)를 작성하세요. 최댓값 이하인 값은 재시도 없이 바로 거부하고, CAS가 실패했을 때만 최신 값을 다시 읽어 판단해야 합니다.

해설 보기

1번. 출력은 true 20, false 20, true 30입니다. 첫 호출은 현재 값 10이 기대값과 같으므로 20으로 바꿉니다. 두 번째 호출은 기대값이 10인데 현재 값이 20이라 실패하고, 값은 20 그대로입니다. 세 번째 호출은 기대값 20이 현재 값과 같으므로 30으로 바꿉니다.

2번. 커질 수 없습니다. 값을 바꾸는 방법은 성공한 CAS뿐이고, 성공한 CAS는 언제나 그 순간의 값에 정확히 1을 더합니다. 그러므로 출력된 값은 성공한 CAS의 개수와 같습니다. CAS 시도는 모두 80만 번이고 그중 일부만 성공하므로, 결과는 80만 이하입니다. 같은 값을 읽은 스레드들 가운데 한 스레드만 성공하고 나머지의 증가는 버려질 뿐, 낡은 값이 새 값을 덮어쓰는 일은 없습니다.

3번. 현재 최댓값을 읽고, 후보가 그 이하이면 바로 false를 돌려줍니다. 후보가 더 크면 CAS로 교체를 시도하고, 실패하면 다시 읽습니다. 그사이에 더 큰 값이 저장되었을 수 있으므로 비교부터 다시 해야 합니다.

exercise/AtomicMaximumSolution.java
import java.util.concurrent.atomic.AtomicInteger;

public final class AtomicMaximumSolution {
    private final AtomicInteger maximum = new AtomicInteger(Integer.MIN_VALUE);

    boolean record(int candidate) {
        while (true) {
            int observed = maximum.get();
            if (candidate <= observed) return false;
            if (maximum.compareAndSet(observed, candidate)) return true;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicMaximumSolution values = new AtomicMaximumSolution();
        Thread a = Thread.ofPlatform().start(() -> values.record(18));
        Thread b = Thread.ofPlatform().start(() -> values.record(27));
        Thread c = Thread.ofPlatform().start(() -> values.record(21));
        a.join();
        b.join();
        c.join();
        System.out.println("max=" + values.maximum.get());
    }
}
max=27

세 호출이 어떤 순서로 실행되어도 최종 출력은 max=27입니다. 20번 실행해 20번 모두 같았습니다. 각 호출의 반환값은 순서에 따라 달라집니다. 18, 27, 21 순서로 실행되면 true, true, false이고, 21, 18, 27 순서라면 true, false, true입니다. main은 반환값을 출력하지 않으므로 이 차이는 출력에 드러나지 않습니다.

경쟁이 일어나는 순서도 하나 따라가 보겠습니다. 21을 제출한 호출이 최댓값으로 18을 읽고 비교를 통과한 뒤, CAS 전에 27이 먼저 저장되었다고 해 봅시다. 기대값 18이 현재 값 27과 다르므로 CAS는 실패합니다. 다음 반복에서 27을 읽으면 21은 그 이하이므로 false를 돌려줍니다. 만약 CAS 대신 set()으로 그냥 썼다면 21이 27을 덮어썼을 것입니다. 이 코드는 앞 글에서 본 accumulateAndGet(candidate, Math::max)와 같은 일을 하면서, 값을 실제로 바꿨는지까지 알려 줍니다.

다음 글

CAS로 스핀 락 만들기에서는 CAS로 '잠겨 있지 않으면 잠근다'를 한 번에 처리해 간단한 락을 직접 만듭니다. 검사와 변경을 나누면 왜 두 스레드가 함께 들어오는지, 기다리는 동안 CPU를 쓰는 스핀 대기가 어디까지 쓸모 있는지 살펴봅니다.