前言 在游戏开发过程中,使用一些计时器来管理定时任务是非常常见的,计时器主要考虑的我认为有以下几个点:
Task 的设计,应当包含哪些 callback,是否支持多次调用 callback
timer 的 tick 和 callback 调用是否一直在多个线程当中处理还是可以放在指定的线程(比如游戏主线程)
是否支持多线程
如何避免浮点数累计带来的误差
TickTimer 此计时器的特点主要有:
使用 time 累加计算当前时间
支持多线程
tick 和 callback 调用可以放在任意线程或者指定线程
Task 的设计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class TickTask { public int taskID; public uint delay; public int count; public double destTime; public Action<int > taskCallback; public Action<int > cancelCallback; public double startTime; public ulong loopIndex; public TickTask ( int taskID, uint delay, int count, double destTime, Action<int > taskCallback, Action<int > cancelCallback, double startTime ) { this .taskID = taskID; this .delay = delay; this .count = count; this .destTime = destTime; this .taskCallback = taskCallback; this .cancelCallback = cancelCallback; this .startTime = startTime; this .loopIndex = 0 ; } }
此处回调都会传入当前任务的 taskID,方便标识当前回调属于哪一个 task。
其中时间使用的是当前时间到 1970-1-1 0:0:0:0
有多少毫秒,方便计算。
Timer 当中应该有哪些数据结构保存信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private readonly DateTime startDateTime = newDateTime(1970 , 1 , 1 , 0 , 0 , 0 , 0 );private readonly ConcurrentDictionary<int , TickTask> taskDic;private readonly bool setHandle;private readonly ConcurrentQueue<TickTaskPack> packQueue;private const string taskIDLock = "TickTimer_TaskIdLock" ;private readonly Thread timerThread;
初始化 timer 做的事 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public TickTimer (int interval = 0 , bool setHandle= true ){ taskDic = new ConcurrentDictionary<int , TickTask>(); this .setHandle = setHandle; if (setHandle) { packQueue = new ConcurrentQueue<TickTaskPack>(); } if (interval != 0 ) { void StartTick () { try { while (true ) { UpdateTask(); Thread.Sleep(interval); } } catch (ThreadAbortException e) { WarnFunc?.Invoke($"Tick Thread Abort:{e} " ); } } timerThread = new Thread(new ThreadStart(StartTick)); timerThread.Start(); } }
此处两个参数的作用为:
interval
:设置每两次 tick 之间相隔多久,如果设置为 0,则自己控制 tick 的调用。
setHanle
:如果为 true,则使用者自己处理 callback 在什么线程当中调用,否则在线程池当中调用。
timer tick 做的事情 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public void UpdateTask (){ double nowTime = GetUTCMilliseconds(); foreach (var item in taskDic) { TickTask task = item.Value; if (nowTime < task.destTime) { continue ; } task.loopIndex++; if (task.count > 0 ) { task.count--; if (task.count == 0 ) { FinishTask(task.taskID); } else { task.destTime = task.startTime + task.delay * (task.loopIndex + 1 ); CallTaskCallback(task.taskID, task.taskCallback); } } else { task.destTime = task.startTime + task.delay * (task.loopIndex + 1 ); CallTaskCallback(task.taskID, task.taskCallback); } } }
这里每次回去计算一下当前的时间间隔,然后和 task 当中的时间间隔做一个比较。此处的 loopIndex
就是来减少浮点数累加误差的。因为存在多次 task 回调,因此 destTime
必然会有一个累加的行为,但是多次浮点数累加,其误差肯定会逐渐增大,因此就换成了 delay * loopIndex
。
FinishTask
和 CallTaskCallback
其中都会去调用 task 的 taskCallback
。唯一不同的是,在 FinishTask
调用完毕之后其会在 taskDic
当中删除自身的 task。
如何在指定线程处理回调 1 2 3 4 5 6 7 8 9 10 11 12 13 14 public void HandleTask (){ while (packQueue != null && packQueue.Count > 0 ) { if (packQueue.TryDequeue(out TickTaskPack pack)) { pack.callback(pack.taskID); } else { ErrorFunc?.Invoke("packQueue Dequeue Data Error." ); } } }
AsyncTimer 此计时器和 TickTimer
大体上相似,唯一不同的是 task 的定义和 tick 延迟方式,AsyncTimer
每个 task 都是会使用线程池,使用 Task.Delay
去实现定时调用。
Task 定义 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class AsyncTask { public int taskID; public uint delay; public int count; public Action<int > taskCallback; public Action<int > cancelCallback; public DateTime startTime; public ulong loopIndex; public int fixDelta; public CancellationTokenSource cts; public CancellationToken ct; public AsyncTask ( int taskID, uint delay, int count, Action<int > taskCallback, Action<int > cancelCallback ) { this .taskID = taskID; this .delay = delay; this .count = count; this .taskCallback = taskCallback; this .cancelCallback = cancelCallback; this .startTime = DateTime.UtcNow; this .loopIndex = 0 ; this .fixDelta = 0 ; cts = new CancellationTokenSource(); ct = cts.Token; } }
此处不一样的主要是加了一个取消令牌以及一个修正浮点数误差的 fixDelta
tick 逻辑 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 void RunTaskInPool (AsyncTask task ){ Task.Run(async () => { if (task.count > 0 ) { do { task.count--; task.loopIndex++; int delay = (int )(task.delay + task.fixDelta); if (delay > 0 ) { await Task.Delay(delay, task.ct); } TimeSpan ts = DateTime.UtcNow - task.startTime; task.fixDelta = (int )(task.delay * task.loopIndex - ts.TotalMilliseconds); CallTaskCallback(task); } while (task.count > 0 ); } else { while (true ) { task.loopIndex++; int delay = (int )(task.delay + task.fixDelta); if (delay > 0 ) { await Task.Delay(delay, task.ct); } TimeSpan ts = DateTime.UtcNow - task.startTime; task.fixDelta = (int )(task.delay * task.loopIndex - ts.TotalMilliseconds); CallTaskCallback(task); } } }); }
此时的 tick 逻辑就是针对某一个 task 的。每次会计算预期值和实际的 time 的差值来修正误差。
而此处如果要取消 task,那就是直接 task.cts.Cancel();
即可。
FrameTime 此计时器只能在单线程当中使用,并且其计数也是针对于帧的,而不是实际的时间。
Task 设计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class FrameTask { public int taskID; public uint delay; public int count; public ulong destFrame; public Action<int > taskCallback; public Action<int > cancelCallback; public FrameTask (int taskID, uint delay, int count, ulong destFrame, Action<int > taskCallback, Action<int > cancelCallback ) { this .taskID = taskID; this .delay = delay; this .count = count; this .destFrame = destFrame; this .taskCallback = taskCallback; this .cancelCallback = cancelCallback; } }
此处不需要记录 start
和 loopIndex
来调整误差。
tick 逻辑 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public void UpdateTask (){ currentFrame++; taskIDList.Clear(); foreach (var item in taskDic) { FrameTask task = item.Value; if (task.destFrame <= currentFrame) { task.taskCallback(task.taskID); task.destFrame += task.delay; task.count--; if (task.count == 0 ) { taskIDList.Add(task.taskID); } } } for (int i = 0 ; i < taskIDList.Count; i++) { if (taskDic.Remove(taskIDList[i])) { LogFunc?.Invoke($"Task taskID: {taskIDList[i]} run to completion." ); } else { ErrorFunc?.Invoke($"Remove taskID: {taskIDList[i]} task in taskDic failed." ); } } }
其中,taskIDList
是存储即将要删除的线程 id,因为 FrameTimer
适用于单线程,自然也没有使用线程安全的数据结构。因此需要在每次 tick 的最后做一个删除更新。
C++ 版本简略实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 #include <vector> #include <unordered_map> #include <functional> #include <chrono> namespace chrono = std::chrono;struct IUnRegister { virtual ~IUnRegister () = default ; virtual void UnRegsiter () = 0 ; }; class UnRegister : public IUnRegister{ private : std::function<void ()> unregister_action; public : explicit UnRegister (std::function<void ()> action) : unregister_action(std::move(action)) { } ~UnRegister () override = default ; void UnRegsiter () override { unregister_action (); } }; using callback_t = std::function<void (int )>;struct Task { int taskID; int64_t delay; uint32_t count; chrono::steady_clock::time_point startTime; chrono::steady_clock::time_point destTime; callback_t callback; }; class Timer { private : std::unordered_map<int , Task> tasks_; std::vector<Task> add_buffer_; std::vector<int > delete_buffer_; int cur_id{}; chrono::steady_clock::time_point start_time_; private : int generate_taskID () { while (true ) { if (cur_id == std::numeric_limits<int >::max ()) cur_id = 0 ; if (!tasks_.contains (cur_id)) return cur_id; cur_id++; } } void delete_task (int taskID) { delete_buffer_.push_back (taskID); } public : Timer () { start_time_ = chrono::steady_clock::now (); } ~Timer () = default ; IUnRegister* add_task (int64_t delay, callback_t callback, uint32_t count = 1 ) { chrono::steady_clock::time_point now = chrono::steady_clock::now (); Task task { .taskID = generate_taskID (), .delay = delay, .count = count, .destTime = now + chrono::milliseconds (delay), .callback = std::move (callback), }; add_buffer_.push_back (task); return new UnRegister ([&] { delete_task (task.taskID); }); } void tick () { chrono::steady_clock::time_point now = chrono::steady_clock::now (); for (auto it = tasks_.begin (); it != tasks_.end (); ++it) { Task& task = it->second; if (now < task.destTime) continue ; if (task.count > 0 ) { task.count--; if (task.count == 0 ) delete_task (task.taskID); else { task.destTime += chrono::milliseconds (task.delay); task.callback (task.taskID); } } else { task.destTime += chrono::milliseconds (task.delay); task.callback (task.taskID); } } for (auto it = add_buffer_.begin (); it != add_buffer_.end (); ++it) { int taskID = it->taskID; tasks_.emplace (taskID, std::move (*it)); } add_buffer_.clear (); for (int id : delete_buffer_) { tasks_.erase (id); } delete_buffer_.clear (); } };
也是一个适用于单线程的计时器,其中主要是注意两点:
将通过传入一个 taskID
的删除任务形式改为了 add_task
之后返回一个 handler
,通过这个 handler
去取消指定的任务。
注意读写分离,在遍历 task 的时候不应该有对其容器的删除和增加操作。
可以将 std::unordered_map
修改为小根堆,来获得更高的效率,不过此处需要使用 std::vector
去手写堆。