谈谈 TCP 与 UDP

825 阅读29分钟

文章涉及的知识点较多,因为每一块都是可以独立写一篇文章来单独介绍,所以请各位看官根据导航酌情品尝(前言可以跳过);

1. 前言

    为什么标题名为重新出发系列?我曾还想过命名为回顾系列,但思来想去觉得重新出发更为贴切;回想起在刚进入运维行业的时候,一次次囫囵吞枣的疯狂吸收着很多新知识,只追求如何快速的实现及使用,基本不顾及背后的原理甚至只是单纯抄过来使用。这样总感觉自己对这个技术点或知识点完全转化不了, 沉淀为自己的知识树上的叶子节点(俗称不能很好的跟别人吹牛B);
    再后来和很多朋友聊起这些事情的时候,发现另外一个有趣的问题就是刚开始很多东西不成体系的学习,非常多的概念一下子涌入脑袋造成更多的混沌,这种混沌而且会长期存在直到我们静下心来攻破它。借此重新出发系列的机会,重新学习一些模糊知识点的理论;(有点带着一身高级装备回去打以前打不过的高级副本)。

    下面进入今天正题,七层模型中的第四层传输层

2. 了解一些简单的概念

    在传输层中我们一般都会谈到两种协议UDPTCP, 在谈论这两种协议前需要先明确一些前置知识;

  • 如我们将数据的称呼明确下:
    • 应用层的分组称为报文(message)
    • 传输层的分组称为报文段 (segment)
    • 网络层的分组称为数据报 (datagram);
  • 传输协议至少需要提供差错校验,能发现数据丢失或出错;
  • 传输层与主机的进程交付报文段的过程我们称为多路复用(transport-layer multiplexing)和 多路分解(demultiplexing) ;
    • 多路复用 指发送端主机从不同的套接字中收集数据块封装上首部信息,然后将报文段发送到网络中;
    • 多路分解 指接收端在传输层接收到报文段,通过检查标示出套接字的字段信息将报文段中的数据交付到正确的主机进程;

    下面正式谈论UDPTCP,在正式谈论前说说一个有趣的事情就是这两个协议的全称。UDP (User Datagram Protoco) 用户数据报协议,TCP (Transmission Control Protocol)传输控制协议,单从名字上就能感觉出TCP的可靠性。但也仅代表TCP可靠性高,不代表就一定优于UDP,技术都是分场景的,如脱离场景讨论技术都是耍流氓;

3. 无连接传输 UDP

3.1 UDP 简介与特性

  • UDP 只定义了传输协议最基本的动作就是复用/分解功能及少量的差错检测,也就是上层程序只知道数据丢失了或者错误了不可用。

  • UDP发送报文前,发送方和接收方在传输层的实体之间是没有握手,所以被称之为无连接

  • 还有一点和TCP在套接字上能明显看出不同的就是报文的发送与接收方式,在发送时直接把数据发送到目的主机的目标端口(即套接字),而接收方无论是来自哪个源主机的报文在传输层都只会通过同一个套接字到达上层程序中且不保证数据的顺序问题。而在TCP中会为不同的发送方建立不同的套接字后面会详细说;

而在下面通过一小段python代码来解析一下, 留意注释;

  • 服务端 udp_server.py
from socket import *
# 定义了服务端口
server_port = 12000
# 选择套接字类型为 socket_dgram 这是udp的一种套接字
server_socket = socket(AF_INET, SOCKET_DGRAM)
# 绑定要接收的端口即可,系统会为该端口生成套接字,这里可以看出只需要简单的绑定即可;
server_socket.bind("", server_port)
while true:
    # 从套接字中获取长度为2048字节的数据,及客户端地址信息;客户端信息包含源主机和源端口
    message, client_address = server_socket.receiver_from(2048)
    # 简易处理一下信息
    modified_message = message.upper()
    # 发送给目的主机的套接字
    server_socket.sendto(modified_message, client_address)
  • 客户端 udp_client.py
