38. 틱 이중 버퍼 스왑의 동시성 결함 (가시성·찢긴 읽기, C#)
난이도 최상해설 — 틱 이중 버퍼 스왑의 동시성 결함 (가시성·찢긴 읽기, C#)
난이도: 최상
요약
_readIndex가 volatile도 아니고 어떤 동기화도 없어, 워커 스레드가 (B)의 인덱스 갱신과 그 앞의 버퍼
쓰기를 재배열된 순서로 볼 수 있다. GC 덕에 UAF는 없지만, 워커가 시뮬레이션이 채우는 도중의 버퍼나
찢긴 스냅샷을 관측할 수 있다. C++판과 달리 크래시 대신 "잘못된 상태 브로드캐스트"로 나타난다.
문제점
- [메모리 모델/가시성] 비volatile
_readIndex. .NET 메모리 모델에서 일반 필드 쓰기/읽기는 스레드 간 즉시 가시성이나 순서를 보장하지 않는다. 워커가_readIndex의 갱신을 늦게 보거나(옛 버퍼 계속 읽기), 인덱스 갱신을 버퍼 내용 갱신보다 먼저 볼 수 있다(부분적으로 쓰인 버퍼 읽기). - [찢긴 읽기 / 2버퍼 부족] 읽는 중 덮어쓰기. 워커가 (C)에서 참조를 얻어
Entities를 순회하는 도중, 후속 틱의CommitTick이 같은 버퍼 객체를 다시 쓰기 대상으로 삼으면List가 동시 수정된다 →InvalidOperationException("Collection was modified")또는 찢긴 스냅샷. 워커의 읽기가 한 틱을 넘기면(느린 브로드캐스트) 반드시 발생 가능. - [참조 반환의 함정]
CommitTick(next)가 외부 객체를 그대로 슬롯에 대입. 호출자가next를 계속 들고 수정하면 게시된 스냅샷이 사후 변형된다(불변성 부재).
근본 원인
게시(인덱스 교체)에 메모리 배리어가 없고, 버퍼 수명이 소비자의 읽기 기간과 분리되지 않았다. 이중 버퍼는 "소비자가 한 틱 내에 읽기를 끝낸다"는 가정에 의존하는데 다수 워커·가변 지연에서 깨진다.
수정안
불변 스냅샷을 만들고, 게시를 Volatile.Write/Interlocked.Exchange로 원자화한다. 소비자는 참조를
스냅으로 받아 GC가 수명을 관리하게 한다(버퍼 재사용 제거).
public sealed class WorldPublisher
{
private WorldSnapshot _current = new();
// 시뮬레이션: 새 스냅샷을 만들어 원자적으로 게시.
public void CommitTick(WorldSnapshot next)
{
// next 는 이후 수정하지 않는(불변) 새 인스턴스여야 한다.
Volatile.Write(ref _current, next); // release 의미의 게시
}
// 워커: 게시된 스냅샷 참조를 안전하게 획득.
public WorldSnapshot Current() => Volatile.Read(ref _current); // acquire
}
- 참조 타입 필드 대입은 원자적이므로 찢김이 없고,
Volatile.Write/Read가 순서(스냅샷 내용 ↔ 참조 게시)를 보장한다. 매 틱 새 인스턴스를 게시하므로, 워커가 붙잡은 옛 스냅샷은 GC가 살려둔다 → 덮어쓰기·동시 수정 없음. - 스냅샷을 진짜 불변으로(예: 게시 후
Entities를 건드리지 않기, 또는IReadOnlyList로 노출) 만들면 사후 변형도 막는다.
더 나은 설계
- 매 틱 할당이 부담이면 스냅샷 풀 + 세대 카운터로 재사용하되, "소비자가 읽는 스냅샷은 재사용 금지"를 참조 카운팅/에폭으로 강제한다.
- 델타(변경분)만 게시해 스냅샷 크기와 할당을 줄인다.
- 브로드캐스트가 오래 걸리면 워커가 스냅샷을 잡은 즉시 필요한 필드만 복사해 보유 시간을 줄인다.
면접 포인트
- .NET에서 일반 필드 vs
volatile/Volatile.Read/Write의 가시성·순서 차이. - 이중 버퍼가 깨지는 조건(읽기 기간 > 한 틱)과 불변 스냅샷+GC로 해결하는 원리.
- C++판(UAF/크래시)과 C#판(예외/잘못된 상태)의 실패 양상 차이 — GC 유무가 만드는 차이.
해설 — 틱 이중 버퍼 스왑의 동시성 결함 (찢긴 읽기·데이터 레이스, C++)
난이도: 최상
요약
이중 버퍼가 "쓰기 도중의 버퍼를 읽지 않는다"를 보장하지 못한다. readIndex_가 비원자이고,
버퍼 배열 자체가 2개뿐이라 워커가 인덱스를 읽은 직후 시뮬레이션이 그 버퍼를 다음 틱에서 다시
덮어쓸 수 있다. 인덱스 게시와 버퍼 내용 사이에 메모리 순서 보장도 없어, 워커가 새 인덱스를 보면서도
낡은/부분적으로 쓰인 내용을 볼 수 있다.
문제점
- [데이터 레이스] 비원자
readIndex_동시 접근. 시뮬레이션이 (B)에서 쓰고 워커가 (C)에서 읽는데 동기화가 없다 — 그 자체가 UB다.int읽기가 찢기진 않더라도, 컴파일러/CPU 재배열로 (A)의 버퍼 쓰기가 (B)의 인덱스 게시 이후로 관측될 수 있다(게시 순서 위반). - [찢긴 읽기 / write-after-read] 2버퍼로는 부족. 워커가 (C)에서
readIndex_=1을 읽고buffers_[1]을 순회하는 도중, 다음 틱의CommitTick이writeIndex = 1 - readIndex_를 계산한다. 그런데 스왑 후readIndex_가 1이므로 다음 틱의 쓰기 버퍼는 0이다 — 여기까진 괜찮아 보이지만, 그 다음 틱엔 다시 1이 쓰기 대상이 된다. 즉 워커의 읽기가 두 틱을 넘겨 지속되면(느린 브로드캐스트, 스케줄링 지연), 시뮬레이션이 워커가 아직 읽고 있는buffers_[1]을buffers_[writeIndex]로 덮어써 순회 중 벡터 재할당/원소 변경이 일어난다 → 찢긴 스냅샷, 반복자/참조 무효화, UAF. - [참조 수명]
const WorldSnapshot&반환. 워커가 참조를 들고 브로드캐스트하는 동안 그 버퍼가 다음 틱에 덮어써진다. 반환된 참조가 가리키는vector가 재할당되면 워커의 순회는 무효 메모리를 밟는다.
근본 원인
"게시-구독"에서 ① 인덱스 게시에 원자성/release-acquire 순서가 없고, ② 버퍼 수명이 소비자의 읽기 기간과 분리되지 않았다. 이중 버퍼는 "소비자가 한 틱 안에 반드시 읽기를 끝낸다"는 가정에 기대는데, 다수 워커·가변 지연 환경에서 그 가정이 깨진다.
수정안
소비자가 스냅샷을 참조가 아니라 소유권(shared_ptr) 으로 가져가게 하고, 게시를 원자적 release/acquire로 만든다. 그러면 버퍼 개수에 의존하지 않고, 아직 읽는 스냅샷은 refcount로 살아남는다.
#include <atomic>
#include <memory>
class WorldPublisher {
public:
WorldPublisher() : cur_(std::make_shared<const WorldSnapshot>()) {}
// 시뮬레이션: 새 스냅샷을 만들어 원자적으로 게시(불변 객체로).
void CommitTick(WorldSnapshot next) {
auto snap = std::make_shared<const WorldSnapshot>(std::move(next));
std::atomic_store_explicit(&cur_, snap, std::memory_order_release);
}
// 워커: 게시된 스냅샷의 소유권 사본을 얻는다(락 없음).
std::shared_ptr<const WorldSnapshot> Current() const {
return std::atomic_load_explicit(&cur_, std::memory_order_acquire);
}
private:
std::shared_ptr<const WorldSnapshot> cur_; // C++17: 자유 함수 atomic_load/store 사용
};
- 각 틱은 새 불변 스냅샷을 만든다. 워커는
shared_ptr로 붙잡으므로, 시뮬레이션이 다음 틱에 새 것을 게시해도 워커가 보던 스냅샷은 refcount가 0이 될 때까지 살아있다(덮어쓰기·재할당 없음). atomic_store/load(release/acquire)가 스냅샷 내용과 포인터 게시의 순서를 보장한다.- (C++20이면
std::atomic<std::shared_ptr<...>>로 더 깔끔; g++11 등에서는 C++17 자유 함수판을 쓴다.)
매 틱 할당이 부담되면 N-버퍼 링(N≥3) + 세대 카운터 또는 스냅샷 풀로 재사용하되, "인덱스 게시 원자성"과 "소비자가 읽는 버퍼는 재사용 대상에서 제외"라는 두 불변식을 반드시 지켜야 한다.
더 나은 설계
- 스냅샷을 불변으로 만들면 동기화 표면이 "포인터 하나의 원자 교체"로 줄어든다(RCU 스타일).
- 델타 인코딩으로 스냅샷 크기를 줄이면 매 틱 할당 비용을 상쇄한다.
- 정말로 락프리 이중/삼중 버퍼가 필요하면 seqlock(짝수/홀수 시퀀스) 패턴도 후보다 — 단, 가변 길이
vector엔 부적합(고정 크기 POD 스냅샷에 적합).
면접 포인트
- 이중 버퍼가 언제 깨지나 — 소비자의 읽기 기간이 한 틱을 넘길 때. 왜 삼중/세대/refcount가 필요한가.
- release/acquire가 여기서 무엇을 보장하나(스냅샷 내용 ↔ 포인터 게시의 happens-before).
- 참조 반환 vs 소유권 반환의 차이가 수명 안전성에 미치는 영향.