一、整体设计思路
我们采用单级时间轮 + 轮次计数(Rounds)的方案:
时间轮是一个固定大小的数组(比如
SLOT_NUM = 64),每个数组元素是一个链表头,存储该时刻到期的所有任务。每个任务节点(对应一个连接)存储两个关键值:
slot:它挂在哪个槽位。
rounds:还需要等指针转满多少圈,才轮到它执行。
一个独立的线程每隔
TICK_MS毫秒拨动指针(current_tick++),并处理当前指针指向的槽位链表。
为什么加 rounds?
因为单级数组长度有限(如64),如果超时时间超过64个Tick,就用rounds记录轮数。指针每经过该槽位一次,rounds减1,减到0时才真正踢出。这样既节约内存,又避免计算复杂绝对时间。
二、完整C代码实现(可直接运行演示)
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
#include <errno.h>
// ===================== 配置参数 =====================
#define SLOT_NUM 64 // 槽位数量(通常设为2的幂,便于位运算取模)
#define TICK_MS 100 // 指针每Tick前进100ms
#define DEFAULT_TIMEOUT 5000 // 默认超时5秒(单位:毫秒)
// ===================== 任务节点(对应一个连接) =====================
typedef struct timer_node {
int fd; // 连接句柄(演示用,实际可为void*)
int rounds; // 剩余轮次(减到0即超时)
struct timer_node *next; // 链表指针
void (*on_timeout)(int fd); // 超时回调函数(踢出连接)
} timer_node_t;
// ===================== 时间轮结构 =====================
typedef struct {
int slot_num; // 槽位总数(=64)
int tick_ms; // Tick间隔(毫秒)
unsigned long current_tick; // 当前已经过去的Tick总数(单调递增)
timer_node_t **slots; // 槽位数组,每个元素是链表头指针
pthread_mutex_t lock; // 互斥锁(保护链表操作)
int running; // 运行状态标志
pthread_t thread_id; // 后台Tick线程ID
} time_wheel_t;
// ===================== 全局时间轮实例(方便演示) =====================
static time_wheel_t g_wheel;
// ===================== 回调函数(实际生产环境就是close(fd)) =====================
void timeout_callback(int fd) {
printf("[超时踢出] 连接 fd=%d 因超时被断开 (模拟close)\n", fd);
// 实际代码: close(fd); 并从连接管理Map中移除
}
// ===================== 创建新节点 =====================
timer_node_t* create_node(int fd, int timeout_ms, unsigned long current_tick) {
timer_node_t *node = (timer_node_t*)malloc(sizeof(timer_node_t));
if (!node) return NULL;
// 计算超时需要的总Tick数(向上取整)
int delay_ticks = (timeout_ms + g_wheel.tick_ms - 1) / g_wheel.tick_ms;
// 计算绝对到期Tick
unsigned long expire_tick = current_tick + delay_ticks;
// 计算挂载的槽位索引和轮次
node->fd = fd;
node->rounds = expire_tick / g_wheel.slot_num; // 轮次
node->next = NULL;
node->on_timeout = timeout_callback;
// 插入到对应槽位的链表头部(头插法,O(1))
int slot_index = expire_tick % g_wheel.slot_num;
pthread_mutex_lock(&g_wheel.lock);
node->next = g_wheel.slots[slot_index];
g_wheel.slots[slot_index] = node;
pthread_mutex_unlock(&g_wheel.lock);
printf("[添加任务] fd=%d, 延迟=%dms, 挂载槽位=%d, 轮次=%d\n",
fd, timeout_ms, slot_index, node->rounds);
return node;
}
// ===================== 删除任务(用于心跳续期) =====================
int delete_node(int fd) {
int found = 0;
pthread_mutex_lock(&g_wheel.lock);
for (int i = 0; i < g_wheel.slot_num; i++) {
timer_node_t *curr = g_wheel.slots[i];
timer_node_t *prev = NULL;
while (curr) {
if (curr->fd == fd) {
// 从链表中摘除
if (prev) {
prev->next = curr->next;
} else {
g_wheel.slots[i] = curr->next;
}
free(curr);
found = 1;
printf("[删除任务] 成功移除 fd=%d (心跳续期)\n", fd);
break;
}
prev = curr;
curr = curr->next;
}
if (found) break;
}
pthread_mutex_unlock(&g_wheel.lock);
return found;
}
// ===================== 心跳续期(实际就是 删除 + 重新添加) =====================
void renew_timeout(int fd, int timeout_ms) {
delete_node(fd); // 先删旧的
create_node(fd, timeout_ms, g_wheel.current_tick); // 再添加新的
}
// ===================== 后台Tick线程(核心处理逻辑) =====================
void* tick_thread_func(void *arg) {
time_wheel_t *wheel = (time_wheel_t*)arg;
while (wheel->running) {
// 1. 休眠一个Tick间隔
usleep(wheel->tick_ms * 1000);
// 2. 推进指针
pthread_mutex_lock(&wheel->lock);
wheel->current_tick++;
int slot_idx = wheel->current_tick % wheel->slot_num;
// 3. 处理该槽位链表中的所有节点
timer_node_t *curr = wheel->slots[slot_idx];
timer_node_t *prev = NULL;
while (curr) {
timer_node_t *next_node = curr->next; // 先保存,防止删除后丢失
if (curr->rounds > 0) {
// 轮次未归零:仅减1,保留节点
curr->rounds--;
prev = curr;
} else {
// 轮次归零:触发超时踢出,从链表摘除并释放
if (prev) {
prev->next = next_node;
} else {
wheel->slots[slot_idx] = next_node;
}
// 执行回调前解锁(防止回调内操作时间轮导致死锁)
pthread_mutex_unlock(&wheel->lock);
curr->on_timeout(curr->fd);
free(curr);
pthread_mutex_lock(&wheel->lock);
// 解锁期间链表可能被修改,需要重置当前遍历状态
// 这里简单处理:重新从链表头开始遍历(牺牲少量效率,换取稳定性)
curr = wheel->slots[slot_idx];
prev = NULL;
continue; // 重新开始本轮循环检查
}
curr = next_node;
}
pthread_mutex_unlock(&wheel->lock);
}
return NULL;
}
// ===================== 初始化时间轮 =====================
void time_wheel_init(time_wheel_t *wheel, int slot_num, int tick_ms) {
wheel->slot_num = slot_num;
wheel->tick_ms = tick_ms;
wheel->current_tick = 0;
wheel->running = 1;
pthread_mutex_init(&wheel->lock, NULL);
// 分配槽位数组并置空
wheel->slots = (timer_node_t**)calloc(slot_num, sizeof(timer_node_t*));
// 创建后台线程
pthread_create(&wheel->thread_id, NULL, tick_thread_func, wheel);
}
// ===================== 销毁时间轮 =====================
void time_wheel_destroy(time_wheel_t *wheel) {
wheel->running = 0;
pthread_join(wheel->thread_id, NULL);
// 释放所有剩余节点
for (int i = 0; i < wheel->slot_num; i++) {
timer_node_t *curr = wheel->slots[i];
while (curr) {
timer_node_t *tmp = curr;
curr = curr->next;
free(tmp);
}
}
free(wheel->slots);
pthread_mutex_destroy(&wheel->lock);
}
// ===================== 演示主函数 =====================
int main() {
printf("=== 时间轮超时踢出演示 ===\n");
printf("槽位数: %d, Tick间隔: %dms, 默认超时: %dms\n\n", SLOT_NUM, TICK_MS, DEFAULT_TIMEOUT);
// 初始化时间轮
time_wheel_init(&g_wheel, SLOT_NUM, TICK_MS);
// 模拟:添加3个客户端连接(fd=10, 20, 30)
create_node(10, DEFAULT_TIMEOUT, g_wheel.current_tick);
create_node(20, 3000, g_wheel.current_tick); // 3秒超时
create_node(30, 8000, g_wheel.current_tick); // 8秒超时
sleep(2); // 等待2秒
printf("\n>>> 2秒后,fd=20 发送心跳,进行续期 (重置为5秒) <<<\n");
renew_timeout(20, DEFAULT_TIMEOUT);
// 让程序运行10秒,观察踢出日志
sleep(10);
printf("\n=== 演示结束,销毁时间轮 ===\n");
time_wheel_destroy(&g_wheel);
return 0;
}三、核心代码逐块精讲(配合运行结果理解)
1. 数据结构定义(关键点:轮次补偿)
c
typedef struct timer_node {
int fd;
int rounds; // 重点:不是绝对到期时间,而是剩余圈数
struct timer_node *next;
void (*on_timeout)(int fd);
} timer_node_t;为什么不用绝对时间戳? 因为时间轮是循环数组,如果直接存绝对到期
current_tick,当数字大于SLOT_NUM时必须取模,但取模后无法区分是“这一圈”到期还是“下一圈”到期。所以用rounds来区分:只有rounds == 0时,当前槽位才真正触发超时。
2. 添加任务:create_node(O(1) 插入)
c
int delay_ticks = (timeout_ms + tick_ms - 1) / tick_ms; // 向上取整
unsigned long expire_tick = current_tick + delay_ticks;
node->rounds = expire_tick / slot_num; // 轮次
int slot_index = expire_tick % slot_num; // 槽位核心思想:将“时间维度”拆解为“槽位索引” + “圈数”。
例如:slot_num=64,current_tick=0, 超时5000ms (50个Tick)。expire_tick = 50,rounds=50/64=0,slot=50。指针转到第50槽时直接触发。
若超时8000ms (80个Tick),expire_tick=80,rounds=80/64=1,slot=16。指针第一次经过16槽时rounds减为0,第二次经过时才会踢出。头插法:插入链表头部,时间复杂度O(1),无需遍历。
3. 删除任务:delete_node(遍历单个槽位)
c
for (int i = 0; i < slot_num; i++) {
// 遍历第i个槽位的链表
}复杂度分析:最坏遍历所有槽位,但因为通常按
fd散列到不同槽位,实际平均只扫描少量槽位。若需极致优化,可引入fd -> slot的映射表(本例未加,保持代码清晰)。锁保护:添加和删除操作都在
pthread_mutex_t保护下进行,防止Tick线程同时操作链表导致指针悬空。
4. 心跳续期:renew_timeout(先删后增)
c
delete_node(fd); // 移除旧节点
create_node(fd, timeout_ms, current_tick); // 创建新节点,重新计算轮次和槽位这是时间轮在长连接保活中最常用的操作。相比
DelayQueue的remove(O(n)堆遍历),这里仅仅是链表摘除 + 头插,开销极低。
5. 核心Tick处理逻辑(重中之重)
c
while (running) {
usleep(tick_ms * 1000);
lock();
current_tick++;
int slot_idx = current_tick % slot_num;
curr = slots[slot_idx];
while (curr) {
if (curr->rounds > 0) {
curr->rounds--; // 仅减轮次,不动节点
prev = curr;
} else {
// 轮次为0:摘除并执行回调
unlink node from list;
unlock(); // 必须解锁,避免回调中再次操作时间轮导致死锁
curr->on_timeout(fd);
free(curr);
lock();
// 重置遍历指针(因解锁期间链表可能变化)
curr = slots[slot_idx];
prev = NULL;
continue;
}
curr = next_node;
}
unlock();
}三个极易踩坑的点(我专门做了处理):
执行回调前必须解锁:因为回调函数(如
close(fd))可能触发连接管理器销毁资源,若其中又调用了delete_node,持有锁会造成死锁。解锁后链表可能被修改:解锁期间,业务线程可能调用
renew_timeout插入新节点或删除其他节点。所以执行完回调后,我直接continue从头重新遍历当前槽位,保证数据一致性(这是“用少量性能损失换绝对安全”的经典策略)。轮次递减的时机:指针每次经过该槽位,所有
rounds>0的节点rounds--。这意味着一个超时8秒的任务,指针需绕完整一圈(64 * 100ms = 6.4秒)回来时才减为0,误差在可接受范围内。
6. 主函数演示流程
添加 fd=10(5秒)、fd=20(3秒)、fd=30(8秒)。
2秒后模拟 fd=20 收到心跳,调用
renew_timeout重置为5秒。运行10秒后观察输出:
fd=20 原本3秒超时,但因第2秒续期,实际约在第7秒超时(2+5)。
fd=10 约在第5秒超时。
fd=30 约在第8秒超时。
四、相比其他方案的实测优势(结合本代码)
在超时踢出场景下,我们允许几百毫秒的误差,所以牺牲精度换取吞吐量,是完美的权衡。
五、生产环境优化建议(进阶)
槽位数设为2的幂(如64、128、512),这样取模运算
%可优化为位运算& (SLOT_NUM - 1),进一步提升性能。无锁化尝试:若对性能极致追求,可用
CAS无锁队列替代pthread_mutex,但实现复杂度骤增,本例为易懂保留互斥锁。多级时间轮:如果超时时间跨度极大(从1秒到1小时),建议改用分层时间轮(如Netty的HashedWheelTimer),但单级轮配合
rounds在本例场景(5~30秒)完全够用。
六、优势
1. 时间复杂度极低(O(1) vs O(log n) / O(n))
这是最核心的优势。
传统方案(如DelayQueue):添加或取消一个超时任务,堆结构调整的时间复杂度为 O(log n)。当百万级连接频繁收发心跳时,每次续期都涉及删除旧任务并新增,CPU开销巨大。
时间轮方案:新增、取消、续期超时任务,本质上只是计算哈希槽位并操作双向链表节点,时间复杂度为 O(1)。在高吞吐场景下,能显著降低CPU损耗。
2. 批量处理与高效的“滴答”机制
时间轮通过固定的时间间隔(Tick)推动指针,在同一刻度上的所有超时连接会被一次性批量处理。
传统定时器(如
ScheduledExecutorService)每个超时任务都是一个独立的调度实体,海量连接会产生大量Timer对象,内存占用高且GC压力大。时间轮则将同一时刻超时的任务放在同一个槽位(Set/List)中,只需要一个线程轮询指针,批量取出并执行踢出逻辑,极大减少了线程上下文切换和对象创建开销。
3. 任务取消/续期成本几乎为零
在长连接保活中,每次收到心跳包都需要“续期”(重置超时时间)。
使用
DelayQueue,续期需要先remove()再offer(),remove()操作在堆中是 O(n) 的(需要遍历查找),在大连接下会成为性能陷阱。使用时间轮,续期只是取出旧节点并重新计算插入新槽位的操作,且由于时间轮通常不维护全局排序,无需移动大量元素,成本极低。
4. 无全局锁竞争(或锁粒度极细)
优秀的Netty时间轮实现通常采用多级时间轮或分桶机制。相比于全局阻塞的优先队列,时间轮的读写操作(Worker线程与业务线程)通过优化(如使用ConcurrentLinkedQueue暂存新任务),将锁竞争降到最低,保证了海量连接场景下的系统稳定性。