标题:揭开tcp&udp协议的真实面貌
引言:tcp需要解决网络不可靠带来的所有不确定因素,作为很多应用层的首选传输协议,udp只管数据的发送与接收,高效的收发机制成为很多通讯应用的首选。
1、网络通信
网络有若干个机器节点连接而成的链路构成,类似路由器、交换机 、防火墙、无线接入点、电脑、打印机、等设备在网络链路中交互数据被称为网络通信。
日常上网用到的浏览网页,社交,视频聊天……都属于网络通信范畴,绝大多数是基于两种通信协议:tcp和upd之上的应用程序。
2、tcp
面向连接,可靠的,基于字节流的传输层通信协议。
2.1、网络不可信
庞大且复杂的网络环境中,一切都是不可信的,不知道对端有没有断网,机器是否故障,服务是否在线,传输层的不可信,数据内容的不可信,数据发送端的不可信,数据接收端的不可信……
- 客户端机器无法知道服务端机器的健康状态,即使客户端知道服务端的健康状态,但是服务端不知道客户端的健康状态;
- 数据发送端不知道自己发的数据有没有送到接收端,即使接收端收到了数据,也给了反馈确认接收,但它不知道自己的反馈有没有送到发送端;
- 用作《三体2》中的一句话描述:整个网络中充满着猜疑链。要想打破链索,唯一有效的方式就是沟通与约定,像日常登录、认证、鉴权……用到的可信私钥,固定密码,动态令牌都属于沟通与约定。
tcp协议需要在这种不可靠的网络链路中,建立可靠的连接,就必须依赖握手沟通。
2.2、三次握手
- 第一次握手:client将标志位SYN设置1,并产生一个随机值seq=J,将SYN&seq组装成数据包发送给server,随后自己进入SYN_SENT状态,等待server的确认信息;
- 第二次握手:server收到数据包后,由SYN=1知道client请求建立连接,然后server将标志位SYN和ACK都置为1,ack=J+1,自己也生成一个随机值seq=K,将上述SYN&ACK&ack&seq组装成数据包后反馈给client,自己进入SYN_RCVD状态;
- 第三次握手:client收到确认后,检查ACK是否为1,ack是否为J+1,验证无误后,将ACK置为1,ack=K+1,自己进入ESTABLISHED连接状态,将ACK&ack组装成数据包再发送给server,server收到数据包验证通过后,同样进入连接状态。
- 到此,握手连接建立成功,双方可以互相传输数据了。
通俗讲述之:
- c:喂喂喂,是s吗,我要给你对讲聊天,你听得见我说的话吗?
- s:我听得见,也愿意与你对讲聊天,你听得见我说的话吗?
- c:我也能听得见
- ……………………
2.2.1、为什么一定三次
假如只有两次
- client第一次向server发送一个SYN1包时,由于网络链路不可靠的原因,没有及时到达server。在中间某个链路节点产生了滞留;
- 滞留的时间触发了最长的超时等待,为了快速建立连接,client会重发SYN2,这次数据包成功到达,server及时回复syn+ack后,client与server之间成功建立连接;
- 此时,SYN1由于阻塞变为恢复,被送到了server,server会误认为client又发起一个新的连接,自己会立马进入连接成功后的监听等待数据的状态;
- server认为是两个连接,客户端认为是一个连接,就直接导致了连接状态的不一致性;
2.2.2、两次可行否
从逻辑上分析,两次握手就已经达到了建立连接的条件,但是为了因为不可靠网络的丢包现象导致server出现大量无故等待的状态,是不可取的。
2.2.3、四次也行否
当然可以。在三次握手的基础上,server收不到最后client返的ack包,自然不会连接建立成功,所以三次握手,是为了保障在不可靠的网络链路上,以最简洁最快速的方式建立可靠的连接,没必要再多加一次。
2.3、问题
tcp是以字节流式传输数据,需要在不可靠的网络中建立可靠的连接,需要面对着以下问题:
2.3.1、拆包与丢包
流式传输数据,当前数据包如果超出了协议缓冲区的大小,可能被拆成多包发送,这就是拆包,也可以称为数据分片。不可靠的环境中一定存在数据包丢失的问题。如果client不对丢失数据包进行重发的话,那么server收到的数据就会减少,影响了应用层的序列化一系列操作,直接造成数据丢失。
2.3.2、粘包的成因
发送端发送的若干个包数据到达接收方时粘连成了一包,从接收缓冲区来看,后一包数据的头紧接着前一包数据的尾,这就是粘包;
成因
- 发送端:为了高效,多条数据包的同时发送;
- 接收端:收到数据包时,不会马上交到应用层,会先保存在接收缓存里,然后应用程序主动从缓存读取。如果接收数据包到缓存的速度大于应用程序从缓存中读取数据包的速度,多个包就会被缓存,应用程序就有可能读取到多个首尾相接粘到一起的包;
影响
- 发送方发送的多个数据分片属于同一块数据的不同部分,例如文件上传时,流式传输下,被分成多段发送,这种情况是不需要处理粘包现象的;
- 如果多个分片毫不相干,属于不同的数据块,此刻需要要处理粘包现象。
2.3.3、乱序
这些数据包到达的先后顺序可能不一样,接收端需要如何处理乱序问题。
为了更好的解决tcp这种ack机制对通信效率的影响,再将数据包拆解为一个个分片包之后,如果一次请求发送的数据量比较小,没达到缓冲区大小,tcp会将多个请求合并为同一个请求进行发送,这就是粘包;
2.3.5、解决方案
为了保障数据传输的不稳定性,TCP协议由如下规定:
- 数据分片原理:发送端对数据进行分片处理,给每个分片打上序号,接收端对接收的数据碎片按照其序号进行重组,数据分片的阈值大小由TCP协议来确定;
- 到达ACK:接收端每收到一个分片数据时,根据分片的序号给发送端一个反馈ACK,表示自己已收到;
- 超时重发:发送端在发送分片时就自己设置了一个定时器,如果定时器在规定的时间内没收到ACK,那就认为触发超时条件,需要重新发送刚才的分片数据;
- 滑动窗口:TCP连接双方的接收缓冲空间大小固定,接收端只允许发送端发送自己缓冲区能容纳的数据大小,TCP协议规定在滑动窗口的基础上提供流量控制,防止一端快进而导致慢的一端直接缓冲区溢出;
- 乱序处理:作为分片传递的数据包在到达接收端时可能乱序,TCP协议规定对收到的数据包按照序号重新排序,将数据包组装后,以原始的形式呈递给应用层;
- 重复数据:超时重发的极端情况下,一定会出现数据包重复的问题,TCP协议规定,接收端跟据序号判断必须丢弃重复的数据包;
- 封装消息:发送端将每个包都封装成固定的长度
- 数据校验:处理丢包与粘包
1、TCP协议中定义将数据包分为头部和消息体,头部保存整个消息体的长度,接收端只有读取到足够长度的消息之后才算是读到了一个完整的数据包,为的就是检测数据在传输过程中是否发生变化,如果接收端收到数据包检测后发现不一致,会直接丢弃该包,不回复ACK,致使发送端触发超时重传,用来处理粘包;
2、发送端在每个包的末尾使用固定的分隔符,例如\r\n,拆包场景下需等待多个包发送过来之后再按照\r\n进行合并
2.3.6、优化效率
一发一确认的机制会影响tcp整体的传输性能,发送端每次都需要等待接收端的ACK,才能进行下一个数据包的发送,为了避开这种循环多次的等待,TCP协议规定发送端可以连续发好几包数据过去,然后接收端针对每一包的依次给出ACK,整体优化了持续性等待的性能损耗。
2.4、实证拆包,连发
1、快速搭建一个httpServer,http协议是基于tcp之上的应用层
const http = require('http');
http.createServer().on("request", (req, res) => {
console.log("接收请求")
console.log("url:"+req.url)
console.log("headers:"+JSON.stringify(req.headers))
let d = ""
req.addListener("data",chunk=>{
d += chunk
})
req.addListener("end", ()=>{
console.log(d)
})
res.statusCode = 200
res.end(JSON.stringify({name: 'ikejcwang'}));
}).listen(8080, "127.0.0.1")
2、postman做文件上传测试,文件必然是大数据块,
3、抓包查看请求报文的传输详细,实证数据包的被拆成多个分片,分片连发。
2.5、四次挥手
client与server都可以主动的发起关闭连接的请求,以下假设client自己主动关闭连接;
- 第一次挥手:client需要向server发送一个FIN包,表示需要关闭连接,自己进入FIN-WAIT-1(终止等待1)状态;
- 第二次挥手:server收到FIN包,回复ACK包,表示自己进入CLOSE-WAIT(关闭等待)状态,client收到ACK后,进入FIN-WAIT-2(终止等待2)状态;
- 此时server还可以发送未发送的数据,client还可以接收数据;
- 第三次挥手:待server发送完数据之后,再发送一个FIN包,进入LAST-ACK(最后确认)状态;
- 第四次挥手:client收到FIN包后,回复ACK包,自身进入TIME-WAIT(超时等待)状态,经过超时时间后,关闭连接;而server收到ACK包后,立即关闭连接;
通俗讲述之:
- c:s,我有事儿需要跟你断开了,
- s:好的,可以断开,但我刚刚的话还没说完,(扒拉扒拉完……)
- s:我说完了,现在可以断开啦;
- c:好的,已收到
2.5.1、第四次的疑障
为什么第四次挥手时,client回复ACK包后,会先进入超时等待状态,而不直接关闭;
- 还是为了兼容这不可靠的网络链路,为了保证server已经收到ACK包,顺利关闭连接;
- 假如ACK由于不可靠的网络链路,滞留在某个节点中,如果此时client回复完ACK包后直接关闭了,数据包并没有到达server,那么server就会一直处于LAST-ACK状态。
- client回复ACK后先进入TIME-WAIT状态,是为了留一个伏笔,如果server没收到ACK包,会重发FIN包,client会再次响应这个FIN包重新回复ACK,然后刷新TIME-WAIT;
- 这个机制跟三次握手一样,连要连得稳健,断要断的彻底,不能因为丢包的现象搁置了对方。
2.6、拆解头部结构
tcp的每一个数据包是由消息头(headers)+消息体(body)组成的,这里专门对头部结构拆解分析一下(图片来自维基百科):
简单描述:一行是4个字节,32位。一直到偏移量结束前,都属于headers内容,往后的才是body。
- 源端口:占用16位;
- 目标端口:占用16位;
- 序列号码:32位,表示当前数据包的序列号,保障数据传输后接收端按顺序组装;
- 确认号码:32位,包含发送ACK的一端所期望收到的下一个序号;
- 数据偏移:4位,用偏移量来表示头部内容最终的长度,因为头部预留了保留项内容,并非定长,最大偏移量为1111,对应十进制就是15,即15 * 32 = 480,表明头部最大长度为60字节;
- 保留:6位,为新功能预留的插槽,现阶段为0;
- NS(拥堵通知):1位,表明阻塞即将发生,需要降低传输效率。
- CWR:减少拥堵窗口,定义于 RFC 3168(2001)。
ECE:取决于SYN标志的值,定义于 RFC 3168(2001)
(NS,CWR,ECE)后续好像没有被使用到,只有维基百科的图上标明了这三位)
- URG(紧急):1位,紧急指针标志, 为1时表示有效,为0则忽略
- ACK(确认):1位, 确认标志, 1 有效, 0 忽略
- PSH(推送):1位,带有PUSH的数据, 接收方获取到数据后,应该立即交给应用程序,而不走缓冲
- RST(复位):1位,连接重置接口,由于外部原因导致连接奔溃,或者遇到非法字段的时候, 进行重连的接口
- SYN(同步): 1位,建立连接,1表明要建立连接
- FIN(终止):1位,释放l连接,1表明要断开连接
- 窗口大小:16位,tcp当前接收缓冲区的大小,最大值65536;
- 校验和:16位,发送端对整个数据包(headers+body)进行长度计算,由服务端来验证;
- 紧急指针:16位,URG置为1时,该值表明偏移量,表示TCP紧急发送;
- 保留项:可选数据内容,表明tcp预留项,具体长度由偏移量决定;
headers结束后,就是body了。
2.6.1、抓包技巧
知道了头部结构信息,就可以对tcp数据流做位运算,来对具体值定向过滤抓取;
1、抓取SYN包,标记位在第13个字节,即00000010,换算出来就是2
tcpdump -Ans0 'tcp[13] & 2 != 0'
2、同理,抓取FIN包,标记位在第13个字节,即00000001,换算出来就是1
tcpdump -Ans0 'tcp[13] & 1 != 0'
3、除了标志位,还有快捷抓取:
tcpdump -Ans0 'tcp[tcpflags] == tcp-syn'
类似如下:
- tcp-fin
- tcp-syn
- tcp-rst
- tcp-push
- tcp-ack
- tcp-urg
3、udp
面向非连接的用户数据包协议,不同于tcp的安全可靠,udp除了给应用程序发送数据包功能并允许应用程序在所需的层次上使用自己的协议之外,几乎没有做什么特别的事情,就是把数据包简单的封装一下,然后从网卡发出去就可以了,数据包之间并没有状态上的呼应,发送端与接收端之间也没有任何反馈;
正因为UDP这种简单的处理方式,直接导致了它的性能损耗超级小,对于CPU,内存等资源的占用,也远低于TCP协议,但是对于网络链路传输过程的丢包现象,UDP协议并不能保证,所以UDP在传输稳定性上要弱于TCP。
例如:
视频电话需要极高的传输效率,底层使用了udp协议。但是在网络不好的情况下,拨打视频电话,丢包严重,视频画面成段的卡掉与突进,这就是不可靠的网络环境在面对非连接的协议时,最直观的问题;
3.1、拆解头部结构
udp的每一个数据包也是由消息头(headers)+消息体(body)组成的,但是比tcp要简单很多:
headers下面,就是body,因为udp是面向消息传输的,接收房一次只接收一条独立的消息,是有消息边界约束,故不存在tcp中的粘包,乱序……,但在网络不可靠的情况下,丢包时有发生。
3.2、实测udp
搭建server与client代码
server:
const dgram = require('dgram');
const server = dgram.createSocket('udp4');
server.on('error', (err) => {
console.log(err);
});
server.on('listening', () => {
console.log('socket正在监听中...');
});
server.on('message', (msg, rInfo) => {
console.log(`receive message from ${rInfo.address}:${rInfo.port}`);
server.send('exit', rInfo.port, rInfo.address)
});
server.bind('8080', '127.0.0.1');
client:
const dgram = require('dgram');
const client = dgram.createSocket('udp4');
client.on('close', () => {
console.log('socket已关闭');
});
client.on('error', (err) => {
console.log(err);
});
client.on('message', (msg, rInfo) => {
if (msg == 'exit') {
client.close();
}
console.log(`receive message from ${rInfo.address}:${rInfo.port}`);
});
client.send(`hello udp`, 8080, '127.0.0.1');
抓包:
分别启动server,client后。只有两条数据包。分别是client发送的hello udp
,sever发送的exit
4、总结:
1、tcp传输稳定可靠,适用于对网络通信质量要求较高的场景,需要准确无误的传输给对方;eg:文件传输,浏览页面……;
2、 udp的优点是速度快,但可能产生丢包,所以适用于对实时性要求较高,而对少量丢包现象并没有太大要求的场景,比如域名解析,DNS服务,语音通话(典型的卡顿后,间断语音丢失)……;