← 문제로

31. 엔티티 핸들(slot map)의 세대 검증 부재·비동기 재사용으로 인한 오접근(ABA형) (C#)

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

해설 — 엔티티 핸들(slot map)의 세대 검증 부재·비동기 재사용으로 인한 오접근(ABA형) (C#)

난이도: 최상

요약

Resolve()Alive 만 보고 핸들의 Generation 을 슬롯의 현재 Generation 과 대조하지 않는다. 디스폰 후 같은 슬롯이 재사용되면 stale 핸들이 Alive==true 를 통과해 엉뚱한 엔티티를 돌려준다. 또 ResolveSpawn/Despawn동기화 없이 같은 Slot 구조체에 접근해 찢긴 읽기·재사용 경합이 난다.

문제점

  • [세대 미검증 / ABA] (A) if (s.Alive)
    • 증상: 핸들 H=(idx, gen=5)가 디스폰(Generation 6) 후 슬롯 idx 재사용되면 Alive==true. Resolve(H) 가 null 대신 새 점유자를 반환 → 죽은 대상 대상 연산이 무관한 엔티티에 적용.
    • 재현: H=Spawn()Despawn(H)H2=Spawn()(같은 idx) → Resolve(H) 가 H2 엔티티 반환.
    • 근본원인: 세대 토큰 미검증. 인덱스 재사용이 ABA 를 일으켜 인덱스만으론 신원 구별 불가.
  • [데이터 레이스 / 찢긴 상태] (B) ref Slot s = ref _slots[h.Index]; ... return s.Entity;
    • 증상: SlotGeneration(uint)+Alive(bool)+Entity(참조)를 담은 구조체. Resolve 가 이 필드들을 읽는 동안 Despawn/Spawn 이 같은 슬롯을 수정 → AliveGeneration/Entity 가 서로 다른 시점 값으로 찢긴다(메모리 안전성은 유지되나 논리적 오류). 잘못된 핸들이면 _slots[h.Index]IndexOutOfRangeException.
    • 근본원인: 슬롯맵 접근 동기화·경계 검증 부재.

수정안

세대 검증 + 경계 검증 + 동시 재사용 차단. 가장 단순한 정책은 스폰/디스폰을 틱 경계에서만 수행해 Resolve 와 분리하는 것. 동시성이 필요하면 락(또는 RW락), 그리고 슬롯 ref 를 락 밖으로 새지 않기.

private readonly object _gate = new();

public Handle Spawn()
{
    lock (_gate)
    {
        uint idx = _freeList.Pop();
        _slots[idx].Alive  = true;
        _slots[idx].Entity = new Entity();
        return new Handle { Index = idx, Generation = _slots[idx].Generation };
    }
}

public void Despawn(Handle h)
{
    lock (_gate)
    {
        if (h.Index >= _slots.Length) return;
        ref Slot s = ref _slots[h.Index];
        if (!s.Alive || s.Generation != h.Generation) return;  // 멱등·중복 디스폰 방지
        s.Alive = false; s.Generation++;
        s.Entity = null!;                                       // 참조 해제(회수 도움)
        _freeList.Push(h.Index);
    }
}

public Entity? Resolve(Handle h)
{
    lock (_gate)
    {
        if (h.Index >= _slots.Length) return null;             // 경계 검증
        ref Slot s = ref _slots[h.Index];
        return (s.Alive && s.Generation == h.Generation) ? s.Entity : null; // (A) 세대 검증
    }
}

// 제자리 수정은 락 안에서 콜백으로(엔티티 참조를 락 밖으로 들고 가도 객체 자체는 살아있으나,
// 그 사이 Despawn 되면 stale 이므로 동작 단위로 묶는 게 안전)
public bool With(Handle h, System.Action<Entity> fn)
{
    lock (_gate)
    {
        if (h.Index >= _slots.Length) return false;
        ref Slot s = ref _slots[h.Index];
        if (!s.Alive || s.Generation != h.Generation) return false;
        fn(s.Entity);
        return true;
    }
}

더 나은 설계

  • 세대 비트로 ABA 차단: 32비트 세대면 순환 충돌 사실상 무시 가능. 핸들을 (index<<32)|generation 처럼 단일 long 으로 만들면 읽기/전달이 원자적이 돼 찢김도 방지.
  • 회수 정책: ① 틱 경계 전용 변경(MMO 틱 루프에 적합, 가장 단순) ② ReaderWriterLockSlim(읽기 다수) ③ 커맨드 큐로 변경 요청을 시뮬레이션 스레드에 위임(액터 모델). concurrency_memory/problem9(ABA)·problem30(RW락)과 연결.
  • 참조를 빌려줄 때 주의: Entity 가 클래스라 GC 가 살려두므로 C++ 같은 댕글링은 없지만, 디스폰된 엔티티를 계속 조작하면 "유령 갱신"이 된다 → 동작을 With(handle, fn) 으로 묶어 세대 검증 후에만 수정.
  • 구조체 슬롯의 찢김: 핸들을 long 단일 값으로 만들거나, 슬롯 상태를 Interlocked/volatile 로 안전 공개(concurrency_memory/problem28).

면접 포인트

  • 인덱스 재사용이 포인터 ABA 와 같은 이유, 세대 토큰의 역할과 폭(16 vs 32비트) 선택.
  • "Alive 만 검사"의 함정과 stale 핸들 → 새 엔티티 접근 시나리오.
  • 동시 슬롯맵 회수: 틱 경계 분리 vs RW락 vs 커맨드 큐(액터)의 트레이드오프, 그리고 핸들을 단일 원자값으로 만드는 이점.