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

난이도 최상 해설 보기 →

결함을 모두 찾고 원인·수정안·더 나은 설계를 제시하라. 마커 (A)(B) 는 주목 위치 힌트다.

결함 코드 · C#
// ============================================================================
// 시나리오
// ----------------------------------------------------------------------------
// 게임서버의 타이머 휠(timing wheel)이다. 스킬 쿨다운, 버프 만료, 재접속
// 타임아웃 등 수십만 개의 타이머를 O(1) 로 굴리기 위한 자료구조다.
//  - 휠은 N개의 슬롯(버킷)으로 구성. 전용 "tick 스레드"가 매 1ms 마다
//    cursor 를 한 칸 전진시키고, 그 슬롯의 만료 타이머들을 실행한다.
//  - 다수의 워커 스레드가 동시에 Schedule() 로 새 타이머를 임의 슬롯에 넣고,
//    Cancel() 로 취소(논리 삭제) 한다.
//  - "tick 은 단일 스레드라 슬롯 리스트는 안전하다"고 가정했다.
//
// 운영 중 증상(드물고 비결정적, 부하 의존):
//  - 가끔 타이머가 "한 바퀴 늦게" 또는 "영영" 안 터진다.
//  - 가끔 막 취소한 타이머의 콜백이 그래도 실행된다(취소 누락).
//  - 아주 가끔 tick 스레드가 슬롯을 순회하다 예외(리스트 손상/중복)로 죽는다.
//  - cursor 값을 워커가 읽어 "내 타이머가 곧 터지는지" 판단하는 로직이 틀린다.
//
// 요구사항
// ----------------------------------------------------------------------------
//  - Schedule/Cancel 은 여러 워커에서 동시에 안전해야 한다.
//  - tick 스레드는 만료 슬롯을 정확히, 누락/중복 없이 실행해야 한다.
//  - cursor 등 공유 상태는 다른 스레드에 정확한 값/순서로 보여야 한다.
//
// 과제
// ----------------------------------------------------------------------------
// 이 코드를 코드리뷰하라.
//  1) 동시성 결함을 모두 찾고, atomic/메모리배리어 관점에서 정확히 설명하라.
//  2) (A)(B)(C)(D) 각 지점의 문제를 지적하라.
//  3) 정확하고 효율적으로 수정하라(설계 대안 포함).
// ============================================================================

using System;

namespace TimingWheelDemo
{
    public sealed class TimerNode
    {
        public long ExpireTick;
        public Action Callback;
        public bool Canceled;       // (B) 취소 플래그 (일반 bool)
        public TimerNode Next;
    }

    public sealed class TimingWheel
    {
        private readonly int _slots;
        private readonly TimerNode[] _buckets;
        private long _cursor; // (A) tick 이 쓰고, 워커가 읽음 (일반 long)

        public TimingWheel(int slots)
        {
            _slots = slots;
            _buckets = new TimerNode[slots];
        }

        // 워커 스레드들이 호출 (동시)
        public TimerNode Schedule(long delayTicks, Action cb)
        {
            long cur = _cursor;                       // (A) 현재 커서 읽기
            long expire = cur + delayTicks;
            int idx = (int)(expire % _slots);

            var n = new TimerNode { ExpireTick = expire, Callback = cb, Canceled = false };

            // (C) 슬롯 리스트 머리에 끼워넣기 (락 없음)
            n.Next = _buckets[idx];
            _buckets[idx] = n;
            return n;
        }

        // 워커 스레드가 호출: 논리 취소
        public void Cancel(TimerNode n)
        {
            // (B) 그냥 플래그만 세운다
            n.Canceled = true;
        }

        // tick 스레드 1개만 호출 (1ms 마다)
        public void Tick()
        {
            long cur = _cursor;
            int idx = (int)(cur % _slots);

            // 이 슬롯을 통째로 떼어내 순회
            TimerNode node = _buckets[idx];
            _buckets[idx] = null;

            TimerNode keep = null;
            while (node != null)
            {
                TimerNode nextNode = node.Next;
                if (!node.Canceled && node.ExpireTick <= cur)
                {
                    node.Callback();
                    // (D) 실행 후 참조 끊기 (논리 회수)
                }
                else if (node.Canceled)
                {
                    // (D) 취소된 것도 버림
                }
                else
                {
                    // 아직 만료 아님(다음 바퀴) → 보관
                    node.Next = keep;
                    keep = node;
                }
                node = nextNode;
            }
            // 보관 노드 되돌려놓기
            _buckets[idx] = keep;

            _cursor = cur + 1;            // (A) 커서 전진
        }

        public long Cursor => _cursor;    // (A) 워커가 읽음
    }
}
내 리뷰 · C#
내 답안 · 자동 저장

작성 후 위 해설 보기에서 모범 해설과 대조하세요.