资讯动态

libuv源码分析:DNS异步解析与线程池实现原理

发布时间:2026/10/25 3:57:29 来源:尧图企业网站定制
写到这里libuv 源码分析系列已经走过了事件循环、定时器、文件 I/O总算轮到网络层一个容易被忽视却天天在用的模块——DNS。这一篇的主题就是Libuv源码分析 —— 9. DNS核心是uv_getaddrinfo和uv_getnameinfo这两个 API 的完整实现链路从你在业务代码里调一个函数开始到工作线程执行系统解析再回到主线程触发回调中间到底发生了什么。如果你是写网络库、做服务端中间件或者只是用 Node.js 但一直好奇dns.lookup()底层凭什么不卡事件循环这篇文章都值得花十分钟看完。我会把源码拆开讲也会把实际开发中容易踩的内存、并发、缓存问题一并说清楚。1. 先搞清楚libuv 的 DNS 解析到底解决什么问题1.1 阻塞式 getaddrinfo 的“罪”先看一个最直接的痛点。C 标准库提供的getaddrinfo()是一个同步阻塞调用它要做的事情包括解析/etc/hosts、查询系统配置的 DNS 服务器、处理 search domain 搜索列表甚至可能并发尝试 A 记录和 AAAA 记录。一个最简单的域名解析正常几十毫秒能返回可一旦 DNS 服务器超时这个函数可能阻塞 5 秒甚至更久。问题在于如果你在事件循环线程里直接调用getaddrinfo()这 5 秒内整个进程就停了定时器不触发、TCP 连接不建立、文件读写回调全部排队。我参与过一个网络框架的早期 Demo当时图省事在主线程里做域名解析压测时只要 DNS 抖动整个服务的响应时间立刻出现秒级毛刺排查半天才锁定到这一行同步调用。libuv 的解决方案很直接把getaddrinfo()封装成异步请求提交给内部线程池去执行主线程继续处理事件解析完成后再通过事件循环机制把结果交还给回调。1.2 异步化的两种路线用户态协议栈 vs 线程池异步 DNS 并不是只有线程池这一条路。业界还有一条路线是用户态 DNS 协议栈比如 c-ares不调用系统getaddrinfo而是自己构造 DNS 查询报文、自己解析响应通过事件循环驱动超时和重传。这条路线的好处是完全非阻塞甚至能控制每一个 DNS 查询的细节。libuv 早年间确实对 c-ares 做过集成但最终在主线版本里去掉了这层依赖统一走线程池方案。原因很实际系统getaddrinfo背后对接了 nsswitch、mDNS、LDAP 等各种平台解析器自己实现一套 DNS 协议栈很难覆盖这些生态可移植性和维护成本都是问题。线程池方案虽然会让 DNS 请求消耗一个工作线程但胜在简单、可靠、跨平台行为一致。1.3 核心 API 一览libuv 的 DNS 模块对外只留下两组请求简洁得很API请求结构体回调类型用途uv_getaddrinfouv_getaddrinfo_tuv_getaddrinfo_cb域名/服务名解析为地址A/AAAAuv_getnameinfouv_getnameinfo_tuv_getnameinfo_cbIP 地址反向解析为主机名uv_freeaddrinfo--释放解析返回的地址链表这两个 API 的语义和系统函数几乎一一对应你如果用过 POSIX 的getaddrinfo/getnameinfo上手 libuv 版本基本没有学习成本。真正的门道在实现层请求怎么提交、结果怎么回来、资源怎么释放。2. 核心数据结构解析请求是怎么装进 libuv 的2.1 uv_getaddrinfo_t 内部结构看一下uv_getaddrinfo_t的定义这个结构体不长但信息量很大typedef struct uv_getaddrinfo_s uv_getaddrinfo_t; struct uv_getaddrinfo_s { UV_REQ_FIELDS uv_getaddrinfo_cb cb; struct addrinfo* addrinfo; char* node; char* service; struct addrinfo hints; };其中UV_REQ_FIELDS展开后包含类型、回调、内部链表节点等通用字段还会内嵌一个uv__work结构体struct uv__work { void (*work)(struct uv__work* w); void (*done)(struct uv__work* w, int status); };这两个函数指针是整个 DNS 请求能够进入线程池的关键。你可以把uv_getaddrinfo_t理解成一辆既能跑业务、又能挂载到线程池任务队列上的“两用车”前半部分是请求上下文后半部分的work_req是线程池调度器识别任务的凭证。2.2 生命周期从创建到回调一次完整的异步 DNS 解析状态流转是这样的调用uv_getaddrinfo传入 loop、请求对象、回调、node/service 和 hints。libuv 内部拷贝 node 和 service 字符串把请求注册到 loop 的请求计数中。如果回调非空请求被提交到线程池任务队列主线程立即返回。某个工作线程取出任务执行内部的 work 函数调用系统getaddrinfo。完成后工作线程把结果挂到任务的完成状态并通过 async 机制通知主循环。主循环在处理事件时执行 done 函数调用用户注册的回调。这个流程的关键设计点是work_req直接内嵌在uv_getaddrinfo_t里而不是单独分配一块内存。这样能省一次堆分配也能保证请求对象在任务执行完之前不会被误释放。2.3 hints 参数的正确打开方式hints参数对应系统getaddrinfo的 hints它决定了查询的类型和过滤条件。libuv 跟系统版本有一个细节差异它会强制加上AI_NUMERICSERV标志禁止 service 参数被解释为端口号以外的服务名避免工作线程在解析服务名时再做额外的阻塞查询。常用的几个 flagAI_PASSIVE配合空 node返回通配地址用于服务端 bind。AI_NUMERICHOST要求 node 参数必须是数字 IP不发起 DNS 查询。AI_CANONNAME返回主机名的规范名。AI_ADDRCONFIG只有本机配置了对应地址族时才返回该族地址。实际开发里最常见的误用是传 NULL hints。这不一定会出错但会让解析器返回一长串地址且顺序在不同系统上不可控。如果你只需要 IPv4建议这样过滤struct addrinfo hints; memset(hints, 0, sizeof(hints)); hints.ai_family AF_INET; hints.ai_socktype SOCK_STREAM;另外注意libuv 在uv_getaddrinfo内部会把传入的 hints 拷贝到req-hints所以你在提交请求后在原线程释放自己的 hints 结构体没有问题。3. 源码逐段拆解一场 DNS 解析是怎么跑完的3.1 uv_getaddrinfo 入口我把 libuv 源码里uv_getaddrinfo的核心路径摘出来不同版本细节略有差异但主干一致int uv_getaddrinfo(uv_loop_t* loop, uv_getaddrinfo_t* req, uv_getaddrinfo_cb cb, const char* node, const char* service, const struct addrinfo* hints) { if (req NULL || (node NULL service NULL)) return UV_EINVAL; req-node uv__strdup(node); req-service uv__strdup(service); if (hints) { req-hints *hints; req-hints.ai_flags | AI_NUMERICSERV; } uv__req_init(loop, req, UV_GETADDRINFO); if (cb NULL) { uv__getaddrinfo_work(req-work_req); return 0; } uv__work_submit(loop, req-work_req, UV__WORK_CPU, uv__getaddrinfo_work, uv__getaddrinfo_done); return 0; }这里有几个值得细看的点。第一参数校验只检查req非空以及node和service至少有一个非空。这与系统getaddrinfo的语义保持一致node 为空时配合AI_PASSIVE可以得到通配地址。第二node和service通过uv__strdup做了深拷贝。原因很直接提交请求后你立刻可以释放调用方栈上的字符串libuv 内部保证在解析完成前这些数据一直有效。这个设计救了不少人我之前见过有人以为必须用全局变量保存 host 字符串完全没必要。第三cb NULL时走同步分支。这个分支很容易被忽略但在调试和写脚本工具时非常有用后面实操部分会演示。第四异步分支调用uv__work_submit传入UV__WORK_CPU。libuv 的线程池把任务分成了 CPU 密集型和快速 IO 型两类DNS 解析走 CPU 类队列。这意味着它和文件读取的某些任务共享线程池资源这也是后面性能部分要专门讨论的原因。3.2 线程池里发生了什么工作线程拿到任务后执行的是uv__getaddrinfo_workstatic void uv__getaddrinfo_work(struct uv__work* w) { uv_getaddrinfo_t* req container_of(w, uv_getaddrinfo_t, work_req); int err getaddrinfo(req-node, req-service, req-hints, req-addrinfo); req-ret err; }简洁到有点无聊但这就是最核心的一行调用真正阻塞的系统getaddrinfo发生在这里跑在某个工作线程的上下文里。主线程此时可以继续处理其他事件。你会注意到结果直接挂到req-addrinfo上失败码存到req-ret。这一步没有做任何错误翻译把原始 errno/错误码原样保存交给主线程侧统一处理。这里有个隐含风险如果系统解析器特别慢这个工作线程会被长时间占用。而线程池默认只有 4 个线程意味着极端情况下第 5 个 DNS 请求要排队等待。后面讲调优时再展开。3.3 结果如何回到主线程工作线程执行完work函数后线程池内部会标记任务完成并通过uv_async_send唤醒事件循环。主线程随后执行uv__getaddrinfo_donestatic void uv__getaddrinfo_done(struct uv__work* w, int status) { uv_getaddrinfo_t* req container_of(w, uv_getaddrinfo_t, work_req); if (status UV_ECANCELED) { req-ret UV_EAI_CANCELED; goto done; } req-ret uv__getaddrinfo_translate_error(req-ret); done: if (req-cb) req-cb(req, req-ret, req-addrinfo); }status参数不是系统解析器的错误码而是线程池任务本身的执行状态比如UV_ECANCELED表示任务被取消。系统错误码通过uv__getaddrinfo_translate_error转换为UV_EAI_*系列这样上层代码可以用uv_strerror拿到统一的错误字符串。还需要注意done 函数在处理完后并没有释放req释放权在用户手里。libuv 的惯例是请求对象由调用方分配由调用方在回调返回后释放。这一点稍后会放到常见问题里重点说。3.4 反向解析 uv_getnameinfouv_getnameinfo的实现和uv_getaddrinfo几乎是一个模子刻出来的区别只是把系统调用换成了getnameinfo请求结构体里保存的是 sockaddr 和 flagsstruct uv_getnameinfo_s { UV_REQ_FIELDS uv_getnameinfo_cb cb; char* hostname; struct sockaddr_storage storage; int flags; };流程依然是提交 → 工作线程执行getnameinfo→ 回到主线程回调。反向解析同样可能阻塞比如某些系统会优先尝试 PTR 查询超时时间同样不可控。所以不要因为它看起来只是一个 IP 转主机名的操作就掉以轻心该异步就异步。3.5 同步模式与取消的边界前面提到cb NULL时uv_getaddrinfo会直接执行uv__getaddrinfo_work整个请求变成同步阻塞。这个模式适合命令行工具、启动时一次性解析、或者测试脚本。但要注意同步模式下loop参数可以传 NULL因为这个路径完全不需要事件循环参与。另外需要澄清一个常见误解libuv 没有提供有效的 DNS 请求取消机制。虽然uv__work_done里处理了UV_ECANCELED分支但对已经提交到线程池的getaddrinfo来说uv_cancel并不可用。系统调用一旦开始阻塞就没有办法从外部打断它所谓的取消只能让回调收到一个取消状态底层线程依然会等到解析返回。实测下来如果系统 DNS 超时是 5 秒取消并不会让你提前 5 秒解脱。要控制总耗时只能自己做超时逻辑或者优化解析器配置。4. 实操手写一个异步 DNS 解析 Demo4.1 完整代码纸上谈兵再多不如跑一段代码。下面这个 Demo 会异步解析 example.com 并打印结果#include uv.h #include stdio.h #include stdlib.h #include string.h static void on_resolved(uv_getaddrinfo_t* resolver, int status, struct addrinfo* res) { if (status 0) { fprintf(stderr, 解析失败: %s\n, uv_strerror(status)); free(resolver-data); free(resolver); return; } char addr[64] {0}; if (res-ai_family AF_INET) { uv_ip4_name((struct sockaddr_in*) res-ai_addr, addr, sizeof(addr)); } else if (res-ai_family AF_INET6) { uv_ip6_name((struct sockaddr_in6*) res-ai_addr, addr, sizeof(addr)); } printf(%s - %s\n, (char*) resolver-data, addr); uv_freeaddrinfo(res); free(resolver-data); free(resolver); } int main() { uv_loop_t* loop uv_default_loop(); uv_getaddrinfo_t* resolver malloc(sizeof(uv_getaddrinfo_t)); resolver-data strdup(example.com); int rc uv_getaddrinfo(loop, resolver, on_resolved, example.com, NULL, NULL); if (rc ! 0) { fprintf(stderr, 提交失败: %s\n, uv_strerror(rc)); return 1; } uv_run(loop, UV_RUN_DEFAULT); uv_loop_close(loop); return 0; }编译命令cc dns_demo.c -o dns_demo -luv跑起来之后输出大概是example.com - 93.184.216.34代码里有三个容易忽略的细节。第一我在回调里通过resolver-data保存了域名用于打印。libuv 的请求结构体带有data字段专门给用户挂上下文用的非常方便。第二我只取了addrinfo链表的第一个节点。实际上一个域名可能返回多个 A 记录和 AAAA 记录后面还有一堆ai_next节点。真实项目里应该遍历整个链表逐个尝试连接。第三回调里分别调用了uv_ip4_name和uv_ip6_name处理不同地址族比直接inet_ntop更贴合 libuv 的封装习惯。4.2 观察回调的异步特性你在代码里加两行打印就能验证整个过程确实是异步的printf(main: 提交完成继续执行\n);运行后输出顺序会显示uv_getaddrinfo返回后主线程立刻继续执行而回调是在uv_run进入事件循环之后才被触发。这一步的体验很直观主循环没有被 DNS 解析阻塞。还可以在uv_run之前加一个uv_timer_t定时 1 秒打印一次你会发现定时器在 DNS 解析完成之前照常触发这就证明了事件循环依然活着。4.3 同步模式对比把上面对uv_getaddrinfo的调用改成同步模式uv_getaddrinfo_t resolver; int rc uv_getaddrinfo(NULL, resolver, NULL, example.com, NULL, NULL);回调为 NULLloop 也传 NULLuv_getaddrinfo会阻塞直到解析完成然后直接返回rc就是解析结果状态码。如果你在结果非空时记得调用uv_freeaddrinfo(resolver.addrinfo)这种模式就能当普通同步接口用。实测对比很容易看出差异——如果在脚本里传入一个注定超时的域名同步版本会卡住整个进程异步版本则能继续处理其他任务。这也是为什么网络服务里几乎永远不要用同步模式。5. 性能、并发与调优5.1 线程池是最容易被忽略的瓶颈libuv 的线程池是一个公共资源不是 DNS 专属。它同时服务文件操作部分类型、uv_queue_work注册的自定义 CPU 任务、以及 DNS 解析。默认UV_THREADPOOL_SIZE是 4这意味着整个进程同一时间最多只有 4 个这样的任务在跑。我把这个模型叫做“一个池子、多种租户”。假设你的服务里既有文件解析任务又有大量 DNS 查询某个时刻 4 个线程都在跑慢 DNS 查询文件操作的完成回调就会被拖住反过来如果线程池被文件任务打满DNS 查询就要排队。这在压测时经常表现为DNS 查询本身只用了 50ms但提交到完成的总耗时变成了 300ms因为前面排了 3 个任务。常见的缓解思路有三个提高UV_THREADPOOL_SIZE但不要无脑调到很大线程切换和内存开销会增长。自己实现 DNS 缓存把高频域名变成内存直查减少线程池占用。对可预见的慢查询做超时熔断避免某一次 DNS 故障拖垮整个池子。5.2 调整线程池大小UV_THREADPOOL_SIZE是一个环境变量代码里也可以用uv_os_environ设置后重新初始化 loop。在程序启动早期设置比较稳妥UV_THREADPOOL_SIZE16 ./my_server官方支持的范围是 1 到 1024。实际经验是大多数服务不需要超过 8因为线程池里的任务大多是短小的系统调用。只有 DNS 查询特别多且解析器响应慢的情况下才值得提高。但你要记住一个原则线程池是共享资源提高它的容量等于把所有租户的并发额度都提高了连带影响的是每个任务调度时带来的上下文切换开销。5.3 缓存才是治本之策libuv 本身不提供 DNS 缓存它每次都调用系统getaddrinfo。系统层面是否有缓存取决于 nscd 等服务是否开启这在服务器环境里不一定有保证。自己实现缓存的时候我建议至少做到三点按域名分桶、记录 TTL、失败结果短时间缓存比如 30 秒避免某个域名临时解析失败时引发雪崩式的重复查询。实际动手时缓存键最好包含ai_family和ai_socktype因为同一个域名在 IPv4-only 和 IPv6 环境下的结果可能不同。缓存的淘汰策略不用太复杂一个带时间戳的哈希表足够。我见过有人在缓存层引入完整 LRU对这种场景属于过度设计。6. 常见问题与排查技巧6.1 错误码速查DNS 解析返回的是UV_EAI_*系列错误码这里列一下实际开发中最常见到的几个错误码含义应对建议UV_EAI_NONAME找不到主机或服务检查域名拼写、搜索域配置UV_EAI_AGAIN临时解析失败通常可安全重试带上退避UV_EAI_MEMORY分配内存失败检查系统内存压力UV_EAI_FAMILY地址族不支持调整 hints 中的 ai_familyUV_EAI_CANCELED请求被取消目前实际触发场景较少判断错误时可以用uv_strerror(status)直接拿可读字符串不要自己硬编码错误消息。这个函数对UV_EAI_*系列都做了映射输出比系统gai_strerror更友好。6.2 内存释放陷阱这是 DNS API 最容易被骂“内存泄漏”的地方实际上是用错了。uv_getaddrinfo_t本身需要你在回调结束后自行释放。回调返回后libuv 已经完成了对req-node、req-service等内部字符串的清理但不会碰req本身。更重要的是res链表它是系统getaddrinfo分配的一块连续内存必须用uv_freeaddrinfo释放不能直接free(res)。我的习惯是不管解析成功还是失败先判空再决定是否释放if (res ! NULL) { uv_freeaddrinfo(res); } free(resolver-data); free(resolver);顺序上先释放res再释放resolver避免有人在释放后还访问resolver里的字段。6.3 回调到底跑在哪个线程很多人问过一个问题回调是在线程池里执行的吗答案是否定的。uv__getaddrinfo_done一定在主循环线程执行也就是你调用uv_run的那个线程。线程池里只跑uv__getaddrinfo_work这一层任务完成后通过 async 通知机制把结果“送回”主线程。这意味着你在回调里可以安全地操作 loop 上的其他 handle不需要额外加锁。想要验证当前线程是不是 loop 线程可以用这个简单技巧在提交请求前记录uv_thread_self()回调里再比较一次。没有特殊情况的话两个值应该相同。6.4 一个容易翻车的隐藏坑最后分享一个我实际踩过的坑在 DNS 回调里又发起一次 DNS 解析。这从逻辑上没问题因为回调又是在主线程执行的可以继续提交新请求。但要注意同时触发的并发量如果每次回调都同步发起 10 个新解析某些极端情况下会把线程池队列塞满导致后续事件处理延迟激增。正确的做法是限制在途的 DNS 请求数量。最简单的方案是用一个信号量计数器提交时加一回调时减一超过阈值就排队等待。这个限制值通常取线程池大小的一半到等量比较合理。另外一个环境相关的坑容器环境里/etc/resolv.conf可能指向一个不存在的 DNS 服务器导致每个域名解析都要等满超时时间。如果你在容器里发现解析特别慢先检查这个文件再结合UV_THREADPOOL_SIZE评估线程池是否被拖垮。最后说一点我自己的体会。libuv 的 DNS 模块写得非常克制它没有自己去实现 DNS 协议没有内置缓存也没有试图“优化”系统的解析行为只是把阻塞的系统调用安全地搬到线程池再通过事件循环把结果送回来。这种克制带来的最直接收益是可移植性和可靠性你不需要关心底层是 glibc 还是别的解析器行为都保持一致。实际项目里如果觉得解析慢优先去看系统解析器配置和业务层缓存libuv 本身很少是瓶颈。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