【网络】传输层-TCP协议详解

文章目录

    • TCP报文格式
    • TCP协议特性
      • 面向连接
        • 连接管理机制(三次握手四次挥手)
          • 三次握手四次挥手重点问题
        • 状态变化
          • 理解CLOSE_WAIT状态
          • 理解TIME_WAIT状态
      • 可靠传输
        • 确认应答
        • 超时重传
        • 校验和
        • 流量控制
        • 拥塞控制
        • TCP效率优化(性能挽救)
          • 滑动窗口(重点)
          • 延迟应答
          • 捎带应答
      • 面向字节流
        • TCP粘包问题
          • 如何避免粘包问题?
    • TCP异常情况
    • 基于TCP应用层协议
    • 用UDP实现可靠传输(经典面试题)
    • 理解listen的第二个参数(backlog)

TCP全称为 " 传输控制协议(Transmission Control Protocol"

TCP是保证可靠的协议,为了保证可靠TCP做了更多的处理,效率会降低而UDP不保证可靠,所以更加的简单,速度也更快

TCP报文格式

查看Linux系统下的/usr/include/netinet/tcp.h文件,可以看到UDP的报文格式

【网络】传输层-TCP协议详解_第1张图片

分别是,源端端口,对端端口,序号,确认序号,数据偏移(报头长度),保留位,标志位,窗口大小,校验和,紧急指针

【网络】传输层-TCP协议详解_第2张图片

  • 源端端口号:表示发送端端口号,字长16位
  • 对端端口号:表示接收端端口号,字长16位
  • 序号:有时也叫序列号,是指发送数据的位置

序号不会从0或1开始,而是建立连接时计算机生成随机数作为初始值,通过SYN包传给接收端主机

  • 确认序号:也叫确认应答号,是指下一次应该收到的数据序号

实际上它指已收到确认应答号减一为止的数据,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收

  • 数据偏移(报头长度):4比特位,单位为4字节,也就是说,这个值最小为5(20字节固定大小),最大为15(报头最大长度60字节)

在分离tcp报头和有效载荷时,先分离20字节固定大小,然后取数据偏移,减去固定的20个字节大小,剩余的就是有效载荷,也就是说,选项最多有40字节大小

  • 保留:暂时没用,留作以后使用,一般为0

  • 标志位:用于标识报文类型

    • URG:表示包中有紧急数据需要处理
    • ACK确认应答

    TCP规定,除了最初建立连接时的SYN包之外,剩下所有的包该位置必须置1

    • PSH:表示告诉上层应用尽快读取缓冲区的数据,缓冲区有水位线的概念,只有到达水位线才会交付数据,而PSH是让其收到数据不存入缓冲区,直接交付
    • RST重建连接标识。当RST=1时,表明TCP连接中出现严重错误(如由于主机崩溃或三次握手第三次ACK丢失),必须释放连接,然后再重新建立连接,我们把携带RST标识的报文称为复位报文段
    • SYN:同步序号标识,用来发起一个连接,我们把携带SYN标识的报文称为同步报文段
    • FIN:发端完成发送任务标识。用来释放一个连接,我们把携带FIN标识的报文称为结束报文段
  • 窗口大小自身接收缓存区剩余空间大小,用于实现滑动窗口机制,进行流量控制

  • 校验和:校验收到的数据与对方发送的数据是否完全一致,和UDP的校验类似,区别在于TCP的校验和无法关闭

  • 紧急指针:标识本报文段中紧急数据(带外数据)的指针,正确来讲,从数据部分的首位到紧急指针所指的位置为止为紧急数据,因此也可以说紧急指针指出了紧急数据的末尾在报文段中的位置。当URG为1时有效

  • 选项:选项字段用于提高TCP的传输性能,由于根据数据偏移(首部长度)进行控制,所以长度最大为60-20,也就是40字节

TCP协议特性

TCP协议的特性有 面向连接可靠传输面向字节流

面向连接

连接管理机制(三次握手四次挥手)

在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接

  • 三次握手

当上层调用connect的时候,三次握手就会由客户端发起向服务器发送SYN报文,服务器收到SYN报文后,就会向客户端回复ACK确认,这时服务器是默认连接状态,收到连接请求后就会默认同意,所以在回复客户端ACK的时候,会在ACK报文中加上SYN,客户端收到SYN+ACK后,就会向服务器发送ACK确认,当服务器收到ACK后,双方的连接就会建立成功

  • 四次挥手

