传输层协议TCP(下)

发布于:2025-06-13 ⋅ 阅读:(21) ⋅ 点赞:(0)

 上一篇https://blog.csdn.net/Small_entreprene/article/details/148193741?sharetype=blogdetail&sharerId=148193741&sharerefer=PC&sharesource=Small_entreprene&sharefrom=mp_from_link

接下来,我们来谈论TCP具体的机制!

具体TCP机制

确认应答(ACK)机制

TCP 将每个字节的数据都进行了编号,即为序列号。

这个ACK的理解我们在上一篇的时候就已经谈论清楚了!

不过要注意:确认应答必须将ACK标志位置为1。


下面,我们来好好理解一下序号!!!

我们知道TCP是有两个缓冲区的:一个发送,一个接收!而且是面向字节流的,所以我们可以将发送缓冲区看成是一个char类型的字符数组! 

char outbuff[N];

那么操作系统看待缓冲区不就是看待一个字符数组了嘛,这不就是字节流了吗?!流就是数组,那么从发送缓冲区拷贝下来的每一个字节,天然的不久有了编号了吗!不就是在发送缓冲区对应的数组下标了嘛!

我们的这个理解并不严谨,我们在滑动窗口中会有更好的理解!

取数据不就是用确认应答的确认序号到序号所对应的下标的整体数组范围嘛!这是我们第一版的理解!

每一个 ACK 都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据,下一次你从哪里开始发!假设接收到的应答序号是1001,那是不是就表明下一次发送的序号就是1001呢?不是的!不然为什么还要有两个序号,所以这个序号不是1001,是像上上图的2000!!!

可靠性的本质就是我收到应答了,那么说明我刚发送的对方收到了!!!---ACK。

超时重传机制

我们要谈重传,就需要先好好理解丢包!那么什么是丢包呢?如何理解?

我们以一个报文为例:丢包的情况就两种:

1.应答前丢:真的是数据包丢了

主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B;

如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发;

2.应答后丢:数据包收到了,但是应答数据丢了

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

结合上面的两种情况,所以对于发送方,没有收到应答ACK,意味着什么?意味着丢包吗?不完全是,只能意味着数据可能丢失 ,无法100%确认对方收到消息,也就是无法保证可靠性,当前情况下,也就是看上图是无法确认是数据丢还是应答丢的(后面会有解决办法)!

这就需要等待特定的时间间隔,如果发送方在特定的时间间隔内没有收到对端的ACK应答,那么发送发就会判定报文丢失!!!是主观,不是客观!

所以说:超时重传的策略就出来了!

还有对于第二种丢包问题,不就是会导致重复问题吗?

这个是可以甄别出来的!因为报文有序号的!!!主机B会收到很多重复数据。那么TCP协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉。序列号,就可以很容易做到去重的效果。

所以序号的作用:确认应答,按序达到,去重!!!

我们现在就可以根据特定的时间间隔,如果收不到应答,发送端就会判定报文丢失!所以,这个特定的时间间隔应该是多长啊?

发送端等的就是应答,那么我们是知道的:数据的传输是需要经过网络的,如果网不好,那么一来一回的就会比较久,要什么排队啥的,时间肯定变长了,如果等少了,就可能重复发生重传,如果网络特别好,那么时间间隔反而也是很长的话就会导致浪费时间了,所以因为网络是变化的,因此:

等待的时长必须是变化的!那么具体是怎么变的呢?

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

  • Linux 中 (BSD Unix 和 Windows 也是如此),超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍。

  • 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。

  • 如果仍然得不到应答,等待 4*500ms 进行重传。依次类推,以指数形式递增

  • 累计到一定的重传次数,TCP 认为网络或者对端主机出现异常,强制关闭连接。

不断的改变时间进行重传,就是在探索网络当前的超时时长!如果下一次4*500ms后接收到了应答报文,那么下一次就直接使用4*500ms的首次等待时长。

连接管理机制

在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接,我们上面为了更好理解相关概念,提前浅浅谈了一下,下面我们来详细说说!

三次握手:建立连接

图中的SYN_SENT,SYN_RCVD等等,都是表示双方主机的状态,整个就是状态变化,在内核层面上,所谓的状态其实就是一个整数!也就是宏值!

多个客户端可以向服务端建立连接,每个还处于不同状态,这不就是在服务端需要进行管理吗!?不就是先描述再组织吗?!所以服务端需要管理这些连接! 

那不就是可以被结构体规范,管理起来吗?在内核中,用于管理TCP连接的结构体通常是 struct tcp_sock(在Linux内核中)。这个结构体包含了TCP连接的所有相关信息,包括连接的状态、发送和接收缓冲区、拥塞控制信息、定时器等。通过这个结构体,内核能够有效地管理和跟踪每个TCP连接的状态和行为。

struct tcp_sock(简化)
#include <linux/inet.h>
#include <linux/sk_buff.h>
#include <linux/tcp.h>

// 定义TCP套接字结构体
struct tcp_sock {
    // 继承自inet_connection_sock,包含通用的连接套接字字段
    struct inet_connection_sock inet_conn;

    // TCP头部长度
    u16 tcp_header_len;

    // 快速路径处理数据的头部预测标志
    __be32 pred_flags;

    // 下一个期望接收的序列号
    u32 rcv_nxt;

    // 未读数据的头部
    u32 copied_seq;

    // 最后一次窗口更新发送时的rcv_nxt
    u32 rcv_wup;

    // 下一个要发送的序列号
    u32 snd_nxt;

    // 要发送的下一个确认号
    u32 snd_una;

    // 接收窗口大小
    u32 rcv_wnd;

    // 接收窗口左边缘
    u32 rcv_wnd_left;

    // 发送窗口大小
    u32 snd_wnd;

    // 发送窗口左边缘
    u32 snd_wnd_left;

    // 其他TCP相关字段...
};

正因为需要创建数据结构对象,花时间,花空间,所以TCP建立连接就会有成本!这也是为什么学校选课会卡,就是连接不断的被建立,越来越多就会导致管理成本增加,甚至到内存不足,以至于操作系统杀进程(服务),也就服务器崩掉了!