from socket import *
server_name = 127.0.0.1
server_port = 12000
# 选取一种套接字
client_socket = socket(AF_INET, SOCKET_DGRAM)
message = "This is a test for udp."
# 这里可以看出完全不需要和服务器建立连接,直接就把报文发送到目标端口;
client_socket.sendto(message, (server_name, server_port))
recived_message, _ = client_socket.recvfrom(2048)
client_socket.close()

3.3 UDP 报文段结构

从报文结构可以看出UDP的结构并不复杂,在头部中除了源端口和目的端口(这里不在赘述)我们还看到了长度校验和长度用于告诉应用程序当前的报文的应用数据的长度是多少,那么应用程序就可以根据这个报文长度去建立buffer去读取出数据;校验和主要用于差错检测,但是它对差错修复是无能为力的,上层应用程序只能收到相应的警告。

下图是UDP报文结构:

image.png

3.4 UDP 的优缺点

3.4.1 优点

    这里谈到UDP的优点其实也是在谈它的一些应用场景。

  • 关于何时发送什么数据的应用层控制更为精细。例如一些实时通讯、实时视频流等,能相对容忍一些数据的丢失,但需要报文段高速的传输。如果使用TCP模型就不太合适,因为TCP会对网络的信息的传输质量(拥塞情况)而进行拥塞控制,导致一些退让行为导致传输速率上不去。所以有些应用在TCP的协议下会尽可能的并发线程进行传输去抢占带宽。

  • 无需建立连接的情况。如DNS如果建立在TCP的基础上,那么每次连接都要经历三次握手,那个解析效率那得放慢3倍以上的效率。

  • 无连接状态。如一些支持大量活跃用户的直播流,因为如果用TCP,那么TCP需要在系统中维护大量的连接状态,如发送缓存,接收缓存、拥塞控制参数和序列号和确认号等;那么在同一软件中使用TCP会大大降低用户的连接数;

3.4.2 缺点

    谈到UDP缺点,那基本就是对传输流完全没有任何的控制。

  • 如缺乏拥塞控制,不进行公平退让导致网络拥堵严重;
  • 如人尽皆知数据丢包后不进行数据的重传;
  • 如没有流量控制,导致网络设备数据报大量溢出,从而产生丢包;

3.5 一些扩展知识

3.5.1 KCP协议

     KCP 是一个快速可靠协议,能以比 TCP 浪费 10%-20% 的带宽的代价,换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效。

  • 这里只是简单的介绍一下,你可以理解为它就是在 UDP 协议封装了一层 TCP,但它是更优的和更灵活可配置的TCP;为什么这么说?因为它加入不少的控制方法对UDP进行了控制(比TCP少),然后以更优的方式进行快速重传、更小的RTO进行重传等,这样一来会消耗更多的带宽资源,但换来更高的传输速率;后面等看完 TCP 再回头对比一下 KCP 的一些控制手段,你就能体会高延迟下 KCP 的优势。TCP 在高延迟的网络中受拥塞控制,传输的窗口大小会被大大压缩从而影响了传输的速率。

  • 源码地址:github.com/skywind3000…

3.5.2 QUIC协议

     QUIC (Quick udp internet connection)快速UDP互联网连接, 它是一个应用层的协议对UDP进行封装;

4. 面向连接的 TCP

4.1 前言

     众所周知 TCP 是有这些特性面向连接可靠传输流量控制拥塞控制 等特性;如果有你没听说过的特性,那正好今天我们一起来谈一谈,如果我有表达不明确或者说错的地方,请在评论区指出我理解错误的地方。(后面会出现比较多的术语,请耐心细品)。

     后面会穿插介绍一些 TCP 日常可能会遇到的事件。

