← 문제로

5. SPSC 링버퍼의 메모리 순서(memory ordering) 버그

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — SPSC 링버퍼의 메모리 순서(memory ordering) 버그

난이도: 최상

요약

인덱스 두 개를 일반 int 필드로 두면 SPSC가 안전하지 않다. 생산자의 "슬롯 데이터 기록"과 "head 공개" 사이, 소비자의 "head 관측"과 "슬롯 데이터 읽기" 사이에 happens-before 관계가 없다. .NET 메모리 모델(ECMA-335)은 일반 필드 접근의 순서를 강제하지 않으므로, JIT/CPU가 (B) 데이터 쓰기와 (C) head 증가를 재배열하거나, 소비자가 새 head를 봤어도 새 슬롯 데이터를 못 보는(가시성 누락) 일이 가능하다. x86은 강한 메모리 모델(TSO)이라 store↔store, load↔load 재배열이 거의 없어 우연히 동작하지만, ARM/AArch64의 약한 메모리 모델에서는 재배열이 실제로 일어나 빈 슬롯/이전 데이터를 읽는다. 올바른 해법은 Volatile.Read/Volatile.Write(acquire/release 의미) 동기화다.

핵심 원리: release-acquire가 만드는 happens-before

  • 생산자가 Volatile.Write(ref _head, ...) 하면, 그 쓰기 이전의 모든 메모리 쓰기(슬롯 데이터 포함) 가 쓰기에 "묶인다"(release fence).
  • 소비자가 같은 _headVolatile.Read로 그 값을 관측하면(acquire fence), 생산자의 release 이전 쓰기들이 소비자에게 보이도록 보장된다(release → acquire의 synchronizes-with → happens-before).
  • 일반 필드 접근은 원자성(int는 정렬 시 보장)만 있을 뿐 순서/가시성은 보장하지 않는다. 그래서 데이터와 인덱스의 관계가 깨진다.

.NET에서 Volatile.Read는 acquire, Volatile.Write는 release 의미를 갖는다(volatile 키워드도 동일). C++의 memory_order_acquire/release에 대응한다.

문제점

(C) _head = head + 1 (생산자) — release 누락 (분류: 동시성/정확성, 핵심)

  • 증상: 소비자가 새 head를 봤지만 슬롯 데이터는 아직 안 보임 → 빈/이전 데이터 읽기.
  • 재현조건: 약한 메모리 모델(ARM/AArch64). x86에선 거의 안 터짐.
  • 근본원인: 일반 쓰기는 그 이전의 _buf[...] = item (B) 쓰기를 함께 가시화한다는 보장이 없다. JIT/CPU가 head 증가를 데이터 쓰기보다 먼저 외부에 노출할 수 있다(store-store 재배열). 소비자가 그 head를 보고 슬롯을 읽으면 미기록 상태. Volatile.Write여야 release 장벽이 생긴다.

(B) 슬롯 쓰기 _buf[head & _mask] = item — (C)와의 순서 결합 부재 (분류: 동시성)

  • 증상: (C)의 상대편. 데이터 쓰기가 head 공개 "이전"에 완료·가시화돼야 하는데 강제할 장치가 없다.
  • 근본원인: (B)와 (C) 사이에 release 장벽이 없어 JIT 재배열과 CPU 재배열이 모두 허용된다.

(D) _head 읽기 (소비자) — acquire 누락 (분류: 동시성/정확성)

  • 증상: 소비자가 새 head 값을 읽어도, 그에 선행한 생산자의 슬롯 쓰기를 본다는 보장이 없다(load-load 재배열로 슬롯 읽기가 head 읽기보다 앞설 수 있음).
  • 근본원인: 일반 읽기는 acquire 의미가 없어 "이 읽기 이후의 읽기들"이 뒤로 정렬된다는 보장이 없다. Volatile.Read여야 (C)의 release와 짝을 이뤄 happens-before가 성립한다.

(A) _tail 읽기 (생산자) — full 판정의 가시성 (분류: 동시성)

  • 증상: 가끔 가득/빈 판정이 어긋나 한 칸을 덮어쓰거나, 반대로 공간이 있는데 full로 본다.
  • 근본원인: 생산자는 소비자가 올린 _tail을 봐야 빈 공간을 정확히 계산한다. 소비자의 _tail 쓰기가 release(Volatile.Write), 생산자의 _tail 읽기가 acquire(Volatile.Read)여야, 소비자가 "그 슬롯을 다 읽었다"는 사실이 생산자에게 보여 덮어쓰기를 막는다. 일반 읽기면 생산자가 stale tail을 보고 full을 오판하거나, 소비자가 아직 다 안 읽은 슬롯을 덮어쓸 수 있다.

수정안

생산자: 슬롯 쓰기 후 Volatile.Write(_head). tail은 Volatile.Read로 읽음. 소비자: 슬롯 읽기 전 Volatile.Read(_head). tail은 Volatile.Write로 씀.

using System.Runtime.CompilerServices;
using System.Threading;

public sealed class SpscRing<T>
{
    private readonly int _mask;
    private readonly T[] _buf;

    // false sharing 방지: head/tail를 충분히 떨어뜨림(아래 더 나은 설계 참고)
    private int _head; // 생산자가 증가
    private int _tail; // 소비자가 증가

    public SpscRing(int capacityPow2)
    {
        _mask = capacityPow2 - 1;
        _buf = new T[capacityPow2];
    }