由图中的服务端:socket-bind-listen-accept到这就阻塞住了,然后到客户端:connect发起连接请求!connect是发起三次握手的,也就是为什么需要传入IP和端口!当然了,是发起三次握手,后续的三次握手具体的过程是由client OS自己完成的!跟connect没有关系!等连接成功了,connect才会返回!

对于accept,我们之前的TCP编程的时候,其实不使用accept接口,就设置listen状态,其实好像也是可以连接上服务器的。所以这说明了什么?就是accept不参与三次握手!!!三次握手依旧是由server OS和client OS双方操作系统自动完成! 

accept直接将建立好的连接拿上去就行了!什么叫做把连接拿上去呢?我们后面会说!大概就是拿到文件描述符,这就相当于在accept的时候可以创建struct file对象,然后将来再让这个struct file和获得的这个连接建立某种关联,这不就可以通过文件访问这个连接了嘛!后面说!😜

三次握手是TCP进行通信之前必须要做的,为什么需要三次握手啊???---两个原因

  • 三次握手是以最短的方式进行验证全双工的!客户端能发能收就是客户端的全双工,服务端能发能收就是服务端的全双工!(结合ACK机制体现,双方地位是需要对等的)(验证全双工本质就是验证我们两个所处的网络是通畅的,能够支持全双工)(父母等外部因素
  • 三次握手是四次握手中由捎带应答压缩带来的产物,双向的SYN其实就是再说你愿意吗---我愿意,所以就是在以最小成本,100%确认双方的通信意愿(你情我愿
  • 结婚!

三次握手的本质其实就是四次握手,因为服务端默认都会对客户端做应答,还有捎带应答的机制,所以可以压缩为三次握手!

记下来,我们来谈谈四次挥手:断开连接

断开连接的本质就是建立双方断开连接的共识!具体的做法就是必须要保证客户端向服务端100%发送对应的断开连接请求,服务端向客户端100%发送自己也要断开的请求!因为TCP是全双工的,也就意味着双方之间发送消息是可以同时进行的,建立双方断开连接的共识,一个具象化的认识就是:

客户端向服务器发送FIN,本质就是客户端给服务器说:“我要发的数据已经发完了,我要和你断开连接。”本质就是断开全双工的一条,即断开client->server;

ACK就是确保客户端向服务端说的话,100%收到了!

同样的道理,TCP是全双工的,此时服务端可能向客户端的数据还没有发完(这也是不能合并的原因,双方的关闭时间不一定是同一时刻,不太好压缩),就需要等发送完了,才会向客户端发送FIN,:“我也要断开连接了”!

ACK就是确保服务端向客户端说的话,100%收到了!

这时候,双方就建立了断开连接的共识了,也就是四次挥手!

为什么需要四次挥手来断开链接呢?

  • 四次挥手以最短次数,最小成本来建立了双方在全双工之下的断开连接的请求!

不过,客户端四次挥手的时候,将文件描述符close了,那么对端服务器还需要向客户端发送数据的时候,此时客户端的文件描述符不是已经关闭了吗?客户端不是读不了了吗???那该怎么办?

所以Linux系统为了支持我们能够对连接进行半关闭,也就是说客户端想要断开连接了,想要关闭自己的写端,那就将写端关闭,但是文件描述符不做释放,后面还可以进行读取数据!这就由全双工退化成半/单双工了!

在Linux中,shutdown 系统调用用于关闭一个套接字的特定端(读端、写端或两者)。这个调用可以用于TCP连接,以优雅地关闭连接的一部分,从而允许数据的完全传输。

#include <sys/socket.h>

int shutdown(int sockfd, int how);

sockfd:套接字文件描述符,必须是通过 socket() 调用创建的。

how:指定如何关闭套接字。可以是以下值之一:

  • SHUT_RD:关闭读端。不再从套接字接收数据。

  • SHUT_WR:关闭写端。不再向套接字发送数据。

  • SHUT_RDWR:关闭读写两端。

其实一般我们在TCP网络套接字编程的时候,我们使用close接口就行了,我们前面数据该发的我们都做了,另外四次挥手本质上也是有双方的操作系统自己完成的。

需要注意的是:

当客户端向服务端发送关闭连接请求,服务端就会处于CLOSE_WAIT的状态,依旧占用文件描述符,连接也没有释放,这就带来如果不关,可用的文件描述符就会越来越少,这就是文件描述符泄漏问题,文件描述符也是有限的资源的!所以fd用完了,就必须要关掉!!!

主动断开连接的一方,在将最后一次发送ACK的时候,就代表着四次挥手完成了,但是不能说主动方就直接变为CLOSED状态而是转会为TIME_WAIT状态,后续还需要等待一定的时间,才能设置为CLOSED状态!

现在做一个测试,首先启动 server,然后启动 client,然后用 Ctrl-C 使 server 终止,这时马 上再运行 server, 结果是:

这是因为,虽然 server 的应用程序终止了,但 TCP 协议层的连接并没有完全断开,因此不 能再次监 听同样的 server 端口. 我们用 netstat 命令查看一下:

那么TIME_WAIT的时间是多久?还有为什么需要TIME_WAIT?

TCP 协议规定,主动关闭连接的一方要处于 TIME_WAIT 状态,等待两个 MSL(maximum segment lifetime) (最大报文的存活时间)的时间后才能回到 CLOSED 状态。说人话就是。我把一个报文从我的主机发送到网络里,我历史上发送了很多报文,发送出去的报文,其中有一个统计数据,是用来衡量一个报文在网络里存放的最长时间,也就是将一个报文发送到网络当中,历史发了许多报文,基于历史做统计,我能大概估算出来,我发出去的报文曾经在网络存活的最长时间,这就是报文的最大存活时间。

为什么又是两个MSL呢?MSL 是 TCP 报文的最大生存时间,因此 TIME_WAIT 持续存在 2MSL 的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的);同时也是在理论上保证最后一个报文可靠到达(假设最后一个 ACK 丢失,那么服务器会再重发一个 FIN。这时虽然客户端的进程不在了,但是 TCP 连接还在,仍然可以重发 LAST_ACK);因为是双向的,两个MSL就是保证两个方向上的报文能够在网络当中消散!

“保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失”,其实我们就是可以理解为有一种情况:发送的数据DATA没有丢,还在某一个路由器中排队,而且发送方已经判定超时重传了,后续甚至连接关闭了,报文还在网络中存活,也就是说一旦我们将连接全部关闭了,不考虑什么TIME_WAIT,假设四次挥手完成了,双方连接直接关闭,那么在网络中可能会残存里上上补发的数据,后来这个客户端立即再重启,端口和之前的一样,那么在网络中的报文就可能到服务端了,这个报文到来的时候,就可能会影响下一次连接的建立和通信的过程!

所以我们需要TIME_WAIT,本质就是需要两个MSL的时间来确保安全性!

所以在TIME_WAIT期间,老端口就不会能够理解使用了,如果对应的客户端想要再次重启,就需要强制更换端口号,就能保证历史上没有消散的报文,源端口和目的端口就和新建立的源端口和目的端口就配不上,所以客户端和服务端就可以对这样的报文进行自动丢弃了! 

在我们应用场景中,就比如PDD,在618,双十一的时候,如果客户一下子太多了,将PDD的服务器搞崩了的话 ,那么PDD就是主动断开连接的一方,那不就不能立即重启了吗?可是这不是无法重启了吗?一旦无法重启就会导致服务器有一段时间没有办法提供服务了,耽误一秒钟就是几千万了!!!所以,虽然我们有TIME_WAIT,但是我们在编程应用角度,我们可以实现让主动断开的一方既可以进行TIME_WAIT,又要让服务器立即重启!不能理解重启是因为历史端口号可能被占用了,所以我们可以使用:系统调用:setsockpot

在创建socket套接字后立马进行:使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符

#include <sys/types.h>
#include <sys/socket.h>

int pot = 1;
int setsockopt(int listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

可是上面不是才刚刚说过吗?可能会有历史的报文会影响连接和通信?那肯定是可能的!所以TCP除了使用TIME_WAIT的两个MSL时间的策略来保证对历史遗留的报文做过略,客户端和服务器通信的时候,还有一个大杀器:TCP报文当中的序号!!! 

当我们在进行通信的时候,对应的遗留的报文的序号和新建立连接时的序号不一定会对得上,就是作为服务器方,会收到很多报文,会做ACK,做ACK得时候,是知道下一次期望收到得序号的,其实三次握手之后,如果服务器的接收能力是5000个字节,那么假设对端客户端想要从序号1000开始,因为其实起始序号是随机的,那么服务端只能接收1000~6000序号的报文了,如果陈旧报文的序号是7000,那么不久丢弃掉了嘛!如果序号不匹配是可以被丢弃的!即便是这样的策略,也有可能会出现问题,但是这时候的概率就已经很低了!当然还有针对这个问题设计了多种策略!我们其实再深一点就可以不用考虑了!

MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同,在 Centos7 上,默认配置的值是 60s。可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看 msl 的值:

lfz@hcss-ecs-ff0f:~$ cat /proc/sys/net/ipv4/tcp_fin_timeout
60

滑动窗口

对于滑动窗口的知识,我们前文做了铺垫:下面我们来回顾一下:

通过确认应答策略,对每一个发送的数据段,都要给一个 ACK 确认应答。收到 ACK 后再发送下一个数据段。这样做有一个比较大的缺点,就是性能较差,尤其是在数据往返时间较长的时候。

既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大地提高性能(其实是将多个段的等待时间重叠在一起了)。

为了能够让主机A能够的向主机B发送批量消息,我们就需要在主机A中规定一个数字 --- 一个无需等待确认应答而可以继续发送数据的最大值,这个值就是窗口大小,那么这个窗口就是滑动窗口!

那么现在主机A可以一次性批量的向主机B一次性发送多条报文了,那么主机A可以发送多少个报文是由什么决定的?

那就是由滑动窗口大小所决定了! 

那么什么是滑动窗口啊?在哪里体现的滑动窗口啊! 

在TCP协议中,我们之前也是认识到TCP的发送/接收缓冲区,数据的发送不是取决于用户,因为我们调用的send/write只是将我们想要发送的数据传输到了下一层的TCP的接收缓冲区,什么时候发送是取决于操作系统的,滑动窗口是我们可以向对方发送数据,暂时可以不需要应答,在哪里呢?就是在主机A的发送缓冲区的一部分!!!

我们其实可以将缓冲区看成:char类型的一维数组:

char outbuffer[N];//逻辑上的认知

然后滑动窗口就有相对应的 start 和 end 的下标,【start,end】以内的就是直接可以向对端主机B发送的数据了!

正因为有滑动窗口的存在,就将发送缓冲区分成了三部分:

  1. 已发送已确认
  2. 可以直接发,暂时不要应答
  3. 待发送/未发送

左边部分:已发送,收到ACK的,不就是这部分数据无效了,就是这部分空间可以再被利用了!所以在网络通信中,我们不需要刻意的清空缓冲区,而只要让数据无效就可以,只要对应的在滑动窗口的左侧就可以体现无效性了!

我们知道,序号在发送的轮次中,数字是在不断的增大的,也就是意味着滑动窗口未来需要向右滑动,那么滑动窗口的本质不就是让滑动窗口的 start 和 end 下标增加嘛!

滑动窗口以内的数据是直接可以发送的,所以滑动窗口的大小是由什么来决定的?

滑动窗口的定义是可以直接发,暂时不需要应答,那么如果把滑动窗口的范围扩得太大,就可能会导致给对方发送太多的数据,导致对方来不及接收,相反,如果我们给对方发送的数据量太小,就会导致单次发送数据量太小,效率降低!所以滑动窗口的大小最终是由对端的接受能力决定的!

所以!滑动窗口的本质是流量控制的具体实现方案!!!滑动窗口是决定发多少的依据!

我们之前认识到TCP协议中的窗口关键字,对端ACK会告诉发送端当前的接受能力,在收到对端对应的报文时,怎么调整滑动窗口对应的大小呢?

start = 对端ACK的报文中的确认序号

end = start + 对端给我通告的窗口大小

记下来,我们来谈谈几个问题:


滑动窗口可以向左滑动吗?

滑动窗口是不会向左滑动的,因为从概念上讲,滑动窗口的左侧是已发送已确认数据,如果将滑动窗口向左滑动,就会将已发送已确认的数据再发送一遍,这是不合理的!还有上面的右移的计算方法,显然是不能向左移动的!


滑动窗口,可以变大吗?可以变小吗?可以不变吗?可以为0吗?

滑动窗口的大小是一个数字,通常用字节(Bytes)来表示。它反映了接收方当前可用的缓冲区大小。窗口大小会根据接收方的缓冲区情况动态调整。

  • 窗口大小增大:表示接收方的缓冲区空间增加,发送方可以发送更多的数据。

  • 窗口大小减小:表示接收方的缓冲区空间减少,发送方需要减少发送的数据量。

  • 窗口大小不变:表示接收方的缓冲区空间没变,发送方可以按照上一次的数据量进行发送。

  • 窗口大小为0:表示接收方的缓冲区已满,发送方需要停止发送数据,直到接收方处理完部分数据并释放缓冲区。(样例:start++,end不变,直到start == end)


如果报文丢了怎么办?滑动窗口,会不会跳过报文进行应答?(重点)

我们假设当前的滑动窗口的范围是【1001,5001】,假设发送1001到2000的报文,没有收到对应的应答,那么我们将来肯定是要将该报文进行重发了,那么没有收到对应的应答,滑动窗口会进行更新吗?

对于丢包问题,宏观上无非就三种情形:实际丢包肯定更复杂,是下面三种情况的自由组合!

  1. 在滑动窗口的数据段中,最左侧的对应报文丢失。
  2. 中间的报文数据丢失。
  3. 最右侧的对应的报文丢失。

对于最左侧的情形: (最左侧就是要点)

情况一: 数据包丢了。

假设主机B收到了【2001,5000】的三个报文,然而【1001,2000】的最左侧的报文数据丢失了,那么对没有丢失的三个报文的应答的确认序号应该是多少呢?

填的都是1001!!!所以滑动窗口将来左侧不做更新,即start的位置没有发生改变,因为确认序号的意思是该确认序号之前的报文已经全部收到了!

  • 当1001-2000的数据包丢失后,发送端会一直收到确认序号为1001的响应报文,就是在提醒发送端“下一次应该从序号为1001的字节数据开始发送”。
  • 如果发送端连续收到三次确认序号为1001的响应报文,此时就会将1001-2000的数据包重新进行发送。
  • 此时当接收端收到1001-2000的数据包后,就会直接发送确认序号为6001的响应报文,因为2001-6000的数据接收端其实在之前就已经收到了。

这种机制被称为“高速重发控制”,也叫做“快重传”。

需要注意的是,快重传需要在大量的数据重传和个别的数据重传之间做平衡,实际这个例子当中发送端并不知道是1001-2000这个数据包丢了,当发送端重复收到确认序号为1001的响应报文时,理论上发送端应该将1001-5000的数据全部进行重传,但这样可能会导致大量数据被重复传送,所以发送端可以尝试先把1001-2000的数据包进行重发,然后根据重发后的得到的确认序号继续决定是否需要重发其它数据包。

快重传和超时重传都是对对应报文进行重传的机制,快重传触发是需要有条件的,是需要收到3个同样的确认应答!超时重传的条件是超时了。但是如果只发了两个报文的话,一个报文丢了,这样只会收到一个ACK啊,那么收不到连续三个以上的相同确认应答的ACK,此时就必须要使用超时重传了,所以超时重传是用来兜底的!快重传是在重传角度提高效率的!!! 

情况二: 数据包已经抵达,ACK丢包。

如果【1001,2000】不是报文数据丢了,而是应答丢了,也就是说主机B这四个报文全部收到了,只不过在应答的时候,对于最左侧的应答报文主机A没有收到,那么对于另外三个报文的应答的确认序号应该是多少呢?

收到【2001,3000】填的是3001,收到【3001,4000】填的是4001,收到【4001,5000】填的是5001!所以即便是【1001,2000】丢失了,只要后面的报文是收到的,就会让滑动窗口向右更新。

所以,对于最左侧的丢失:有两种情况:

数据真的丢了:滑动窗口左侧不变

数据收到了,应答丢了:滑动窗口正常工作(因为确认序号的定义就是确认序号之前的已经全部收到了,难道不是吗?只是说应答丢了,不影响!!!😝)(在发送端连续发送多个报文数据时,部分ACK丢包并不要紧,此时可以通过后续的ACK进行确认)

还有通过丢包问题,我们就可以理解说:TCP发出的报文,暂时还没有应答的时候,必须让对应的报文暂时保存起来,以方便后续的重传!!!那么对已发送的报文保存起来,是保存到哪里?如何理解保存?

滑动窗口的定义不就是把报文数据统一发出去,可以暂时不需要应答,但是在将滑动窗口中的数据发送出去的时候,没有任何应答时,滑动窗口不会向右滑动,当收到响应的应答的时候,才有可能向右滑动,就像上面丢了最左侧的就start就不会动了😊所以向右滑动的本质就是把对应数据删除!!!所以保存在滑动窗口当中!怎么理解这个保存,就是这个窗口不要动!

所以超时重传和快重传的底层支持是滑动窗口!!!不仅仅与流量控制有关,还与重传有关!!!

如果中间报文丢失,意思就是左边报文收到了,滑动窗口首先是会向右滑动的!!!本质就转化为最左侧报文丢失的情况了!!!最右侧丢失何尝不也是会转化为最左侧报文丢失了嘛!!!

报文丢失---》最左侧丢失---》超时重传/快重传,我们注意的是:

确认序号一定是连续的!!!---》发送必须连续发送!!!---》滑动窗口不能跳跃!!!(支撑)


滑动窗口,一直向右,会不会溢出? 

这肯定是不会的!重点是我们如何理解为什么不会溢出!我们对于缓冲区的数组认知其实是抽象出来的,我们可以将其再加上一层抽象认知:我们可将char类型的数组想象成一个环形区域!(TCP确实也是这么设计的,但是他的环形并不是基于数组的,底层的报文都是以队列的形式呈现的,报文节点不连续。) 

这好像和上面的分成三部分(已发已收-待应答-待发)冲突,不过在环形中有一个分界点,一样的还是左侧已发送已应答,右边待发送!


流量控制

其实之前的文章的内容中基本就已经将流量控制说完了,我们可以复习一下,顺便补充几点知识!

接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。在这里,其实并不影响可靠性本身,但是本质上这是对资源的一种浪费(让费了那么多电力,带宽...网络资源!“泥他🐎不要了!?”),因此 TCP 支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)。(UDP是没有考虑这种问题的,只会无脑sendto)

  • 接收端将自己可以接收的缓冲区剩余空间的大小放入 TCP 首部中的“16位窗口大小”字段,通过 ACK 报文通知发送端。

  • 窗口大小字段越大,说明网络的吞吐量越高。

  • 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。

  • 发送端接收到这个窗口之后,就会减慢自己的发送速度。

  • 如果接收端缓冲区满了,就会将窗口置为 0。这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。

那么下面有几个子问题需要解决:

在TCP三次握手的时候,第三次握手只发送ACK吗,ACK可以携带数据吗?

其实是可以的!因为合理!客户端发送ACK出去的时候,就代表客户端三次握手就已经完成了!那么客户端可能就会发现自己还有数据,那么客户端就可能将应答和用户数据做捎带应答!


第一和第二次可以携带数据吗?

不可以!!!因为不管是第一次还是第二次,双方三次握手都不算完成!在前两次握手发送的是报头,这样也就可以知道了双方的当前缓冲区的剩余空间情况,为流量控制做好了准备,后续发送数据的时候就可以正常进行流量控制了!!!


如果主机A给主机B发送消息,主机B上层一直没有读取对应的数据,知道将自己主机B的接收缓冲区打满了,那么主句A就会停止向主句B发送数据,我们就可以将滑动窗口设置为0,主机A停止发送!

那么什么时候可以继续进行数据包的传送与接收呢?(对方上层什么时候才取走呀!取走了主机A他知道吗?😭)

两种办法:

第一种策略:

主机A也没有办法,只能周期性的给主机B发送窗口探测,也就是发送一个携带序号的TCP报头,是报头,不携带正文数据,所以并不会放到主机B的接收缓冲区当中,所以不会导致丢包,根据TCP定义,没有数据,但是也需要应答,只要应答,就会将主机B的缓冲区剩余空间大小通告给主机A!所以通过这种方式,就可以知道对端窗口有没有更新! 

  • 窗口探测报文是一个特殊的TCP报文,它不携带数据(数据长度为0),但包含TCP报头

  • 它的主要目的是触发接收端发送一个ACK报文,从而获取接收端当前的窗口大小。

第二种策略:

因为TCP通信中,双方地位是对等的,双方之间可以自行发送消息,不向HTTP,服务方要处理的是请求,服务方给对方做的是应答,双方地位不对等,永远都是客户端请求服务器!所以主机B也可以自动给主机A发送消息,所以当主机B的对应窗口大小更新了,就会自动向主机A发送一个窗口更新的通知!

在真实的情况下,这两种策略同时被采用!

如果单独的一个第二种策略,就会导致:如下的类似死锁的现象:

我们利用第一种策略! 

这时候,我们加上第一种策略的话, 就可以很好的相辅相成了!不仅保证了可靠性,还考虑了性能!!!

接收端如何把窗口大小告诉发送端呢?回忆我们的 TCP 首部中,有一个 16 位窗口字段,就是存放了窗口大小信息。

那么问题来了,16 位数字最大表示 65535,那么 TCP 窗口最大就是 65535 字节么?

实际上,TCP 首部 40 字节选项中还包含了一个窗口扩大因子 M,实际窗口大小是窗口字段的值左移 M 位。

拥塞控制

 一个例子:发送方向接收方一次发送1000个报文,报文丢了2,3个,那么很正常,发送方补发就可以了,但是如果1000个报文,就2,3个收到,其他全部丢了呢?!就好比大学期末考试,100个人挂科2,3个很正常,可能就是没好好学,但是如果挂了98个人的话,那很可能就是阅卷老师的问题了。

所以,对于丢包数量,丢包相对多,丢包相对少是会给出不同的结论!

所以TCP不仅仅考虑了双方主机的问题,还考虑了网络本身的问题!但是请不要幻想TCP能将各种网络问题怎么解决!如果网络中的硬件设备,比如说路由器挂掉了,运营商大量机器挂掉了等等问题,能让TCP干啥?!是干不了的!所以TCP说他考虑网络问题,其实是只能考虑一些能恢复的网络问题,这是在硬件无障碍的前提下!

在我们进行网络通信时,如果出现了少量的数据包丢失,在我们看来,就是这几个报文的问题,是不是路由器走错了,是不是报文转发的时候,到对端校验和失败了......反正就是这一两个报文的问题,如果一旦丢包太多了,那么发送方就会判定网络出现问题!网络出现拥塞问题!

那么发送方判定出网络拥塞,这些报文还需不需要重发呢?

是不能立即重发的!!! 就像一个十字路口已经堵得不成样了,还让进去吗!!!如果立即重发就会增加网络的压力负载,让网络变得更加拥堵!就用客户端-服务端,客户端肯定是有多个的!就像我们在宿舍同一个网络下访问CSDN的时候,卡的不仅仅是自己,舍友肯定也卡了,那么所有的主机(客户端),TCP就会将所有主机接管,大家会采用相同的策略处理!就需要进行拥塞控制!

想要理解拥塞控制,我们就需要时刻告诉自己,拥塞控制,会让发送端的多个主机都采用拥塞控制的策略!--- 慢启动

虽然滑动窗口能够高效可靠的发送大量的数据,但如果在刚开始阶段就发送大量的数据,就可能会引发某些问题。因为网络上有很多的计算机,有可能当前的网络状态就已经比较拥塞了,因此在不清楚当前网络状态的情况下,贸然发送大量的数据,就可能会引起网络拥塞问题。

因此TCP引入了慢启动机制,在刚开始通信时先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。(刚开始大家都很慢)

当判定网络拥堵了,就会引入慢启动机制,前期慢一点,本质就是为了探测网络是否慢慢恢复,一旦判定出网络已经不怎么卡了,后期就会慢慢恢复网络通信过程!

每收到一个ACK应答拥塞窗口的值就加一,此时拥塞窗口就是以指数级别进行增长的,如果先不考虑对方接收数据的能力,那么滑动窗口的大家就只取决于拥塞窗口的大小,此时拥塞窗口的大小变化情况如下:

拥塞窗口 滑动窗口
$1=2^0$ 1
$1+1=2^1$ 2
$2+2=2^2$ 4
$4+4=2^3$ 8
... ...

可是上面不是说好了,发送多少数据,由滑动窗口决定啊!但是滑动窗口不是受对方当前的接受能力的影响吗?

所以为了支持拥塞控制算法(慢启动),我们需要再提出一个新的概念 --- 拥塞窗口

拥塞窗口是可能引起网络拥塞的阈值,如果一次发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。(一个临界值,值以下,网络较大概率不阻塞,值以上,网络可能阻塞!)

网络肯定是变化的,这也就决定了这个拥塞窗口一定要进行跟更新变化!

所以,我们对滑动窗口的认知就要更丰富,更细致了:

滑动窗口 = min(对方接收缓冲区剩余空间的大小,拥塞窗口); 
  • 刚开始发送数据的时候拥塞窗口大小定义以为1,每收到一个ACK应答拥塞窗口的值就加一。
  • 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送数据的窗口大小,即滑动窗口的大小。

所以我们发送数据的时候,发送数据的量,不会一直进行指数级增长,即便是拥塞窗口再怎么增长,再大的话,主要矛盾就会转化为对方目前的接受能力的大小了!

可是,拥塞窗口大小这个数字总不能一直指数增长吧?32位机器下会快就会溢出了!利用指数增长,前期慢,后期快,目的是为了尽快探测并恢复网络通信,在我们当前计算机内,拥塞窗口的大小是衡量网络是否会拥堵的一个指标!!!再说了,网络是变化的,也就决定了这个拥塞窗口的大小是一定要进行更新变化的!然而指数增长是为了应对异常的,是用来恢复和处理网络问题的,在拥塞窗口不做指数增长之后,还是需要变化的,这是取决于网络变化!!!其实变化就是由指数增长转化为线性增长了!这个线性变化本质就是在不断探测新的拥塞窗口的值!我们将由指数增长转为线性增长的转折点称为 "ssthresh" 

也就是说:

  • 为了避免短时间内再次导致网络拥塞,因此不能一直让拥塞窗口按指数级的方式进行增长。
  • 此时就引入了慢启动的阈值,当拥塞窗口的大小超过这个阈值时,就不再按指数的方式增长,而按线性的方式增长。
  • 当TCP刚开始启动的时候,慢启动阈值设置为对方窗口大小的最大值。
  • 在每次超时重发的时候,慢启动阈值会变成当前拥塞窗口的一半,同时拥塞窗口的值被重新置为1,如此循环下去。

拥塞窗口在增加,我们发送的数据量一定在增加吗?不一定的哈?当拥塞窗口增加到一定层度的话,主要矛盾就会变为对端的接受能力了!上面才说过!是取min!拥塞窗口特别大不就是网络特别好嘛!

所以,当触发网络拥塞时,TCP会进入慢启动阶段,拥塞窗口会重新开始指数增长。慢启动阶段的指数增长是为了快速探测网络的可用带宽,直到拥塞窗口达到一个阈值(ssthresh)。每一次都有应答,一旦达到阈值,TCP会进入拥塞避免阶段,此时拥塞窗口的增长速度会变慢(通常是线性增长)。 因为发送了10轮,100轮,1000轮....都不发生丢包,时间越久,就代表网络越好,那么拥塞窗口值就应该越大,拥塞窗口在线性增长本质就是在衡量当前网络的通常程度的!但是,在发送数据的时候,突然发生了网络拥塞,这时候就需要重置慢启动,就是在下图的24发生拥塞了,本质不就是我们探测出来了的当前的拥塞窗口了嘛!然后重新开始,除了是支持慢启动,本质也是重新开始探测网络健康!而且下一次从指数探测到线性探测的阈值根据算法规定,我们新的ssthresh值由上一次网络拥堵时的窗口大小去" ÷ 2 " 

如下图:

不过在极端情形下,就是网络非常好,也就是说拥塞窗口在线性探测的过程中,会一直增大吗?

连续正常发送一个月的数据,不就是网络很好嘛!从逻辑上来说,就是需要不断增大!要大就大到整型的最大值就不变呗!但是理论上是不会一直增大的! 从带宽利用率的角度来看,即使网络条件很好,TCP拥塞窗口也不会无限制增长。因为TCP的拥塞控制机制会动态调整窗口大小,使其在充分利用可用带宽的同时,避免过度占用导致网络拥塞,从而保持网络的稳定性和高效性。其实再大也就没有什么意义了!

延时应答

为什么要延迟?

每次接收方回复确认应答的时候,会在TCP头部当中携带窗口大小, 来告知发送方自己的接收能力。
发送方是要通过接收方通告的窗口大小来调整发送窗口的
假设不考虑网络的情况下:

  • 接收方通告的窗口大小越大,则发送窗口越大, 则发送方发送的数据越多
  • 接收方通告的窗口大小越小,则发送窗口越小, 则发送方发送的数据越少

如果接收方收到数据就立即返回ACK应答,这时候的缓冲区中接受的数据许多还没能够处理,缓冲区的剩余大小就是窗口大小,所以此时返回的窗口值会比较小。
在收到数据以后并不立即返回确认应答,延迟一小会,等待缓冲区中数据被处理,接收缓冲区空间变大一些,再进行应答(此时确认应答的窗口值会大一些)——这是延迟\延时应答

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

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

那么所有的包都可以延迟应答吗?? 不是的,与以下有关:

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

具体的数量和超时时间,依操作系统不同也有差异;一般N取2,超时时间取200ms;

在系统中,有一个固定的定时器每隔200ms会来检查是否需要发送ACK包,这样做有两个目的。

  1. 这样做的目的是ACK是可以合并的,也就是指如果连续收到两个TCP包,并不一定需要ACK两次,只要回复最终的ACK就可以了(每一次数据都未必需要应答了),可以降低网络流量;
  2. 如果接收方有数据要发送,那么就会在发送数据的TCP数据包里,带上ACK信息,也就是捎带应答,这样做,可以避免大量的ACK以一个单独的TCP包发送,减少了网络流量。

捎带应答 

我们之前对捎带应答已经有了比较多的认识了,捎带应答本质就是为了用来提高效率的!下面我们来稍微回顾一下:

在延迟应答的基础上,我们发现,在很多情况下,客户端服务器在应用层也是“一发一收”的。这意味着客户端给服务器说了“How are you”,服务器也会给客户端回一个“Fine, thank you”。那么这个时候 ACK 就可以搭顺风车,和服务器回应的“Fine, thank you”一起回给客户端。

TCP小结 

分类 内容
可靠性 校验和、序列号(按序到达)、确认应答、超时重发、连接管理、流量控制、拥塞控制
提高性能 滑动窗口、快速重传、延迟应答、捎带应答
其他 定时器(超时重传定时器、保活定时器、TIME_WAIT 定时器等)(在系统部分,信号专题,闹钟alarm就是很好的例子,说明操作系统本身就是可以计时的!)

下面我们来谈谈相关的其他话题:

面向字节流 

这个我们之前也讲过了! 我们主要还是回顾一下:

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

  • 调用 write 时, 数据会先写入发送缓冲区中;

  • 如果发送的字节数太长, 会被拆分成多个 TCP 的数据包发出;

  • 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;

  • 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;

  • 然后应用程序可以调用 read 从接收缓冲区拿数据;

  • 另一方面, TCP 的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工

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

  • 写 100 个字节数据时, 可以调用一次 write 写 100 个字节, 也可以调用 100 次 write, 每次写一个字节;

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

粘包问题

【八戒吃馒头例子】

  • 首先要明确,粘包问题中的“包”,是指的应用层的数据包。

  • 在 TCP 的协议头中,没有如同 UDP 一样的“报文长度”这样的字段,但是有一个序号这样的字段。

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

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

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

那么如何避免粘包问题呢?归根结底就是一句话,明确两个包之间的边界。(其实就是自定义协议 --- 用户自己来解决!)

  • 对于定长的包,保证每次都按固定大小读取即可;例如上面的 Request 结构,是固定大小的,那么就从缓冲区从头开始按 sizeof(Request)依次读取即可;

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

  • 对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可);

