文章目录
- 传输层在网络通信中扮演的角色
- 认识TCP协议
- TCP协议的多种机制
- 确认应答(ACK)机制
- 超时重传机制
- 连接管理机制🔺
- 滑动窗口
- 流量控制
- 拥塞控制
- 延迟应答
- 捎带应答
- 面向字节流
- 粘包问题
- TCP异常处理
- 总结
传输层在网络通信中扮演的角色
上图是网络通信中五个模块,今天所谈的传输层介于应用层和网络层之间,应用层是程序运行所在的地方,而网络层是定位主机从而发送信息的地方,因此传输层的基本任务就是完成信息在程序与发送之间的数据缓冲和异常处理。
有了这个大致的功能认知,这对于学习传输层的协议非常有帮助。
认识TCP协议
传输层协议其实包括UDP协议和TCP协议,这个在之前写的一篇应用层协议文章中提到过,应用层的代码实现是要基于传输层协议的。话说回来,由于UDP是面向数据报的一种协议,因此可靠性不如面向连接和字节流的TCP协议。因此TCP被广泛应用于各种网络领域的传输层,所以TCP是本篇博客的主角。
TCP协议作为一种协议,理所应当的具有其独特的数据规划,下面是抽象出来的数据分化图:
关于上图的具体数据的解释,部分内容后面着重讲解:
图中前5行(前20个字节)是TCP首部的标准长度,选项部分忽略,最后的数据部分就是应用层交付或需要接收的数据。也就是说前20个字节是报头,下面的数据就是报文(有效载荷)。
一、端口的意义:解决分用
协议需要解决的两个问题:
1.如何进行封装和解包?
TCP的数据格式已经写死了,就如上面的结构图一样,只需要按照顺序就可以拿取或者填充数据。
2.如何进行分用?
首行的端口号可以解决,其中目的端口表明了数据送往应用层的哪一个程序。
二、32位序号和32位确定序号的作用:应答部分讲解
三、4位首部长度的意义:
报头有标准长度(20字节),也就意味着报头是变长的,具体的大小由报头中的4位首部长度体现。4位bit取值范围为[0000-1111],单位是4字节,意味着报头的理论长度范围是[0-60]字节。但是由于最少是20字节,因此实际长度范围是[20-60]字节。
四、序号实现消息的应答机制
TCP作为一种可靠的传输手段,必须能够应对丢包的问题,解决这个问题的首要任务就是要能够检测出来数据丢失问题。32位序号是发送端每次发送数据的标识号,唯一表明一条数据。32位确认序号是接收端对于发送端消息的应答序号,该序号表明所有该序号之前的信息都被接收。具体细节单独讲解。
五、6位标志位
URG: 紧急指针是否有效
ACK: 确认号是否有效
PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
RST: 对方要求重新建立连接; 把携带RST标识的称为复位报文段
SYN: 请求建立连接; 把携带SYN标识的称为同步报文段
FIN: 通知对方, 本端要关闭了, 称携带FIN标识的为结束报文段
六、16位窗口大小
表明缓冲区的大小,标识着一个接收端的接收能力。
七、16位检验和
发送端填充, CRC校验. 接收端校验不通过, 则认为数据有问题。 此处的检验和不光包含TCP首部, 也包含TCP数据部分。
八、16位紧急指针
标识哪部分数据是紧急数据。
TCP协议的多种机制
TCP协议作为一种可靠的传输协议,大佬们对其进行了多方面的考量和完善,出现了一系列的机制来保证安全、高效、尽量贴近现实需求。这些机制基本全是围绕TCP的报头内容设计的,因此接下来会对报头的部分数据进行详细解析。
确认应答(ACK)机制
一个消息有没有被对方接收到?在网络中如何确定这点?借助应答来解决。只要发送的消息有一条对应的应答被发送回来了,就认为消息成功被对方接收到了。这种机制就是确认应答机制,但是这种机制总会有一条最新的消息没有应答,这是不可避免的,只要保证部分百分百应答就可以。
确认应答机制主要是解决丢包的问题,毕竟数据在网络中传输不可能保证每次都是成功的,甚至即使传输成功也会由于种种原因而没有被客户端正确收到。这时就需要需要检测是哪些数据丢失,并对这些数据进行重传操作,直到数据传输成功。
其中[1-1000]、[1001-2000]、[2001-3000]这种数据的含义是[序号 - 数据大小+序号-1],数据大小的单位是字节,对于数据[1-1000],该数据序号是1,数据大小为(1000-1+1),则确认序号是1001,即确认序号=序号+数据大小。一旦出现丢包问题,确认序号将会保持不变,数值是最后一个确认收到的数据的确认序号:
事实上,序号还能保证数据能够一定的顺序被接收端处理。
超时重传机制
主机A作为发送端,主机B作为接收端,如果主机A给B发送了一条消息,在一定时间内收不到应答,就认为消息发送失败了。
可能的失败原因有三种:
1.主机A发送的消息丢包了。
2.主机B的应答丢包了。
3.网络拥塞,消息一直在路上而无法被接收。
无论哪一种情况,最终的结果都是主机A作为发送方重发数据,直到接收到主机B的应答。但是第二种情况由于是应答丢失,实际上主机B已经接收到了信息,因此主机A重发数据会导致数据重复,因此得依靠序号来实现去重,直接丢弃重复的数据。
那么这个超时怎么定义?多久算是超时呢?
最理想的情况下, 找到一个最小的时间, 保证 “确认应答一定能在这个时间内返回”。但是这个时间的长短, 随着网络环境的不同, 是有差异的。如果超时时间设的太长, 会影响整体的重传效率;如果超时时间设的太短, 有可能会频繁发送重复的包;TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间。
Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍。
如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传。
如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增。
累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接。
连接管理机制🔺
在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接。这就是著名的三次握手和四次挥手原则。
客户端与服务端建立连接之后,连接的属性是由双方共同维护的,只要有一方进行了某种改变连接的操作,连接属性都会发生变化。
三次握手:
服务端:
• [CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态, 等待客户端连接。
• [LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中, 并向客户端发送SYN确认报文。
• [SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 可以进行读写数据了。
客户端:
• [CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段。
• [SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态, 开始读写数据。
四次挥手:
服务端:
• [ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close), 服务器会收到结束报文段, 服务器返回确认报文段并进CLOSE_WAIT。
• [CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)。
• [LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接。
客户端:
• [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] 客户端要等待一个2倍的MSL(Max Segment Life, 报文最大生存时间)的时间, 才会进入CLOSED状态。
为什么是TIME_WAIT的时间是2*MSL?
MSL是TCP报文的最大生存时间, 因此TIME_WAIT持续存在2*MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的)。同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失, 那么服务器会再重发一个FIN。 这时虽然客户端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK)。
TIME_WAIT状态引起的服务端bind失败?
• 原因:
TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL的时间后才能回到CLOSED状态。使用Ctrl+C终止了server, 所以server是主动关闭连接的一方, 在TIME_WAIT期间仍然不能再次监听同样的server端口。MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s。
• 但是在某些情况下是不合理的:
服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短, 但是每秒都有很大数量的客户端来请求)。这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃, 就需要被服务器端主动清理掉), 就会产生大量TIME_WAIT连接。由于我们的请求量很大, 就可能导致TIME_WAIT的连接数很多, 每个连接都会占用一个通信五元组(源ip,源端口, 目的ip, 目的端口, 协议)。其中服务器的ip和端口和协议是固定的。如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了, 就会出现问题。
• 解决办法:
使用setsockopt()设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符。
为什么要进行三次握手?而不进行两次握手甚至是一次握手?
• 一次握手:
客户端发送SYN给服务端,因为网络延迟的原因客户端没等到连接完成就走了。但服务端接收到了请求就认为连接建立成功了,一直等待客户端发送消息,白白浪费了服务端的资源。
• 二次握手:
客户端发送SYN给服务端,因为网络延迟的原因客户端没等到连接完成就走了。服务端接收到了迟来的SYN,并发送SYN+ACK给客户端,并认为连接建立成功。但是此时客户端早就走了,那么服务端的资源就又白白浪费了。
🔺以上两个情况在一般情况下影响也不是特别大,但是一旦有恶意的攻击者故意利用这点漏洞,快速地向一个服务端同时发送多次连接请求,就会导致服务端在短时间内资源被大量的无效连接资源占用,最终导致服务器崩溃。
首先三次握手会避免因为网络延迟问题而建立无效的连接,因为最后一次的ACK表明客户端一直在等着服务端的应答。三次握手虽然也没办法避免被攻击的情况发生,但是会大大加重攻击的代价,因为三次握手有客户端向服务端发送ACK,注定会导致客户端也会建立连接资源,导致攻击者与被攻击者都承担资源占用的后果。而一次握手和两次握手则不会产生这种效果,客户端往往只是发送请求而不真正建立连接资源,这是TCP协议的特性,不能看作是漏洞。
为什么要进行四次挥手?
这里我们得清楚一个概念:客户端关闭连接的最开始的潜台词是客户端不再发送任何信息了,即关闭接收缓冲区。但这并不意味着服务端就不给客户端发送信息,因此想要断开连接的客户端不能想走就走,不能直接关闭自己的发送和接收缓冲区,必须还得等到服务端完成所有的任务才会允许双方断开连接。
• 最开始的client->server的FIN意味着client不再发送信息。
• 接着server->client的ACK表示server已经知晓了client的状况,将server自己的接收缓冲区内的剩余信息全部取出来,之后就可以关闭server的接收缓冲区了。
• server->client的FIN表示server也不再给client发送信息了,意味着已经没有剩余的事情可以做了,可以断开连接了。双方都将自己的IO关闭。
• client->server的ACK表示client已经知晓最后的断开连接通牒了,完成断开动作。注意,最后的应答不属于IO操作,不属于信息的发送!
现实生活中形象点的例子:恋爱煲电话粥
男子张三和女子王丽谈恋爱,正值热恋,晚上打电话。([张三:client]、[王丽:server])
张三和王丽你侬我侬,说了半夜的情话,最后张三瞌睡了,实在是顶不住了。
张三:宝宝,我瞌睡了,咱不聊了,睡觉吧🥱
王丽:可以啊,但是刚刚的话我还没讲完欸,你先听我说完。(接着王丽将剩余的话讲完,张三一直听着,不敢直接挂断电话)
王丽:好啦,我讲完了,时间确实不早了,咱们睡觉吧,我也不讲哩。(王丽等着张三回应自己)
故事到这里就有了分叉点。
1.若是张三直接回应了王丽,则有如下情况:
张三:好嘞,睡觉咯😴
王丽听到后,知晓挂电话与分别的最终时刻到了,最终双方和和美美地挂断了电话。
2.此时若是张三很长时间没有回答王丽,则有如下情况,直到张三回应王丽:
王丽:你睡着了嘛?回我一声话啊,我好知道挂不挂电话去睡觉啊🙄
知识点补充:
listen函数作为服务端的监听函数,其中有第二个参数backlog在学习完连接管理机制之后可以做如下解释:
当服务端没有调用accept函数时,底层所能建立的最多的连接数是有限的(连接状态为ESTABLISHED),并且与backlog这个参数有关,具体最大数量为backlog+1,超过这个数目,建立的连接状态就不是完全连接了,连接状态是SYN_RCVD。
这样就会有一个类似队列一样的存在,去管理那些没来的及被accept的连接,一旦服务器空闲起来了,就会从队列中获取连接执行对应的任务。这大大减缓了服务器在满载的时候的处理压力,避免了重复建立连接的情况,提高了效率。但是队列的长度不宜过长,因为这个队列存在的意义就在于缓解压力,太长的队列维护起来本身就很浪费资源,因此均衡一点的长度是被期望的。
滑动窗口
确认应答策略, 对每一个发送的数据段, 都要给一个ACK确认应答.。收到ACK后再发送下一个数据段。这样做有一个比较大的缺点, 就是性能较差。尤其是数据往返的时间较长的时候。
如果一次性发送多条信息,使得他们的等待应答时间重合,那么信息发送的效率就会大大提升。但是发送多条信息的前提是接收端得有相应的接收能力,对应的就是接收端的接收缓冲区的大小,在TCP的报头中通过16位窗口大小体现。因此就在发送端的发送缓冲区中引入了滑动窗口的概念。
发送端发送缓冲区的抽象视角:
关于发送缓冲区的各部分数据含义:
• 滑动窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。
• 滑动窗口内的信息, 不需要等待任何ACK, 直接发送。
• 收到ACK后, 滑动窗口向后移动, 调整窗口位置,依旧认为窗口内的数据无需等待即可发送,接着发送尚未被发送的信息; 依次类推……
• 窗口越大, 则网络的吞吐率就越高。
• 在尚未收到ACK之前,滑动窗口是不会移动位置的,一旦需要重发某条信息并完成信息的重发后,窗口会立即进行调整。
流量控制
接收端处理数据的速度是有限的。如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度。 这个机制就叫做流量控制 (Flow Control)。
• 窗口的大小是随着应答报头中的16位窗口大小改变而改变,接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段, 通过ACK端通知发送端。16位表示的数据范围:[0-64]KB,但这并不意味着最多只能发送 64 KB大小的数据,在选项中可以对窗口大小进行扩充,但这一般不是编程猿所负责的了,所以不做深入解析了。
• 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;发送端接受到这个窗口之后, 就会减慢自己的发送速度。
• 如果接收端缓冲区满了, 就会将窗口置为0。这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端。
可能存在的情况:
拥塞控制
拥塞控制是针对网络阻塞和网络异常这种情况所设计的,当网络比较拥堵时,如果上来就发送大量的信息时,就会恶化拥堵的情况。因此发送端最开始会先进入慢启动阶段,发送少量的数据,进行一种试探,再决定用多大的传输速度。
拥塞窗口在这种情况下被引入进来,最开始大小是1。
• 每收到一个ACK应答,阻塞窗口就会增大。
• 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口。
• 拥塞窗口增长速度, 是指数级别的。“慢启动” 只是指初使时慢, 但是增长速度非常快。为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍。此处引入一个叫做慢启动的阈值,当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长。
• 当TCP开始启动的时候, 慢启动阈值等于窗口最大值,在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1。
少量的丢包, 仅仅是触发超时重传; 大量的丢包, 就认为是网络拥塞。
当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降。
拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案。
延迟应答
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小。如果接收缓冲区接收了一部分数据,但是很快就被接收端给拿走了,空间很快就会被腾出来。这意味着接收者的处理能力较强,应答晚一点的话,应答中所携带的窗口大小就会更大。这意味着接收者向外界表示它的网络吞吐量较大,也就使得发送者一次能够发送更多信息,适当的延迟有助于挖掘接收者的处理潜能。
那么所有的包都可以延迟应答么? 也不是的。
数量限制: 每隔N个包就应答一次。
时间限制: 超过最大延迟时间就应答一次。
具体的数量和超时时间, 依操作系统不同也有差异。
捎带应答
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 “一发一收” 的。也就是说,一条信息,不仅仅只有单纯的应答或是信息任务,而是二者兼备。
面向字节流
TCP协议下,使用socket函数创建网络套接字,实际上就是创建一个文件描述符,具有全双工性质。
全双工就是可以对同一个文件描述符同时进行读和写的操作,而不发生读写冲突,这就意味着读写双方的信息肯定不在一块空间里,而是单独的两块空间:接收缓冲区和发送缓冲区。
调用write时,数据会先写入发送缓冲区中。如果发送的字节数太长, 会被拆分成多个TCP的数据包发出。如果发送的字节数太短, 就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去。
接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区。然后应用程序可以调用read从接收缓冲区拿数据。
另一方面, TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,即可以一次性读取或写入完所有的信息,也可以分批次多次读取或写入信息。
粘包问题
站在传输层的角度,TCP是一个一个报文过来的。 按照序号排好序放在缓冲区中。站在应用层的角度,看到的只是一串连续的字节数据。那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。上述问题就是粘包问题了,很类似于一屉包子挨得近了就容易粘在一起,分界线就模糊了。
解决的思路当然就是明确两个包之间的分界线。
对于定长的包,保证每次都按固定大小读取即可。
对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可)。但是这些都是TCP所要面临的问题,对于UDP来说, UDP是一个一个把数据交付给应用层。就有很明确的数据边界。站在应用层的站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收。不会出现"半个"的情况。
TCP异常处理
• 进程关闭
进程关闭的最后会自动关闭文件描述符,也就是会发送FIN信号,那么四次挥手就会正常完成。
• 机器重启
和进程终止的情况相同
• 异常断电
接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
总结
TCP设计得这么麻烦,就是为了保证通信的可靠性,并且尽量提高通信的效率。
可靠性:校验和、序列号(按序到达)、确认应答、超时重发、连接管理、流量控制、拥塞控制
提高效率:滑动窗口、快速重传、延迟应答、捎带应答
那么如果用UDP实现类似于TCP的可靠传输,要根据具体的情景要求,加入对应的机制。