4.2 面向连接

     总说TCP面向连接的,那到底什么是面向连接?这是因为在一个应用进程可以开始向另一个进程发送数据之前,这两个进程必须先相互“握手”,即它们必须先相互发送某些预备文段,以建立确保数据传输的参数。这个动作是很有必要的,因为我们是要在一条不可靠的三层网络中进行可靠的传输。
    到这里我们先暂停一下对TCP面向连接的描述,直接上一小段Python代码来解析一下这个连接的过程。

4.2.1 Python 代码解析

  • TCP中服务端会监听一个端口并生成套接字(server socket)进行监听,当客户端进行连接时会向服务端的监听端口发出一个请求(SYN包,不急知道是什么后面会说),服务端会响应这个请求,这么一个过程我们称之为三次握手(这个词也听了这么久,懂的都懂了,再忍一会)。三次握手完了之后,TCP会为客户专门生成一个连接套接字(connection socket)用于数据传输,一但建立完成就会维护对应的一组收发缓存变量套接字(socket)

    套接字:UDP是二元祖(目的IP,目的端口);TCP是四元祖(源IP,源端口,目的IP,目的端口)

  • TCP 进程的套接字

Xnip2021-04-119_00-11-56.jpg

  • tcp_server.py
from socket import *
# 定义端口
server_port = 12000
# 选用一种tcp的套接字
server_socket = socket(AF_INET, SOCK_STREAM)
# 当端口被释放后能立即重用,因为系统默认是要等2分钟才可以使用的。
server_socket.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
# 绑定端口生成套接字
server_socket.bind(('', server_port))
# 监听套接字,且设置了最大连接数为1
server_socket.listen(1)
print("The server is ready to receive.")
# 调用accpet为客户端创建一个连接套接字,可以对比一下udp
conn_socket, addr = server_socket.accept()

while True:
    # 从缓存中取值
    sentence = conn_socket.recv(1024)
    m_sentence = sentence.upper()
    print(m_sentence)
    # 发送修改后的信息
    conn_socket.send(m_sentence)
    # 当收到exit 变进行连接中断
    if m_sentence == b'EXIT':
        conn_socket.close()
        break
        
server_socket.close()
  • tcp_client.py
import time
from  socket import *

server_name = "127.0.0.1"
server_port = 12000
client_socket = socket(AF_INET, SOCK_STREAM)
# 连接服务端
client_socket.connect((server_name, server_port))

while True:
    sentence = input(">")
    # 将数据写入缓存
    client_socket.send(sentence.encode())
    # 从缓存中读取数据
    modified_sentence = client_socket.recv(1024)
    print(f"From server: {modified_sentence.decode()}")
    # 让服务端断开连接
    if sentence == "exit":
        break

  • TCP进程简易传输图解

image.png

4.2.2 TCP 报文结构

  接下来我们先看看TCP报文段里面有什么,报文段又是怎么产生的?

  • 当上层应用向TCP发送一个大数据文件时,文件数据会被切割成一段段的报文段存放至TCP缓存中,那么报文段的大小应该是多少呢?这就要提到一个值最大报文段长度(Maximum Segment Size, MSS),而这个 MSS 值最终会受到链路层传输的数据长度即 最大传输单元(Maximum Transmission Unit, MTU)的限制。在以太网中链路层协议都具有1500字节的MTU,而报文段长度一般是1460字节,还有40字节是TCP/IP的首部。

  如下图所示,TCP报文段结构里面提到不少术语。下面,仅对需要关注的部分进行描述;

