← 문제로

46. 완료 슬롯의 비휘발성 플래그: 무한 스핀과 결과 가시성 결여

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

해설 — 완료 슬롯의 비휘발성 플래그: 무한 스핀과 결과 가시성 결여

난이도: 상

요약

_done 이 일반 bool 이라 (1) Wait 의 while(!_done){} 는 JIT 가 조건을 루프 밖으로 끌어올려 영원히 도는 스핀이 될 수 있고, (2) 소비자가 _done==true 를 보더라도 _result 쓰기가 함께 보인다는 보장이 없어 기본값/오래된 결과를 읽을 수 있다. 발행(release)과 획득(acquire) 동기화가 없다.

문제점

  • [가시성 / 스핀 호이스팅] 무한 대기: _done 은 volatile 이 아니므로 JIT 은 while(!_done) 안에서 _done 을 매번 다시 읽을 필요가 없다고 판단해 레지스터에 캐시(호이스팅)할 수 있다. 그러면 다른 스레드가 _done=true 로 바꿔도 소비자는 갱신을 보지 못하고 무한히 돈다(라이브락/멈춤).
  • [메모리 순서] 결과-플래그 게시 동기화 부재: (A) 는 _result 를 쓰고 _done 을 쓰지만, 일반 필드 쓰기에는 release 의미가 없고 (B) 의 읽기에는 acquire 의미가 없다. 소비자가 _done==true 를 관측한 시점에 _result 의 최신 값이 보인다는 happens-before 관계가 성립하지 않는다. 결과가 값 형식(struct)이면 찢긴 읽기, 참조 형식이면 아직 초기화가 끝나지 않은 객체를 볼 수 있다.
  • [바쁜 대기] 스핀 자체의 낭비: 완료까지 CPU 코어를 태우는 순수 스핀은 응답 지연이 긴 DB/RPC 대기에 부적합하다(코어 점유·전력).
  • 재현 조건: Release 빌드(JIT 최적화 활성) + 서버 GC/여러 코어. x86 은 store-store/load-load 재배열이 약해 덜 드러나지만, ARM 서버나 최적화된 호이스팅에서 바로 나타난다.

수정안

플래그를 volatile(또는 Volatile.Read/Write)로 두고 "결과 먼저, 플래그 나중" 게시 + "플래그 먼저, 결과 나중" 획득 순서를 지킨다. 스핀 대신 신호 기반 대기를 쓰는 것이 낫다.

public sealed class CompletionSlot<T>
{
    private T _result = default!;
    private volatile bool _done;                 // volatile: 쓰기=release, 읽기=acquire
    private readonly ManualResetEventSlim _evt = new(false);

    public void Complete(T value)
    {
        _result = value;                          // 결과 먼저
        _done = true;                             // volatile 쓰기(release)로 결과 발행
        _evt.Set();
    }

    public T Wait()
    {
        _evt.Wait();                              // 스핀 대신 신호 대기
        // _done 을 acquire 로 읽은 뒤 결과 판독
        return _result;
    }

    public bool TryGet(out T value)
    {
        if (_done) { value = _result; return true; }   // volatile 읽기(acquire) 후 결과
        value = default!; return false;
    }
}

가장 견고한 방법은 바퀴를 다시 발명하지 않고 TaskCompletionSource<T> 를 쓰는 것이다. 이는 결과 게시·완료 신호·대기의 메모리 순서를 모두 올바르게 처리하고 await 와도 자연스럽게 결합된다.

더 나은 설계

  • TaskCompletionSource<T> / Task<T>: 표준 완료 프리미티브로 대체하면 가시성·대기·취소·예외 전파가 한 번에 해결된다. 게임 루프에서 블로킹이 싫으면 await 로 논블로킹 대기한다.
  • 스핀이 꼭 필요하면: 매우 짧은 대기에 한해 SpinWait 를 쓰되 플래그는 Volatile.Read 로 읽는다.
  • 결과·완료의 단일 발행점: 완료 상태와 결과를 하나의 원자적 게시로 묶으면(예: 참조 교체) 부분 관측을 없앤다.
  • 트레이드오프: 이벤트 기반 대기는 컨텍스트 스위치 비용이 있지만 긴 대기에 적합하고, 스핀은 초저지연이지만 코어를 태운다.

면접 포인트

  • 비휘발성 플래그 스핀이 왜 JIT 호이스팅으로 무한 루프가 되는가(volatile/Volatile.Read 의 역할).
  • release/acquire 가 없으면 "완료 플래그는 보이는데 결과는 안 보이는" 부분 관측이 왜 생기는가.
  • 직접 만든 동기화 대신 TaskCompletionSource/Task 를 쓰는 이유.