简介:这是面向C/C++开发者的c-ares异步DNS解析库应用资料,重点解决网络编程中的非阻塞域名解析、自定义DNS服务器等实际问题。压缩包为gz格式,共380个文件,整体约1.5MB;内容以C源文件、头文件、构建脚本和测试样例为主,包含configure、Makefile相关模板及多组解析样例,便于在Linux环境编译与二次开发。其中通过ares_set_servers_csv等接口演示DNS服务器设置方法,并展示不同DNS服务器解析同一域名可能得到不同IP的结果,有助于理解异步查询流程、应答差异与常见使用陷阱。可下载后编译运行并结合示例对比验证,缩短从接口了解到工程落地的时间。已有300人学习下载,适合希望快速上手c-ares的C/C++网络工程师和需要完善域名解析模块的开发者。
1. c-ares 在 C/C++ 里到底解决了什么:同步解析卡线程的问题
很多人第一次遇到 c-ares,是在一个“看起来不该慢”的地方:业务线程要连一个数据库或对象存储,先调一次 getaddrinfo 做域名解析,结果上游 DNS 抖动一下,整个线程卡了五秒。五秒对于在线服务来说已经是事故了。同步解析的麻烦就在于它不给你选择权——只要解析没结束,调用线程就得等着。c-ares 是异步 DNS 解析库,它把“发查询、等响应、重试、拿结果”拆成事件驱动流程,你的线程可以去干别的,结果到了再通过回调通知你。这篇文章就是讲 c-ares 在 C/C++ 里怎么落地:从 channel 初始化、事件循环接入、参数调优,到几个能让你排查到半夜的坑。适合自己写网络层、网关、消息队列客户端,或者任何不想为 DNS 单独起线程池的服务端开发者。
2. 先搞懂 c-ares 的工作方式:channel、socket 状态机与两种接入姿势
2.1 c-ares 不是“回调库”,而是一个可嵌入的事件状态机
初看 c-ares 的 API,容易把它理解成“异步 = 注册回调 = 完事”。实际上 c-ares 的核心是一个ares_channel_t(老版本直接叫ares_channel),它不是什么连接句柄,而是一整套解析器的上下文:里面装着 nameserver 列表、超时参数、重试次数、search 后缀、内部 socket 集合和 pending 查询队列。你在这个 channel 上发起的每个查询,都会经历一个完整的状态流转:
- 构造 DNS 查询报文,选择一个 nameserver 发出 UDP 请求;
- 把一个 socket 标记为“可读等待”;
- 收到响应后做报文校验、解析、触发回调;
- 超时没收到,按重试策略换服务器或重发;
- 多轮失败后,以错误码触发回调。
这个状态机需要“外部推动”才能前进。推动方式只有一个:在 socket 可读/可写时调用ares_process_fd或ares_process。换句话说,c-ares 把解析逻辑全部收敛在库内部,但把“什么时候驱动它”完全交给你。理解了这一点,你就明白为什么不能像用阻塞getaddrinfo那样用 c-ares,它不是替代品,而是另一种模型。
2.2 两种接入方式:外部事件循环 vs 专用线程
我看到的实际项目里,接入 c-ares 基本是两种姿势。
第一种是把 c-ares 的 fd 集合混入自己已有的事件循环。每次迭代开始时调用ares_fds拿到当前需要监听的读写 fd 集合,调用ares_timeout拿到最近一个超时时间,然后和你的其他事件源一起交给select、poll或epoll。事件循环醒来后,把就绪的 fd 集合再喂给ares_process。这种方式的优点是不引入额外线程,和现有异步框架天然融合,适合网关、代理层、网络库这类对线程模型敏感的项目。
第二种是单独开一个线程,在线程里跑死循环:ares_fds→select→ares_process。这种方式代码简单,适合只想把同步改异步、不想动主架构的场景。但代价是你引入了一个专职线程,且这个线程和业务线程之间要摆消息队列做通信。如果只是为了让一个查询不阻塞,用一个线程加一个队列并不比直接调用阻塞解析便宜。
我在新项目里一般优先走第一种:把 DNS 解析的驱动权交给已经存在的事件循环,避免“线程爆炸”。线程池加阻塞解析能做,但线程数量、fd 数量、任务排队都会变成你新的维护负担,远不如一个非阻塞状态机来得干净。
2.3 为什么不像 getaddrinfo 那样直接开线程池
有人会问:我开一个线程池,里面放阻塞 getaddrinfo,不一样是异步的吗?能一样,但有几个长期隐患:第一,DNS 解析可能慢在天上,DNS 服务器超时重试一套下来能到十几秒,线程池很容易被这种慢查询占满;第二,线程池方案难以做全局层面的约束,比如最多允许多少个在途 DNS 查询、统一的超时策略、统一的 nameserver 配置;第三,线程池里的任务一旦发起,你无法取消。c-ares 的 channel 上可以调用ares_cancel取消正在进行的查询,这个能力在服务降级、快速失败时非常有用。所以选型结论是:追求架构可控选 c-ares,追求快速上线选线程池,但后者迟早要回来补债。
3. 最小可跑工程:初始化、发起查询、驱动事件循环的一次完整迭代
3.1 初始化 channel 与选项:不是所有 flag 都必须开
下面这段是 c-ares 查询的骨架,我用的是基于 select 的经典写法。先把 channel 初始化出来,再发起一个域名解析,最后在事件循环里驱动它。代码可以在任何带 libcares 的 Linux 环境下编译运行,链接参数是-lcares。
#include <ares.h> #include <netdb.h> #include <stdio.h> #include <string.h> #include <sys/select.h> static void dns_callback(void *arg, int status, int timeouts, struct hostent *host) { (void)arg; if (status != ARES_SUCCESS) { fprintf(stderr, "resolve failed: %s (timeouts=%d)\n", ares_strerror(status), timeouts); return; } if (host && host->h_name) { printf("resolved: %s\n", host->h_name); } /* 注意:hostent 的内存是库内部管理的,回调返回后就不应该再持有 */ } int main(void) { ares_channel_t *channel = NULL; struct ares_options opts; memset(&opts, 0, sizeof(opts)); /* timeout: 单次尝试的等待时间,毫秒;tries: 总尝试次数 */ opts.timeout = 2000; opts.tries = 3; opts.flags = ARES_FLAG_STAYOPEN; int status = ares_init_options(&channel, &opts, ARES_OPT_TIMEOUTMS | ARES_OPT_TRIES | ARES_OPT_FLAGS); if (status != ARES_SUCCESS) { fprintf(stderr, "ares_init_options failed: %s\n", ares_strerror(status)); return 1; } /* 发起一次 IPv4 主机名解析 */ ares_gethostbyname(channel, "www.example.com", AF_INET, dns_callback, NULL); /* 事件循环:把 c-ares 的 fd 和超时交出去,用 select 等待 */ while (1) { fd_set read_fds, write_fds; FD_ZERO(&read_fds); FD_ZERO(&write_fds); int nfds = ares_fds(channel, &read_fds, &write_fds); if (nfds == 0) { /* 没有在途请求,跳出循环 */ break; } struct timeval tv; struct timeval *tvp = ares_timeout(channel, NULL, &tv); int res = select(nfds, &read_fds, &write_fds, NULL, tvp); if (res < 0) { perror("select"); break; } /* 把就绪的 fd 集合交还给 c-ares,让它推进状态机 */ ares_process(channel, &read_fds, &write_fds); } ares_destroy(channel); return 0; }这段代码的逻辑是:初始化后发起查询,随后进入一个while (1)循环。每轮循环开始时用ares_fds取出所有需要监听的 socket,用ares_timeout算出最近一个超时时间;select返回后调用ares_process,把就绪的读写集合还给库。库内部会读 UDP 响应、做解析、触发回调。当所有查询都结束、没有不再需要监听的 fd 时,ares_fds返回 0,循环退出。
参数说明:
opts.timeout = 2000:单次尝试的等待时间,单位毫秒。不要设得太短,否则公网环境下的正常抖动会导致解析失败。opts.tries = 3:总尝试次数。注意是总次数,不是重试次数。第一次发出去也算一次,所以tries=3意味着最多发 3 遍查询。ARES_FLAG_STAYOPEN:让 c-ares 复用内部 socket,而不是每次查询都新建、结束后关闭。在高频查询场景下,这个 flag 能省掉大量 socket 系统调用。nfds:传给select的第一个参数,表示需要遍历的最大 fd 编号加一。不要偷懒填一个固定大值,否则会有多余的系统调用开销。
3.2 实际接入现有事件循环的三个改造步骤
把上面这段代码嵌进你的项目时,不要直接搬循环,而是做三步改造。第一步:初始化操作放在程序启动阶段,channel 作为全局上下文长期持有,不要每次请求都新建,代价太高。第二步:把ares_fds和ares_timeout的结果合并进你自己已有的 fd 集合和超时逻辑。多数异步框架都有“注册 fd 事件”和“注册定时器”的接口,把 c-ares 的 fd 注册成读事件、把ares_timeout注册成一次性定时器就行。第三步:在你的事件分发函数里,当对应的 c-ares fd 可读时,构造一个只有那个 fd 就绪的fd_set调用ares_process_fd,而不是每次都调ares_process。
/* 事件回调里处理 c-ares 的读事件 */ void on_socket_readable(int sock) { ares_process_fd(channel, sock, ARES_SOCKET_BAD); }ares_process_fd的第二个参数是读就绪的 fd,第三个是写就绪的 fd,如果某一端没有就绪事件,传ARES_SOCKET_BAD。这个接口比ares_process更精确,适合在事件循环中精确定位到具体 socket 的场景。
3.3 新项目优先用 ares_getaddrinfo
上面的例子用ares_gethostbyname是为了讲清楚最基本的状态流。实际做新项目时我更推荐ares_getaddrinfo,它一次调用能同时拿到 IPv4 和 IPv6 的结果,不需要你根据AF_INET/AF_INET6分别发查询,回调里返回的是标准的struct ares_addrinfo,再用ares_freeaddrinfo释放。
static void addrinfo_callback(void *arg, int status, int timeouts, struct ares_addrinfo *result) { if (status == ARES_SUCCESS) { /* 遍历 result->nodes,取出需要的信息 */ } if (result) { ares_freeaddrinfo(result); } } ares_getaddrinfo(channel, "www.example.com", NULL, NULL, addrinfo_callback, NULL);ares_getaddrinfo的第四个参数是ARES_AI_CANONNAME之类的 flags,按需传NULL即可。它在内部管理着更复杂的双栈查询流程,省得你自己处理“v4 失败要不要等 v6”这类策略问题。
4. 三个必调参数与四个高频踩坑现场
4.1 参数怎么设:timeout、tries、ndots
c-ares 默认的超时和重试策略偏保守,直接裸奔会遇到“解析特别慢”的问题。最值得先调的三个参数如下:
| 参数 | 作用 | 建议配置 |
|---|---|---|
timeout | 单次查询等待响应的时间,毫秒 | 局域网内 500-1000,公网 1500-3000 |
tries | 包括首次在内的总尝试次数 | 内网 2,公网 3 |
ndots | 名字里包含至少几个点才直接按 FQDN 查询 | 默认 1,内网场景建议调大 |
ndots这个参数影响最大也最容易被忽略。它的作用是:当你要解析的名字里点的数量小于等于阈值时,会先尝试按 search 列表里的域名拼成完整主机名去查,查不到再回退。举例来说,你在配置里写了search corp.example.com,然后代码里调用ares_gethostbyname("db1"),c-ares 会先查db1.corp.example.com,失败后才查db1。每次失败都意味着一次完整的超时等待,整个流程慢得离谱。如果确认自己的场景下不存在短主机名需求,可以直接在初始化时把ndots设为一个大值(比如 5),强制所有带点名字都先按原样查询。
4.2 坑一:回调里做了重活,整个事件循环被拖死
现象:接入 c-ares 后,并发一高,所有解析请求都变慢,甚至超时。原因:回调函数是在ares_process调用栈内触发的,回调里如果做了业务处理、写日志、拿锁、甚至发起新的同步请求,就会阻塞住整个事件循环。解决:回调里只做两件事——把结果拷贝到自己管理的结构体里,然后往任务队列里投递消息;真正的业务逻辑放到下一步执行。这一点和所有事件驱动模型的要求一致,但 c-ares 的回调出现时机容易让人放松警惕。
4.3 坑二:channel 的生命周期没管理好,崩溃没有现场
现象:程序退出或 channel 销毁时,偶尔出现 use-after-free,堆栈在 ares 内部。原因:调用了ares_destroy(channel),但 channel 上还有尚未完成的查询,这些查询的 socket 还在事件循环里注册着。事件循环下一次 select 返回后,调用ares_process时 channel 已经不存在了。解决:销毁前先调用ares_cancel(channel)取消所有在途请求,让回调以ARES_ECANCELLED把所有未完成事件全部清掉;之后再做ares_destroy。我的习惯是:先把 channel 从事件循环里摘掉,再 cancel,再 destroy,三步顺序不能乱。
4.4 坑三:hostent 结果不能保存到回调外面用
现象:回调里把struct hostent *存入全局结构体,下次查询完成后,之前保存的内容变了。原因:c-ares 内部复用结果缓冲区,回调返回后,之前的hostent内存随时可能被新查询覆盖。解决:回调里用memcpy把 IP 地址和主机名拷贝到自己管理的堆内存中。这个坑属于“看起来能跑、偶尔数据错乱”的类型,非常难排查,我见过有人把这种问题归因到并发上,绕了一大圈。
4.5 坑四:DNS 查询慢,但 strace 里看不到任何网络包
现象:换到 c-ares 后,解析确实不阻塞线程了,但整体耗时仍然很大,抓包发现 UDP 53 端口迟迟没有流量。原因:初始化时没有读本机/etc/resolv.conf以外的配置,c-ares 默认会尝试读 hosts 文件和环境变量,但某些嵌入式环境里这些文件路径不对,于是查询进了错误的流程。解决:显式调用ares_set_servers_csv(channel, "nameserver_ip"),把 DNS 服务器地址写死,绕开环境探测逻辑。生产环境里我一般都会显式配置 nameserver,而不是依赖环境里的默认配置——这样出问题时可复现性高得多。
5. 进阶用法:自定义 nameserver、sockopt 回调与多线程边界
5.1 显式控制 DNS 服务器与 search 列表
一个 channel 默认会继承系统/etc/resolv.conf的配置,但这在容器环境里经常不满足需求。某容器化的项目中有一个“必须从特定 DNS 解析特定域名”的需求,直接在代码里改 nameserver 名单是更干净的做法。
/* 用逗号分隔的字符串设置多个 nameserver,支持 IPv4 和 IPv6 */ int rc = ares_set_servers_csv(channel, "10.10.0.1,10.10.0.2"); if (rc != ARES_SUCCESS) { fprintf(stderr, "ares_set_servers_csv failed: %s\n", ares_strerror(rc)); }ares_set_servers_csv的好处是覆盖可控,不依赖环境变量。注意这个接口必须在任何查询发起之前调用,中途调用需要先用ares_cancel把在途请求清掉,否则行为不定。同样的道理,search列表也支持运行时修改,但建议统一放在初始化阶段做,运行时改动会让问题变化更难追踪。
5.2 sockopt 回调:为每个内部 socket 设置参数
c-ares 内部创建的 UDP/TCP socket 是它自己管理的。如果你对 socket 有额外要求,比如设置IPV6_V6ONLY、修改 TCP keepalive、绑定网卡,可以通过ares_set_socket_callback注册一个回调,在 socket 创建后立刻配置。这个回调的签名能拿到 socket 类型和文件描述符,但我个人用的最多的场景是设置 TCP_NODELAY 和 SO_MARK,对解析连接本身的质量做约束。
5.3 多线程边界:一个 channel 不能被多个线程同时驱动
c-ares 的 channel 本身不是线程安全的。常见误解是“既然解析是异步的,那我可以多个线程共用同一个 channel”。实际上,一个 channel 内部的 socket 集合、查询队列、超时状态都是在ares_process中修改的,两个线程同时调用ares_process就会产生竞态,表现为偶发的崩溃或查询丢失。常见做法有两个:一是每个线程维护独立的 channel,查询互不相干;二是用一个专用线程驱动 channel,业务线程把“查询请求”发给它,再把“查询结果”回调转发回业务线程。线程池方案里我一般选后者,这样全局并发和超时策略能收口在一个地方,资源占用也可控。
5.4 EDNS 与 TCP fallback 的边界
c-ares 默认使用 UDP 查询,响应包被截断时会自动重试 TCP。这个 fallback 是透明发生的,但它依赖 channel 能创建新的 TCP socket。如果你的进程对 fd 数量有严格限制,或者监控系统没给 TCP 连接数打点,高并发下可能会出现“解析偶尔超时但 CPU 不高”的怪状。排查方法很简单:在 sockopt 回调里对SOCK_STREAM类型打日志,看看 TCP 连接出现频率是否和慢查询时段吻合。若吻合,再考虑是否启用 EDNS(扩展 DNS 机制)减少截断概率,或者调大 UDP 缓冲区。
6. 验证 c-ares 真的“异步”了:故障注入与观察习惯
6.1 让解析先“变坏”,才能确认行为符合预期
接入 c-ares 之后,我做的第一件事通常不是测正常流程,而是故意制造故障。办法是把 nameserver 指到一个黑名单 IP(比如 192.0.2.1,这是一个专门用来测试的保留地址),然后发起一批查询,观察线程是否还卡得住。用ARES_OPT_TIMEOUTMS=500和tries=2配置时,单次查询最坏情况大约 1 秒内结束,且业务线程全程不被阻塞。如果此时你的业务代码里没有明显的异步效果,那大概率是调用方式写错了,应该回到第 3 章的代码骨架重新对一遍。
6.2 strace 观察系统调用序列
验证事件驱动是否真正生效,最好的工具是 strace。解析一个域名时观察系统调用:不应该出现“发 UDP 包之后线程挂起等待”的痕迹,而应该是sendto返回后,线程继续执行其他逻辑,稍后recvfrom被事件循环触发时才有读操作。如果 strace 里出现recvfrom之前线程原地阻塞了很久,说明 select 超时设得太大或事件循环有问题。connect这种调用如果出现在解析路径上,则是意外行为,在 UDP 模式里本来不该有。
6.3 一个值得保持的习惯:每次循环都重算 nfds 和超时
很多人第一次写 c-ares 事件循环时,会把nfds、ares_timeout的结果存成局部变量反复用,这是错误用法。channel 内部状态每次ares_process后都会变化,可能有新 socket 加入、旧 socket 关闭、超时时间前移。正确习惯是每一轮循环都重新调用ares_fds和ares_timeout,不要做任何缓存。这个习惯是从一次线上事故里换来的:当时为性能把 fd 集合缓存了 1 秒,结果新请求发不出去,所有解析全部超时,排查到最后才发现是缓存惹的祸。从那以后,我对 c-ares 的状态查询一律不缓存,完整重算一次的开销远小于一次事故的代价。
希望这篇实战笔记对你有帮助。c-ares 的接口不算多,但每个细节都值得按自己的业务场景调一遍,跑通了之后,DNS 解析在整个系统里会变成最安静的那一环。
本文还有配套的精品资源,点击获取