把 TCP 交还给内核:TUN 代理里的 system netstack 设计与实测

TL;DR — 移动端隧道进程的内存墙让用户态 TCP 栈(smoltcp)成了负担:每条连接要背几百 KiB 的固定缓冲,还要自己处理重传、拥塞和窗口。system netstack 换了一条路:不实现 TCP,而是把 TUN 里读到的 TCP 包原地做源 NAT,改写后写回 TUN,让内核把它当成发给本机 listener 的连接;accept 出来的普通 socket 再按 NAT 表里恢复的原始五元组交给分发器。代价是每个 TCP 载荷要穿越 TUN 两次,第一版因此比 smoltcp 慢 40%,靠 GRO/GSO 批量 I/O 追回来;收益是每条连接的用户态内存从 1 MiB 降到一个约 100 字节的 NAT 表项。这篇文章记录设计、那些只有真机才会教你的坑,以及实测数字。


一、问题:移动端隧道进程的内存墙

TUN 模式的代理客户端要在用户态解析 IP 包并把 TCP 流转发出去。常见做法是跑一个用户态 TCP/IP 栈,比如 smoltcp、gVisor 的 netstack 或 lwIP。clash-rs 用的是 smoltcp。

桌面上这没什么问题。到了 iOS,情况完全不同:Packet Tunnel 运行在 Network Extension 进程里,系统给这个进程的内存上限只有几十 MB,超了直接被杀,用户看到的就是”VPN 掉线”。Android 的 VpnService 宽松一些,但仍然是后台进程的预算。

在这个预算里跑 smoltcp,有两个结构性问题:

  1. 每条 TCP 连接的固定内存。clash-rs 原版为每条 TUN 连接分配 2 × 256 KiB 的 smoltcp SocketBuffer 加 2 × 256 KiB 的 relay ring buffer,也就是 1 MiB,而且全部零初始化。64 条并发连接就是 64 MiB,一个网页打开就能把 Extension 打爆。我们后来在移动端把它收到每块 64 到 128 KiB,但线性增长的形状不变。
  2. 用户态要自己扛 TCP 的全部行为。重传、拥塞控制、窗口缩放、SACK、弱网恢复,这些内核已经做了三十年的事,smoltcp 需要在移动端的休眠、切网、弱网条件下重新被验证一遍。

而内核的 TCP 栈就在旁边,免费、成熟、内存不算在我们进程头上。问题只是:TUN 里读出来的是 IP 包,不是 socket。

二、思路:不实现 TCP,而是把 TCP 包”骗”给内核

这个思路不是我们发明的,灵感来自 sing-box 的 stack: system。核心只有一句话:

把每个 TCP 包的源地址改成一个本机可路由的地址,目的地址改成本机一个正在 listen 的端口,再写回 TUN。内核会认为这是一条发给本机的连接,替我们完成整个 TCP。

具体涉及四个地址角色:

角色 举例 说明
客户端地址 10.0.0.9:12345 App 发包时的源地址
网关地址 198.18.0.1 TUN 接口自己的地址,也是内核 listener 绑定的地址
NAT 地址 198.18.0.2 网关 + 1,改写后的源地址,必须在 TUN 前缀内,且不能是网络地址或广播地址
listener 端口 40000 内核 listener 的端口,启动时随机绑定

数据流是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
上行(App -> 远端)

App 发出 10.0.0.9:12345 -> 1.1.1.1:443
|
v TUN 读出
改写为 198.18.0.2:P -> 198.18.0.1:40000 P = NAT 表分配的端口
|
v 写回 TUN
内核路由:目的是本机地址,投递给 listener
|
v
accept() 得到一条普通 TcpStream,peer = 198.18.0.2:P
|
v 用 P 反查 NAT 表
恢复原始五元组 10.0.0.9:12345 -> 1.1.1.1:443,交给分发器 / 出站

下行(内核 listener 的回包)

