37. 필드 자원 노드 동시 채집: 검사-차감 비원자와 고갈 디스폰 경합 (C#)
난이도 중해설 — 필드 자원 노드 동시 채집: 검사-차감 비원자와 고갈 디스폰 경합 (C#)
난이도: 중
요약
같은 자원 노드에 여러 채집 워커가 동시에 진입하면, "남았는지 검사 → 지급 → 감소"가 하나의 원자 구간이 아니라 **아이템이 초기 채집 횟수보다 많이 지급(복제)**되고, Remaining 이 음수로 내려가며, 디스폰이 누락되거나 두 번 일어난다. 게다가 Dictionary 를 락 없이 동시 읽기/제거해 컬렉션 자체가 깨진다.
문제점
- 분류 태그: TOCTOU(검사-후-실행 비원자) · 갱신 유실(lost update) · 비스레드세이프 컬렉션 동시 접근.
- 증상: 채집 1회만 남은 노드에서 여러 명이 자원을 받음(복제),
Remaining이 -1, -2 … 로 내려감. 노드가 디스폰되지 않고 남거나, 두 워커가 각각 디스폰을 시도함. 동시 채집이 몰리면Dictionary열거/버킷 손상으로 예외·미정의 동작. - 재현 조건:
Remaining == 1인 노드에 워커 T1, T2 가 거의 동시에 진입.- T1:
(A)Remaining(=1) <= 0거짓 → 통과 - T2:
(A)Remaining(=1) <= 0거짓 → 통과 ← T1 이 아직 감소 안 함 - T1: 지급,
Remaining = 0,(B)디스폰 - T2: 지급(복제!),
Remaining = -1,(B)== 0아니라 디스폰 안 함(또는 T1·T2 순서에 따라 이중 디스폰)
- T1:
- 근본 원인:
- (A)
Remaining의 검사와 차감이 분리되어 있고 그 사이 지급까지 낀다. read-modify-write(Remaining -= 1)가 동기화 없이 수행돼 갱신이 유실되고, 검사 결과가 실행 시점에는 더 이상 참이 아니다. - (B) 소진 판정을
== 0으로만 한다. 병렬 감소로 값이 0을 건너뛰어 -1 이 되면 디스폰이 영영 안 걸리고, 반대로 두 워커가 모두 0을 관측하면 이중 디스폰된다. 디스폰과 채집을 묶는 락도 없다. _nodes(Dictionary)를 조회(TryGetValue)와 제거(Remove)가 동시에 일어나 비스레드세이프 컬렉션 자체 손상.
- (A)
수정안
채집을 노드 단위 원자 구간으로 만들고, "검사 직후 차감(예약)" 을 먼저 해서 지급이 예약을 초과하지 못하게 한다. 구조 변경(제거)은 별도 게이트로 보호한다.
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): 락 없이 빠르지만 "지급 실패 시 롤백" 을 스스로 처리해야 하고, 디스폰/통지 같은 부수효과는 결국 한 스레드로 몰아야 한다.
면접 포인트
- TOCTOU(검사-후-실행)와 lost update의 차이, 그리고 "지급 후 감소"가 아니라 "감소(예약) 후 지급" 으로 뒤집으면 왜 복제가 막히는가.
- 소진 판정을
== 0이 아니라 "이 스레드가 0으로 만든 경우"로 좁혀야 디스폰이 정확히 1회가 되는 이유. - 락 보유 중 지급/통지 같은 무거운·블로킹 작업을 하지 않는 원칙(락 구간 최소화, 호위 방지).
해설 — 필드 자원 노드 동시 채집: 검사-차감 비원자와 삭제 후 접근 (C++)
난이도: 중
요약
같은 자원 노드에 여러 채집 워커가 동시에 진입하면 "검사 → 지급 → 감소"가 원자적이지 않아 자원이 초기 채집 횟수보다 많이 지급(복제) 되고 remaining 이 음수로 내려간다. C++ 에서는 여기에 더해, 한 워커가 노드를 delete 한 뒤 다른 워커가 같은 포인터를 계속 참조하고(nodes_ 에서 꺼낸 raw 포인터), unordered_map 을 동기화 없이 find/erase 해 use-after-free 와 컨테이너 자료구조 파손(UB) 이 겹친다.
문제점
- 분류 태그: TOCTOU(검사-후-실행 비원자) · 갱신 유실(lost update) · 삭제 후 접근(use-after-free) · 비동기화 컨테이너 동시 접근(data race UB).
- 증상: 자원 복제,
remaining음수, 디스폰 누락/이중delete, 그리고 간헐적 크래시(해제된 노드 접근,erase와 동시find로 인한 버킷 손상). - 재현 조건:
remaining == 1노드에 T1, T2 동시 진입.- T1·T2 모두
(A)에서 노드를 찾고remaining(=1) <= 0거짓 통과 - T1: 지급,
remaining=0,(B)despawnNode→erase+delete node - T2: 이미
delete된node를 역참조(node->remaining -= 1) → UAF. 동시에 T2 의find와 T1 의erase가 겹치면 맵 내부가 깨진다.
- T1·T2 모두
- 근본 원인:
- (A)
remaining검사·지급·감소가 한 임계 구간이 아니다. 락 없는 read-modify-write 로 갱신이 유실되고, 검사 결과가 실행 시점엔 무효. - (B) 소진을
== 0으로만 판정 → 병렬 감소가 0을 건너뛰면 디스폰 미발동, 두 워커가 0을 보면 이중delete. 그리고delete로 노드 수명을 끝내지만 다른 워커가 그 포인터를 잡고 있어 수명 관리가 깨진다. nodes_(unordered_map)를 동시find/erase— 표준 컨테이너의 동시 수정+읽기는 데이터 레이스(UB).
- (A)
수정안
노드 수명을 shared_ptr 로 관리해(꺼낸 뒤 안전) UAF 를 차단하고, 맵은 shared_mutex, 노드 상태는 per-node mutex 로 보호한다. 차감을 검사 직후 수행(예약)한다.
#include <unordered_map>
#include <string>
#include <memory>
#include <mutex>
#include <shared_mutex>
struct ResourceNode {
int nodeId;
std::string itemId;
int remaining;
bool despawned = false;
std::mutex m; // 노드 상태 보호
};
class HarvestHandler {
std::shared_mutex mapMx_; // 맵 구조 보호(다중 읽기/단독 쓰기)
std::unordered_map<int, std::shared_ptr<ResourceNode>> nodes_;
void grantItem(int, const std::string&, int) { /* 지급 */ }
public:
bool onHarvest(int playerId, int nodeId) {
std::shared_ptr<ResourceNode> node;
{
std::shared_lock<std::shared_mutex> lk(mapMx_);
auto it = nodes_.find(nodeId);
if (it == nodes_.end()) return false;
node = it->second; // shared_ptr 복사로 수명 연장 → 이후 UAF 불가
}
bool depleted = false;
{
std::lock_guard<std::mutex> lk(node->m);
if (node->despawned || node->remaining <= 0) return false;
node->remaining -= 1; // 예약: 검사 직후 차감
depleted = (node->remaining == 0);
if (depleted) node->despawned = true; // 0으로 만든 스레드만
}
grantItem(playerId, node->itemId, 1); // 지급은 락 밖
if (depleted) {
std::unique_lock<std::shared_mutex> lk(mapMx_);
nodes_.erase(nodeId); // 노드 객체는 마지막 shared_ptr 소멸 시 자동 해제
}
return true;
}
};
핵심: erase 는 맵에서만 빼고, 실제 해제는 남은 shared_ptr 이 모두 사라질 때 일어나므로 진행 중이던 다른 워커가 그 노드를 안전하게 마저 쓴다. remaining 은 락 안에서 예약되므로 음수·복제가 불가능하고, despawned 를 0 도달 스레드만 세팅해 이중 해제가 없다.
더 나은 설계 (트레이드오프)
- 노드/존 소유 스레드 단일 처리(샤딩·액터): 채집 요청을 노드 소유 틱 스레드로 직렬화하면 락·
shared_ptr경합이 사라지고 수명도 단순(소유 스레드만 삭제)해진다. 대신 메시지 홉 지연·구조 복잡도. - 핸들(index+generation) + 지연 회수: raw 포인터 대신 slot-map 핸들을 넘기고 틱 경계에서만 회수하면 원자 refcount 비용 없이 UAF 를 막는다. 대신 세대 검증 로직이 필요.
- 원자 예약:
std::atomic<int> remaining에fetch_sub후 결과가 음수면 롤백(fetch_add). 락은 없지만 부수효과(디스폰/통지)를 한 스레드로 모으는 조정이 필요.
면접 포인트
- 컨테이너에서 꺼낸 raw 포인터의 수명 — 다른 스레드가 컨테이너에서 원소를 제거·삭제할 수 있다면 raw 포인터는 즉시 위험.
shared_ptr(수명 연장) vs 핸들+지연 회수의 선택. delete시점과 "마지막 사용자" 시점의 분리 —erase(자료구조에서 제거)와 실제 메모리 해제를 분리해야 진행 중 접근을 안전하게 끝낼 수 있다.- 표준 컨테이너 동시 접근 규칙(동시 읽기는 OK, 하나라도 수정하면 외부 동기화 필요)과
shared_mutex로 읽기 병렬성 확보.