← 문제로

41. 락프리 스냅샷 교체의 유예 기간 부재: 풀 조기 반납 후 재사용 오염 (C#)

난이도 상
내 리뷰 · C#
해설 · 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 / 에폭 지연 반납 / 참조 카운트)의 트레이드오프를 설명할 수 있어야 한다.