← 문제로

6. Double-Checked Locking의 메모리 재배열 버그

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

해설 — Double-Checked Locking의 메모리 재배열 버그

난이도: 하

요약

이중 검사 잠금(DCL)을 직접 구현했지만 _instance 필드에 volatile이 없다. 따라서 _instance = GameConfig.Load() 에서 "객체 생성·필드 채우기"와 "참조 대입"의 순서가 컴파일러/CPU에 의해 재배열될 수 있다. 다른 스레드가 락 없는 1차 검사에서 null이 아닌(=대입은 끝난) 참조를 봤지만 내부 필드는 아직 안 채워진 부분 초기화 객체를 읽을 수 있다. x86은 강한 메모리 모델이라 거의 안 터지지만, 약한 모델(ARM)이나 JIT 최적화가 강한 릴리스 빌드에서 간헐 발생한다. 해법은 volatile 추가 또는 Lazy<T> 사용이다.

핵심 원리: 발행(publication) 안전성

_instance = new GameConfig{...} 한 줄은 내부적으로 (1) 힙에 객체 할당, (2) 생성자/필드 초기화, (3) 참조를 _instance에 store — 세 단계다. volatile이 없으면 (3)이 (2)보다 먼저 다른 스레드에 보일 수 있다(store-store 재배열, 또는 JIT가 초기화를 store 뒤로 미룸). 다른 스레드는 락 없는 1차 검사에서 (3)만 보고 객체를 사용 → (2)가 아직 안 끝났으면 필드가 기본값(0/null).

문제점

(A) private static GameConfig _instance;volatile 누락 (분류: 동시성/정확성, 핵심)

  • 증상: 락 없는 읽기 경로에서 부분 초기화 객체 관측. MaxPlayers == 0 등.
  • 재현조건: 릴리스 빌드(JIT 최적화 강함) + 다중 코어, 특히 ARM. 디버그/단일 코어에선 거의 안 터짐.
  • 근본원인: volatile이 없으면 (i) 다른 스레드의 _instance 읽기가 acquire 의미를 갖지 못해, 참조를 본 뒤 객체 필드를 읽을 때 stale 값을 볼 수 있고, (ii) 초기화 스레드의 쓰기가 release 의미를 갖지 못해 필드 채우기와 참조 발행의 순서가 보장되지 않는다. .NET 메모리 모델(ECMA)은 일반 필드 쓰기의 순서를 강제하지 않는다(실측 CLR이 x86에서 강하게 동작할 뿐 표준 보장 아님).

(B) 락 없는 1차 검사 if (_instance == null) — acquire 부재 (분류: 동시성)

  • 증상: 이 읽기가 비volatile이라, 참조를 "봤다"는 사실과 그 객체의 필드 가시성 사이에 happens-before가 없다.
  • 근본원인: DCL의 빠른 경로는 의도적으로 락을 건너뛴다. 그렇다면 그 읽기 자체가 acquire여야(=volatile 읽기) 초기화 스레드의 release 쓰기와 짝을 이뤄 필드 가시성이 보장된다. 비volatile 읽기는 load-load 재배열로 필드 읽기가 참조 읽기보다 앞설 수도 있다.

(C) _instance = GameConfig.Load() — release 부재 (분류: 동시성)

  • 증상: (A)(B)의 발행 측. 객체 내부 쓰기들이 참조 대입에 "묶이지" 않는다.
  • 근본원인: 락 안에서 대입하더라도, 락은 1차 검사를 한 스레드의 가시성까지 보장하지 않는다(빠른 경로 독자는 락을 안 잡았으므로). volatile 쓰기여야 그 이전 쓰기들이 release되어, volatile 읽기를 한 독자에게 보인다.

참고: 락(Monitor.Enter/Exit)은 full fence를 동반하지만, 빠른 경로 독자가 그 락을 획득하지 않으므로 락만으로는 부족하다. DCL의 본질은 "독자 대부분이 락을 건너뛴다"이고, 그래서 필드 자체가 volatile이어야 한다.

수정안

방법 1: volatile 추가 (DCL을 유지하는 최소 수정)

public sealed class ConfigCache
{
    private static volatile GameConfig _instance;   // (A) volatile
    private static readonly object _lock = new object();

    public static GameConfig Instance
    {
        get
        {
            if (_instance == null)                   // (B) volatile read = acquire
            {
                lock (_lock)
                {
                    if (_instance == null)
                        _instance = GameConfig.Load();  // (C) volatile write = release
                }
            }
            return _instance;
        }
    }
}

volatile 읽기는 acquire, volatile 쓰기는 release 의미를 가져 "필드 채우기 → 참조 발행" 순서와 가시성이 보장된다.

방법 2: Lazy<T> (권장 — 직접 DCL을 짜지 말 것)

public sealed class ConfigCache
{
    private static readonly Lazy<GameConfig> _lazy =
        new Lazy<GameConfig>(GameConfig.Load, LazyThreadSafetyMode.ExecutionAndPublication);

    public static GameConfig Instance => _lazy.Value;
}

Lazy<T>(기본 ExecutionAndPublication)는 정확히 한 번 실행 + 안전한 발행을 보장한다. 직접 DCL을 짜다 volatile을 빠뜨리는 실수를 원천 차단.

방법 3: 정적 생성자 / 필드 초기화 (가장 단순)

public sealed class ConfigCache
{
    // CLR이 타입 초기화의 스레드 안전·발행 안전을 보장(beforefieldinit 주의)
    private static readonly GameConfig _instance = GameConfig.Load();
    public static GameConfig Instance => _instance;
}

지연 시점이 "타입 최초 접근"으로 충분하면 이게 가장 안전·간단하다. 단, beforefieldinit 최적화로 초기화 시점이 앞당겨질 수 있어 "첫 요청에 정확히 로드"가 중요하면 명시적 정적 생성자로 제어.

더 나은 설계

  1. Lazy<T> 우선: 직접 DCL은 미묘한 메모리 모델 함정이 많다. 표준 라이브러리(Lazy<T>, LazyInitializer.EnsureInitialized)에 위임. 트레이드오프: Lazy<T> 객체 1개의 추가 간접 참조(무시할 수준).

  2. 불변(immutable) 설정 객체: 설정을 init-only/readonly 필드의 불변 객체로 만들면, 일단 참조가 발행된 뒤엔 누구도 수정 못 해 추론이 단순해진다. 핫 리로드가 필요하면 "새 불변 객체를 만들어 참조를 volatile로 원자 교체(swap)"하는 패턴.

  3. LazyThreadSafetyMode 선택: 초기화가 비싸고 중복 실행이 절대 안 되면 ExecutionAndPublication. 초기화가 싸고 멱등이면 PublicationOnly(경쟁 허용, 첫 완료본 채택)로 락 비용 절감. 트레이드오프: 중복 실행 가능성 vs 락 경합.

면접 포인트

  1. DCL에서 volatile이 왜 필요한가? "락 안에서 대입하니 안전하지 않냐"는 반박에 어떻게 답하나? (빠른 경로 독자가 락을 안 잡는다 → 필드 자체가 acquire/release여야 함)
  2. 같은 코드가 x86에선 통과하고 ARM/릴리스에서 깨지는 이유는? .NET 메모리 모델과 실측 CLR 동작의 차이를 구분하라.
  3. C#에서 안전한 지연 초기화 방법들(volatile DCL, Lazy<T>, 정적 생성자)의 차이와 beforefieldinit이 무엇인지 설명하라.