周一有位朋友咨询个问题,问题本身不重要,但牵扯出的细节却是非常有趣。
Linux 内核协议栈的 skb 设计非常高效和精巧,多个 skb 可以指向同一块 data,这就是 clone,当 data 不止一个 skb 指示时,任何一个 skb 要修改 data 时,必须 unclone,其内部实现就是一个简单的 cow(copy on write),这就是 unclone,原理如下:
clone 和 unclone 的含义简述如下:
- clone,复制一个新 skb,指向相同 data,dataref 递增;
- unclone,复制一份新 data,原 dataref 递减,skb 指向新 data;
值得注意的是 skb 的 clone 和 share 的区别:
- clone,针对 skb 的 data;
- share,针对 skb 结构体本身;
因此,kfree_skb 就非常清晰了:
- 先递减 share 计数,自己是最后一个才继续释放 skb 的其它内容;
- 再递减 data 的 dataref,自己是最后一个才彻底释放 data 本身;
至于 skb_copy,pskb_copy,自然就不必说,理解 skb_clone,skb_unclone,kfree_skb 就够了。
下面是 TCP 传输和重传过程的 clone,unclone 序列:
TCP 传输和重传时,红黑树(解释理由)上的本体 skb 结构体始终未变,变的是 clone skb 结构体和 data:
- 每次传输或重传时,均会 clone 一份本体 skb 实际传输,clone 过程本体 skb 的 data 不变,dataref 递增;
- 每次重传时,如果 dataref 大于 1,本体 skb 会 unclone,复制一份 data ,原 data 的 dataref 递减,回到 1;
unclone 后留下的原 data 并未游离,因为还有上下文引用它们,可能驱动尚未发送完毕,等发完了一般会有中断通知,那就留给 clone 它的上下文去 kfree 了。
以下是上面知识在处理朋友问题时的一个应用,该应用过程反过来也能加深上面知识的理解和内化。
问题是这样的。理论上 TCP 重传时要从重传队列 head 开始,可抓包却发现先重传了最后 fin,再次重传时才重传队列更前面的 data。
初步猜测是 tlp,过年期间写过一个关于 tlp 的,详见 探秘 TCP TLP:从背景到实现。为帮朋友定位问题,我写了下面的 pdrill 复现脚本,并打开 tlp:
c
// 开启 tlp
//sysctl -q net.ipv4.tcp_early_retrans=4 net.ipv4.tcp_recovery=1
0 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+0 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
+0 bind(3, ..., ...) = 0
+0 listen(3, 1) = 0
+0 < S 0:0(0) win 32792 <mss 1460,sackOK, nop, nop, nop,wscale 7>
+0 > S. 0:0(0) ack 1 <mss 1460,nop, nop, sackOK, nop, wscale 7>
+0 < . 1:1(0) ack 1 win 257
+0 accept(3, ..., ...) = 4
+0 write(4, ..., 1360) = 1360
+0 close(4) = 0
+10 < . 1:1(0) ack 2821 win 257
通篇没收到任何 ack, 1360 字节数据和 close 的 fin 均要被重传,会先重传最后的 fin,但理论上后续重传 1360 字节时会将 fin 合并带上,几乎可以完全复现场景:
然而抓包却是这样:
并没有合并捎带 fin。
不合并就不合并了,不是什么大事,本身 tcp collapse 触发条件就不止一个。但作为 pdrill 本地测试,最基本的场景,立场应该是想让它 collapse 它就得 collapse,一定是哪里出了问题才导致 1360 字节的报文没有 collapse fin。
tcp 重传 collapse 的条件是没有其它上下文引用该 skb 的 data,即 shinfo->dataref == 1,以下是详细观测 skb shinfo->dataref 的脚本:
c
bpftrace -e '
//kprobe:__tcp_transmit_skb // 尚未 clone,dataref = 1
//kprobe:__ip_queue_xmit // 已经 clone,dataref = 2
//kprobe:__dev_queue_xmit
//kprobe:dev_hard_start_xmit
//kprobe:dev_queue_xmit_nit // PACKET 套接字等抓包程序会再次 clone,dataref = 3,处理完成后 dataref = 2
//kprobe:网卡 xmit 回调 // 传输完毕由驱动负载 kfree_skb,dataref = 1
{
$skb = (struct sk_buff *)arg0;
$dev = (struct net_device *)($skb->dev);
if (strncmp($dev->name, "tun0", 4) == 0) {
$shinfo = (struct skb_shared_info *)($skb->head + $skb->end);
$dataref = $shinfo->dataref.counter;
printf("tun0 skb=%p dataref=%d\n", $skb, $dataref & 0xffff);
}
}
'
结果是到了 tun_net_xmit 时,dataref = 3,但在 dev_hard_start_xmit 时 dataref 还是 2,这中间只有 PACKET 套接字,类似抓包路径了。而抓包需要 copy data,肯定是需要 clone skb 的。还果然是这个原因:
bash
root@vbox:~# strace -e trace=socket packetdrill ./tlp-with-fin.pkt
...
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=6624, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=6626, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) = 6
也可在 packetdrill 运行期间通过 procfs 确认:
bash
root@vbox:~# cat /proc/net/packet
sk RefCnt Type Proto Iface R Rmem User Inode
00000000dafae456 3 2 0003 0 1 0 0 42872
但即使减少了 PACKET 套接字的 1 个 ref,还有 1 个 ref 直到 tun_net_xmit 也没释放,原因在于 packetdrill 并未 read tun0fd。
为了避免这种本地测试工具不处理 read 造成的额外混乱,我觉得 tun 驱动应该增加一个 "低效率" 模式,即 skb 进入 tun 驱动的 tun_net_xmit 回调时直接转交掉,多一次复制,少一份混乱:
c
static netdev_tx_t tun_net_xmit(struct sk_buff *skb, struct net_device *dev)
{
struct tun_struct *tun = netdev_priv(dev);
enum skb_drop_reason drop_reason;
int txq = skb->queue_mapping;
struct netdev_queue *queue;
struct tun_file *tfile;
int len = skb->len;
struct sk_buff *nskb = skb;
rcu_read_lock();
if (tun-> & 低效) {
skb = skb_copy(nskb, GFP_ATOMIC);
kfree_skb(nskb);
// 这里顺带帮 PACKET 套接字解除一个 ref,实际中不需要
atomic_dec(&skb_shinfo(nskb)->dataref);
}
tfile = rcu_dereference(tun->tfiles[txq]);
再次运行,结果就符合预期了:
如果 skb 发到真实网卡,当网卡中断通知发送完成,该 skb 即可得到释放,但本地环回的 skb 生命周期可是要长得多,比如 loopback_xmit,直接用 tx 路径的 skb 调用 netif_rx 了。
不出本机协议栈的数据包环回处理最好在 ndo_start_xmit 中都转交 copy 一下 skb,毕竟本地处理不要求性能(要求高性能的抓包也不用 skb),这样可避免循环依赖而伤害到协议栈的固有行为,比如我用 pdrill 几乎无法验证 collapse 机制。
多年以前我曾因为 tun 网卡没有调用 sk_mem_uncharge 导致了通过 tun 网卡的 socket 异常行为,但忘记题目了,文章找不到了。
不管这世界多么无知和操蛋,只要给我一本历史书,给我一个 TCP 疑难问题,或带我到一座可以攀登的山脚下,我就马上元气爆满!
浙江温州皮鞋湿,下雨进水不会胖。