内核发出 198.18.0.1:40000 -> 198.18.0.2:P
|
v 路由回 TUN,被我们读出
改写为 1.1.1.1:443 -> 10.0.0.9:12345
|
v 写回 TUN
App 看到的是"服务器直接回了我"

为什么 NAT 地址必须是网关 + 1 且在同一前缀内?因为 listener 的回包目的地址是 NAT 地址,内核要把它路由回 TUN 接口,我们才有机会读出来改写。如果 NAT 地址不在 TUN 前缀里,回包会走默认路由,整条链就断了。

UDP 不走这条路。UDP 包原样交给已有的 IP 报文编解码器和 datagram 分发路径,DNS 劫持等逻辑一个字节都不用改。ICMP 只处理一种:发给网关地址的 echo request,原地改成 reply 写回,其余丢弃。

三、每个包上发生了什么

1)解析:只做必要的校验,拒绝处理不了的形态

IPv4 只看头长、总长和分片标志。分片包直接拒绝:端口只在第一个分片里,要正确处理必须重组,我们不做。

IPv6 要走一遍扩展头链:Hop-by-Hop、Routing、Destination Options 按 8 字节单位跳过,Authentication Header 按 4 字节单位跳过,遇到 Fragment(44)或 ESP(50)拒绝,前者需要重组,后者根本看不到端口。

所有下标访问前都有长度检查。这一层是纯字节操作,没有 I/O,没有共享状态,所以能用最朴素的单元测试覆盖。

2)分类:这个包是上行还是下行

判断下行只需要三个条件同时成立:源地址等于网关地址,目的地址等于 NAT 地址,源端口等于 listener 端口。其余全部按上行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let rewritten = if view.source == tun_address
&& view.destination == nat_address
&& source_port == listener_port
{
// 下行:用目的端口(NAT 端口)反查原始五元组
let entry = nat.lookup_back(destination_port)?;
rewrite_ipv4_tcp(packet, header_len, entry.key.destination, entry.key.source)
} else {
// 上行:按五元组查或分配 NAT 端口
let key = TcpKey { source, destination };
let port = nat.lookup_or_insert_packet(key, is_initial_tcp_syn(packet, tcp))?;
rewrite_ipv4_tcp(
packet,
header_len,
SocketAddrV4::new(nat_address, port),
SocketAddrV4::new(tun_address, listener_port),
)
};

下行查不到映射的包直接丢弃,而不是带着一个错误的五元组转发出去。

3)改写:RFC 1624 增量校验和

改写四元组要同时更新 IP 头校验和(IPv4)和 TCP 校验和。全量重算 TCP 校验和要扫一遍载荷,1500 字节的包就是 750 次加法。RFC 1624 的增量更新只需要对被替换的字做减旧加新:

1
2
3
4
5
6
7
8
9
10
11
/// 把一次 16 位字替换累加到未折叠的校验和上。
/// 先把所有替换加完再折叠,避免 IPv4 折叠 6 次、IPv6 折叠 18 次。
#[inline]
fn checksum_add_replacement(sum: &mut u32, old: u16, new: u16) {
*sum += (!old as u32) + new as u32;
}

#[inline]
fn checksum_finish_replacements(sum: u32) -> u16 {
!checksum_fold(sum)
}

IPv4 要替换 4 个地址字加 2 个端口字,IPv6 要替换 16 个地址字加 2 个端口字(地址参与 TCP 伪首部)。成本与载荷大小无关。

一个容易漏的细节:TCP 校验和算出来是 0 时要写成 0xFFFF,因为 0 在 UDP 里表示”无校验和”,部分实现对 TCP 也会拒绝。

改写在接收 buffer 上原地完成。同一块 buffer 改完直接写回 TUN,TCP 路径上没有一次载荷拷贝,也没有一次 channel 传递。

4)处置:三种结果