思考:对于 UDP 协议来说,是否也存在“粘包问题”呢?

  • 对于 UDP,如果还没有上层交付数据,UDP 的报文长度仍然在。同时,UDP 是一个一个把数据交付给应用层。就有很明确的数据边界。

  • 站在应用层的站在应用层的角度,使用 UDP 的时候,要么收到完整的 UDP 报文,要么不收。不会出现“半个”的情况。

TCP异常情况

进程终止:进程终止会释放文件描述符,仍然可以发送 FIN。和正常关闭没有什么区别。

双方建立的两个连接不就是双方(客户端和服务端的两个进程建立的嘛,就好比服务器的accpet的文件描述符就是一个连接,相关话题我们后续的文章会说到!!!)(进程退出,进程打开的文件基本就不在了,引用计数到0的时候,这时候连接就会自动进行自动挥手)

机器重启:和进程终止的情况相同。因为关机的话,操作系统会关闭所有的进程!

机器掉电/网线断开:对于被拔掉网线的一方,因为操作系统是软硬件资源的管理者,就会立马意识到当前的网络连接出现问题了!硬件上识别到之后,软件上理所应当是对应的连接没了!就像我们浏览器上,也就是客户端的网络断开了,会出现:

可是这个客户端是没有机会和服务器发生四次挥手的!因为网线一拔,都来不及发送报文了!所以接收端也就是服务端还认为连接在,一旦接收端有写入操作(服务端向客户端发消息),接收端发现连接已经不在了,就会进行 reset(这个就是我拔掉网线后,重连了,可是通信之前需要进行三次握手呀,所以发reset进行连接重置!)。即使没有写入操作,TCP 自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。 

