9. 락프리 스택의 ABA 문제
난이도 상해설 — 락프리 스택의 ABA 문제
난이도: 상
요약
이 락프리 스택은 고전적인 ABA 문제에 취약하다. Pop이 oldHead를 읽고 oldHead.Next를 떼어내 Interlocked.CompareExchange로 head를 바꾸려는 사이에, 다른 스레드가 그 노드 A를 Pop했다가(혹은 다른 노드 B도 거쳐) 다시 Push하면, head 참조가 A → B → A로 돌아온다. 그러면 첫 스레드의 CAS는 "head가 여전히 A다(같은 참조)"라고 보고 성공하지만, 그 사이 A의 Next는 이미 달라졌을 수 있어 head를 stale한 Next(=이미 빠진 노드)로 덮어쓴다 → 노드 유실, 같은 노드 이중 대여, 리스트 손상. CompareExchange는 참조 동일성(reference identity)만 비교하지 "그동안 안 바뀌었는지"를 모른다. 해법: 버전 카운터(태그)를 묶은 CAS, 또는 노드를 매번 새로 할당해 재사용을 막기 등.
C++판은 같은 ABA지만 노드를
delete하면 use-after-free/이중해제까지 겹친다. C#은 추적 GC 덕분에 use-after-free는 없다(노드가 아직 참조되면 회수 안 됨). 그러나 이 코드처럼 노드를 고정 풀에 재활용하면 같은 참조가 다시 head로 돌아오는 "값 재현(value recurrence)" ABA가 그대로 발생해 리스트 무결성이 깨진다. 즉 C#에서 ABA는 메모리 안전성이 아니라 자료구조 정확성 문제로 나타난다.
핵심 시퀀스: ABA가 터지는 순간
프리리스트 head = A → B → C 라 하자.
- 스레드 1:
Pop시작.oldHead = A읽음.next = A.Next = B읽음(아직 CAS 전). - 스레드 1 멈춤(선점).
- 스레드 2:
PopA (head = B),PopB (head = C), 그리고 A를 다시 Push (head = A, 단A.Next = C로 바뀜). - 이제 head = A 지만 리스트는 A → C.
- 스레드 1 재개:
CompareExchange(ref _head, next=B, oldHead=A). head가 여전히 A(같은 참조) 라서 성공 → head = B. - 하지만 B는 이미 스레드 2가 대여해 쓰고 있는 노드다. 리스트가 깨지고 C가 유실되며 B는 이중 대여된다.
CompareExchange는 5번에서 "참조가 A로 같다"만 확인했을 뿐, 그 사이 두 번의 변경(A→B→...→A)을 감지하지 못한다. 이것이 ABA다.
문제점
(A) n.Next = oldHead 후 참조-CAS만으로 Push (분류: 동시성)
- 증상: Push 자체는 단독으론 맞지만, 노드가 "빠졌다 다시 들어오는" 재순환을 허용해 ABA의 B(되돌아옴)를 만든다.
- 근본원인: head를 참조 값으로만 식별한다. 같은 노드 인스턴스가 재사용되면 "다른 시점인데 같은 참조"가 되어, Pop 측 CAS가 변화를 구분 못 한다. Push 단독 버그는 아니지만 ABA 성립의 한 축. (풀 재사용이 ABA의 전제다.)
(B) oldHead 관측과 CAS 사이의 시간차 (분류: 동시성, 핵심)
- 증상: 관측-결정 사이에 다른 스레드가 노드를 Pop/Push해 head 참조를 원위치시키면, CAS가 오탐 성공.
- 근본원인: "read head → 계산(Next) → CAS"는 원자적 한 덩어리가 아니다. 그 틈에 ABA가 끼어든다. 참조 비교 CAS는 이 틈의 변화를 감지할 수 없다.
(C) Node<T> next = oldHead.Next; — stale Next 역참조 (분류: 동시성/정확성, 가장 위험)
- 증상:
oldHead가 그 사이 다른 스레드에 대여돼Next가 바뀌었다면, 여기서 읽는next는 stale이다. CAS가 (B)로 성공하면 그 stale 참조가 head가 된다. - 근본원인: 다른 스레드가 동시에 그 노드의
Next를 만지는데, "내가 보는 동안 이 노드의 Next가 안 바뀐다"는 보장이 없다. C#은 GC라 객체 자체는 살아있어 크래시는 안 나지만, 잘못된next로 head를 갱신하면 리스트 구조가 손상된다(노드 유실/이중 대여).
Interlocked.CompareExchange는 메모리 배리어(full fence) 의미를 가지므로 이 문제는 메모리 순서(가시성) 문제가 아니라 ABA 문제다. 배리어를 더 강하게 해도 ABA는 안 풀린다.
수정안
방법 1: 버전 태그를 묶은 CAS — ABA 직접 차단
head를 "노드 참조 + 단조 증가 버전"으로 함께 다뤄, 값이 A로 돌아와도 버전이 달라 CAS가 실패하게 한다. C#엔 128비트 CAS가 없으므로, head를 불변 구조체(참조+버전) 로 만들고 Interlocked.CompareExchange<HeadRef>(참조 타입 박스) 또는 인덱스+버전을 long에 패킹한다.
가장 깔끔한 길은 인덱스 기반 + (index, tag)를 64비트 long에 패킹해 일반 Interlocked.CompareExchange(ref long, ...)로 처리하는 것이다(아래 더 나은 설계 2번).
참조를 유지해야 하면 head 상태를 불변 클래스로 감싸 교체한다:
using System.Threading;
public sealed class LockFreeStack<T>
{
// head 상태: 최상위 노드 + 버전. 불변 객체를 통째로 교체한다.
private sealed class State { public Node<T> Head; public long Tag; }
private State _state = new State { Head = null, Tag = 0 };
public void Push(Node<T> n)
{
State cur, next;
do
{
cur = Volatile.Read(ref _state);
n.Next = cur.Head;
next = new State { Head = n, Tag = cur.Tag + 1 }; // 버전 증가
}
while (Interlocked.CompareExchange(ref _state, next, cur) != cur);
}
public Node<T> Pop()
{
State cur, next;
Node<T> head;
do
{
cur = Volatile.Read(ref _state);
head = cur.Head;
if (head == null) return null;
next = new State { Head = head.Next, Tag = cur.Tag + 1 };
}
while (Interlocked.CompareExchange(ref _state, next, cur) != cur);
return head; // 성공
}
}
State를 매번 새로 할당하므로 CAS가 비교하는 참조가 절대 재현되지 않아 ABA가 사라진다(버전까지 더하면 이중 방어). 트레이드오프: 매 연산마다 State 객체 할당 → GC 압박. 핫패스엔 부담.
방법 2: 인덱스 + 태그 패킹 (할당 0, 권장 — 풀 전용)
노드를 고정 배열로 두고, head를 (int index, int tag)로 하나의 long에 패킹한다. Interlocked.CompareExchange(ref long head, ...)로 ABA(태그)까지 막으며 객체 할당이 0이다.
// head_ 의 상위 32비트 = tag(버전), 하위 32비트 = node index (-1 = null)
long packed = ((long)(tag + 1) << 32) | (uint)nodeIndex;
값이 같은 인덱스로 돌아와도 태그가 달라 CAS가 실패한다. 게임서버 오브젝트 풀에 잘 맞는다. 트레이드오프: 최대 노드 수 32비트로 제한, 인덱스↔객체 매핑 배열 필요.
방법 3: 검증된 동시성 컬렉션
직접 짜지 말고 System.Collections.Concurrent.ConcurrentStack<T>(내부적으로 ABA에 안전한 락프리 구현)나 ConcurrentBag<T>/ObjectPool<T>(MS.Extensions.ObjectPool)를 쓴다. 가장 안전하고 검증됨.
더 나은 설계
-
검증된 라이브러리 사용(권장):
ConcurrentStack<T>,ConcurrentBag<T>,Microsoft.Extensions.ObjectPool. 직접 락프리는 ABA·가시성이 얽혀 버그 표면이 넓다. -
풀 전용이면 인덱스 기반 + 고정 배열: 위 방법 2처럼
(index, tag)를 64비트에 패킹하면 일반Interlocked로 ABA까지 막고 할당 0. 게임서버 핫패스 오브젝트 풀의 정석. 트레이드오프: 최대 노드 수 고정. -
경합이 낮으면 그냥 락: Push/Pop이 아주 짧으면
lock/스핀락이 락프리보다 단순하고 충분히 빠를 수 있다. "락프리=항상 빠름"은 오해. 트레이드오프: 고경합에서 확장성 저하. -
GC가 ABA를 일부 가려준다는 함정: C#은 GC라 노드를 free하지 않아 use-after-free는 없지만, 풀 재사용 시 ABA에 의한 리스트 손상은 그대로다. "GC가 있으니 락프리가 안전하다"는 잘못된 결론. 노드를 재사용하지 않고 매번 새로 할당하면 참조 재현이 없어 ABA가 사라지지만(방법 1 변형), 그건 풀의 목적(할당 절감)과 모순된다.
면접 포인트
- ABA 문제를 정의하고, 락프리 스택에서 터지는 정확한 인터리빙을 단계별로 설명하라.
Interlocked.CompareExchange가 왜 ABA를 막지 못하는가(참조 동일성만 비교)? - C++의 ABA(use-after-free 동반)와 C#의 ABA(GC로 메모리 안전, 그러나 리스트 손상)의 차이는? GC가 ABA를 "완전히" 막아주지 못하는 이유는?
- ABA 해결 기법(버전 태그 묶은 CAS, 인덱스+태그 패킹, 검증된 동시성 컬렉션)의 차이와 트레이드오프는? 태그 비트가 좁으면 무슨 문제가 생기나(랩어라운드)? C#에서 128비트 CAS 부재를 어떻게 우회하나?
해설 — 락프리 스택의 ABA 문제
난이도: 상
요약
이 락프리 스택은 고전적인 ABA 문제에 취약하다. Pop이 oldHead를 읽고 oldHead->next를 떼어내 CAS로 head를 바꾸려는 사이에, 다른 스레드가 그 노드 A를 Pop했다가(혹은 다른 노드 B도 거쳐) 다시 Push하면, head 포인터 값이 A → B → A로 돌아온다. 그러면 첫 스레드의 compare_exchange는 "head가 여전히 A다"라고 보고 성공하지만, 그 사이 A의 next는 이미 달라졌을 수 있어 head를 stale한 next(=이미 빠진 노드 또는 해제된 메모리)로 덮어쓴다 → 노드 유실, 같은 노드 이중 대여, use-after-free. CAS는 "포인터 값"만 비교하지 본질적으로 "그동안 안 바뀌었는지"를 모른다. 해법: 태그된 포인터(버전 카운터), hazard pointer, 또는 epoch 기반 회수.
핵심 시퀀스: ABA가 터지는 순간
프리리스트 head = A → B → C 라 하자.
- 스레드 1:
Pop시작.oldHead = A읽음.next = A->next = B읽음(아직 CAS 전). - 스레드 1 멈춤(선점).
- 스레드 2:
PopA (head = B),PopB (head = C), 그리고 A를 다시 Push (head = A, 단A->next = C로 바뀜). - 이제 head = A 지만 리스트는 A → C.
- 스레드 1 재개:
compare_exchange(oldHead=A, next=B). head가 여전히 A 라서 성공 → head = B. - 하지만 B는 이미 스레드 2가 대여해 쓰고 있는/해제된 노드다. 리스트가 깨지고 C가 유실되며 B는 이중 대여된다.
CAS는 5번에서 "값이 A로 같다"만 확인했을 뿐, 그 사이 두 번의 변경(A→B→...→A)을 감지하지 못한다. 이것이 ABA다.
문제점
(A) n->next = oldHead 후 값-CAS만으로 Push (분류: 동시성)
- 증상: Push 자체는 단독으론 맞지만, 노드가 "빠졌다 다시 들어오는" 재순환을 허용해 ABA의 B(되돌아옴)를 만든다.
- 근본원인: head를 포인터 값으로만 식별한다. 같은 주소의 노드가 재사용되면 "다른 시점인데 같은 값"이 되어, Pop 측 CAS가 변화를 구분 못 한다. Push 단독 버그는 아니지만 ABA 성립의 한 축.
(B) oldHead 관측과 CAS 사이의 시간차 (분류: 동시성, 핵심)
- 증상: 관측-결정 사이에 다른 스레드가 노드를 Pop/Push해 head 값을 원위치시키면, CAS가 오탐 성공.
- 근본원인: "load → 계산(next) → CAS"는 원자적 한 덩어리가 아니다. 그 틈에 ABA가 끼어든다. 값 비교 CAS는 이 틈의 변화를 감지할 수 없다.
(C) Node* next = oldHead->next; — stale/해제 노드 역참조 (분류: 동시성/메모리, 가장 위험)
- 증상:
oldHead가 그 사이 다른 스레드에 대여돼 값이 바뀌었거나 해제/반환됐다면, 여기서 읽는next는 쓰레기다. CAS가 (B)로 성공하면 그 쓰레기가 head가 된다. 노드가 진짜 free된다면 이 역참조 자체가 use-after-free. - 근본원인: 다른 스레드가 동시에 그 노드를 만지는데, "내가 보는 동안 이 노드가 살아있고 안 바뀐다"는 보장이 없다. 락프리에서 "안전한 메모리 회수(safe reclamation)"가 없으면 역참조가 근본적으로 위험하다.
메모리 순서(acquire/release)는 대체로 맞게 달려 있다 — 이 문제는 순서가 아니라 ABA/회수 문제다. 배리어를 더 강하게 해도 ABA는 안 풀린다.
수정안
방법 1: 태그된 포인터 (버전 카운터) — ABA 직접 차단
포인터에 단조 증가 버전을 묶어 CAS가 "포인터+버전"을 함께 비교하게 한다. 값이 A로 돌아와도 버전이 달라 CAS가 실패한다.
#include <atomic>
#include <cstdint>
template <typename T>
class LockFreeStack
{
public:
struct Node { T value; Node* next; };
void Push(Node* n)
{
TaggedHead old = head_.load(std::memory_order_relaxed);
TaggedHead neu;
do {
n->next = old.ptr;
neu.ptr = n;
neu.tag = old.tag + 1; // 버전 증가
} while (!head_.compare_exchange_weak(
old, neu,
std::memory_order_release,
std::memory_order_relaxed));
}
Node* Pop()
{
TaggedHead old = head_.load(std::memory_order_acquire);
TaggedHead neu;
while (old.ptr != nullptr)
{
neu.ptr = old.ptr->next; // (C) 회수 안전성은 별도 필요(아래 주의)
neu.tag = old.tag + 1;
if (head_.compare_exchange_weak(
old, neu,
std::memory_order_acquire,
std::memory_order_acquire))
return old.ptr;
}
return nullptr;
}
private:
struct TaggedHead { Node* ptr = nullptr; std::uint64_t tag = 0; };
std::atomic<TaggedHead> head_{}; // 16바이트 → DWCAS(cmpxchg16b) 필요
static_assert(std::atomic<TaggedHead>::is_always_lock_free
|| true, "needs 128-bit CAS for lock-free");
};
주의: TaggedHead가 16바이트라 lock-free가 되려면 128비트 CAS(x86-64 cmpxchg16b, ARMv8 casp) 가 필요하다. 컴파일러/하드웨어 지원과 16바이트 정렬을 확인해야 한다(-mcx16). 태그가 좁으면(예: 포인터 정렬 비트 재활용 16비트 태그) 일반 64비트 CAS로도 가능하지만, 태그 랩어라운드 시 ABA가 재발한다.
태그된 포인터는 "값이 되돌아오는 ABA"는 막지만, (C)의 노드가 free되어 메모리가 사라지는 경우의 use-after-free는 막지 못한다. 노드 메모리를 절대 OS에 반환하지 않고 풀에서만 재활용하면(이 데모처럼 고정
storage) 역참조는 안전하다. 일반 동적 회수에는 아래 방법이 필요.
방법 2: Hazard Pointer — 안전한 메모리 회수
각 스레드가 "지금 보고 있는 노드"를 hazard pointer로 발표한다. 회수 측은 어떤 hazard pointer도 가리키지 않는 노드만 실제 free한다. ABA와 use-after-free를 함께 해결.
방법 3: Epoch-based reclamation (RCU 류)
스레드가 임계영역에 들어간 epoch를 기록하고, 모든 스레드가 지나간 epoch의 노드만 회수. 처리량이 높고 hazard pointer보다 단순한 경우가 많다.
더 나은 설계
-
검증된 라이브러리 사용(권장):
boost::lockfree::stack(내부적으로 tagged pointer/freelist), Folly의 hazard pointer, libcds 등. 직접 짠 락프리는 ABA·회수·메모리 순서가 얽혀 버그 표면이 넓다. -
풀 전용이면 인덱스 기반 + 고정 배열: 노드를 동적 할당하지 말고 고정 배열의 인덱스로 다루면, "메모리 free" 자체가 없어 use-after-free가 사라진다. head를
(index, tag)로 64비트에 패킹하면 일반 CAS로 ABA까지 막을 수 있다(인덱스 24비트 + 태그 40비트 등). 게임서버 오브젝트 풀에 잘 맞는다. 트레이드오프: 최대 노드 수 고정. -
경합이 낮으면 그냥 락: Pop/Push가 아주 짧으면 스핀락/뮤텍스가 락프리보다 단순하고 충분히 빠를 수 있다. "락프리=항상 빠름"은 오해. 트레이드오프: 고경합에서 확장성 저하.
-
DWCAS 가용성 검증: tagged pointer를 쓸 거면 타깃에서
cmpxchg16b/casp가 lock-free인지is_always_lock_free로 확인. 없으면 라이브러리가 내부 락으로 폴백해 "락프리"가 아니게 된다.
면접 포인트
- ABA 문제를 정의하고, 락프리 스택에서 터지는 정확한 인터리빙을 단계별로 설명하라. CAS가 왜 ABA를 막지 못하는가?
- ABA 해결 기법 3가지(태그된 포인터/DWCAS, hazard pointer, epoch/RCU)의 차이와 트레이드오프는? 태그 비트가 좁으면 무슨 문제가 생기나(랩어라운드)?
- 락프리에서 "안전한 메모리 회수(safe memory reclamation)"가 왜 별도 문제인가? 태그된 포인터만으로 use-after-free가 안 풀리는 이유와, 풀/인덱스 기반 설계가 이를 어떻게 회피하는지 설명하라.