← 문제로

37. 필드 자원 노드 동시 채집: 검사-차감 비원자와 고갈 디스폰 경합 (C#)

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

해설 — 필드 자원 노드 동시 채집: 검사-차감 비원자와 고갈 디스폰 경합 (C#)

난이도: 중

요약

같은 자원 노드에 여러 채집 워커가 동시에 진입하면, "남았는지 검사 → 지급 → 감소"가 하나의 원자 구간이 아니라 **아이템이 초기 채집 횟수보다 많이 지급(복제)**되고, Remaining 이 음수로 내려가며, 디스폰이 누락되거나 두 번 일어난다. 게다가 Dictionary 를 락 없이 동시 읽기/제거해 컬렉션 자체가 깨진다.

문제점

  • 분류 태그: TOCTOU(검사-후-실행 비원자) · 갱신 유실(lost update) · 비스레드세이프 컬렉션 동시 접근.
  • 증상: 채집 1회만 남은 노드에서 여러 명이 자원을 받음(복제), Remaining 이 -1, -2 … 로 내려감. 노드가 디스폰되지 않고 남거나, 두 워커가 각각 디스폰을 시도함. 동시 채집이 몰리면 Dictionary 열거/버킷 손상으로 예외·미정의 동작.
  • 재현 조건: Remaining == 1 인 노드에 워커 T1, T2 가 거의 동시에 진입.
    1. T1: (A) Remaining(=1) <= 0 거짓 → 통과
    2. T2: (A) Remaining(=1) <= 0 거짓 → 통과 ← T1 이 아직 감소 안 함
    3. T1: 지급, Remaining = 0, (B) 디스폰
    4. T2: 지급(복제!), Remaining = -1, (B) == 0 아니라 디스폰 안 함(또는 T1·T2 순서에 따라 이중 디스폰)
  • 근본 원인:
    • (A) Remaining 의 검사와 차감이 분리되어 있고 그 사이 지급까지 낀다. read-modify-write(Remaining -= 1)가 동기화 없이 수행돼 갱신이 유실되고, 검사 결과가 실행 시점에는 더 이상 참이 아니다.
    • (B) 소진 판정을 == 0 으로만 한다. 병렬 감소로 값이 0을 건너뛰어 -1 이 되면 디스폰이 영영 안 걸리고, 반대로 두 워커가 모두 0을 관측하면 이중 디스폰된다. 디스폰과 채집을 묶는 락도 없다.
    • _nodes(Dictionary)를 조회(TryGetValue)와 제거(Remove)가 동시에 일어나 비스레드세이프 컬렉션 자체 손상.

수정안

채집을 노드 단위 원자 구간으로 만들고, "검사 직후 차감(예약)" 을 먼저 해서 지급이 예약을 초과하지 못하게 한다. 구조 변경(제거)은 별도 게이트로 보호한다.

class ResourceNode
{
    public int NodeId;
    public string ItemId;
    public int Remaining;
    public bool Despawned;
    public readonly object Gate = new();   // 노드 상태 보호
}

class HarvestHandler
{
    private readonly Dictionary<int, ResourceNode> _nodes = new();
    private readonly object _nodesGate = new();   // 컬렉션 구조 변경 보호

    public bool OnHarvest(int playerId, int nodeId)
    {
        ResourceNode node;
        lock (_nodesGate)
        {
            if (!_nodes.TryGetValue(nodeId, out node)) return false;
        }

        bool depleted;
        lock (node.Gate)                       // 검사·차감·디스폰표시를 원자로
        {
            if (node.Despawned || node.Remaining <= 0) return false;
            node.Remaining -= 1;               // 예약: 먼저 줄이고
            depleted = node.Remaining == 0;    // 정확히 0에 도달한 스레드만
            if (depleted) node.Despawned = true;
        }

        GrantItem(playerId, node.ItemId, 1);   // 지급은 락 밖에서(보유 중 무거운 작업 회피)

        if (depleted)
        {
            lock (_nodesGate) { _nodes.Remove(nodeId); }
            NotifyDespawn(node);               // 디스폰 통지는 1회만
        }
        return true;
    }
}

핵심: 차감을 검사 직후 같은 락 안에서 하므로 Remaining 이 음수로 못 내려가고, Despawned 를 0에 도달한 한 스레드만 세팅해 디스폰이 정확히 한 번 일어난다. 지급이 실패할 수 있는 시스템이면 예약분을 되돌리는 보상(Remaining += 1)이 필요하다.

원자 정수만으로 하려면 예약 CAS 패턴을 쓸 수 있다:

int cur;
do { cur = Volatile.Read(ref node.Remaining); if (cur <= 0) return false; }
while (Interlocked.CompareExchange(ref node.Remaining, cur - 1, cur) != cur);
// 여기 도달 = 예약 성공. cur-1 == 0 이면 이 스레드가 디스폰 담당.

더 나은 설계 (트레이드오프)

  • 노드 소유 스레드 단일 처리(액터/샤딩): 각 노드(또는 존)를 한 틱 스레드가 소유하고 채집 요청을 그 스레드 큐로 직렬화하면 락 자체가 사라진다. 대신 크로스-스레드 메시지 지연·구현 복잡도가 는다. 핫 자원(이벤트 광맥)에 유리.
  • per-node 락: 단순하고 지역적이지만 인기 노드에 채집이 몰리면 그 락에 경합이 집중된다. 노드 수가 많고 개별 경합이 낮은 일반 필드에 적합.
  • 원자 예약(fetch/CAS): 락 없이 빠르지만 "지급 실패 시 롤백" 을 스스로 처리해야 하고, 디스폰/통지 같은 부수효과는 결국 한 스레드로 몰아야 한다.

면접 포인트

  1. TOCTOU(검사-후-실행)와 lost update의 차이, 그리고 "지급 후 감소"가 아니라 "감소(예약) 후 지급" 으로 뒤집으면 왜 복제가 막히는가.
  2. 소진 판정을 == 0 이 아니라 "이 스레드가 0으로 만든 경우"로 좁혀야 디스폰이 정확히 1회가 되는 이유.
  3. 락 보유 중 지급/통지 같은 무거운·블로킹 작업을 하지 않는 원칙(락 구간 최소화, 호위 방지).