← 문제로

10. 타이머 휠의 동시성: 비volatile cursor, 비동기 취소, 무보호 슬롯 리스트

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

해설 — 타이머 휠의 동시성: 비volatile cursor, 비동기 취소, 무보호 슬롯 리스트

난이도: 최상

요약

"tick은 단일 스레드라 안전하다"는 전제가 틀렸다. 워커들이 동시에 같은 슬롯에 Schedule하고, tick 스레드가 같은 슬롯을 순회한다 — 슬롯 리스트는 다중 스레드가 만지는 공유 자료구조다. 결함이 네 갈래로 겹친다. (A) _cursor일반 long 이라 tick의 쓰기와 워커의 읽기 사이에 가시성·순서 보장이 없고, 32비트 런타임에선 64비트 읽기/쓰기가 찢어질(torn) 수 있다. (B) Cancel이 비volatile bool 플래그만 세워 tick 스레드에 가시성 보장 없이 전달되고, tick이 !Canceled를 본 직후 워커가 취소하면 "막 취소했는데 실행됨". (C) 슬롯 리스트 head 삽입이 락도 Interlocked도 없어 Schedule끼리, Schedule과 Tick의 슬롯 떼어내기(_buckets[idx]=null)가 레이스 → 노드 유실/리스트 손상/순회 중 예외. (D) "실행 후 버림"이 동시 취소·재참조와 충돌해 워커가 잡고 있던 노드 상태가 어긋난다. 핵심은 "단일 tick 스레드"라는 가정이 삽입/취소가 다른 스레드라는 사실을 무시한 것.

C++판은 같은 시나리오를 std::atomic 부재 + 비원자 bool + delete 즉시 해제(use-after-free/double-free) 로 다룬다. C#은 GC라 use-after-free/double-free는 없지만, 데이터 레이스로 인한 리스트 손상·가시성 누락·찢긴 cursor는 그대로 발생한다.

문제점

(A) long _cursor — 비volatile 공유 (분류: 동시성, 핵심)

  • 증상: 워커의 Cursor/Schedule이 stale하거나(64비트에서 가시성 누락) 찢긴(32비트) cursor를 봐서 만료 슬롯을 잘못 계산 → 타이머가 한 바퀴 늦게 또는 영영 안 터짐. "곧 터지는지" 판단 로직 오류.
  • 재현조건: 32비트 런타임에선 64비트 store/load가 비원자라 찢어질 수 있고, 64비트에서도 가시성·순서 보장이 없어 워커가 옛 cursor를 본다.
  • 근본원인: 일반 long 필드에 대한 동시 읽기/쓰기는 .NET 메모리 모델상 순서·가시성을 보장하지 않으며, 64비트 값의 단순 읽기/쓰기 원자성은 정렬된 경우에만, 그리고 사실상 64비트 플랫폼에서만 보장된다. _cursorInterlocked.Read/Volatile.Read/Volatile.Write로 다뤄야 하며, "cursor 전진"이 "슬롯 변경 발행"과 묶이려면 release/acquire 의미가 필요하다.

(B) bool Canceled 비volatile + 취소-수명 분리 (분류: 동시성)

  • 증상: (1) 워커가 Cancel로 세운 플래그가 tick 스레드에 안 보여 취소된 타이머가 그대로 실행(취소 누락). (2) tick이 콜백 실행 직전 !Canceled를 봤는데 그 직후 워커가 취소 → "막 취소했는데 실행됨".
  • 근본원인: 비volatile bool은 가시성·순서 보장이 없다. Volatile.Write(취소)/Volatile.Read(tick)로 가시성을 확보하되, "확인-실행" 사이의 경합은 본질적이라 콜백을 실행 직전 한 번 더 검사하거나 취소 의미를 명확히 정의해야 한다.