处理函数对每个包返回一个处置:WriteBack(len) 原地改写完成,把这么多字节写回设备;ForwardUdp 把整个 IP 包移交给 UDP 编解码器;Done 丢弃。I/O 驱动只需要根据处置动作,不需要知道协议细节,这让 Linux 批量、macOS 批量、移动端逐包三套驱动共享同一段处理逻辑。

四、NAT 表:把状态机写清楚比写快更重要

NAT 表是这个设计里唯一的共享可变状态,所有难写的 bug 都在这里。

1)结构

双向映射:正向表按五元组查 NAT 端口,反向表按 NAT 端口查五元组。两张表都分 64 片,按哈希取片,让并发流不挤同一把锁。锁序固定为正向先于反向,lookup_or_insert 持有正向锁时取反向锁,任何需要两把锁的地方要么按这个顺序,要么先释放一把。

2)三个状态

1
2
3
4
5
enum TcpEntryState {
HalfOpen, // 已分配端口,内核 listener 尚未 accept
Active, // 已 accept,流正在分发器里
Closing { since: Instant }, // 流已结束,等内核的 FIN/ACK 翻译完
}

为什么需要 Closing?当分发器里的 TcpStream 被 drop,内核才开始关闭这条 accept 出来的 socket,它发出的 FIN 和最终 ACK 仍然要经过下行改写才能到达 App。如果流一结束就删映射,这些包查不到映射被丢弃,App 只能等自己的超时。所以映射要在 Closing 状态多活一个 linger 窗口(10 秒),由周期清理回收。

3)generation:防止旧定时器拆掉新连接

同一个五元组的连接可以关掉再重连。如果重连复用了同一个 NAT 端口,旧连接的 linger 清理就可能把新连接的映射删掉;更糟的是,复用刚关闭的端口会撞上内核 socket 的 TIME_WAIT。

解法是两条:每次 accept 给表项打一个递增的 generation,清理和标记操作都带 generation,不匹配就不动;一个不带 ACK 的 SYN 到来时,如果现有映射处于 Closing,直接删掉它并分配一个不同的端口。

1
2
3
4
5
6
/// SYN 且无 ACK 才算新连接。SYN 重传仍复用 HalfOpen 映射,
/// 这个信号只用来替换分发器已标记为 Closing 的映射。
#[inline]
fn is_initial_tcp_syn(packet: &[u8], tcp_offset: usize) -> bool {
packet.get(tcp_offset + 13).is_some_and(|flags| flags & 0x12 == 0x02)
}

4)清理不能黑洞活连接

周期清理先在各分片里收集过期端口,释放锁,再逐个删除。收集与删除之间,同一条流的新包可能刷新了 last_seen,或者 accept 已经把它变成 Active。删除前必须再检查一次”仍然过期”,否则下行路径会找不到映射,把内核发来的每个包都丢掉,这条活连接就被黑洞了。

5)有界

表项有预算,耗尽时返回错误而不是继续分配。分配新端口时探测上限 4096 次:表接近满时如果没有这个上限,一个包就会在持锁状态下扫完 55000 个端口。NAT 表项的预算与”活跃 TCP 会话”的准入预算是分开的:一次浏览器突发会同时产生大量 SYN、半开和 linger 中的映射,早期把两者绑在一起时,iOS 上 256 个五元组就把整张表耗尽了,而每个表项其实只有 100 字节。

6)任务结束时的兜底

Active 表项只有在会话任务正常返回时才被标记 Closing。分发器里一个被 tokio 吞掉的 panic,或者一次 abort,就会留下一个永远不会被回收的 Active 表项。修法是一个持有 NAT 引用、端口和 generation 的 guard,Drop 里调 mark_closing,任务无论怎么结束都会执行。

五、accept 侧:内核给我们一条流,我们还它一个五元组

