理解TCP中的数据传输


一、TCP 协议:物流公司

TCP 协议提供了一种 面向连接的可靠的基于字节流的 传输层通信协议。

可以把 TCP 协议理解成一个物流公司,把货物从一个仓库运送到另一个仓库:

  • TCP 是 基于字节流的 协议,所以运输的货物就是字节;
  • 货物是有保质期的(TTL);
  • 货车就是 TCP 数据包;
  • 发送仓库的容量大小是固定的,里面还存放了一堆带运送的字节,这就是发送缓冲区;
  • 接收仓库的容量大小也是固定的,里面可以存放字节,这就是接收缓冲区;
  • TCP 是 面向连接的 协议,所以物流公司是一一对应的;
  • TCP 是 可靠的 协议,所以在接收方确认收到货物前,发送方都要保存已发送的货物以备重发。

二、3 次握手:成立物流公司

TCP 协议是 面向连接的 协议。所以即使同一个服务端,不同的客户端,都是应该是不同的连接,亦即对应者不同的”物流公司”。

可以把一个操作系统理解为一个写字楼:

  • IP 地址对应着写字楼的地址;
  • 端口对应着写字楼不同的大门,写字楼一共有 65535 个大门;
  • 服务端应用对着写字楼中不同的公司。

一个服务端应用首先需要向操作系统申请在某个端口上监听(listen),等同于某个公司向写字楼申请占用某个大门来办事。

服务端应用在某个端口监听成功后,3 次握手就可以理解为:

序号 方向 内容 标记 客户端状态变化 服务端状态变化
1 C -> S 可以给你发送货物吗,我的仓库空闲大小是a? SYN,Win=a CLOSED -> SYN-SENT
2 S -> C 你可以给我发送货物;那我可以给你发送货物吗,我的仓库空闲大小是b? ACK,SYN,Win=b SYN-SENT -> ESTABLISHED LISTEN -> SYN-RECEIVED
3 C -> S 可以的,我的仓库新的空闲大小是c ACK,Win=c SYN-RECEIVED -> ESTABLISHED

注意:TCP 并不允许建立单向的数据传输,连接必须都是双向传输的。这与断开连接时不同。

握手的过程中除了建立连接本身外,还在不停的向对方更新自己仓库空闲空间的大小等信息,这些信息对于后续的数据运输是很重要的。

经过 3 次握手后,一对一的物流公司就建立起来了。现在双方可以开始发送数据给对方了。

三、拥塞窗口:各物流公司之间如何协调上路

物流公司都是一对一的,一个连接就是一个物流公司。各个物流公司之间没有沟通,也就没有协调。大家想发货时就发车,那么路上肯定会堵起来,而货物是有有效期的,不能无限制的堵下去。同样的,对于网络也是,网速也是有限的,数据包也是有超时(TTL),不能无休止发送数据。

但是,与现实世界中有一个全局统一交通系统来控制各个路口的红绿灯不同,网络世界中没有这样一个系统。所以就需要各个物流公司根据货物发送出去并且对方确认收到的时间差来估算网络是否拥堵等情况。

各个物流公司可以应用不同的算法来估算网络是否拥堵等情况,并根据估算的结果来控制货车发车的频率,这就是 拥塞处理(Congestion Handling)

拥塞处理算法计算的结果是当前情况下最多能发送多少数据,也是一个窗口,叫 拥塞控制窗口(cwnd,congestion window) 。拥塞窗口为了避免拼命发包,把网络塞满了,定义一个窗口的概念,在这个窗口之内的才能发送,超过这个窗口的就不能发送,来控制发送的频率。

1450px-TCP_Slow-Start_and_Congestion_Avoidance.svg

图片来源:File:TCP Slow-Start and Congestion Avoidance.svg

Tahoe和Reno算法

图片来源:TCP congestion control - Fast Recovery in graph

在 Linux 中可以使用下面的命令来查看系统支持的拥塞处理算法和当前使用的拥塞处理算法:

