TCP为何做三次握手和四次挥手

TCP时可靠的,面向传输的传输协议
三次握手:

  1. 第一次和第二次握手是为了保证服务端能接收到客户端的消息并能做出正确应答
  2. 第二次和第三次握手是为了保证客户端能收到服务端的消息并能做出正确应答

四次挥手

  1. 服务器在LISTEN状态下,当收到对方的FIN报文时,仅表示对方不再发送数据但还能接收数据
  2. 还需要服务端将所有数据发送到客户端在确认关闭

TIME_WAIT状态

  1. 可靠的终止TCP链接

这样可让TCP再次发送最后的ACK以防这个ACK丢失

  1. 保证让迟来的TCP报文段有足够的时间被识别并丢弃

每个具体TCP实现必须选择一个报文段最大生存时间MSL。它是任何报文段被丢弃前在网络内的最长时间。

大量出现TIME_WAIT状态

如果业务服务器的压力造成服务端大量主动关闭连接,就会产生大量的TIME_WAIT状态的TCP链接。这些链接会在数分钟内像僵尸一样堆在那里,榨干所有的连接数

TCP如何保证有序传输

既然 TCP 报文段作为 IP 数据报来传输,而 IP 数据报的到达可能会失序,因此 TCP 报文段的到达也可能会失序。TCP 将对失序数据进行重新排序,然后才交给应用层

  1. 主机每次发送数据时,TCP就给每个数据包分配一个序列号并且在一个特定的时间内等待接收主机对分配的这个序列号进行确认,
  2. 如果发送主机在一个特定时间内没有收到接收主机的确认,则发送主机会重传此数据包。
  3. 接收主机利用序列号对接收的数据进行确认,以便检测对方发送的数据是否有丢失或者乱序等,
  4. 接收主机一旦收到已经顺序化的数据,它就将这些数据按正确的顺序重组成数据流并传递到高层进行处理。

    TCP的慢启动

    初始化拥塞窗口大小,每收到一个ACK,拥塞窗口加1,每经过一个RTT,cwnd翻倍,当达到慢启动值,开始拥塞避免策略

    RTT 往返时延

TCP的拥塞避免

原来每收到一个ACK,cwnd加翻倍,现在只加1/cwnd,一轮RTT下来,收到cwnd个ACK,最后cwnd只增加1.

TCP的快速重传

TCP采用的是累计确认机制,即当接收端收到比期望序号大的报文段时,便会重复发送最近一次确认的报文段的确认信号,我们称之为冗余ACK
如图所示,报文段1成功接收并被确认ACK 2,接收端的期待序号为2,当报文段2丢失,报文段3失序到来,与接收端的期望不匹配,接收端重复发送冗余ACK 2。
TCP/UDP - 图1
这样,如果在超时重传定时器溢出之前,接收到连续的三个重复冗余ACK(其实是收到4个同样的ACK,第一个是正常的,后三个才是冗余的),发送端便知晓哪个报文段在传输过程中丢失了,于是重发该报文段不需要等待超时重传定时器溢出,大大提高了效率。这便是快速重传机制。

TCP的快速恢复

发送端收到三次重复 ACK 之后,发现丢包,觉得现在的网络已经有些拥塞了,自己会进入快速恢复阶段。

  • 拥塞阈值降低为 cwnd 的一半
  • cwnd 的大小变为拥塞阈值
  • cwnd 线性增加

TCP拥塞控制

