48. 결투(듀얼) 신청 수락과 만료 타이머의 경합 (C#)
난이도 중해설 — 결투(듀얼) 신청 수락과 만료 타이머의 경합 (C#)
난이도: 중상
요약
결투 성립은 "두 플레이어가 모두 Idle인가"를 확인하고 "두 플레이어를 InDuel로 바꾸는" 복합 연산인데,
검사와 전이가 원자적이지 않고 신청서 소비(Consumed)도 CAS가 아니다.
게다가 만료 타이머가 자기가 만들지 않은 상태(플레이어 State)를 되돌려 놓기 때문에,
수락과 만료가 겹치면 이미 시작된 결투의 참가자를 Idle로 리셋해 상태 짝이 깨진다.
문제점
(A) check-then-act — 상태 검사와 결투 전이의 비원자성 / 신청서 이중 소비
- 분류: 경쟁 조건(TOCTOU) · 원자성 위반
- 증상: 한 플레이어가 동시에 두 개의 결투에 참가한 것으로 기록된다(
CurrentDuelId가 마지막 것으로 덮여, 먼저 만들어진 결투는 참가자를 잃은 채_duels에 남는다). 또는InCombat으로 막아야 할 플레이어가 결투에 들어간다. - 재현 조건: B에게 A와 C가 각각 신청 → B의 클라이언트가 두 수락 패킷을 연달아 보내 서로 다른 IOCP 워커 스레드에서
Accept가 동시에 실행. 두 호출 모두a.State/b.State를 Idle로 읽고 통과. - 근본 원인:
if (a.State != Idle || b.State != Idle)검사와 그 아래a.State = InDuel대입 사이에 어떤 락도 없다. 플레이어에Sync락 필드가 있는데도Accept는 쓰지 않는다.req.Consumed를 평범한bool읽기·쓰기로 다루므로 "신청서는 한 번만 소비"라는 불변식이 두 스레드 동시 진입을 막지 못한다.Interlocked계열의 단일 승자 선정이 없다.Request시점의 Idle 검사는 수락 시점의 보증이 아니다. 신청 후 30초 사이에 상태는 얼마든지 바뀐다.PlayerState/CurrentDuelId는 락 없이 여러 스레드가 읽고 쓰는데volatile도 아니어서 가시성 보장도 없다.
(B) 만료 경로가 남의 상태를 되돌린다 — 소유권 없는 롤백 + 경합
- 분류: 상태 소유권 위반 · 경쟁 조건
- 증상: 결투가 막 시작됐는데 두 플레이어가 Idle로 리셋된다. 반대로 한쪽만 리셋되면 A는
InDuel, B는Idle이 되어 짝이 안 맞는 유령 결투가 남고, A는 영영 다른 결투를 신청할 수 없다. - 재현 조건: 30초 경계에서
Accept와Expire가 동시 실행.Expire가req.Consumed를 false로 읽은 직후Accept가 상태를InDuel로 올리고Consumed = true로 바꾼다.Expire는 이미 통과한 분기를 계속 진행해Idle로 덮어쓴다. - 근본 원인:
Expire의Consumed확인 → 상태 되돌리기 역시 검사와 조치가 비원자적이다.- 더 근본적으로 만료 경로가 플레이어 상태를 건드릴 권한이 없다. 신청서는 플레이어 State를 바꾸지 않았으므로(
Request는 State를 읽기만 한다) 만료가 State를 Idle로 되돌리는 것 자체가 잘못된 보상 동작이다. 신청서 만료는 신청서만 지워야 한다. Accept에서req.ExpireTimer.Dispose()를 호출해도 이미 실행 중인 콜백은 멈추지 않는다(Dispose()는 향후 실행만 막는다).Timer.Dispose(WaitHandle)또는 상태 기반 방어가 필요하다.Request안에서req.ExpireTimer를 대입하기 전에 타이머가 생성·발화될 수 있다. 30초라 실제로는 거의 안 나지만, 짧은 만료로 바꾸면Expire에서req.ExpireTimer?.Dispose()가 null을 보게 되는 발행 경합이다.
수정안
핵심은 세 가지다. ① 결투 성립을 두 플레이어 락(정규 순서) 안에서 원자적으로 수행, ② 신청서 소비를 CAS 단일 승자로, ③ 만료는 신청서만 지운다.
public sealed class Player
{
public long Id;
private int _state = (int)PlayerState.Idle; // Interlocked 대상
public long CurrentDuelId;
public readonly object Sync = new object();
public PlayerState State => (PlayerState)Volatile.Read(ref _state);
public void SetState(PlayerState s) => Volatile.Write(ref _state, (int)s);
}
public sealed class DuelRequest
{
public long Id;
public long FromId;
public long ToId;
public Timer ExpireTimer;
private int _consumed; // 0 = 미소비
public bool TryConsume() => Interlocked.CompareExchange(ref _consumed, 1, 0) == 0;
}
public bool Accept(long requestId, long accepterId)
{
if (!_requests.TryGetValue(requestId, out var req)) return false;
if (req.ToId != accepterId) return false;
// ① 신청서를 먼저 원자적으로 소비 — 동시 수락/만료 중 단 한 명만 통과
if (!req.TryConsume()) return false;
_requests.TryRemove(requestId, out _);
req.ExpireTimer?.Dispose();
var a = _players[req.FromId];
var b = _players[req.ToId];
// ② 정규 락 순서(Id 오름차순)로 두 플레이어를 함께 잠근다 — ABBA 데드락 방지
var first = a.Id < b.Id ? a : b;
var second = a.Id < b.Id ? b : a;
lock (first.Sync)
lock (second.Sync)
{
// ③ 락 안에서 재확인 후 전이 (check-and-act 원자화)
if (a.State != PlayerState.Idle || b.State != PlayerState.Idle)
return false; // 소비된 신청서는 그대로 폐기
var duel = new Duel
{
Id = Interlocked.Increment(ref _nextDuelId),
P1 = a.Id, P2 = b.Id, StartedUtc = DateTime.UtcNow,
};
_duels[duel.Id] = duel;
a.SetState(PlayerState.InDuel); a.CurrentDuelId = duel.Id;
b.SetState(PlayerState.InDuel); b.CurrentDuelId = duel.Id;
return true;
}
}
private void Expire(long requestId)
{
if (!_requests.TryRemove(requestId, out var req)) return;
// 만료는 신청서만 소비하고 끝. 플레이어 상태는 건드리지 않는다.
if (!req.TryConsume()) return; // 이미 수락됐다면 아무 일도 하지 않는다
req.ExpireTimer?.Dispose();
NotifyExpired(req.FromId, req.ToId);
}
Request의 타이머 생성 경합은 타이머를 정지 상태로 만든 뒤 필드에 대입하고 나서 시작해 없앤다.
var timer = new Timer(_ => Expire(req.Id), null, Timeout.Infinite, Timeout.Infinite);
req.ExpireTimer = timer;
timer.Change(30_000, Timeout.Infinite);
또한 같은 사람에게 온 여러 신청을 관리하려면 _requests 외에 수신자 → 신청서 집합 역인덱스를 두고,
결투 성립 시 두 참가자에게 걸린 나머지 신청서를 모두 TryConsume()으로 무효화해야 한다.
그러지 않으면 결투가 끝난 직후 오래된 신청서가 되살아난다.
더 나은 설계
(1) 플레이어 상태를 상태 기계 + CAS 전이로.
TryBeginDuel(long duelId)가 Interlocked.CompareExchange(ref _state, InDuel, Idle)로 전이하고,
둘 중 하나만 성공하면 성공한 쪽을 되돌리는 방식이면 락 없이 처리할 수 있다.
- 트레이드오프: 부분 성공 롤백 로직이 필요하고, "두 플레이어를 원자적으로 잡는다"는 의미가 코드에 드러나지 않아 읽기 어렵다. 참가자가 3명 이상(파티 대전)으로 늘면 관리가 급격히 어려워진다.
(2) 결투 매니저를 단일 액터(직렬 큐)로. 결투 신청/수락/만료/종료를 전부 하나의 메시지 큐에 넣고 한 스레드가 순차 처리하면 이 유형의 경합이 설계상 사라진다. 만료도 "만료 메시지"로 같은 큐에 들어오므로 수락과의 순서가 자연히 결정된다.
- 트레이드오프: 결투 매니저가 단일 스레드 병목이 된다. 다만 결투 성립은 초당 수백 건 수준이라 실제로는 문제되지 않고, 락 순서/재진입 고민이 통째로 사라지는 이득이 훨씬 크다. 존 단위로 샤딩하면 병목도 해소된다.
(3) 신청서를 만료 시각 기반 lazy 검증으로.
타이머 스레드를 없애고 ExpiresAtUtc만 저장한 뒤, 수락 시점에 만료 여부를 판정하고 주기적 청소만 배치로 돌린다.
- 트레이드오프: 타이머 콜백과의 경합이 원천 제거되고 타이머 객체 수천 개를 안 만들어도 된다. 대신 "만료됐습니다" 알림이 즉시 가지 않아 UX가 약간 나빠진다. 실무에서는 이 방식 + 저해상도 타임휠 조합이 흔하다.
(4) 결투 종료 경로도 같이 설계.
지금 코드에는 종료가 없는데, 종료 역시 "두 플레이어를 Idle로"라는 복합 연산이므로 동일한 락 순서·CurrentDuelId 일치 검증(a.CurrentDuelId == duelId)이 필요하다. 이 검증이 없으면 늦게 도착한 옛 결투의 종료 처리가 새 결투를 끊는다.
면접 포인트
- "여러 객체의 상태를 원자적으로 바꿔야 할 때 락 순서를 어떻게 정하는가?" — 고유 ID 오름차순 같은 전역 정규 순서. 인자 순서대로 잠그면 반대 방향 요청과 ABBA 데드락이 난다.
- "
Timer.Dispose()를 호출하면 콜백이 안 오는가?" — 이미 시작된 콜백은 못 막는다. 취소는 항상 "타이머 정지 + 상태 기반 멱등 방어" 두 겹으로 해야 한다. - "만료/타임아웃 처리가 상태를 롤백해도 되는가?" — 자신이 만든 상태만 되돌려야 한다. 남이 만든 상태를 되돌리는 보상 로직은 정상 경로와 반드시 경합한다. 이는 사가(saga) 보상 트랜잭션 설계와 같은 원칙이다.
해설 — 결투(듀얼) 신청 수락과 만료 스레드의 경합 (C++)
난이도: 상
요약
C# 트윈과 같은 논리 결함(검사-전이 비원자, 만료가 남의 상태를 롤백)에 더해,
C++ 버전은 신청서를 DuelRequest* 생 포인터 + 수동 delete 로 관리한다.
두 경로가 같은 신청서를 각자 delete 할 수 있으므로 이중 해제와 해제 후 접근이 추가로 성립한다.
또한 두 플레이어 뮤텍스를 from, to 인자 순서대로 잠가 반대 방향 결투와 락 순서가 엇갈린다.
문제점
(A) 두 플레이어 뮤텍스의 순서 없는 획득 + 락 밖 검사
- 분류: 데드락(ABBA) · 경쟁 조건(TOCTOU)
- 증상: 두 세션 스레드가 서로를 영원히 기다려 존 서버의 결투 처리 스레드가 통째로 멈춘다. 데드락이 안 나는 실행에서는 한 플레이어가 두 결투에 동시 배정된다.
- 재현 조건:
- 데드락: A→B 신청을 B가 수락하는 동시에, B→A 신청을 A가 수락한다. 스레드 1은
lock(A)후lock(B)대기, 스레드 2는lock(B)후lock(A)대기. - 오배정: B에게 A와 C가 각각 신청 → B가 둘 다 수락. 두
Accept가 서로 다른 플레이어 쌍(A,B)과 (C,B)를 잠그므로 겹치는 락은 B뿐이지만,req->consumed검사는 락 밖에 있어 둘 다 통과한다.b->currentDuelId는 나중 것으로 덮인다.
- 데드락: A→B 신청을 B가 수락하는 동시에, B→A 신청을 A가 수락한다. 스레드 1은
- 근본 원인:
std::lock_guard la(a->sync); std::lock_guard lb(b->sync);가 호출 인자 순서를 그대로 따른다. 여러 뮤텍스를 잡을 때는 주소·ID 같은 전역 정규 순서가 필요하며, 표준은 이를 위해std::lock/std::scoped_lock을 제공한다.req->consumed읽기·쓰기가 락 밖의 비원자bool접근이다. C++ 메모리 모델상 서로 다른 스레드의 비동기화 접근은 데이터 레이스 = UB이며, 컴파일러가 이 읽기를 루프 밖으로 끌어내거나 재사용해도 된다.Request의 Idle 검사도 락 없이 수행되며 수락 시점까지 유효하지 않다.
(B) 만료 경로의 소유권 위반 + 생 포인터 이중 해제 / 해제 후 접근
- 분류: 수명 관리 오류(이중 해제·해제 후 접근) · 상태 소유권 위반
- 증상: 힙 손상으로 인한 즉시 크래시, 또는
req->consumed가 쓰레기 값이 되어 만료가 살아있는 결투를 해제한다. 부하 중에만 재현되고 스택 트레이스가 매번 다르다. - 재현 조건: 30초 경계에서
Accept와Expire가 동시 실행.Expire가 맵에서req포인터를 꺼낸 뒤 락을 놓는다 →Accept가erase후delete req→Expire가req->consumed를 읽는다(해제 후 접근).- 또는 두 경로가 각각
erase에 성공했다고 믿고 둘 다delete req를 호출한다(이중 해제).
- 근본 원인:
- 소유권이 명시되지 않았다.
requests_는DuelRequest*를 보관할 뿐 소유권이 맵에 있는지 호출자에게 있는지 코드로 표현되지 않는다.erase와delete가 분리돼 있어 "맵에서 지운 스레드가 삭제한다"는 규약이 지켜지는지 컴파일러가 검증할 수 없다. - 포인터를 락 안에서 꺼내 락 밖에서 역참조한다. 락은 맵 자료구조만 보호하고 가리키는 객체의 수명은 보호하지 않는다.
- 만료가
a->state,b->state를Idle로 되돌리는 것 자체가 잘못이다.Request는 상태를 바꾸지 않았으므로 만료가 되돌릴 상태가 없다. 게다가 이 쓰기는 플레이어 뮤텍스 밖에서 일어나Accept의 락 안 쓰기와 레이스한다. - 신청서마다
std::thread(...).detach()로 30초 자는 스레드를 만든다. 신청 1만 건이면 스레드 1만 개(각 수 MB 스택)이고,detach된 스레드는 서비스 종료 시 정리할 방법이 없어 종료 중 파괴된this를 만진다.
- 소유권이 명시되지 않았다.
수정안
#include <memory>
#include <mutex>
#include <atomic>
#include <algorithm>
struct DuelRequest {
std::uint64_t id = 0, fromId = 0, toId = 0;
std::atomic<bool> consumed{false};
bool TryConsume() {
bool expected = false;
return consumed.compare_exchange_strong(expected, true,
std::memory_order_acq_rel, std::memory_order_acquire);
}
};
shared_ptr 로 소유권을 명시해 이중 해제·해제 후 접근을 원천 제거한다.
class DuelService {
std::unordered_map<std::uint64_t, std::shared_ptr<DuelRequest>> requests_;
public:
bool Accept(std::uint64_t requestId, std::uint64_t accepterId) {
std::shared_ptr<DuelRequest> req; // 소유 지분을 들고 나온다
{
std::lock_guard<std::mutex> lk(reqMx_);
auto it = requests_.find(requestId);
if (it == requests_.end()) return false;
req = it->second; // 이후 req 는 안전하게 살아있다
}
if (req->toId != accepterId) return false;
// ① 단일 승자 선정: 동시 수락 / 만료 중 하나만 통과
if (!req->TryConsume()) return false;
{
std::lock_guard<std::mutex> lk(reqMx_);
requests_.erase(requestId); // delete 없음 — shared_ptr 가 처리
}
Player* a = Find(req->fromId);
Player* b = Find(req->toId);
if (!a || !b) return false;
// ② 정규 순서 없이도 안전한 동시 획득 (내부적으로 데드락 회피 알고리즘)
std::scoped_lock lk(a->sync, b->sync); // C++17
// ③ 락 안에서 재검사 후 전이
if (a->state != PlayerState::Idle || b->state != PlayerState::Idle)
return false;
auto duel = std::make_shared<Duel>();
duel->id = ++nextDuelId_;
duel->p1 = a->id;
duel->p2 = b->id;
{
std::lock_guard<std::mutex> dk(duelMx_);
duels_[duel->id] = duel;
}
a->state = PlayerState::InDuel; a->currentDuelId = duel->id;
b->state = PlayerState::InDuel; b->currentDuelId = duel->id;
return true;
}
private:
void Expire(std::uint64_t requestId) {
std::shared_ptr<DuelRequest> req;
{
std::lock_guard<std::mutex> lk(reqMx_);
auto it = requests_.find(requestId);
if (it == requests_.end()) return;
req = it->second;
}
if (!req->TryConsume()) return; // 이미 수락됨 → 아무것도 하지 않는다
{
std::lock_guard<std::mutex> lk(reqMx_);
requests_.erase(requestId);
}
NotifyExpired(req->fromId, req->toId); // 플레이어 상태는 건드리지 않는다
}
};
std::scoped_lock이 없는 C++11/14라면:
std::unique_lock<std::mutex> la(a->sync, std::defer_lock);
std::unique_lock<std::mutex> lb(b->sync, std::defer_lock);
std::lock(la, lb); // 데드락 회피 동시 획득
a == b(자기 자신과의 결투)를 막지 않으면 같은 뮤텍스를 두 번 잠가 UB가 된다.
Request에서 fromId == toId를 거르고 있으나, 방어적으로 if (a == b) return false;를 락 직전에 두는 편이 안전하다.
만료 스레드는 스레드 1개 + 타임휠/우선순위 큐로 바꾼다.
// 신청서마다 스레드를 만들지 않는다
struct Pending { std::chrono::steady_clock::time_point deadline; std::uint64_t reqId; };
// 단일 만료 스레드가 std::priority_queue 를 돌며 condition_variable::wait_until
더 나은 설계
(1) 결투 매니저를 단일 스레드 액터로.
Request/Accept/Expire/Finish를 모두 하나의 lock-free MPSC 큐로 보내 한 스레드가 처리하면
뮤텍스 순서·수명·재진입 문제가 통째로 사라진다.
- 트레이드오프: 처리량 상한이 생기지만 결투 성립은 초당 수백 건 수준이고, 존 단위 샤딩으로 확장 가능하다. C++에서는 특히 "생 포인터 수명"이라는 가장 위험한 축을 없앤다는 점이 크다.
(2) 상태 전이를 std::atomic<PlayerState> + CAS 로.
Idle → InDuel 전이를 CAS로 하고, 한쪽만 성공하면 되돌린다.
- 트레이드오프: 뮤텍스가 사라져 데드락이 구조적으로 불가능해지지만, 부분 성공 롤백 코드가 필요하고 참가자가 3명 이상이면 복잡도가 폭발한다. 롤백 사이의 짧은 창에서 다른 요청이 실패할 수도 있다(가짜 실패).
(3) 소유권을 타입으로 표현.
requests_는 std::unordered_map<Id, std::shared_ptr<DuelRequest>>, 결투는 std::unique_ptr + 핸들(ID) 참조.
"누가 지우는가"를 주석이 아니라 타입으로 강제하면 이중 해제 리뷰 비용이 사라진다.
- 트레이드오프:
shared_ptr참조 카운트의 원자 연산 비용이 붙지만, 초당 수백 건 규모에서는 무시 가능하다. 틱 루프 핫패스라면 세대 카운터가 붙은 슬롯맵 핸들로 바꾸는 편이 낫다.
(4) 신청서를 만료 시각 기반 lazy 검증으로.
deadline만 저장하고 수락 시점에 만료를 판정, 청소는 주기적 배치로.
- 트레이드오프: 타이머 경합이 원천 제거되지만 만료 알림이 즉시 가지 않는다. 실무에서는 lazy 검증 + 저해상도 타임휠 알림 조합이 표준이다.
면접 포인트
- "여러 뮤텍스를 동시에 잡아야 할 때?" —
std::scoped_lock(C++17) 또는std::lock+defer_lock. 손으로 순서를 정한다면 주소가 아니라 안정적인 논리 키(ID) 를 쓴다(주소는 재할당되면 순서가 바뀐다). - "락 안에서 꺼낸 생 포인터를 락 밖에서 써도 되는가?" — 안 된다. 락은 컨테이너를 보호하지 컨테이너가 가리키는 객체의 수명을 보호하지 않는다.
shared_ptr지분을 복사해 나오거나, 락 안에서만 쓴다. - "C++ 데이터 레이스가 왜 '틀린 값'이 아니라 UB인가?" — 컴파일러는 레이스가 없다고 가정하고 읽기를 호이스팅/CSE 한다. 그래서
bool consumed를 락 밖에서 읽는 코드는 "가끔 옛 값"이 아니라 "루프에서 영원히 옛 값"이 될 수 있다.