$ sysctl net.ipv4.tcp_available_congestion_control
net.ipv4.tcp_available_congestion_control = reno cubic

$ sysctl net.ipv4.tcp_congestion_control
net.ipv4.tcp_congestion_control = cubic

万字详文:TCP 拥塞控制详解

四、发送窗口:接收方仓库的空闲空间大小

但是发送货物之前,发送方需要先考虑两个问题:

  1. 发送方需要时刻知道接收方还有多少空闲的空间能存放货物。因为如果发送的货物总量超出了接收方空闲空间的大小,多余的货物会被丢弃掉。
  2. TCP 协议是 可靠的 的协议,所以发送方还需要记录发出的货物,接收方是否确认收到了等状态信息。

发送窗口(Send Window) 的大小等于 接收方 接收仓库空闲空间的大小,这个数据在 3 次握手的过程中初始化,并在随后的通信的过程中不断的更新。

有时发送窗口(相对于发送者来说)也叫接收窗口(相对于接收者来说)。

发送窗口(Send Window) 也叫 滑动窗口(Sliding Window)。滑动窗口的存在是为了怕把接收方塞满,而控制发送速度。

滑动窗口的内部状态如下:

滑动窗口的内部状态

  1. 字节 1-31:已经发送并且接收方确认收到的数据;
  2. 字节 32-45:已经发送但是接收方还未确认收到的数据;
  3. 字节 46-51:还未发送但是接收方还有足够空间可以容纳的数据;
  4. 字节 52 及以上:还未发送并且接收方没有足够空间可以容纳的数据。

图片来源:TCP Sliding Window Acknowledgment System For Data Transport, Reliability and Flow Control 6

当上图中所有滑动窗口内的数据都发送出后,滑动窗口内的状态变成了下图的状态:

滑动窗口内的数据都已被发送但未被确认

图片来源:TCP Sliding Window Acknowledgment System For Data Transport, Reliability and Flow Control 7

  1. 字节 1-31:已经发送并且接收方确认收到的数据;
  2. 字节 32-51:已经发送但是接收方还未确认收到的数据;
  3. 字节 52 及以上:还未发送并且接收方没有足够空间可以容纳的数据。

当收到了 32-36 字节 的 ACK 确认消息,那么滑动窗口就会向后面滑动,并可以发送 字节 52-56 。如下图:

滑动窗口收到确认后向后滑动

图片来源:TCP Sliding Window Acknowledgment System For Data Transport, Reliability and Flow Control 8

  1. 字节 1-36:已经发送并且接收方确认收到的数据;
  2. 字节 37-51:已经发送但是接收方还未确认收到的数据;
  3. 字节 52-56:还未发送但是接收方还有足够空间可以容纳的数据;
  4. 字节 57 及以上:还未发送并且接收方没有足够空间可以容纳的数据。

循环上面的步骤就能够一直发送数据了。如下图(下图中滑动窗口没有向后滑动,所以最后滑动窗口的大小会变成 0):

滑动窗口完整工作流程

图片来源:TCP Window Size Adjustment and Flow Control 2

五、拥塞窗口和发送窗口结合

在 Linux 内核中,实现发送 TCP 包的函数是 net/ipv4/tcp_output.c 文件中的 tcp_write_xmit 函数:

