← 문제로

44. 버프 슬롯 비트마스크 CAS 루프의 재시도 결함 (C#)

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

해설 — 버프 슬롯 비트마스크 CAS 루프의 재시도 결함 (C#)

난이도: 중상

요약

TryApplyBuff 와 TryAcquireEntryToken 은 둘 다 "읽고 → 계산하고 → CAS 로 바꾸고 → 실패하면 재시도"라는 표준 락프리 패턴을 흉내 내지만, 재시도의 두 가지 전제를 모두 어겼다. ① 재시도할 때는 반드시 최신값을 다시 읽어 기대값으로 삼아야 하는데 처음 읽은 로컬 값을 그대로 다시 쓰거나, Interlocked.CompareExchange 의 반환값을 잘못 비교해 성공/실패를 거꾸로 판정한다. ② 재시도 루프 안은 몇 번이든 실행될 수 있는 구간인데 되돌릴 수 없는 부수효과(통계 증가, 이벤트 발행, 발급 로그 push)를 그 안에 넣었다. 그 결과 CAS 가 3번 실패하면 이벤트가 3번 나간다.

문제점

(A) 재시도 시 기대값을 갱신하지 않음 / CAS 반환값 오독

  • 분류: 락프리 동기화 오류 · 라이브락 · API 계약 오해
  • 증상:
    1. TryApplyBuff — 같은 플레이어의 마스크를 다른 스레드가 한 번이라도 먼저 바꾸면, 이후 이 호출은 영원히 CAS 에 실패하며 while(true) 를 빠져나오지 못한다. IOCP 워커 한 개가 코어 하나를 100% 물고 죽는다. 존 전체 tick 이 밀리고, 부수효과(B) 때문에 이벤트 큐가 초당 수백만 건으로 부풀어 OOM 으로 이어진다.
    2. TryAcquireEntryToken — prev == desired 비교 때문에 실제로 성공한 CAS 를 실패로 판정한다. CompareExchange 는 교체 성공 시 이전 값(= observed)을 돌려주므로 desired = observed - cost 와는 (cost > 0 이라) 결코 같을 수 없고, 성공한 차감이 매번 실패로 읽힌다. 루프는 observed 를 다시 읽어 또 차감하고 또 실패로 판정하기를 반복하므로, 호출 한 번이 잔량을 cost 단위로 전부 깎아 내린 뒤 observed < cost 에 걸려 false 를 반환한다. 토큰을 하나도 발급하지 못한 채 풀을 통째로 말려 버리는 것이라, 그 뒤 모든 던전 입장이 거부되고 ReleaseEntryToken 이 반납할 때마다 다음 호출이 다시 말린다(R3 위반). 반대 방향의 오판도 있다. 다른 스레드가 우연히 잔량을 desired 와 같은 값으로 만들어 놓은 순간에는 CAS 가 실패했는데도 true 를 반환해 토큰을 차감하지 않고 입장을 허용한다.
  • 재현 조건: 같은 플레이어에게 광역 힐과 파티 오라가 같은 tick 에 서로 다른 워커에서 들어오면 TryApplyBuff 가 즉시 스핀에 빠진다. 토큰 쪽은 던전 입장이 동시에 몰릴 때 재현된다.
  • 근본 원인:
    1. int observed = st.SlotMask; 를 루프 밖에서 한 번만 읽는다. CAS 실패는 "내가 본 값이 낡았다"는 신호인데, 그 낡은 값을 그대로 다시 기대값으로 넣는다. C++ 의 compare_exchange 와 달리 C# Interlocked.CompareExchange 는 기대값 변수를 갱신해 주지 않는다. 반환값(이전 값)을 직접 observed 에 대입해야 한다.
    2. Interlocked.CompareExchange(ref x, value, comparand) 의 반환값은 연산 전의 x 다. 성공 판정은 return == comparand(= observed) 여야 한다. desired 와 비교하는 것은 계약 오해다.
    3. 평범한 필드 읽기 st.SlotMask / _entryTokens 에는 Volatile.Read 가 없다. JIT 가 루프 내 재읽기를 호이스팅해도 되는 형태이므로, observed 를 제대로 갱신하도록 고쳐도 가시성 근거는 여전히 약하다.
    4. 스핀 구간에 SpinWait / Thread.Yield 가 전혀 없다. 논리 오류를 고쳐도 경합이 심하면 순수 스핀이 하이퍼스레드 형제 코어를 굶긴다.
    5. TryClearBuff 도 같은 계열 결함이다. 재시도 없이 CAS 를 단 한 번만 시도하고 실패하면 그냥 false 를 반환한다. 즉 경합이 있으면 버프 해제가 조용히 누락되어 슬롯이 영구 점유된다.

(B) 재시도 루프 안의 되돌릴 수 없는 부수효과

  • 분류: 부수효과 멱등성 위반 · 이중 집계/이중 알림
  • 증상: 버프가 실제로는 1개 적용됐는데 _metrics.BuffApplied 는 5 증가하고, BuffAppliedEvent 가 5건 큐에 쌓여 클라이언트에 버프 아이콘 갱신 패킷이 5번 나간다. 토큰 쪽은 _grantLog 건수와 실제 차감량이 어긋나 정산이 맞지 않고, _nextRequestId 가 실패한 시도마다 소모되어 발급 ID 에 구멍이 생긴다(R2·R3 위반).
  • 재현 조건: 경합이 있는 모든 경우. (A)의 무한 스핀과 겹치면 단일 요청이 이벤트를 무한히 발행한다.
  • 근본 원인:
    1. CAS 루프의 본문은 "실패하면 통째로 버려지는 투기적 계산"이어야 한다. 순수한 값 계산만 넣고, 외부에서 관측 가능한 효과는 CAS 성공이 확정된 뒤 루프 밖에서 정확히 1회 수행해야 한다.
    2. st.Payloads[slot] = payload; 를 CAS 전에 쓰는 것도 같은 문제다. 마스크 비트가 아직 서지 않은 슬롯의 payload 를 미리 덮어쓰므로, 그 슬롯을 이미 점유 중인 다른 스레드의 데이터를 파괴한다. 게다가 이 배열 쓰기는 CAS 실패 시 되돌려지지 않는다.
    3. 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 없는 스핀은 코어를 태운다. 락프리는 **논블로킹 보장(진행성)**을 사는 것이지 성능을 자동으로 사는 것이 아니다. 실제로 이 코드의 토큰 경로는 세마포어가 더 낫다.