(C) 슬롯 리스트 head 삽입/탈착 무보호 (분류: 동시성, 매우 심각)

  • 증상: 두 워커가 같은 슬롯에 동시에 Schedule하면 n.Next = _buckets[idx]; _buckets[idx] = n; 가 인터리브돼 한 노드가 유실된다(lost update). tick의 _buckets[idx] = null(슬롯 떼기)와 워커의 삽입이 겹치면 방금 넣은 노드가 사라지거나 리스트가 끊겨 순회 중 잘못된 Next 참조로 예외/무한루프.
  • 재현조건: 같은 슬롯에 동시 삽입, 또는 tick 처리 중 그 슬롯에 삽입. 슬롯 수가 적고 부하가 높을수록 충돌↑.
  • 근본원인: _buckets[idx]는 여러 스레드가 쓰는 공유 참조인데 락도 Interlocked.CompareExchange도 없다. "tick이 단일 스레드"여도 삽입은 워커(다중) 이므로 슬롯은 공유 자원이다.

(D) "실행 후 버림" — 동시 취소/재참조 충돌 (분류: 동시성)

  • 증상: tick이 노드를 처리(실행/폐기)하는 동안 워커가 같은 노드 참조로 Cancel하거나 재스케줄을 시도하면, 워커가 들고 있는 노드의 상태/연결이 어긋난다(예: 이미 슬롯에서 빠진 노드를 취소).
  • 근본원인: C#은 GC라 메모리는 안전하지만, 노드 상태(Canceled, Next, 슬롯 소속)가 동기화 없이 두 스레드에서 변경되어 논리적 손상이 생긴다. 취소는 "플래그 + 안전한 소유권/세대 토큰"으로 설계해야 한다.

메모리 순서만의 문제가 아니라 공유 자료구조 동기화 + 취소 수명 + cursor 발행이 전부 얽힌 복합 결함이다. Volatile 한두 개로는 안 풀린다.

수정안

핵심 결정: 각 슬롯을 락으로 보호, cursor를 Volatile/Interlocked, 취소는 atomic flag, 콜백은 슬롯 락 밖에서 실행. (게임서버 타이머 휠에서 가장 견고하고 단순한 길.)

using System;
using System.Collections.Generic;
using System.Threading;

public sealed class TimerNode
{
    public long ExpireTick;
    public Action Callback;
    private int _canceled;                       // (B) 0/1 atomic flag
    public bool IsCanceled => Volatile.Read(ref _canceled) != 0;
    public void Cancel() => Volatile.Write(ref _canceled, 1);
}

public sealed class TimingWheel
{
    private readonly int _slots;
    private readonly List<TimerNode>[] _buckets;
    private readonly object[] _locks;            // (C) 슬롯별 락(경합 분산)
    private long _cursor;                         // (A)

    public TimingWheel(int slots)
    {
        _slots = slots;
        _buckets = new List<TimerNode>[slots];
        _locks = new object[slots];
        for (int i = 0; i < slots; i++) { _buckets[i] = new List<TimerNode>(); _locks[i] = new object(); }
    }

    public TimerNode Schedule(long delayTicks, Action cb)
    {
        long cur = Interlocked.Read(ref _cursor);            // (A) 원자적 읽기
        long expire = cur + delayTicks;
        int idx = (int)(expire % _slots);

        var n = new TimerNode { ExpireTick = expire, Callback = cb };
        lock (_locks[idx])                                   // (C) 슬롯 락
            _buckets[idx].Add(n);
        return n;                                            // 워커는 이 핸들로만 Cancel
    }

    public void Cancel(TimerNode n) => n?.Cancel();          // (B) atomic flag

    // tick 스레드 단독
    public void Tick()
    {
        long cur = Interlocked.Read(ref _cursor);
        int idx = (int)(cur % _slots);

        var due = new List<TimerNode>();
        lock (_locks[idx])                                   // (C) 슬롯 락
        {
            var bucket = _buckets[idx];
            var keep = new List<TimerNode>(bucket.Count);
            foreach (var n in bucket)
            {
                if (n.IsCanceled) continue;                  // 그냥 버림(GC가 회수)
                if (n.ExpireTick <= cur) due.Add(n);         // 실행 대상
                else keep.Add(n);                            // 다음 바퀴
            }
            _buckets[idx] = keep;
        }

        // (D) 콜백은 슬롯 락 밖에서 실행 (재진입/장시간 콜백 안전)
        foreach (var n in due)
            if (!n.IsCanceled) n.Callback();

        // (A) cursor 전진을 Volatile.Write(release)로 발행: 위 슬롯 변경이 워커에게 보이도록
        Volatile.Write(ref _cursor, cur + 1);
        // 또는 Interlocked.Exchange/Increment 로 원자성+장벽 동시 확보
    }