image.png

  • 源端口目的端口 相信到这里是不需要解析了;

  • 序号确认号 是用于可靠传输,简单来说是一个顺序关系,后面会继续谈。

  • 接收窗口 用于作为流量控制,该字段用于指示接收方愿意接收的字节数量。

  • 标志(FLAG) 标记出包的一些作用

    • URG - 紧急: 当 URG 设置为1时,紧急数据指针指向的数据会被优先传递, 无需经过TCP缓存;
    • SYN - 同步: 表示开始会话请求;
    • RST - 复位: 用来关闭异常连接,即不需要通过4次分手;
    • PSH - 推送: 尽快将数据传递到上层,这种在交互式终端就会看到这个标记的包;
    • ACK - 应答: 用于确认确认号的值时有效的;
    • FIN - 结束: 结束连接,即发起4次分手;
    • ECE - 显示拥塞回显: 这是和拥塞控制有关系,是通过路由设备(支持ECN功能)为报文段添加的一个flag,后面讲解拥塞控制再详谈;
    • CWR - 拥塞窗口减少: 发送端将通过降低发送窗口的大小来降低发送速率, 后面讲解拥塞控制再谈;
  • 最后看看TCP抓包截图感受下结构,这是一张发起四次分手的第一个包截图,可以从flags看出该包的作用:

Xnip2021-04-119_23-49-51.jpg

4.2.3 TCP 连接管理

  接下来聊一聊大家都熟悉却又有点陌生的“三次握手”和“四次分手”;

4.2.3.1 三次握手
  • 为什么要 三次握手 ?而不是“两次握手”?

    其实很好理解,为的就是让双方保明确通信协议及一些传输参数。简单来说就是:请求连接[client] -> 允许连接[server] -> 确认连接[client]

  • 先看看三次握手图解(可能你已经看过很多次了,这次或许有更多的收获?)

    image.png

    • 照例分解一下

      第一步:客户端向服务端发起了一个特殊报文段,这个报文段里的FLAG(上面提到过FLAG)中的SYN标志位会设置为 1。客户端会随机设置一个初始client_isn号,需要服务端响应一个ack为client_isn + 1 的报文段;

      第二步:一旦服务端收到TCP报文段中含有SYN,便开始分配对应的缓存和变量,然后向客户端发送允许报文段,ack变成client_isn + 1 与此同时会发送自己的初始序列号为 server_isn;

      第三步:客户端接收到 SYNACK 报文段后也会为该连接分配缓存和变量,准备完后再发一个确认报文段;该报文段中 SYN 标志位设置为 0,因为连接已经建立了,也代表了三次握手的结束;

    • 这样的分解其实还不足以让我们比平时更多的去了解这一过程,接下来抓包看看真实的情况;(client:55816,server:12000)

      1. 第一个报文段 (client->server),这里可以看到flags分别为SYNECNCWR,除了我们熟知的SYN还多了2个标志位(上面已经赘述过了),但不妨碍我们能确认的它是一个连接请求包,原因是SYN被设置为 1 且没有ACK为 0 ;

        image.png

      2. 第二个报文段 (server->client),flags出现了 SYNECNACK ,由此我们判断出它是一个允许连接的包。

        image.png

      3. 第三个报文段 (client->server),flags只出现了 ACK,由此可以判断连接已经完成,但这里可以看到客户端发送的报文段窗口值改成12759,可以对比上两个报文段都是为65535。这其实在上个报文段中的option字段中存在一个window scale字段,协商需要修改发送窗口大小(),至此三次握手结束。

        image.png

      4. 第四个报文段 (server->client),这是一个服务端明确向客户端确认 TCP window update的报文段,往后在如果没有拥塞的情况下会以这个window size进行传输数据;(这个截图只是为了加深一下理解,不属于原理上的三次握手)

        image.png

   这里提一个注意小点:在抓包时,如果是抓回环地址所在网卡(如 lo0)默认的MTU 是16384字节,所以我们会看到 MSS 值为16344字节,为什么会相差40字节?不知道的话,回头看看上面报文结构。