当客户端或者服务器任意一方调用close关闭文件描述符或者直接退出进程时,关闭方会向被关闭方发送一个FIN报文被关闭方收到FIN报文,就会立即关闭方发送ACK应答,然后完成剩下的报文发送,当被关闭方完成报文发送,就会向关闭方发送FIN报文关闭方收到FIN后,就会给被关闭方ACK应答,并且进入TIME_WAIT状态被关闭方收到ACK后,就会关闭连接关闭方TIME_WAIT时间过后,也会关闭连接

【网络】传输层-TCP协议详解_第3张图片


三次握手四次挥手重点问题
  • 为什么要进行三次握手,一次或者两次行不行?
  1. 当服务器一收到SYN请求就立即建立连接,也就是一次或者两次握手,这样虽然可以成功建立连接,但是如果有恶意用户一次发送大量SYN请求(SYN洪水攻击),服务器直接建立连接,那么服务器就会负载不了这么多连接而宕机
  2. TCP是全双工通信,建立连接的核心要务保证双方通信信道的成功通信三次握手是验证双方通信成功建立的最小次数
  3. 连接建立异常情况下,要保证已经建立的连接在客户端

双方三次握手建立连接,双方为了维护连接,都会在系统层面创建对应的结构体来管理连接,也就是说,建立连接也要消耗系统资源,进行三次握手,发起连接的一方也就是客户端,在接收到对方的FIN报文后就会立即创建相应的数据结构,这时如果发给服务器的ACK丢包,那么客户端就不会建立连接,也就不会消耗资源,奇数次握手保证了客户端先建立连接,减少了服务器的资源浪费

  • 为什么握手只要三次,而挥手要四次?

三次握手,在服务端收到SYN报文时,服务器处于监听状态,这时服务器的状态是默认连接状态,也就是说,当客户端的SYN请求到来,服务器立马就能响应SYN报文,所以在进行对客户端的ACK应答时,可以在ACK报文中捎带上SYN连接请求,减少了一次SYN报文的发送,所以握手时只需三次。

而在进行连接断开时,客户端给服务器发送FIN报文,服务器收到FIN报文时,并不能立即回复FIN响应,要等服务器端连接的响应发送完,才能关闭连接,也就是说在对客户端的FIN进行ACK应答时,服务器还不能立即关闭连接,所以不能在进行ACK时捎带上FIN响应,要等到服务器调用close(sock)关闭连接时才能发送FIN请求断开和客户端的连接,FIN和对客户端的ACK不能合并发送所以需要四次挥手


状态变化

建立连接的状态转换

【网络】传输层-TCP协议详解_第4张图片

  • 服务端

[CLOSED -> LISTEN]服务器端调用listen后进入LISTEN状态, 等待客户端连接

[LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中, 并向客户端发送SYN确认报文

[SYN_RCVD -> ESTABLISHED]服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 连接建立成功,可以进行通信

  • 客户端

[CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段

[SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态,连接建立成功,开始通信


断开连接的状态变化

【网络】传输层-TCP协议详解_第5张图片

  • 服务器端

[ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close), 服务器会收到结束报文段, 服务器返回确认报文段并进入CLOSE_WAIT

[CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)

[LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接进入CLOSED状态

  • 客户端

[ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时, 向服务器发送结束报文段, 同时进入FIN_WAIT_1

[FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认, 则进入FIN_WAIT_2, 开始等待服务器的结束报文段

[FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段, 进入**TIME_WAIT, 并发出LAST_ACK

[TIME_WAIT -> CLOSED] 客户端要等待一个2MSL(Max Segment Life, 报文最大生存时间)的时间, 才会进入CLOSED状态


理解CLOSE_WAIT状态

CLOSE_WAIT状态是当服务器收到客户端的FIN报文,并向客户端发送ACK确认后所处的状态,这时的服务器已经不再接收来自客户端的消息,而是处理未发送的数据响应,而当数据处理完时,服务器会主动调用close(sock)来关闭文件描述符,也就是向客户端发送FIN报文,进入LAST_ACK状态,而当服务器**没有主动关闭文件描述符时,服务器就会一直处于CLOSE_WAIT状态**

如果服务器上挂满了大量的CLOSE_WAIT状态的连接,那么是服务端没有关闭和客户端通信的的文件描述符,导致4次挥手没有完成,而且还会导致文件描述符泄露(资源泄露)解决办法就是 close(sock)


理解TIME_WAIT状态

TIME_WAIT状态出现在主动断开连接的一方,当收到被关闭方的FIN报文时,关闭方就会处于TIME_WAIT状态,这个状态要持续2MSL时间

MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s

原因

  • 为了防止第四次挥手的丢包,如果第四次挥手也就是最后一个ACK丢包,没有被对方收到,那么对方并不会关闭连接,而是会超时重传,为了确保ACK丢包后能收到对方重发的FIN请求,所以要设置TIME_WAIT状态处理这种情况
  • 等待一段时间是为了本连接持续时间内所产生的所有报文都从网络上消失,使得下一个新的连接不会出现旧的连接请求报文

如果主动断开连接的一方是服务器,那么服务器就会处于TIME_WAIT状态,在这期间服务器不能再次监听同样的server端口,也就是说,当服务器主动关闭时,不能再次立即启动监听原来的端口号

在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的
比如服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短, 但是每秒都有很大数量的客户端来请求)这个时候如果由服务器端宕机,我们应该立即重启服务器,但是这时服务器处于TIME_WAIT状态,不能监听原来的端口,需要等待两分钟才能重新启动,那势必会造成极大的损失

使用上次写的简易HTTP服务器进行测试,在服务器被客户端连接时ctrl+c掉服务器,监控服务器的状态变化

所以我们要求服务器在处于TIME_WAIT状态时,任然要能够监听原来的端口号

使用setsockopt()设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符

在这里插入图片描述

这时再主动关闭服务器,可以看到在服务器处于TIME_WAIT状态时也能进行绑定

可靠传输

确认应答

在TCP中,当发送端的数据到达接受端主机时,接收端主机会返回一个已收到消息的通知,这个消息叫确认应答(ACK)

  • TCP将每个字节的数据都进行了编号. 即为序号

  • 每一个ACK都带有对应的确认序号, 意思是告诉发送者, 我已经收到了确认序号之前的所有数据; 下一次你从哪里开始发

序列号解决的按序到达的问题,确认序号保证的确认应答的问题,可以保证两端同时通信的全双工。解决了丢包和乱序的问题

【网络】传输层-TCP协议详解_第6张图片

【网络】传输层-TCP协议详解_第7张图片

确认应答其实是TCP的丢包检测,如果没有收到哪条报文的应答,就可以断定这个报文丢失了,或者是这个报文的应答丢失了

如果报文丢了,就要进行重新发送,这时就有了超时重传机制


超时重传

  • 主机A发送数据给B之后, 可能因为网络拥堵等原因, 数据无法到达主机B
  • 如果主机A在一个特定时间间隔内没有收到B发来的确认应答, 就会进行重发

【网络】传输层-TCP协议详解_第8张图片

但是, 主机A未收到B发来的确认应答, 也可能是因为ACK丢失了

【网络】传输层-TCP协议详解_第9张图片

因此主机B会收到很多重复数据. 那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉.

这时候我们可以利用前面提到的序号, 就可以很容易做到去重的效果


那么,重发超时的时间如何确定?

  • 最理想的情况下, 找到一个最小的时间, 保证 “确认应答一定能在这个时间内返回”
  • 但是这个时间的长短, 随着网络环境的不同, 是有差异的
  • 如果超时时间设的太长, 会影响整体的重传效率
  • 如果超时时间设的太短, 有可能会频繁发送重复的包

TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间

Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍. 如果重发一次之后, 仍然得不到应答, 等待 500ms 后再进行重传 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接


校验和

校验收到的数据与对方发送的数据是否完全一致,检测到不一致的数据就会丢弃重传


流量控制

接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,
就会造成丢包, 继而引起丢包重传等等一系列连锁反应.
因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control)

  • 其实流量控制的表现就是滑动窗口机制,滑动窗口机制通过对方ACK中的窗口大小字段来动态计算滑动窗口的大小
  • 窗口大小越大,说明滑动窗口越大,说明网络的吞吐量越高

如果接收端的缓冲区满了,就会将ACK中的窗口大小字段设置为0,这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端,而且接收端也会给发送端发送窗口更新通知,通知自己最新的窗口大小数据

【网络】传输层-TCP协议详解_第10张图片


拥塞控制

虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题

因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据,可能会导致整个网络环境出现拥塞瘫痪,所有主机的传输都会收到影响,所有主机都出现大量丢包的现象

这时所有的主机都要遵循TCP的规则,执行拥塞控制,而拥塞控制的一个应用就是慢启动机制

TCP引入慢启动机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据

【网络】传输层-TCP协议详解_第11张图片

此处引入一个概念拥塞窗口

  • 发送开始的时候, 定义拥塞窗口大小为1
  • 每次收到一个ACK应答, 拥塞窗口加1
  • 每次发送数据包的时候, 将拥塞窗口和接收端ACK报文中的窗口大小(接收缓冲区)做比较, 取较小的值作为实际发送的滑动窗口大小

也就是说,实际的滑动窗口大小 = min(ACK报文中的窗口大小,拥塞窗口大小)

像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指初使时慢, 但是增长速度非常快.
为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍
此处引入一个叫做慢启动的阈值
当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长

【网络】传输层-TCP协议详解_第12张图片

图片转自

https://blog.csdn.net/qq_41431406/article/details/97926927

https://www.bilibili.com/video/BV1c4411d7jb

  • 当TCP开始启动的时候, 慢启动阈值等于窗口最大值
  • 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1

少量的丢包, 我们仅仅是触发超时重传

大量的丢包, 我们就认为网络拥塞

当TCP通信开始后, 网络吞吐量会逐渐上升

随着网络发生拥堵, 吞吐量会立刻下降

拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案

TCP效率优化(性能挽救)

TCP为了实现可靠性传输,引入了很多机制,确认应答,超时重传等,但是这些机制会大大影响TCP的传输效率,为了在保证可靠性的前提下提高TCP的传输效率,引入了以下机制


滑动窗口(重点)

TCP为了实现可靠性,要对每一个收到的报文进行确认应答,这样每一个报文和应答是串行的,效率非常低,尤其是数据往返时间较长的时候

【网络】传输层-TCP协议详解_第13张图片

为了解决这个问题,TCP引入了窗口的概念

确认应答不再是对每个报文进行应答,而是以更大的单位进行确认,转发时间被大幅缩短。

也就是说,发送端主机,在发送了一个报文以后不必要一直等待确认应答,而是继续发送

【网络】传输层-TCP协议详解_第14张图片

窗口大小就是指无需确认应答而可以直接发送的最大值,其实就是对方的确认报文里填的窗口大小(不考虑拥塞窗口)

【网络】传输层-TCP协议详解_第15张图片

滑动窗口的滑动规则(这里的滑动窗口大小不考虑拥塞窗口)

  • 滑动窗口的大小不是固定的,它的初始大小是在建立连接时对方的ACK报文里的窗口大小字段决定的,其实反应的是对方接收缓冲区的剩余空间大小
  • 只要收到一个ACK确认序号,不管这个序号前面的确认序号有没有收到TCP都认为这个序号之前的所有数据都已经安全收到(因为只有收到数据,对方才会对确认序号之前的数据发送收到确认)
  • 滑动窗口中的数据都是已经统一发出的,当收到对方的ACK后,滑动窗口左端滑动收到最大的确认序号处,而右端的位置则需要根据对方ACK中的窗口大小决定
  • 右端的位置=收到的最大确认序号+对方窗口大小

那么如果出现了丢包, 如何进行重传?

  • ACK丢包

【网络】传输层-TCP协议详解_第16张图片

这种情况,只要收到了丢包ACK后面的ACK,数据就不用重发,后面的ACK就足以证明前面发送的数据都已被接收

  • 数据丢包

【网络】传输层-TCP协议详解_第17张图片

  • 当某个报文丢失后,接收方没有收到这个报文,但是却收到了这个报文序号后面的报文,这时接收方就能判断出发生了丢包
  • 这时发送方不会对后续的报文进行应答,而是持续发送丢失报文的应答
  • 这时如果发送方收到了同样三次重复的确认应答,就会对这个报文进行重发,像这样连续发送,对方收到三次重复报文进行重发的方式叫做快重传
  • 当接收方收到重发的报文后,如果可以和后面的序号接上,就会把前面提前收到的报文全部应答,要是还是接不上,就继续重复发送,让对方继续重传

既然有快重传,为什么还要超时重传这样的机制?

因为快重传的触发机制是发送方连续收到三次同样的确认,但是如果由于网络原因等问题,接收方短时间内收不到三次重复确认,那么就无法重传,这时就要超时重传来保底,超时重传是到时间就一定会重传,而快重传的触发条件有可能会受影响

快重传是用来保证效率的,而超时重传则保证重传一定能被触发


延迟应答

如果接收方在收到发送方报文时就立即给发送方回应ACK,那么这个时候接收方的接收缓冲区里的数据还没有被上层读取,接收缓冲区剩余大小可能很小,ACK报文里的窗口大小可能比较小,这可能会影响发送方的滑动窗口大小影响通信的效率

这时我们就可以引入一个机制,延迟应答

那就是接收方收到数据后不立即确认应答,而是延迟一段时间,等上层应用读取接收缓存区内的数据,腾出更多的空间,在应答ACK里填入更大的窗口大小,让发送方下次发送可以发更多的报文提高吞吐量

  • 假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K
  • 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了
  • 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来
  • 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M

窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率

其实发送方发来的报文不必每个报文都进行ACK应答,TCP有滑动窗口机制,因此少一些应答也没有关系,可以隔几个报文进行一次应答

TCP文件传输中,绝大多数是每两个报文进行一次确认应答

【网络】传输层-TCP协议详解_第18张图片

所有的包都可以延迟应答么?

  • 数量限制: 每隔N个包就应答一次
  • 时间限制: 超过最大延迟时间就应答一次

具体的数量和超时时间, 依操作系统不同也有差异

一般N取2, 超时时间取200ms


捎带应答

发送方发送报文,接收方收到报文被上层读取,有可能接收方上层处理完数据会对发送方进行回执,如echo服务器等

接收方在收到报文也要对报文进行ACK应答,这时就可以把对发送方的回执数据ACK确认放在同一个报文内进行发送,这种方式叫做捎带应答

通过这种方式,可以让收发的数据量减少

【网络】传输层-TCP协议详解_第19张图片

另外,如果接收数据以后立即确认应答,就无法实现捎带应答,而是将所收到的数据传给应用处理生成数据后在进行发送请求为止,必须一直等待确认应答的发送

也就是说,没有延迟应答无法实现捎带应答

面向字节流

我们知道,UDP的数据传输是面向数据报

当用户消息通过 UDP 协议传输时,操作系统不会对消息进行拆分,在组装好 UDP 头部后就交网络层来处理,所以发出去的 UDP 报文中的数据部分就是完整的用户消息,也就是每个 UDP 报文就是一个用户消息的边界,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到完整的用户消息。

而判断报文的边界依据是UDP的报头中有一个数据报总长字段,可以通过总长来判断UDP报文边界

udp传输就好比生活中的取快递,快递是被封装好在包裹内,取快递时,只能连带包裹整个取出,不能把包裹切成好多份取出


但是TCP的报文传输时没有严格的数据边界区分,上层应用发来的数据,在TCP眼里全都是字节流的形式,没有所谓的边界概念,TCP传送时并不是传送一个完整的数据,而是想发多少就发多少想分为多少份就分多少份

TCP传输好比生活中的水龙头出水的例子,水在管道中是流式传输的,没有区分界限,想取多少就取多少


创建一个TCP的socket, 同时在内核中创建一个发送缓冲区和一个接收缓冲区

  • 调用write时, 数据会先写入发送缓冲区中
  • 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出
  • 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去
  • 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区
  • 然后应用程序可以调用read从接收缓冲区拿数据
  • 另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据.这个概念叫做全双工

由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如

  • 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节
  • 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次

TCP粘包问题

由于TCP传输数据没有边界的概念,那么应用层发来的数据包就会出现

  • 数据包被操作系统拆分成多个TCP报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输
  • 一个TCP报文里有多个应用层数据包,也就是一个TCP报文被对方收到时,里面可能是一个完整的数据包和另一个数据包的一部分

站在传输层的角度, TCP是一个一个报文过来的. 按照序号排好序放在缓冲区中

站在应用层的角度, 看到的只是一串连续的字节数据

那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包

这就是TCP的粘包问题


如何避免粘包问题?

明确两个包之间的边界

  • 数据包定长

对于定长的包, 保证每次都按固定大小读取即可

  • 添加数据包长度属性

对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置

  • 包与包之间使用明确分隔符

应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可

HTTP协议就是用分隔符包长度属性来区分包的边界


TCP异常情况

TCP异常情况分为两种种

  • 进程终止或者机器关机/重启

这种情况,尽管进程已经退出,进程并没有发起断开连接的请求,但是TCP协议是在操作系统层面的,进程的退出并不会影响操作系统向对端发起挥手断开连接,在进程退出后或者关机时,操作系统会自动发送FIN断开连接

  • 机器断电/断网

这时对方无法第一时间判断我方的连接已经失效,而是等到对方发送报文过来或者ACK应答时,才会发现连接已经失效

对方就会发送RST报文重置连接,多次重置连接失败,对方就会断开连接

即使对方没有发送报文和ACK, TCP自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放

另外, 应用层的某些协议, 也有一些这样的检测机制.

例如HTTP长连接中, 也会定期检测对方的状态


基于TCP应用层协议

  • HTTP
  • HTTPS
  • SSH
  • Telnet
  • FTP
  • SMTP

用UDP实现可靠传输(经典面试题)

不同的传输需求都可以通过参考TCP的可靠性机制在应用层实现类似的逻辑

例如:

  • 引入序号, 保证数据顺序
  • 引入确认应答, 确保对端收到了数据
  • 引入超时重传, 如果隔一段时间没有应答, 就重发数据

理解listen的第二个参数(backlog)

  • 实现一个服务器,只创建监听套接字,并且监听,不进行accept
  • 设置listen的第二个参数为2,也就是backlog为2
#include
#include
#include
#include
#include
#include
using namespace std;

//设置listen的第二个参数为2,也就是backlog为2
#define BACKLOG 2
#define PORT 8080

int main(){
    //创建监听套接字
    int lsock = socket(AF_INET,SOCK_STREAM,0);
    if(lsock == 0){
        cerr<<"socket error!"<<endl;
        return 1;
    }
    int opt = 1;
    setsockopt(lsock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
    struct sockaddr_in local;
    local.sin_family = AF_INET;
    local.sin_port = ntohs(PORT);
    local.sin_addr.s_addr = INADDR_ANY;
	//绑定信息
    if(bind(lsock,(struct sockaddr*)&local,sizeof(local)) < 0){
        cerr<<"bind error"<<endl;
        return 2;
    }
	//监听
    if(listen(lsock,BACKLOG) < 0){
        cerr<<"listen error"<<endl;
        return 3;
    }
    //只进行监听,并不进行accept
    
    //这里没有进行accept,就不会获取已经建立好的连接
    
    ///
    while(1){
        sleep(1);
    }  
    return 0;
}
  • 此时向服务器发送三个连接请求, 用 netstat 查看服务器状态, 一切正常,三个已经建立好的连接都处于ESTABLISHED状态

【网络】传输层-TCP协议详解_第20张图片

  • 此时再发起三个请求

【网络】传输层-TCP协议详解_第21张图片

这时我们已经发送了6个TCP连接请求,但是只有三个处于ESTABLISHED状态,还有一个SYN_RECV状态,说明TCP并没有对这三个请求建立对应的连接

这是因为, Linux内核协议栈为一个tcp连接管理使用两个队列:

  • 半连接队列:SYN 队列(用来保存处于SYN_SENT和SYN_RECV状态的请求)
  • 全连接队列:accept队列(用来保存处于ESTABLISHED状态,但是应用层没有调用accept取走的请求

服务端收到客户端发起的 SYN请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到全连接队列,等待进程调用 accept 函数时把连接取出来

在上层不调用accept时,TCP会把连接保存到全连接队列里,当作缓冲区,让连接在这里排队,全连接队列满了的时候, 就无法继续让当前连接的状态进入 ESTABLISHED 状态了,而这个队的长度是在上层监听时设置的,也就是listen的第二个参数backlog的长度+1

【网络】传输层-TCP协议详解_第22张图片

图片转自https://baijiahao.baidu.com/s?id=1668737037502745284&wfr=spider&for=pc


至于为什么全连接队列的长度是backlog+1,则要追溯到内核源码

我们找到sock的结构体,sock结构体里面有两个字段

在这里插入图片描述

  • sk_ack_backlog 当前全连接数量
  • sk_max_ack_backlog 全连接队列长度最大值

listen的第二个参数,其实就是赋值到了sk_max_ack_backlog

所以要判断当前全连接队列是否满了,就可以用sk_ack_backlog>=sk_max_ack_backlog

而内核的实现却用的是**>**号

在这里插入图片描述

所以全连接队列的长度是backlog+1

你可能感兴趣的:(网络,网络协议,网络,http,tcp,linux)