1. Zone 통계 수집기의 데이터 레이스 (lost update / torn read)
난이도 하해설 — Zone 통계 수집기의 데이터 레이스 (lost update / torn read)
난이도: 하
요약
_totalDamage += amount, _killCount++, _maxSingleHit 비교-갱신은 모두 읽기-수정-쓰기(RMW) 연산인데, 락이나 원자 연산 없이 여러 워커 스레드가 동시에 수행한다. 결과적으로 누적값이 유실(lost update) 되어 기대값보다 작게 나온다. 또한 Snapshot()은 세 필드를 따로 읽어 서로 다른 시점의 값이 섞인 일관성 없는 스냅샷을 반환할 수 있다. 핫패스이므로 락 대신 Interlocked를 쓰는 것이 정답이다.
문제점
(A) _totalDamage += amount — lost update (분류: 동시성/정확성)
- 증상: 최종
damage가 기대값(workers * hitsPerWorker만큼의 합)보다 작게 나온다. 실행할 때마다 값이 다르다. - 재현조건: 워커 2개 이상이 동시에
AddDamage호출. 코어 수가 많고 호출이 잦을수록 유실이 커진다. - 근본원인:
x += a는 원자적이지 않다. 내부적으로tmp = load(x); tmp = tmp + a; store(x, tmp)3단계다. 스레드 A와 B가 같은x를 읽고 각자 더한 뒤 쓰면, 한쪽의 증분이 통째로 덮어써진다. 게다가long은 64비트라 32비트 환경/구조체 필드 배치에 따라 store 자체가 찢어질(torn write) 수도 있다(.NET은long단일 대입의 원자성을 정렬된 경우에만 보장하며,+=는 어차피 비원자다).
(B) if (amount > _maxSingleHit) _maxSingleHit = amount — check-then-act 레이스 (분류: 동시성/정확성)
- 증상: 실제 최대 피해보다 작은 값이 기록되거나, 갱신이 유실된다.
- 재현조건: 두 스레드가 거의 동시에 더 큰 값으로 갱신을 시도.
- 근본원인: 비교와 대입 사이에 다른 스레드가 끼어든다. A가
500 > 100통과 후 대입 직전에 B가900을 대입하면, A가 다시500으로 덮어써서900이 사라진다. 전형적인 "비교 후 행동(check-then-act)" 레이스다.
(C) Snapshot() — torn / 비일관 스냅샷 (분류: 동시성)
- 증상: 대시보드에 "킬 수는 늘었는데 데미지는 그대로"처럼 서로 어긋난 조합이 잠깐 보인다.
- 근본원인: 세 필드를 락 없이 따로 읽으므로, 읽는 중간에 다른 스레드가 일부 필드만 갱신할 수 있다. 세 값이 동일한 논리적 시점의 스냅샷이라는 보장이 없다. (
_hasData = true역시 다른 필드 쓰기보다 먼저 보일 수 있어, 운영 스레드가HasData만 보고 0 데이터를 읽을 수 있다 — 가시성/재배열 문제.)
참고:
bool _hasData는 그 자체로는 단어 단위 쓰기라 찢어지지 않지만, JIT/CPU 재배열로 인해 다른 스레드에서의 가시성·순서가 보장되지 않는다.
수정안
핫패스는 락보다 Interlocked가 훨씬 가볍다. 누적은 Interlocked.Add, 최대값은 CAS 루프, 스냅샷 일관성은 Interlocked.Read로 처리한다.
public sealed class ZoneMetrics
{
private long _totalDamage;
private long _killCount; // int 대신 long: Interlocked.Read 활용 + 정렬 보장
private long _maxSingleHit;
private volatile bool _hasData;
public void AddDamage(long amount)
{
Interlocked.Add(ref _totalDamage, amount); // (A) 원자적 누적
// (B) CAS 루프로 최대값 갱신
long observed = Interlocked.Read(ref _maxSingleHit);
while (amount > observed)
{
long prev = Interlocked.CompareExchange(ref _maxSingleHit, amount, observed);
if (prev == observed) break; // 성공
observed = prev; // 누가 끼어듦 → 다시 시도
}
_hasData = true; // volatile write: 위 갱신들 뒤에 가시화
}
public void AddKill() => Interlocked.Increment(ref _killCount);
public (long damage, long kills, long maxHit) Snapshot()
{
// (C) 각 필드를 원자적으로 읽음. 64비트 값은 Interlocked.Read로 torn read 방지.
return (
Interlocked.Read(ref _totalDamage),
Interlocked.Read(ref _killCount),
Interlocked.Read(ref _maxSingleHit));
}
public bool HasData => _hasData;
}
주의점:
- 32비트 플랫폼에서
long의 단순 읽기는 찢어질 수 있으므로Interlocked.Read를 쓴다(64비트 플랫폼에서도 의도를 드러내는 효과). _killCount를int에서long으로 바꾼 이유는Interlocked.Read가long전용이고, 정렬·일관성을 단순하게 가져가기 위함이다.int로 유지하려면 단순 읽기로도 원자성은 보장되지만(int정렬 시), 통일성을 위해long권장.
더 나은 설계
-
완전 일관 스냅샷이 필요하면
volatile세대 카운터(seqlock) 또는 짧은 락:Interlocked세 번은 각각은 원자적이지만 "셋이 한 시점"이라는 보장은 여전히 없다. 대시보드 표시 용도로는 충분하지만, 정산/검증처럼 정합성이 중요하면 쓰기 측에lock또는 seqlock(짝수=안정, 홀수=쓰기중)을 두고 읽기 측이 세대 번호로 재시도한다. 트레이드오프: 정합성 보장 vs 핫패스 오버헤드.
-
스레드 로컬 누적 + 주기적 머지 (가장 권장):
- 각 워커가
[ThreadStatic]또는 per-worker 카운터에 락/Interlocked 없이 누적하고, 1초마다 운영 스레드가 모든 워커의 값을 합산한다. 핫패스에서 경합이 완전히 사라진다(false sharing만 주의 → 패딩). 게임서버 통계의 표준 패턴이며 처리량이 가장 높다. 트레이드오프: 스냅샷이 최대 1틱 지연된다(통계엔 무방).
- 각 워커가
-
잡큐(Actor)로 단일 스레드 집계:
- 워커는 "+damage" 메시지를 큐에 넣고, 통계 전용 스레드 1개가 순차 처리. 동시성 문제가 원천 차단되나, 큐잉 비용과 지연이 생긴다.
면접 포인트
x++가 원자적이지 않은 이유를 어셈블리/RMW 관점에서 설명하라.volatile을 붙이면 해결되는가? (→ 아니다.volatile은 가시성/순서만 다루고 원자성을 주지 않는다.)Interlocked.CompareExchange기반 최대값 갱신 루프가 왜 필요한가?Interlocked.Add처럼 한 방에 안 되는 이유는?- 64비트 플랫폼에서
long읽기는 원자적인데도Interlocked.Read를 권장하는 경우는 언제인가? (정렬 보장 안 되는 구조체 필드, 32비트 타깃, 의도 표현 등.)
해설 — Zone 통계 수집기의 데이터 레이스 (lost update / torn read)
난이도: 하
요약
totalDamage_ += amount, killCount_++, maxSingleHit_ 비교-갱신은 모두 읽기-수정-쓰기(RMW) 연산인데, 락이나 원자 연산 없이 여러 워커 스레드가 동시에 수행한다. C++에서 일반 변수에 대한 동시 읽기/쓰기는 데이터 레이스 = 정의되지 않은 동작(UB) 이다. 결과적으로 누적값이 유실(lost update) 되어 기대값보다 작게 나오고, 컴파일러가 레이스를 가정하지 않고 최적화하므로 더 황당한 결과도 가능하다. 또한 Snapshot()은 세 필드를 따로 읽어 서로 다른 시점의 값이 섞인 일관성 없는 스냅샷을 반환한다. 핫패스이므로 뮤텍스 대신 std::atomic을 쓰는 것이 정답이다.
문제점
(A) totalDamage_ += amount — lost update / data race (분류: 동시성/정확성)
- 증상: 최종
damage가 기대값(workers * hitsPerWorker만큼의 합)보다 작게 나온다. 실행할 때마다 값이 다르다. - 재현조건: 워커 2개 이상이 동시에
AddDamage호출. 코어 수가 많고 호출이 잦을수록 유실이 커진다. - 근본원인:
x += a는 원자적이지 않다. 내부적으로tmp = load(x); tmp = tmp + a; store(x, tmp)3단계다. 스레드 A와 B가 같은x를 읽고 각자 더한 뒤 쓰면, 한쪽의 증분이 통째로 덮어써진다. 게다가 C++ 메모리 모델상 비원자 변수에 대한 동시 접근(하나라도 쓰기)은 UB이며, 컴파일러는 "이 변수는 다른 스레드가 안 건드린다"고 가정해 레지스터에 올려두거나 호이스팅·재배열할 수 있어 결과가 예측 불가다. 64비트int64_t라도 일부 플랫폼에서 store 자체가 찢어질(torn write) 수 있다.
(B) if (amount > maxSingleHit_) maxSingleHit_ = amount — check-then-act 레이스 (분류: 동시성/정확성)
- 증상: 실제 최대 피해보다 작은 값이 기록되거나, 갱신이 유실된다.
- 재현조건: 두 스레드가 거의 동시에 더 큰 값으로 갱신을 시도.
- 근본원인: 비교와 대입 사이에 다른 스레드가 끼어든다. A가
500 > 100통과 후 대입 직전에 B가900을 대입하면, A가 다시500으로 덮어써서900이 사라진다. 전형적인 "비교 후 행동(check-then-act)" 레이스다.
(C) Snapshot() — torn / 비일관 스냅샷 (분류: 동시성)
- 증상: 대시보드에 "킬 수는 늘었는데 데미지는 그대로"처럼 서로 어긋난 조합이 잠깐 보인다.
- 근본원인: 세 필드를 동기화 없이 따로 읽으므로, 읽는 중간에 다른 스레드가 일부 필드만 갱신할 수 있다. 세 값이 동일한 논리적 시점의 스냅샷이라는 보장이 없다. (
hasData_ = true역시 다른 필드 쓰기보다 먼저 보일 수 있어, 운영 스레드가HasData()만 보고 0 데이터를 읽을 수 있다 — 가시성/재배열 문제.)
참고:
bool hasData_는 그 자체로는 단어 단위 쓰기지만, 비원자라 다른 스레드에서의 가시성·순서가 보장되지 않는다(컴파일러/CPU 재배열).
수정안
핫패스는 뮤텍스보다 std::atomic이 훨씬 가볍다. 누적은 fetch_add, 최대값은 CAS 루프, 가시성은 적절한 memory_order로 처리한다.
#include <atomic>
#include <cstdint>
#include <tuple>
class ZoneMetrics
{
public:
void AddDamage(std::int64_t amount)
{
// (A) 원자적 누적. 통계 용도라 relaxed로 충분(순서 의존 없음, 손실만 막으면 됨)
totalDamage_.fetch_add(amount, std::memory_order_relaxed);
// (B) CAS 루프로 최대값 갱신
std::int64_t observed = maxSingleHit_.load(std::memory_order_relaxed);
while (amount > observed)
{
// 성공하면 break, 실패하면 observed에 현재값이 들어와 재시도
if (maxSingleHit_.compare_exchange_weak(
observed, amount,
std::memory_order_relaxed, std::memory_order_relaxed))
break;
}
// release: 위 누적/갱신이 hasData_=true 관측자에게 보이도록 발행
hasData_.store(true, std::memory_order_release);
}
void AddKill()
{
killCount_.fetch_add(1, std::memory_order_relaxed); // (A) 원자적 증가
}
std::tuple<std::int64_t, std::int64_t, std::int64_t> Snapshot() const
{
// (C) 각 필드를 원자적으로 읽음(찢김 없음). relaxed면 충분(통계 표시 용도).
return {
totalDamage_.load(std::memory_order_relaxed),
killCount_.load(std::memory_order_relaxed),
maxSingleHit_.load(std::memory_order_relaxed)};
}
bool HasData() const { return hasData_.load(std::memory_order_acquire); }
private:
std::atomic<std::int64_t> totalDamage_{0};
std::atomic<std::int64_t> killCount_{0};
std::atomic<std::int64_t> maxSingleHit_{0};
std::atomic<bool> hasData_{false};
};
주의점:
fetch_add/CAS는 lock-free atomic이라 뮤텍스 없이 원자성을 보장한다.std::atomic<int64_t>::is_always_lock_free로 타깃에서 lock-free인지 확인할 수 있다(주류 64비트 플랫폼은 true).memory_order_relaxed는 원자성(찢김 없음) 은 주지만 다른 변수와의 순서/가시성은 안 준다. 여기선 카운터가 서로 독립이고 통계 표시 용도라 relaxed로 충분하며, "데이터 들어왔다"는 신호만hasData_의 release/acquire로 묶었다.- CAS 루프에서
compare_exchange_weak는 spurious failure(가짜 실패)가 가능하지만 루프 안이라 무방하며, 일부 플랫폼(특히 ARM LL/SC)에서_strong보다 빠르다.
더 나은 설계
-
완전 일관 스냅샷이 필요하면 seqlock 또는 짧은 락:
atomic세 번은 각각 원자적이지만 "셋이 한 시점"이라는 보장은 없다. 대시보드 표시엔 충분하나, 정산/검증처럼 정합성이 중요하면 쓰기 측에 짧은std::mutex또는 seqlock(짝수=안정, 홀수=쓰기중 시퀀스 카운터)을 두고 읽기 측이 시퀀스로 재시도한다. 트레이드오프: 정합성 보장 vs 핫패스 오버헤드.
-
스레드 로컬 누적 + 주기적 머지 (가장 권장):
- 각 워커가
thread_local또는 per-worker 카운터에 atomic 없이 누적하고, 1초마다 운영 스레드가 모든 워커의 값을 합산한다. 핫패스에서 경합이 완전히 사라진다(false sharing만 주의 →alignas(64)패딩). 게임서버 통계의 표준 패턴이며 처리량이 가장 높다. 트레이드오프: 스냅샷이 최대 1틱 지연.
- 각 워커가
-
잡큐(Actor)로 단일 스레드 집계:
- 워커는 "+damage" 메시지를 큐에 넣고, 통계 전용 스레드 1개가 순차 처리. 동시성 문제가 원천 차단되나, 큐잉 비용과 지연이 생긴다.
면접 포인트
x++가 원자적이지 않은 이유를 어셈블리/RMW 관점에서 설명하라.volatile을 붙이면 해결되는가? (→ 아니다. C++의volatile은 원자성도 스레드 간 가시성도 주지 않는다.std::atomic이 필요하다.)compare_exchange기반 최대값 갱신 루프가 왜 필요한가?fetch_add처럼 한 방에 안 되는 이유는?compare_exchange_weakvs_strong의 차이는?memory_order_relaxed/acquire/release의 차이는? 독립 카운터에 relaxed가 충분한 이유와, "데이터 도착 신호"엔 왜 release/acquire가 필요한지 설명하라. 비원자 변수의 동시 접근이 왜 UB인가?