static bool tcp_write_xmit(struct sock *sk, unsigned int mss_now, int nonagle, int push_one, gfp_t gfp)
{
......
    int cwnd_quota;
    bool is_cwnd_limited = false, is_rwnd_limited = false;
    u32 max_segs;
......
    max_segs = tcp_tso_segs(sk, mss_now);
    while ((skb = tcp_send_head(sk))) {
        unsigned int limit;
......
        cwnd_quota = tcp_cwnd_test(tp, skb);
......
        if (unlikely(!tcp_snd_wnd_test(tp, skb, mss_now))) {
            is_rwnd_limited = true;
            break;
        }
......
}

拥塞窗口的相关判断函数是 tcp_cwnd_test

/* Can at least one segment of SKB be sent right now, according to the
 * congestion window rules?  If so, return how many segments are allowed.
 */
static inline unsigned int tcp_cwnd_test(const struct tcp_sock *tp,
                     const struct sk_buff *skb);

发送窗口的判断函数是 tcp_snd_wnd_test

/* Does at least the first segment of SKB fit into the send window? */
static bool tcp_snd_wnd_test(const struct tcp_sock *tp,
                 const struct sk_buff *skb,
                 unsigned int cur_mss);

六、糊涂窗口综合症:运输效率太低了怎么办

更多的细节可以查阅 《TCP/IP 详解 卷一:协议 第 2 版》15.5.3 糊涂窗口综合症 一节。
相关内容摘录如下:

需要遵循以下规则:

  1. 对于接收端来说,不应通告小的窗口值。RFC1122 描述的接收算法中,在窗口可增至一个全长的报文段(即接收端 MSS)或者接收端缓存空间的一半(取两者中较小者)之前,不能通告比当前窗口(可能为 0 )更大的窗口值。注意到可能有两种情况会用到该规则:

a. 当应用程序处理接收到的数据后使得可用缓存增大,
b. 以及 TCP 接收端需要强制返回对窗口探测的响应。

  1. 对于发送端来说,不应发送小的报文段,而且需要由 Nagle 算法控制何时发送。为避免糊涂窗口综合症(Silly Window Syndrome,SWS)问题,只有至少满足以下条件之一时才能传输报文段:

a. 全长(发送 MSS 字节)的报文段可以发送。
b. 数据段长度 >= 接收端 通告过的 最大窗口值的一半的,可以发送。
c. 满足以下任一条件的都可以发送:

i. 某一 ACK 不是目前期盼的(即没有未经确认的在传数据);
ii. 该连接禁用 Nagle 算法。

条件 a 最直接地避免了高耗费的报文段传输问题。条件 b 针对通告窗口值较小,可能小于要传输的报文段的情况。条件 c 防止 TCP 在数据需要被确认以及 Nagle 算法启用的情况下发送小报文段。若发送端应用在执行某些较小的写操作(如小于报文段大小),条件 c 可能有效避免糊涂窗口综合症。

上述三个条件也让我们回答了以下问题:当有未经确认的在传数据时,若使用 Nagle 算法阻止发送小的报文段,究竟多小才算小?从条件 a 可以看出,”小”意味着字节数要小于 SMSS (即不超过 PMTU 或者接收端的 MSS 的最大包大小)。条件 b 只用于比较旧的原始主机,或者因接收端缓存有限而使用较小通告窗口时。

条件 b 要求发送端记录接收端通告窗口的最大值。发送端以此猜测接收端缓存大小。尽管当连接建立时缓存大小可能减小,但实际这种情况很少见。另外,前面也提到过, TCP 需要避免窗口收缩。

每辆货车的最大载货量是 MSS(Maximum Segment Size,最大分段大小),但是如果在运输时货车里面实际的载货量常常很小,造成这种情况的原因可能是:

  • 发送方的发送仓库中每次只有一点货物要发送;
  • 接收方的接收仓库中每次只能腾出一点空间出来接收货物;
  • 上面两种原因都有。

这种情况会导致运输效率低,而且还容易造成网络拥堵,所以需要避免出现这种情况。

  1. 如果是发送方导致这种情况出现,那么可以通过在发送方使用 纳格算法,Nagle’s algorithm 来避免。算法的原理和实现可以参考 Linux 源码中 linux-4.19.194/net/ipv4/tcp_output.c 文件中 tcp_nagle_check 函数的实现:

    /* Return false, if packet can be sent now without violation Nagle's rules:
    * 1. It is full sized. (provided by caller in %partial bool)
    * 2. Or it contains FIN. (already checked by caller)
    * 3. Or TCP_CORK is not set, and TCP_NODELAY is set.
    * 4. Or TCP_CORK is not set, and all sent packets are ACKed.
    *    With Minshall's modification: all sent small packets are ACKed.
    */
    static bool tcp_nagle_check(bool partial, const struct tcp_sock *tp,
                    int nonagle)
    {
        return partial &&
            ((nonagle & TCP_NAGLE_CORK) ||
            (!nonagle && tp->packets_out && tcp_minshall_check(tp)));
    }
    1. 如果包长度达到 MSS ,则允许发送;
    2. 如果该包含有 FIN,则允许发送;
    3. 未设置 TCP_CORK 选项,但设置了 TCP_NODELAY 选项,则允许发送;
    4. 未设置 TCP_CORK 选项时,若所有发出去的包均被确认,或所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送。

      TCP_NODELAY 选项是用来控制是否开启纳格算法。

      TCP_CORK 开启时,内核将不会马上发送长度小于 MSS 的数据包。

      TCP_CORK 对应 FreeBSD 中的 TCP_NOPUSH 选项。

      nginx 的配置文件中 tcp_nopush 选项控制的就是套接字的 TCP_CORK / TCP_NOPUSH 选项。

  2. 如果是接收方导致这种情况出现,那么可以通过在接收方使用 David D Clark 算法 来避免。

    算法原理是:如果 接收方接收缓存区空闲空间的大小 小于 MSS 或者 接收方接收缓存区完整空间大小的一半 中的较小值时,通知发送方 接收方接收缓冲区空闲空间大小为 0 ,以禁止发送数据。

    需要注意的是,当发送方收到接收方将滑动窗口的大小减少到 0 后,就不能再发送数据给对方了。此时可以发送 Zero Window Probe 包给对方,这样在对方回复此包的 ACK 的同时,可以更新滑动窗口的大小,来恢复发送数据。

  3. 接收方还可以使用 TCP 延迟确认 技术

    TCP 延迟确认技术在收到数据后并不马上回复对应的 ACK ,而是累计需要发送的 ACK 数据,等到有数据要发送给对方时一起发送给对方。如果一直没有数据发送给对方,在等到一段时间后也会把累计的 ACK 发送给对方。

    在 Linux 下,套接字选项 TCP_QUICKACK 用于关闭 TCP 延迟确认技术(需要每次发送都设置一次,参考 man 7 tcp

    参考:Linux下TCP延迟确认(Delayed Ack)机制导致的时延问题分析

七、4 次挥手:注销物流公司

当有 A 不再需要发送数据给 B 时,可以单向的关闭连接。流程如下:

序号 方向 内容 标记 A 状态变化 B 状态变化
1 A -> B 我发送完数据了 FIN ESTABLISHED -> FIN-WAIT-1
2 B -> A 好的,我知道了 ACK FIN-WAIT-1 -> FIN-WAIT-2 ESTABLISHED -> CLOSE-WAIT

当 B 也不再需要发送数据时,可以继续关闭连接。流程如下:

序号 方向 内容 标记 A 状态变化 B 状态变化
3 B -> A 我也发送完数据了 FIN CLOSE-WAIT -> LAST-ACK
4 A -> B 好的,我知道了 ACK FIN-WAIT-2 -> TIME-WAIT LAST-ACK -> CLOSED

八、TCP 协议抓包

  1. 两台机器分别是:

    • A: 192.168.81.88(Windows 10)
    • B: 192.168.20.122(CentOS 7.7)
  2. 操作步骤:

    1. B 在 9999 端口监听 TCP 连接;
    2. A 连接 B的9999 端口;
    3. A 向 B 发送 4 个字节的数据;
    4. B 向 A 发送 4 个字节的数据;
    5. B 主动断开连接。
  3. Wireshark 截图

    TCP连接发送数据断开的Wireshark截图

    • 让 Wireshark 显示绝对的 sequence numbers

      在菜单栏依次点击 编辑 -> 首选项 -> Protocols -> TCP 中,取消选项 Relative sequence numbers 前的复选框。

    • 如何显示截图的界面

      在菜单栏依次点击 统计 -> 流量图,然后点击右键,在弹出的选项中选择放大/缩小调整图形。


文章作者: Kiba Amor
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 Kiba Amor !
  目录