Linux笔记 高性能定时器
介绍两种高性能定时器:时间轮和时间堆。
时间轮
基于排序链表的定时器,使用一条链表存放所有定时器(时间复杂度O(n)),因此存在添加定时器效率偏低的问题。当存在需要大量添加定时器场景时,添加定时器可能会严重影响性能。
时间轮可以有效解决这个问题。下图是一个简单时间轮的示意图:
时机轮内,实线指针指向的一圈(1,2,...,N)代表轮子上的槽(slot)(槽位)。时间轮以恒定速度顺时针转到,每转动一步就指向下一个槽(虚线指针指向的槽),每次转动称为一个滴答(tick)。一个滴答的时间称为时间轮的槽间隔si(slot interval),实际上就是心博时间。
这个时间轮有N个槽位,因此转动一周代表时间Nsi。每个槽位指向一条定时器链表,每条链表上的定时器具有相同的特征:它们的定时时间相差Nsi的整数倍。时间轮利用这个关系将定时器散列到不同链表中:每个定时器有一个超时时间值(int),槽位N个,每个槽位代表si时间,一圈总时间N*si。
假如当前指针指向cs,用户要添加一个定时时间为ti的定时器,则该定时器将被插入槽ts(timer slot)对应的链表中:
ts = (cs + (ti/si)) % N
基于时间轮的定时器,使用哈希表,将定时器散列到不同链表上。这样每条链表上定时器数目明显小于原来排序链表上的定时器数目,插入操作的效率不受定时器数目的影响。
要提高定时精度,需要槽位间隔时间si值变小;要提高执行效率,则要让N值变大,增多链表数量,减少哈希冲突,避免定时器集中到少数几个链表上。
复杂时间轮可能有多个轮子,每个轮子有不同粒度(si)。相邻的两个轮子,精度高的转一圈,精度低的转动一个槽位,就像水表。
实现一个简单时间轮
定时器tw_timer和用户数据client_data
一个tw_timer对象代表一个定时器,封装了属于轮子上哪个槽位、定时器超时回调函数。tw_timer以链表形式组织,包含next、prev域名,头节点位于时间轮槽位上,一个槽位就是一个链表头结点。
由于时间轮转动一圈所代表时间N*si是固定值,所以可以根据用户需要设置的超时时间值,来算出时间轮需要转动的圈数和定时器应该位于时间轮哪个槽位上(即哪个链表上)。
#define BUFFER_SIZE 64
class tw_timer;
/* 绑定socket和定时器 */
struct client_data
{
sockaddr_in address;
int sockfd;
char buf[BUFFER_SIZE];
tw_timer* timer;
};
/* 定时器类 */
class tw_timer
{
public:
tw_timer(int rot, int ts)
: next(NULL), prev(NULL), rotation(rot), time_slot(ts)
{}
int rotation; /* 记录定时器在时间轮转多少圈后生效 */
int time_slot; /* 记录定时器属于时间轮上哪个槽位(对应的链表) */
void (*cb_func)(client_data*); /* 定时器回调 */
client_data* user_data; /* 客户数据 */
tw_timer* next; /* 指向下一个定时器 */
tw_timer* prev; /* 指向钱一个定时器 */
};
时间轮类time_wheel
time_wheel有两个重要方法:1)add_timer用于添加定时器;2)del_timer用于删除定时器。
/* 时间轮类 */
class time_wheel
{
public:
time_wheel() : cur_slot(0)
{
for (int i = 0; i < N; ++i) {
slots[i] = NULL; /* 初始化每个槽的头节点 */
}
}
~time_wheel()
{
/* 遍历每个槽, 并销毁其中的定时器 */
for (int i = 0; i < N; ++i) {
tw_timer* tmp = slots[i];
while (tmp) {
slots[i] = tmp->next;
delete tmp;
tmp = slots[i];
}
}
}
/* 根据定时值timeout 创建一个定时器, 并把它插入合适的槽中 */
tw_timer* add_timer(int timeout)
{
if (timeout < 0) {
return NULL;
}
int ticks = 0;
/* 根据带插入定时器的超时值, 计算它将在时间轮转多少个滴答后被触发, 并将该滴答数存储于变量ticks中.
* 如果待插入定时器的超时值 < 时间轮的槽间隔SI, 则将ticks向上折合为1, 否则就将ticks向下折合为timeout/SI */
if (timeout < SI) {
ticks = 1;
}
else
{
ticks = timeout / SI;
}
/* 计算待插入的定时器在时间轮转动多少圈后被触发 */
int rotation = ticks / N;
/* 计算待插入的定时器应该被插入哪个槽中 */
int ts = (cur_slot + (ticks % N)) % N;
/* 创建新的定时器, 它在时间轮转动rotation圈后被触发, 且位于第ts个槽上 */
tw_timer* timer = new tw_timer(rotation, ts);
/* 如果第ts个槽中尚无任何定时器, 则把新建的定时器插入其中, 并将该定时器设置为该槽的头节点 */
if (!slots[ts]) {
printf("add timer, rotation is %d, ts is %d, cur_slot is %d\n",
rotation, ts, cur_slot);
slots[ts] = timer;
}
/* 否则, 将定时器插入第ts个槽中 */
else {
timer->next = slots[ts];
slots[ts]->prev = timer;
slots[ts] = timer;
}
return timer;
}
/* 删除目标定时器timer */
void del_timer(tw_timer* timer)
{
if (!timer) {
return;
}
int ts = timer->time_slot;
/* slots[ts]是目标定时器所在槽的头节点. 如果目标定时器就是该头节点,
* 则需要重置第ts个槽的头节点 */
if (timer == slots[ts]) {
slots[ts] = slots[ts]->next;
if (slots[ts]) {
slots[ts]->prev = NULL;
}
delete timer;
}
else {
timer->prev->next = timer->next;
if (timer->next) {
timer->next->prev = timer->prev;
}
delete timer;
}
}
/* SI时间到后, 调用该函数, 时间轮向前滚动一个槽的间隔.
* 要求每隔SI时间, 定时调用一次该函数 */
void tick()
{
tw_timer* tmp = slots[cur_slot]; /* 取得时间轮上当前槽的头节点 */
printf("current slot is %d\n", cur_slot);
/* 遍历当前槽位对应链表 */
while (tmp) {
printf("tick the timer once\n");
/* 如果定时器的rotation值 > 0, 则它在这一轮不起作用 */
if (tmp->rotation > 0) {
tmp->rotation--;
tmp = tmp->next;
}
/* 否则, 说明定时器已经到期, 于是执行超时任务, 然后删除该定时器 */
else {
tmp->cb_func(tmp->user_data);
if (tmp == slots[cur_slot]) { // 超时定时器就是当前槽位头节点, 删除时要专门处理
printf("delete header in cur_slot\n");
slots[cur_slot] = tmp->next;
delete tmp;
if (slots[cur_slot]) {
slots[cur_slot]->prev = NULL;
}
tmp = slots[cur_slot];
}
else { // 超时定时器不是当前槽位头节点
tmp->prev->next = tmp->next;
if (tmp->next) {
tmp->next->prev = tmp->prev;
}
tw_timer* tmp2 = tmp->next;
delete tmp;
tmp = tmp2;
}
}
}
cur_slot = ++cur_slot % N; /* 更新时间轮当前槽位, 以反映时间轮的转动 */
}
private:
/* 时间轮上槽的数目 */
static const int N = 60;
/* 每1秒时间轮转动一次, 即槽间隔1s */
static const int SI = 1;
/* 时间轮的槽, 其中每个元素指向一个定时器链表, 链表无序 */
tw_timer* slots[N];
int cur_slot; /* 时间轮的当前槽 */
};
使用时间轮
借用muduo函数的EventLoop::runEvery,为心博函数time_wheel::tick()提供1秒周期的入口。
#include "time_wheel.h"
#include "muduo/net/EventLoop.h"
#include <stdio.h>
using namespace muduo;
using namespace muduo::net;
void print(client_data* data)
{
printf("%s\n", data->buf);
}
int main()
{
time_wheel wheel;
tw_timer* timer = wheel.add_timer(5); // 添加5秒后运行的定时器
timer->cb_func = print;
timer->user_data = new client_data;
strncpy(timer->user_data->buf, "hello", strlen("hello"));
EventLoop loop;
loop.runEvery(1.0, std::bind(&time_wheel::tick, &wheel)); // 每1秒钟运行1次心博函数tick()
loop.loop();
return 0;
}
时间堆
时间轮是以固定频率调用心搏函数tick,在其中依次查找到期(轮数+槽位 确定定时器超时时间)的定时器,然后执行定时器超时回调函数。
另一种设计定时器思路:将所有定时器中超时时间最小的一个定时器的超时值,作为心博间隔。这样,一旦心博函数tick被调用,超时时间最小的定时器必然到期,我们就可以在tick()中处理该定时器。然后,再次从剩余的定时器中找出超时时间最小的那个,并将这段最小时间设为下一次心博间隔。如此反复,实现较为精准的定时。
最小堆用来处理这种定时方案。
什么是最小堆?
最小堆(又称小根堆)是指具有这种特性的一颗完全二叉树:每个节点的值 <= 子节点的值。
如下图所示
比如,节点21,21 <= 24且21<=31;节点24 <= 65且24 <= 26;节点31 <= 32。
树基本操作:插入节点、删除节点。
插入操作
为了将元素X插入最小堆,可以在树下一个空闲位置创建一个空穴(没有元素的节点)。如果X可以放在空穴中而不被破坏堆特性,则插入完成;否则,违法了对特性,执行上虑操作,即交换空穴和其父节点元素,直到X可以被放入空穴。这个过程也称为向上调整堆。
例如,要往上图所示最小堆插入值14,可以按下图所示步骤:
删除操作
删除根节点元素,并且不破坏堆特性。删除时,需要先在根节点创建一个空穴。由于堆少了一个元素,不符合堆特性,因此可以把堆最后一个元素X移动到该堆的某个地方。如果X可以被放入空穴,则删除操作完成;否则,就指向下虑操作,即交换空穴和它的两个儿子节点中较小者。不断进行上述过程,直到X可以被放入空穴。这个过程也称为向下调整堆。
例如,要往上面图示最小堆执行删除(堆顶)操作,可以按下图所示步骤进行:
因为最小堆是一种完全二叉树,因此可以用数组来存储其元素。对于数组中任意位置i上的元素,其左儿子节点在位置2i + 1,右儿子节点在位置2i + 2,父节点在[(i-1) / 2] (i > 0)。与用链表表示堆相比,用数组表示堆节省空间,而且更容易实现堆的插入、删除等操作(数组支持随机访问)。
对时间堆,添加一个定时器时间复杂度:O(logn);删除一个定时器时间复杂度:O(1);执行一个定时器时间复杂度:O(1)。
用最小堆实现时间堆
用最小堆实现的定时器称为时间堆。
用户数据类client_data 和 定时器类heap_timer
#define BUFFER_SIZE 64
class heap_timer; /* 前向声明 */
/* 绑定socket和定时器 */
struct client_data
{
sockaddr_in address;
int sockfd;
char buf[BUFFER_SIZE];
heap_timer* timer;
};
/* 定时器类 */
class heap_timer
{
public:
explicit heap_timer(int delay)
{
expire = time(NULL) + delay;
}
time_t expire; /* 定时器生效的绝对时间 */
void (*cb_func)(client_data*); /* 定时器的回调函数 */
client_data* user_data; /* 用户数据 */
};
时间堆类time_heap
#include <iostream>
#include <netinet/in.h>
#include <time.h>
using std::exception;
/* 时间堆类 */
class time_heap
{
public:
/* 构造函数之一, 初始化一个大小为cap的空堆 */
time_heap(int cap) throw(std::exception) : capacity(cap), cur_size(0)
{
array = new heap_timer*[capacity]; /* 创建堆数组 */
if (!array) {
throw std::exception();
}
for (int i = 0; i < capacity; ++i) {
array[i] = NULL;
}
}
/* 构造函数之二, 用已有数组来初始化堆 */
time_heap(heap_timer** init_array, int size, int cap) throw(std::exception)
: cur_size(size), capacity(cap)
{
if (capacity < size) {
throw std::exception();
}
array = new heap_timer*[capacity]; /* 创建堆数组 */
if (!array) {
throw std::exception();
}
for (int i = 0; i < capacity; ++i) {
array[i] = NULL;
}
if (size != 0) {
/* 初始化堆数组 */
for (int i = 0; i < size; ++i) {
array[i] = init_array[i];
}
for (int i = (cur_size - 1) / 2; i >= 0 ; ++i) {
/* 对数组中第[(cur_size - 1)/2]~0个元素执行下虑操作 */
percolate_down(i);
}
}
}
/* 销毁时间堆 */
~time_heap()
{
for (int i = 0; i < cur_size; ++i) {
delete array[i];
}
delete[] array;
}
/* 添加目标定时器timer */
void add_timer(heap_timer* timer) throw(std::exception)
{
if (!timer) {
return;
}
if (cur_size >= capacity) { /* 如果当前数组容量不够, 则将其扩大一倍 */
resize();
}
/* 新插入一个元素, 当前堆大小+1, hole是新建空穴的位置 */
int hole = cur_size++;
int parent = 0;
/* 对从空穴到根节点的路径上的所有节点执行上虑操作 */
for ( ; hole > 0; hole = parent) {
parent = (hole - 1) / 2;
if (array[parent]->expire <= timer->expire) {
break;
}
array[hole] = array[parent];
}
array[hole] = timer;
}
/* 删除目标定时器timer */
void del_timer(heap_timer* timer)
{
if (!timer) {
return;
}
/* 仅仅将目标定时器的回调函数设置为空, 即所谓的延迟销毁.
* 将节省真正删除该定时器造成的开销, 但这样容易使堆数组膨胀 */
// FIXME: 删除定时器, 为何不重新调整堆?
timer->cb_func = NULL;
}
/* 获得堆顶部的定时器 */
heap_timer* top() const
{
if (empty()) {
return NULL;
}
return array[0];
}
/* 删除堆顶部定时器 */
void pop_timer()
{
if (empty()) {
return;
}
if (array[0]) {
delete array[0];
/* 将原来堆顶元素替换为堆数组中最后一个元素 */
array[0] = array[--cur_size];
percolate_down(0); /* 对新的堆顶元素执行下虑操作 */
}
}
/* 心搏函数 */
void tick()
{
heap_timer* tmp = array[0];
time_t cur = time(NULL); /* 循环处理堆中到期的定时器 */
while (!empty()) {
if (!tmp) {
break;
}
/* 如果堆顶定时器没到期, 则退出循环 */
if (tmp->expire > cur) {
break;
}
/* 否则执行堆顶定时器中的任务 */
if (array[0]->cb_func) {
array[0]->cb_func(array[0]->user_data);
}
/* 将堆顶元素删除, 同时生成新的堆顶定时器(array[0]) */
pop_timer();
tmp = array[0];
}
}
bool empty() const
{ return cur_size == 0; }
private:
/* 最小堆的下虑操作, 它确保堆数组中以第hole个节点作为根的子树拥有最小堆性质 */
void percolate_down(int hole)
{
heap_timer* temp = array[hole];
int child = 0;
for ( ; (hole * 2 + 1) <= (cur_size - 1); hole = child) {
child = hole * 2 + 1;
/* 找到hole子节点中最小节点child */
if ((child < (cur_size - 1)) && (array[child + 1]->expire < array[child]->expire)) {
++child;
}
/* 交换hole, child位置对应元素, 以满足小堆特性 */
if (array[child]->expire < temp->expire) {
array[hole] = array[child];
}
/* 否则, 直接跳出继续找子节点的循环. 因为子树中不会存在不满足对特性的节点了 */
else {
break;
}
}
/* 找到hole最终位置, 并赋值 */
array[hole] = temp;
}
/* 将堆数组容量扩大1倍 */
void resize() throw(std::exception)
{
heap_timer** temp = new heap_timer*[2 * capacity];
for (int i = 0; i < 2 * capacity; ++i) {
temp[i] = NULL;
}
if (!temp) {
throw std::exception();
}
capacity = 2 * capacity;
for (int i = 0; i < cur_size; ++i) {
temp[i] = array[i];
}
delete[] array;
array = temp;
}
private:
heap_timer** array; /* 堆数组 */
int capacity; /* 堆数组容量 */
int cur_size; /* 堆数组当前包含元素的个数 */
};
时间堆类的使用
提供time_heap::tick()的入口时间,不必是固定频率,只需要 <= 当前堆顶定时器的超时时间即可。
#include "time_heap.h"
#include <thread>
using namespace std;
void print(client_data* data)
{
printf("%s\n", data->buf);
}
int main()
{
time_heap th(20);
for (int i = 8; i >= 0; --i) {
int delay = i + 1;
heap_timer* timer = new heap_timer(delay);
timer->user_data = new client_data;
snprintf(timer->user_data->buf, sizeof(timer->user_data->buf), "runs at %d sec", delay);
timer->cb_func = print;
th.add_timer(timer);
}
while (1) {
th.tick();
this_thread::sleep_for(chrono::milliseconds(100));
}
return 0;
}
参考
《Linux高性能服务器编程》