对于连接的保活机制其实是TCP协议报头选项的一种功能!是TCP自带的,是大几十分钟级别的,或者小时级的!但是对于保活机制,我们基本都是在应用层自己完成的!来符合应用层的需要与控制!

另外,应用层的某些协议,也有一些这样的检测机制。例如 HTTP 长连接场景中,也会定期检测对方的状态。例如 QQ,在 QQ 断线之后,也会定期尝试重新连接。

综上:TCP对于连接异常,有很强的容错性!

基于TCP应用层的协议

  • HTTP
  • HTTPS
  • SSH
  • Telnet
  • FTP
  • SMTP
  • 当然,我们自己写的TCP程序时自定义的应用层协议!就如前面的网络版本计算器!

TCP VS UDP

TCP和UDP的优缺点不能简单绝对地进行比较。

  1. TCP用于可靠传输的情况,适用于文件传输、重要状态更新等场景;
  2. UDP用于对高速传输和实时性要求较高的通信领域,例如早期的QQ、视频传输等,另外UDP还可以用于广播。

归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。

我们在选择协议上,到底是选择TCP还是UDP,基本的原则是:

只要在网络通信时,要求的是足够简单,而且对报文丢包的容忍度比较高,这就可以选择UDP,就像直播,允许点画面糊掉了,没有多大关系!除此之外,一律选择TCP!就好比登录注册,支付转账等等!

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

