前言

在游戏开发过程中,使用一些计时器来管理定时任务是非常常见的,计时器主要考虑的我认为有以下几个点:

  • 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);

// 存储 Task
private readonly ConcurrentDictionary<int, TickTask> taskDic;

// 是否设置了其他线程处理回调
private readonly bool setHandle;

// callback 队列,pack 当中只包含 taskID 和对应的 callback
private readonly ConcurrentQueue<TickTaskPack> packQueue;

// 在生成线程 ID 的时候用来作为互斥锁
private const string taskIDLock = "TickTimer_TaskIdLock";

// timer tick 线程
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

FinishTaskCallTaskCallback 其中都会去调用 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;
}
}

此处不需要记录 startloopIndex 来调整误差。

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; // ms
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 去手写堆。