序言
最近在编写 C++ 的服务器代码时,我遇到了一个需求,服务器很可能会遇到那些长期不活跃的连接,这些连接占用了一定的资源但是并没有进行有效的通信。为了优化资源使用,我决定实现一个定时器,以便定期检查连接的活跃状态并适时关闭那些不再活跃的连接。
在这里我将介绍以下三种方式:
1. 循环遍历任务定时器
1.1 原理
这个的原理就相当简单了,我们利用一块连续的空间来存储我们需要执行的任务以及指定执行的时刻,每隔一段时间来判断哪些任务就绪了,我们就执行该任务。具体流程图如下:
在这里有几个点需要注意了,需求不同,设计也不同:
- 执行后是否移除:当我们执行我们的定时任务后,是否需要将他从我们的任务队列移除呢,这个完全就取决于自己的需求了,就比如我的需求是关闭某个连接,那么执行该任务后连接也没了,自然是要移除该任务的
- 休眠时间:这个就要看你的要求是否严格了,如果需要较小的时间差异,那么就将休眠时间间隔短一些,但是 CPU 的负担也大。
1.2 实现
首先我们需要一块空间来存储我们的任务并且方便遍历,那么就首选 vector
了,我们还需要设计在容器中元素的类型,该类型的任务是表示一个定时任务:
using TaskFunc = std::function<void()>;
using Timer = std::chrono::steady_clock::time_point;
struct TimerTask
{
Timer _execute_time; // 定时器
TaskFunc _call_back; // 回调函数
};
之后我们定时器无非就是添加任务,启动函数:
// 添加定时任务
void AddTask(TaskFunc callback, int timeout)
{
auto execute_time = std::chrono::steady_clock::now() + std::chrono::seconds(timeout); // 计算执行时间
_tasks.push_back({ execute_time, callback });
}
// 运行定时器
void Loop()
{
while (true)
{
auto now = std::chrono::steady_clock::now();
for (auto it = _tasks.begin(); it != _tasks.end(); ++it)
{
if (now >= it->_execute_time)
{
it->_call_back();
it = _tasks.erase(it);
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
实现起来还是比较简单的,但是在需要注意,在执行的时候一般是使用一个专门的线程来进行定时器的运行,这可能会涉及到线程安全的问题,所以我们需要进一步完善的话,需要进行加锁操作。
1.3 优缺点
优点很明显:
- 简单易用:循环遍历定时器通常简单直观,易于实现和理解。
是的就没了,我也没想出其他的优点,欢迎大家在评论区提出。
缺点的话也很明显:
- 资源消耗:如果循环遍历频率过高,可能会导致 CPU 资源浪费,尤其是在高负载情况下。
- 效率问题:当定时器数量很大时,每次遍历所有定时器会变得低效,因为即使大部分定时器尚未超时,也需要被检查。
作为一个启蒙的任务定时器还不错的,但是实际使用的话还是算了吧…
2. 小堆任务定时器
1.1 原理
首先我们先回忆一下小堆这种数据结构:
在小堆中,树的每个节点的值都小于或等于其子节点的值。这意味着堆的最小元素总是位于树的根部,即堆顶。 当我们取出该堆的最小元素时,步骤是:
- 交换堆顶和末尾元素:将堆顶元素(最小元素)与堆的最后一个元素交换位置。此时,堆顶元素不再是最小元素
- 调整堆:从堆顶开始,将交换后的堆顶元素与它的子节点比较,如果它大于其子节点中的任何一个,则与较小的子节点交换位置,并继续这一过程直到堆顶元素小于其所有子节点或到达叶节点。
- 移除最小元素:由于最小元素已经与堆的最后一个元素交换,现在可以直接从数组中移除它
这和我们说的任务定时器啥关系呢?现在,我们堆的每一个元素就不再是一个数了,而是我们的 TimerTask
,我们将所有任务构造为一个小堆,之后任务检测步骤如下:
- 检查堆顶元素:检查堆顶的任务(时间最小的任务)执行时间是否到了
-
- 条件不满足: 直接休眠指定时间再检查,时间最小的都不行,其他的更不行了
- 条件满足:取下堆顶元素执行任务,调整堆的结构使其为小堆
- 重复 1 - 2 的步骤
这样的话我们就不需要每次都遍历整个数组,具体的优缺后面说。
1.2 实现
在这里我们存储人物的容器就换成 priority_queue
了,因为需要实现一个小堆。其次任务结构体,我们需要在原来的基础上加上一个比较函数,因为涉及到自定义结构体排序比较的操作:
struct TimerTask
{
TimerTask(Timer time, TaskFunc callback)
: _execute_time(time), _call_back(callback)
{}
// 比较函数但是底层我们写为 > , 因为我们需要得到一个小堆
bool operator<(const TimerTask& other) const
{
return _execute_time > other._execute_time;
}
Timer _execute_time; // 定时器
TaskFunc _call_back; // 回调函数
};
之后稍有一些不一样的地方就是启动函数:
void Loop()
{
while (true)
{
auto now = std::chrono::steady_clock::now();
while (! _tasks.empty() && _tasks.top()._execute_time <= now)
{
auto task = _tasks.top();
_tasks.pop();
task._call_back();
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
实现起来不难,主要是思想很重要。所以说熟悉数据结构是很重要一件事!
1.3 优缺点
网上都说小堆的性能是好于我们第一种遍历方案的,但是我们要思考高效在哪里呢?我们算一笔账,在 n 个任务中 m 个任务就绪了, 前者执行定时任务的时间复杂度是 O(n)
,因为是遍历嘛;后者的话涉及到堆的调整操作 O(logn)
,一共取 m,那就是 O(mlogn)
。
当我们的数据量也就是 n 较小的情况下,请问谁更好一点呢?我想,是前者吧。(化学上有一句话是,抛开计量谈毒性,就是耍流氓!咋们计算机也差不多,抛开数据量谈高效,也是耍流氓!)
优点:
- 高效的任务管理:当数据量较大时,性能更加的不错。
- 底部开销:不和 vector 一样,不需要频繁的扩容操作
缺点:
- 不适合小任务集:当任务数量很少时,使用小堆可能反而不如简单的线性结构(如数组或链表)高效,因为堆的维护开销可能超过直接遍历的成本。
所以我们还是要带着辩证的角度看待问题!
3. 时间轮定时器
1.1 原理
大家对秒钟肯定都比较的熟悉吧,秒钟的单位是 1s,每隔一秒就走一步。现在,我们也创建一个类似于秒钟的功能的数据结构:
一共有八个格数,如果我们依旧使用秒为单位的话,那么最多可以表示 8s。这和我们今天的任务定时器的关系是什么呢?我们将该计时任务存储在现在 (秒针 + 任务时间)% 最大刻度
的位置上,并且秒针指向哪个位置,哪个位置的任务就执行!
没听懂,没关系,我第一次也没听懂,那我们模拟一遍,相信会好很多:
- 不越界的情况:现在秒针指向位置 1,我想要添加一个时间为 2s 的任务,那么该该任务存储在
(1 + 2)% 8 = 3
的位置。时间过 1s ,秒针指向 2,执行 2 中的全部任务(现在为空,就不执行)!时间再过 1s,秒针指向 3,执行 3 中的全部任务。OK!我们的目标达成了! - 越界的情况:现在秒针指向位置 3,我想要添加一个时间为 7s 的任务,那么该该任务存储在
(3 + 7)% 8 = 2
的位置。情况就和第一种一样了,所以%运算
保证了我们的任务存储在恰当的运算!
这就是时间轮!现在基本怎么运行大家心里或多或少了解了一点,我提出一些疑问,也可能会是你的疑问:
- 如果我有多个定时任务需要存储在 2 的位置,怎么办?
- 你的刻度最大 8s,我想要一个 10s 的任务,怎么办?
问题一:
在我们的哈希表中如果产生了哈希冲突,我们采用的一个方式为 链地址法。(新增的话,使用链表更为高效,不用进行扩容操作)我们也可以呀:
问题二:
所以我们在创建时间轮时我们需要确定一个合适的时间范围大小。但是如果我想要一个 1000000s 大小的时间轮不可能申请这么大个空间吧?当然不是,大家可以去了解一下多级时间轮。
1.2 实现
首先我们需要考虑什么数据结构是环状的呀,好像并没有接触过。其实我们使用 vector 就可以啦!啊?后者不是线性的一段空间吗,怎么会是环状的呢?没关系,我们再遍历数组的时候使用 %运算
不就好了嘛。我演示一下:
int main()
{
std::vector<int> array = { 1,2,3,4,5 };
int index = 0;
while (true)
{
std::cout << array[index] << " ";
index = (index + 1) % array.size(); // 这里是关键,每当遍历完时,重头再来
std::this_thread::sleep_for(std::chrono::seconds(1));
}
return 0;
}
输出结果:
1 2 3 4 5 1 2 3 4 5 1 …
所以说经过 %
他的逻辑结构已经成环了!
之后我们需要了解时间轮的成员变量应该需要哪些:
using TaskFunc = std::function<void()>;
using Wheel = std::vector<std::list<TaskFunc>>;
int _tick; // 秒针
int _capacity; // 最大容量(刻度)
Wheel _wheel; // 时间轮
有了这些之后,我们需要指定构建函数,构建函数中需要指定时间轮的最大刻度:
TimeWheel(int capacity)
: _tick(0), _capacity(capacity), _wheel(_capacity)
{}
最后也就是我们的新增函数以及启动函数:
void AddTask(TaskFunc callback, int timeout)
{
int index = (_tick + timeout) % _capacity;
_wheel[index].push_back(callback);
}
void Loop()
{
while (true)
{
std::this_thread::sleep_for(std::chrono::seconds(1));
_tick = (_tick + 1) % _capacity; // 秒针移动
// 执行该位置的函数
for (auto& func : _wheel[_tick])
{
func();
}
// 清除
_wheel[_tick].clear();
}
}
在这里我们尤其需要注意不要越界了!
1.3 优缺点
优点:
- 高效性:时间轮能够以
O(1)
的复杂度处理定时任务的插入和删除,适合大量定时任务的场景。 - 节省空间:通过使用固定大小的轮子和槽,时间轮在内存使用上较为高效,避免了过多的动态内存分配。
缺点:
- 时间精度限制:时间轮的精度由槽的大小决定,若需要高精度的定时任务,可能不适合使用时间轮。
到现在为止,时间轮是我见过最精妙的一种方式,编程之美呀!
4. 总结
今天的内容简单地向大家介绍了三种任务定时器方式,遍历方式最为简单,效率也比较低,小堆方式处理大数据集有优势,数据量少了消耗反而还多了,最后一种时间轮是比较全面的一种,但是要确定合适的时间范围。希望大家有所收获!