Workspace IndexAlgorithms › Day 37

Lock-Free and Wait-Free Algorithms, the ABA Problem, Hazard Pointers and Epoch Reclamation TODO

Algorithms · Day 37 / 100 · C. Concurrency & Performance Engineering (Day 36-51)

Concept

Non-blocking algorithms are classified by the strength of progress guarantee they offer. Lock-free guarantees that even if some thread stalls, the system as a whole always has someone making progress; wait-free guarantees every thread finishes its own operation within a bounded number of steps; obstruction-free guarantees only that an operation finishes once contention disappears. Most of these are built on an atomic read-modify-write like CAS, and that's where the ABA problem comes from: if a location's value changes from A to B and back to A, CAS sees "no change" and succeeds, even though the data structure's actual state has changed underneath it. The fix is a tagged pointer — attaching a version counter to the pointer — and, more fundamentally, a safe memory reclamation scheme. Hazard pointers have each thread publish the pointer it's currently referencing so other threads can't free that node; epoch-based reclamation keeps a global epoch and batches up freeing nodes from epochs every thread has already passed.

If you write or use a lock-free queue or map and skip handling ABA and memory reclamation, you get use-after-free and data corruption that are extremely hard to reproduce and that only surface in production.

Code & Formula

# 락프리 CAS 와 ABA 문제 — 값이 A->B->A 로 되돌아오면 순진한 CAS 는 "안 변했다"고 착각한다.
# 해결책: 포인터에 버전(태그)을 붙여, 값이 같아도 버전이 다르면 CAS 가 실패하게 만든다.

import threading

lock = threading.Lock()

def cas_naive(cell, expected, new):
    """cell[0] 값만 비교하는 순진한 CAS — ABA 에 취약."""
    with lock:
        if cell[0] == expected:
            cell[0] = new
            return True
        return False

def cas_tagged(cell, expected_value, expected_version, new_value):
    """(값, 버전) 쌍을 함께 비교하는 태그드 포인터 CAS — 값이 되돌아와도 버전은 못 되돌린다."""
    with lock:
        if cell[0] == expected_value and cell[1] == expected_version:
            cell[0] = new_value
            cell[1] += 1
            return True
        return False

# --- ABA 시나리오: 순진한 CAS ---
cell = ["A", 0]  # [value, version] 이지만 naive CAS 는 version 을 무시
read_value = cell[0]                 # 스레드1이 "A" 를 읽었다(포인터를 들고 대기 중이라 가정)
cell[0] = "B"                        # 다른 스레드가 A -> B 로 바꿨다가
cell[0] = "A"                        # 다시 A 로 되돌려놓았다 (스택으로 치면 pop/push/pop/push)
naive_ok = cas_naive(cell, read_value, "C")   # 스레드1은 "안 변했네" 하고 착각 -> 성공해버림 (버그)
print("naive CAS after A->B->A round-trip succeeded:", naive_ok, "  <- ABA 로 인한 잘못된 성공")

# --- 같은 시나리오를 태그드 포인터로 방어 ---
cell2 = ["A", 0]
read_value2, read_version2 = cell2[0], cell2[1]   # 스레드1이 값과 버전을 함께 읽음
cell2[0] = "B"; cell2[1] += 1                      # A -> B, version 0 -> 1
cell2[0] = "A"; cell2[1] += 1                      # B -> A, version 1 -> 2 (값은 되돌아왔지만 버전은 못 돌아옴)
tagged_ok = cas_tagged(cell2, read_value2, read_version2, "C")
print("tagged CAS after A->B->A round-trip succeeded:", tagged_ok, "  <- 버전 불일치로 정확히 거부됨")
print("final tagged cell state:", cell2)

Exercise

Implement a Treiber stack with CAS and deliberately trigger ABA, then add a tagged-pointer version and an epoch-reclamation version, and compare throughput and how much memory ends up in delayed-free state under contention.

Practical Connection

In Verex's off-chain order book matching engine, where multiple threads update the same price-level structure on a low-latency path, trying to eliminate lock contention can walk you straight into this trap.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

락프리·wait-free, ABA 문제, 해저드 포인터·에포크 회수 TODO

Algorithms · Day 37 / 100 · C. 동시성·성능 엔지니어링 (Day 36–51)

개념

