协程挂起时需在awaitable中注入定时器调度以实现超时,正确做法是监听i/o与定时器事件并用原子操作避免双重唤醒,错误做法是使用阻塞式sleep。

协程挂起时如何绑定超时逻辑
协程本身不提供超时能力,必须在挂起点(比如 co_await 一个 awaitable)注入定时器调度。常见错误是把 std::this_thread::sleep_for 直接塞进协程体——这会阻塞整个线程,彻底破坏异步语义。
正确做法是让 awaitable 同时监听 I/O 事件和定时器事件。例如使用 io_uring 的 IORING_OP_TIMEOUT,或在 libuv 中用 uv_timer_t 关联 uv_poll_t;若基于 epoll + timerfd_create,需将 timerfd 加入同一 event loop 并统一 epoll_wait。
- awaitable 的
await_suspend必须注册超时回调,不能只靠协程栈保存状态 - 超时时间应以绝对时间(如
clock_gettime(CLOCK_MONOTONIC, &ts))计算,避免被系统时间调整干扰 - 多个并发 awaitable 共享同一个 timerfd 时,需用最小堆管理所有待触发超时点,每次只设置最近的那个
如何避免超时与 I/O 完成竞争导致的双重唤醒
典型现象:I/O 已完成并唤醒协程,但超时定时器也恰好触发,再次调用 resume(),引发 std::coroutine_handle::resume(): undefined behavior 错误。
根本原因是 awaitable 状态未原子同步。必须在 I/O 完成回调和超时回调中,用原子操作标记“已唤醒”,并在 await_ready() 或 await_resume() 中检查该标记。
- 推荐用
std::atomic<bool></bool>表示“是否已唤醒”,初始化为false - I/O 回调中先
exchange(true),仅当返回false时才 resume;超时回调同理 - 切勿在
await_suspend中直接 resume —— 此时协程尚未挂起,行为未定义
基于 C++20 标准协程的手写 awaitable 示例
以下是一个简化但可运行的 async_read_with_timeout awaitable 片段,依赖 Linux epoll 和 timerfd:
struct read_awaiter {
int fd;
std::span<:byte> buf;
int timerfd;
struct timespec timeout;
std::coroutine_handle handle;
std::atomic<bool> resumed{false};
bool await_ready() { return false; }
void await_suspend(std::coroutine_handle h) {
handle = h;
// 同时添加 fd 和 timerfd 到 epoll
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &ev_read);
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, timerfd, &ev_timer);
// 设置 timerfd 超时
timerfd_settime(timerfd, 0, &timeout, nullptr);
}
ssize_t await_resume() {
if (!resumed.load()) {
throw std::runtime_error("I/O or timeout failed to resume");
}
// 检查是哪个事件触发:读就绪 or timerfd 可读
return (triggered_by_timer ? -ETIMEDOUT : ::read(fd, buf.data(), buf.size()));
}
};
</bool></:byte>
注意 epoll_wait 返回后,必须遍历所有就绪事件,分别调用对应 awaitable 的唤醒逻辑,并确保只唤醒一次。
第三方库选型的关键取舍点
自己手写易出错,但过度依赖封装会丢失对超时精度和取消语义的控制。libuv、Boost.Asio、cppcoro 各有侧重:
-
Boost.Asio的async_read+steady_timer组合最成熟,但默认超时是 cancel 操作,可能丢弃部分已接收数据 -
cppcoro提供timed_wait_operation,但底层仍依赖平台 event loop,Linux 下需自行桥接io_uring - 若用
io_uring,务必开启IORING_SETUP_IOPOLL和IORING_SETUP_SQPOLL降低延迟,但超时需用IORING_OP_TIMEOUT_REMOVE主动清理,否则残留 timeout 请求会泄漏
真正难的不是实现单次超时,而是高并发下每个连接都带独立超时、且支持动态调整——这时 timerfd 堆管理或红黑树索引就成了性能瓶颈,容易被忽略。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