    public bool Enqueue(T item)
    {
        int head = _head;                                  // 자기 인덱스: 일반 읽기 OK
        // (A) 소비자가 공개한 tail을 acquire로 관측 → 슬롯 재사용 안전
        int tail = Volatile.Read(ref _tail);
        if (head - tail == _buf.Length)
            return false; // full

        _buf[head & _mask] = item;                         // (B) 데이터 기록
        // (C) release: 위 슬롯 쓰기를 head 공개에 묶음
        Volatile.Write(ref _head, head + 1);
        return true;
    }

    public bool Dequeue(out T outItem)
    {
        int tail = _tail;                                  // 자기 인덱스: 일반 읽기 OK
        // (D) acquire: 생산자의 release와 짝 → 슬롯 데이터 가시성 확보
        int head = Volatile.Read(ref _head);
        if (head == tail)
        {
            outItem = default;
            return false; // empty
        }

        outItem = _buf[tail & _mask];                      // 데이터 읽기(acquire 이후)
        // 소비 완료를 생산자에게 공개: release
        Volatile.Write(ref _tail, tail + 1);
        return true;
    }
}

규칙 요약:

  • 자기 스레드만 쓰는 인덱스를 자기 스레드가 읽을 때는 일반 읽기로 충분(생산자의 head 읽기, 소비자의 tail 읽기). 단일 스레드 내 프로그램 순서는 보장되므로.
  • 상대 스레드가 갱신한 인덱스를 읽을 때Volatile.Read(acquire), 상대에게 공개할 때Volatile.Write(release).
  • 생산자 release ↔ 소비자 acquire (head), 소비자 release ↔ 생산자 acquire (tail) — 두 쌍의 동기화.

x86에서 안 터지는 이유

x86-64는 TSO(Total Store Order): store↔store, load↔load 재배열이 없고 store→load만 재배열된다. 그래서 (B)→(C)(store-store)와 (D)→슬롯읽기(load-load) 순서가 하드웨어적으로 유지돼 일반 필드여도 우연히 동작한다. 다만 JIT 재배열은 여전히 가능하므로 x86에서도 이론상 위험하다(최적화로 슬롯 쓰기를 head 쓰기 뒤로 옮기거나, 인덱스를 레지스터에 캐싱해 stale 읽기). 반면 ARM/AArch64는 약한 모델이라 하드웨어 재배열이 실제 발생 → 간헐 버그. "x86에서 통과했으니 맞다"는 잘못된 결론. (.NET의 강한 보장에 의존하지 말 것 — ECMA 메모리 모델은 일반 필드 순서를 보장하지 않으며, .NET 5+ ARM64 지원으로 약한 모델이 현실 문제가 됐다.)

더 나은 설계

  1. 인덱스 캐싱으로 Volatile 읽기 줄이기: 생산자가 _tail을 매번 Volatile.Read하면 캐시 트래픽이 크다. 마지막으로 본 tail을 로컬에 캐시해두고, full로 보일 때만 다시 acquire-read. 소비자도 head를 캐싱. 처리량이 크게 오른다(LMAX Disruptor, Folly ProducerConsumerQueue의 기법). 트레이드오프: 코드 복잡도.

  2. head/tail 캐시라인 분리: 생산자/소비자가 서로의 인덱스 라인을 핑퐁하지 않게 패딩한다. C#에선 head/tail을 별도 패딩 구조체([StructLayout(Size=64)])나 큰 더미 필드로 떨어뜨린다. SPSC 성능의 핵심.

  3. 검증 도구: .NET엔 C++의 TSan 같은 표준 도구가 약하므로, ARM64 실기/CI에서 스트레스 테스트가 1차. System.Threading.Channels/Channel<T>(SPSC 옵션 SingleReader/SingleWriter)나 ConcurrentQueue<T>처럼 검증된 구현을 쓰는 게 가장 안전.

  4. Interlocked는 과한가?: 인덱스를 Interlocked로 다루면 full barrier(seq_cst 유사)라 정확하지만 ARM에서 비싼 배리어가 들어가 느리다. SPSC엔 Volatile(acquire/release)이 정확하고 최소 비용이다. 트레이드오프: Interlocked = 단순/안전하지만 느림, Volatile = 빠르지만 추론 필요.

  5. MPSC/MPMC로 확장 시: 생산자가 여럿이면 head를 Interlocked.CompareExchange로 예약해야 하고 중간 가시성 문제가 추가된다. SPSC 가정을 코드/타입으로 못박을 것.

면접 포인트

  1. .NET 메모리 모델에서 일반 필드 접근의 순서 보장은? Volatile.Read/Writevolatile 키워드가 acquire/release를 어떻게 제공하나? happens-before로 release 쓰기와 acquire 읽기가 synchronizes-with를 만드는 조건은?
  2. 같은 코드가 x86에선 통과하고 ARM64에서 깨지는 이유는? TSO와 약한 메모리 모델, 그리고 JIT 재배열의 역할을 구분하라. ".NET이 알아서 강하게 해준다"는 오해를 반박하라.
  3. SPSC에서 어떤 인덱스 접근은 일반 읽기로 충분하고 어떤 것은 Volatile이 필요한가? 그 판단 기준은? head/tail을 별 캐시라인에 두는 이유와, Volatile vs Interlocked의 비용 차이는?