listener 绑在网关地址的 0 端口,backlog 取自平台队列预算。accept 循环做四件事:

  1. 只接受来自 NAT 地址的对端。listener 对本机其他流量也可达,不是我们改写出来的连接直接丢。
  2. 一个临界区里完成”查五元组 + 标 Active + 打 generation”。分开做的话,清理可能在两步之间把表项回收。
  3. 准入预算。拿不到许可时,不是删映射,而是 drop socket 并把映射标为 Closing:内核已经完成了握手,drop 会让它发 FIN 或 RST,这个包经过下行改写能到达 App,App 立刻看到连接失败。删映射的效果是相反的,App 会对着黑洞重传到超时。
  4. 错误分类。EMFILE、ENFILE、ENOBUFS、ENOMEM 是资源耗尽,退避 50 ms 再试并限频告警;EINTR、EPROTO、连接被对端中止是瞬时错误,1 ms 后重试;其他视为致命,整个栈退出并把错误向上报。

accept 出来的 TcpStream 用恢复的原始五元组构造会话,走和 smoltcp 栈完全相同的 dispatch_stream。分发器、路由、出站对两个栈没有任何区别。

六、真机教我们的一课:Darwin 上 listener 必须绑到 utun

这个坑值得单独一节,因为它在 Linux 上完全不存在,而且症状极具误导性。

iOS 真机上的现象:packet rewrite 的计数一直在涨,说明上行改写和写回都在工作;但 TCP accept 的计数始终为零。抓包能看到改写后的 SYN 进了 utun,却没有任何回应。

原因是 Darwin 的路由与 socket 绑定语义。listener 只绑了地址没绑接口,内核不把从 utun 重新注入的 SYN 与这个 listener 关联起来。sing-box 在 Darwin 上有一个 ForwarderBindInterface 的设计,做的正是这件事。修法是 bind 之前用 IP_BOUND_IF / IPV6_BOUND_IF 把 socket 绑到实际的 utun 接口:

1
2
3
4
5
6
7
8
9
let socket = Socket::new(Domain::IPV4, SocketType::STREAM, Some(Protocol::TCP))?;
#[cfg(any(target_os = "ios", target_os = "macos"))]
if let Some(interface_index) = interface_index {
// 没有 IP_BOUND_IF,iOS Network Extension 可以成功把 SYN 写回 utun,
// 内核却永远不会把它交给这个 listener,引擎显示 Running 而 TCP 会话为零。
socket.bind_device_by_index_v4(Some(interface_index))?;
}
socket.bind(&SockAddr::from(SocketAddrV4::new(address, 0)))?;
socket.listen(backlog)?;

接口索引怎么拿?iOS 上 tun-rs 故意不暴露设备名,要对复制出来的 raw fd 调 getsockopt(SYSPROTO_CONTROL, UTUN_OPT_IFNAME) 读出 utunN,再 if_nametoindex。

顺着这个坑我们还补了两条启动约束:

  • 就绪信号必须晚于 listener 绑定成功。之前 TUN 一起来就报 ready,上层看到的是”运行中”,实际上一条 TCP 都进不来。
  • 启动前对照真实接口校验配置的网关地址和掩码。listener 绑网关地址,NAT 地址从前缀推导,设备实际地址不一致就会绑到一个它没有的地址。Apple 上接口地址有时晚于 fd 出现,所以校验带 10 次、间隔 50 ms 的重试。

IPv6 还有一个小坑:地址可能处于 DAD 的 tentative 状态,bind 返回 EADDRNOTAVAIL。Linux 用 IPV6_FREEBIND 绕过;其他平台回退到绑 ::,靠 accept 循环的对端过滤兜底。

七、性能:第一版比 smoltcp 慢,原因是设备操作翻倍

1)问题

system 栈有一个结构性成本:每个 TCP 载荷要穿越 TUN 两次。上行被改写后重新注入,listener 的回包又经 TUN 读出、改写、再注入。smoltcp 栈从不重注入上行,它的用户态 TCP 直接消费。同样的载荷,system 栈的设备操作大约是两倍。

