返回博客
·12 分钟阅读·productdevbook

macOS 网络层级:开发者指南

面向开发者的 macOS 网络栈导览:从 socket(2) 到 Network.framework,逐层附上可观测性提示。

  • Developer tools
  • macOS
  • Networking
  • Deep dive

你在调试一个慢 API 调用。服务器日志说响应 80 ms 内发出。用户说页面用了四秒。在线和 UI 之间的栈某处时间消失了——而"网络"作为答案太宽泛没用。要知道往哪看,你需要 macOS 网络层的脑内地图,从底部的 BSD socket 调用到顶部应用实际用的框架。

这是一份开发者巡礼,关于一个数据包怎样在 macOS 上变成 URLResponse、每一层在哪记日志和插桩、每一级你伸手去拿哪个工具。它长是因为栈分层有充分理由,每一层都值得理解。

macOS 网络层,自顶向下

自底向上看,macOS 网络栈大致这样:

  1. 硬件和内核驱动 — IOKit 系列如 Wi-Fi 的 IO80211Family、以太网的 IOEthernetFamily
  2. BSD 网络栈 — IP、TCP、UDP、socket 缓冲、用于包过滤的 pf
  3. 网络扩展 — 内容过滤器、数据包隧道提供方、DNS 代理、新的 nw_path 机制。
  4. socket(2) 系统调用 — 最低的用户空间 API;现在很少直接调。
  5. CFNetwork / Network.framework — 关于连接、路径和监听器的现代 C/Swift API。
  6. NSURLSession(URLSession — Foundation 的 HTTP/HTTPS 客户端。几乎所有应用的默认。
  7. 应用级库 — Alamofire、AFNetworking、gRPC-Swift、WebSocket 库、Apollo,任何包装 URLSession 的。
  8. 你的视图层 — SwiftUI、UIKit、AppKit 视图,最终渲染响应。

多数应用开发者把时间花在第 6–8 层,把下面所有的当成"网络"。在出问题之前没事。当你在追真正的 bug——TLS 握手超时、请求卡在代理认证、来自依赖的可疑流量——你需要知道每个 macOS 网络层提供什么、在哪记日志。

下层栈:BSD、网络扩展、socket

底下三层可编程层贴得很紧,作为一整块看比较合算。

第 2 层:BSD 栈

当一个数据包到达 en0 时,内核让它走 BSD 网络栈。它决定包是否给你(匹配目的 IP 和端口到一个 socket),让它过 pf(BSD 包过滤器,macOS 防火墙),并在接收 socket 的缓冲上排队。

这一层是 tcpdumpWireshark 住的地方。它们接到 BPF(Berkeley Packet Filter)设备上,看到内核做太多动作之前的原始包。

sudo tcpdump -i en0 -n 'tcp port 443'

BSD 栈层有用的工具:

  • netstat -an — 监听 socket、已建立连接、监听端口
  • netstat -rn — 路由表
  • pfctl -s rules — 当前 pf 规则集(除非启用了防火墙否则常空)
  • ifconfig / networksetup -listallnetworkservices — 接口状态

你在怀疑 IP/TCP 层有问题时在这里插桩:TCP 重传风暴、路由抖动、pf 规则在挡流量。

第 3 层:网络扩展

网络扩展是第三方应用和 Apple 自己在不写 kext(已弃用)的情况下钩数据路径的方式。大类别:

  • 内容过滤提供方 — 看流元数据并决定允许/拒绝。Little Snitch 和 LuLu 用这个。
  • 数据包隧道提供方 — 完整 VPN 风格隧道。Tailscale、WireGuard 应用、NordVPN 等。
  • 应用代理提供方 — 按应用通过远端的路由。
  • DNS 代理提供方 — 在解析前拦截 DNS 查询。

如果你的流量去了你不预期的地方,网络扩展是更可能的元凶之一。检查 systemextensionsctl list 看活跃扩展。

第 4 层:socket(2) 及其朋友

经典 POSIX API:socket()bind()connect()send()recv()close()。在 macOS 上你几乎不再直接写这种代码——Apple 不鼓励新应用用,因为它不跟 nw_path 集成处理连接变化、不处理 Happy Eyeballs(IPv4/IPv6 双栈)、也不免费拿到 TLS。

但它仍然是基础。每个更高层 API 最终都调 socket()。当你从内核读进程归因——通过带 PROC_PIDFDSOCKETINFOproc_pidinfo,或通过 /dev/bpf——你读的是 socket 调用建立起来的状态。

你在这里插桩用:

  • lsof -i -P -n — 系统上每个打开的 socket,带 PID 和进程名
  • proc_pidinfo API — Apple 祝福的"这个 PID 打开了哪些 socket?"问法
  • dtracedtruss — 系统调用级跟踪(未签名脚本要求 SIP 关闭)
  • Instruments Network 模板 — 包装 socket 调用的 dtrace 探针

ova 这种按应用带宽监控读这一层。它约 1 Hz 采样内核按 PID 的 socket 和接口字节计数器,把每个 PID 的字节归到它的父应用 bundle,并把得到的时序本地存。没有包检视——字节来自内核暴露给 nettop 的同一组计数器。

中层栈:Network.framework 和 URLSession

多数应用级网络在这两个 API 里。它们血脉相连但解决不同问题。

第 5 层:CFNetwork 和 Network.framework

CFNetwork 是更老的 C API。Network.framework(2018 引入,对 Swift 友好)是它的现代替代品,是 Apple 对任何新底层网络代码的推荐。

Network.framework 给你:

  • NWConnection 做出站连接(TCP、UDP、QUIC、TLS、自定义协议)
  • NWListener 接受入站连接
  • NWPathMonitor 观察路径可用性(Wi-Fi vs 蜂窝 vs 以太网)
  • NWBrowser 做 Bonjour 发现
  • TLS、代理和协议栈,可作为 NWProtocolFramer 组合

你在写点对点协议、自定义二进制协议、多路径 TCP,或者为 macOS 实现服务端软件时直接用它。所有 HTTP 形态的东西,往上一层。

这一层的插桩:NWConnection.stateUpdateHandlerNWPathMonitor 看路径变化,以及统一日志子系统 com.apple.network(关于统一日志下面更多)。

第 6 层:URLSession

如果你应用讲 HTTP,几乎肯定用 URLSession。它处理 HTTP/1.1、HTTP/2、HTTP/3(QUIC,支持的地方)、TLS、重定向、缓存、cookie 和认证挑战。它也跟 NSURLProtocol 集成,所以你能在测试中拦截它的流量。

这里最有用的插桩 hook 是:

  • URLSessionDelegateURLSessionTaskDelegate — 看每个重定向、挑战和完成
  • URLSessionTaskMetrics — 按任务的时序分解:DNS、connect、secure-connect、request、response
  • URLSessionConfiguration.protocolClasses — 注册一个 NSURLProtocol 子类在测试里记录每个请求
  • 子系统 com.apple.CFNetwork 上的 os_log — CFNetwork 发往的统一日志通道

URLSessionTaskMetrics 被低估。它精确告诉你每个任务在 DNS 解析 vs TCP connect vs TLS 握手 vs 请求传输 vs 服务器处理 vs 响应传输上花了多少时间。如果你慢 API 调用问题实际是慢握手,这就是证据数据。

func urlSession(_ session: URLSession,
                task: URLSessionTask,
                didFinishCollecting metrics: URLSessionTaskMetrics) {
    for tx in metrics.transactionMetrics {
        print("DNS:", tx.domainLookupEndDate?.timeIntervalSince(tx.domainLookupStartDate ?? .distantPast) ?? 0)
        print("Connect:", tx.connectEndDate?.timeIntervalSince(tx.connectStartDate ?? .distantPast) ?? 0)
        print("TLS:", tx.secureConnectionEndDate?.timeIntervalSince(tx.secureConnectionStartDate ?? .distantPast) ?? 0)
    }
}

看 ova 实战

一眼可瞄的菜单栏带宽监控——本地、签名、约 3 MB。

下载 macOS 版

栈顶:库和视图层

最高两层是多数应用代码花时间的地方。这里的 bug 容易被误以为是网络问题。

第 7 层:应用级库

多数应用把 URLSession 包在 Alamofire 或自定义网络模块这种东西里。这些库通常暴露一个请求完成信号你可以钩起来记日志。它们也常有自己的重试、节流和排队逻辑,可能掩盖更下层的问题。

调试时:

  • 检查库是不是在自动重试——三次重试能把一个失败请求变成四行日志和线上四倍字节。
  • 检查超时配置。默认值常令人意外(URLSession 资源超时 60 秒)。
  • 检查隐藏并行。OperationQueuemaxConcurrentOperationCount = 1 会以你可能没预期的方式串行化请求。

第 8 层:视图层

有时 bug 根本不在网络。响应 80 ms 到了,但视图用了 3.9 秒渲染,因为有人在主线程上塞了一个同步 JSON 解码,或者布局 pass 在 800 个 cell 上级联。Instruments 的 Time Profiler 是合适工具——它会告诉你主线程时间是花在 JSONDecoder.decode 还是 UIView.layoutSubviews

教训:在你测量之前别怪网络。URLSessionTaskMetrics 加 Time Profiler 跟踪在 10 分钟内能解决多数"慢页面"争论。

统一日志

横跨每一层的是统一日志(os_log / Logger)。网络相关子系统包括:

  • com.apple.CFNetwork — URLSession 级事件
  • com.apple.network — Network.framework 事件
  • com.apple.networkd — 网络守护进程事件
  • com.apple.cfnetwork.NSURLConnection — 旧版 NSURLConnection 日志

实时读网络日志:

log stream --predicate 'subsystem == "com.apple.network"' --level debug

读上一小时的 CFNetwork 事件:

log show --last 1h --predicate 'subsystem == "com.apple.CFNetwork"'

当问题是"我请求根本没离开设备"时这极有帮助。日志会告诉你路径未满足、代理自动配置脚本返回了 DIRECT、或 TLS 服务器证书因特定原因码被拒。

选合适工具,加几个习惯

"我在这一层有问题,哪个工具?"的速查:

症状可能层第一工具
应用打不到任何主机2–3(BSD / 网络扩展)pingtraceroutesystemextensionsctl list
一个主机不可达2(BSD / DNS)dignslookupdscacheutil -q host
慢握手5–6(TLS / URLSession)URLSessionTaskMetrics、统一日志
请求从未离开设备5–6(Network.framework)统一日志 com.apple.network
不知道按应用流量预算4(内核计数器)nettopova
可疑目的地2–3(BSD / NE 过滤器)lsof -i、内容过滤器
随机响应体损坏6+(HTTP)URLProtocol 拦截器、抓包
快速响应后慢渲染8(视图)Instruments Time Profiler
按应用带宽视图
ova 在 socket 层读内核计数器,把辅助 PID 归并到父应用下,给你菜单栏实时速率加可拖动历史——当"这甚至是网络吗?"是要回答的第一个问题时有用。

跨这些都回报的几个习惯:

  1. 总是记 taskMetrics release 也记。它便宜,能省下后来几小时。
  2. 给每个出站请求打标签。 设一个自定义头如 X-Request-Id,把它放进你的客户端日志行和服务器日志行。现在你能连接它们。
  3. 不要相信活动指示器。 它只显示某个请求在飞,不显示是哪个或挂了多久。
  4. 冷启动和热启动分别 profile。 启动后第一个请求碰 DNS、TCP、TLS、可能还有 HTTP/3 协商。第十个请求重用连接,10–50 倍快。两个数字都重要。
  5. 小心连接合并。 HTTP/2 和 HTTP/3 会跨共享证书的主机重用单个连接。这让按主机推理更难。

收尾

macOS 网络层的栈密但可知。从底下的 socket(2) 到顶上的 URLSession 和你的视图层,每一层提供一个特定的镜头——包、流、请求、任务、帧。挑对镜头,bug 从"网络慢"塌缩成"到 api.example.com 的 TLS 握手要 1.2 秒,因为证书链每次都被重新拿"。

如果你想要一个随意的、常驻视图看哪个应用现在在用你的带宽——不在终端里跑 nettop——装 ova。它是菜单栏应用,约 3 MB,运行于 macOS 14 及以上,约 1 Hz 采样,所有东西本地存。无遥测、无远程面板、一次性付费。