4.2.3.2 四次分手
  • 为什么要 四次分手

    其实也是非常好理解,数据传输完毕要结束连接是不是要告知对方做资源的回收,上面已经说过在连接建立阶段会为连接分配缓存、变量和socket等资源;同样的逻辑在Linux上进程也是可以看到的,如接收到退出的信号(kill 15)时,程序就应当去启动关闭流程(close)进行关闭处理;

  • 照例图解下“四次分手”,这个图加了一段在 TIME_WAIT 后面的定时等待到正式关闭,网络上很多文章都会忽略这段,其实这段的定时等待时间会直接影响机器的并发能力。

    image.png

    第一步:客户端发起了一个带有FIN标志的报文段,并进入 FIN_WAIT_1 状态,意思是我要请求关闭连接了;

    第二步:服务端收到一个FIN报文段后进入 CLOSE_WAIT,立刻回复一个 ACK, 意思是同意关闭连接。客户端收到一个ACK报文段后就立刻进入 FIN_WAIT_2

    第三步:服务端向客户端发送FIN报文段后便进入 LAST_ACK,在此过程中服务端会进行资源回收;。

    第四步:客户端收到 FIN 报文段后就,立刻回复最后的ACK,然后进入 TIME_WAIT状态;如果服务端收到确认报文段就, 就会让连接状态变为CLOSED;此时客户端在等上2倍的MSL(Max segment livetime),最大报文段生存时间)时间,这样可让TCP再次发送最后的 ACK 以防其丢失,这个时间在linux中默认值大部分是1分钟或2分钟,等待过后客户端也会把连接状态变为CLOSED, 并进行资源回收。

    其实谁是客户端和服务端这种描述也不是非常的准确,因为在实际情况中,往往第一个发起 FIN 报文段的不一定是客户端而是服务端,谁(发送方)首先发起FIN 报文段谁就会进入 FIN_WAIT_1,接收方接收后就会进入 CLOSE_WAIT;

4.2.3.3 聊聊连接状态

   看起来TCP的连接管理状态比较多,但其实我们比较经常关注的状态是在“四次分手”里面的 TIME_WAITCLOSE_WAIT

  • CLOSE_WAIT

    这是一个在服务端的确认关闭连接的状态。在某些情况下我们会看到服务端有大量的 CLOSE_WAIT 存在,而导致了这一情况发生,大概率是因为客户端发起请求关闭连接后就立刻退出了把服务端凉在那了(即不往下走关闭流程)。在liunx系统中会默认等待7200秒才开始进行连接探测,默认探测大约11分钟(间隔75秒的9个探头)后,空闲连接才会被终止;那么下面我们可以通过某些系统参数进行优化。

    sysctl -a |grep live
    net.ipv4.tcp_keepalive_intvl = 75
    net.ipv4.tcp_keepalive_probes = 9
    net.ipv4.tcp_keepalive_time = 7200
    # 被探测前的空闲时间
    echo 120 > /proc/sys/net/ipv4/tcp_keepalive_time
    # 探测间隔时间
    echo 2 > /proc/sys/net/ipv4/tcp_keepalive_intvl
    # 探头数
    echo 1 > /proc/sys/net/ipv4/tcp_keepalive_probes
    
  • TIME_WAIT

    这是一个在客户端最后等待关闭 2MSL 值后进行连接关闭及清理工作。在高并发场景下主动发起FIN报文段断开连接,那么此时就会出现大量的TIME_WAIT

    # 打开配置
    vi /etc/sysctl.conf
    
    # 表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;
    net.ipv4.tcp_tw_reuse = 1
    # 表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。
    net.ipv4.tcp_tw_recycle = 1
    # 修改系統默认的TIMEOUT时间,这个就是MSL时间
    net.ipv4.tcp_fin_timeout = 60 
    
    # 启用配置
    sysctl -p 
    

4.3 可靠传输

