学习 libuv 到一定阶段你会发现,uv_tcp、uv_timer、uv_fs 这些句柄都是围着“单线程事件循环”转的,可真实项目里免不了要自己开几条线程去干活。线程跑完了,怎么把结果或状态安全地告诉正在uv_run里阻塞的主循环?Windows 上大家习惯 PostMessage,Unix 上有人自造 pipe,而在 libuv 里其实已经给你准备好了原生原语:uv_async 异步句柄。这篇是系列第 9 篇,专门把 uv_async 讲透,包括它的 API 语义、回调合并行为、线程间唤醒机制,以及我在实际项目中踩过的关闭时序坑。适合已经理解事件循环基本概念、想自己管理线程并且控制主循环通信的读者,读完你就能在项目里放心地用上它。
1. 事件循环的“门铃”:uv_async 到底解决什么问题
1.1 为什么普通锁和条件变量解决不了“唤醒事件循环”
假设你在做一个实时数据转发模块:几条工作线程负责拉取数据、解析、写本地缓冲,主线程负责跑 libuv 事件循环,定时上报统计。最自然的想法是搞一个互斥锁保护的队列,工作线程把数据塞进去,然后发一个条件变量通知主线程。
但这里有个根本矛盾:主线程通常阻塞在uv_run内部的 poll 调用上,它并没有在pthread_cond_wait上等你。你pthread_cond_signal得再多,阻塞在 epoll/iocp 上的线程也根本不会因此醒过来。就算你用uv_stop强退循环,处理完这轮之后循环就结束了,你还要自己想着怎么把它重新跑起来,这显然不是常规做法。
所以你必须有一个机制,能让“正在阻塞等待事件”的循环线程立刻返回。这就是 uv_async 的定位:它不是用来做文件异步读写的那个“异步”,也不是网络回调,它是专门解决“跨线程通知事件循环线程”这个问题的句柄。它可以被任意线程调用,每次调用就相当于往事件循环里按了一下门铃,门铃响了,事件循环就会从阻塞中醒来,事后再在你的回调函数里做具体事情。
1.2 uv_async 和 uv_queue_work 的分工
很多人第一次接触 libuv 多线程,看到的是uv_queue_work:它把一件事丢进 libuv 内部线程池,干完了自动回到主线程回调。这确实很像“在线程和事件循环之间搭桥”。但它是一锤子买卖——递交一个 work 函数,执行完、回调完,任务就结束了。如果你需要的是一个长期存活、反复发消息的线程,或者线程生命周期完全由你控制,uv_queue_work就非常别扭。
这时候 uv_async 才是对的工具。它本身不创建线程,它只负责“通知”。你的线程可以是自己pthread_create或uv_thread_create创建的,跑多久都无所谓,只要在需要主循环处理时调用uv_async_send即可。用一句大白话总结:uv_queue_work 是“派活”,uv_async 是“喊人”。
1.3 一句话理解这个句柄
把事件循环想象成一间店铺的老板,工作线程是店外的伙计。店里有一扇门铃,名字叫uv_async_t。伙计们干完一件事就按一下门铃,老板听到门铃响,就出来看看柜台上的单子,处理完再回去接着等。门铃本身不递单子,单子放在柜台上(共享变量 / 队列),老板自己来取。
这个比喻还能帮你理解一个关键行为:如果伙计连续按了很多次门铃,老板听到第一次响就出来处理,等把所有单子处理完再回去,那中间按的几次铃其实都被“合并”了,老板不会因为伙计手速快就多跑几趟。这个特点后面会反复用到。
2. uv_async_init 与 uv_async_send 的完整语义
2.1 初始化:只能在事件循环线程做
uv_async_init函数签名是:
int uv_async_init(uv_loop_t *loop, uv_async_t *handle, uv_async_cb cb);它在循环线程里把一个uv_async_t句柄挂到 loop 的异步通知链表上。调用成功后,句柄处于活跃状态,事件循环会因为它而保持运行,除非你调用uv_unref或者把它uv_close掉。这也是最常见的一个“uv_run 退不出来”的原因:你初始化了 async,忘了 close,loop 就一直认为还有事情没做完。
初始化必须在事件循环线程执行,不要在 worker 线程里做这件事。原因很简单,内部要操作 loop 的句柄链表和唤醒用的文件描述符,这些结构不是线程安全的。如果你在多个线程里同时初始化多个 async,或者一边初始化一边跑uv_run,很可能出现各种奇怪的崩溃。libuv 的文档对这件事写得很轻描淡写,但实际踩坑的时候一点不含糊。
另外,一个 loop 上可以挂多个 async 句柄,每个有自己的回调,互不干扰。有些人喜欢一个模块一个句柄,有些人喜欢全局一个句柄然后在共享数据里塞类型字段,两种我都用过,小项目无所谓,模块一多还是建议按功能拆分,回调里能少做一次分支判断。
2.2 发送:线程安全但不等同于“调用回调”
uv_async_send的签名只有一行:
int uv_async_send(uv_async_t *handle);它是整个 libuv 里少数几个明确声明“可以从任意线程调用”的函数之一。线程安全,非阻塞,调用后立刻返回。它做的事情是:给这个 async 句柄打上一个“有通知”的内部标记,然后唤醒 handle 所属的事件循环。回调并不会在发送线程执行,而是等事件循环线程从阻塞中醒来、处理完当前这一轮的 IO 事件之后,由事件循环线程调用你的回调。
这句话值得再强调一遍:uv_async_send 只负责叫醒,不负责当场执行回调。你在 worker 线程里做uv_async_send之后立刻修改本地变量,主线程回调不一定马上看到,必须靠锁或者原子操作保证共享数据的可见性。这一点我见过太多人出错:以为 send 完就完事了,把栈上变量地址传给回调用,结果回调还没跑,栈已经失效了。
还有一个容易被忽略的细节:如果你在事件循环线程尚未调用uv_run之前就多次调用uv_async_send,这些发送会被缓存下来。等到uv_run真正开始跑、进入 IO poll 阶段时,内核会一次性把唤醒事件交出来,回调会被合并执行。这不是 bug,而是 libuv 的设计特性。
2.3 回调合并不是 bug,是设计
libuv 的 async 回调采用“有通知标志 + 唤醒”的机制。同一轮事件处理中,不管uv_async_send被调用多少次,只要事件循环还没来得及处理,这个句柄上就只有一个“pending”状态。处理完一轮之后,这个状态被清掉,回调最多调用一次。
这也意味着uv_async 不保证一次 send 对应一次回调。如果你的语义是“每产生一个数据必须让主线程处理一次”,那不能指望 send 的次数和回调次数一致。正确的做法是:把数据放进共享队列,构建一个可靠计数或直接让回调去把队列里的东西全部取出来。
举个例子,worker 线程连续产生 100 条消息并调用 100 次 send,事件循环可能只回调几次,但每次回调时队列里都有若干条消息。只要你的回调逻辑是“取出当前队列中所有数据并处理”,最终这 100 条消息都会处理到,只是调度次数变少了。
2.4 数据搬运要靠共享状态,通知本身不携带数据
这一点经常被刚接触的人误解:uv_async 的回调函数签名是void callback(uv_async_t *handle),它不像网络回调那样带一个buf参数,也不像uv_fs_t那样把结果塞进 request 里。它真的是“只负责通知”。
你需要自己定义共享结构体,典型写法是这样的:
// 发送方 uv_mutex_lock(&lock); queue_push(item); uv_mutex_unlock(&lock); uv_async_send(&async_handle); // 事件循环线程回调 void on_async(uv_async_t *handle) { uv_mutex_lock(&lock); while (queue_not_empty()) { process(queue_pop()); } uv_mutex_unlock(&lock); }锁是必须的,因为生产者和消费者通常是两个线程。如果你只是更新一个整数计数,也可以考虑用 C11 的原子变量,但要搞清内存序,别为了少写一行锁引入更玄学的 bug。我自己的习惯是:数据量小用互斥锁,简单直接;数据结构复杂就单独封装一个线程安全队列,不要把锁的逻辑散落在业务代码里。
3. 最小可跑示例:worker 线程往主循环发通知
3.1 代码不长,但每一行都有讲究
下面这个示例演示了“100 条消息从 worker 线程发往主循环,最终全部被消费”的完整流程。为了让回调合并的现象稳定重现,我故意在主线程里先uv_sleep(200),让 worker 有足够时间把 100 次 send 全发完,再启动uv_run。
#include <uv.h> #include <stdio.h> #define TOTAL_MESSAGES 100 typedef struct { uv_async_t async; // 必须放在第一个字段,方便通过 handle 取到 context uv_mutex_t mutex; int pending; // 发送方累计的消息数 int processed; // 回调里已经消费的消息数 } context_t; static context_t ctx; static void on_async(uv_async_t *handle) { context_t *c = (context_t *)handle; uv_mutex_lock(&c->mutex); int n = c->pending; c->pending = 0; uv_mutex_unlock(&c->mutex); c->processed += n; printf("async callback: drain %d message(s), total %d/%d\n", n, c->processed, TOTAL_MESSAGES); // 这里可以真正处理这 n 条消息,注意不要做阻塞 IO if (c->processed >= TOTAL_MESSAGES) { uv_close((uv_handle_t *)handle, NULL); } } static void worker(void *arg) { (void)arg; for (int i = 0; i < TOTAL_MESSAGES; i++) { uv_mutex_lock(&ctx.mutex); ctx.pending++; uv_mutex_unlock(&ctx.mutex); uv_async_send(&ctx.async); } } int main() { uv_loop_t *loop = uv_default_loop(); if (uv_async_init(loop, &ctx.async, on_async) != 0) { return 1; } uv_mutex_init(&ctx.mutex); uv_thread_t tid; uv_thread_create(&tid, worker, NULL); uv_sleep(200); // 故意让 worker 先发完,演示合并 uv_run(loop, UV_RUN_DEFAULT); uv_thread_join(&tid); uv_mutex_destroy(&ctx.mutex); return 0; }编译命令很简单:
gcc -o async_demo async_demo.c -luv -lpthread我在这个例子里把async放在了结构体的第一个字段,所以on_async里可以直接把handle强转成context_t *。这是 libuv 社区常见的写法,也是对初学者最友好的写法。如果字段不在第一个位置,就要用container_of,否则指针转换是错的。
3.2 输出解读与合并现象
按上面的代码逻辑,worker 线程在 200 毫秒内把 100 次uv_async_send全部发完了,然后主线程才启动uv_run。此时事件循环第一次进入 IO poll,就收到了内核的唤醒事件,遍历 pending 的 async 句柄,调用回调。
我实际跑出来的输出基本是一行:
async callback: drain 100 message(s), total 100/100也就是说 100 次发送合并成了 1 次回调。这就是 uv_async 回调合并最直观的体现。如果你把uv_sleep(200)去掉,让事件循环和 worker 线程同时竞争运行,输出就会随机变成几行,比如:
async callback: drain 37 message(s), total 37/100 async callback: drain 14 message(s), total 51/100 async callback: drain 49 message(s), total 100/100两种输出都是正常行为,但第一批消息的合并几乎无法避免。原因在于 worker 线程抢占先机,在事件循环第一次 poll 开始前已经把多个消息都塞进了队列。
所以你在设计协议时,千万不要写“回调次数应该等于发送次数”这样的断言。这也是为什么我在示例里用processed累加,而不是用回调次数判断结束。
3.3 带互斥锁的可靠通知队列模式
真实项目里,你很快会从“一个 worker 发 100 条消息”变成“四五个 worker 持续发消息”,代码结构也会演变成下面这种模式:
typedef struct { uv_async_t async; uv_mutex_t mutex; uv_loop_t *loop; int stop_flag; } Dispatcher; static void dispatch_notify(Dispatcher *d) { uv_mutex_lock(&d->mutex); // push item into your queue uv_mutex_unlock(&d->mutex); uv_async_send(&d->async); }所有 worker 共享同一个Dispatcher,生产数据时先锁、后入队、再 send。事件循环线程的回调负责把队列里的任务尽量取空。只要队列上锁正确,发送多少次都不怕,回调合并只是减少了唤醒次数,不会丢数据。
有人会问:如果 worker 太多,回调里一次要处理几千条任务,会不会把事件循环卡死?会,所以更工程化的做法是给回调处理设置一个预算,比如每轮最多处理 500 条,处理完就返回,让事件循环喘口气。剩下没处理完的消息留在队列里,等下一次 async 回调再继续。这个思路在后面讲背压的时候还会再提。
4. 实战中最容易翻车的关闭时序问题
4.1 uv_close 之后 send:未定义行为的现场还原
async 句柄用起来不难,真正让项目出事故的往往在关闭阶段。最典型的一个错误场景是这样的:
主线程决定退出,于是执行了:
uv_close((uv_handle_t *)&async_handle, close_cb);但某个 worker 线程完全不知道这件事,它还在做最后一批数据处理,过了一会儿执行了:
uv_async_send(&async_handle);这里会发生什么?libuv 的文档直接告诉你:这是未定义行为。现实中的表现可能是三种:
第一,调用uv_async_send时 handle 已经被从 loop 里摘除,但内存还没释放,函数内部访问了已经失效的内部字段,返回一个莫名错误或者没有效果。第二,close 回调已经执行,内存被释放,worker 线程再去 send,直接踩到已释放内存,整个进程崩溃。第三,运气好,没有崩溃,但这条通知永远丢失,主线程等到超时才发现问题。
我最初调试这个问题时,以为只要在 close 回调里加一个标志位,worker 看到标志就不再 send,就能解决。实际上这个方案并不可靠,因为 worker 可能在标志置位前已经执行了uv_async_send。更稳妥的做法是从源头保证:在关闭句柄之前,确保所有可能调用 send 的线程都已经全部退出或确认不再发送。
4.2 安全的收尾流程:先停发送方,再关句柄
我后来总结出一套在项目里反复使用的关闭顺序:
- 设置一个共享的
stop_flag,用互斥锁或原子变量保护。 - worker 循环的每一轮都检查
stop_flag,检测到后停止生产数据并退出线程函数。 - 主线程等待所有 worker 线程结束,也就是逐个调用
uv_thread_join。 - 确认没有线程再调用
uv_async_send后,在事件循环线程里调用uv_close。 - 继续调用
uv_run,让 close 回调有机会执行,然后销毁相关的锁和共享数据。
严格来说,第 3 步才是最关键的。join 会阻塞住调用线程,直到 worker 彻底跑完。这种阻塞发生在主线程上没问题,因为此时你已经决定退出事件循环了,不用再担心阻塞住 loop。如果你需要在事件循环继续跑的同时等待 worker 结束,那就要设计更复杂的“thread done”通知机制,通常做法是让 worker 退出前再发一条特定的退出消息,主循环收到后调用 uv_close,等 close 回调里确认句柄关闭,再让整个进程退出。
4.3 回调里做重活:另一个隐形坑
除了关闭竞态,还有一个很多人会踩的坑:在on_async回调里做重活。
我有一次做某台模拟网关的数据上报模块,worker 线程每秒钟产生几千条统计记录,主循环的 async 回调负责把记录格式化后写入日志文件。写入量大的时候,整个事件循环的延时肉眼可见地升高,因为格式化日志这种 CPU 密集操作把回调占住了,其他网络的读事件全部积压。
排查下来,问题不在 async 本身,而在回调函数干了太多不该它干的事。正确的做法是回调只做两件事:从共享队列取出数据,以及立刻返回。真正耗时的格式化、写盘、网络发送,应该交给uv_queue_work或者另一组线程池去处理。记住一个原则:async 回调是事件循环的“现场调度员”,不是“干重活的工人”。
4.4 handle 不关闭导致 loop 无法退出
还有一个隐蔽问题:你初始化了 async,但直到程序退出都没有关闭它。这时uv_run(loop, UV_RUN_DEFAULT)永远不返回,因为 libuv 判断 loop 还有活跃的 handle。你要么在退出前显式调用 uv_close,要么一开始就对这个 async 调用 uv_unref。uv_unref 会让 loop 不因这个 handle 而保持运行,非常适合那种“有人发消息才需要处理,没人发消息也不阻塞进程退出”的辅助句柄。
这里容易混淆的是:uv_unref 只是让 loop 不因它挂起,并不影响它内部仍然有效。unref 之后,如果其他地方还有活跃句柄,async 照样能接收 send 和触发回调。
5. 底层唤醒原理与性能边界
5.1 Unix 上的 eventfd/self-pipe
看到这里,你可能会好奇:一次uv_async_send到底做了什么,能让阻塞中的事件循环瞬间醒来?在 Unix/Linux 上,libuv 有一个内部唤醒用的文件描述符,老版本用的是 self-pipe,也就是提前创建一对 pipe,往写端写一个字节,读端就会被 epoll 监听;现代 Linux 上一般用eventfd,语义差不多,但开销更小。
调用uv_async_send时,如果事件循环正在epoll_wait等待,那么这个唤醒描述符就会变得可读,epoll 立即返回。libuv 随后会去遍历挂在这个 loop 上的所有 async 句柄,找出那些打上了 pending 标记的句柄,依次调用它们的回调。
这种设计解释了很多现象:同一轮唤醒可能同时带动多个 async 句柄回调;同一个句柄只执行一次回调;数据传递完全依靠共享内存结构而不是内核消息队列。
5.2 Windows 上的 IOCP 通知
在 Windows 上,libuv 后端走的是 IOCP。异步句柄的唤醒方式是通过PostQueuedCompletionStatus向完成端口投递一个特殊完成状态。事件循环线程正阻塞在GetQueuedCompletionStatus上,收到这个特殊状态后会从完成端口返回,然后同样去处理 pending 的 async 句柄。
所以跨平台的实现细节不同,但对外暴露的语义一致。你写代码时不需要关心到底是 eventfd 还是 IOCP,只需要相信uv_async_send是线程安全的,并且“能叫醒正在阻塞的 loop”就足够了。
5.3 为什么“一次 send 多次回调合并”和性能有关
回调合并与其说是限制,不如说是保护机制。假设 worker 线程在一个循环里短时间内发送一万次通知,如果每发送一次都要让事件循环进入一次完整的事件处理迭代,IO poll 的唤醒开销会是巨大的。合并之后,一万个通知可能只触发一次回调,事件循环的处理成本大幅下降。
但要注意,这不代表你可以无节制地高频调用uv_async_send。每次 send 至少涉及一次跨线程的变量访问和可能的系统调用,如果 inside 极高频循环中,它仍然会带来可感知的开销。我做过一个粗略测试:在多线程环境中,一次性发送一万条并使用同一个 async 合并处理,花费的时间比“每次等待回调处理完再发送”要小得多。这也提醒我们:能用批量的方式传递消息,就不要一条一条挤牙膏。
6. 工程用法:和 uv_queue_work、uv_poll 的组合套路
6.1 一张表格看清 uv_async 与 uv_queue_work
我经常被问“什么时候用 uv_async,什么时候用 uv_queue_work”,这里直接放一张对比表:
| 维度 | uv_queue_work | uv_async 异步句柄 |
|---|---|---|
| 线程来源 | libuv 内置线程池 | 任意线程,通常是你自己创建的线程 |
| 生命周期 | 一次性任务,执行完自动收尾 | 长期存在,可反复发送通知 |
| 自定义线程 | 不适用,控制权在线程池 | 线程创建、销毁、生命周期都由你控制 |
| 是否携带数据 | work 函数有自己的 data 参数 | 不携带数据,数据走共享队列 |
| 回调位置 | 事件循环线程 | 事件循环线程 |
| 典型场景 | 文件压缩、DNS 查询、一次性计算 | 实时数据流、后台线程向主循环持续上报 |
如果你的任务是“跑一次、结束一次”,直接用 uv_queue_work;如果你的线程要反复和事件循环通信,或者线程数量、优先级你都要自己掌控,那 uv_async 才是主力。
6.2 有界队列与 backpressure 设计
前面说过 uv_async 本身不提供背压。如果 worker 产生数据的速度远大于主循环处理速度,共享队列会越积越长,最后内存膨胀,甚至拖垮进程。这时候就得自己设计有界队列。
简单做法是给队列设置一个最大值,worker 入队前先uv_mutex_lock,检查队列长度是否达到上限。如果到了上限,就丢弃这批数据并把丢弃计数加一,之后用 send 通知主循环处理剩余数据;或者让 worker 短暂休眠重试,等主循环消费掉一部分之后再继续入队。
需要特别强调的是,不要在 worker 线程里使用pthread_cond_wait等待队列有空位,因为等待期间这个 worker 线程什么都干不了,也不会有信号机制唤醒它,除非主循环在回调里专门给你 signal。这种设计可以做,但一不小心就会睡死。更稳的做法是采用“丢弃旧数据或者丢弃新数据”的策略,配合统计指标,让系统在过载时仍然能继续运行。
6.3 我的项目使用心得
写到这里,分享一点我的个人体会。我用 uv_async 做得最多的一类功能是“状态汇总”:多个 worker 线程各自处理不同上游连接,每完成一批数据就向主循环发送一次通知,主循环回调里把汇总状态更新到共享结构体,然后写入指标系统。这样即使某个 worker 频率很高,主循环也能借助回调合并机制把负载平滑掉。
还有一个小技巧:调试 async 问题时,我会在程序里临时加两个计数器,一个是发送次数,一个是回调次数。把它们打出来,就能直观地看到合并比例,也能判断出是不是某个线程发得太猛。这个临时日志帮过我很多次,尤其是排查“为什么主循环迟迟收不到数据”这种问题。记住,async 只负责叫醒,门铃响了,不代表一定会有你想看的通知,真正可靠的判定还得回到共享数据的计数上。
另外,如果你发现自己需要区分不同的通知来源,可以创建多个 async 句柄,每个句柄绑定一类消息;也可以在共享消息结构体里加一个type字段,用一个句柄 + 一个队列来处理。两种方式都可以,我一般倾向于“一个功能域一个句柄”,代码可读性更好,也不会因为某个类型的消息处理太慢而影响其他类型。