通过packetdrill构造的包序列理解TCP快速重传机制

TCP的逻辑是极其复杂的,其学习曲线虽然很平缓但其每一步都是异常艰难,好在这些都是体力活,只要肯花时间也就不在话下了。想彻底理解一个TCP的机制,有个四部曲:
1.读与其相关的RFC;
2.看Linux协议栈的TCP实现;
3.通过抓包以及其它工具来确认事实就是如此;
4.解决一个与之相关的网络问题。

经历了以上四步骤,相信任何人都可以在相关领域内稍微装逼一把了...
        本文的内容是TCP快速重传机制,但是与其它文章不同的是,本文并不剖析源码实现,也不翻译RFC,更不是原理性介绍,而是通过一个TCP报文序列来看TCP到底是怎么运作的,在可能的情况下,我会对照Linux协议栈源码。
        我们知道,TCP是一个与全世界相关的混沌系统,但是其最终还是要落实到每一个细节,这些细节都是有章可循的,最终就是RFC的规范,在这些微观的细节章法的范围内,TCP在宏观上才得以表现为一个让人琢磨不透的混沌系统。
        理解一个机制最好的办法就是实例讲解,那么随便从网上抓一个报文序列来分析就再好不过,但是这只是事情的一个方面,事情的另一个方面就是,一定要可重现!正是由于TCP在宏观上是一个混沌系统,才让现实中无法重现任意一个TCP报文序列。因此我们必须手工构造报文序列。
        可以做到构造报文序列的工具有很多,比较了一下,我还是选择了packetdrill,这是google的一个工具,比较好用,其不足在于在不同的内核版本上可能会有不同的怪异的问题,正如其社区所承认的,他们没有时间去维护一个放之四海而皆准的稳定版本,但是这无所谓,小白bug自己修复便是,没有修复过bug的程序员是经理。关于packetdrill的细节我就不再赘述,自行到其github上去了解便是,本文马上开始步入正题。
        在行文继续之前,我先说明一个细节。

        由于packetdrill是在运行时才打开一个tun(由tun.ko驱动的一个虚拟网卡设备)网卡设备,然后对其配置,为了仅仅论述TCP本身,我需要消除任何offload机制的影响,并且我不知道怎么用参数去掉offload(好像也没有!),因此我修改了packetdrill的netdev.c的代码:


/* Set the offload flags to be like a typical ethernet device */
static void set_device_offload_flags(struct local_netdev *netdev)
{
#ifdef linux
//      const u32 offload =
//          TUN_F_CSUM | TUN_F_TSO4 | TUN_F_TSO6 | TUN_F_TSO_ECN | TUN_F_UFO;
//      if (ioctl(netdev->tun_fd, TUNSETOFFLOAD, offload) != 0)
//              die_perror("TUNSETOFFLOAD");
#endif
}


很傻比的一个修改,特此声明。

        首先我给出我的第一个packetdrill脚本,注意看其中的注释。

// 建立连接
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 1000,sackOK,nop,nop,nop,wscale 7>
+0  > S. 0:0(0) ack 1 <...>
+.1 < . 1:1(0) ack 1 win 32792
+0  accept(3, ..., ...) = 4

// 发送一个段,注意,它不会诱发拥塞窗口增加,因为初始拥塞窗口已经遵循google的建议变为10个段,此不满10个段会被限制。
// 请用tcpprobe来确认,限制窗口增加的代码请参加本脚本后面的代码。注意,在拥塞窗口从1,2或者3起步的年代,这种限制是不存在的。
+0  write(4, ..., 1000) = 1000
// 发包序列为:+0  > P. 1:1001(1000) ack 1

+.1 < . 1:1(0) ack 1001 win 32792

// 写入4个段,此目的为了触发快速重传
+0  write(4, ..., 4000) = 4000

// 我们得到3个SACK,但是请注意,FACK和标准的SACK的区别要体现了:
// 标准SACK:只有等到4001-5001这一个段以及2001-4001这两个段都收被SACK了,才能触发重传
// FACK:注意到先收到的那个4001-5001 SACK,其与UNA的距离已经超过了3,不用收到2001-5001这个SACK即可触发重传
+.1 < . 1:1(0) ack 1001 win 257 <sack 4001:5001,nop,nop>
+0  < . 1:1(0) ack 1001 win 257 <sack 2001:4001,nop,nop>
// 一个小问题,如果SACK的序列成了以下的:+0  < . 1:1(0) ack 1001 win 257 <sack 2001:3001,nop,nop> 重传还会被触发吗?
// 此时抓包的话,就会发现,重传已经发生

// 全部确认
+.1 < . 1:1(0) ack 5001 win 257