在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分网络性能就要变坏,这种情况就叫做网络拥塞。
在计算机网络中数位链路容量(即带宽)、交换结点中的缓存和处理机等,都是网络的资源。
若出现拥塞而不进行控制,整个网络的吞吐量将随输入负荷的增大而下降。
TCP/UDP - 图2
为了进行拥塞控制,TCP 发送方要维持一个拥塞窗口(cwnd) 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个。
TCP 的拥塞控制采用了四种算法,即:慢开始、拥塞避免、快重传和快恢复。在网络层也可以使路由器采用适当的分组丢弃策略(如:主动队列管理 AQM),以减少网络拥塞的发生。

  • 慢开始:由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。cwnd 初始值为 1,每经过一个传播轮次,cwnd 加倍。
  • 拥塞避免:拥塞避免算法的思路是让拥塞窗口 cwnd 缓慢增大,即每经过一个往返时间 RTT 就把发送方的 cwnd 加 1
  • 快重传和快恢复:在 TCP/IP 中,快速重传和快恢复(fast retransmit and recovery,FRR)是一种拥塞控制算法,它能快速恢复丢失的数据包。如果接收机接收到一个不按顺序的数据段,它会立即给发送机发送一个重复确认。如果发送机接收到三个重复确认,它会假定确认件指出的数据段丢失了,并立即重传这些丢失的数据段

    TCP流量控制

    首先双方三次握手,初始化各自的窗口大小,均为 200 个字节。
    假如当前发送端给接收端发送 100 个字节,那么此时对于发送端而言,SND.NXT 当然要右移 100 个字节,也就是说当前的可用窗口减少了 100 个字节,这很好理解。
    现在这 100 个到达了接收端,被放到接收端的缓冲队列中。不过此时由于大量负载的原因,接收端处理不了这么多字节,只能处理 40 个字节,剩下的 60 个字节被留在了缓冲队列中。
    注意了,此时接收端的情况是处理能力不够用啦,你发送端给我少发点,所以此时接收端的接收窗口应该缩小,具体来说,缩小 60 个字节,由 200 个字节变成了 140 字节,因为缓冲队列还有 60 个字节没被应用拿走。
    因此,接收端会在 ACK 的报文首部带上缩小后的滑动窗口 140 字节,发送端对应地调整发送窗口的大小为 140 个字节。
    此时对于发送端而言,已经发送且确认的部分增加 40 字节,也就是 SND.UNA 右移 40 个字节,同时发送窗口缩小为 140 个字节。
    这也就是流量控制的过程。尽管回合再多,整个控制的过程和原理是一样的。

    TCP快速打开的原理(TFO)

    方式SYN Cookie有使用SYNcookie的技术,在服务端接收到SYN后不立即分配连接资源,而是根据这个SYN计算出一个Cookie,连同第二次握手回复给客户端,在客户端回复ACK的时候带上这个Cookie值,服务端验证 Cookie 合法之后才分配连接资源。

    防止只发SYN包不ack的攻击

这个cookie同样可以实现TFO

  1. 首次三次握手中,服务端给客户端的回复将cookie放到TCP报文的fast open选项照顾你,然后给客户端返回
  2. 后面的三次握手中,客户端将之前缓存的cookie、SYN和HTTP请求发送给服务端,服务端验证了cookie的合法性,就正常返回SYN + ACK,现在服务端能向客户端发HTTP响应了(三次握手还没建立,就可以返回http响应了)
  3. 当然,客户端的ACK还要正常传回来

    客户端最后握手的 ACK 不一定要等到服务端的 HTTP 响应到达才发送,两个过程没有任何关系。

image.png

TCP keep-alive

当有一方因为网络故障或者宕机导致连接失效,由于 TCP 并不是一个轮询的协议,在下一个数据包到达之前,对端对连接失效的情况是一无所知的。
这个时候就出现了 keep-alive, 它的作用就是探测对端的连接有没有失效。
在 Linux 下,可以这样查看相关的配置:

  1. sudo sysctl -a | grep keepalive
  2. // 每隔 7200 s 检测一次
  3. net.ipv4.tcp_keepalive_time = 7200
  4. // 一次最多重传 9 个包
  5. net.ipv4.tcp_keepalive_probes = 9
  6. // 每个包的间隔重传间隔 75
  7. snet.ipv4.tcp_keepalive_intvl = 75

不过,现状是大部分的应用并没有默认开启 TCP 的keep-alive选项,为什么?
站在应用的角度:

  • 7200s 也就是两个小时检测一次,时间太长
  • 时间再短一些,也难以体现其设计的初衷, 即检测长时间的死连接

因此是一个比较尴尬的设计。

quic协议

TCP 协议连接建立的成本相对较高,在网络基建本身就已经越来越完善的情况下,TCP设计本身的问题便暴露了出来。
而使用UDP链接无法确保数据传输的可靠性

QUIC 是 Quick UDP Internet Connections 的缩写,谷歌发明的新传输协议。与 TCP 相比,QUIC 可以减少延迟。
QUIC 协议可以在 1 到 2 个数据包(取决于连接的服务器是新的还是已知的)内,完成连接的创建(包括 TLS)
image.pngTCP/UDP - 图5

QUIC 协议的主要目的,是为了整合 TCP 协议的可靠性和 UDP 协议的速度和效率。QUIC只需要一次往返就能建立HTTPS连接