4.3.1 一些概念及原理

  如何在一条不可靠的信道上建立一条可靠的传输信道?我们先了解一些概念。

  • RDT(reliable data transfer protocol, 可靠数据传输协议),这是一个用于建立可靠传输的协议,里面定义了如何构造出如何一个可靠传输信道。
    • FSM (Finite-State-Machine, 有限状态机),这个概念定义了发送方和接收方是如何根据事件去流转状态并产生对应的动作。如:何时需要发送ACK,何时需要重传等动作,何时向上交付。
    • SEQ(Sequence number,序号)与ACK(acknowledgment,确认),这个我相信大家都清楚按顺序的的确认机制。
    • 校验和,校验数据的完整性。
    • 定时器ARQ(Automatic repeat request, 自动重传协议),发送方会为每一个分组数据加入一个倒数的定时器事件,定时器时间到达后仍未收到 ACK 就会立即进入超时重传事件,重新发送分组。
    • 冗余数据分组(duplicate data packet),当因为网络延时等原因,发送方未能在超时时间内接收到ACK就会重传数据分组,因此接收方就会收到冗余的分组数据,协议就可以通过序号等方式甄别出已经接收过的分组。
    • 流水线协议(pipelining),允许发送方一次性发送多个分组(由窗口长度限制)而无须等待 ACK 才发送另一个分组。(其实这里有另一种传输协议叫 停等协议(stop-and-wait) ,它是发送一个分组然后需要等待一个 ACK 再继续发送分组,TCP 并不使用这种方式,它比较影响性能。)
      协议特性:
      • 分组序号增加必须唯一;
      • 协议的发送方和接收方需缓存多个分组。发送方应当能缓存那些已发送但没有确认的分组。接收方需要缓存那些已正确接收的但未交付的分组。
      • 差错恢复(处理丢失、损失及延时过大的分组)。两种滑动窗口协议, 窗口协议会发送在窗口长度范围内的所有分组,并等待分组确认,当前面的分组确认完成后就会向前移动,故称为滑动窗口。
        1. GBN(Go-back-N,回退N步),字母N常被称为窗口长度(window size)。发送方会一次性发送多个分组,如 1 ~ 5个分组,1~2 收到 ACK,但是 3 分组都未收到ACK,发送方会一直等待 3 分组后面的所有分组直至超时重传(重传后面所有分组),在这过程中即使后面的分组到达也会被丢弃,这种方式叫做累积确认。它的弊端是在延迟网络下耗费大量的无用功重传。
          • GBN 维护的发送窗口,它只维护发送窗口;(图片来自网络)

            image.png

        2. SR(Selective Repeat, 选择重传)让发送方仅重传那些它怀疑在接收方出错的分组而避免了不必要的重传(按需重传)。它也需要通过窗口来限制流水线中未完成、未被确认的分组并且缓存它们(即使失序到达),最后逐个地确认。它的弊端是发送方和接收方要各自维持一个窗口,但是一旦出现ACK分组丢失了,两边的窗口滑动时就会出现不一致。
          • SR 需维护一组窗口,下面看到的是窗口不一致时就会出现停滞;(图片来自网络)

            image.png

  • 一下子冒出这么多关键概念可能会比较晕,这些概念其实是RDT的概念并不代表 TCP会全部照抄而不添油加醋。TCP是可靠传输协议的一种实现,所以会再加入一些新的概念;

4.3.2 TCP 可靠传输

   在4.3.1中我们介绍了一些概念,在 TCP 中引用了大部分的概念,下面尽可能地清楚且详细解析一下:

  • 在分组传输时使用了SEQACK 来构建出有序的字节流以及校验和保证数据的完整性。(这个应该比较容易理解);

  • 在分组出现丢失问题时,TCP采用了超时以及重传机制来处理这类问题,这就涉及刚刚提到的定时器重传协议;它间隔侦测分组的RTT(Round trip time,往返时间)来估算出一个超时时间,而且超时时间需要比RTT大。

    当超时时间到了之后会发生分组重传,只是每次TCP重传同一个分组都会是上一次超时值的两倍。如第一次超时是0.75秒,下一次同样的分组超时时间是1.5秒。

    在重传的机制上 TCP 除了 超时重传(Retransmission timeout, RTO)还加入了快速重传(fast retransmit);

    • 当发送方等待到3个冗余 ACK 的时候就会立刻重传而无需等待分组超时。那为什么是3个冗余ACK?因为 ACK 的发送是由接收方决定什么时候发的这个ACK,所以在延迟网络中会产生一连串的冗余ACK, 这些ACK的产生和前面提到FSM机制有联系,在某个状态下通过不同的事件来决定。(如:序号大的分组先到达了,但期望的分组还没到,这时接收方就会立刻发送一个期望分组的ACK)。
  • 刚上面提到 TCP 使用 流水线协议,那么它需要有一个窗口来批量的发送多个分组,在协议中提到 GBNSR 方法,其实 TCP 两种方法都有使用,但并不完全使用更像是一种融合;
    TCP的流水线协议,在窗口内一次性发送多个分组报文,接收方只要是正确接收的报文段(失序的)都会被缓存起来,然后会被SACK(selective acknowledgment, 选择确认)而不采用累积确认,最后再结合SR机制来跳过已经被确认的分组,只重传未被确认的分组。
    但总体看下来还是比较偏向SR的机制选择性的重发,而且也需要维护一组收发窗口。

