39. 낙관적 스냅샷(seqlock)의 잘못된 구현: 찢긴 읽기 (C#)
난이도 최상내 리뷰 · 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의 의미, 그리고 불변 스냅샷 교체 대안의 트레이드오프.
해설 · C++
해설 — seqlock 의 단일 시퀀스·데이터 레이스·메모리 순서 결함 (C++)
난이도: 최상
요약
retry 루프까지 넣어 seqlock 처럼 보이지만 세 가지가 틀렸다. (1) seq_를 갱신마다 한 번만 올려 "쓰기 진행 중"을 표현하지 못한다(홀짝 프로토콜 부재) → 쓰기 도중 시작한 리더가 찢긴 값을 재시도 없이 수락, (2) seq_와 필드가 모두 비원자(plain) 변수라 writer 쓰기와 reader 읽기가 겹치면 데이터 레이스 = UB, (3) 메모리 순서(release/acquire) 미지정이라 필드 쓰기와 시퀀스 쓰기, 시퀀스 읽기와 필드 읽기가 재정렬돼 retry 루프가 무의미해진다.
문제점
1) [프로토콜 결함] 단일 시퀀스 증가 (증상: 찢긴 읽기 수락)
- 증상: (A)에서 쓰기 시작 시
seq_++한 번뿐이다. 리더가 (B)의 필드 갱신 도중에 (C)로s = seq_를 읽으면 이미 증가된 값을 잡고, 찢긴 필드를 읽은 뒤 (D)에서s != seq_를 검사하지만 그 사이 새 쓰기가 없으면 같아서 찢긴 값을 그대로 수락한다. 올바른 seqlock 은 쓰기 시작 때 홀수(seq|1), 완료 때 짝수로 두 번 올려서, 리더가 "시작 시퀀스가 홀수면 진행 중 → 재시도", "짝수이고 앞뒤가 같아야 일관"으로 판정한다. - 재현조건:
Write의 필드 갱신 중Read진입.
2) [데이터 레이스] 비원자 동시 접근 (증상: UB)
- 증상:
seq_(비원자)와pos_/vx_/vy_(비원자)를 writer 와 reader 가 동기화 없이 동시에 읽고 쓴다. C++ 메모리 모델상 이는 정의되지 않은 동작이다 — 컴파일러가 읽기를 합치거나 재정렬하고, retry 루프 자체를 "seq_ 는 안 변한다"고 최적화해 없앨 수도 있다. - 근본원인: 공유 변수를
std::atomic으로 선언하지 않음.
3) [메모리 순서] release/acquire 부재 (증상: 재정렬로 찢김)
- 증상: 원자로 바꿔도 순서를 지정하지 않으면(혹은 relaxed) store-store/load-load 재정렬로 필드가 시퀀스보다 늦게 보이거나 먼저 보인다. writer 는 "필드 → release 시퀀스", reader 는 "acquire 시퀀스 → 필드 → acquire 시퀀스" 순서를 배리어로 강제해야 한다.
수정안
정식 seqlock: 원자 시퀀스, 홀짝 프로토콜, release/acquire 배리어, 필드는 relaxed 원자(또는 배리어로 감싼 접근).
#include <atomic>
class TransformSnapshot {
std::atomic<uint32_t> seq_{0}; // 짝수=안정, 홀수=쓰기 중
std::atomic<float> x_{0}, y_{0}, z_{0}, vx_{0}, vy_{0};
public:
void Write(const Vec3& p, float vx, float vy) { // 단일 writer
uint32_t s = seq_.load(std::memory_order_relaxed);
seq_.store(s + 1, std::memory_order_relaxed); // 홀수: 쓰기 시작
std::atomic_thread_fence(std::memory_order_release);
x_.store(p.x, std::memory_order_relaxed);
y_.store(p.y, std::memory_order_relaxed);
z_.store(p.z, std::memory_order_relaxed);
vx_.store(vx, std::memory_order_relaxed);
vy_.store(vy, std::memory_order_relaxed);
seq_.store(s + 2, std::memory_order_release); // 짝수: 완료(게시)
}
void Read(Vec3& outPos, float& ovx, float& ovy) { // 다수 reader
for (;;) {
uint32_t s1 = seq_.load(std::memory_order_acquire);
if (s1 & 1) continue; // 쓰기 중 → 재시도
outPos = { x_.load(std::memory_order_relaxed),
y_.load(std::memory_order_relaxed),
z_.load(std::memory_order_relaxed) };
ovx = vx_.load(std::memory_order_relaxed);
ovy = vy_.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);
if (seq_.load(std::memory_order_relaxed) == s1) return; // 그 사이 쓰기 없음
}
}
};
- 스니펫 검증:
g++ -std=c++17 -pthread -fsyntax-only통과. - 핵심: 홀짝 시퀀스 + 앞뒤 시퀀스 일치 재시도 + release(writer)/acquire(reader) 펜스. 필드를 원자로 두어 데이터 레이스 UB 를 제거(값 자체는 relaxed 로 충분, 순서는 펜스가 담당).
더 나은 설계 (트레이드오프)
- 불변 스냅샷 + atomic<shared_ptr> 교체: writer 가 새
Transform을 만들어 원자 포인터 교체, reader 는 포인터 하나만 acquire-load. 찢김·재시도 원천 제거. 트레이드오프: 갱신마다 할당·참조카운트(C++20atomic<shared_ptr>또는atomic_load/store(shared_ptr*)). - double buffering + 원자 인덱스: 두 버퍼 번갈아, 인덱스만 원자 교체. 트레이드오프: 리더 지연이 갱신 주기보다 길면 찢김 가능(2버퍼 한계).
- seqlock 적합 조건: 작은 데이터·드문 쓰기·잦은 읽기에 최적. 데이터가 크거나 쓰기가 잦으면 재시도 비용 폭증.
면접 포인트
- 단일 시퀀스 증가가 왜 찢긴 읽기를 못 막는지, 홀짝 2회 증가와 리더 앞뒤 검증의 필요성.
- C++ 메모리 모델에서 비원자 공유 접근이 UB 인 이유와
std::atomic+fence 로 seqlock 을 올바로 구성하는 법. - seqlock vs 불변 스냅샷 교체 vs double buffering 의 트레이드오프.