有一种特殊要求:TCP通信的时候稍微优点过重了!还需要建立连接之类的,我不想建立连接,而且我还要用UDP来保证基本的可靠性,在应用层,如何使用UDP来实现对应的可靠性?

在UDP协议的基础上实现可靠传输是一个经典的面试题,通常考察面试者对TCP可靠性机制的理解以及对协议设计的思考。UDP本身是一种无连接、不可靠的传输层协议,不保证数据包的顺序、完整性和可靠性。而TCP的可靠性机制包括序列号、确认应答、超时重传、拥塞控制等。如果要在应用层基于UDP实现类似TCP的可靠传输,可以参考以下设计思路:

1. 引入序列号

序列号的作用是为每个发送的数据包分配一个唯一的编号,接收方可以根据序列号来判断数据包是否丢失、重复或乱序。

  • 实现方式:在每个UDP数据报中添加一个序列号字段。发送方在发送数据时,为每个数据包依次分配序列号(例如从1开始递增)。

  • 优势:接收方可以通过序列号检测数据包的顺序,如果发现序列号不连续,可以判断中间的数据包丢失。

2. 确认应答(ACK)

发送方需要接收方的确认应答来确认数据包是否被正确接收。如果没有收到确认应答,发送方可以重传数据。

  • 实现方式:接收方在收到数据包后,发送一个确认应答(ACK)消息,ACK消息中包含已成功接收的最高序列号。发送方收到ACK后,知道该序列号及之前的数据包已被接收。

  • 优势:通过ACK机制,发送方可以明确知道哪些数据包需要重传,从而提高传输的可靠性。

