31. 엔티티 핸들(slot map)의 세대 검증 부재·비동기 재사용으로 인한 오접근(ABA형) (C#)
난이도 최상해설 — 엔티티 핸들(slot map)의 세대 검증 부재·비동기 재사용으로 인한 오접근(ABA형) (C#)
난이도: 최상
요약
Resolve() 가 Alive 만 보고 핸들의 Generation 을 슬롯의 현재 Generation 과 대조하지 않는다. 디스폰 후 같은 슬롯이 재사용되면 stale 핸들이 Alive==true 를 통과해 엉뚱한 엔티티를 돌려준다. 또 Resolve 와 Spawn/Despawn 이 동기화 없이 같은 Slot 구조체에 접근해 찢긴 읽기·재사용 경합이 난다.
문제점
- [세대 미검증 / ABA] (A)
if (s.Alive)- 증상: 핸들 H=(idx, gen=5)가 디스폰(
Generation6) 후 슬롯 idx 재사용되면Alive==true.Resolve(H)가 null 대신 새 점유자를 반환 → 죽은 대상 대상 연산이 무관한 엔티티에 적용. - 재현:
H=Spawn()→Despawn(H)→H2=Spawn()(같은 idx) →Resolve(H)가 H2 엔티티 반환. - 근본원인: 세대 토큰 미검증. 인덱스 재사용이 ABA 를 일으켜 인덱스만으론 신원 구별 불가.
- 증상: 핸들 H=(idx, gen=5)가 디스폰(
- [데이터 레이스 / 찢긴 상태] (B)
ref Slot s = ref _slots[h.Index]; ... return s.Entity;- 증상:
Slot은Generation(uint)+Alive(bool)+Entity(참조)를 담은 구조체.Resolve가 이 필드들을 읽는 동안Despawn/Spawn이 같은 슬롯을 수정 →Alive와Generation/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 커맨드 큐(액터)의 트레이드오프, 그리고 핸들을 단일 원자값으로 만드는 이점.
해설 — 엔티티 핸들(slot map)의 세대 검증 부재·비동기 재사용으로 인한 오접근(ABA형) (C++)
난이도: 최상
요약
resolve() 가 슬롯의 alive 만 보고 핸들의 generation 을 슬롯의 현재 generation 과 대조하지 않는다. 그래서 디스폰 후 같은 슬롯이 다른 엔티티로 재사용(ABA) 되면, 오래된 핸들이 alive==true 를 통과해 엉뚱한 엔티티를 돌려준다. 게다가 resolve 와 spawn/despawn 이 동기화 없이 같은 슬롯에 접근해, generation/alive/entity 를 비원자로 읽는 데이터 레이스(UB)와, 재사용 중인 슬롯 포인터 반환(use-after-recycle)이 겹친다.
문제점
- [세대 미검증 / ABA] (A)
if (s.alive)- 증상: 핸들 H=(idx, gen=5)가 가리키던 엔티티가 디스폰(
generation6으로) 후, 슬롯 idx 가 새 엔티티로 재사용되면alive==true.resolve(H)가 nullptr 대신 새 점유자를 반환 → 죽은 대상에게 보낸 데미지/버프/거래가 무관한 새 엔티티에 적용. - 재현:
H=spawn()→despawn(H)→H2=spawn()(같은 idx 재사용) →resolve(H)가 H2 의 엔티티 반환. - 근본원인: 핸들의 세대 토큰을 검증하지 않음. 슬롯 재사용은 인덱스를 ABA 시키므로, 인덱스만으론 신원을 구별 못 한다.
- 증상: 핸들 H=(idx, gen=5)가 가리키던 엔티티가 디스폰(
- [데이터 레이스 / use-after-recycle] (B)
return &s.entity;- 증상:
resolve(게임플레이/네트워크)가s.alive/s.generation/s.entity를 읽는 동안despawn/spawn(이벤트 스레드)이 같은 슬롯을 비우고/덮어쓴다. 비원자 동시 접근은 C++ 에서 데이터 레이스 = UB. 반환한Entity*가 곧바로 다른 엔티티로 재초기화될 수도 있다(use-after-recycle). - 근본원인: 슬롯맵 접근에 동기화·안전한 회수 정책 부재.
h.index범위 검증도 없음(잘못된 핸들이면 OOB).
- 증상:
수정안
(1) 항상 세대 검증, (2) 인덱스 경계 검증, (3) 동시 재사용을 막는 정책. 가장 단순·견고한 정책은 스폰/디스폰을 시뮬레이션 경계(틱 사이)에서만 수행해 resolve 와 시간적으로 분리하고, 그 동안 슬롯 포인터를 들고 있지 않는 것이다. 동시성이 필요하면 RW락 또는 지연 회수(deferred free).
#include <cstdint>
#include <vector>
#include <mutex>
#include <optional>
struct Entity { int hp = 0; float x = 0, y = 0; };
struct Handle { uint32_t index = 0; uint32_t generation = 0; };
class EntityRegistry {
public:
explicit EntityRegistry(uint32_t capacity) : slots_(capacity) {
for (uint32_t i = capacity; i-- > 0; ) freeList_.push_back(i);
}
Handle spawn() {
std::lock_guard<std::mutex> lk(m_);
uint32_t idx = freeList_.back(); freeList_.pop_back();
Slot& s = slots_[idx];
s.alive = true; s.entity = Entity{};
return Handle{ idx, s.generation };
}
void despawn(Handle h) {
std::lock_guard<std::mutex> lk(m_);
if (h.index >= slots_.size()) return;
Slot& s = slots_[h.index];
if (!s.alive || s.generation != h.generation) return; // 멱등·중복 디스폰 방지
s.alive = false; ++s.generation;
freeList_.push_back(h.index);
}
// 값 복사로 안전하게 반환(포인터를 락 밖으로 새지 않게)
std::optional<Entity> resolve(Handle h) {
std::lock_guard<std::mutex> lk(m_);
if (h.index >= slots_.size()) return std::nullopt; // 경계 검증
const Slot& s = slots_[h.index];
if (s.alive && s.generation == h.generation) // (A) 세대 검증
return s.entity; // (B) 복사본
return std::nullopt;
}
// 제자리 수정이 필요하면 락 안에서 함수를 받아 적용(포인터 비노출)
template <class F>
bool with(Handle h, F&& fn) {
std::lock_guard<std::mutex> lk(m_);
if (h.index >= slots_.size()) return false;
Slot& s = slots_[h.index];
if (!s.alive || s.generation != h.generation) return false;
fn(s.entity);
return true;
}
private:
struct Slot { uint32_t generation = 0; bool alive = false; Entity entity; };
std::mutex m_;
std::vector<Slot> slots_;
std::vector<uint32_t> freeList_;
};
더 나은 설계
- 세대 비트로 ABA 차단: 핸들에 세대를 넣고 슬롯 재사용마다 +1 하는 게 정석(EnTT/slot_map). 세대 폭(예: 32비트)이 충분하면 순환 충돌은 사실상 무시 가능 — 단 16비트면 랩어라운드 고려.
- 회수 정책 분리: ① 스폰/디스폰을 틱 경계에서만(가장 단순, MMO 틱 루프에 적합) ② RW락(읽기 다수) ③ 지연 회수(epoch/리타이어 리스트)로 "리더가 보는 동안 재사용 금지".
concurrency_memory/problem9(ABA)·problem11(Hazard/RCU)와 연결. - 포인터를 빌려주지 말 것:
resolve가 rawEntity*를 락 밖으로 반환하면 그 사이 재사용으로 댕글링. 값 복사 또는with(handle, fn)으로 락 안에서만 접근. - 락 비용이 문제면: 슬롯을 샤딩(스트라이프 락)하거나, 시뮬레이션 단일 스레드 소유 + 다른 스레드는 커맨드 큐로 변경 요청(액터 모델,
concurrency/problem4).
면접 포인트
- 인덱스 재사용이 왜 포인터 ABA 와 같은 문제인지, 세대 토큰이 어떻게 신원을 복원하는지.
- "alive 만 검사"의 함정 — 슬롯 재사용 후 stale 핸들이 새 엔티티에 접근하는 시나리오.
- 동시 슬롯맵의 회수 안전성: 틱 경계 분리 vs RW락 vs 지연 회수(epoch/Hazard)의 트레이드오프.