락과 CAS 중 무엇을 쓸까
같은 갱신을 CAS와 락으로 각각 구현해 보면서, 충돌이 얼마나 잦은지, 계산이 얼마나 긴지, 함께 바꿀 상태가 얼마나 큰지, 예외가 났을 때 무엇이 남는지에 따라 둘 중 하나를 고르는 방법을 배웁니다.
공유 상태를 지키는 방법을 두 가지 보았습니다. 19장의 synchronized와 ReentrantLock 같은 락을 쓰면 한 번에 한 스레드만 임계 영역에 들어가고, 나머지 스레드는 차례를 기다립니다. CAS와 재시도 루프에서 본 CAS는 반대로 아무도 기다리지 않습니다. 모든 스레드가 각자 새 값을 계산하고, 그사이에 값이 바뀌었으면 계산을 버리고 다시 합니다. 두 방법 모두 올바른 결과를 낼 수 있으니, 고르는 기준은 '충돌했을 때 무엇을 치르는가'입니다. 락은 기다리는 시간을 치르고, CAS는 버려지는 계산을 치릅니다.
그래서 'CAS는 락을 쓰지 않으니 빠르다'처럼 이름만 보고 고르면 안 됩니다. 충돌이 드물고 계산이 짧으면 CAS가 버리는 양이 거의 없어 유리할 수 있습니다. 반대로 여러 스레드가 같은 값을 자주 바꾸거나 새 값을 계산하는 데 오래 걸리면, 실패한 계산이 CPU를 낭비합니다. 이 글에서는 같은 작업을 두 방법으로 구현해 보면서 충돌 빈도, 계산 길이, 함께 바꿀 상태의 크기, 예외가 났을 때 남는 상태를 차례로 비교합니다. 실행 결과는 앞 글들과 같은 코어 2개짜리 컴퓨터에서 JDK 21로 얻었습니다.
충돌하면 락은 기다리고 CAS는 다시 계산한다
두 스레드 T1과 T2가 같은 값을 거의 동시에 1씩 늘리려 한다고 해 봅시다. 새 값을 계산하는 데 시간이 조금 걸린다면 두 방법은 다음처럼 진행됩니다.
| 순서 | 락 | CAS |
|---|---|---|
| 1 | T1이 락을 얻고 계산을 시작함 | T1과 T2가 같은 값을 읽고 각자 계산을 시작함 |
| 2 | T2는 락을 얻지 못해 기다림 | T1의 CAS가 성공함 |
| 3 | T1이 새 값을 쓰고 락을 풂 | T2의 CAS는 값이 바뀌어 실패하고, T2는 계산 결과를 버림 |
| 4 | T2가 락을 얻어 계산하고 새 값을 씀 | T2가 바뀐 값을 다시 읽어 처음부터 계산하고 CAS에 성공함 |
결과는 두 방법 모두 2만큼 늘어나지만, 그 과정에서 치른 비용이 다릅니다. 락에서는 계산이 정확히 두 번 일어났고, 대신 T2는 T1의 계산이 끝날 때까지 아무 일도 하지 못했습니다. CAS에서는 T2가 기다리지 않았지만 계산이 세 번 일어났고, 그중 한 번은 버려졌습니다. 충돌이 드물면 3번 줄 같은 일이 거의 일어나지 않으니 버리는 계산도 거의 없습니다. 반대로 충돌이 잦고 계산이 길수록 버려지는 양이 늘어납니다. 그 양을 직접 세어 보겠습니다.
계산이 무거운 CAS 갱신은 몇 번 실행될까
다음 프로그램은 AtomicInteger의 updateAndGet()으로 값을 1씩 늘립니다. 21장 첫 글에서 본 것처럼 updateAndGet()은 넘겨받은 함수로 새 값을 계산해 CAS로 교체하고, CAS가 실패하면 바뀐 값으로 함수를 다시 실행합니다. 여기서는 무거운 계산을 흉내 내려고 함수 안에서 1만 번 도는 반복문을 실행하고, 함수가 실행될 때마다 calculations를 1씩 늘립니다. 스레드 8개가 1,000번씩 호출하므로 성공한 갱신은 모두 8,000번이어야 합니다.
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());
}
}calculations는 여러 스레드가 동시에 더하고 마지막에 한 번만 읽는 통계 값이라 LongAdder로 셉니다. 그런데 갱신 함수 안에서 바깥의 값을 바꾸는 것은 CAS와 재시도 루프에서 피하라고 한 부수 효과입니다. 여기서는 함수가 몇 번 실행되는지 보려고 일부러 넣었을 뿐이고, 실제 코드에서는 이렇게 쓰지 않습니다. checksum 반복문은 결과가 언제나 같아서 JIT 컴파일러가 줄여 버릴 수도 있습니다. 그러니 이 예제가 세는 것은 함수가 실행된 횟수일 뿐, 걸린 시간이나 CPU 사용량은 재지 않습니다.
모두 28번 실행했더니 첫 줄은 언제나 updates=8000이었고, 둘째 줄은 8,020부터 13,359까지 실행마다 크게 달랐습니다. 다음은 그중 한 번의 출력입니다.
updates=8000
calculations=11586첫 줄이 언제나 같은 이유는 updateAndGet()이 CAS에 성공해야만 반환하고, 성공한 CAS는 정확히 1을 더하기 때문입니다. 둘째 줄에서 8,000을 넘는 만큼은 다른 스레드와 부딪혀 결과를 버리고 함수를 다시 실행한 횟수입니다. 위 출력이라면 3,586번, 성공한 계산의 절반에 가까운 양이 버려졌습니다. 이 수는 실제로 동시에 실행되는 스레드 수와 스케줄링에 따라 달라집니다. 컴퓨터가 다른 일로 바쁠 때 실행한 다섯 번은 모두 8,800보다 작았는데, 다른 프로그램이 코어를 차지하면 이 프로그램의 스레드 둘이 같은 순간에 계산하는 일이 드물어지기 때문입니다. 반대로 코어가 더 많은 컴퓨터에서는 훨씬 커질 수 있습니다.
더 깊이: calculations는 CAS 실패 횟수와 같을까
꼭 같지는 않습니다. 공식 문서는 '경합 때문에 갱신이 실패하면 함수가 다시 적용될 수 있다'고만 정해 두었고, CAS를 몇 번 시도할지는 구현에 맡깁니다. JDK 21의 updateAndGet()은 약한 CAS(weakCompareAndSetVolatile())를 쓰는데, 약한 CAS는 값이 그대로여도 가끔 실패할 수 있습니다. 그래서 구현은 CAS가 실패하면 값을 다시 읽어 보고, 값이 그대로면 앞서 계산한 결과로 CAS만 다시 시도합니다. 함수를 다시 실행하는 것은 값이 실제로 바뀌었을 때뿐입니다. 따라서 calculations에서 8,000을 뺀 값은 '값이 바뀌어서 버린 계산'의 수이고, CAS 실패 횟수는 그보다 클 수 있습니다. 실패 횟수 자체가 필요하면 연습 문제 3처럼 재시도 루프를 직접 쓰고 실패할 때마다 셉니다.
같은 갱신을 락으로 하면
같은 일을 ReentrantLock으로 해 보겠습니다. 위 프로그램에서 value를 일반 int 필드로 바꾸고, 락을 얻은 뒤에 계산하고 값을 쓰도록 increment()를 고칩니다. 파일 맨 위에 import java.util.concurrent.locks.ReentrantLock;을 추가하고, main의 첫 출력 줄에서 demo.value.get()을 demo.value로 바꿉니다. 나머지는 그대로 둡니다.
private final ReentrantLock lock = new ReentrantLock();
private int value;
void increment() {
lock.lock();
try {
calculations.increment();
long checksum = 0;
for (int i = 0; i < 10_000; i++) {
checksum += i;
}
if (checksum < 0) throw new AssertionError();
value = value + 1;
} finally {
lock.unlock();
}
}30번 실행해 30번 모두 같은 결과가 나왔습니다. CAS 버전에서는 calculations가 실행마다 8,000보다 크게 달라졌는데, 이번에는 둘째 줄이 어떻게 나오는지 비교해 보세요.
updates=8000
calculations=8000락 안에서는 한 번에 한 스레드만 계산하므로 버려지는 계산이 없고, 계산은 정확히 갱신 횟수만큼 실행됩니다. 대신 한 스레드가 1만 번짜리 반복문을 도는 동안 나머지 일곱 스레드는 락을 기다립니다. ReentrantLock은 오래 기다리는 스레드를 재우므로 기다리는 동안 CPU를 거의 쓰지 않지만, CAS로 스핀 락 만들기에서 본 것처럼 재우고 깨우는 데도 비용이 듭니다. 두 프로그램 중 어느 쪽이 먼저 끝나는지는 이 예제로 알 수 없습니다. 이 예제가 보여 주는 것은 충돌했을 때 비용을 치르는 방식의 차이입니다.
그런데 이 예제의 무거운 계산은 사실 공유 값과 아무 상관이 없습니다. checksum은 current를 읽지 않으므로 락이나 CAS 안에 있을 이유가 없습니다. 공유 값에 의존하지 않는 계산은 락 밖, CAS 루프 밖에서 먼저 하고, 공유 값을 바꾸는 짧은 부분만 보호하는 것이 가장 효과가 큽니다. 그러면 CAS로 하든 락으로 하든 충돌했을 때 버리거나 기다리는 시간이 짧아집니다. 연습 문제 2에서 이렇게 고쳐 봅니다.
성능을 비교할 때 함께 봐야 하는 값
어느 쪽이 빠른지는 결국 실제와 비슷한 부하를 걸어 재 봐야 압니다. 이때 수치 하나만 보면 판단을 그르치기 쉽습니다. 같은 부하에서 초당 처리량, 응답 시간의 분포, CAS 재시도 횟수를 함께 봐야 합니다. 응답 시간은 평균만 보지 말고, 가장 느린 1%의 요청이 얼마나 걸리는지를 나타내는 99번째 백분위수 같은 값도 봅니다. 경합 때문에 길어진 대기는 평균에 묻혀 잘 드러나지 않기 때문입니다.
손으로 시작 시각과 종료 시각을 재는 방법은 믿기 어렵습니다. 실행 도중에 JIT 컴파일러가 자주 실행되는 코드를 기계어로 컴파일하므로 같은 코드라도 처음과 나중의 속도가 다르고, 앞의 checksum처럼 결과가 쓰이지 않는 계산은 아예 사라질 수도 있습니다. 그래서 Java에서 작은 코드의 성능을 비교할 때는 이런 함정을 줄여 주는 JMH 같은 벤치마크 도구를 씁니다.
좌석과 매출을 함께 바꾸는 예약을 락으로
지금까지는 값 하나를 바꿨습니다. 실제로는 여러 상태를 함께 바꿔야 하는 경우가 많습니다. 다음 LockedSeatInventory는 예약할 수 있는 좌석의 집합 available과 총매출 revenue를 관리합니다. 예약 한 번에 좌석을 집합에서 빼고 매출에 가격을 더하므로, 다른 스레드가 보기에 두 변경은 함께 일어나야 합니다. '팔린 좌석과 매출이 서로 맞는다'처럼 언제나 참이어야 하는 이런 조건을 불변식(invariant)이라고 합니다. 좌석만 빠지고 매출은 그대로인 중간 상태를 다른 스레드가 보거나, 두 스레드가 같은 좌석을 함께 예약하면 불변식이 깨집니다.
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());
}
}reserve()는 가격부터 검사합니다. 이 검사는 공유 상태를 읽지 않으므로 락을 얻기 전에 합니다. 그다음 락 안에서 available.remove(seat)로 좌석을 뺍니다. remove()는 좌석이 있어서 실제로 뺐을 때만 true를 돌려주므로, 이 한 줄이 '좌석이 남아 있는지 확인하기'와 '좌석 빼기'를 함께 합니다. 좌석이 없으면 IllegalStateException을 던집니다. 좌석을 뺐으면 Math.addExact()로 매출을 늘리는데, 이 메서드는 결과가 int 범위를 넘으면 ArithmeticException을 던집니다. 마지막으로 finally가 어떤 경우에도 락을 풉니다.
HashSet과 일반 int 필드는 여러 스레드가 함께 쓰도록 만든 것이 아니지만, 모든 접근이 같은 락 안에서 일어나므로 안전합니다. 읽기만 하는 revenue()도 같은 락을 잡는 이유가 여기에 있습니다. 한 스레드가 락을 풀기 전에 쓴 값은 그 락을 다음에 얻은 스레드에 보입니다. 그러니 읽는 쪽도 같은 락을 거쳐야 마지막 예약까지 반영된 값을 보고, 두 필드가 바뀌는 도중의 상태를 보지 않습니다.
main에서는 두 스레드가 같은 좌석 A1을 가격 120에 예약하려 합니다. 63번 실행해 63번 모두 다음과 같이 출력되었습니다.
rejected
Receipt[seat=A1, charged=120]
revenue=120remove()가 락 안에서 실행되므로 두 스레드 중 한 스레드만 true를 받습니다. 그래서 Receipt 한 줄과 rejected 한 줄, 그리고 revenue=120이 나온다는 것은 실행할 때마다 같습니다. 반면 어느 스레드가 좌석을 얻을지와 앞의 두 줄 중 어느 것이 먼저 출력될지는 정해져 있지 않습니다. 이번 63번은 매번 rejected가 먼저였지만, 두 줄은 서로 다른 스레드가 출력하므로 그 순서를 정하는 코드가 없습니다. 마지막 줄은 두 join()이 끝난 뒤에 출력하므로 언제나 맨 끝입니다.
예외가 나도 락은 바꾼 것을 되돌리지 않는다
main은 정상 예약과 좌석 없음만 실행하지만, reserve()가 끝나는 경우는 모두 네 가지입니다. 가격이 잘못된 경우, 좌석이 없는 경우, 정상 예약, 그리고 매출이 int 범위를 넘는 경우입니다. 그림은 각 경우에 두 필드가 어떻게 남는지와 락이 언제 풀리는지를 단계별로 보여 줍니다.
좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다
예약의 좌석 제거와 revenue 변경·예외·unlock 범위가 다릅니다. 특히 overflow 시 좌석은 제거되고 수익은 유지되는 부분 변경을 단계로 설명합니다.
LockedSeatInventory.reserve의 가격 검사, 좌석 제거, 합계 계산 순서에 따른 정상 반환과 세 예외 경로를 비교합니다.
예외 경계 · 변경과 잠금 해제
좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다
같은 락으로 예약해도 예외 전에 끝난 좌석 제거는 자동으로 되돌아가지 않습니다.
설명과 원래 예시 값
가격 거절: 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에서 실행하지 않은 코드 경로입니다.
이 예의 조건
- 원 main의 같은 A1·가격 120 요청 두 개에서는 정상 Receipt와 좌석 없음 거절이 각각 한 번이며 첫 두 출력의 순서는 가변입니다.
- 가격 거절·합계 overflow는 main 미실행 조건입니다. 각 분기를 공통 검사·변경·종료 단계마다 비교합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 LockedSeatInventory.reserve의 가격 검사, 좌석 제거, 합계 계산 순서에 따른 정상 반환과 세 예외 경로를 비교합니다. 예외 경계 · 변경과 잠금 해제 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 같은 락으로 예약해도 예외 전에 끝난 좌석 제거는 자동으로 되돌아가지 않습니다. 가격 거절: 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에서 실행하지 않은 코드 경로입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 가격과 잠금의 진입 조건 가격 거절은 잠금 전에 끝나고 유효 가격의 요청만 같은 락에 들어갑니다. |
|
|
| 제거와 합계의 분기 좌석 제거 결과와 그 뒤 합계 계산에 따라 필드 변경 범위가 달라집니다. |
|
|
| 각 반환과 해제 책임 잠금을 얻은 분기는 정상·예외 모두 finally에서 unlock합니다. |
|
|
좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다
예약의 좌석 제거와 revenue 변경·예외·unlock 범위가 다릅니다. 특히 overflow 시 좌석은 제거되고 수익은 유지되는 부분 변경을 단계로 설명합니다.
LockedSeatInventory.reserve의 가격 검사, 좌석 제거, 합계 계산 순서에 따른 정상 반환과 세 예외 경로를 비교합니다.
예외 경계 · 변경과 잠금 해제
좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다
같은 락으로 예약해도 예외 전에 끝난 좌석 제거는 자동으로 되돌아가지 않습니다.
설명과 원래 예시 값
가격 거절: 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에서 실행하지 않은 코드 경로입니다.
이 예의 조건
- 원 main의 같은 A1·가격 120 요청 두 개에서는 정상 Receipt와 좌석 없음 거절이 각각 한 번이며 첫 두 출력의 순서는 가변입니다.
- 가격 거절·합계 overflow는 main 미실행 조건입니다. 각 분기를 공통 검사·변경·종료 단계마다 비교합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 LockedSeatInventory.reserve의 가격 검사, 좌석 제거, 합계 계산 순서에 따른 정상 반환과 세 예외 경로를 비교합니다. 예외 경계 · 변경과 잠금 해제 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 같은 락으로 예약해도 예외 전에 끝난 좌석 제거는 자동으로 되돌아가지 않습니다. 가격 거절: 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에서 실행하지 않은 코드 경로입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 가격과 잠금의 진입 조건 가격 거절은 잠금 전에 끝나고 유효 가격의 요청만 같은 락에 들어갑니다. |
|
|
| 제거와 합계의 분기 좌석 제거 결과와 그 뒤 합계 계산에 따라 필드 변경 범위가 달라집니다. |
|
|
| 각 반환과 해제 책임 잠금을 얻은 분기는 정상·예외 모두 finally에서 unlock합니다. |
|
|
가격이 0 이하이면 락을 얻기 전에 끝나므로 풀 락도 없고, 바뀐 것도 없습니다. 좌석이 없으면 remove()가 false를 돌려주므로 아무것도 바꾸지 않은 채 예외를 던집니다. 문제는 마지막 경우입니다. 좌석은 이미 빠졌는데 그다음 Math.addExact()에서 예외가 납니다. finally는 락을 풀어 줄 뿐 빠진 좌석을 되돌려 놓지 않으므로, 좌석은 사라지고 매출은 그대로인 상태가 남습니다. 락이 막아 주는 것은 다른 스레드가 끼어드는 일이지, 내 코드가 중간에 실패하는 일이 아닙니다.
직접 확인해 보겠습니다. LockedSeatInventory의 main을 다음 코드로 바꿔 실행합니다. 같은 클래스 안이므로 private 필드인 available도 읽을 수 있습니다.
public static void main(String[] args) {
LockedSeatInventory inventory = new LockedSeatInventory();
System.out.println(inventory.reserve("A1", Integer.MAX_VALUE));
try {
inventory.reserve("A2", 1);
} catch (ArithmeticException e) {
System.out.println("second reserve failed: " + e.getMessage());
}
System.out.println("available=" + inventory.available + " revenue=" + inventory.revenue());
}Receipt[seat=A1, charged=2147483647]
second reserve failed: integer overflow
available=[] revenue=2147483647첫 예약으로 매출이 int의 최댓값인 2,147,483,647이 되었고, 두 번째 예약은 여기에 1을 더하다가 실패했습니다. 그런데 available이 비어 있습니다. A2는 팔리지 않았는데 예약할 수 있는 좌석에서 사라졌습니다.
고치는 방법은 실패할 수 있는 검사와 계산을 모두 끝낸 뒤에 상태를 바꾸는 것입니다. reserve()의 try 블록 안을 다음처럼 바꿉니다.
if (!available.contains(seat)) throw new IllegalStateException("unavailable: " + seat);
int newRevenue = Math.addExact(revenue, price); // 실패할 수 있는 계산을 먼저 한다
available.remove(seat); // 검사와 계산이 끝난 뒤에 바꾼다
revenue = newRevenue;
return new Receipt(seat, price);이렇게 고친 뒤 같은 main을 실행하면 마지막 줄이 available=[A2] revenue=2147483647로 바뀝니다. 두 번째 예약이 상태를 하나도 바꾸지 않고 실패한 것입니다. 확인과 제거가 두 줄로 나뉘었지만, 둘 다 같은 락 안에 있으므로 그사이에 다른 스레드가 끼어들 수는 없습니다.
작은 상태라면 불변 객체 하나를 CAS로 바꾼다
같은 예약을 CAS로 할 수도 있습니다. 여러 값을 CAS 하나로 바꾸려면 21장 첫 글에서 본 것처럼 값들을 불변 객체 하나에 담고, 그 객체의 참조를 AtomicReference로 교체합니다. 다음 CasSeatCounter는 남은 좌석 수와 매출을 State 레코드 하나에 담습니다. 좌석 번호 없이 개수만 세므로 앞 예제보다 단순한 모델입니다. main에서는 좌석 2개를 놓고 세 스레드가 각각 가격 70, 80, 90으로 예약을 시도하고, 각자 결과를 출력합니다.
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(
() -> System.out.println("price=70 reserved=" + counter.reserve(70)));
Thread b = Thread.ofPlatform().start(
() -> System.out.println("price=80 reserved=" + counter.reserve(80)));
Thread c = Thread.ofPlatform().start(
() -> System.out.println("price=90 reserved=" + counter.reserve(90)));
a.join();
b.join();
c.join();
System.out.println(counter.state.get());
}
}reserve()는 CAS와 재시도 루프에서 본 재시도 루프입니다. 현재 상태를 읽어 observed에 담고, 남은 좌석이 없으면 아무것도 바꾸지 않고 false를 돌려줍니다. 좌석이 있으면 observed만 가지고 좌석이 하나 줄고 매출이 늘어난 새 상태 candidate를 만들어, 그사이에 상태가 바뀌지 않았을 때만 교체합니다. 바뀌었다면 다시 읽어 처음부터 판단합니다.
130번 실행했더니 다음과 같은 출력이 가장 흔했습니다.
price=70 reserved=true
price=80 reserved=true
price=90 reserved=false
State[remaining=0, revenue=150]좌석이 2개뿐이므로 세 시도 가운데 정확히 두 번만 성공하고, 남은 좌석은 0이 됩니다. 이것은 130번 모두 같았습니다. 좌석이 0인 상태를 본 시도는 CAS 없이 false를 돌려주고, 좌석이 남은 상태를 보고 계산한 시도라도 CAS가 실패하면 바뀐 상태를 다시 읽어 판단하기 때문입니다. 반면 어느 시도가 지는지는 스레드가 실행되는 순서에 달려 있습니다. 130번 중 123번은 가장 늦게 시작한 90이 졌지만, 나머지 7번은 70이나 80이 져서 매출이 160이나 170이었습니다. 앞의 세 줄은 세 스레드가 각자 출력하므로 순서가 실행마다 달랐고, 마지막 줄은 세 join()이 끝난 뒤에 출력하므로 언제나 맨 끝이었습니다.
이 방식에는 락 예제에 없던 장점이 있습니다. 새 상태를 공유 상태와 따로 다 만든 뒤에 참조 하나로 바꾸므로, 만드는 도중에 예외가 나면 공유 상태는 전혀 바뀌지 않습니다. 매출을 넘치게 만드는 예약이 들어오면 Math.addExact()가 candidate를 만드는 도중에 예외를 던지고, compareAndSet()까지 가지 않습니다. 실제로 첫 예약을 Integer.MAX_VALUE로, 두 번째를 1로 해 보면 두 번째 호출이 ArithmeticException으로 끝난 뒤에도 상태는 State[remaining=1, revenue=2147483647] 그대로입니다. 락 예제에서 순서를 바꿔 고쳐야 했던 문제가, 새 상태를 따로 만들어 한 번에 바꾸는 구조에서는 처음부터 생기지 않습니다. 참고로 이 예제는 비교를 단순하게 하려고 가격 검사를 뺐습니다. 실제로는 락 예제처럼 루프에 들어가기 전에 검사하면 됩니다.
대신 이 방식은 상태가 작을 때만 알맞습니다. AtomicReference가 원자적으로 바꾸는 것은 참조 하나뿐이어서, State 안에 가변 객체가 있다면 그 객체를 직접 고치는 일은 CAS가 전혀 막아 주지 못합니다. 그래서 상태는 불변이어야 하고, 바꿀 때마다 새 객체를 만들어야 합니다. 이 예제는 숫자 두 개라 새로 만드는 비용이 작지만, 앞 예제처럼 좌석 번호의 집합까지 관리한다면 예약할 때마다 집합 전체를 복사해야 합니다. 충돌해서 다시 시도하면 그 복사도 다시 합니다. 집합이 크거나 바꿀 상태가 여러 객체에 흩어져 있다면 락 하나로 묶는 편이 단순하고, 대개 비용도 적습니다.
락과 CAS를 고르는 기준
지금까지 비교한 내용을 표로 정리하면 다음과 같습니다.
| 따져 볼 점 | CAS가 알맞은 경우 | 락이 알맞은 경우 |
|---|---|---|
| 충돌 빈도 | 같은 값을 동시에 바꾸는 일이 드묾 | 같은 값을 자주 동시에 바꿈 |
| 새 값을 계산하는 시간 | 아주 짧아서 버려도 손해가 작음 | 길어서 다시 계산하면 낭비가 큼 |
| 함께 바꿀 상태 | 불변 객체 하나에 담을 만큼 작음 | 크거나 여러 가변 객체에 흩어져 있음 |
| 기다리는 동안 | 재시도하는 동안 CPU를 계속 씀 | 오래 기다리는 스레드는 잠들어 CPU를 내놓음 |
| 중간에 예외가 나면 | 새 상태를 만드는 도중이면 공유 상태는 그대로 | 이미 바꾼 필드가 남으므로 바꾸는 순서를 정해야 함 |
| 외부 작업 | 루프 안에 넣지 않음 (재시도마다 반복됨) | 락 안에 넣지 않음 (그동안 다른 스레드가 모두 기다림) |
마지막 줄의 외부 작업은 어느 쪽을 고르든 보호 구간 밖으로 빼야 합니다. 데이터베이스 호출이나 네트워크 요청을 CAS 루프 안에 넣으면 재시도할 때마다 다시 호출되고, 락 안에 넣으면 응답을 기다리는 동안 같은 락이 필요한 스레드가 모두 멈춥니다. 외부 작업은 보호 구간 밖에서 하고, 그 결과를 반영할 때만 짧게 락을 잡거나 CAS를 시도합니다. 외부 작업을 하는 동안 상태가 바뀌었을 수 있으므로, 반영하기 전에 조건을 다시 확인하고 맞지 않으면 어떻게 할지도 정해 둬야 합니다.
실제로는 둘 중 하나를 직접 구현하기 전에 이미 있는 도구부터 찾는 경우가 많습니다. 독립된 값 하나는 AtomicInteger 같은 원자 클래스의 메서드로, 여러 스레드가 아주 자주 더하는 통계는 LongAdder로 충분합니다. 여러 상태를 묶어 바꾸는 일은 락이 가장 이해하기 쉽고, 상태가 작고 불변으로 만들 수 있을 때만 AtomicReference를 검토합니다.
정리
- 충돌했을 때 락은 다른 스레드를 기다리게 하고, CAS는 진 스레드가 계산을 버리고 다시 하게 합니다. 고르는 기준은 이름이 아니라 이 비용이 얼마나 큰가입니다.
updateAndGet()같은 CAS 갱신에 넘긴 함수는 충돌할 때마다 다시 실행됩니다. 계산이 무겁고 충돌이 잦을수록 버리는 계산이 늘어납니다.- 공유 값에 의존하지 않는 계산은 락이나 CAS 밖에서 먼저 하고, 공유 값을 바꾸는 짧은 부분만 보호합니다.
- 락은 다른 스레드의 끼어들기를 막을 뿐, 예외가 났을 때 이미 바꾼 필드를 되돌리지 않습니다. 실패할 수 있는 검사와 계산을 먼저 끝내고 상태를 바꿉니다.
- 불변 객체를 새로 만들어 CAS로 바꾸면 중간에 실패해도 공유 상태가 그대로지만, 상태가 크면 바꿀 때마다 복사하는 비용이 듭니다.
- 성능은 같은 부하에서 처리량, 응답 시간 분포, 재시도 횟수를 함께 재서 판단합니다.
연습 문제
-
다음 네 경우에 각각 무엇을 쓰면 좋을지 고르고 이유를 설명하세요. (가) 웹 서버가 받은 요청 수를 여러 스레드가 함께 세는 카운터 (나) 주문 한 건마다 여러 상품의 재고와 고객의 포인트 잔액을 함께 바꾸는 작업 (다) 가끔 통째로 바뀌고 자주 읽히는 작은 불변 설정 객체 (라) 외부 결제 API를 호출해 결제가 성공하면 잔액을 줄이는 작업
-
ExpensiveCasUpdate의 무거운 계산은 공유 값current를 읽지 않습니다. 충돌이 잦아도 무거운 계산이 호출마다 정확히 한 번만 실행되도록increment()를 고치세요. -
입금만 할 수 있는 잔액이 있습니다. CAS의 성공과 실패 횟수를 세고, 실패율이 20%를 넘으면 경고를 출력하세요. 갱신 함수 안에서 세지 말고, 재시도 루프를 직접 써서 실패할 때만 셉니다.
해설 보기
1번. (가)는 값 하나를 늘리기만 하므로 AtomicLong이면 되고, 여러 스레드가 아주 자주 더하고 가끔 합계만 읽는다면 LongAdder가 더 알맞습니다. (나)는 여러 객체에 흩어진 상태를 함께 바꿔야 하므로 락이 알맞습니다. 이것을 CAS 하나로 하려면 재고와 잔액 전체를 불변 객체 하나로 묶어 주문마다 복사해야 하고, 충돌하면 그 복사도 다시 해야 합니다. (다)는 새 객체로 통째로 바꾸기만 하므로 volatile 필드나 AtomicReference 하나로 충분합니다. 이전 값을 읽어 새 값을 계산하는 복합 동작이 아니어서 재시도 루프 없이 새 객체를 쓰기만 하면 되고, volatile 쓰기는 읽는 스레드가 새 객체를 보게 해 줍니다. (라)는 결제 API 호출을 CAS 루프에도 락 안에도 넣으면 안 됩니다. 결제는 보호 구간 밖에서 한 번만 하고, 성공한 뒤에 잔액을 줄이는 짧은 갱신만 락이나 CAS로 합니다. 그사이에 잔액이 바뀌어 차감할 수 없게 되었다면 결제를 취소하는 식의 처리도 정해 둬야 합니다.
2번. 무거운 계산을 갱신 함수 밖으로 옮기고, 공유 값은 incrementAndGet()으로 늘립니다.
void increment() {
calculations.increment();
long checksum = 0;
for (int i = 0; i < 10_000; i++) {
checksum += i;
}
if (checksum < 0) throw new AssertionError();
value.incrementAndGet();
}이제 무거운 계산은 increment() 호출마다 정확히 한 번 실행됩니다. incrementAndGet()이 원자적으로 처리하는 것은 1을 더하는 짧은 연산뿐이므로, 다른 스레드와 부딪혀도 다시 하거나 기다리는 양이 아주 작습니다. calculations도 함수 밖에서 세므로 더는 부수 효과가 아닙니다. 고친 프로그램을 10번 실행해 10번 모두 updates=8000, calculations=8000이 출력되었습니다. 락으로 구현할 때도 마찬가지로 무거운 계산을 lock() 앞으로 옮기면, 락을 쥐고 있는 시간이 짧아져 다른 스레드가 덜 기다립니다.
3번. 성공과 실패를 따로 세는 LongAdder 두 개를 두고, CAS 결과에 따라 하나를 늘립니다.
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);
}
}실패율은 실패 횟수를 전체 시도 횟수(성공 + 실패)로 나눈 값입니다. 성공은 deposit() 호출마다 정확히 한 번이므로 successes는 언제나 80,000이고, failures만 경합에 따라 달라집니다. 세 값은 모든 작업자를 join()한 뒤에 읽으므로 더 바뀌지 않습니다. retryRate는 소수 셋째 자리까지 반올림해 보여 주지만, warn은 반올림하기 전의 값이 0.20을 넘는지로 정해집니다.
28번 실행했을 때 balance는 언제나 80000이었고, retryRate는 0.000부터 0.051까지 실행마다 달랐으며, warn은 한 번도 true가 되지 않았습니다. 다음은 그중 한 번의 출력입니다.
balance=80000 retryRate=0.010 warn=false코어가 더 많은 컴퓨터에서는 동시에 실행되는 스레드가 늘어 실패율이 훨씬 높게 나오고, warn=true가 될 수도 있습니다. 실제 서비스에서 이 값이 계속 높게 나온다면, 같은 부하를 걸어 락으로 구현한 버전과 처리량, 응답 시간 분포를 비교해 봅니다.
다음 글
ConcurrentHashMap과 동시성 컬렉션에서는 여러 스레드가 함께 쓰는 컬렉션으로 넘어갑니다. 메서드 하나하나는 안전한 컬렉션이라도 두 메서드를 이어서 호출하면 그사이에 다른 스레드가 끼어들 수 있다는 것을 확인하고, putIfAbsent()와 merge()처럼 확인과 변경을 한 번에 처리하는 메서드를 배웁니다.