3. 超时重传

如果发送方在一定时间内没有收到确认应答,可以认为数据包可能丢失,需要重传。

  • 实现方式:发送方为每个未确认的数据包设置一个计时器。如果在计时器超时后仍未收到ACK,就重传该数据包。

  • 优势:通过超时重传机制,可以弥补UDP不保证数据包可靠传输的缺陷。

4. 滑动窗口机制

为了提高传输效率,可以引入滑动窗口机制,允许发送方在等待确认应答之前发送多个数据包。

  • 实现方式:发送方维护一个滑动窗口,窗口大小表示可以发送但尚未收到确认的数据包数量。接收方也维护一个滑动窗口,用于接收并缓存乱序到达的数据包。

  • 优势:滑动窗口机制可以提高数据传输的效率,减少等待确认应答的时间。

5. 数据分片与重组

如果数据量较大,需要将数据分片后通过UDP发送,接收方需要将分片的数据重新组合。

  • 实现方式:在每个UDP数据报中添加分片标识和总分片数字段。接收方根据分片标识和总分片数将数据重新组合。

  • 优势:通过分片与重组机制,可以支持大块数据的可靠传输。

6. 拥塞控制

为了避免网络拥塞,可以引入简单的拥塞控制机制,动态调整发送速率。

  • 实现方式:根据网络的拥塞情况动态调整滑动窗口大小。如果检测到丢包,可以减小窗口大小;如果网络状况良好,可以适当增大窗口大小。

  • 优势:拥塞控制可以避免发送方过度占用网络资源,提高网络的整体性能。