我们现在知道的是TCP滑动窗口的作用,一种是提供可靠性(通过重传方式确保所有分组的ack),还有另一种是提供流控特性(窗口的大小来控制)。至于流量控制的特性我们下一节再谈。

Q: 那么窗口的容量是多少呢?先别急着看答案,提示一下widnow在报文结构中是一个16bit的字段。

...
...
...

TCP的标准窗口最大为2^16-1=65535个字节

4.4 流量控制

  上面我们谈到了滑动窗口,在TCP中滑口的大小是取决于rwnd(接收窗口)和另一个cwnd(拥塞窗口),当哪个值小哪个值就是滑动窗口,即TCP中window的大小。在这一节我们谈谈rwndcwnd留到拥塞控制的时候再聊。

  TCP 为了消除发送方使接收方缓存溢出的可能性,因此需要对双方流量做一个控制达到一个速度匹配的状态,即发送方的发送速率与接收方应用程序的读取速率相匹配。所以TCP在连接两端的发送方各自维护一个rwndrwnd的值是由接收方在发送ACK时放入window字段发送给发送方,而且这个值是动态的它的大小是取决于接收方读取缓存数据的速度。

  下面看看接收方缓存和接收窗口的关系;

image.png

4.5 拥塞控制

   TCP拥塞控制这个话题,在日常工作中如果不是专注于传输层的开发可能完全没关注过,但实际上拥塞控制才是TCP的灵魂能力。可以想像一下这样一个场景,如果在一个没有拥塞控制的传输通道内,大家都会以自己最高的速率进行报文段的传输。这样很容易造成网络设备的吞吐量过载尽而发生丢包,丢包后发送方又会进行最大窗口的报文段重传,如此反复往来最终造成网络瘫痪。而拥塞控制就是为了尽量让网络设备接近它最高核载进行工作(压榨它的极限),但又不至于它出现过载的情况。

  • 如下图所示(图片来自网络)

image.png

  TCP的拥塞控制有四种算法。慢启动拥塞避免快速重传快速恢复,这里提到快速重传上面也已经赘述过了所以下面就不再重复。

4.5.1 慢启动

  先介绍一个比较重要的概念 拥塞窗口(congestion window, cwnd), 这在发送方都维护的这么一个窗口变量,其值取决于网络的拥塞程度而且是动态变化,它会对发送方进行一个发送的流程限速。(在上面其实已经提到过拥塞窗口,最终大概率会作为window的值)

  假设当前发送方拥塞窗口(cwnd)的值为 1个 MSS值,当传输了第一个报文段并且接收到ACK后,cwnd就会增加 1个 MSS (现在window size 是 2MSS)。那么下一次就会同时发送2个报文段,等待接收到2个报文段后就会再增加2个 MSS。这么循环往复直到cwnd的值变为16,16这个值我们称为ssthresh(慢启动阀值),其实它也是一个动态值等下一阶段拥塞避免时,我们再谈谈。

  • 慢启动,如下图所示 image.png

