本章节主要讨论 TCP/IP 协议栈
应用层是和程序员交互最多的一层, 很多时候写代码, 都涉及到应用层协议
这样就需要咱们自定义一个应用层协议
为什么要自定义协议?
当前的应用程序要解决的任务是错综复杂的, 在不同的公司, 有着不同的业务, 不同的业务有着不同的业务流程. 程序员来解决这个复杂的业务, 程序也就复杂了.
怎么去进行自定义协议呢?
- 结合需求, 分析清楚, 请求响应要传递哪些信息
- 明确传递信息是以什么样的格式来组织
典型的用来组织数据的格式
上述这两种格式都是按照文本的方式来组织的
优点: 可读性好, 用户不需要借助其他工具就能读懂
缺点: 效率不高, 占用较多的网络带宽
特点: 肉眼观察不了(二进制的, 用记事本打开就乱码了) 但是占用空间小, 传输的带宽也就降低了.
应用层除了上述自定义的协议, 也有一些大佬已经设计好的现成的协议
最典型的HTTP协议
传输层虽然是操作系统内核已经实现好的了, 但是程序员写代码, 要调用系统提供的 Socket API 完成网络编程.
这里设计到两个相当重要的协议: UDP, TCP
端口号
数据库 MySQL 默认端口 3306
端口的作用就是区分一个主机上具体的应用程序
这样就要求, 在同一个主机上, 一个端口号不能被多个进程绑定,
TCP和UDP协议报头中都会包含 源端口和 目的端口
这两个协议都是使用两个字节(16bit)来表示, 这就说明一个端口的取值范围是 0~65535
但是我们自己写程序得从1024开始, 因为0~1023 这个范围的端口, 称为"知名端口/具名端口号", 这些端口号属于已经分配给了一些知名的广泛使用的应用程序了
1023以下的端口也不是完全不能用, 这些端口虽然已经分配给了特定程序, 但是这个程序是否在你电脑上运行着, 电脑上是否安装了这个程序, 这是不一定的.
无连接
不可靠
面向数据报
全双工
一次数据通信涉及到五元组
源IP, 源端口, 目的IP, 目的端口, 协议类型
UDP 报头里就包含一些特定的属性, 就携带了一些重要的信息
不同的协议, 功能不同, 所以报头中携带的属性信息也就不同
对于UDP来说 报头一共是8个字节, 分成四个部分(每部分2字节)
UDP 的报文长度也是2个字节表示, (0~65535) 也就是64kb, 这就说明一个UDP数据报, 最大只能传输 64 KB 的数据
那么就带来一个问题
如果一个应用层数据报超过了64KB 怎么办呢?
- 需要在应用层, 通过代码的方式针对应用层数据报进行手动的分包, 拆成多个包, 通过多个UDP数据报进行传输(本来只需send一次, 现在就需要send多了)
- 不用UDP, 换成TCP(TCP没有这样的限制)
校验和: 作用是验证传输的数据是否是正确的
网络传输过程中, 可能会收到一些干扰, 在这些干扰下就可能出现"比特翻转"的情况
比如说一个程序使用 1 表示某个功能开启, 0表示某个功能关闭, 如果出现比特翻转的情况, 本来想开启的结果就变成关闭了, 这种情况是客观存在的, 不可避免, 我们能做的就是及时识别出, 当前数据是否出现了问题, 所以就引入了校验和进行鉴别.
几个知名的校验和算法
- CRC (循环冗余检验)
简单粗暴, 把数据的每个字节, 循环往上累加, 如果累加溢出了, 高位就不要了,好算, 但是校验效果不是特别理想,如果同时变动两个bit(前一个少1, 后一个多1), 就会出现, 内容变了, 而检验和没变的情况- MD5
这个不是简单相加了, 有一系列的公式, 来进行更加复杂的数学运算
特点:
1 ) 无论你原始数据有多长, 得到的MD5值都是固定长度(4字节, 8字节)
2 )冲突概率很小, 原始数据只变动一个数据, 算出来的MD5值都会差别很大
3 )不可逆, 通过原始数据计算MD5很容易. 通过MD5还原成原始数据很难(计算量极大)
基于这样的特点, 让MD5作用更多:
1 ) 校验和
2 ) 作为计算Hash值的方式
3 ) 加密- SHA1(和MD5类似)
有连接
可靠传输
面向字节流
全双工
一个TCP报头, 长度是可变的(不像UDP一样固定8个字节)
首部长度描述了TCP报头具体多长,
选项之前的部分是固定的长度(20字节),
首部长度占4个比特位(0~15), 这里要注意的是, 这个首部的单位不是字节而是"4字节" , 如果首部长度值是5, 就表示整个TCP报头是20(5 * 4)字节, 没有选项, 如果首部长度是15, 就表示整个TCP报头是60(15 * 4)字节, 选项就相当于40字节
这里的保留6位, 是给以后扩展来考虑的, 就是现在还没用, 给以后万一要用的时候留下位置
TCP 是一个复杂的协议, 里面有很多机制, 这里只讨论比较核心的 10 个机制
TCP 进行可靠性传输, 最主要的就是这个确认应答机制
A 给 B 发一个消息, B 收到消息后就会返回一个应答报文(ACK), 此时 A 收到应答后, 就知道刚才发的数据已经顺利到达 B 了.
注意: 网络上可能存在"后发先至", 这个情况下, 收到消息的顺序是可能存在变数的.
两个主机之间, 路线有很多条, 数据报1和数据报2 走的路线可能不一样. 有的转发速率快, 有的转发速率低.
结: 网络后发先至这个现象是客观存在的, 无法避免
因此应答报文到达的顺序也是可能发生变化, 此时就要考虑如何规避这种情况:
解决这个问题也很简单, 给传输的数据和应答报文都进行编号
当我们引入了序号之后, 就不怕顺乱了, 即使是顺序乱的, 也可以通过序号来区分当前应答报文是针对哪个数据进行的了.
任何一条数据(包含应答报文)都是有序号的
确认序号则是只有应答报文有(普通报文确认序号里的值无意义)
那么这一条数据是不是应答报文又如何确定呢?
是不是应答报文取决于 ACK 标志位(ACK标志位为1表示是应答报文, 为0则不是应答报文)
确认序号的取值是收到的数据的最后一个字节 +1 ,
应答报文报头中, 确认序列填写的是 1001 (就是在传输1000字节的基础上 +1)
表示的含义:
1 ). <1001 的数据都收到了.
2 ). A 接下来应该从 1001 这个序号开始继续发送(B 向 A 索要1001的数据)
结: TCP 可靠传输能力, 最主要就是通过确认应答机制来保证的, 通过应答报文, 就可以让发送方清楚的知道传输是否成功, 进一步引入序号和确认序号, 针对多组数据进行详细分析.
前面讨论确认应答的时候, 只是讨论了顺利传输的情况, 那么丢包了该如何处理呢?
丢包涉及两种情况:
发送方看到的结果就是没有收到 ACK , 区分不了哪种情况, 这些情况统一认为是丢了.
因此, TCP 就引入了重传机制, 在丢包的时候, 重新发一份数据
那么到底当前这次传输是丢包了还是ACK在路上呢?
TCP 直接引入了一个时间阈值.
发送方发送了一个数据, 就会等待 ACK , 此时开始计时.
如果超出了这个时间阈值, 没有收到ACK, 这个时候不用管ACK是否还在路上, 还是彻底丢了, 就都是为丢包.
那么是否会出现同一份数据被收到了多次呢?
TCP 对于这种重复数据的传输也是有特殊处理的(去重)
TCP 有一个"接受缓冲区" 这样的存储空间(接受方操作系统内核里的一段内存)
每个 TCP 的 Socket 对象, 都有一个接受缓冲区,
主机B接收到主机A的数据
其实是主机B的网卡读到数据了, 然后把这个数据放到B的对应Socket的接受缓冲区中
后续程序应用程序使用 getInputStream , 进一步的使用 read , 就是 从接受缓冲区来读取数据
可以把这个接受缓冲区想象成一个阻塞队列, 根据数据的序号, TCP 很容易识别当前缓冲区里的这两个数据是否是重复的了, 如果重复, 则把后来接受的这份数据丢弃了, 保证应用程序调用 read 读取到的数据一定是不重复的.
结: 由于去重和重新排序的机制存在, 发送方只要发现 ACK 没有按时到达, 就会重传数据, 即使重复了, 顺序乱了, 都没关系, 接受方都能很好的处理了.(去重和排序都依赖TCP报头的序号)
可靠传输是 TCP 最核心的部分, TCP 的可靠传输就是通过 确认应答和超时重传来体现的.
确认应答描述的是传输顺利的情况
超时重传描述的是传输出现问题的情况
这两者相互配合, 共同支撑整体的 TCP 可靠性
建立连接(三次握手)
通信双方各自记录对方的信息, 彼此之间要相互认同
小结: 所谓的三次握手, 本质上是四次握手,
通信双方, 各自要向对方发起一个"建立连接"的请求, 同时, 在各自向对方回应一个ACK
这里其实是有4次信息交互, 但是中间有两次交互是可以合并成一次的, 所以就构成了"三次握手"
为啥要把中间这两次合并呢? 不合并行不行呢?
必须合并
封装分用两次一定比封装分用一次成本高
如果是两次握手能否建立连接?
肯定不行
三次握手另外一个重要作用:验证通信双方各自的发送接收能力和接收能力是否正常
三次握手也是一定程度上保证了TCP传输的可靠性(辅助作用)
TCP 的状态(非常复杂), 这里介绍两个状态:
四次挥手和三次握手非常类似, 都是通信双方各自向对方发起一个断开连接的请求, 在各自给对方一个回应
三次握手的中间两次能够合并, 是应为它们是同一时机, 具体来说三次握手这三次交互, 是纯内核中完成的, 应用程序感知不到, 也干预不了, 服务器的系统内核收到 SYN 之后, 就会立即发送 ACK, 也会立即发送SYN
那么断开连接这四次挥手为什么不能合并呢?
FIN 的发起(第一次挥手), 不是由内核控制的, 而是由应用程序调用 Socket 的 close 方法(或进程退出)才会触发 FIN
ACK 则是由内核控制的, 是收到 FIN 之后, 立即返回ACK(第二次挥手)
服务器的应用程序. 执行到对应的 close 方法,触发FIN (第三次挥手)
客户端收到FIN后立即返回ACK(第四次挥手)
第二次挥手和第三次挥手是否可以合并, 还是看代码怎么写, 如果这两次挥手的间隔时间很短, 那就有可能合并为一次, 如果间隔时间长了, 就不能被合并.
四次挥手中涉及到的两个重要的TCP状态
CLOSE_WAIT
出现在被动发起断开连接的一方, 等待关闭(等待调用 close 方法关闭 Socket)
TIME_WAIT
出现在主动发起断开连接的一方, 假设是客户端主动断开连接, 当客户端进入 TIME_WAIT 状态的时候, 相当于四次挥手已经完了.
此时这里的TIME_WAIT 要保持当前的TCP连接状态不要立即就释放
为什么不要立即释放呢?
此时最后ACK刚刚发出去, 还没到呢, 万一这个ACK丢包呢
在三次握手和四次挥手的过程中, 同样也是存在"超时重传的"
如果是最后一个ACK丢了, 站在服务器的角度来看, 服务器是不知道是因为客户端的ACK丢了还是自己发的FIN丢了,统一视为FIN丢了, 统一进行重传操作.
服务器可能要重传FIN, 客户端就需要能够针对这个FIN进行ACK响应, 所以需要使用TIME_WAIT 状态保留一定的时间, 处理最后ACK丢包的情况
TIME_WAIT 等了一段时间后, 也没有收到重传的FIN, 此时就认为, 最后一个ACK没丢, 于是就彻底的释放连接了.
TIME_WAIT 具体保持多长时间就真正释放呢?
2 MSL (这个MSL指的是互联网上两个结点之间, 数据传输消耗的最长时间, 通常情况下是60s)
结:
TCP 作为一个有连接的协议, 就需要建立连接和断开连接
建立连接的过程是三次握手
断开连接的过程是四次挥手
意义:
- 双方建立对对方的认同(保存对方的信息)
- 验证通信双方的发送和接收能力
- 协商一些关键的参数
前面讲到的确认应答, 超时重传, 连接管理 都是给TCP的可靠性提供支持, 引入了可靠性, 必然就损失的效率.
UDP 虽然没有可靠性, 但是传输效率要比TCP高.
TCP 也引入了一些机制(补救措施),提高传输效率.
其实这里在怎么提高, 也不可能比完全不考虑的可靠性的UDP的效率高. 但是也得保证自己传输效率不是太低.
滑动窗口本质上就是降低了确认应答, 等待 ACK 消耗的时间.
具体怎么缩短等待时间呢
对于基本确认应答的情况来说, 每次发一个数据, 都需要等, 等ACK到了再发下一个
既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了).
滑动窗口的本质就是不等待的批量发送一组数据.
然后使用一份时间来等待这一组数据的ACK.
把不需要等待, 就能直接发送数据的最大的量, 称为"窗口大小", 上图窗口大小就是4000
当批量发送了窗口大小的这些数据后, 发送方就要等待ACK了
那么什么时候继续往下发送呢? 等待啥时候结束呢?
不是所有的ACK到达才继续发, 而是到一个ACK就继续往下发一条.这样就让等待的ACK始终都是4条.
本来等待ACK是1001~5000
接下来, 收到了2001这个ACK, 说明1001 ~ 2000这个数据已经被确认了, 此时就可以立即发送5001 ~ 6000 的数据了. 此时等待的范围就是2001 ~ 6000了, 后面依此类推.
这种机制就被形象的称为"滑动窗口"
下面我们来讨论丢包的情况
关键要点, 在于 确认序号 的含义, 一个确认序号表示从该序号往前的数据都已经确认到达了.
这个丢包重传的方式, 起了个名字就叫做"快速重传" (重传操作只重传了丢失的数据), 也可以视为是超时重传机制在滑动窗口下的变形.
如果当时传输数据密集, 按照滑动窗口的方式来输出, 此时按照快速重传来处理丢包.
如果当时传输数据稀疏, 不在按照滑动窗口方式了, 那就按照之前的重传处理丢包.
这是一种干预发送的窗口大小的机制
滑动窗口, 窗口越大, 传输效率越高(一份时间等的ACK就越多)
但是窗口也不能无限大.
- 完全不等ACK, 可靠性能否保证呢?
窗口太大也会消耗大量的系统资源.
发送速度太快, 接受方处理不过来, 就白发了.
接受方的处理能力, 就是一个很重要的约束机制.
发送方发的速度, 不能超过接收方处理能力
流量控制要做的工作就是, 根据接受方的处理能力, 协调发送方的发送速率
那么如何衡量接受方的处理能力呢?
很简单, 那就是直接看接收方的接受缓冲区的剩余大小.
每次A给B发个数据B就需要算一下自己接受缓冲区里的剩余空间,然后把这个值通过ACK报文返回给A.
A就根据这个值来决定接下来的发送速率是多少(窗口大小)
这里只有16位, 是不是就以为这窗口大小最大是64kb呢?
显然不是, TCP为了让窗口更大, 在选项部分, 引入了窗口扩展因子. 比如说此时窗口是64kb, 扩展因子里写了2, 意思就是让 64kb <<2 变成了256kb.
发送窗口大小不是固定值, 也不是配置的, 是随着传输过程, 动态调整的.
当窗口大小为0了, 发送方就要暂停发送了. 暂停发送的等待过程中, 会给B定期发送一个窗口探测报文, 这个报文不携带具体的业务数据, 只是为了触发ACK查询窗口大小.
流量控制和拥塞控制共同决定发送方的窗口大小是多少
流量控制考虑的是接受方的处理能力,
拥塞控制描述的是传输过程中, 中间结点的处理能力
窗口大小是发送方的概念, 只不过这个窗口大小,是通过接收方的ACK报头里的窗口大小字段知道的.
拥塞控制, 本质上是通过实验的方式, 逐渐找到一个合适的窗口大小(合适的发送速率)
发现传输顺利, 没丢包, 就扩大窗口,
当增长速率达到阈值的时候, 此时指数增长就变成了线性增长
在接下来, 当传输过程中丢包了, 说明此时发送的速率已经接近网络的极限了
此时就把窗口大小一下缩成很小的值, 再重复刚才的指数增长和线性增长.
拥塞窗口和流量控制的窗口, 共同决定了发送方实际的发送窗口(拥塞窗口和流量控制窗口较小值)
延时应答也是提升效率的机制.
滑动窗口的关键是让窗口大一点, 传输速度就快一点.
所以这个延时应答要做的就是在接收方能处理的情况下, 尽可能把窗口放大一点.来提高传输速率.
延时是指, 接收方在收到数据后, 稍微等一会再返回ACK.
等待的时间里, 接受方的应用程序就能把接受缓冲区的数据给处理处理了. 此时剩余空间就更大了一点.
实际上延时应答采取的方式是在滑动窗口下, 不再是每一条ACK都返回了, 比如此时的是隔一条返回一个ACK.
剩余空间大小变化是一个复杂的过程, 既取决于发送方的发送, 也取决于接收方的处理.
捎带应答也是提高效率的方式, 在延时应答的基础上, 引入了捎带应答.
服务器客户端程序, 最典型的模型就是"一问一答"
业务上的请求和响应.
服务器在收到客户端请求后, 本来应该立即返回ACK, 因为有了延时应答这个机制, 返回响应就有可能和返回ACK在同一个时机, 在这种情况下, 就把这两个数据合并了,这就是捎带应答.
客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine, thank you”;
那么这个时候ACK就可以搭顺风车,和服务器回应的 “Fine,thank you” 一起回给客户端
面向字节流, 引入了一个问题, 就是粘包问题.
由于TCP是字节流的, 一次读1个字节, 还是读n个字节, 都是可以的. 这就会导致一次读到的数据报, 可能是半个应用层数据报, 也可能是多个应用层数据报…
那怎么解决这个问题呢?
那就是在应用层, 约定好应用层协议, 尤其是明确应用层数据报和应用层数据报之间的边界.
传输过程中出现了不可抗力
- 进程崩溃了
- 主机关机了(按照正常流程关机了)
- 主机掉电了
- 网线断开
1, 2 两种情况是属于进程没了, 对应的PCB没了, 对应的文件描述符表就释放了, 相当于 socket.close(), 此时内核会继续完成四次挥手(可能会没挥完), 但是仍然是一个正常的断开流程.
3, 4两种情况显然就来不及挥手了
这个消息称为"心跳包"(保活机制)
用这个心跳包来确认通信双方是处于正常工作中
心跳没了, 说明就挂了.