因为这个脚本过于简单,注释里已经给出了一切,因此在文中就不再抓包确认,有兴趣的可以自行确认Reno,SACK,FACK之间的区别,方法很简单,分别开启关闭sack,sack即可:
net.ipv4.tcp_sack = 1|0
net.ipv4.tcp_fack = 1|0

然后抓包来看重传触发的时机,如果你还不是很理解其不同,请一定要做上述的抓包确认,保证可以瞬间搞明白。关于注释里说的那个限制窗口增加的代码如下:


int tcp_is_cwnd_limited(const struct sock *sk, u32 in_flight)
{
    const struct tcp_sock *tp = tcp_sk(sk);
    u32 left;
    if (in_flight >= tp->snd_cwnd)
        return 1;

    left = tp->snd_cwnd - in_flight;
    if (sk_can_gso(sk) &&
        left * sysctl_tcp_tso_win_divisor < tp->snd_cwnd &&
        left * tp->mss_cache < sk->sk_gso_max_size)
        return 1;
    return left <= tcp_max_burst(tp);
}

这个函数会在增窗前被调用。

        下面我用一个综合的复杂点的例子来作为本文接下来的内容,这个例子中不仅仅包含了快速重传,还有关于拥塞窗口的部分细节,请一定仔细分析。这个比较复杂些的例子如下:


// 建立连接
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 65535 <mss 1000,sackOK,nop,nop,nop,wscale 7>
+0  > S. 0:0(0) ack 1 <...>
+.1 < . 1:1(0) ack 1 win 65535
+0  accept(3, ..., ...) = 4

// 发送1个段,不会诱发拥塞窗口增加
+0  write(4, ..., 1000) = 1000
+.1 < . 1:1(0) ack 1001 win 65535

// 再发送1个段,拥塞窗口还是初始值10!
+0  write(4, ..., 1000) = 1000
+.1 < . 1:1(0) ack 2001 win 65535

// .....
+0  write(4, ..., 1000) = 1000
+.1 < . 1:1(0) ack 3001 win 65535

// 不管怎么发,只要是每次发送不超过init_cwnd-reordering,拥塞窗口就不会增加,详见上述的tcp_is_cwnd_limited函数
+0  write(4, ..., 1000) = 1000
+.1 < . 1:1(0) ack 4001 win 65535

// 多发一点,结果呢?自己用tcpprobe确认吧
+0  write(4, ..., 6000) = 6000
+.1 < . 1:1(0) ack 10001 win 65535

// 好吧,我们发送10个段,可以用tcpprobe确认,在收到ACK后拥塞窗口会增加1,这正是慢启动的效果!
+0  write(4, ..., 10000) = 10000
+.1 < . 1:1(0) ack 20001 win 65535

// 该步入正题了。为了触发快速重传,我们发送足够多的数据,一下子发送8个段吧,注意,此时的拥塞窗口为11!
+0  write(4, ..., 8000) = 8000

// 以下为收到的SACK序列。由于我假设你已经通过上面那个简单的packetdrill脚本理解了SACK和FACK的区别,因此这里我们默认开启FACK!
// sack 1的效果:确认了27001-28001,此处距离ACK字段20001为8个段,超过了reordering 3,会立即触发重传。
+.1 < . 1:1(0) ack 20001 win 257 <sack 27001:28001,nop,nop>                                         // ----(sack 1)
+0  < . 1:1(0) ack 20001 win 257 <sack 22001:23001 27001:28001,nop,nop>                             // ----(sack 2)
+0  < . 1:1(0) ack 20001 win 257 <sack 23001:24001 22001:23001 27001:28001,nop,nop>                 // ----(sack 3)
+0  < . 1:1(0) ack 20001 win 257 <sack 24001:25001 23001:24001 22001:23001 27001:28001,nop,nop>     // ----(sack 4)

// 收到了28001的ACK,注意,此时的reordering已经被更新为6了,另外,这个ACK也会尝试触发reordering的更新,但是并不成功,为什么呢?详情见下面的分析。
+.1 < . 1:1(0) ack 28001 win 65535

// 由于经历了上述的快速重传/快速恢复,拥塞窗口已经下降到了5,为了确认reordering已经更新,我们需要将拥塞窗口增加到10或者11
+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 33001 win 65535

// 由于此时拥塞窗口的值为5,我们连续写入几个等于拥塞窗口大小的数据,诱发拥塞窗口增加到10.
+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 38001 win 65535

+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 43001 win 65535

+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 48001 win 65535

+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 53001 win 65535

+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 58001 win 65535

+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 63001 win 65535