4.5.2 拥塞避免

  经过一番慢启动之后我们便进入拥塞避免,此时的cwnd变成变成了线性加 1,如同时发出16个报文段并收到16个报文段后cwnd才会+1,也就是cwnd变为 17 MSS

  现在假设cwnd是24MSS,在下次报文传输中发送方收到了3个冗余ACK,在此之前我们说过TCP如果收到3个冗余ACK就会进行快速重传。并且ssthresh值就会调整为12MSS (cwnd/2)即cwnd的一半,最后cwnd设置位 1 MSS 并进入快速恢复慢启动 阶段。

4.5.3 快速恢复

  快速恢复TCP里面是一个推荐组件,但其实已经纳入在新版的TCP里面而且不断的迭代更新。我们现在来看看最早期的拥塞控制(Tahoe 和 Reno)算法,快速恢复这个机制在Reno算法中才出现的,后面的算法也会沿用这个概念。

参考:wikpedia

  这两种算法在对于丢包事件判断都是以RTO(retransmission timeout,超时重传)和冗余ACK为条件,但两者的做法是不同的;

  • 发现3个冗余ACK的情况:

    • Tahoe:如果收到三次重复ACK——即第四次收到相同ACK,Tahoe算法则进入快速重传,将慢启动阈值改为当前拥塞窗口的一半,将拥塞窗口降为1个MSS,并重新进入慢启动阶段。
    • Reno:如果收到三次重复ACK,Reno算法则进入快速重传,只将拥塞窗口减半来跳过慢启动阶段,将慢启动阈值设为当前新的拥塞窗口值,进入快速恢复
  • RTO(超时重传) 的情况

    • 两个算法都是将拥塞窗口降为1个MSS,然后进入慢启动阶段。

  下面看看图解(图片来自网络):

image.png

4.5.4 拥塞通告(扩展)

   ECN(Explicit Congestion Notification,拥塞通告)允许拥塞控制的端对端通知而避免丢包。ECN为一项可选功能,如果底层网络设施支持,则可能被启用ECN的两个端点使用。在ECN成功协商的情况下,ECN感知路由器可以在IP头中设置一个标记来代替丢弃数据包,以标明阻塞即将发生。数据包的接收端回应发送端的表示,降低其传输速率,就如同在往常中检测到包丢失那样。

4.6 TCP公平性

  我个人理解所谓的TCP的公平退让原则其实就是受到拥塞控制的影响,因为只要出现丢包就会大幅度降低速率。因为拥塞窗口总体看来其实“加性增,乘性减”,所以在多个TCP端在传输的时候就会通过丢包的方式往复的触发拥塞控制,最终达到让速率稳定在一个不丢包的状态。

4.7 一些计算

  • 计算TCP吞吐量的公式
    • 公式:TCP窗口大小(bits) / 延迟(秒) = 每秒吞吐量(bits)

    • 比如说windows系统一般的窗口大小为64K, 中国到美国的网络延迟为150ms.

    • 64KB = 65536 Bytes

    • 65536 * 8 = 524288 bits

    • 每秒吞吐量(bits) = 524288 / 0.15 = 3495253 bit/s = 0.43MB/S 所以就算是10M专线,那么单个Tcp连接也最大只能达到0.31M的速度。

  • 计算最优TCP窗口大小的公式
    • 公式:带宽(bits每秒) * 往返延迟(秒) = TCP窗口大小(bits) / 8 = TCP窗口大小(字节)
    • 如 10Mbps的带宽和 150ms 的延迟的例子中,可以计算如下:
    • 10 * 1024* 1024 bps * 0.15 seconds = 1572864 bits / 8 = 1,572,864 Bytes = 1.5 MB

5. 写在最后

  看官都看到这了,文章肝出来不易,点个呗;后面如果有知识点补充便继续更新。

5.1 参考资料