    public long Cursor => Interlocked.Read(ref _cursor);     // (A) 원자적 읽기
}

핵심 변경 요약:

  • (A) _cursorInterlocked.Read로 원자적 읽기, 전진은 Volatile.Write(release, 슬롯 변경 발행) 또는 Interlocked. 32비트에서도 찢김 없음.
  • (B) CanceledVolatile.Read/Write 기반 atomic flag로 가시성 확보. 콜백 실행 직전 한 번 더 검사해 경합 창을 줄임.
  • (C) 슬롯별 lock으로 삽입/순회를 보호. 슬롯 단위라 경합이 분산된다.
  • (D) 콜백을 슬롯 락 에서 실행해 재진입(콜백이 다시 Schedule) 데드락을 회피. 노드 회수는 GC가 처리하므로 명시 해제 불필요.

더 나은 설계

  1. 슬롯별 lock-free 삽입 큐(MPSC): 워커=다중 생산자, tick=단일 소비자이므로 슬롯마다 ConcurrentQueue<TimerNode>를 두면 Schedule이 락 없이 Enqueue, tick이 단독 Drain. 락보다 확장성↑. 트레이드오프: 만료 순서/중간 취소 처리 로직 추가.

  2. 취소를 "세대 토큰"으로: 노드에 int Generation을 두고, Cancel은 generation을 Interlocked.Increment해 핸들과 불일치하면 무효 처리. 노드 재사용(풀) 시 ABA류 오취소도 방어. 트레이드오프: 핸들 비교 로직.

  3. 계층형 타이밍 휠(hierarchical timing wheel): 큰 delay를 여러 단계 휠로 캐스케이드(리눅스 커널 방식). 슬롯 수를 작게 유지하면서 넓은 범위를 O(1)에 가깝게. 동시성 설계는 위 원칙 동일.

  4. 취소 비용 vs 즉시 제거: 여기처럼 "플래그 후 tick에서 스킵(lazy delete)"은 Cancel이 O(1)이지만 만료 시점까지 노드가 살아있다. 즉시 제거가 필요하면 슬롯 락 잡고 리스트에서 제거(O(n), 또는 인트루시브 연결 리스트로 O(1)). 트레이드오프: Cancel 지연 vs 메모리 점유.

  5. 콜백 실행 정책: 콜백을 tick 스레드에서 직접 실행하면 긴 콜백이 휠을 막는다. 만료 노드를 잡 큐(ThreadPool/Channel)로 넘겨 워커 풀에서 실행하면 tick이 가벼워진다. 트레이드오프: 지연/순서.

면접 포인트

  1. "tick이 단일 스레드라 슬롯이 안전하다"는 주장의 허점은? 어떤 접근이 다중 스레드라서 슬롯이 공유 자원이 되는가?
  2. _cursor를 단순 long으로 두면 32비트/64비트 런타임에서 각각 왜 문제인가? 데이터 레이스, 64비트 읽기 원자성, 그리고 Volatile/Interlocked로 cursor 전진과 슬롯 변경 발행을 묶는 이유를 설명하라.
  3. 동시 환경에서 "취소"를 비volatile bool 플래그로 구현하면 어떤 가시성/경합 문제가 생기나? C++(use-after-free 동반)와 C#(GC로 메모리 안전하나 논리 손상)에서 결과가 어떻게 다른가? 세대 토큰/슬롯 락 중 무엇으로 안전하게 만드는지와 트레이드오프를 논하라.