← 문제로

39. 낙관적 스냅샷(seqlock)의 잘못된 구현: 찢긴 읽기 (C#)

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

해설 — 낙관적 스냅샷(seqlock)의 잘못된 구현: 찢긴 읽기 (C#)

난이도: 최상

요약

버전 카운터로 찢긴 읽기를 막으려 했지만 seqlock 프로토콜의 핵심 요소가 전부 빠져 있다. (1) 쓰기 전/후로 버전을 두 번 올려 "쓰기 진행 중"을 표시하지 않고 완료 후 한 번만 올린다, (2) 읽기가 버전을 재확인·재시도하지 않는다, (3) 홀짝(진행 중) 판정이 없다, (4) volatile/메모리 배리어가 없어 컴파일러·CPU 재정렬로 필드 쓰기가 버전 갱신을 앞지르거나 읽기 순서가 뒤집힌다. 결과적으로 리더는 서로 다른 갱신의 필드가 섞인 값(찢긴 스냅샷)을 얻는다.

문제점

1) [프로토콜 결함] 단일 버전 증가 — 진행 중 감지 불가 (증상: 찢긴 읽기)

  • 증상: (B)에서 쓰기 완료 후에만 _version++. 리더가 (A) 도중(예: _x,_y는 새 값, _vx,_vy는 옛 값) 필드를 읽어도 버전으로 이를 감지할 방법이 없다. 올바른 seqlock 은 쓰기 시작 시 홀수로, 끝에 짝수로 두 번 증가시켜 리더가 "홀수면 진행 중"·"앞뒤 버전 불일치면 재시도"를 판단한다.
  • 재현조건: Write의 필드 갱신 도중 Read가 끼어듦.

2) [재시도 부재] 리더가 버전을 검증하지 않음 (증상: 찢긴 값 확정)

  • 증상: (C)에서 v를 읽지만 필드를 읽은 뒤 다시 버전을 읽어 비교하지 않는다. 리더는 필드를 읽는 동안 쓰기가 일어났는지 전혀 확인하지 않고 그대로 반환한다. v를 읽는 행위 자체가 아무 보호도 못 한다(주석의 "버전 v 의 것"이라는 가정이 거짓).

3) [메모리 순서] volatile/배리어 부재 (증상: 재정렬로 인한 찢김·무한 stale)

  • 증상: _version과 필드 모두 일반 필드다. 컴파일러/CPU 는 (A)의 필드 쓰기와 (B)의 버전 쓰기를 재정렬할 수 있고(store-store), 리더의 버전 읽기와 필드 읽기도 재정렬(load-load)될 수 있다. 배리어가 없으면 seqlock 을 올바로 짜도 찢김이 남는다. 또한 리더가 _version을 레지스터에 캐싱해 갱신을 영원히 못 볼 수도 있다.
  • 근본원인: acquire/release 의미의 부재.

수정안

정식 seqlock: 쓰기 시작 홀수 → 필드 → 완료 짝수, 리더는 홀짝·앞뒤 버전 일치까지 재시도. 배리어 명시.

using System.Threading;

public sealed class TransformSnapshot
{
    private int _version;                     // 짝수=안정, 홀수=쓰기 중
    private float _x, _y, _z, _vx, _vy;

    // 단일 writer
    public void Write(float x, float y, float z, float vx, float vy)
    {
        int v = _version + 1;                 // 홀수로 (쓰기 시작 표시)
        Volatile.Write(ref _version, v);      // release: 이후 필드 쓰기가 앞서지 않게
        Thread.MemoryBarrier();
        _x = x; _y = y; _z = z; _vx = vx; _vy = vy;
        Thread.MemoryBarrier();               // 필드 쓰기 완료 후 버전 게시
        Volatile.Write(ref _version, v + 1);  // 짝수로 (완료)
    }

    // 다수 reader — 배타락 없이 재시도
    public (float x, float y, float z, float vx, float vy) Read()
    {
        while (true)
        {
            int v1 = Volatile.Read(ref _version);
            if ((v1 & 1) != 0) { Thread.SpinWait(1); continue; } // 쓰기 중 → 재시도
            Thread.MemoryBarrier();
            float x = _x, y = _y, z = _z, vx = _vx, vy = _vy;
            Thread.MemoryBarrier();
            int v2 = Volatile.Read(ref _version);
            if (v1 == v2) return (x, y, z, vx, vy);              // 그 사이 쓰기 없음 → 일관
            // 달라졌으면 찢겼을 수 있으니 재시도
        }
    }
}
  • 핵심 3요소: 홀짝 진행 표시, 앞뒤 버전 일치 재시도, release/acquire 배리어.
  • 리더는 락을 잡지 않아 읽기 처리량이 높고, writer 는 리더를 기다리지 않는다(찢김만 재시도로 흡수).

더 나은 설계 (트레이드오프)

  • 불변 스냅샷 교체(immutable + atomic swap): Write가 새 Transform 객체를 만들고 Interlocked.Exchange(또는 Volatile.Write<참조>)로 참조만 교체. 리더는 참조 하나만 읽으면 항상 일관. 트레이드오프: 갱신마다 할당(GC 압박), 하지만 로직이 단순하고 ABA·찢김이 원천 불가.
  • double buffering: 두 버퍼를 번갈아 쓰고 인덱스만 원자 교체(이 저장소 concurrency_memory 틱 이중버퍼 문제와 연결). 트레이드오프: 2버퍼로는 읽기 지연이 길면 부족.
  • seqlock 적합성: 데이터가 작고(캐시라인 몇 개) 쓰기가 드물 때 최적. 데이터가 크거나 쓰기가 잦으면 재시도 폭증.

면접 포인트

  • seqlock 의 세 가지 필수 요소(홀짝 시퀀스·리더 재시도·acquire/release)를 정확히 설명할 수 있는가.
  • 왜 "버전을 한 번만 올리면" 진행 중을 감지 못하는지, 왜 리더가 앞뒤로 두 번 읽어야 하는지.
  • C#의 Volatile.Read/Write·Thread.MemoryBarrier의 의미, 그리고 불변 스냅샷 교체 대안의 트레이드오프.