传输层协议TCP
TCP 全称为"传输控制协议(Transmission Control Protocol"). 人如其名, 要对数据的传输进行一个详细的控制;
TCP协议段格式
16位源端口号
- 源端口号代表改报文是某台主机的那个程序发送的报文
16位目的端口号
- 目的端口号是数据到达对方主机后,交给哪一个应用程序。
4位首部长度
- 4位首部长度代表的是报头包含选项(不包含数据)的大小单位是字节。
- 其中TCP报头不带选项最小是20字节
- 因此4位首部长度的基本单位是4字节,表示范围为20到60字节。
标志位
- 为什么需要标志位?为了标识我们报文的类型。
- FIN:通知对方, 本端要关闭了, 我们称携带FIN 标识的为结束报文段
应用场景:- 正常连接关闭(四次挥手)
- 半关闭连接(一方结束发送但仍可接收)
- 异常终止时的优雅关闭
ACK:确认号是否有效
应用场景:- 除初始SYN包外的所有报文
- 捎带应答(数据包携带确认信息)
- 重复确认(快重传机制)
SYN:请求建立连接; 我们把携带SYN 标识的称为同步报文段
应用场景:- 连接建立(三次握手)
- 连接重建(异常断开后)
- 负载均衡中的连接迁移
安全机制:
RST:对方要求重新建立连接; 我们把携带RST 标识的称为复位报文段
应用场景:- 处理半开连接(对方意外断开会重置连接)
PSH:提示接收端应用程序立刻从TCP 缓冲区把数据读走
应用场景:- 催促对方应用层读取传输层数据
URG:紧急指针是否有效
应用场景:- 优先级高于普通数据
- 重要告警信息(比如网盘上传时终止操作)
- 网络中断命令(Ctrl+C)
- 该标志位与我们的16位紧急指针联系
- 注意紧急的数据就是在16位紧急指针紧急数据就是一个字节我们可以不同的状态码进行标识传输的紧急数据,比如时暂停或者取消上上传
序号与确认序号
- 序号:用来标识一个报文从传输层的发送缓冲区哪里发的
- 确认序号:返回一个应答给对方,表示在确认序号之前的数据我已经全部收到了,下次我希望你从确认序号的位置开始发送数据。
- 为什么需要两个序号?因为我在向对方发送应答的时候,可以进行捎带应答,就是我发送的应答的报文有数据,为了标识我发送的数据,我也需要序号。
- 什么时间看确认序号?当标志位ACK被置为1时
- 序号可以用来去重,排序,判断是否是老旧报文
- 去重:比如我们一次发送4个报文,序号分别为100,200,300,400,其中序号为200的报文数据发过去了,但是应答丢失了,因此序号100的ack确认序号为200,序号为200的ack确认序号为300但是丢失了,序号300也是正常返回ack,此时由于序号200的应答没有被收到,也不会触发快重传,此时进行超时重传的话,对方收到后发现序号为200的数据已经收到过了(通过比较最近的ack确认序号为500说明200的序号已经背收到),就会进行丢弃。
- 排序:报文不一定说是先发的就一定先到,因此需要进行排序。
- 老旧报文:在每次简历连接进行三次握手就会进行双方的初始发送序号,为随机值,如果此时有上次连接的报文还在网络中,当到达时,接收方会把该报文与此时最近的ack确认序号比较看一下是否是我想要收到的。
16位窗口大小
- 用来存储我们对方的win窗口大小,来影响我们滑动窗口的大小
- 16位窗口大小不只是只可以表示216字节,我们报头中有选项可以表设计对我们的16位窗口进行变大
动态调整机制:
零窗口特殊处理
16位紧急指针
- 就是指向对方接受缓冲区的偏移量从而传输我们的紧急数据
- 紧急数据只有1字节,因此我们可以考虑传输状态码
应用场景:
SSH会话中按Ctrl+C:
1. 客户端发送URG包,紧急指针指向中断指令
2. 服务端TCP栈立即中断当前数据流处理
3. 服务端shell进程优先处理中断信号
4. 终止正在执行的命令
TCP可靠性保证机制
确认应答机制
核心作用:
- 确认应答机制就是对历史报文进行可靠性的保证
- 比如你发送了一个报文,对方给你应答了说明的发送的报文被对方收到了,这就是对历史报文进行验证
- 其中序号、和确认序号在起到重要作用
- 每一个ACK 都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发.
工作流程:
关键技术:
- 累积确认:ACK=N 表示 N 之前所有数据都已接收
异常处理:
- 延迟 ACK:等待 200ms 或收到两个包才回复(在延迟应答中详细讲解)
- 重复 ACK:触发快速重传(收到三个以上相同的ack确认序号,在快重传详细讲解)
超时重传机制
- 主机A 发送数据给B 之后, 可能因为网络拥堵等原因, 数据无法到达主机B;
- 如果主机A 在一个特定时间间隔内没有收到B 发来的确认应答, 就会进行重发;
但是, 主机A 未收到B 发来的确认应答, 也可能是因为ACK 丢失了
因此主机B 会收到很多重复数据. 那么TCP 协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉.这时候我们可以利用前面提到的序列号, 就可以很容易做到去重的效果.
超时时间的确认:
- Linux 中(BSD Unix 和Windows 也是如此), 超时以500ms 为一个单位进行控制, 每次判定超时重发的超时时间都是500ms 的整数倍.
- 如果重发一次之后, 仍然得不到应答, 等待2*500ms进行重传
- 如果仍然得不到应答, 等待4*500ms 进行重传.以指数形式递增
- 累计到一定的重传次数, TCP 认为网络或者对端主机出现异常,强制关闭连接
连接管理机制
下面都是以客户端为主动方进行讲解的,服务端和客户端地位对端,反过来也是一样的
三次握手
- 上面的CLOSED未开始进行三次握手的客户端状态
- LISTEN状态是服务端进行调用listen接口处于LISTEN状态
- connect系统调用只负责开始三次握手,accept系统调用不参与三次握手的过程,三次握手由双方的操作系统完成。
- 客户端发送SYN后状态为SYB_SENT,服务端收到SYN报文状态为SYN_RCVD
- 服务端发送SYN+ACK,客户端收到状态为ESTABUSHED,代表客户端三次握手成功
- 服务端收到客户端的ACK,状态为ESTABUSHED,服务端三次握手成功
- 三次握手会进行双方初始序号的协商
- 三次握手还会进行双发窗口的大小
- connect返回代表客户端建立连接成功
- accept返回才代表服务端建立连接成功
为什么要进行三次握手?
- 首先以最小成本确定双方通信的意愿
- 其次就是验证了全双工
四次挥手
- 四次挥手和三次握手差不多就是进行确定双方解除连接的意愿
- CLOSE_WAIT状态就是我们客户端进行连接断开,但是服务端任然保持连接,但是我们服务端不关闭我们的fd就会造成fd泄露问题。
理解TIME_WAIT状态
- 主动断开连接状态的一方会进入TIME_WAIT状态,在该状态下我们无法立即进行我们再次连接(用相同的ip和端口号)。
- 改状态是为了防止上次连接的老旧报文在网络中由于拥堵留在我们的网络中,此时我们如果立马建立连接的话这个遗留在网络中的老旧报文会对我们本次连接造成影响
- 因此该状态时间为2MSL,MSL是报文最大在网络中最大存活时间
为什么TIME_WAIT时间为2MSL?
- TIME_WAIT 持续存在2MSL 的话,就能保证在两个传输方向上的的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);
- 同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK 丢失, 那么服务器会再重发一个FIN. 这时虽然客户端的进程不在了, 但是TCP 连接还在, 仍然可以重发LAST_ACK);如果没有重发LAST_ACK不久代表最后一个ACK服务端收到了吗
- 现代Linux对上述这种情况进行了优化,加入了时间戳,新建立的TCP的时间戳总比之前建立的时间戳大。再次考虑上述那种场景,当留存在网络中的包(迷失报文)发到新建立起来的TCP的一端时,会由于时间戳小,导致接收端放弃该数据包。所以,迷失报文的问题(考虑一)也就迎刃而解。故你可以认为2MSL和时间戳是迷失报文问题的双重保证。
- 此外还有一个序列号的机制,也对其这个问题进行了保障。新连接的SYN ,一定比 TIME_WAIT 老连接的末序列号大,这样迷失报文到达新连接的一端时,由于序列号比当前TCP连接的初始化序列号小,而被抛弃。
如何规避我们TIME_WAIT的影响
- 使用setsockopt()设置socket 描述符的选项SO_REUSEADDR 为1, 表示允许创建端口号相同但IP 地址不同的多个socket 描述符
int opt = 1;
setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt, sizeof(opt));
close函数和我们的shutdown函数
shutdown函数
- shutdown 函数允许你关闭套接字连接的一个或两个方向上的数据传输。
- 函数原型如下:
int shutdown(int sockfd, int how);
- 参数 sockfd 是要关闭的套接字的文件描述符。
- 参数 how 指定了关闭的方式,可以是以下之一:
- SHUT_RD(读端关闭):关闭连接的读方向。
- SHUT_WR(写端关闭):关闭连接的写方向。
- SHUT_RDWR(读写端关闭):同时关闭读和写方向。
- 使用 shutdown 可以半关闭一个套接字,即关闭其中一个方向的数据传输,而另一个方向仍然可以传输数据。
- 应用场景:
- 当你需要停止在一个方向上的数据传输,但仍然希望在另一个方向上继续传输数据时,可以使用 shutdown 来半关闭连接。例如,在客户端发送完请求数据后,你可能想要关闭写方向,但仍然需要读取服务器的响应。
- 在需要确保所有未发送的数据都已经发送完毕,并且对方知道连接已经关闭的情况下,可以使用 shutdown 来优雅地关闭连接。这有助于通知对方端点连接的关闭,从而避免数据丢失。
- 在使用 select、poll 或 epoll 等多路复用技术时,可能需要单独关闭套接字的读或写事件,而 shutdown 允许你这样做。
- 在发生错误时,你可能需要停止在一个方向上的数据传输,同时保持套接字打开以读取错误信息或进行其他操作。
close函数
- close 函数用于关闭一个套接字的文件描述符,这会终止连接并释放相关的资源。
- 它的原型通常如下
int close(int fd);
- 当你调用 close 时,如果套接字还有未发送的数据,close 会尝试发送剩余的数据。如果套接字设置了 SO_LINGER 选项,close 可能会阻塞直到数据发送完毕或者超时。
- close 会释放套接字的文件描述符,这意味着你不能再使用这个套接字进行任何操作。
流量控制机制
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送, 就会造成丢包, 继而引起丢包重传等等一系列连锁反应.因此TCP 支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制。
- 接收端将自己可以接收的缓冲区大小放入TCP 首部中的"窗口大小" 字段, 通过ACK 端通知发送端;
- 窗口大小字段越大, 说接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;明网络的吞吐量越高;
- 发送端接受到这个窗口之后, 就会减慢自己的发送速度;
- 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端.
- 接收端如何把窗口大小告诉发送端呢? 回忆我们的TCP 首部中, 有一个16 位窗口字段,就是存放了窗口大小信息;那么问题来了, 16 位数字最大表示65535, 那么TCP 窗口最大就是65535 字节么?实际上, TCP 首部40 字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是窗口字段的值左移M 位;
拥塞控制机制
如果我们在开始建立连接就发送大量的数据,可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据, 是很有可能引起雪上加霜的.因此TCP 引入慢启动机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
- 此处引入一个概念称为拥塞窗口,相当于一个整形变量;
- 发送开始的时候, 定义拥塞窗口大小为1;
- 每次收到一个ACK 应答, 拥塞窗口加1;
- 每次发送数据的大小,是拥塞窗口和窗口大小的较小值,这是滑动窗口大小决定的因素,拥塞窗口和对方窗口大小。
像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指初使时慢, 但是增长速度非常快.
- 为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.
- 此处引入一个叫做慢启动的阈值(ssthresh)
- 当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长
- 线性探测就是我们不断探测此时网络的最好状态
- 当TCP 开始启动的时候, 慢启动阈值等于窗口最大值;
- 在每次超时重发的时候, 慢启动阈值会变成原来的一半(就是我们不断进行线性探测的峰值直到发生网络拥堵,为线性探测峰值的一半), 同时拥塞窗口置回1;少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;当TCP 通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降拥塞控制, 归根结底是TCP 协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案.
TCP效率保证机制
滑动窗口
- 我们通信过程中并不是发送一个报文给一个应答,这样就效率太慢了
- 通信过程中是一次发送多个报文
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值. 上图的窗口大小就是4000 个字节(四个段).
- 发送前四个段的时候, 不需要等待任何ACK, 直接发送;
- 收到第一个ACK 后(假设四个段全都收到了那么第一个ack的确认序号一定是第5个段的序号), 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推;
- 操作系统内核为了维护这个滑动窗口, 需要开辟发送缓冲区来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;为了方便后续我们的重传
- 窗口越大, 则网络的吞吐率就越高;
- 滑动窗口是发送缓冲区的一部分
- 滑动窗口大小由我们对方接收缓冲区可用的大小和我们的拥塞窗口大小共同决定,取两者之间的最小值。
- 滑动窗口我们逻辑上可以看作一个环形数组,分为待发送区域,已经发送区域(这里就是发送报文后先不要删除为了方便后面的重传),和正在发送的数据(就是滑动窗口的起始位置)。
快重传
上面我们知道了滑动窗口下面我们考虑一下如果发送多个报文的时候丢包怎么办?
首先我们要知道超时重传是一直在工作的,在我们发送报文的时候计时器已经启动了。
上面的图我们就假设滑动窗口最左边的报文4001序号报文丢失了(真的丢包了不是丢了应答),此时对方收到的其他三个报文返回的ack确认序号都是4001,因为确认序号的定义是代表该序号之前的数据都收到了,此时4001丢包了,导致确认序号只能写4001,三个ack的确认序号都是4001,就会立马进行重传,不用进行等待,这就是快重传。
因此快重传的条件是收到三个以上的确认序号。
假设此时我们就发送了三个报文,最左边报文丢失,此时收到两个一样的ack,不会进行快重传,而是等待时间进行超时重传。
如果不是真的丢包呢? 只是丢了应答呢?此时我们还是以发送四个报文为例子,应答丢了,此时返回的ack确认序号只会是8001,因为只是应答丢了,但是数据我接收方收到了啊,此时滑动窗口继续向右边滑动,进行数据发送。
延迟应答
如果接收数据的主机立刻返回ACK 应答, 这时候返回的窗口可能比较小.
因为接收方应用层还没有读取接受缓冲区的数据,就会导致返回的窗口比较小。如果我不立刻返回ack,就可以有大概率增加返货窗口大小。
- 假设接收端缓冲区为1M. 一次收到了500K 的数据; 如果立刻应答, 返回的窗口就是500K;
- 但实际上可能处理端处理的速度很快, 10ms 之内就把500K 数据从缓冲区消费掉了;
- 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
- 如果接收端稍微等一会再应答, 比如等待200ms 再应答, 那么这个时候返回的窗口大小就是1M;
窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;那么所有的包都可以延迟应答么? 肯定也不是的
- 数量限制: 每隔N 个包就应答一次;
- 时间限制: 超过最大延迟时间就应答一次;
- 具体的数量和超时时间, 依操作系统不同也有差异; 一般N 取2, 超时时间取200ms;
捎带应答
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是"一发一收"的. 意味着客户端给服务器说了"How are you", 服务器也会给客户端回一个"Fine,thank you";那么这个时候ACK 就可以搭顺风车, 和服务器回应的"Fine, thank you. And you?" 一起回给客户。
- 我们三次握手的过程中返回的ACK+SYN不就是相当于做了一次捎带应答吗
面向字节流
创建一个TCP 的socket, 同时在内核中创建一个发送缓冲区和一个接收缓冲区;
调用write 时, 数据会先写入发送缓冲区中;
- 如果发送的字节数太长, 会被拆分成多个TCP 的数据包发出;
- 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
- 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
- 然后应用程序可以调用read 从接收缓冲区拿数据;
- 另一方面, TCP 的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做全双工
由于缓冲区的存在, TCP 程序的读和写不需要一一匹配, 例如:
- 写100 个字节数据时, 可以调用一次write 写100 个字节,
- 也可以调用100 次write, 每次写一个字节;
- 读100 个字节数据时, 也完全不需要考虑写的时候是怎么写的,
- 既可以一次read 100 个字节, 也可以一次read 一个字节, 重复100 次;
粘包问题
- 首先要明确, 粘包问题中的"包" , 是指的应用层的数据包.
- 在TCP 的协议头中, 没有如同UDP 一样的"报文长度" 这样的字段, 但是有一个序号这样的字段.
- 站在传输层的角度, TCP 是一个一个报文过来的. 按照序号排好序放在缓冲区中.
- 站在应用层的角度, 看到的只是一串连续的字节数据.
- 那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包.这就是为啥我们要自己实现应用层协议的原因。
那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界.
- 对于定长的包, 保证每次都按固定大小读取即可;
- 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
- 对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可);
对于UDP 协议来说, 是否也存在"粘包问题" 呢?
- 对于UDP,如果还没有向上层交付数据,UDP报文长度仍然在。同时UDP交付给应用层是一个一个交付的, 已经有了很强的数据边界。
- 站在应用层的站在应用层的角度, 使用UDP 的时候, 要么收到完整的UDP 报文, 要么不收. 不会出现"半个"的情况.
TCP异常情况
- 进程终止: 进程终止会释放文件描述符, 仍然可以发送FIN. 和正常关闭没有什么区别.fd文件描述符生命周期随进程。
- 机器重启: 和进程终止的情况相同.
- 机器掉电/网线断开: 这种情况就是进程还在只不过网络没有了,相当于王者荣耀突然断线了,此时应用层还在工作,可是网络层已经故障了接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset. 即使没有写入操作, TCP 自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放.
- 另外, 应用层的某些协议, 也有一些这样的检测机制. 例如HTTP 长连接中, 也会定期检测对方的状态. 例如QQ, 在QQ 断线之后, 也会定期尝试重新连接.
- 应用层的保活机制,就像亲密朋友间约定的“定时报平安”小信号。它是在基本的通信方式(微信/电话 - 类比 TCP 连接)之上,由通信双方自己制定并执行的一套用于快速、主动检测对方状态的规则。当这个小信号中断时,他们就能比等待运营商通知(TCP 缓慢保活或最终错误)更快地知道出了问题,并采取行动(重连)。也就是应用层提前会得知根据自己的协议连接已经断开了,就会进行重新socket建立连接。
理解TCP协议内核中的关系
socke系统调用
- 该系统调用在内核中会为我们创建一个struct socket(通用套接字)结构如下:
- 该系统调用在内核中会为我们创建一个struct socket(通用套接字)结构如下:
- accept如何获取连接以及我们的listen参数backlog的含义
- 全连接队列:有我们的listen套接字来管理里面放的是三次握手成功的连接,而我们的backlog的含义就是全连接队列最大数量-1,也就是backlog+1=全连接队列最大来连接个数。
- 内核结构:
struct inet_connection_sock {
struct request_sock_queue icsk_accept_queue; // 接受队列管理器
// ...
};
struct request_sock_queue {
struct request_sock *rskq_accept_head; // accept队列头指针
struct request_sock *rskq_accept_tail; // accept队列尾指针
// SYN队列使用哈希表实现
struct listen_sock *listen_opt; // 指向SYN队列
// ...
};
// accept队列中的元素
struct request_sock {
struct sock *sk; // 指向已建立的连接
struct request_sock *dl_next; // 队列链表指针
// ...
};
// SYN队列中的元素
struct request_sock {
// ... 半连接信息
struct hlist_node hash; // 哈希表节点
// ...
};
- listen套接字
- accept 操作流程