44. 버프 슬롯 비트마스크 CAS 루프의 재시도 결함 (C#)
난이도 중해설 — 버프 슬롯 비트마스크 CAS 루프의 재시도 결함 (C#)
난이도: 중상
요약
TryApplyBuff 와 TryAcquireEntryToken 은 둘 다 "읽고 → 계산하고 → CAS 로 바꾸고 → 실패하면 재시도"라는
표준 락프리 패턴을 흉내 내지만, 재시도의 두 가지 전제를 모두 어겼다.
① 재시도할 때는 반드시 최신값을 다시 읽어 기대값으로 삼아야 하는데 처음 읽은 로컬 값을 그대로 다시 쓰거나,
Interlocked.CompareExchange 의 반환값을 잘못 비교해 성공/실패를 거꾸로 판정한다.
② 재시도 루프 안은 몇 번이든 실행될 수 있는 구간인데 되돌릴 수 없는 부수효과(통계 증가, 이벤트 발행,
발급 로그 push)를 그 안에 넣었다. 그 결과 CAS 가 3번 실패하면 이벤트가 3번 나간다.
문제점
(A) 재시도 시 기대값을 갱신하지 않음 / CAS 반환값 오독
- 분류: 락프리 동기화 오류 · 라이브락 · API 계약 오해
- 증상:
TryApplyBuff— 같은 플레이어의 마스크를 다른 스레드가 한 번이라도 먼저 바꾸면, 이후 이 호출은 영원히 CAS 에 실패하며while(true)를 빠져나오지 못한다. IOCP 워커 한 개가 코어 하나를 100% 물고 죽는다. 존 전체 tick 이 밀리고, 부수효과(B) 때문에 이벤트 큐가 초당 수백만 건으로 부풀어 OOM 으로 이어진다.TryAcquireEntryToken—prev == desired비교 때문에 실제로 성공한 CAS 를 실패로 판정한다.CompareExchange는 교체 성공 시 이전 값(=observed)을 돌려주므로desired = observed - cost와는 (cost > 0 이라) 결코 같을 수 없고, 성공한 차감이 매번 실패로 읽힌다. 루프는observed를 다시 읽어 또 차감하고 또 실패로 판정하기를 반복하므로, 호출 한 번이 잔량을 cost 단위로 전부 깎아 내린 뒤observed < cost에 걸려false를 반환한다. 토큰을 하나도 발급하지 못한 채 풀을 통째로 말려 버리는 것이라, 그 뒤 모든 던전 입장이 거부되고ReleaseEntryToken이 반납할 때마다 다음 호출이 다시 말린다(R3 위반). 반대 방향의 오판도 있다. 다른 스레드가 우연히 잔량을desired와 같은 값으로 만들어 놓은 순간에는 CAS 가 실패했는데도 true 를 반환해 토큰을 차감하지 않고 입장을 허용한다.
- 재현 조건: 같은 플레이어에게 광역 힐과 파티 오라가 같은 tick 에 서로 다른 워커에서 들어오면
TryApplyBuff가 즉시 스핀에 빠진다. 토큰 쪽은 던전 입장이 동시에 몰릴 때 재현된다. - 근본 원인:
int observed = st.SlotMask;를 루프 밖에서 한 번만 읽는다. CAS 실패는 "내가 본 값이 낡았다"는 신호인데, 그 낡은 값을 그대로 다시 기대값으로 넣는다. C++ 의compare_exchange와 달리 C#Interlocked.CompareExchange는 기대값 변수를 갱신해 주지 않는다. 반환값(이전 값)을 직접observed에 대입해야 한다.Interlocked.CompareExchange(ref x, value, comparand)의 반환값은 연산 전의 x 다. 성공 판정은return == comparand(=observed) 여야 한다.desired와 비교하는 것은 계약 오해다.- 평범한 필드 읽기
st.SlotMask/_entryTokens에는Volatile.Read가 없다. JIT 가 루프 내 재읽기를 호이스팅해도 되는 형태이므로,observed를 제대로 갱신하도록 고쳐도 가시성 근거는 여전히 약하다. - 스핀 구간에
SpinWait/Thread.Yield가 전혀 없다. 논리 오류를 고쳐도 경합이 심하면 순수 스핀이 하이퍼스레드 형제 코어를 굶긴다. TryClearBuff도 같은 계열 결함이다. 재시도 없이 CAS 를 단 한 번만 시도하고 실패하면 그냥 false 를 반환한다. 즉 경합이 있으면 버프 해제가 조용히 누락되어 슬롯이 영구 점유된다.
(B) 재시도 루프 안의 되돌릴 수 없는 부수효과
- 분류: 부수효과 멱등성 위반 · 이중 집계/이중 알림
- 증상: 버프가 실제로는 1개 적용됐는데
_metrics.BuffApplied는 5 증가하고,BuffAppliedEvent가 5건 큐에 쌓여 클라이언트에 버프 아이콘 갱신 패킷이 5번 나간다. 토큰 쪽은_grantLog건수와 실제 차감량이 어긋나 정산이 맞지 않고,_nextRequestId가 실패한 시도마다 소모되어 발급 ID 에 구멍이 생긴다(R2·R3 위반). - 재현 조건: 경합이 있는 모든 경우. (A)의 무한 스핀과 겹치면 단일 요청이 이벤트를 무한히 발행한다.
- 근본 원인:
- CAS 루프의 본문은 "실패하면 통째로 버려지는 투기적 계산"이어야 한다. 순수한 값 계산만 넣고, 외부에서 관측 가능한 효과는 CAS 성공이 확정된 뒤 루프 밖에서 정확히 1회 수행해야 한다.
st.Payloads[slot] = payload;를 CAS 전에 쓰는 것도 같은 문제다. 마스크 비트가 아직 서지 않은 슬롯의 payload 를 미리 덮어쓰므로, 그 슬롯을 이미 점유 중인 다른 스레드의 데이터를 파괴한다. 게다가 이 배열 쓰기는 CAS 실패 시 되돌려지지 않는다.Interlocked.Increment를 썼으니 통계는 "스레드 안전"하지만, 원자성과 멱등성은 다른 문제다. 원자적으로 5번 더하면 결과도 5다.
수정안
핵심은 셋이다. ① 실패 시 반환값으로 기대값 갱신, ② 성공 판정은 prev == observed, ③ 부수효과는 루프 밖에서 1회.
public bool TryApplyBuff(long playerId, int slot, BuffPayload payload)
{
if (slot < 0 || slot >= 32) return false;
var st = _states.GetOrAdd(playerId, id => new PlayerBuffState { PlayerId = id });
int bit = 1 << slot;
var spin = new SpinWait();
int observed = Volatile.Read(ref st.SlotMask);
while (true)
{
if ((observed & bit) != 0)
{
_metrics.IncBuffRejected();
return false;
}
int desired = observed | bit;
// 루프 안에는 순수 계산만. CAS 결과로 성공/실패가 갈린다.
int prev = Interlocked.CompareExchange(ref st.SlotMask, desired, observed);
if (prev == observed)
{
// 여기서부터는 이 스레드가 슬롯의 단독 소유자다.
st.Payloads[slot] = payload;
_metrics.IncBuffApplied();
_events.Enqueue(new BuffAppliedEvent
{
PlayerId = playerId, Slot = slot,
MaskAfter = desired, AtUtc = DateTime.UtcNow,
});
return true;
}
observed = prev; // 실패가 알려준 최신값으로 재시도
spin.SpinOnce(); // 경합 시 코어 양보
}
}
주의: 여기서
Payloads[slot]쓰기를 CAS 뒤로 옮기면 R3(마스크 비트를 본 스레드는 완성된 payload 를 본다)이 반대로 깨진다. C# 에서는Interlocked.CompareExchange자체가 full fence 라 "비트를 세운 뒤 payload 를 쓰는" 순서가 그대로 관측될 수 있다. 슬롯 내용까지 안전하게 발행하려면 payload 를 불변 객체로 만들어 참조를 CAS 로 교체하거나(아래 (2)), 슬롯에Ready플래그를 두어 2단계로 발행해야 한다.
토큰 쪽은 성공 판정을 바로잡고 부수효과를 밖으로 뺀다.
public bool TryAcquireEntryToken(int cost)
{
if (cost <= 0) return false;
var spin = new SpinWait();
long observed = Volatile.Read(ref _entryTokens);
long desired;
while (true)
{
if (observed < cost) return false;
desired = observed - cost;
long prev = Interlocked.CompareExchange(ref _entryTokens, desired, observed);
if (prev == observed) break; // 반환값 == 기대값 → 성공
observed = prev;
spin.SpinOnce();
}
// 차감이 확정된 뒤 정확히 1회
long requestId = Interlocked.Increment(ref _nextRequestId);
_metrics.IncTokenGranted();
_grantLog.Enqueue(new TokenGrant { RequestId = requestId, Cost = cost, RemainAfter = desired });
return true;
}
TryClearBuff 도 동일한 재시도 루프로 바꿔야 한다. 단발 CAS 는 "경합이 없을 때만 동작하는 코드"다.
더 나은 설계
(1) 비트 연산은 CAS 루프 대신 Interlocked.Or / Interlocked.And 로.
.NET 5+ 의 Interlocked.Or(ref int, int) 는 이전 값을 반환하므로
(Interlocked.Or(ref st.SlotMask, bit) & bit) == 0 한 줄로 "내가 이 슬롯을 처음 차지했는가"를 판정할 수 있다.
루프도 기대값도 없어져 (A) 유형 결함이 구조적으로 불가능해진다.
- 트레이드오프: 단일 비트 set/clear 처럼 연산이 값에 의존하지 않을 때만 쓸 수 있다. "잔량이 cost 이상일 때만 차감" 같은 조건부 갱신은 여전히 CAS 루프가 필요하다.
(2) 슬롯 상태를 불변 객체 + 참조 CAS 로.
BuffPayload 를 immutable class 로 만들고 PlayerBuffState.Slots 를 object[] 로 두어
Interlocked.CompareExchange<BuffPayload>(ref slots[i], newObj, null) 로 점유·발행을 한 번에 처리한다.
참조 대입은 원자적이고, CAS 성공이 곧 "완성된 객체의 발행"이라 R3 가 자동으로 지켜진다.
- 트레이드오프: 버프 갱신마다 할당이 생겨 Gen0 압력이 올라간다. 존당 수천 명 × 초당 수만 갱신이면 GC 부담이 실측 대상이다. 구조체 배열 + 비트마스크는 할당이 없지만 발행 순서를 직접 설계해야 한다.
(3) 토큰은 SemaphoreSlim 또는 액터 단일 스레드로.
입장 토큰은 초당 수만 번이 아니라 초당 수십~수백 번 수준의 저빈도 연산이다. 락프리로 짤 이유가 약하다.
SemaphoreSlim(64) 는 대기·타임아웃·비동기 대기를 전부 제공하고, 발급 로그도 획득 성공 직후에 자연스럽게 1회 남길 수 있다.
- 트레이드오프: 경합 시 커널 대기로 내려가 지연이 마이크로초 단위로 늘지만, 인스턴스 생성 비용(수십 ms)에 비하면 무시할 수 있다. 핫패스가 아닌 곳에 락프리를 쓰는 것은 위험만 사는 것이라는 판단이 핵심이다.
면접 포인트
- "
Interlocked.CompareExchange의 반환값은 무엇이며 성공을 어떻게 판정하는가?" — 연산 전의 값을 반환하고, 성공은반환값 == comparand. C++ 의compare_exchange_*가 bool 을 반환하며 실패 시 expected 를 갱신해 주는 것과 계약이 다르다. C++ 습관으로 C# 을 짜면 기대값 갱신을 빠뜨리고, C# 습관으로 C++ 을 짜면 불필요한 재대입을 넣는다. - "CAS 루프 안에 넣어도 되는 것과 안 되는 것의 기준은?" — 재실행해도 무해한 순수 계산만. 통계, 로그, 이벤트, 큐 push, 외부 호출은 전부 CAS 성공 이후 루프 밖으로. 락프리 코드에서 루프 본문은 "몇 번이든 버려질 수 있는 시도"다.
- "락프리로 짜면 항상 빠른가?" — 아니다. 경합이 심하면 CAS 재시도가 폭증해 락보다 느려지고,
SpinWait없는 스핀은 코어를 태운다. 락프리는 **논블로킹 보장(진행성)**을 사는 것이지 성능을 자동으로 사는 것이 아니다. 실제로 이 코드의 토큰 경로는 세마포어가 더 낫다.
해설 — 버프 슬롯 비트마스크 CAS 루프의 재시도 결함 (C++)
난이도: 상
요약
TryApplyBuff 는 compare_exchange_weak 를 루프 없이 단발로 호출한다. weak 는 경합이 없어도
실패할 수 있으므로(spurious failure) 정상 상황에서도 버프가 조용히 누락된다.
TryAcquireSpawnToken 은 반대로, weak 가 실패 시 expected 를 최신값으로 갱신해 준다는 시맨틱을 모르고
루프 끝에서 처음 읽은 snapshot 을 다시 대입해 그 갱신을 지워버린다 → CAS 가 영원히 실패한다.
여기에 두 함수 모두 순서 지정이 전부 memory_order_relaxed 라 payload/이벤트의 발행 순서가 보장되지 않고,
재시도 루프 안에 되돌릴 수 없는 부수효과가 들어가 있다.
문제점
(A) compare_exchange_weak 의 계약 오해 — 단발 사용 · 실패 시 expected 수동 덮어쓰기 · relaxed 순서
- 분류: 락프리 동기화 오류 · 메모리 순서 위반 · 라이브락
- 증상:
TryApplyBuff— 아무도 경쟁하지 않는데도 버프가 간헐적으로 적용되지 않고false를 반환한다. 재현율이 플랫폼마다 다르다(x86 은 거의 안 나고, LL/SC 기반인 ARM/PowerPC 서버에서는 컨텍스트 스위치·캐시라인 무효화만으로도 수시로 발생). "리눅스 ARM 인스턴스에서만 파티 오라가 가끔 안 걸린다" 같은 재현 불가 버그가 된다.TryAcquireSpawnToken— 다른 스레드가 토큰 잔량을 한 번이라도 바꾸면 이 호출은for(;;)를 영원히 빠져나오지 못한다. 워커가 코어를 100% 물고, (B) 때문에 이벤트 큐와 통계가 무한히 증가한다.ReadBuff가 마스크 비트를 보고payloads[slot]을 읽는데, 순서 보장이 없어 아직 다 써지지 않은 payload 를 읽는다. buff_id 는 새 값인데 stack/expire_ms 는 이전 버프 값인 찢어진 상태가 관측된다(R3 위반).
- 재현 조건: (1)은 경합이 없어도 발생. (2)는 던전 입장이 동시에 몰릴 때. (3)은 약한 순서 아키텍처에서 상시.
- 근본 원인:
compare_exchange_weak는 반드시 루프 안에서 써야 한다. weak 는 값이 일치해도 실패(spurious failure)할 수 있고, 그 대가로 LL/SC 아키텍처에서 내부 재시도 루프를 생략해 더 빠르다. 단발로 판정에 쓸 거라면compare_exchange_strong을 써야 한다. 지금 코드는 "가장 빠른 버전"을 골라 놓고 그 전제 조건을 지키지 않았다.- 실패하면
expected는 이미 최신값으로 갱신된다. 그런데expected = snapshot;이 그 갱신을 되돌려 처음 읽은 낡은 값으로 되돌린다. 그 값으로는 CAS 가 다시 성공할 수 없으므로 진행이 영원히 없다. (C# 처럼 "기대값을 직접 갱신해야 한다"는 습관을 C++ 에 그대로 가져오면 나오는 전형적인 오류다.) - 성공/실패 순서가 모두
memory_order_relaxed다.st.payloads[slot] = p;라는 비원자 쓰기와 마스크 비트 발행 사이에 release 가 없어 재배열이 허용된다. 읽는 쪽ReadBuff에도 acquire 가 없어 대칭 쌍이 성립하지 않는다. 마스크가 atomic 이라는 사실은 마스크 자체의 원자성만 보장할 뿐, 그것이 지키려는 payload 배열의 발행은 순서 지정으로만 보장된다. - 실패 시 순서 인자를
relaxed로 준 것도 문제다. 실패 순서는 성공 순서보다 강할 수 없다는 제약이 있어, 재시도 루프에서 최신값을 acquire 로 관측하려면 실패 인자를acquire로 줘야 한다.
(B) 재시도 루프 안의 되돌릴 수 없는 부수효과
- 분류: 부수효과 멱등성 위반 · 이중 집계/이중 알림
- 증상: 토큰 1회 발급에
metrics_.granted()가 수십 증가하고bus_의 grant 큐에 같은 발급이 여러 건 쌓인다.next_request_id_가 실패한 시도마다 소모되어 발급 ID 에 구멍이 난다. 정산 배치가 "발급 로그 건수 ≠ 실제 차감량"으로 매일 어긋난다(R4 위반). (A)의 무한 루프와 겹치면EventBus의 deque 가 무제한 성장해 OOM 으로 끝난다. - 재현 조건: 토큰 경합이 있는 모든 경우.
- 근본 원인:
- CAS 루프 본문은 버려질 수 있는 투기적 계산이다. 외부에서 관측되는 효과(통계, 이벤트 발행, ID 소비)는 CAS 성공이 확정된 뒤에 정확히 1회 실행해야 한다.
bus_.PublishGrant는 내부에서std::mutex를 잡는다. 락프리 재시도 루프 안에서 뮤텍스를 잡는 것 자체가 설계 오류다. 락 대기 중에 다시 잔량이 바뀌어 CAS 실패 확률이 올라가고, 실패가 다시 락 획득을 유발하는 양성 피드백이 생겨 경합이 폭주한다.TryApplyBuff의st.payloads[slot] = p;는 CAS 성공 전에 실행되므로, CAS 가 실패하면 이미 남의 슬롯 데이터를 덮어쓴 뒤다. 되돌릴 방법이 없다.
수정안
① weak 는 루프 안에서, ② 실패 시 expected 는 건드리지 않고, ③ 발행은 release/acquire 쌍으로, ④ 부수효과는 루프 밖 1회.
bool TryApplyBuff(PlayerBuffState& st, int slot, const BuffPayload& p) {
if (slot < 0 || slot >= 32) return false;
const uint32_t bit = 1u << slot;
// payload 를 먼저 완성해 두고, 마스크 비트를 release 로 발행한다.
st.payloads[slot] = p;
uint32_t expected = st.slot_mask.load(std::memory_order_acquire);
for (;;) {
if (expected & bit) { // 다른 스레드가 이미 점유
metrics_.IncRejected();
return false;
}
const uint32_t desired = expected | bit;
// 실패 시 expected 는 최신값으로 자동 갱신된다 — 직접 대입 금지.
if (st.slot_mask.compare_exchange_weak(expected, desired,
std::memory_order_release,
std::memory_order_acquire)) {
metrics_.IncApplied(); // 성공 후 1회
bus_.Publish(BuffEvent{st.player_id, slot, desired});
return true;
}
}
}
주의: 위 수정안은 release 발행 순서를 지키려고
st.payloads[slot] = p;를 점유 판정보다 먼저 쓴다. 따라서 그 슬롯을 이미 다른 스레드가 점유 중이면 (B)에서 지적한 "남의 payload 덮어쓰기"가 그대로 남는다. 마스크 하나로 점유와 발행을 동시에 표현하는 설계의 한계이며, 완전히 없애려면 ① 슬롯을 먼저 acquire CAS 로 점유 → payload 기록 → 별도ready_mask를 release 로 세우는 2단계 발행, 또는 아래 더 나은 설계 (2) 의 포인터 CAS 로 가야 한다. 점유와 발행은 원래 서로 다른 두 사건이다.
읽는 쪽도 반드시 acquire 로 대칭을 맞춘다. 이것이 없으면 위의 release 는 아무 의미가 없다.
bool ReadBuff(const PlayerBuffState& st, int slot, BuffPayload* out) const {
if (!(st.slot_mask.load(std::memory_order_acquire) & (1u << slot))) return false;
*out = st.payloads[slot];
return true;
}
토큰은 CAS 성공 후에만 ID 를 소비하고 이벤트를 발행한다. 스핀에는 양보를 넣는다.
bool TryAcquireSpawnToken(uint32_t cost) {
if (cost == 0) return false;
uint32_t expected = tokens_.load(std::memory_order_relaxed);
uint32_t desired = 0;
int spins = 0;
for (;;) {
if (expected < cost) return false;
desired = expected - cost;
if (tokens_.compare_exchange_weak(expected, desired,
std::memory_order_acq_rel,
std::memory_order_acquire)) {
break;
}
if (++spins > 64) { std::this_thread::yield(); spins = 0; }
}
const uint64_t rid = next_request_id_.fetch_add(1, std::memory_order_relaxed);
metrics_.IncGranted();
bus_.PublishGrant(TokenGrant{rid, cost, desired});
return true;
}
fetch_sub 로 먼저 빼고 음수면 되돌리는 우회는 답이 아니다. 되돌리기 전에 다른 스레드가 음수 잔량을 관측한다.
// 안티패턴: 잔량 하한을 fetch_sub 로 지키려는 시도
inline bool BadAcquire(std::atomic<int32_t>& tokens, int32_t cost) {
if (tokens.fetch_sub(cost, std::memory_order_acq_rel) - cost < 0) {
tokens.fetch_add(cost, std::memory_order_acq_rel); // 이미 늦었다
return false;
}
return true;
}
더 나은 설계
(1) 비트 set 은 fetch_or 로.
(st.slot_mask.fetch_or(bit, std::memory_order_acq_rel) & bit) == 0 한 줄이면
"내가 이 슬롯을 처음 차지했는가"가 판정된다. 루프도 expected 도 사라져 (A) 유형이 구조적으로 불가능해진다.
- 트레이드오프: 새 값이 이전 값에 의존하지 않는 연산(단일 비트 set/clear)에만 쓸 수 있다. 토큰 차감처럼 "조건을 만족할 때만"인 갱신은 여전히 CAS 루프가 필요하다. 다만
fetch_or는 슬롯 점유와 payload 발행 순서를 별도로 설계해야 하므로, payload 를 먼저 쓰고fetch_or를 release 로 주는 조합이 필요하다.
(2) 슬롯을 std::atomic<BuffPayload*> 로 두고 포인터 CAS.
불변 payload 를 힙에 만들고 nullptr 을 기대값으로 CAS 하면 점유와 발행이 한 번에 끝난다. release CAS 성공 = 완성된 객체의 발행이라 R3 가 자동으로 지켜지고, 찢어진 읽기가 원천 봉쇄된다.
- 트레이드오프: 해제 시 회수 시점 문제(다른 스레드가 아직 읽고 있을 수 있다)가 생겨 hazard pointer 나 epoch 기반 회수, 또는
shared_ptr원자 연산이 필요하다. 락프리 자료구조의 난이도가 여기서 급격히 올라간다. 게임서버 실무에서는 "존 tick 경계에서 일괄 회수"라는 도메인 지식으로 회피하는 경우가 많다.
(3) 토큰은 std::counting_semaphore(C++20) 또는 뮤텍스+조건변수로.
인스턴스 생성은 초당 수십~수백 건이고 생성 자체가 수십 ms 걸린다. 이 빈도에 락프리를 쓸 이유가 없다.
세마포어는 대기·타임아웃을 제공하고, 발급 로그도 획득 직후 자연스럽게 1회 남길 수 있다.
- 트레이드오프: 경합 시 커널 대기로 내려가 지연이 늘지만 인스턴스 생성 비용에 묻힌다. 핫패스가 아닌 곳의 락프리는 위험만 사는 것이라는 판단이 요점이다. 반대로 버프 마스크는 진짜 핫패스이므로 원자 연산이 정당하다.
면접 포인트
- "
compare_exchange_weak와_strong의 차이와 선택 기준은?" — weak 는 spurious failure 가 허용되는 대신 LL/SC 아키텍처에서 내부 재시도가 없어 빠르다. 이미 재시도 루프가 있으면 weak, 단발 판정이면 strong. 단발 weak 는 x86 에서 테스트가 통과해 버려 ARM 배포 후에야 터지는 전형적인 이식성 버그다. - "CAS 실패 시 expected 는 어떻게 되는가?" — 최신값으로 자동 갱신된다. 그래서 C++ CAS 루프의 표준형은 루프 안에서 expected 를 다시 읽지 않는다. C#
Interlocked.CompareExchange는 반대로 반환값(이전 값)을 직접 대입해야 하므로, 두 언어를 오가며 짤 때 가장 자주 뒤섞이는 지점이다. - "atomic 변수를 썼는데 왜 다른 데이터가 찢어져 보이는가?" — atomic 은 그 변수 자체의 원자성만 보장한다. 주변 비원자 데이터의 발행은 release/acquire 쌍이라는 순서 지정으로 만든다.
relaxed는 원자성만 주고 순서는 주지 않으므로, 카운터처럼 값 자체가 목적일 때만 안전하다.