Skip to content

fix(dns): server 层查询并发化,消除队头阻塞 (issue #223) - #233

Merged
flyhigher139 merged 1 commit into
masterfrom
codex/fix-dns-server-head-of-line-blocking-223
Sep 28, 2026
Merged

flyhigher139 merged 1 commit into
masterfrom
codex/fix-dns-server-head-of-line-blocking-223

Conversation

@flyhigher139

Copy link
Copy Markdown
Contributor

Closes #223

问题

mhost-dns/src/server.rs 的 UDP 主循环把 handle_dns_request(...).await 内联在 recv_from 分支里 —— 一条查询完成前 recv_from 不会被 poll。

上游解析超时默认 3000ms(DnsConfig.timeout_ms),所以一条慢上游查询期间所有后续查询排队等待;最坏情况吞吐 ≈ 1/上游 RTT,系统 DNS 解析近乎停摆。用户感知为「部分网站突然打不开、过几秒恢复」,尤其在某个上游 DNS 抖动时。

proxy 层早在 #76 就做了 per-query socket + spawn + 并发信号量,server 层没有对应模型。

改动

主循环改成与 proxy.rs 一致的分发模型:

修复前 修复后
查询处理 内联 await,串行 每查询独立 task
接收缓冲 Vec<u8>(跨 await 借用) 栈上 [u8; 4096] + Bytes::copy_from_slice
并发控制 无 Semaphore,上限 1024(对齐 proxy)
在途任务 无(串行) JoinSet,shutdown 时 abort_all
每包读锁 每 task 一次 每包一次

要点:

  • 限流在 spawn 之前(try_acquire_owned),且刻意放在拷贝 / 读 resolver 之前 —— 命中上限时那些 per-query 开销全是白做的。同 proxy 的模型。
  • resolver_slot 的读提到 spawn 之前,从「每 task 一次读锁」降为「每包一次」(只是原子 ref bump)。hot-swap 逐查询生效的语义不变。
  • shutdown 走 abort_all + drain,stop() 立即返回,不被最长 3000ms 的上游超时拖住 —— 这条在应用退出 / 关闭 DNS 模式的路径上尤其重要(见 AGENTS.md 的 exit / DNS cleanup 契约)。顺带消除了 detached task 对着已 drop 的 socket 刷无意义 warn 的问题。
  • 顺带去掉 build_answer_response 的 answer 双重 clone(参数改收 &Record,clone 只发生在 add_answer 内部那一次),cache-miss 的 answer 查询每次省一次 Record 分配。

为什么不需要 proxy 那套 per-query 临时 socket:proxy 需要是因为它 connect() 后要在同一个 socket 上 recv 回包,并发 query 会互相把回包读串。server 层是「收包 → 处理 → send_to 原地址」的无状态发送,没有 recv 配对,共享同一个 socket 并发 send_to 是安全的。代码注释里记了这个差异。

验证

核心回归测试在修复前的串行实现上验证过会失败:

a slow upstream query must not head-of-line block other queries:
fast query took 1.204152209s, expected < 500ms

正好是预测的 ~1200ms;修复后是毫秒级。(做法:git checkout HEAD -- server.rs 回到旧主循环,套上测试,跑完再恢复。)

新增测试:

  • test_dns_server_no_head_of_line_blocking —— mock upstream 按名字分流,slow.example.com 睡 1200ms、其他立即应答;用通知通道确定性等到 slow 查询确实打到上游(不靠 sleep 猜,否则 server 还没收包就发 fast 会假通过),再断言 fast 查询 500ms 内返回。
  • test_dns_server_concurrency_capped —— 黑洞 upstream + 洪水,断言 available_permits() 归零且 server 不 panic。
  • test_dns_server_stop_aborts_inflight —— 断言 stop() 在 500ms 内返回且 is_running() == false。

CI 门槛(均本地跑通):

cargo fmt --all -- --check                                        ✅
cargo clippy --workspace --all-targets -- -D warnings            ✅
cargo test --workspace --all-features   606 passed / 0 failed     ✅

三个新测试连跑 8 次稳定。flood 测试 client socket 用完即丢(不持有 2048 个 fd);本测试 fd 峰值 ~1026,主要来自 hickory 为每个在途查询开的上游 socket,与已有的 test_proxy_concurrency_capped 同量级(后者峰值 ~3074,长期 CI 绿)。

已知取舍

  • 并发化后响应不再保证与请求同序,并发同域查询可能重复打上游(cache stampede)—— DNS 语义与用户体验上可接受,未做 single-flight 合并。
  • 并发上限取常量 1024,未进 DnsConfig(避免动 serde 与前端,控制在 issue 范围内)。
  • recv_from 出错仍按现状向上传播终止 loop,未加 proxy 那样的 backoff,保持现有错误语义。
  • 未引入 TCP 监听(代码里的 TODO 保持原样)。

🤖 Generated with Claude Code

UDP 主循环此前把 handle_dns_request(...).await 内联在 recv_from 分支里,
一条撞上慢上游的查询(config.timeout_ms 默认 3000ms)没返回之前
recv_from 根本不会被 poll,所有后续查询全部排队 —— 最坏吞吐 ≈ 1/上游 RTT。
用户感知为「部分网站突然打不开、过几秒恢复」。

改成与 proxy.rs(#76)一致的分发模型:

- 主循环只做 recv_from + 分发,每个查询交给独立 task;
- 请求字节用 bytes::Bytes 拷贝出栈上 4 KiB 缓冲(对齐 proxy 的 P-R5 / P-R4),
  消除「原 buffer 被慢上游 await 阻塞住」的问题;
- Semaphore 限流(MAX_CONCURRENT_CLIENT_QUERIES = 1024,与 proxy 对齐),
  try_acquire_owned 发生在 spawn **之前**,且刻意放在拷贝 / 读 resolver 之前 ——
  命中上限时那些 per-query 开销全是白做的;
- JoinSet 管理在途 task,shutdown 走 abort_all + drain:stop() 立即返回,
  不被最长 3000ms 的上游超时拖住(见 AGENTS.md 的 exit cleanup 契约),
  同时避免 detached task 对着已 drop 的 socket 刷无意义 warn;
- resolver_slot 的读提到 spawn 之前,从「每 task 一次读锁」降为「每包一次」,
  hot-swap 逐查询生效的语义不变。

server 层与 proxy 不同,不需要 per-query 临时 socket:socket 只用于无状态的
send_to(没有 connect() + recv 配对),共享并发发送是安全的。

顺带去掉 build_answer_response 的 answer 双重 clone(参数改收 &Record,
clone 只发生在 add_answer 内部那一次),cache-miss 的 answer 查询每次省一次
Record 分配。

测试:
- test_dns_server_no_head_of_line_blocking —— 已在修复前的串行实现上验证过
  会失败(fast 查询耗时 1.204s,阈值 500ms),修复后为毫秒级;
- test_dns_server_concurrency_capped —— 洪水下断言 available_permits() 归零;
- test_dns_server_stop_aborts_inflight —— 断言 stop() 在 500ms 内返回。

flood 测试里 client socket 用完即丢,不持有 2048 个 fd。本测试 fd 峰值
~1026(主要来自 hickory 为每个在途查询开的上游 socket),与已有的
proxy 并发测试同量级(后者峰值 ~3074,长期 CI 绿)。

cargo fmt / clippy --workspace --all-targets -D warnings / 全量测试(606)全绿。
@flyhigher139

Copy link
Copy Markdown
Contributor Author

Review 结论:可合并(2 个 P3 小项 + 2 个观察,均不阻塞)

已在本地验证(PR 分支的临时 worktree 中):cargo clippy -p mhost-dns --all-targets --all-features -- -D warnings clean、cargo fmt --all -- --check clean、cargo test -p mhost-dns --all-features 160 passed;三个新测试重复跑 5/5 稳定。

已验证的关键点

  • 并发模型正确:send_to 是无状态发送(没有 proxy 那种 connect() + recv 配对),共享 Arc<UdpSocket> 并发发送安全。PR 里对「为什么不需要 per-query 临时 socket」的解释成立。
  • permit 生命周期严密:try_acquire_owned 在 spawn 前拿、move 进 task、abort/完成时自动归还,中间没有任何 early-return 会漏掉 permit。
  • shutdown 链路符合 AGENTS.md 的 exit / DNS cleanup 契约:abort_all + drain 让 stop() 不被 3000ms 上游超时拖住,且有专门测试(test_dns_server_stop_aborts_inflight)钉住 500ms 上限。
  • hot-swap 语义未变:resolver 每包 read().clone(),refresh 任务的换入对后续查询仍生效。
  • build_answer_response 签名改动:唯一调用点已同步更新,clone 确实只剩 add_answer 内部一次。
  • 回归测试设计好:HOL 测试用通知通道确定性等待「slow 查询已打到上游」再计时,不是靠 sleep 猜;flood 测试分批发包对抗内核 UDP 缓冲丢包,时序假设都写进了注释。PR 里「修复前 fast 查询耗时 1.204s」的失败记录与 1200ms mock 延迟吻合(未独立复现旧实现上的失败,但证据可信)。

非阻塞发现

1. [P3] recv 错误路径与 shutdown 分支不对称。
recv_from 出错时主循环直接 ? 返回,inflight 这个 JoinSet 被 drop —— JoinSet 的 drop 是 detach 语义,在途 task 会继续跑完(能正常回包、permit 由 task 释放,行为无害),但不会像 shutdown 分支那样被确定性 abort。recv 错误实际意味着 socket 真坏了,量级极小;建议顺手加一行注释说明这是有意的,或统一成 abort,避免未来读者困惑。

2. [P3] permit 耗尽的 continue 跳过了 try_join_next 回收。
洪水场景下已完成 task 的 output 会短暂滞留 JoinSet(上限约 1024 条),直到下一个成功 spawn 的包触发回收。内存影响可忽略,nit。

3. [观察] fd 压力从 1 变成 ~1026。
并发化后 hickory 为每个在途上游查询开一个 socket,1024 上限下峰值 ~1026 fds —— 与 proxy 层同模型同量级(#76,长期 CI 绿),且被 semaphore 有界,macOS GUI 进程的 fd 软限(10240)内安全。只是相对串行实现这是新的资源轮廓,记录在案即可。

4. [观察] MAX_CONCURRENT_CLIENT_QUERIES 在 server.rs 和 proxy.rs 各定义一份。
值都是 1024,靠注释「对齐」维持,没有编译期关联。两处语义将来可能分化,保持分开也合理;若想钉死可以让 server 层引用 proxy 层的 pub const。

总结

修复方向、实现、测试三者都对:并发化模型与 proxy 层(#76)一致,已知取舍(cache stampede 不做 single-flight、上限不进 DnsConfig、不加 backoff)都在 PR 里明说了且合理。建议合并;第 1、2 点可以作为合并前顺手的小修或后续 issue。

@flyhigher139
flyhigher139 merged commit 17c2c3f into master Sep 28, 2026
4 checks passed
@flyhigher139
flyhigher139 deleted the codex/fix-dns-server-head-of-line-blocking-223 branch September 28, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[perf] DNS server 查询串行处理:一条慢上游查询队头阻塞全部解析

1 participant