28. 공유 설정의 제자리 수정과 안전 공개(찢긴 스냅샷) (C#)
난이도 최상해설 — 공유 설정의 제자리 수정과 안전 공개(찢긴 스냅샷) (C#)
난이도: 최상
요약
C# 에서 참조 대입은 원자적이라 참조 자체는 찢기지 않는다. 그래서 "Config
객체를 통째로 바꿔치기"하면 안전하다. 그러나 이 코드는 그러지 않고 Reload 가
공개된 Config 객체의 필드를 제자리(in place)에서 수정한다(B). 워커가 잡은
c(=_current 와 같은 객체)는 진짜 스냅샷이 아니라 살아 움직이는 객체라,
DropTable.Count 와 DropTable[...] 사이에 DropTable 이 교체되어 찢긴
스냅샷(옛 Version + 새 DropTable, 또는 인덱스 범위 초과 예외)이 난다. 더해
_current 가 volatile 이 아니라 안전 공개(safe publication) 보장도 없다.
문제점
(1) [찢긴 읽기] 제자리 수정으로 스냅샷이 깨짐 — (A)(B)
- 증상:
RollDrop에서n = c.DropTable.Count(옛 리스트, 예: 3) 직후Reload가c.DropTable = 새 리스트(크기 1)로 바꾸면,c.DropTable[seed % 3]이 새 리스트를 인덱싱해 IndexOutOfRangeException 또는 엉뚱한 값. Version 은 옛 값, DropTable 은 새 값인 불일치 스냅샷도 발생. - 재현: 워커들이
RollDrop도는 중 갱신 스레드가Reload. - 근본원인: "스냅샷"이 불변이 아니다. 공개된 객체의 필드를 나중에 바꾸면,
그 객체를 읽는 모든 워커가 영향을 받는다.
List<T>자체도 동시 읽기/쓰기가 스레드 안전하지 않다(열거 중 변경 예외·내부 배열 손상).
(2) [안전 공개] 가시성·재정렬 — _current 비휘발성
- 증상: 새 객체를 만들어 대입하더라도,
_current가 일반 필드면 다른 코어가 참조는 보이는데 그 객체의 필드 초기화는 아직 안 보이는 순서로 관측할 여지가 있다(약한 메모리 모델/ARM). 즉 부분 초기화 객체 노출. - 근본원인: 공개 시점에 메모리 배리어(
Volatile.Write/Interlocked)가 없음.
C++ 트윈과의 언어 차이
- C++ 에서는 같은
shared_ptr객체에 동시 read/write 자체가 데이터 레이스(UB) 라, 불변 스냅샷을 "통째로 바꿔치기"해도std::atomic_load/store가 필요했다. - C# 에서는 참조 대입이 원자적이라 "통째로 바꿔치기"는 안전하다(참조가 찢기지 않음). 그래서 C# 버전의 버그는 재대입이 아니라 제자리 수정 + 안전 공개 누락이다. 즉 같은 시나리오라도 올바른 패턴이 언어마다 다르다.
수정안
불변 스냅샷을 새로 만들어 한 번에 공개하고, 워커는 참조를 한 번만 읽어 그
스냅샷만 쓴다(읽기 락-프리). 공개는 Volatile.Write/Interlocked.Exchange 로.
public sealed class Config // 공개 후 불변로 취급
{
public int Version { get; init; }
public IReadOnlyList<int> DropTable { get; init; } = System.Array.Empty<int>();
public string MapName { get; init; } = "";
}
public sealed class World
{
private Config _current = new Config();
public int RollDrop(int seed)
{
Config c = Volatile.Read(ref _current); // 참조를 한 번만 읽어 스냅샷 확정
int n = c.DropTable.Count;
return n == 0 ? 0 : c.DropTable[seed % n]; // 같은 c 만 사용 → 일관
}
public void Reload(int version, IReadOnlyList<int> drops, string map)
{
var next = new Config { Version = version, DropTable = drops, MapName = map };
Volatile.Write(ref _current, next); // 완전히 만든 뒤 한 번에 공개
// 또는 Interlocked.Exchange(ref _current, next);
}
}
- 새 객체는 공개 전에 완전 초기화되고,
Volatile.Write가 그 초기화가 참조 공개보다 먼저 보이도록 보장(안전 공개). - 워커는
Volatile.Read로 잡은c만 쓰므로, 갱신이 와도 자기 스냅샷은 불변.
더 나은 설계 (트레이드오프)
- 불변 스냅샷 + 원자 참조 교체(RCU 패턴): 읽기 압도적 우세(설정/라우팅/월드 스냅샷)에 최적. 읽기 락-프리, 갱신만 새 객체.
- 진짜로 부분 갱신이 잦고 큰 객체면
ImmutableInterlocked/ReaderWriterLockSlim검토. 그래도 "공개된 객체는 변경 금지" 원칙은 유지. record(값 의미론·불변)로 Config 를 정의하면 의도가 더 분명.
흔한 오해·함정
- "참조 대입이 원자적이니 동시성 안전" — 참조 교체는 안전하지만 공개된 객체의 필드 수정은 안전하지 않다. 불변으로 공개해야 한다.
- volatile 없이 새 객체 공개 → 약한 메모리 모델에서 부분 초기화 노출 위험.
List<T>를 여러 스레드가 동시에 읽고 쓰기 — 스레드 안전 아님.
면접 포인트
- C# 참조 대입의 원자성과 그 한계(객체 내부 가변성·가시성)를 정확히 구분.
- 안전 공개: 완전히 만든 뒤
Volatile.Write/Interlocked로 공개. - 같은 핫스왑 문제라도 C++(atomic shared_ptr) vs C#(불변+원자 참조 교체) 로 올바른 패턴이 갈린다는 점을 설명할 수 있는지.
해설 — shared_ptr 동시 재대입의 데이터 레이스와 안전 공개 (C++)
난이도: 최상
요약
"shared_ptr 는 스레드 안전하다"는 말은 제어블록의 참조 카운트가 원자적
이라는 뜻일 뿐이다. 같은 shared_ptr 객체를 한 스레드가 재대입(B)하는
동안 다른 스레드가 복사로 읽으면(A), 그 shared_ptr 인스턴스 자체(내부 객체
포인터 + 제어블록 포인터의 쌍)에 대한 데이터 레이스(UB) 다. 찢긴 읽기·참조
카운트 손상으로 크래시나 use-after-free / 누수가 난다.
문제점
(1) [데이터 레이스] 같은 shared_ptr 에 동시 읽기/쓰기 — (A)(B)
- 정확한 구분:
- 안전: 서로 다른
shared_ptr인스턴스가 같은 객체를 가리키며 각 스레드가 자기 인스턴스를 복사/소멸 → 제어블록 refcount 가 원자적이라 OK. - 불안전(이 문제): 하나의
shared_ptr멤버current_를 한 스레드가reload(쓰기)하고 다른 스레드가current()(읽기/복사)함.shared_ptr은 포인터 2개로 이뤄지는데 그 갱신/판독이 한 단위로 원자화되지 않는다.
- 안전: 서로 다른
- 증상: 읽는 쪽이 옛 객체 포인터 + 새 제어블록(또는 그 반대)을 보거나, 막 0 으로 떨어지는 제어블록을 증가시키려다 이미 해제된 제어블록 접근(UAF), 또는 refcount 가 어긋나 이중 해제/누수.
- 근본원인: 표준은 "같은
shared_ptr객체에 대한 동시 비-const 접근"을 레이스로 규정한다. refcount 원자성과 인스턴스 자체의 원자성은 별개다.
수정안
같은 shared_ptr 에 대한 동시 접근을 원자적 로드/스토어로 감싼다.
C++17 — std::atomic_load/std::atomic_store(shared_ptr 특수화)
class World {
public:
std::shared_ptr<Config> current() const {
return std::atomic_load_explicit(¤t_, std::memory_order_acquire); // (A')
}
void reload(std::shared_ptr<Config> next) {
std::atomic_store_explicit(¤t_, std::move(next),
std::memory_order_release); // (B')
}
private:
std::shared_ptr<Config> current_ = std::make_shared<Config>();
};
- 이 free 함수들이
shared_ptr의 포인터 쌍 갱신/판독을 한 원자 단위로 만든다. (C++20 에서 deprecated 지만 C++17 정석.)
C++20 — std::atomic<std::shared_ptr<Config>> (구현별 가용성 주의: g++11 libstdc++ 는 아직 미제공 → 최신 libstdc++/libc++ 필요. 그 전까지는 위 C++17 free 함수가 정석)
std::atomic<std::shared_ptr<Config>> current_{ std::make_shared<Config>() };
std::shared_ptr<Config> current() const { return current_.load(std::memory_order_acquire); }
void reload(std::shared_ptr<Config> next) { current_.store(std::move(next), std::memory_order_release); }
핵심: 워커는 한 번 로드해 얻은 스냅샷만 사용하고, Config 는 공개 후 불변이라 그 스냅샷 내부는 락 없이 읽어도 안전하다(읽기-복사-갱신 RCU 식 교체).
더 나은 설계 (트레이드오프)
- 불변 스냅샷 + 원자 포인터 교체(RCU 패턴): 읽기는 락-프리, 갱신만 새 객체로 교체. 읽기 압도적 우세 워크로드(설정/라우팅 테이블/월드 스냅샷)에 최적.
- 읽기-쓰기 락: 단순하지만 읽기 경합이 크면 atomic shared_ptr 가 유리.
- 옛 스냅샷은 마지막 참조가 사라질 때 자동 해제 — 진행 중 워커가 끝낼 때까지 안전하게 살아 있다.
흔한 오해·함정
- "shared_ptr 은 스레드 안전" → refcount 만. 같은 인스턴스 동시 read/write 는 레이스. 가장 흔한 실전 버그 중 하나.
- 멤버
shared_ptr를 그냥=로 바꾸는 핫스왑 코드는 거의 항상 이 레이스를 품는다(설정 리로드, 콜백 테이블 교체 등).
면접 포인트
- "shared_ptr 스레드 안전"의 정확한 경계(제어블록 원자성 vs 인스턴스 원자성).
- 동시 읽기/쓰기엔
std::atomic_load/store(C++17) 또는std::atomic<shared_ptr>(C++20). - 불변 스냅샷 + 원자 포인터 교체(RCU)로 읽기 락-프리 핫스왑을 구현하는 설계.