18、协程超时处理:在协程中实现 sleep 与定时器

协程里最常用的功能是什么?我个人觉得,除了切换和通信,就是超时处理了。你想想看,一个网络请求发出去,总不能一直等下去吧?现实世界是有延迟的,网络会丢包,服务会超载。所以,给协程加个定时器,让它能在指定时间后自动醒来或者超时退出,这是异步编程的必修课。

18.1 为什么协程需要自己的 sleep?

很多新手会问:直接用系统提供的 sleep() 不行吗?嗯,这里要注意——系统 sleep() 会阻塞整个线程。在协程模型里,一个线程上跑着成百上千个协程,你让其中一个调了 sleep(3),那这 3 秒内所有协程都卡死了。这显然不行。

协程的 sleep 本质上是「我暂时不干活了,到点了叫我」。它会把当前协程挂起,让出 CPU 给其他协程。时间到了,调度器再把它重新放回就绪队列。这个机制,说白了就是定时器 + 协程切换的组合拳。

核心思想: 协程 sleep 不是真的让线程休眠,而是让协程自己休眠。线程继续跑别的协程。

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 就很简单了。核心流程就三步:

  1. 记录当前协程的上下文,保存到调度器里
  2. 创建一个定时器,到期时间 = 当前时间 + 睡眠时长
  3. 定时器的回调函数里,把协程重新放回就绪队列

代码大概长这样:

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 精度与性能的权衡

定时器的精度取决于两个因素:一是系统时钟的精度,二是调度器检查定时器的频率。我个人习惯把调度器的主循环设计成「事件驱动」的——没有就绪协程时,就阻塞在 epollselect 上,同时把最近定时器的到期时间作为超时参数传进去。这样既能及时响应定时器,又不浪费 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 和超时处理,你的协程库才算真正有了「时间感知」能力。

总结: 协程 sleep = 挂起 + 定时器 + 唤醒。超时处理 = 定时器 + 标志位 + 强制唤醒。两者都依赖一个高效的最小堆定时器。

公众号:蓝海资料掘金营,微信deep3321