程序员在应用层要做的事情
在网络通信的过程中, 应用层描述了应用程序如何理解和使用网络中的通信数据, 和程序员打交道最多的就是应用层了, 针对不同的业务场景, 很多时候程序员需要去自定义应用层协议, 自定义协议主要需要完成下面的两件事情:
XML, json, protobuffer
.其中xml
和json
都是按照文本的方式来组织的, 优点是可读性好, 用户不需要借助其他工具, 肉眼就能看懂数据的含义, 缺点是要额外传很多的标签或键名, 占用较多的网络带宽, 影响效率; 而protobuffer
会将文本数据压缩为二进制数据传输, 特点是肉眼无法解析, 但占用空间更小小, 传输占用的带宽也就降低了.
应用层也有知名并广泛使用的成品协议, 就比如 : HTTP
协议.
传输层和应用层的联系
除了最上层的应用层, 下面的传输层, 网络层, 数据链路层, 物理这四层都是已经在系统内核/驱动程序/硬件中已经实现好了, 不许要我们去实现, 传输层是紧接着应用层的一层, 虽然传输层是操作系统内核实现好了, 但是我们在写应用层代码的时候, 是要调用系统的socket API
去完成网络编程, 所以需要我们了解这里传输层的一些关键协议UDP
和TCP
.
端口号的使用注意
端口号是传输层协议的概念, TCP和UDP协议的报头中都会包含源端口和目的端口, 并且都是使用2
个字节, 16bit
来表示端口号, 范围也就是 0 -> 65535
; 但是我们日常写的程序使用的端口号一般都是从1024
开始的, 因为0 -> 1023
这个范围的端口号也称为 “知名端口号/具名端口号”, 这些端口号系统已经分配给了一些知名并广泛使用的应用程序.
这里我们并不是完全不能使用0 -> 1023
这个范围的端口号, 只是建议使用, 虽然这些端口被分配给了特定程序, 但是这些程序是否在主机运行着, 主机上是否安装了这些程序都是不一定的, 要使用0 -> 1023
这些端口, 需要注意2点 :
UDP
是User Datagram Protocol的缩写, UDP的特点是无连接, 不可靠传输, 面向数据报, 全双工, UDP使用起来简单高效, 但它的数据载荷较小, 一般适用于以下场景:
UDP协议的格式 :
上面是教科书中的画法, 这样画是为了排版方便, 实际上画成下面这样更合理一些, 不过这并不影响理解.
UDP会把从应用层拿到数据(就是网络编程中应用层调用socket API, send()
发送的数据)的基础上再前面拼装上8个字节的报头.
下面就来分析一下报头中所包含的这些属性, 首先UDP
报头一共是8
个字节, 有4
个部分分别占2
个字节, 源端口号和目的端口号不必多说, 要完成一次网络通信, 涉及到源IP, 源端口, 目的IP, 目的端口, 协议类型这五元组, 其中端口信息是是由传输层负责的.
UDP的包长度, 也叫报文长度(报头+载荷), 这个属性表示了一个UDP数据报的大小, 单位为字节, 2字节表示 0->65535
这个范围, 也就是说一个UDP数据报最大不超过64KB
.
那如果应用层的数据报大于64KB怎么办呢?
这里有两种方案:
而且使用TCP也是更方便的, 如果使用第一种方案, 应用层的代码实现起来也比较复杂, 代码也得进行很多的测试, 毕竟代码复杂了, 随之bug也会更多更难处理一些, 这就好比叫货拉拉搬家, 如果东西很多, 一般的或者装不下, 如果采用方案一, 就需要多约几辆车, 这样约车, 装车, 清点都比较麻烦; 而采用方案二, 之间叫一个大一点的货车就好了.
最后一个属性就是校验和, 作用是验证传输的数据是否是正确的, 网络传输, 本质上是在传输光信号/电信号, 在传输过程中, 可能会受到一些物理环境的干扰等, 在这些干扰下就可能出现 “比特翻转” 的情况, 0
会变成1
, 1会变成0
.
一旦数据变了, 对于数据含义的解析可能就致命的, 举一个典型的例子, 程序中经常使用1表示某个功能开启, 0表示关闭, 本来网络数据报是想开启功能, 结果因为翻转, 就导致变成了关闭了.
像这样的现象是客观存在不可避免了, 我们能做的只是及时的识别出当前的数据是否出现了问题, 因此就引入了校验和来进行鉴别, 校验和是针对数据的内容进行一系类的运算(每一个比特位都会参与运算)得到一个比较短的结果, 我们可以认为, 数据内容一定, 得到的校验和就是相同的, 如果我们的数据变了, 那么的校验和就变了, 如此即可验证得到数据是否准确.
比如发送方要发送的数据是 “反射导弹”, 计算出来的校验和也会放到报头中发送, 当接收方拿到数据就会有两种情况了, 第一种情况是接收方拿到数据后重新计算一个校验和, 得到的结果如果和拿到的校验和相同, 就认为数据是正确的, 不同, 则认为数据不正确.
还有就是说, 数据传输过程出错了, 但是可能计算得到的校验和和之前的校验和恰好─样, 这种情况理论上是存在的, 但在工程实践中出现这种情况的概率极小, 就忽略不计了.
针对网络传输中, 生成校验和的算法有很多种, 这里简单介绍一下下面几个比较知名的:
CRC
(循环冗余校验): 这种方法简单粗暴, 就是报数据的每个字节循环往上累加, 如果累加溢出, 高位就不要了, 这种方法比较好算, 但是校验的效果不够理想, 万一你的数据同时变动了两个bit位(前一个字节少1, 后一个字节多1), 就会出现内容变了, CRC没变这样的情况.MD5
: MD5是使用一系列公式来进行更复杂的数学运算, MD5算法得到的结果具有下面几个特点.基于MD5的这些特点, 让MD5适用于更多场景, 计算校验和, 作为哈希函数计算Hash值, 用于加密等; 网上会看到一些解密MD5的方法, 都不是真正的解密, 因为真正的的想将数据还原回去凭借现在的计算机是做不到的, 这里的解密实质上是将一些常见的字符串的MD5值进行汇总成一张表, 解密的过程相当于查表, 总不可能把所有的字符串都列出来吧…
SHA1
: SHA1和MD5是类似的, 只是运算过程不一样吧, 这就属于数学问题了…TCP
, 即Transmission Control Protoco, TCP协议相比于UDP协议要更复杂, TCP的特点是有连接, 可靠传输, 面向字节流, 全双工.
TCP协议格式:
可以看到TCP报头中报还的属性还是很多的, 下面就来介绍这里的关键数属性.
首先对于端口号和校验和这几个属性这里就不做介绍了, 和UDP是一样的.
数据偏移和选项(option), 选项可有可无也可以有多个, 用于对TCP一些功能的扩展和TCP中的一些属性进行解释说明, 可能包括 “窗口扩大因子”, “时间戳” 等选项, 数据偏移表示TCP数据起始处与TCP报文起始处之间的距离, 也就是4
个比特位(0
到15
)表示TCP首部报头的长度, 单位是4字节
;
正是由于TPC当中有了数据偏移和选项这两个属性, 致使使TCP的报头长度是可变的, 不像UDP一样固定是8
字节, 选项之前的部分是固定的长度(20
字节), 选项长度 = 首部长度 - 20
字节, 通过首部长度就可以去调节选项长度; 如果首部长度值是5
, 表示整个TCP报头是20
字节(相当于没有选项), 如果首部长度值是15
, 表示整个TCP报头是60
字节(选项部分就是40
字节), 填充是为了保证选项为32
比特的整数倍.
保留项, 数据偏移后面还有6
位保留项, 这里保留项的存在是为了未来TCP协议的拓展升级准备的, 网络协议的拓展升级是一件成本极高的事情, 比如现在UDP协议报文长度是最大是2字节(64KB), 如果想要升级一下让UDP的报文支持更大的长度, 在技术上可以实现, 但实际上要想让世界上所有能上网的设备所安装的各式的操作系统都能够同步完成升级, 支持新的UDP, 这是不现实的; 一种系统升级了, 其他系统不升级, 就办法进行通信了…
而像TCP这样引入了 “保留位”, 如果在未来想要引入一些新的功能, 就可以使用这些保留位, 这样对于TPC本来的报头结构影响是比较小的, 针对不升级的设备也更容易兼容就版本的TPC.
剩下的属性和TCP内部的工作机制有关, 在后文的内容中会引入介绍, 这里先简单概述一下; 32位序号和32位确认序号和TCP确认应答机制有关; 16位窗口和滑动窗口, 流量控制机制有关; 16 位紧急指针要配合URG控制位一起使用的, 用于指明紧急数据之后正常数据的起始位置; 6位控制位从左至右分别为 , URG, ACK, PSH, RST, SYN, FIN, 含义如下:
该位为1时, 表示包中有需要紧急处理的数据.
该位为1时, 确认应答的字段变为有效, TCP规定除了最初建立连接时的SYN包之外该位必须设置为1.
PSH(Push Flag)
该位为1时, 表示需要将受到的数据立刻传给上层应用协议, PSH为0时, 不需要立即传而是先进行缓存.
该位为1时表示TCP连接中出现异常必须强制断开连接.
用于建立连接, SYN为1表示希望建立连接, 并在其序列号的字段进行序列号初始值的设定(Synchronize本身有同步的意思, 也就意味着建立连接的双方, 序列号和确认应答号要保持同步).
该位为1时, 表示今后不会再有数据发送, 希望断开连接.
确认应答是实现TCP可靠传输最核心的机制, 这里的可靠并不是指发送方能够百分百能将数据发送到接收方, 可靠传输是要尽可能的把数据发过去, 发送方能够准确的知道接收方是否收到了数据.
比如, 女神跟你关系是非常要好的, 你发的消息她一定立刻会回, 当你给你的女神发消息说要请她吃麻辣烫, 当你看到她的回复的时候, 你就知道消息她是收到了的.
这里女神回复的好啊好啊就称为 “应答报文”, 也叫ACK
(acknowledge), TCP进行可靠传输最主要就是靠这个确认应答机制来保证的, A给B发一个消息, B收到之后就会返回一个ACK, A收到这个应答之后, 就知道了发的数据顺利到达了(没有丢包), 如果没有收到ACK就说明A发的数据消息大概率不见了(丢包).
考虑更复杂的情况, 如果多条消息同时发送, 可能会发生 “后发先至” 的情况, 比如你问你的女神是否吃麻辣烫后紧接着又问一句: “女神, 女神, 你做我女朋友好吗?”, 就可能会是下面的情况:
在网络通信中, 两个主机之间, 传输路线存在多条, 数据报1和数据报2可能走的都是不同路线, 而且设备的转发速率也是有快有慢, 受这样的网络环境的影响, 这种后发先至的情况是很常见也是无法避免的, 收到消息的顺序就会存在变数, 这样应答错乱后, 解析数据的含义就出现歧义了, 就如上图, 如果按照正常的逻辑解析的话就是 “吃麻辣烫->滚, 做女朋友->好啊好啊”, 而实际上女神没想做你女朋友…
为了解决上述先发后至的问题, TCP中就引入了序号和确认序号.
上面发送方的序号就是序号, 接收方的序号就是确认序号, 这样即使出现先发后至的情况导致消息的顺序错位了, 也能区分清楚应答报文是针对哪一条消息做出的应答.
这里的序号和确认序号就是对应与TCP报头中的序号与确认序号, 它们都是32位大小, ACK
同样也在报头中占据1个比特位.
要注意任何一条数据(包括应答报文)都是有序号的, 但确认序号只有应答报文有, 是否为应答报文取决于ACK这个标志位是否为1, 如果为1就表示是应答报文, 如果是0就表示不是应答报文.
实际上的TCP序号并不是上面举例那样简单的以1, 2这种方式编号, TCP是面向字节流的, 在实际的TCP传输中, 是针对每一个字节都进行了编号, 假设我们的一条数据长度是1000个字节, 传输的数据第一个字节的序号是1, 由于这1000个字节都是属于同一TCP报文的, 因此就将一条数据中的第一个字节作为了TCP报头里记录的序号也就是1, 代表要发送的这一条数据.
确认序号的取值是收到的数据的最后一个字节的序号+1
, 接收方收到数据后会返回一个1001做为确认序号, 这里的确认序号表示着两个含义:
这里总的来说就是, TCP可靠传输的能力最主要是通过确认应答机制保证的, 通过应答报文不仅可以让发送方清楚的知道是否传输成功, 而且通过序号和确认序号对多组数据的应答对应关系进行了详细的区分.
上面说的确认应答只是讨论了数据顺利传输的情况, 那如果出现意外了呢? 比如网络原因可能会导致发送数据或者返回的ACK丢包了又该如何呢?
也好解决, 出现了丢包的情况, 发送方重新再发一个就行了, 这就是TCP引入的超时重传机制, 当一个数据发送后, 如果在一个时间阈值内没有收到ACK, 就认为是丢包了, 就会重发一份同样的数据.
触发重传有两种情况, 一是数据包丢了, 此时发送方等待一段时间没有收到ACK就会重新发送.
第二种情况是返回的ACK丢包了, 对于发送方是不知道是自己这边发送的消息丢包了还是对方返回的ACK出问题了, 此时就会按照最坏的情况, 重发数据, 这就可能导致接受方重复收到多次同样的消息, 但TCP针对这种重复数据的传输是有特殊处理的, TCP实现了一个去重机制, TCP中存在一个发送缓冲区和接收缓冲区(操作系统内核里面的一段内存), 接收方拿到数据后是将数据放到了接收缓冲区(有阻塞队列功能, 但不限于阻塞队列)中, 缓冲区会根据数据的序号进行排序, 并且根据数据的序号, TCP很容易识别当前接收缓冲区里的这两条数据是否是重复的, 如果重复就把后来的这份数据就直接丢弃了, 然后重新返回一个与之前相同的ACK.
然后就是说, 重传也是有可能出现丢包的情况的, 可能会出现多次重传都丢包的情况, 而在网络正常的情况下丢包的概率是很小的, 两次以及多次丢包的概率那就更加小了, 所以重发一定次数后就会降频重发(), 还是不行就会认为是网络出现故障了, TCP就会尝试重置连接(断开重连), 如果重置还是失败, 就会彻底断开连接了.
关于超时重传这里, 总的来说就是, 由于去重和重新排序机制的存在, 发送方只要发现ACK没有按时到达, 就会重传数据, 即使数据的顺序乱了, 重复了, 依赖于TCP报头的序号, 接收方都能很好进行处理.
TCP的可靠传输就是通过确认应答和超时重传来体现出来的, 其中确认应答描述的是传输顺利的情况, 超时重传描述的是传输出现问题的情况, 这两者相互配合, 共同支撑整体的TCP可靠性.
TCP要完成通信是需要先建立连接的, 所以就有了连接管理的机制, 连接管理一定程度上也可以体现TCP的可靠性, 但保证可靠传输最核心的机制还是上面介绍的确认应答和超时重传.
TCP这里的连接指的是由一个四元组(源IP, 源端口, 目的IP, 目的端口)来标识, 一个连接建立完成就表示通信双方知晓对方的IP和端口信息, 就是通信双方各自都维护着连接这样的一个数据结构, 双方把对方的地址信息都保存下来就是完成了连接, 而断开连接就是把各自存储的连接删除掉.
对于TCP的连接管理就是建立连接(三次握手), 断开连接(四次挥手)了.
客户端与服务器之间进行三次交互建立连接的过程被形象地称为 “三次握手”, 在这三次交互中, 通信双方要完成对彼此信息的记录.
还是你和你的女神聊天, 你想要让女神做你的女朋友, 于是就有了下面的对话.
首先是你向女神表白, 女神接受了就表明, 你和女神就有了一个认同, 女神是你的唯一, 但此时你和女神还没有成为男女朋友的关系, 因为女神不知道你是不是她的唯一(万一你脚踏好几只呢…), 然后女神也对你表白, 你有接收了, 此时就说明你俩互为唯一了, 这就真正建立了男女朋友关系, 相当于客户端和服务器建立成功了.
把每次通信形象的称为一次挥手, 上面的过程就是四次挥手了, 但在实际TCP的建立连接过程上是三次挥手, 其实就相等于于把上面女神的两条消息合并成了一条消息.
所以, 三次握手本质上是四次信息交互, 通信双方各自需要发送一个 “建立连接” 的请求, 然后再各自向返回一个ACK, 中间两次是合并完成的, 而且必须是三次握手, 两次握手是不行的(没有最后一次, 女神就不知道你是不是她的唯一了…)
我们知道客户端是主动发出请求的一端, 服务器是被动响应的, 所以, TCP建立连接需要让客户端向服务器发送一个连接请求, 即SYN
, 然后服务器收到请求后会给客户端响应一个ACK
和SYN
, 客户端收到服务器的SYN
后会立即发送一个ACK
给服务器, 服务器收到客户端的确认应答后,客户端与服务器就连接成功了.
要注意理解的是, 服务器给客户端发送的ACK和SYN这两次通信是必须要进行合并的, 因为封装分用两次一定是比一次成本要高的, 而建立连接的这四次信息交互是在纯内核中完成的, 服务器的系统内核是可以做到收到SYN之后就立即同时发送ACK和SYN的, 也就必须是这样做.
三次握手的作用不限于建立连接, 除此之外三次握手还能检测发送能力与接收能力是否正常, 还是你和女神之间的日常, 你和女神带着耳机连麦打游戏, 有如下场景:
第一次通信, 当女神听到你的声音时, 女神就知道了你的的麦克风和女神的耳机是都是可以使用的, 而你现在还什么都判断不了; 第二次通信, 当你听到女神的回复, 你就知道了你和女神的麦克风和耳机都是可以使用的, 但此时女神还不知道你的耳机和她的麦克风能不能使用; 第三次通信, 当女神再次听到你的回复时间, 你和女神就都知道了你们双方的耳机和麦克风都是可以正常使用的, 这里的麦克风就对应发送能力, 耳机就对应接收能力.
再从实际上TCP的三次握手来说, 当发送方发出SYN后, 接收方都到发送方的SYN后, 此时接收方就能够确定发送方的发送能力和接收方的接收能力是正常的, 然后接收方回应ACK和SYN, 当接收方收到ACK和SYN后, 就知道了发送方和接收方的发送能力, 接收能力都是正常的, 最后发送方回应ACK给接收方, 此时接收方也确定了接收方和发送方的接收能力, 发送能力都是正常的.
所以, 总结一下三次握手的意义就是:
发起建立建立连接请求的报文就称为SYN
, 也叫同步报文段, SYN是TCP首部控制控制位当中的一位, 这个标志位为1就表示是请求建立连接的报文, 其他控制位同样如此, 用0和1来进行控制.
在建立连接的过程中, 服务器与客户端是存在着不同的状态的, 不同的状态体现了TCP当前的工作, 具体如下:
LISTEN
表示服务器已经准备就绪, 等待客户端连接的状态.ESTABLISHED
表示客户端或服务器已经建立成功连接, 随时可以进行通信, 要注意理解, 两次握手后, 从客户端来看, 客户端已经把该发送的和该接收的都完成了, 此时客户端就认为进行成功建立连接; 而对于服务器, 当第三次握手后才能认为成功建立连接.三次握手这里也能一定程度上保证TCP的可靠传输, 但只是辅助做作用, 真确保可靠传输个关键机制还是上面介绍的确认应答和超时重传.
与三次握手类似, 客户端与服务器通过四次交互断开连接的过程称为 “四次挥手”, 通信双方向对方发起一个断开连接的请求FIN
, 再各自给对方一个回应ACK, 这个时候你和女神的缘分就到了尽头了, 出现了下面的场景:
断开连接的请求也被称为FIN
, 在TCP报文中也是一个控制位, 断开连接的请求可以是客户端先发起, 也可以是服务器先发起, 这里以客户端主动断开连接为例继续介绍.
这里与三次握手需要区别的是, 断开连接的过程中, 中间两次通信通常是不能合并的, 这是因为FIN的发起并不是由系统内核控制的, 而是由应用程序调用socket的close方法(或者进程退出)才会触发FIN, ACK则是由系统内核控制的, 所以, 上图描述的客户端发出断开连接FIN请求, 然后服务器收到请求后立即响应ACK(内核返回ACK), 但此时并不能确定服务器应用层程序到底什么时候才会调用socket.close()
从而触发返回给客户端的FIN.
也就是说, 四次挥手不像三次握手那样是在重内核中完成的, 三次握手可以做到中间两次交互的时机是相同的, 而四次挥手的中间两次交互时机首否是相同的完全取决于服务器应用层的代码是怎么写的, 如果特殊情况下中间的两次交互时机是相同的也有可能是合并完成的.
同样断开连接过程中, 客户端和服务器的的状态如下:
CLOSE_WAIT
出现在被动发起断开连接的一方, 当被动方收到主动方发送的FIN请求后, 被动方响应ACK, 然后等待关闭连接( 等待socket调用close方法).TIME_WAIT
出现在主动发起断开发起连接的一方, 收到被动方FIN, 发送最后一次ACK, 然后继续保持当前的TCP状态, 再等一会儿后释放连接.这里重点要理解的是TIME_WAIT
这个状态, 从上图来看在客户端看来它的将最后一次ACK发出去后, 四次挥手就已经是完成了? 那为什么TIME_WAIT这里还要等待一会而不是立即释放连接, 这是因为最后一次客户端发送ACK后是可能存在丢包的情况的, 在三次握手和四次挥手的过程中, 同样是存在超时重传的, 如果丢包了, 服务器就会以最坏情况认为自己的FIN丢了, 会重发FIN, 此时客户端就需要等待以预防服务器重发FIN的这种情况, 因此使用TIME WAIT状态保留一定的时间, 就是为了能够处理最后一个ACK丢包的情况, 能够在收到重传的FIN之后, 进行ACK响应.
TIME_WAIT这里等待的时间为2MSL
, MSL表示报文最大生存时间(通常是60s), 也就是在两个节点进行网络传输过程中消耗的最大时间, 如果TIME_WAIT维持了2MSL都没用收到重传的FIN, 就认为最后一个ACK顺利到达了, 服务器与客户端就完全断开连接了.
上面介绍的TCP机制都是再给TCP的可靠性提供支持, 但保证了可靠性其实就牺牲了一定的效率, 滑动窗口做的事情就是在保证传输的可靠性的基础上, 尽量地去提高传输效率.
在进行IO操作的时候, 时间成本主要是两个部分, 一是等, 二是数据传输, 大多数情况下, IO花的时间成本大头都是在等上面, 滑动窗口本质上就是降低了等待确认应答ACK消耗的时间.
对于基本的确认应答机制来说, 每发送一次数据, 都需要等待ACK返回后才能进行下一次发送, 这样大部分的时间都用在等ACK上了.
滑动窗口的本质就是不进行等待发送多条数据, 然后使用一份时间来等待多个ACK返回.
把不需要等待, 就能直接发送的最大数据量, 称为 “窗口大小”, 上图中窗口的大小就是4000, 客户端发送了4条数据之后并不是等到4个ACK都都返回后才能继续发送, 而是每收到一次ACK就继续发下一条数据, 这样就让客户端这里等待ACK的数据始终始终都是4条, 就如上图, 客户端发出1-1000,1001-2000, 2001-3000, 3001-4000这四条数据后, 客户端收到1001, 紧接着就发送4001-5000这条数据, 收到2001, 就继续发送5001-6000…
这里图中本来等待ACK是1001-5000, 接下来, 收到了2001这个ACK, 就说明2001之前的数据(1001-2000)已经被确认了, 此时就可以立即发送5001-6000的数据, 此时意味着等待ACK的范围就是2001-6000, 这就相当于一个大小始终不变的窗口, 但窗口框住的数据变了, 相当于窗口向右滑动了一格, 所以这里就形象的称为 “滑动窗口”.
上述是正常传输的情况, 那如果丢包了应该如何处理呢? 下面就来分析一下,
情况1, ACK丢了: 这种情况下是不用做任何的处理的, 数据还是能够正常传输, 比如1001丢了, 但实际上1-1000的数据是服务器是收到了的, 当客户端收到2001时就表明2001之前的数据都已经确认到达服务器, 就会接着再发送两条数据, 所以只要大部分的ACK没有丢, 客户端可以通过下一次或者后面的确认应答序号来进行确认, 不处理也没事.
情况2, 数据丢了: 数据都丢了, 这就必须得处理了, 比如1-3000的数据中, 其中1001-2000的数据丢了, 那服务器每收到一个数据, 都会返回1001, 表示让客户端重传1001-2000这个数据, 当客户端收到若干个个相同的确认应答序号时, 就明白了, 数据丢了, 就会对丢失的数据进行重传, 直到服务器收到1001-2000的数据, 就会返回最新的确认应答序号, 当然, 如果中间还有数据都丢包, 返回的就是新丢的包的序号了, 然后还是上述操作.
这种丢包重传的方式被称作 “快速重传”, 让重传操作只重传了丢失的数据, 可以视为是超时重传机制在滑动窗口下的变形; 如果当前传输数据密集, 按照滑动窗口的方式来传输, 此时按照快速重传来处理丢包; 如果当前传输数据稀疏, 就不再按照滑动窗口方式了传输了, 此时还是按照之前的超时重传处理丢包.
滑动窗口机制是在提高TCP的传输效率, 窗口越大, 传输效率就越高, 但是窗口也是不能无限大下去的.
首先如果窗口无限大了, 那么一个窗口就把数据都发完了, 也就是完全没有在等ACK了, 数据传输的可靠性就得不到保障了, 这就和TCP的初心背道而驰了, 而且窗口太大, 也会消耗大量的系统资源.
然后就是说, 窗口大了, 发送方的发送效率确实提高了, 但是接收方能接受得过来吗? 但是如果发送速度过快, 接收方的接缓冲区满了之后, 接收方就处理不过来了, 白发了…
所以并不是窗口大小越大, 传输效率就越高, 只有保证发送方发送与接收方接收的速率最大并保持一致时, 传输效率才是最高的, 而流量控制要做的工作就是根据接收方的处理能力, 动态协调发送方的发送速率.
接收方的处理能力是通过接收方缓冲区的剩余容量来衡量的, 接收方缓冲区的容量剩余多少, 下次发送方的窗口大小就是多少, 可以接收方的缓冲区想象成一个蓄水池, 那么发送方的工作就是注水, 接收方的工作就是使用水池中的水, 当水位比较低(剩余空间大)那就注水的时候就快一点, 水位比较高(剩余空间小)那就注水的时候就慢一点, 池子满了就暂时先停止注水.
当发送方的数据到达接收方的时候, 接收方都会返回一个ACK,这个ACK除了确认能够确认应答, 还能告知接收方缓冲区的剩余容量, 然后发送方就会根据接收方缓冲区的剩余容量来控制发送速度(窗口大小), 当接收方得知接收方缓冲区空间满了的时候, 就暂时不会发送数据了, 而是会定期去给接收方发送一个探测窗口报文, 这个报文不携带具体的业务数据, 只是为了触发ACK查询接收方缓冲区的剩余容量.
上面获取接收方缓冲区的剩余容量, 是通过TCP报头中的窗口大小来进行获取的, 占16个比特位(即64kb), 但这并不意味着窗口大小最大就是64kb, 因为TCP报头的选项部分里面有一个窗口扩大因子M, 实际窗口大小是将窗口大小字段左移M位, 也就是扩大 2 ^ M 倍, 比如窗口大小已经是64kb, 如果扩展因子M标识为2, 最大的窗口大小就拓展为了256KB(64KB << 2).
流量控制是考虑到了接收方的处理能力来调节发送方的窗口大小, 而实际上数据传输不单单是简单的从发送方直接到了接收方, 往往还有复杂的中间转发过程(众多交换机和路由器等节点), 拥塞控制描述的是传输过程中中间节点的处理能力, 同样的如果中间转发过程中链路的拥堵了, 那接收方的处理能力再快也是白搭的(木桶效应).
拥塞控制本质上就是通过实验的方式来逐渐找到一个合适的窗口大小(合适的发送速率).
接收方处理能力是好量化衡量的, 但是由于设备众多, 数据每次传输路线也大概率是不相同的… 众多影响因素导致中间节点的处理能力是不好量化衡量的, 因此拥塞控制采取了 “测试实验” 的方式逐渐调整不同情况下合适的发送速度.
初始的时候接收方会以较小的窗口进行发送(0轮, 窗口大小是1, 但不是一个字节), 由于初始窗口比较小(发送速率慢), 每一轮不丢包都会使窗口大小扩大一倍(指数增长), 当增长速率达到阈值之后, 指数增长就成为了线性增长, 再当窗口达到一定的大小, 就会出现丢包的情况, 这就意味着链路就出现了 “拥堵”, 说明此时发送的速率已经接近网络的极限的; 此时就会减小窗口的大小(速度很快, 立马缩成很小的值), 因为如果出现丢包减小窗口大小的速度不够大, 可能会出现持续性的丢包, 对网络通信的质量会造成很大的影响, 然后就是重复刚才指数增长和线性增长的过程了.
拥塞窗口不是固定数值, 而是一直动态变化的, 随着时间的推移, 逐渐达到一个动态平衡的过程, 使窗口大小随着网络的动态变化而动态变化.
实际上的窗口大小是拥塞控制和流量控制共同决定的, 取的是拥塞窗口和流量控制窗口的较小值.
延时应答也是提升TCP效率的机制, 流量控制是为了在接收方能够处理得了的前提下, 尽可能的把窗口大小放大一点, 延时应答相当于流量控制的延伸, 想要在此基础上, 让窗口的大小尽量再大一点.
就是接受方收到数据之后, 不是立即返回ACK了而是稍微等会再返回, 等待的时间里, 接收方的应用程序就能够把接收缓冲区的数据给再处理一波, 此时接收缓冲区的剩余容量就更大了.
实际上延时应答采取的方式, 就是在滑动窗口下, ACK不再每一条数据都返回了, 比如下图就是隔一条返回一个ACK.
实际上接收缓冲区剩余空间大小的变化是一个复杂的过程, 既取决于发送方的发送也取决于接收方的处理.
同样捎带应答也是提升TCP效率的机制, 是延迟应答的延伸, 由于延时应答的存在, 接收方并不是立即就返回响应ACK的, 而很多情况下, 客户端服务器在应用层也是 “一发一收” 的, 当服务器的应用层有业务数据要发送给客户端时, 就可以捎带的将ACK一起发送, 此时应用层代码需要响应的时机与ACK响应时机重合的, 就可以将这两个数据合二为一进行发送, 结合下图理解, 还是你和女神的日常,
图中你给女神发送的ACK是由系统内核返回的, 业务数据是由应用程序发送的, 这两条数据的发送本来是在不同的时机发送的, 由于延时应答机制的存在, 就导致等待ACK的过程中, 接收方就要发送业务数据给发送方了, 此时就可以让业务数据捎上这个ACK一起发过去就行了.
也就是说, 上面的ACK和业务数据本来是在不同的时机的, 但在延时应答的情况下是可能成为相同时机的, 然后就合并发送了, 延时应答是提高了这里合并的概率, 捎带应答就是针对这种能合并的情况进行的特殊处理.
TCP是面向字节流的, 在接收缓冲区其实是把多个数据都放到一起的, 这就导致应用层去使用read()读取缓冲区的数据时, 会出现分不清读到哪里才算是一个完整的应用层数据报, 由于TCP是面向字节流的, 那么一次读1个字节或者读N个字节, 都是可以的, 这就导致一次读到的数据可能是半个应用层数据报, 可能是一个应用层数据报, 也有可能是多个应用层数据报…
也就是说在TCP层次的socket API中是没有告诉我们应该读几个字节的, 具体怎么读, 完全是由程序员自己负责, 但我们所希望的是每次读的是一个完整的应用层数据报, 这就是需要程序员自己去解决了.
其实解决方案也很简单, 程序员是可以控制应用层协议的, 只需要在应用层代码中约定好应用层数据报和应用层数据报之间的边界就好了, 比如可以应用层数据报结尾约定一个分隔符, 这样在读取的时候, 就能区分出一个完整的应用层数据报了; 也可以约定好每个包的长度, 读取时先读取长度, 让然后再读取读到长度的字节数就能得到完整的数据报了.
在进行TCP协议传输过程中会出现由于不可抗力导致的异常情况, 针对如下几种进行简单介绍:
进程崩溃了(进程终止)
TCP连接是通过socket来进行连接的, socket本质上是进程打开维护的一个PCB, 进程终止了, 对应的PCB就没了, 再对应在文件描述表中的位置就释放了, 就相当于文件自动关闭了, 这个过程和手动调用socket.close方法没有区别, 系统内核依然会完成四次挥手的过程, 此时其实还时一个正常断开连接的流程.
主机关机(按照正常流程关机)
主机关机首先终止的是进程, 就和上面一样的, 还是会触发四次挥手, 然后正式关机.
主机掉电
当电源或网络直接断开时, 是没有任何时间留给操作系统去反应的, 所以根本来不及去完成四次挥手.
假设是接收方掉电了, 此时发送方仍然是继续在发数据的, 发完数据要等待ACK返回, 接收方都挂了肯定时传不了了, 那么发送方就会进行超时重传, 但不管怎么重传, 都是收不到ACK的, 重传了几次, 还是没有收到ACK, 发送方就会尝试重置TCP连接, 显然这个重置也会失败, 然后发送方就会单方面放弃连接了.
TCP重置连接的报文的是通过复位报文段来判断的, 即RST, 也是TCP报头中控制位中的一位.
再考虑发送方掉电的情况, 此时接收方会发现, 发送方很差时间没有数据发送过来了, 但从接受方的角度来看, 接受方不知道是发送方挂了还是发送方在组织数据, 所以针对这种情况, 接受方会周期性的给发送方发送一个探测报文, 触发服务器的ACK, 如果没有反应, 就说明是发送挂了.
这样的探测报文也被形象的叫做 “心跳包”, 用来确认通信双方是否处在正常的工作状态中, 因为心跳是周期性的, 如果心跳没了, 说明就挂了, 心跳包是非常常见并且经常用到的保活机制.
最后在这里对比一下UDP和TCP, TCP优势在于可靠传输, 在绝大部分场景中都需要进行的是可靠传输; 而UDP优势在于高效率, 如果有些场景对于性能要求更苛刻使用UDP就很合适, 比如同一个机房内部的服务器之间通行就可以使用UDP, 因为这种场景下的网络结构相对简单, 网络带宽也是比较充裕的, 转发设备也是比较好的设备, 整体丢包的可能性就比较小了, 这里就可以要求以更高的效率进行传输; UDP还有一个天然的优势就是支持广播, IP地址中有一种特殊的地址叫 “广播IP”, 通过UDP往广播IP上发送数据报, 此时该局域网内所有的设备都能收到数据.
TCP是个非常复杂的协议, 上面所介绍的十大特性只是TCP中比较核心的特性, 其他的就不在这里介绍了, 传输层的协议也不只有UDP与TCP这两个, 比如还有KCP, QUIC等, 在游戏场景中经常使用.