示例代码(伪代码)

以下是一个简化的伪代码示例,展示如何基于UDP实现可靠传输:

// 发送方
initialize sequence_number = 1
initialize timeout = 1 second
initialize window_size = 4

while (data_to_send) {
    if (window_size > 0) {
        send_udp_packet(data, sequence_number)
        set_timer(sequence_number, timeout)
        sequence_number += 1
        window_size -= 1
    }

    if (timer_expired(sequence_number)) {
        resend_udp_packet(data, sequence_number)
        reset_timer(sequence_number, timeout)
    }

    if (receive_ack(ack_sequence_number)) {
        if (ack_sequence_number >= expected_ack) {
            window_size += 1
            expected_ack = ack_sequence_number + 1
        }
    }
}

// 接收方
initialize expected_sequence_number = 1
initialize buffer = []

while (true) {
    receive_udp_packet(data, sequence_number)
    if (sequence_number == expected_sequence_number) {
        deliver_data(data)
        expected_sequence_number += 1
        send_ack(expected_sequence_number - 1)
        while (buffer contains sequence_number == expected_sequence_number) {
            data = buffer.pop(sequence_number)
            deliver_data(data)
            expected_sequence_number += 1
        }
    } else if (sequence_number > expected_sequence_number) {
        buffer.append(data, sequence_number)
        send_ack(expected_sequence_number - 1)
    }
}