TCP/UDP - 图6
QUIC使用包序号代替了TCP的序列号,并且每个包序号都严格递增,如果包N丢失了,重传的包N的号码已经不是N,而是一个比N大的值。这样依靠严格递增的QUIC无法保证数据的顺序性和可靠性。于是QUIC引入了流偏移量的概念,即一个流可以经过多个包传输,包的序号严格递增,没有依赖,但包的Payload如果是流的话,就要依靠流的偏移量来保证数据地顺序
TCP/UDP - 图7
TCP/UDP - 图8

假设 Packet N 丢失了,发起重传,重传的 Packet Number 是 N+2,但是它的 Stream 的 Offset 依然是 x,这样就算 Packet N + 2 是后到的,依然可以将 Stream x 和 Stream x+y 按照顺序组织起来,交给应用程序处理。

TCP粘包

如果客户端连续不断的向服务端发送数据包时,服务端接收的数据会出现两个数据包粘在一起的情况。

  1. TCP 是基于字节流的,虽然应用层和 TCP 传输层之间的数据交互是大小不等的数据块,但是 TCP 把这些数据块仅仅看成一连串无结构的字节流,没有边界;
  2. 从 TCP 的帧结构也可以看出,在 TCP 的首部没有表示数据长度的字段。

基于上面两点,在使用 TCP 传输数据时,才有粘包或者拆包现象发生的可能。一个数据包中包含了发送端发送的两个数据包的信息,这种现象即为粘包。
接收端收到了两个数据包,但是这两个数据包要么是不完整的,要么就是多出来一块,这种情况即发生了拆包和粘包。拆包和粘包的问题导致接收端在处理的时候会非常困难,因为无法区分一个完整的数据包。

场景:

  1. 要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包。
  2. 待发送数据大于MSS(最大报文长度),TCP在传输前将进行拆包。
  3. 要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包。
  4. 接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包。


在于如何给每个数据包添加边界信息

解决方法:

  1. 发送端给每个数据包添加包首部,首部中应该至少包含数据包的长度,这样接收端在接收到数据后,通过读取包首部的长度字段,便知道每一个数据包的实际长度了。
  2. 发送端将每个数据包封装为固定长度(不够的可以通过补0填充),这样接收端每次从接收缓冲区中读取固定长度的数据就自然而然的把每个数据包拆分开来。
  3. 可以在数据包之间设置边界,如添加特殊符号,这样,接收端通过这个边界就可以将不同的数据包拆分开。

    MTU, mss , 半关闭

  • MTU 最大传输单元,受协议限制,以太网1500, IP 65535
  • mss 受MTU影响,标识一个数据包携带数据的上限数
  • win 滑动窗口,当前本端能接受的数据上限值(字节)

    socket

    一个缓冲区fd对应两个buffer
    image.png

    socket

    // domain 使用的协议 AF_INET AF_INET6 AF_UNIX  
    // type SOCK_STREAM 流式协议,一般使用TCP进行传输   SOCK_DGRAM 报式协议,一般使用UDP连接
    // protocol 传0代表默认协议
    // 成功返回指向新创建的socket的文件描述符, 失败返回-1,不包含地址和端口
    int socket(int domain, int type, int protocol);
    

    socket打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据。

bind

// sockfd 文件描述符
// addr 构造出IP地址加端口号
// addrlen sizeof(addr)长度
// 将地址端口和套接字进行绑定(通过文件描述符),成功返回0
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)

listen

// 同时允许多少个客户端建立连接,多出的需要等待(处于3次握手中的对象可以有多少个)
// 查看系统默认backlog cat /proc/sys/net/ipv4/tcp_max_syn_backlog
int listen(int sockfd, int backlog);

accept

// 服务端调,有阻塞等待功能
// sockfd 文件描述符
// addr 传出参数,返回链接客户端地址信息,含IP地址和端口号
// addrlen 传入传出参数(值-结果),传入sizeof(addr)大小,函数返回时返回真正接收到地址结构体大小
// 接收一个连接请求, 返回一个新的文件描述符,用于和客户端通信(发送数据和接收数据)
int accept(int sockfd, struct sockaddr *addr,  sockle_t *addrlen)

connect

客户端可以依赖隐式绑定,不执行bind而直接connect服务端,自动分配端口号去进行绑定

// 客户端调
// sockfd socket文件描述符
// addr 传入参数,指定服务器端地址信息,含IP地址和端口号
// addrlen 传入参数,传入sizeof(addr)大小
// 客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)

TCP状态转换

TCP/UDP - 图10