6. Double-Checked Locking의 메모리 재배열 버그
난이도 하해설 — 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 최적화로 초기화 시점이 앞당겨질 수 있어 "첫 요청에 정확히 로드"가 중요하면 명시적 정적 생성자로 제어.
더 나은 설계
-
Lazy<T>우선: 직접 DCL은 미묘한 메모리 모델 함정이 많다. 표준 라이브러리(Lazy<T>,LazyInitializer.EnsureInitialized)에 위임. 트레이드오프:Lazy<T>객체 1개의 추가 간접 참조(무시할 수준). -
불변(immutable) 설정 객체: 설정을
init-only/readonly 필드의 불변 객체로 만들면, 일단 참조가 발행된 뒤엔 누구도 수정 못 해 추론이 단순해진다. 핫 리로드가 필요하면 "새 불변 객체를 만들어 참조를volatile로 원자 교체(swap)"하는 패턴. -
LazyThreadSafetyMode선택: 초기화가 비싸고 중복 실행이 절대 안 되면ExecutionAndPublication. 초기화가 싸고 멱등이면PublicationOnly(경쟁 허용, 첫 완료본 채택)로 락 비용 절감. 트레이드오프: 중복 실행 가능성 vs 락 경합.
면접 포인트
- DCL에서
volatile이 왜 필요한가? "락 안에서 대입하니 안전하지 않냐"는 반박에 어떻게 답하나? (빠른 경로 독자가 락을 안 잡는다 → 필드 자체가 acquire/release여야 함) - 같은 코드가 x86에선 통과하고 ARM/릴리스에서 깨지는 이유는? .NET 메모리 모델과 실측 CLR 동작의 차이를 구분하라.
- C#에서 안전한 지연 초기화 방법들(
volatileDCL,Lazy<T>, 정적 생성자)의 차이와beforefieldinit이 무엇인지 설명하라.
해설 — Double-Checked Locking의 메모리 재배열 버그
난이도: 하
요약
이중 검사 잠금(DCL)을 직접 구현했지만 instance_ 포인터가 일반 GameConfig* 이고 동기화 수단이 없다. instance_ = GameConfig::Load() 에서 "객체 생성·필드 채우기"와 "포인터 발행(store)"의 순서가 컴파일러/CPU에 의해 재배열될 수 있고, 락 없는 1차 검사의 읽기도 동기화 없는 일반 읽기다. 그래서 비원자 변수에 대한 동시 읽기/쓰기 자체가 데이터 레이스 = UB이며, 다른 스레드가 null 아닌(=대입은 끝난) 포인터를 봤지만 내부 필드는 아직 안 채워진 부분 초기화 객체를 읽을 수 있다. x86은 강한 메모리 모델(TSO)이라 거의 안 터지지만, 약한 모델(ARM/AArch64)에서 간헐 발생한다. 해법은 std::atomic + acquire/release, std::call_once, 또는 함수 지역 static이다.
핵심 원리: 발행(publication) 안전성
instance_ = new GameConfig{...} 한 줄은 내부적으로 (1) 힙에 객체 할당, (2) 생성자/필드 초기화, (3) 포인터를 instance_에 store — 세 단계다. 동기화가 없으면 (3)이 (2)보다 먼저 다른 스레드에 보일 수 있다(store-store 재배열). 다른 스레드는 락 없는 1차 검사에서 (3)만 보고 객체를 사용 → (2)가 아직 안 끝났으면 필드가 기본값(0). 읽는 측도 acquire가 없으면 포인터를 본 뒤 필드 읽기가 재배열될 수 있다.
문제점
(A) static GameConfig* instance_; — 비원자 공유 (분류: 동시성/정확성, 핵심)
- 증상: 락 없는 읽기 경로에서 부분 초기화 객체 관측.
MaxPlayers == 0등. - 재현조건: 다중 코어, 특히 ARM/AArch64. x86/단일 코어에선 거의 안 터짐.
- 근본원인: 일반 포인터에 대한 동시 읽기(1차 검사)와 쓰기(발행)는 C++ 메모리 모델상 데이터 레이스 = UB. happens-before가 없어 (i) 발행 측 쓰기가 release되지 않고(필드 채우기와 포인터 발행 순서 미보장), (ii) 읽기 측이 acquire가 아니라 포인터를 봤다는 사실과 객체 필드 가시성 사이에 순서 보장이 없다.
std::atomic<GameConfig*>+ release/acquire가 필요하다.
(B) 락 없는 1차 검사 if (instance_ == nullptr) — acquire 부재 (분류: 동시성)
- 증상: 이 읽기가 비원자라, 포인터를 "봤다"는 사실과 그 객체 필드의 가시성 사이에 happens-before가 없다.
- 근본원인: DCL의 빠른 경로는 의도적으로 락을 건너뛴다. 그렇다면 그 읽기 자체가 acquire여야(=
load(memory_order_acquire)) 발행 스레드의 release store와 짝을 이뤄 필드 가시성이 보장된다. 비원자 읽기는 load-load 재배열로 필드 읽기가 포인터 읽기보다 앞설 수도 있다.
(C) instance_ = GameConfig::Load() — release 부재 (분류: 동시성)
- 증상: (A)(B)의 발행 측. 객체 내부 쓰기들이 포인터 발행에 "묶이지" 않는다.
- 근본원인: 락(
std::mutex) 안에서 대입하더라도, 빠른 경로 독자는 그 락을 잡지 않으므로 락의 release 의미가 그 독자에게 전달되지 않는다. 발행 자체가store(memory_order_release)여야 그 이전 필드 쓰기들이 release되어, acquire 읽기를 한 독자에게 보인다.
참고:
std::mutex의 lock/unlock은 acquire/release 의미를 갖지만, 빠른 경로 독자가 그 뮤텍스를 획득하지 않으므로 뮤텍스만으로는 부족하다. DCL의 본질은 "독자 대부분이 락을 건너뛴다"이고, 그래서 포인터 자체가 atomic(acquire/release)이어야 한다.
수정안
방법 1: std::atomic + acquire/release (DCL을 유지하는 정석)
class ConfigCache
{
public:
static GameConfig* Instance()
{
// (B) acquire load: 발행 측 release와 짝 → 필드 가시성 확보
GameConfig* p = instance_.load(std::memory_order_acquire);
if (p == nullptr)
{
std::lock_guard<std::mutex> lk(mutex_);
p = instance_.load(std::memory_order_relaxed); // 락 안: relaxed로 충분
if (p == nullptr)
{
p = GameConfig::Load();
// (C) release store: 위 객체 초기화를 포인터 발행에 묶음
instance_.store(p, std::memory_order_release);
}
}
return p;
}
private:
static std::atomic<GameConfig*> instance_; // (A) atomic
static std::mutex mutex_;
};
std::atomic<GameConfig*> ConfigCache::instance_{nullptr};
std::mutex ConfigCache::mutex_;
acquire load는 발행 스레드의 release store와 synchronizes-with 관계를 만들어 "필드 채우기 → 포인터 발행" 순서와 가시성이 보장된다.
방법 2: std::call_once (권장 — 직접 DCL을 짜지 말 것)
class ConfigCache
{
public:
static GameConfig* Instance()
{
std::call_once(flag_, [] { instance_ = GameConfig::Load(); });
return instance_;
}
private:
static std::once_flag flag_;
static GameConfig* instance_;
};
std::call_once는 정확히 한 번 실행 + 발행 안전(initializer의 효과가 모든 호출자에게 보임)을 보장한다. 직접 DCL을 짜다 atomic/배리어를 빠뜨리는 실수를 원천 차단.
방법 3: 함수 지역 static (가장 단순, C++11 magic static)
GameConfig& Instance()
{
static GameConfig* s = GameConfig::Load(); // 스레드 안전한 1회 초기화 보장(C++11)
return *s;
}
C++11부터 함수 지역 static의 초기화는 스레드 안전(컴파일러가 내부적으로 guard variable + 동기화 생성)이며 발행 안전하다. 지연 시점이 "함수 최초 호출"로 충분하면 가장 간단하고 안전하다.
더 나은 설계
-
std::call_once/ magic static 우선: 직접 DCL은 미묘한 메모리 모델 함정이 많다. 표준 메커니즘에 위임. 트레이드오프: magic static은 매 호출 시 guard 체크(초기화 후엔 거의 공짜)가 있으나 무시할 수준. -
불변(immutable) 설정 객체: 설정을
const/한번 채우면 안 바뀌는 객체로 만들면, 발행 후엔 누구도 수정 못 해 추론이 단순해진다. 핫 리로드가 필요하면 "새 불변 객체를 만들어std::atomic<shared_ptr<const GameConfig>>(C++20)로 원자 교체(swap)"하는 패턴. -
수명 관리: 위 예처럼 raw
new면 프로그램 종료까지 누수(싱글턴이라 의도적일 수 있음). 명시 해제가 필요하면std::unique_ptr/shared_ptr로 관리하거나 magic static(자동 소멸)을 쓴다.
면접 포인트
- DCL에서 왜 단순
std::mutex만으로 부족한가? "락 안에서 대입하니 안전하지 않냐"는 반박에 어떻게 답하나? (빠른 경로 독자가 락을 안 잡는다 → 포인터 자체가 atomic acquire/release여야 함) - 같은 코드가 x86에선 통과하고 ARM에서 깨지는 이유는? TSO와 약한 메모리 모델, store-store/load-load 재배열, 그리고 컴파일러 재배열의 역할을 구분하라.
- C++에서 안전한 지연 초기화 방법들(
std::atomicDCL,std::call_once, 함수 지역 static)의 차이와 트레이드오프는? C++11 magic static이 스레드 안전한 근거는?