// 好吧!此时重复上面发生SACK的序列,写入8个段,我们来看看同样的SACK序列还会不会诱发快速重传!
+0  write(4, ..., 8000) = 8000

// 我们构造同上面sack 1/2/3/4一样的SACK序列,然而等待我们的不是重传被触发,而是...
// 什么?没有触发重传?这不可能吧!你看,70001-71001这个段距离63001为8个段,而此时reordering被更新为6,8>6,依然符合触发条件啊,为什么没有触发呢?
// 答案在于,在于8>6触发快速重传有个前提,那就是开启FACK,然而在reordering被更新的时候,已经禁用了FACK,此后就是要数SACK的段数而不是数最高被SACK的段值了,以下4个SACK只是选择确认了4个段,而4<6,不会触发快速重传。
+.1 < . 1:1(0) ack 63001 win 257 <sack 70001:71001,nop,nop>
+0  < . 1:1(0) ack 63001 win 257 <sack 65001:66001 70001:71001,nop,nop>
+0  < . 1:1(0) ack 63001 win 257 <sack 67001:68001 65001:66001 70001:71001,nop,nop>
+0  < . 1:1(0) ack 63001 win 257 <sack 68001:69001 67001:68001 65001:66001 70001:71001,nop,nop>

// 这里,这里到底会不会触发超时重传呢?取决于packetdrill注入下面这个ACK的时机
// 如果没有发生超时重传,下面这个ACK将会再次把reordering从6更新到8
+.1 < . 1:1(0) ack 71001 win 65535

//从这里往后,属于神的世界...

然后在我们来看抓包结果之前,允许我再次重申,如果不是为了分析协议的细节,请直接使用tcpdump的屏幕输出即可,没必要用wireshark/tshark来展现自己的高大上,我们直接看tcpdump的输出来确认细节:



如果你已经精通TCP的细节了,就会说,爆炸!完全符合预期啊,但对于别人而言,什么是预期,这又是怎么发生的呢?细节是什么呢?如果你只是为了理解TCP快速重传的发生原理,到这里就可以不必接续看下去了,除了TCP快速重传之外,我还附送了关于reordering的更新以及拥塞窗口的变化等细节,已经够丰富了吧。但是如果你想知道Linux协议栈的实现细节方面,或者说你的工作与协议栈的实现相关,请继续往下看。之所以我会写下来这些细节,是因为这也是怕我自己在今后的一段时间过去后忘记这些,毕竟人的记忆系统无非就是一个cache,并非永久存储系统,它无时无刻不在进行替换操作,和计算机,云等系统一样,人本身也必须有一个永久存储系统,以前是纸,现在成了硬盘...
        我想还是用图示的办法来展示细节,虽然本人画图能力不是太好,但却一直在提高,图示是一个二维的说明,要比文字高效的多,比图示更高效的是三维模型,但是那目前已经超出了我的能力范围。我们先分析第一次收到多个SACK的情形:


很明了,如果你去仔细比Linux内核协议栈的实现,会更加清晰,那么在同一TCP连接的后面呈现相同的SACK序列时,情况就不同了,没有触发快速重传,下图分析之:




最终呢?我还是发现上面的论述没有一个归纳性的结论,好像一个实例解析一样,这个导致我很失望!不过我还是想归纳一下关于reordering更新的两个细节:
1.SACK导致的reordering更新
if (skb 没有被选择确认 && skb没有被重传过)
{
    if (skb的序列号<最高被SACK的序列号)
        更新reordering为最高被SACK的skb序列号-当前skb的序列号
}

2.ACK导致的reordering更新
if (skb 没有被选择确认 && skb没有被重传过)
{
    if (skb的序列号<最高被SACK的序列号)
        更新reordering为最高被SACK的skb序列号-当前skb的序列号
}

又复制粘贴了,其实上面两种情况是一样的!不管是选择确认还是ACK确认,只要倒序确认,都有可能证明网络存在乱序,只是是否真的乱序要有一个度,这个度就是由reordering来度量的,爆炸!
        总结一下上面的这个packetdrill脚本解释的问题:
1.拥塞窗口的升降机制。初始窗口定为10后的慢启动行为以及降窗行为。
2.标准SACK以及FACK的快速重传触发时机。
3.TCP连接内的reordering的更新机制以及其与FACK的关系。
4.在触发快速重传时的TCP内部维护的各个计数器之间的关系。


到此,本文也该结束了,现在时间是2016/07/16 10:32,距离起床已经过了6个小时,本文写的比较慢,原因在于中间给小小做了早饭,然后又修了空调...
最后,如果说你不知道packetdrill的工作机制,也不必慌张,如果你已经懂了packetdrill的工作机制,也不必张狂,这些都是无关紧要的。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值