42. 원자 변수의 복합 연산 비원자성: peak 갱신 유실과 찢긴 평균 쌍 (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), 그 뒤 T2Exchange(peak, 15)→ peak=15 로 20 이 유실.
- 둘 다
- 근본 원인:
Interlocked.Read와Interlocked.Exchange는 각각 원자적이지만 그 사이에 시간 간격이 있다. "조건부 갱신"은 단일 원자 연산(CAS)으로 해야 한다.
(B) 총합·횟수 쌍의 비원자성 — 찢긴 스냅샷(torn pair)
- 증상:
AverageDamage가 순간적으로 실제와 다른 값을 반환(예: 총합은 새 히트 반영, 횟수는 미반영 → 평균 과대). - 재현 조건:
RecordHit이Add(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, 근사 지표면 개별 원자로 충분. 과잉 동기화도 비용이다.
해설 — 원자 변수의 복합 연산 비원자성: peak 갱신 유실과 찢긴 평균 쌍 (C++)
난이도: 상
요약
std::atomic 의 개별 연산(load/store/fetch_add)은 원자적이지만, 여러 원자 연산을 조합한 복합 동작은 원자적이지 않다. recordConcurrent 의 "load → 비교 → store(check-then-act)"는 사이에 다른 스레드가 끼어들어 더 큰 최고치를 덮어써 유실시킨다. recordHit 의 totalDamage_·hitCount_ 는 각각 원자적이지만 둘이 한 쌍으로 원자적이지 않아 averageDamage 가 짝이 안 맞는 값을 읽어 순간적으로 틀린 평균을 낸다. 여기에 모든 접근이 memory_order_relaxed 라 순서 보장도 없다.
문제점
(A) peak 갱신의 check-then-act — 최댓값 유실(lost update)
- 증상: 관측된 실제 최고치보다 낮은 값이 peak 로 남는다.
- 재현 조건: peak=10. T1
current=20, T2current=15동시 진입.- 둘 다
load()=10통과. - T1
store(20), 이어 T2store(15)→ peak=15, 20 유실.
- 둘 다
- 근본 원인:
load와store는 각각 원자적이나 그 사이 간극이 있다. 조건부 갱신은compare_exchange(CAS) 단일 원자 연산으로 해야 한다.
(B) 총합·횟수 쌍의 비원자성 — 찢긴 스냅샷(torn pair)
- 증상:
averageDamage가 순간적으로 실제와 다른 값을 반환. - 재현 조건:
recordHit이totalDamage_.fetch_add(dmg)후hitCount_.fetch_add(1)전에 대시보드가averageDamage호출 → total 은 새 값, count 는 이전 값 → 평균 과대(반대 순서면 과소). - 근본 원인: 두 원자 변수의 갱신·읽기가 하나의 원자 단위로 묶이지 않았다. 개별 원자성만으로는 일관된 쌍을 보장하지 못한다.
수정안
(A)는 CAS 루프로, (B)는 쌍 일관성이 필요한지에 따라 뮤텍스 또는 seqlock 으로 처리한다. 순서 보장이 필요한 곳은 relaxed 를 상향한다.
void recordConcurrent(int64_t current) {
int64_t observed = peakConcurrent_.load(std::memory_order_relaxed);
while (current > observed) {
// observed 가 그대로일 때만 current 로 교체(원자적 조건부 갱신)
if (peakConcurrent_.compare_exchange_weak(observed, current,
std::memory_order_relaxed))
break; // 성공. 실패 시 observed 에 최신값이 실려 재비교
}
}
compare_exchange_weak 는 실패하면 observed 를 현재값으로 갱신해주므로 루프가 자연스럽다. weak 는 가짜 실패(spurious)가 가능하지만 루프 안에서는 무해하고 일부 아키텍처에서 더 빠르다.
(B) — 일관된 쌍이 필수라면 뮤텍스로 묶는다:
void recordHit(int64_t damage) {
std::lock_guard<std::mutex> g(m_);
total_ += damage; ++count_; // 쌍을 원자적으로
}
double averageDamage() const {
std::lock_guard<std::mutex> g(m_);
return count_ == 0 ? 0.0 : static_cast<double>(total_) / count_;
}
// private: mutable std::mutex m_; int64_t total_=0, count_=0;
- (A) 의 CAS 루프는 더 큰 값이 덮이는 것을 원천 차단(락프리, 진행 보장).
- (B) 는 "일관된 쌍"이 필요한지에 달렸다. 필수면 뮤텍스나 seqlock. 모니터링 근사값이면 개별 원자 유지 + 근사 명시로도 충분하다.
더 나은 설계
- seqlock 스냅샷: 쓰기를 짝수/홀수 시퀀스로 감싸고, 읽기는 전후 시퀀스가 같고 짝수일 때만 채택 → 락 없이 일관된 (total,count) 읽기. 읽기 편중에 유리(단 쓰기 측 메모리 순서 정확히 필요).
- 샤딩 카운터: total/count 를 코어별로 나눠 누적, 조회 시 합산 → 원자 변수 경합(캐시 라인 핑퐁) 완화. 조회 스냅샷 일관성은 별도.
- 메모리 순서: 통계가 다른 상태 공개와 happens-before 로 엮여야 하면 relaxed 대신 acquire/release 를 쓴다. 순수 카운터면 relaxed 로도 정확성엔 문제없다(순서 요구가 없으므로) — 단 본 문제의 결함은 순서가 아니라 복합 연산 원자성이 핵심.
- 트레이드오프: CAS 루프는 고경합 시 재시도↑, 뮤텍스는 직렬화, seqlock 은 읽기 빠르나 구현 까다로움. 근사 지표면 개별 원자가 가장 저렴.
면접 포인트
- 원자 연산의 원자성 ≠ 복합 연산의 원자성: load+store, 두 변수 갱신/읽기는 각각 원자적이어도 합치면 레이스.
- 조건부 갱신(max/min)은
compare_exchange_weak/strong루프로. weak vs strong, 실패 시 기대값이 갱신되는 동작. memory_order_relaxed의 의미(원자성은 보장, 순서는 미보장)와, 이 결함이 순서 문제가 아니라 원자 단위 경계 문제임을 구분.