在 Linux 上用 iperf3 测第一版,一个 syscall 一个包,结果是 1、16、64 并发流分别比 smoltcp 慢 6.6%、40.5%、43.6%。原因不是 CPU,是设备队列在并发下被打满,丢包换来 TCP 重传。

2)批量 I/O

修法是 tun-rs 的 offload API:recv_multiple 一次 GRO 读(最多 64 KiB)拆成多个包,send_multiple 把多个包合并成更少更大的写。两个细节:

  • writev 在这里没用。TUN 是字符设备,一次向量写仍然只组装一个包,只有 GRO/GSO 能摊薄 syscall。
  • 接收 buffer 的数量要按 MSS 下限算,不是按 MTU。recv_multiple 按流的 MSS 切分一次 GRO 读,MSS 可能远小于 MTU(低 MTU 路径、advmss 路由、TCP_MAXSEG)。按 MTU 算的数量在 MSS 低于 512 时会溢出报 ErrTooManySegments;按 48 字节的 TCP_MIN_SND_MSS 算,64 KiB 需要 1367 个 buffer。同时把这个错误当成可丢弃的单次读错误而不是致命错误,对抗性的小分段最多导致丢包,不会拆掉整个后端。

我们还实现并测了一个 ping-pong 方案:读写分在两个任务,来回交换 buffer 组,读永远不被写阻塞。结果更差:三档并发分别 +7.6%、−19.0%、−32.0%。每批一次 channel 往返的调度开销,比它省下的写阻塞更贵。代码里留了注释,免得下一个人再推导一遍。

3)三套驱动,一份处理逻辑

平台 驱动 说明
Linux GRO/GSO 批量 recv_multiple / send_multiple,TCP 复用接收 buffer 写回,UDP 因批次复用接收区做一次受预算约束的拷贝
macOS recvmsg_x / sendmsg_x 每条消息仍是一个 IP 包,pool buffer 原地改写零拷贝,批量摊薄 syscall 与唤醒
iOS / Android / Windows 逐包 设备每次只给一个包;一次就绪边沿内同步排空最多 32 个包,写用 try_send 快路径,满了再走异步等待

三套驱动都只有一个任务同时持有设备的读写:读出来的 buffer 改写后直接写回,没有 channel、没有拷贝。UDP 在移动端直接移交 buffer 所有权给编解码器,只有 Linux GRO 路径因为要复用接收区才拷一次。

4)数字

Linux 6.18(WSL2),i7-12650H,release 构建,两个 netns 用 veth 相连,iperf3,双方 TUN txqueuelen 都设为 10000,取稳态每秒区间的中位数。不经 TUN 的 veth 直连基线约 66 Gbps。

并发流 smoltcp system 差值 smoltcp RSS system RSS
1 3.39 Gbps 4.82 Gbps +42.2% 18 MB 18 MB
16 4.36 Gbps 4.68 Gbps +7.3% 47 MB 18 MB
64 4.13 Gbps 4.09 Gbps −0.8% 137 MB 19 MB

吞吐上两者在高并发时打平,单流 system 更快。真正的差别在内存,而且是结构性的:system 栈不为连接分配任何用户态缓冲,一条连接的成本就是一个约 100 字节的 NAT 表项;smoltcp 的 137 MB 会随连接数继续线性增长。

一个还没完全解决的毛边:64 流时 system 栈仍记录约 7.4 万次设备 TX 丢包,尽管吞吐已经与 smoltcp 持平。把 txqueuelen 继续调大丢包归零、system 略微领先,所以这是突发吸收能力的问题,不是吞吐上限。顺带一提,默认的 txqueuelen 500 对多 Gbps 来说两个栈都太小,smoltcp 在 16/64 流时也丢 3.9 万 / 6.7 万包。

八、有界:移动端每个队列都要有上限

