한양과학기술고등학교

에듀테크 프로젝트 대시보드

비교-교환 (CAS)과 락-프리

비교-교환 (Compare-And-Swap)

원자적 CAS 명령 · 읽기-계산-교환 재시도 루프 · 락-프리와 ABA 문제  |  잠금 없이 공유 데이터를 안전하게 고치는 법

여러 스레드가 같은 변수 하나를 동시에 고치면 값이 유실됩니다(경쟁 상태). 가장 흔한 해결책은 잠금(lock)이지만, 잠금은 데드락· 우선순위 역전·대기 시간이라는 비용이 있습니다. 비교-교환(CAS)은 CPU가 제공하는 단 하나의 원자적 명령으로, 잠금 없이도 "내가 읽은 값이 아직 그대로일 때만 바꾼다"를 보장합니다. 현대의 카운터, 큐, 스케줄러, 가비지 컬렉터가 모두 이 명령 위에 서 있습니다.

1. CAS 명령이 하는 일

주소 addr 기대값 expected 새 값 new ──▶ CAS(addr, expected, new)

CAS는 아래 세 단계를 중간에 누구도 끼어들 수 없게(원자적으로) 한 번에 처리합니다.

# 하드웨어가 원자적으로 실행 — 이 블록은 쪼개지지 않는다
def CAS(addr, expected, new):
    if memory[addr] == expected:     # 내가 본 값이 그대로인가?
        memory[addr] = new             # 그렇다 → 바꾼다
        return True                   # 성공
    return False                      # 누가 먼저 바꿨다 → 실패, 아무것도 안 함

x86에서는 LOCK CMPXCHG, ARM에서는 LDREX/STREX(로드-링크/스토어-조건부) 명령 쌍이 이 역할을 합니다. C++ std::atomic::compare_exchange_weak, Java AtomicInteger, Go atomic.CompareAndSwap, Rust AtomicUsize 가 모두 이 명령으로 구현됩니다.

2. CAS 재시도 루프 — 락-프리의 심장

CAS 한 번은 실패할 수 있습니다. 그래서 성공할 때까지 반복합니다: ① 현재 값을 읽고 ② 원하는 새 값을 계산하고 ③ CAS로 바꿔 본다. ③이 실패하면 그 사이 누군가 값을 바꾼 것이므로 ①부터 다시 합니다.

# 원자적 증가 (잠금 없이)
def atomic_inc(counter):
    while True:
        cur = counter.load()          # ① 읽기
        nxt = cur + 1               # ② 계산
        if counter.CAS(cur, nxt):     # ③ 교환 시도
            return nxt                 #    성공하면 탈출
        # 실패 → 다른 스레드가 먼저 바꿈 → 루프 처음으로
진행 보장(lock-free): 어떤 스레드가 도중에 멈춰도(스케줄 아웃·페이지 폴트) 다른 스레드는 막히지 않고 계속 전진합니다. 잠금과 달리 "락을 쥔 채 죽어서 전체가 멈추는" 일이 없습니다.

3. 직접 돌려 보기 — 카운터 경쟁

여러 스레드가 공유 카운터를 각각 목표 횟수만큼 올립니다. 각 스레드는 값을 읽고(READ), +1을 교환 시도(CAS) 합니다. 그 사이 다른 스레드가 값을 바꿔 놨으면 CAS가 실패(RETRY) 하고 다시 읽습니다. 원자적 CAS 체크를 끄면 CAS가 검사 없이 무조건 덮어써서 갱신 유실이 발생합니다.

3
20
6
shared counter
0
기대 최종값 (스레드수 × 증가) = 60
실제 카운터 값 = 0  
총 CAS 재시도 0 회  ·  갱신 유실 0 회

원자적 검사를 켜면 실제 값은 항상 기대값과 일치합니다. 다만 스레드가 많고 충돌이 잦을수록 재시도 횟수가 늘어납니다(경합, contention). 검사를 끄면 두 스레드가 같은 cur를 읽고 똑같이 cur+1을 써서 한 번의 증가가 통째로 사라집니다 — 이것이 갱신 유실입니다.

4. ABA 문제 — CAS의 함정

CAS는 "값이 expected와 같은가"만 봅니다. 그런데 값이 A → B → A 로 돌아왔다면? CAS는 "안 변했다"고 착각하고 성공합니다. 실제로는 그 사이 세상이 바뀌었는데도요. 락-프리 스택에서 특히 위험합니다.

1. 스레드1: 스택 top = 노드 A 읽음. A.next = B 기억. pop() 하려고 CAS(top, A, B) 준비.
2. 스레드1 여기서 멈춤(스케줄 아웃).
3. 스레드2: A를 pop, 이어서 B도 pop. B가 쓰던 메모리는 반환됨. 그 뒤 재사용된 주소로 새 노드가 다시 top=A 로 push됨.
4. 스레드1 재개: CAS(top, A, B) 실행 → top이 여전히 A라서 성공. 하지만 B는 이미 해제된 노드! top이 쓰레기를 가리킴.
핵심: 포인터 값이 같아도 그것이 가리키는 대상은 완전히 다를 수 있다. CAS는 동일성(identity)이 아니라 비트 패턴만 비교한다.

ABA 막기

방법아이디어
버전 태그
(tagged pointer)
포인터 + 카운터를 함께 CAS(더블 워드 CMPXCHG16B). A로 돌아와도 카운터가 달라 실패.
해저드 포인터스레드가 "지금 이 노드 보는 중"이라 표시 → 표시된 노드는 재사용 금지.
에폭 기반 회수
(EBR / RCU)
모든 스레드가 옛 에폭을 떠난 뒤에야 메모리 반환.
GC 언어Java·C#·Go는 참조가 살아 있는 한 주소가 재사용되지 않아 ABA가 자연히 완화됨.

5. 락 vs 락-프리

뮤텍스 잠금CAS 락-프리
스레드가 도중에 멈추면락 보유 시 전체 정지다른 스레드는 계속 진행
데드락가능불가능(락이 없음)
경합이 심할 때줄 서서 대기재시도 폭증(라이브록 위험)
구현 난이도비교적 쉬움어려움(ABA·메모리 순서·회수)

진행 보장의 강도는 wait-free(모든 스레드가 유한 스텝 내 완료) > lock-free(전체 중 최소 하나는 항상 전진) > obstruction-free(혼자 남으면 완료) 순입니다. 대부분의 실전 자료구조는 lock-free를 목표로 합니다.

핵심 정리

  • CAS = "내가 본 값이 그대로면 바꾼다" 를 원자적으로 수행하는 CPU 명령
  • 락-프리 = 읽기 → 계산 → CAS → 실패 시 재시도 루프
  • 장점: 데드락 없음, 멈춘 스레드가 남을 막지 않음
  • 함정: ABA 문제 — 값이 되돌아오면 CAS가 변화를 놓침 → 버전 태그·해저드 포인터로 방어
  • 경합이 심하면 재시도가 폭증하므로 무조건 빠른 것은 아니다