← 문제로

42. 원자 변수의 복합 연산 비원자성: peak 갱신 유실과 찢긴 평균 쌍 (C#)

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

해설 — 원자 변수의 복합 연산 비원자성: peak 갱신 유실과 찢긴 평균 쌍 (C#)

난이도: 상

요약

Interlocked 로 개별 연산은 원자적이지만, 여러 원자 연산을 조합한 복합 동작은 원자적이지 않다. RecordConcurrent 의 "읽고-비교하고-쓰기(check-then-act)"는 사이에 다른 스레드가 끼어들어 더 큰 최고치를 덮어써 유실시킨다. RecordHit 의 총합·횟수는 각각 원자적이지만 둘이 한 쌍으로 원자적이지 않아 AverageDamage 가 짝이 안 맞는 값을 읽어 순간적으로 틀린 평균을 낸다.

문제점

(A) peak 갱신의 check-then-act — 최댓값 유실(lost update)

  • 증상: 관측된 실제 최고 동접보다 낮은 값이 peak 로 기록된다.
  • 재현 조건: 현재 peak=10. T1 이 current=20, T2 가 current=15 로 동시에 진입.
    • 둘 다 Read(peak)=10 을 통과(각자 자기 값이 더 큼).
    • T1 Exchange(peak, 20), 그 뒤 T2 Exchange(peak, 15)peak=15 로 20 이 유실.
  • 근본 원인: Interlocked.ReadInterlocked.Exchange 는 각각 원자적이지만 그 사이에 시간 간격이 있다. "조건부 갱신"은 단일 원자 연산(CAS)으로 해야 한다.

(B) 총합·횟수 쌍의 비원자성 — 찢긴 스냅샷(torn pair)

  • 증상: AverageDamage 가 순간적으로 실제와 다른 값을 반환(예: 총합은 새 히트 반영, 횟수는 미반영 → 평균 과대).
  • 재현 조건: RecordHitAdd(total, dmg) 를 마치고 Increment(count) 하기 전에, 대시보드가 AverageDamage 를 호출 → total 은 갱신본, count 는 이전본 → 평균이 부풀려짐. 반대 순서로 읽으면 과소.
  • 근본 원인: 두 원자 변수의 갱신(그리고 읽기)이 하나의 원자 단위로 묶이지 않았다. 개별 원자성은 있어도 일관된 쌍(consistent snapshot) 이 보장되지 않는다.

수정안

(A)는 CAS 루프로, (B)는 쌍의 일관성이 필요한지에 따라 락 또는 단일 원자 구조로 처리한다.

public void RecordConcurrent(long current)
{
    // CAS 루프: 관측한 최고치보다 클 때만, 그 값이 안 바뀌었을 때만 교체
    long observed = Interlocked.Read(ref _peakConcurrent);
    while (current > observed)
    {
        long prev = Interlocked.CompareExchange(ref _peakConcurrent, current, observed);
        if (prev == observed) break;   // 성공
        observed = prev;               // 누가 바꿨다 → 다시 비교
    }
}

(B) — 평균이 항상 일관된 쌍이어야 하면 둘을 함께 보호한다:

private readonly object _statLock = new();
private long _totalDamage, _hitCount;

public void RecordHit(long damage)
{
    lock (_statLock) { _totalDamage += damage; _hitCount++; }   // 쌍을 원자적으로
}
public double AverageDamage()
{
    lock (_statLock) { return _hitCount == 0 ? 0.0 : (double)_totalDamage / _hitCount; }
}
  • (A) 의 CompareExchange 는 "내가 본 값이 그대로일 때만 교체"를 원자적으로 수행 → 더 큰 값이 덮이지 않는다. 실패하면 최신값으로 재비교(락프리, 진행 보장).
  • (B) 는 "총합/횟수의 일관된 쌍"이 필수인지에 달렸다. 필수면 락(또는 seqlock/단일 128비트 구조)이 필요하고, 순간적 오차를 허용(모니터링 지표) 한다면 개별 원자 유지 + "근사값" 명시로도 충분하다 — 요구사항에 따라 선택.

더 나은 설계

  • 락프리 max: Interlocked.CompareExchange 기반 max 헬퍼는 표준 관용구. .NET 9+ 의 Interlocked.Or/max 류가 없으면 직접 CAS 루프.
  • 경합 분산(sharded counters): 총합/횟수를 스레드/코어별로 나눠 누적하고 조회 시 합산 → 원자 변수 경합(캐시 라인 핑퐁)을 줄인다. 단 조회는 여전히 스냅샷 일관성을 별도로 다뤄야 함.
  • seqlock 스냅샷: 쓰기는 짧은 시퀀스 번호로 감싸고, 읽기는 시퀀스가 짝수·전후 동일할 때만 채택 → 락 없이 일관된 쌍 읽기(읽기 편중에 유리).
  • 트레이드오프: CAS 루프는 락프리지만 고경합 시 재시도 증가. 락은 단순·일관적이나 경합 시 직렬화. 지표가 "근사면 충분"하면 개별 원자로 두고 일관성 요구를 낮추는 것이 가장 싸다.

면접 포인트

  • 원자 연산의 원자성 ≠ 복합 연산의 원자성: Read+Exchange, Add+Increment, 두 변수 읽기 — 각각 원자적이어도 합치면 레이스가 남는다.
  • 조건부 갱신(max/min/조건부 대입)은 CAS 루프로. CompareExchange 의 반환값(이전값)으로 성공/재시도를 판정하는 관용구.
  • "일관된 스냅샷"이 정말 필요한지 요구사항으로 되묻기 — 필요하면 락/seqlock, 근사 지표면 개별 원자로 충분. 과잉 동기화도 비용이다.