在 Extension 进程里,任何无界的东西迟早变成一次崩溃。system 栈里每一个可能增长的量都有预算,而且预算来自同一个进程级的 buffer 系统,与 smoltcp 栈共享:

  • NAT 表项数、TCP 准入许可数、UDP 上行与下行队列深度、listener backlog,全部取自平台级的队列上限。
  • iOS 的 TCP 准入在正常、升高、危急三档内存压力下分别是 64、32、16。压力升高只阻止新会话,已有会话保留许可自然结束,不为回收内存强拆连接。
  • 耗尽时丢弃或拒绝并计数,告警限频到 5 秒一次,绝不阻塞 TCP 路径。
  • 入向统计按 64 个包或 100 ms 批量提交一次原子操作,空闲隧道不再每 100 ms 醒一次刷零计数。

九、代价与边界

这个方案不是零成本,选它之前要知道它的账单:

  • 设备操作翻倍。CPU 与功耗在移动端还需要真机测量,Linux 上的吞吐数字不能直接推到 iOS。
  • 内核 socket 内存看不见。它不在 Rust 堆里,是否计入 Extension 进程的 phys_footprint 不能靠堆统计推断,发布前必须测实际峰值。
  • 不重组分片,IPv4 和 IPv6 都是。带扩展头的 IPv6 UDP 也会被丢弃,因为共享的 UDP 编解码器假定固定 40 字节头。
  • ICMP 只回应网关。ping 隧道内任意远端不会有回应。
  • 不在运行中切换栈。启动失败返回稳定错误,不在半启动状态静默换数据面。
  • 桌面继续用 smoltcp。桌面没有 Extension 那样的内存墙,没必要为省内存多付一倍设备操作。两个栈共享 buffer 系统、UDP 路径、分发器和出站,切换只是一个配置字段。

十、测试怎么写

这个模块最值钱的测试都不需要特权 TUN:

  • 增量校验和对照全量重算,IPv4 和 IPv6 各一份。IPv6 的测试要从一个正确的校验和出发,因为对零校验和做增量更新结果看起来合理但其实是错的。
  • 下行恢复:一个从 listener 回到 NAT 地址的包,改写后源目的必须是原始服务器和客户端;没有映射的下行包必须被丢弃。
  • NAT 竞态:清理已判定过期、删除前同一流的新包到达,映射必须存活;新 SYN 必须拿到与 Closing 映射不同的端口,旧 linger 定时器不能删掉重连。
  • 容量释放:预算为 1 时第二条流被拒并计数,清理后能再分配。
  • Darwin 绑定:在 macOS 上把 listener 绑到 lo0,用 getsockopt(IP_BOUND_IF) 读回接口索引断言相等。
  • 准入与内存压力:进入压力不撤销已有许可,只有活跃数降到新上限以下才放行新会话。
  • Criterion 基准:纯改写、稳定流 NAT 改写、1024 条新流批量三个场景,直接调用生产的解析、分片查表和改写函数,避免基准演化成第二套算法。

结语

system netstack 的全部想法可以压成一句话:

不要在内核旁边再写一个 TCP。把包改写成内核愿意接的样子,让它替你干活,你只负责记住每条连接原来是谁。

这条路上真正难的不是改写本身,那只是几十行字节操作。难的是三件事:把 NAT 表的状态机和它的竞态写清楚;接受”设备操作翻倍”这个结构性成本并用批量 I/O 把它摊掉;以及承认有些坑只有真机才会教你,比如 Darwin 上那个 SYN 进得去、accept 永远为零的下午。

如果你也在移动端做 TUN 代理,被 Extension 的内存上限追着跑,这个方案值得试。但请先测,再下结论:Linux 上的数字是我们的,你的平台上的数字得是你的。


参考

  • clash-rs 上的实现与讨论:feat(tun): add system (kernel) TCP stack option #1491
  • sing-box 的 stack: system,本设计的思路来源,以及 Darwin 上的 ForwarderBindInterface
  • RFC 1624:Computation of the Internet Checksum via Incremental Update
  • tun-rs:Linux GRO/GSO 批量收发 API