← 문제로

1. Zone 통계 수집기의 데이터 레이스 (lost update / torn read)

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

해설 — 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비트 플랫폼에서도 의도를 드러내는 효과).
  • _killCountint에서 long으로 바꾼 이유는 Interlocked.Readlong 전용이고, 정렬·일관성을 단순하게 가져가기 위함이다. int로 유지하려면 단순 읽기로도 원자성은 보장되지만(int 정렬 시), 통일성을 위해 long 권장.

더 나은 설계

  1. 완전 일관 스냅샷이 필요하면 volatile 세대 카운터(seqlock) 또는 짧은 락:

    • Interlocked 세 번은 각각은 원자적이지만 "셋이 한 시점"이라는 보장은 여전히 없다. 대시보드 표시 용도로는 충분하지만, 정산/검증처럼 정합성이 중요하면 쓰기 측에 lock 또는 seqlock(짝수=안정, 홀수=쓰기중)을 두고 읽기 측이 세대 번호로 재시도한다. 트레이드오프: 정합성 보장 vs 핫패스 오버헤드.
  2. 스레드 로컬 누적 + 주기적 머지 (가장 권장):

    • 각 워커가 [ThreadStatic] 또는 per-worker 카운터에 락/Interlocked 없이 누적하고, 1초마다 운영 스레드가 모든 워커의 값을 합산한다. 핫패스에서 경합이 완전히 사라진다(false sharing만 주의 → 패딩). 게임서버 통계의 표준 패턴이며 처리량이 가장 높다. 트레이드오프: 스냅샷이 최대 1틱 지연된다(통계엔 무방).
  3. 잡큐(Actor)로 단일 스레드 집계:

    • 워커는 "+damage" 메시지를 큐에 넣고, 통계 전용 스레드 1개가 순차 처리. 동시성 문제가 원천 차단되나, 큐잉 비용과 지연이 생긴다.

면접 포인트

  1. x++가 원자적이지 않은 이유를 어셈블리/RMW 관점에서 설명하라. volatile을 붙이면 해결되는가? (→ 아니다. volatile은 가시성/순서만 다루고 원자성을 주지 않는다.)
  2. Interlocked.CompareExchange 기반 최대값 갱신 루프가 왜 필요한가? Interlocked.Add처럼 한 방에 안 되는 이유는?
  3. 64비트 플랫폼에서 long 읽기는 원자적인데도 Interlocked.Read를 권장하는 경우는 언제인가? (정렬 보장 안 되는 구조체 필드, 32비트 타깃, 의도 표현 등.)