논블로킹 알고리즘은 진행 보장의 강도로 나뉜다. lock-free는 어떤 스레드가 멈춰도 시스템 전체로는 누군가 반드시 전진함을 보장하고, wait-free는 모든 스레드가 유한한 단계 안에 자기 연산을 끝냄을 보장하며, obstruction-free는 경합이 사라지면 끝남만 보장한다. 대부분 CAS 같은 원자적 read-modify-write 위에 만들어지는데, 여기서 ABA 문제가 생긴다. 어떤 위치의 값이 A에서 B로 바뀌었다가 다시 A가 되면 CAS는 '변한 적 없다'고 착각해 성공하지만 실제 자료구조 상태는 달라져 있을 수 있다. 해결책은 포인터에 버전 카운터를 붙이는 태그드 포인터, 그리고 더 근본적으로는 안전한 메모리 회수 기법이다. 해저드 포인터는 각 스레드가 지금 참조 중인 포인터를 공개해 다른 스레드가 그 노드를 해제하지 못하게 하고, 에포크 기반 회수는 전역 에포크를 두어 모든 스레드가 지나간 에포크의 노드만 일괄 해제한다.

락프리 큐나 맵을 직접 쓰거나 만들 때 ABA와 메모리 회수를 빠뜨리면 재현이 극히 어려운 use-after-free와 데이터 손상이 프로덕션에서만 터진다.

코드 · 수식

# 락프리 CAS 와 ABA 문제 — 값이 A->B->A 로 되돌아오면 순진한 CAS 는 "안 변했다"고 착각한다.
# 해결책: 포인터에 버전(태그)을 붙여, 값이 같아도 버전이 다르면 CAS 가 실패하게 만든다.

import threading

lock = threading.Lock()

def cas_naive(cell, expected, new):
    """cell[0] 값만 비교하는 순진한 CAS — ABA 에 취약."""
    with lock:
        if cell[0] == expected:
            cell[0] = new
            return True
        return False

def cas_tagged(cell, expected_value, expected_version, new_value):
    """(값, 버전) 쌍을 함께 비교하는 태그드 포인터 CAS — 값이 되돌아와도 버전은 못 되돌린다."""
    with lock:
        if cell[0] == expected_value and cell[1] == expected_version:
            cell[0] = new_value
            cell[1] += 1
            return True
        return False

# --- ABA 시나리오: 순진한 CAS ---
cell = ["A", 0]  # [value, version] 이지만 naive CAS 는 version 을 무시
read_value = cell[0]                 # 스레드1이 "A" 를 읽었다(포인터를 들고 대기 중이라 가정)
cell[0] = "B"                        # 다른 스레드가 A -> B 로 바꿨다가
cell[0] = "A"                        # 다시 A 로 되돌려놓았다 (스택으로 치면 pop/push/pop/push)
naive_ok = cas_naive(cell, read_value, "C")   # 스레드1은 "안 변했네" 하고 착각 -> 성공해버림 (버그)
print("naive CAS after A->B->A round-trip succeeded:", naive_ok, "  <- ABA 로 인한 잘못된 성공")

# --- 같은 시나리오를 태그드 포인터로 방어 ---
cell2 = ["A", 0]
read_value2, read_version2 = cell2[0], cell2[1]   # 스레드1이 값과 버전을 함께 읽음
cell2[0] = "B"; cell2[1] += 1                      # A -> B, version 0 -> 1
cell2[0] = "A"; cell2[1] += 1                      # B -> A, version 1 -> 2 (값은 되돌아왔지만 버전은 못 돌아옴)
tagged_ok = cas_tagged(cell2, read_value2, read_version2, "C")
print("tagged CAS after A->B->A round-trip succeeded:", tagged_ok, "  <- 버전 불일치로 정확히 거부됨")
print("final tagged cell state:", cell2)

연습

Treiber 스택을 CAS로 구현해 ABA가 발생하도록 유도한 뒤, 태그드 포인터 버전과 에포크 회수 버전을 각각 붙여 경합 상황에서 처리량과 메모리 지연 해제량을 비교하라.

실무 · Verex 연결

Verex의 오프체인 오더북 매칭 엔진처럼 다수 스레드가 같은 가격 레벨 구조를 갱신하는 저지연 경로에서, 락 경합을 없애려다 이 함정에 그대로 걸릴 수 있다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 36. 메모리 모델과 원자성 순서38. RCU →