41. 락프리 스냅샷 교체의 유예 기간 부재: 풀 조기 반납 후 재사용 오염 (C#)
난이도 상해설 — 락프리 스냅샷 교체의 유예 기간 부재: 풀 조기 반납 후 재사용 오염 (C#)
난이도: 상
요약
Publish 는 참조를 원자적으로 교체한 직후 옛 스냅샷을 풀로 반납한다. 그러나 교체 직전에 _current 를 읽은 읽기 스레드가 아직 그 객체의 필드를 사용하는 중일 수 있다. 풀은 반납된 객체를 곧 Acquire 로 다시 내주어 Reset() 하고 새 값으로 덮어쓴다. 그러면 읽기 스레드는 리셋되거나 다른 틱의 값으로 바뀐 객체를 읽는다. C# 은 참조가 살아있으면 GC 가 수거하진 않지만, 풀 재사용은 "객체 수명"이 아니라 "객체 내용"을 무너뜨린다 — GC 가 UAF 를 막아준다는 가정이 무력화된다.
문제점
- [수명·재사용/회수 시점] 사용 중인 옛 스냅샷을 즉시 반납 → 재사용 오염
- 증상: 읽기 스레드가
(C)에서s = old를 읽고 필드를 연달아 쓰는 사이, 틱 스레드가(A)로 교체하고(B)로old를 풀에 반납. 풀이old를 다음Acquire에 내주어Reset()+ 새 값 기록 → 읽기 스레드가 보던Tick/EventMultiplier가 도중에 0 또는 다른 세대 값으로 바뀐다(찢긴/오염된 읽기). - 재현: 읽기 다수 + 잦은 교체.
ReadTick이 여러 필드를 연달아 쓸수록 창이 넓다. - 근본원인:
Interlocked.Exchange는 참조 게시만 원자화한다. 옛 객체를 참조할 수 있는 모든 독자가 끝날 때까지 반납을 미루는 유예 기간이 없다.
- 증상: 읽기 스레드가
수정안 (선택지)
- 불변 객체 + 풀 없이 GC 에 맡기기(가장 단순): 스냅샷을 불변으로 만들고 풀 반납을 제거. 참조가 살아있는 한 GC 가 옛 객체를 지켜준다. 독자는 참조 하나만 읽어 안전. 트레이드오프: 교체마다 할당(Gen0 압박, 보통 감당 가능).
private WorldSnapshot _current;
public void Publish(WorldSnapshot fresh) => Volatile.Write(ref _current, fresh); // 옛 것은 미참조 시 GC
public WorldSnapshot Read() => Volatile.Read(ref _current); // 독자가 참조 쥐는 동안 안전
-
풀을 꼭 써야 하면 유예 기간을 두기: 반납을 즉시 하지 말고, "이 시점 이후 시작한 읽기만 새 스냅샷을 본다"가 보장되는 안전 지점(예: 다음 틱 경계, 세대 카운터로 모든 워커가 한 바퀴 돈 뒤)까지 지연 반납한다. 세대 기반(epoch) 회수 큐를 둔다.
-
참조 카운팅: 스냅샷에 사용 카운트를 두고 독자가 진입/이탈 시 증감, 0 이 될 때만 반납.
Interlocked로 관리하되 획득-해제 규율을 지켜야 한다.
더 나은 설계
- 읽기 다수/교체 소수라면 불변 객체 + GC 가 가장 단순·안전하다. 풀의 이득(할당 절감)보다 회수 안전성 관리 비용이 크면 풀을 버리는 게 맞다.
- 풀이 정말 필요(초고빈도 교체·큰 객체)하면 에폭 기반 지연 반납으로 "게시 시점 ≠ 반납 시점"을 명확히 분리한다.
- 어느 경우든 결함의 핵심은 반납을 게시 직후에 둔 것이다.
면접 포인트
- 원자적 참조 교체는 게시를 원자화할 뿐, 옛 버전의 안전한 회수를 보장하지 않는다. 이것이 락프리 자료구조의 진짜 난제(safe reclamation).
- 풀 재사용은 GC 의 UAF 방지 보장을 우회한다 — 객체가 살아 있어도 내용이 재사용으로 덮이면 논리적 오염이 난다. "참조가 있으니 안전"은 풀 환경에서 성립하지 않는다.
- 해결책(불변+GC / 에폭 지연 반납 / 참조 카운트)의 트레이드오프를 설명할 수 있어야 한다.
해설 — 락프리 스냅샷 교체의 유예 기간(grace period) 부재: 조기 회수 UAF (C++)
난이도: 상
요약
publish 는 원자적 포인터 교체로 새 스냅샷을 게시한 직후 옛 스냅샷을 풀로 반납한다. 하지만 교체 직전에 옛 포인터를 load 한 읽기 스레드가 아직 그 필드들을 참조하는 중일 수 있다. 반납된 스냅샷은 곧 다른 acquire 로 재사용/덮어써지므로, 읽기 스레드는 use-after-free 또는 재사용으로 값이 바뀐 객체를 읽는다. 원자 포인터 교체는 "게시"만 원자적으로 해줄 뿐, 언제 옛 객체를 안전하게 회수(reclaim)할 수 있는가(유예 기간)는 전혀 보장하지 않는다.
문제점
- [수명/회수(reclamation)] 참조 중인 옛 스냅샷을 즉시 반납 → UAF/재사용 오염
- 증상: 읽기 스레드가
(C)에서s = old를 load 한 뒤 필드를 읽는 사이, 틱 스레드가(A)로 교체하고(B)로old를 풀에 반납한다. 풀은old를 다음acquire에 내주어 다른 틱이 그 메모리를 덮어쓴다. 읽기 스레드는 파괴/재사용된 객체를 참조 → 찢긴 값·크래시.pool.release가 실제delete라면 명백한 use-after-free. - 재현: 읽기 다수 + 잦은 교체.
readTick안에서 여러 필드를 연달아 읽는 구간이 길수록 창이 넓다. TSan/ASan 으로 검출. - 근본원인: 원자 포인터 교체는 게시의 원자성만 준다. 옛 버전을 참조할 수 있는 모든 읽기 스레드가 그 참조를 놓을 때까지 회수를 미루는 유예 기간이 없다.
- 증상: 읽기 스레드가
수정안 (선택지)
핵심은 "게시"와 "회수"를 분리하고, 회수를 안전한 시점까지 지연하는 것.
- 원자적
shared_ptr(가장 단순, C++20): 참조 카운트가 마지막 독자까지 옛 객체를 살려둔다.
std::atomic<std::shared_ptr<WorldSnapshot>> current_; // C++20
void publish(std::shared_ptr<WorldSnapshot> fresh) {
current_.store(std::move(fresh), std::memory_order_release); // 옛 것은 refcount 0 될 때 자동 소멸
}
std::shared_ptr<WorldSnapshot> read() {
return current_.load(std::memory_order_acquire); // 독자가 참조를 쥐는 동안 살아있음
}
C++17 이면 std::atomic_load/atomic_store(&shared_ptr) 자유 함수로 대체.
-
에폭 기반 회수(EBR)/RCU: 독자는 진입 시 에폭을 표시하고, 회수는 "모든 독자가 새 에폭으로 넘어간 뒤"에만 옛 객체를 파괴. 게임 서버 틱 경계가 자연스러운 정지점(quiescent state)이 된다.
-
해저드 포인터(hazard pointer): 각 독자가 지금 참조 중인 포인터를 공개(publish)하고, 회수 전에 어떤 해저드 포인터도 그 주소를 가리키지 않는지 확인.
더 나은 설계
- 읽기 다수/교체 소수 패턴엔 원자
shared_ptr이 가장 단순하고 검증하기 쉽다. 트레이드오프: 원자 refcount 갱신 비용(초고빈도 읽기에선 캐시 라인 경합). - 초고빈도 읽기엔 RCU/EBR 이 읽기 측 비용을 거의 0 으로 만들지만 구현·회수 스케줄링이 복잡하다.
- 어느 방식이든 "게시 시점 ≠ 회수 시점" 을 코드로 명확히 분리해야 한다. 풀 반납을 게시 직후에 두는 것이 결함의 핵심.
면접 포인트
- 원자 포인터 교체는 게시를 원자화할 뿐, 옛 버전의 안전한 회수를 보장하지 않는다. 락프리 자료구조의 진짜 난제는 "언제 자유롭게 delete/재사용할 수 있는가"(safe memory reclamation)다.
- 해결책 3종(refcount/
shared_ptr, RCU·EBR, 해저드 포인터)의 트레이드오프를 설명할 수 있어야 한다. - 풀 재사용은 GC 언어의 UAF-없음 가정을 무력화한다 — 반납된 객체가 곧 다른 데이터로 덮이므로, "참조가 살아있다"만으로는 안전하지 않다는 점을 짚는다.