synchronized·ReentrantLock은 이제 익숙하실 거예요. 이번 딥다이브는 락을 아예 걸지 않고 CAS만으로 스레드 안전을 만드는 세계를 파고듭니다. Treiber 스택·Michael-Scott 큐 같은 고전 알고리즘부터 ConcurrentLinkedQueue·LongAdder가 내부에서 하는 일, 그리고 ABA·재시도 폭증·false sharing 같은 함정까지 — "락-프리가 항상 정답은 아니다"의 감각을 잡습니다.
AtomicStampedReference로 푸는 법을 설명할 수 있어요.ConcurrentLinkedQueue·LongAdder·ConcurrentHashMap이 언제·왜 락-프리 기법을 쓰는지 알아요.뮤텍스 락은 단순하고 강력하지만 본질적으로 블로킹이에요. 한 스레드가 락을 쥔 채 스케줄에서 밀리거나(preempt), 페이지 폴트가 나거나, 심지어 죽으면 그 락을 기다리는 모든 스레드가 함께 멈춰요. 락 하나의 장애가 시스템 전체 진행을 막는 셈이죠. 락-프리는 "어떤 스레드가 어느 순간 멈춰도, 시스템 전체는 계속 전진한다"를 목표로 합니다.
| 락의 문제 | 무슨 일이 벌어지나 |
|---|---|
| 블로킹 | 락을 못 얻은 스레드는 대기. 락 보유자가 느리면 대기자도 함께 느려짐 |
| 컨텍스트 스위치 비용 | park/unpark로 스레드가 재웠다 깨면 스케줄러·캐시 오염 비용 발생 |
| 우선순위 역전 | 낮은 우선순위 스레드가 락을 쥐면 높은 우선순위 스레드가 대기 |
| 데드락 | 여러 락을 서로 다른 순서로 잡으면 순환 대기로 영구 정지 |
| 호송 효과(convoy) | 느린 락 보유자 뒤로 줄이 길게 늘어서 처리량이 급락 |
synchronized 키워드를 안 쓴다는 뜻이 아니라, 어느 스레드가 임의 지점에서 멈춰도 다른 스레드의 진행이 막히지 않는다는 진행 보장(progress guarantee)이 핵심이에요. 그래서 스핀락은 락이란 이름이 없어도 락-프리가 아닙니다(보유자가 멈추면 대기자가 영원히 돎).락-프리의 심장은 CAS(Compare-And-Swap)예요. "이 메모리가 내가 본 값(expected)이 맞으면 새 값으로 바꾸고, 아니면 실패를 알려라"를 원자적으로 수행하는 CPU 명령(CMPXCHG)이죠. 자바에선 AtomicInteger.compareAndSet(expect, update) 같은 형태로 노출돼요.
AtomicInteger v = new AtomicInteger(0);
// "지금 값이 0이면 1로 바꿔" — 성공하면 true
boolean ok = v.compareAndSet(0, 1);
// 락-프리 증가의 전형: 읽고 → 계산 → CAS, 실패하면 재시도
int cur, next;
do {
cur = v.get();
next = cur + 1;
} while (!v.compareAndSet(cur, next)); // 경합에서 밀리면 다시 시도
해결의 핵심은 값에 버전(스탬프)을 붙여 "값+버전"을 함께 비교하는 거예요. 값이 A로 돌아와도 버전은 증가했으니 CAS가 옛 스냅샷을 거부합니다. 자바는 AtomicStampedReference<V>(int 스탬프)와 AtomicMarkableReference<V>(boolean 마크)를 제공해요.
// 참조 + int 스탬프를 한 번에 CAS → ABA 차단
AtomicStampedReference<Node> ref =
new AtomicStampedReference<>(head, 0);
int[] stampHolder = new int[1];
Node cur = ref.get(stampHolder); // 값과 스탬프를 함께 스냅샷
int stamp = stampHolder[0];
// 값이 cur이고 && 스탬프도 그대로일 때만 성공. 성공 시 스탬프 +1
boolean ok = ref.compareAndSet(cur, next, stamp, stamp + 1);
free()해도 되나?"라는 무서운 문제(use-after-free)를 hazard pointer·RCU·epoch 같은 기법으로 직접 풀어야 해요. 자바는 GC가 있어서 아직 참조가 남은 노드는 절대 회수되지 않아요 — 이 문제 상당 부분이 자동으로 사라집니다. 다만 ABA(논리적 재사용) 자체는 GC로도 안 사라져서 스탬프가 여전히 필요합니다.Treiber 스택(1986)은 락-프리 자료구조의 "Hello World"예요. 단일 연결 리스트의 head 포인터 하나만 CAS로 갈아끼우면 push/pop이 됩니다. push는 새 노드의 next를 현재 head로 걸어두고 head를 새 노드로 CAS, pop은 head를 head.next로 CAS하죠.
class TreiberStack<E> {
static final class Node<E> {
final E item; Node<E> next;
Node(E item){ this.item = item; }
}
private final AtomicReference<Node<E>> head = new AtomicReference<>();
void push(E item) {
Node<E> n = new Node<>(item);
Node<E> cur;
do {
cur = head.get();
n.next = cur; // 새 노드를 현재 head 위에 얹고
} while (!head.compareAndSet(cur, n)); // head를 n으로 교체 (실패 시 재시도)
}
E pop() {
Node<E> cur, next;
do {
cur = head.get();
if (cur == null) return null; // 비어 있음
next = cur.next;
} while (!head.compareAndSet(cur, next)); // head를 next로 교체
return cur.item;
}
}
pop에서 cur=A, next=A.next(=B)까지 읽고 잠깐 멈춘 사이, T2가 A와 B를 pop한 뒤 같은 객체 A를 다시 push하면 head는 또 A가 돼요. 깨어난 T1의 compareAndSet(A, B)는 "head가 A네" 하고 성공하지만, 이제 B는 이미 리스트 밖이라 스택이 깨집니다. 그래서 실전 락-프리 스택은 스탬프/버전을 함께 CAS해요. (자바 GC는 use-after-free는 막아주지만, 이 논리적 ABA는 못 막습니다.)큐는 스택보다 어려워요. 양 끝(head=dequeue, tail=enqueue)을 각각 갱신해야 하니까요. Michael & Scott(1996) 큐는 더미(sentinel) 노드를 두고, enqueue를 두 번의 CAS로 나눠 우아하게 해결합니다.
핵심 통찰은 tail이 "뒤처질(lagging)" 수 있다는 걸 정상으로 허용하는 거예요. ①에서 next 연결에는 성공했지만 ②의 tail 전진 전에 스레드가 멈춰도 큐는 일관성 있는 중간 상태에 있어요. 다른 스레드가 tail.next != null인 걸 발견하면, 자기 일을 하기 전에 남의 tail을 대신 전진시켜(helping) 줍니다. 어느 한 스레드가 멈춰도 전체는 전진하는 락-프리의 전형이에요.
// Michael-Scott enqueue의 뼈대 (개념 코드)
void enqueue(E item) {
Node<E> n = new Node<>(item);
while (true) {
Node<E> last = tail.get();
Node<E> next = last.next.get();
if (last == tail.get()) { // tail이 그대로인지 재확인
if (next == null) { // 진짜 마지막이면
if (last.next.compareAndSet(null, n)) { // ① next 연결
tail.compareAndSet(last, n); // ② tail 전진(실패해도 OK)
return;
}
} else {
tail.compareAndSet(last, next); // 남이 뒤처뜨린 tail을 도와서 전진
}
}
}
}
ConcurrentLinkedQueue가 바로 이 알고리즘의 후예예요. Doug Lea가 Michael-Scott 큐를 기반으로, tail을 매번 갱신하지 않고 여러 노드마다 한 번씩만 전진시키는(느슨한 tail) 최적화 등을 얹어 구현했어요. 그래서 ConcurrentLinkedQueue는 락-프리·무제한(unbounded) FIFO이고, size()가 O(n)에 근사값이라는 특성도 이 구조에서 나옵니다.여기까지 읽고 "멋지다, 직접 만들어봐야지"라는 마음이 들 수 있어요. 하지만 실무의 정답은 대개 "직접 만들지 마라"입니다. 락-프리 자료구조는 메모리 순서·ABA·재시도·false sharing이 얽혀 정확히 짜기가 극도로 어렵고 테스트도 힘들어요. Doug Lea가 수년간 다듬은 java.util.concurrent를 쓰세요.
| 클래스 | 내부 성격 | 언제 쓰나 |
|---|---|---|
ConcurrentLinkedQueue | 락-프리 FIFO(Michael-Scott 기반), 무제한 | 고성능 논블로킹 큐. 생산/소비가 많고 대기가 필요 없을 때 |
ConcurrentLinkedDeque | 락-프리 양방향(deque) | 양끝 삽입/삭제가 필요한 논블로킹 워크로드 |
LongAdder / LongAccumulator | 스트라이핑(striped) — 셀을 여러 개로 쪼개 경합 분산 | 고경합 카운터. 값을 자주 더하고 가끔 읽을 때(AtomicLong보다 훨씬 빠름) |
ConcurrentHashMap | CAS + 락 혼합 — 빈 버킷은 CAS, 충돌 시 버킷 단위 synchronized | 범용 동시성 맵의 기본값 |
ConcurrentSkipListMap/Set | 락-프리 skip list(정렬 유지) | 동시성 + 정렬이 필요할 때 |
AtomicLong은 단 하나의 변수를 모든 스레드가 CAS해요. 경합이 높으면 CAS가 계속 실패·재시도하며 하나의 캐시라인을 두고 핑퐁이 벌어집니다. LongAdder는 값을 여러 셀(Cell)로 분산해 스레드마다 다른 셀을 갱신하게 하고, 읽을 때 sum()으로 합쳐요.
그래서 쓰기가 압도적으로 많고 읽기는 가끔인 카운터·통계 지표에 이상적이에요. 대신 sum()은 순간적으로 정확한 스냅샷이 아닐 수 있고, 셀만큼 메모리를 더 씁니다.
ConcurrentLinkedQueue가 아니라 ArrayBlockingQueue·LinkedBlockingQueue 같은 블로킹 큐를 쓰세요 — 락-프리 큐는 대기 기능이 없어요. 즉 "논블로킹 = 항상 좋다"가 아니라 필요한 시맨틱(대기·유계·공정성)에 맞춰 고르는 게 맞습니다.LongAdder의 스트라이핑)하는 겁니다.
그래서 경합이 극심하면 잘 만든 락(park/unpark로 대기하며 CPU를 양보)이 바쁘게 도는 락-프리보다 총 처리량이 더 나을 수도 있어요.
@Contended(내부용)나 패딩으로 셀을 캐시라인 단위로 떼어 이 문제를 피합니다(LongAdder의 Cell이 그렇게 패딩돼 있어요).new ReentrantLock(true))이 필요한 요구사항엔 맞지 않아요. ③ 여러 변수를 원자적으로 함께 바꿔야 할 때 — 단일 CAS로는 표현이 어렵습니다(그래서 락이나 STM을 씀).AtomicStampedReference)로 해결. 자바 GC는 use-after-free는 막지만 ABA는 못 막음.ConcurrentLinkedQueue의 뿌리.ConcurrentLinkedQueue/Deque·LongAdder(스트라이핑)·ConcurrentHashMap(CAS+락 혼합).