18、协程超时处理:在协程中实现 sleep 与定时器
协程里最常用的功能是什么?我个人觉得,除了切换和通信,就是超时处理了。你想想看,一个网络请求发出去,总不能一直等下去吧?现实世界是有延迟的,网络会丢包,服务会超载。所以,给协程加个定时器,让它能在指定时间后自动醒来或者超时退出,这是异步编程的必修课。
18.1 为什么协程需要自己的 sleep?
很多新手会问:直接用系统提供的 sleep() 不行吗?嗯,这里要注意——系统 sleep() 会阻塞整个线程。在协程模型里,一个线程上跑着成百上千个协程,你让其中一个调了 sleep(3),那这 3 秒内所有协程都卡死了。这显然不行。
协程的 sleep 本质上是「我暂时不干活了,到点了叫我」。它会把当前协程挂起,让出 CPU 给其他协程。时间到了,调度器再把它重新放回就绪队列。这个机制,说白了就是定时器 + 协程切换的组合拳。
18.2 定时器数据结构设计
我在项目中实现过一个轻量级定时器,用的是「最小堆」结构。为什么选最小堆?因为我们需要频繁地找到「最近要超时」的那个定时器。最小堆的根节点就是最小值,取出来是 O(1) 的,插入和删除是 O(log n) 的。对于几千个协程的场景,完全够用。
每个定时器节点大概长这样:
typedef struct timer_node {
uint64_t expire_ms; // 到期时间(毫秒时间戳)
uint64_t interval_ms; // 间隔(如果是周期性定时器)
int is_periodic; // 是否周期性触发
void (*callback)(void*); // 回调函数
void* arg; // 回调参数
int timer_id; // 定时器ID,用于取消
struct timer_node* next; // 堆内索引(实际用数组实现)
} timer_node_t;
实际存储时,我用一个动态数组来维护堆结构。每个节点在数组中的位置就隐含了父子关系。这样内存连续,缓存友好。
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 添加定时器 | O(log n) | 插入到堆尾,然后上浮 |
| 取消定时器 | O(log n) | 标记删除,或者交换后下沉 |
| 获取最近到期时间 | O(1) | 直接取堆顶 |
| 触发到期定时器 | O(k log n) | k 是到期的定时器数量 |
18.3 协程 sleep 的实现
有了定时器基础设施,实现协程的 sleep 就很简单了。核心流程就三步:
- 记录当前协程的上下文,保存到调度器里
- 创建一个定时器,到期时间 = 当前时间 + 睡眠时长
- 定时器的回调函数里,把协程重新放回就绪队列
代码大概长这样:
int coroutine_sleep(uint64_t ms) {
coroutine_t* co = get_current_coroutine();
if (!co) return -1;
// 1. 保存当前协程状态
co->state = COROUTINE_SLEEPING;
// 2. 创建定时器
timer_node_t* timer = timer_create(
get_current_ms() + ms, // 到期时间
0, // 非周期性
wakeup_coroutine, // 回调:唤醒协程
co // 参数:协程指针
);
// 3. 把定时器加入调度器的定时器堆
scheduler_add_timer(sched, timer);
// 4. 让出CPU,切换到调度器
coroutine_yield();
return 0;
}
// 定时器回调:唤醒协程
static void wakeup_coroutine(void* arg) {
coroutine_t* co = (coroutine_t*)arg;
co->state = COROUTINE_READY;
scheduler_add_ready(sched, co);
}
coroutine_resume(),结果在中断上下文里切换协程,导致栈混乱。正确的做法是:回调里只做「标记就绪」这件事,真正的切换留给调度器的主循环去处理。
18.4 定时器驱动:调度器的主循环
调度器每次循环都要做一件事:检查有没有到期的定时器。如果有,就触发它们的回调。这个检查的时机很关键——我一般放在「所有就绪协程都跑完一轮」之后,或者「当前没有就绪协程」的时候。
流程图如下:
18.5 超时取消:给协程操作加个「闹钟」
除了 sleep,定时器另一个重要用途是「超时取消」。比如你发起一个 HTTP 请求,最多等 5 秒,超时就放弃。这个场景在异步编程里太常见了。
实现思路:在发起操作的同时,注册一个超时定时器。如果操作在超时前完成,就取消定时器;如果超时了,定时器回调就把协程唤醒,并设置一个「超时标志」。
int coroutine_call_with_timeout(
void (*operation)(void*),
void* arg,
uint64_t timeout_ms
) {
coroutine_t* co = get_current_coroutine();
// 设置超时标志初始为 false
co->timeout_flag = 0;
// 注册超时定时器
int timer_id = timer_add(
get_current_ms() + timeout_ms,
0, // 非周期性
timeout_callback,
co
);
// 执行操作(可能是异步的,会 yield)
operation(arg);
// 从操作中返回后,检查是否超时
if (co->timeout_flag) {
// 超时了,做清理工作
return -1; // 超时错误码
} else {
// 正常完成,取消定时器
timer_cancel(timer_id);
return 0;
}
}
static void timeout_callback(void* arg) {
coroutine_t* co = (coroutine_t*)arg;
co->timeout_flag = 1;
// 强制唤醒协程
co->state = COROUTINE_READY;
scheduler_add_ready(sched, co);
}
18.6 精度与性能的权衡
定时器的精度取决于两个因素:一是系统时钟的精度,二是调度器检查定时器的频率。我个人习惯把调度器的主循环设计成「事件驱动」的——没有就绪协程时,就阻塞在 epoll 或 select 上,同时把最近定时器的到期时间作为超时参数传进去。这样既能及时响应定时器,又不浪费 CPU。
举个例子:如果最近一个定时器 10 秒后才到期,那 epoll_wait 的超时就设为 10 秒。这 10 秒内如果有网络事件来了,也能立刻处理。两不耽误。
| 方案 | 精度 | CPU 占用 | 适用场景 |
|---|---|---|---|
| 忙等待轮询 | 高(微秒级) | 高(100% CPU) | 实时性要求极高,不关心功耗 |
| epoll 超时驱动 | 中(毫秒级) | 低(空闲时 0%) | 通用网络服务,大部分场景 |
| 定时器文件描述符 | 高(纳秒级) | 低 | Linux 专用,需要高精度 |
我在做物联网网关的时候,用的就是 epoll 超时驱动方案。设备上报数据的时间间隔通常是 30 秒到 5 分钟,毫秒级精度完全够用。而且空闲时 CPU 占用几乎为零,这对嵌入式设备来说很重要。
18.7 实际项目中的坑
最后分享几个我踩过的坑:
- 定时器回调里不要做耗时操作。 回调是在调度器的主循环里触发的,如果回调里调了
sleep或者做了大量计算,整个调度器都会卡住。正确的做法是:回调只做「标记」和「入队」,真正的处理交给协程自己去完成。 - 注意定时器 ID 的复用。 取消定时器时,要确保这个定时器还没被触发。我见过一个 bug:定时器 A 刚触发完,ID 被回收了,然后新创建的定时器 B 拿到了同一个 ID。这时候取消 A 的代码执行了,结果把 B 给取消了。解决方案是用一个递增的序列号,或者用「是否已触发」的标志位。
- 时间溢出问题。 32 位系统上,毫秒时间戳大概 49 天就会溢出。虽然嵌入式设备很少连续运行这么久,但服务器程序必须考虑。我一般用 64 位整数来存时间戳,一劳永逸。
协程的定时器,说白了就是「让协程学会等待」。这个等待不是空转,而是把控制权交出去,让系统去做更有意义的事。掌握了 sleep 和超时处理,你的协程库才算真正有了「时间感知」能力。
公众号:蓝海资料掘金营,微信deep3321