总结

通过引入序列号、确认应答、超时重传、滑动窗口、分片与重组以及拥塞控制等机制,可以在应用层基于UDP实现类似TCP的可靠传输。在面试中,除了描述这些机制外,还可以结合实际场景讨论如何优化和调整这些机制,以应对不同的网络环境和应用需求。

从开始到现在,理论是理论,操作是操作,我们该如何将其有效的关联起来呢?

Linux内核中Socket相关结构和流程

1. 用户态到内核态的Socket调用流程

当用户程序进行socket调用时,该调用会通过系统调用接口进入内核态,触发内核中的socket创建流程。

2. 内核态中Socket的创建和关联
  • sock_create:在内核中创建一个新的socket结构体。

  • 初始化等待队列:为socket初始化等待队列,用于处理同步操作。

  • sock_alloc_file:分配一个file结构体,用于用户态和内核态之间的文件描述符(fd)关联。

  • 建立双向关联

    • file->private_data 指向 socket 结构。

    • socket->file 指向 file 结构。

  • alloc_fd:分配文件描述符。

  • fd_install:将文件描述符与file结构体关联。

3. 数据结构关系
  • task_struct:表示一个进程,包含进程的所有信息。

  • files_struct:每个进程都有一个files_struct结构,用于管理该进程打开的所有文件。

  • fd_array:文件描述符数组,存储该进程打开的所有文件的指针。

  • struct file:表示一个打开的文件,包含文件操作相关的信息。

  • private_data:指向socket结构体。

  • struct socket:表示一个网络socket,包含网络连接的相关信息。

  • file指针:socket结构体中包含一个指向file结构体的指针。

  • sk_sleep等待队列:socket的等待队列,用于处理同步操作。

  • __wait_queue_head:等待队列头,包含锁和等待任务列表。

结构图