作者 | 薄景仁
最近面试了很多的学生,发现很多 TCP 的新手对于 TCP 的使用有一些误区,而这些坑也是当初我曾经疑惑过的地方。网上很少有文章对这些问题进行详细的解析,即使有也只是直接给出结论和做法,没有人能将其中的来龙去脉讲解清楚,本文会介绍这些问题背后的原理,希望能帮广大 TCP 的新手避开这些坑。
粘包处理
问题
我面试时经常会问的一个问题是当 TCP 两端 A、B 建立了连接后,A 端先发送 100 个字节,再发送 100 个字节。那么 B 端会分别收到两次 100 字节吗? 答案是不一定会,但是只有少数人能够正确的回答这个问题。如果能回答上这个问题那么我会接着问那么对于这种情况应该怎样处理才能正确的按照发送端发送的长度收到数据。能完美回答出这个问题的人就更少了。原因
我们常说 TCP 是一种流式连接,这个流字到底怎么理解?它是指 TCP 的数据传输就像一种水流一样,并不区分不同数据包之间的界限。
就像我们打开水龙头后,水流自然的流出,我们并不知道背后水泵是分了几次将水供上来的。
缓存发送
其实仔细看过 TCP 协议内容的人就可以发现,TCP 协议允许发送端将几次发送的数据包缓存起来合成一个数据包发送到网络上去,因为这样可以获得更高的效率,这一行为通常是在操作系统提供的 SOCKET 中实现,所以在应用层对此毫无所觉。 所以我们在程序中调用 send 发送超过 MTU 的数据包时,操作系统提供的 SOCKET 的 send 发送了数据后,操作系统有可能缓存了起来,等待后续的数据一起发送,而不是立即发送出去。 send 的文档中对此也有说明。 分包发送 网络传输的概念中有 MTU 的概念,也即是网络中一个数据包最大的长度。如果要发送超过这个长度的数据包,就需要分包发送。当调用 SOCKET 的 send 发送超过 MTU 的数据包时,操作系统提供的 SOCKET 实现会自动将这个数据包分割成几个不超过 MTU 的数据包发送。 当出现这些上面这些情况的时候,接收端就会发现接收到的数据和发送的数据的次数不一致。这个就是粘包现象。 解决 当我们传输如文件这种数据时,流式的传输非常适合,但是当我们传输指令之类的数据结构时,流式模型就有一个问题:无法知道指令的结束。所以粘包问题是必须要解决的。短连接
最简单的方法就是短连接,也就是需要发送数据的时候建立 TCP 连接,发送完一个数据包后就断开 TCP 连接,这样接收端自然就知道数据结束了。
但是这样的方法因为会多次建立 TCP 连接,性能低下。随便用用还可以,只要稍微对性能有一点追求的人就不会使用这种方法。
长连接使用长连接能够获得更好的性能但不可避免的会遇到如何判断数据结构的开始与结束的问题。
而此时的处理方式根据数据结构的类型分两种方式。
定长结构
因为粘包问题的存在,接收端不能想当然的以为发送端一次发送了多少数据就能一次收到多少数据。如果发送端发送了一个固定长度的数据结构,接收端必须每次都严格判断接收到额数据的长度,当收到的数据长度不足时,需要再次接收数据,直到满足长度,当收到的数据多于固定长度时,需要截断数据,并将多余的数据缓存起来,视为长度不足需要再次接收处理。 不定长结构定长的数据结构是一种理想的情况,真正的应用中通常使用的都是不定长的数据结构。
对于发送不定长的数据结构,简单的做法就是选一个固定的字符作为数据包结束标志,接收到这个字符就代表一个数据包传输完成了。
但是这只能应用于字符数据,因为二进制数据中很难确定结束字符到底是结束还是原本要传输的数据内容(使用字符来标识数据的边界在传输二进制数据时时可以实现的,只是实现比较复杂和低效。想了解可以参考以太网传输协议)。
目前最通用的做法是在每次发送的数据的固定偏移位置写入数据包的长度。
接收端只要一开始读取固定偏移的数据就可以知道这个数据包的长度,接下来的流程就和固定长度数据结构的处理流程类似。
所以对于处理粘包的关键在于提前获取到数据包的长度,无论这个长度是提前商定好的还是写在在数据包的开头。
因为在每次发送的数据的固定偏移位置写入数据包的长度的方法是最通用的一种方法,所以对这种方法实现中的一些容易出错误的地方在此特别说明。
通常我们使用 2~4 字节来存放数据长度,多字节数据的网络传输需要注意字节序,所以要注意接受者和发送者要使用相同的字节序来解析数据长度。
每次新开始接收一段数据时不要急着直接去解析数据长度,先确保目前收到的数据已经足够解析出数据长度,例如数据开头的 2 个字节存储了数据长度,那么一定确保接收了 2 个字节以上的数据后才去解析数据长度。
如果没做到这一点的服务器代码,收到了一个字节就去解析数据长度的,结果得到的长度是内存中的随机值,结果必然是崩溃的。
有些非法客户端或者有 bug 的客户端可能会发出错误的数据,导致解析出的数据长度异常的大,一定要对解析出的数据长度做检查,事先规定一个合适的长度,一旦超过果断关闭 SOCKET,避免服务器无休止的等待下去浪费资源。
不要妄想说自己写的客户端不会出错,哪怕客户端不出错,只要其他任何一个使用 TCP 的客户端写错了端口,也足以让你崩溃,毕竟管得了自己管不了别人。
处理完一个完整的数据包后一定检查是否还有未处理的数据,如果有的话要对这段多余的数据再次开始解析数据长度的过程。不要忙着去继续接受数据。
这应该是最常犯的一个错误,很多人以为完整的处理了一个数据包后就万事大吉,可以重新开始处理流程,但是别忘了,收到的数据有可能带着下一个数据包的数据,别把他们忘掉。
小结
TCP 中的粘包的处理应该是任何一个网络编程人员都必须掌握的技能,但是很多被面试者向我表示从未听说过粘包问题。面试者如果对粘包问题没有任何的了解那么就谈不上所谓的精通、掌握 SOCKET 编程。
心跳检查
问题
我面试时还经常会问的一个问题是当 TCP 两端 A、B 建立了连接后,如果一端拔掉网线或者拔掉电源,那么另一端能够收到通知吗?
答案是不会,但是只有少数人能够正确的回答这个问题。
原因
TCP 是一种有连接的协议,但是这个连接并不是指有一条实际的电路,而是一种虚拟的电路。TCP 的建立连接和断开连接都是通过发送数据实现的,也就是我们常说的三次握手、四次挥手。TCP 两端保存了一种数据的状态,就代表这种连接,TCP 两端之间的路由设备只是将数据转发到目的地,并不知道这些数据实际代表了什么含义,也并没有在其中保存任何的状态信息,也就是说中间的路由设备没有什么连接的概念,只是将数据转发到目的地,只有数据的发送者和接受者两端真正的知道传输的数据代表着一条连接。
但是这就说明了一点,如果不发送数据那么是无法断开连接的。正常情况下当 TCP 的一端A调用了 SOCKET 的 close 或者进程结束,操作系统就会按照 TCP 协议发送FIN 数据报文。B 端收到后就会断开连接。但是当出现了上文所说的异常情况时:被拔掉网线或者断掉电源,总结起来就是没有机会发出断开的 FIN 数据报文。那么和 A 直连的路由设备虽然知道 A 设备已经断开了,但是路由设备并没有保存连接的状态信息,所以路由设备也就不可能去通知 B 端 A 端的断开。而 B 端没有收到断开的数据报文就会依然保持连接。所以 A 端拔掉网线或者断掉电源后 B 端是没办法收到断开连接的通知的。
解决方案
保持连接并不是毫无代价的,如果这种异常断开的连接有很多,那么势必会耗费大量的资源,必须要想办法检测出这种异常连接。
检测的方法很简单,只要让 B 端主动通过这个连接向 A 端继续发送数据即可。上文说过,A 端异常断开后,和A端直接连接的路由器是知道的。当 B 端发送的数据经过转发后到达这个路由器后,必然最终会返回 B 端一个目的不可达。此时 B 端立刻就会知道这条连接其实已经异常断开了。
但是 B 端不可能知道什么时候会出现这种异常,所以 B 端必须定时发送数据来检测连接是否异常断开。数据的内容无关紧要,任何数据都能达到这个效果。这个数据就是我们经常在 TCP 编程中所说的心跳。
KEEP_ALIVE
TCP 协议本身就提供了一种这样的机制来探测对端的存活。TCP 协议有一个KEEP_LIVE 开关,只要打开这个开关就会定时发送一些数据长度为零的探测心跳包,发送的频率和次数都可以设置,具体的方法在网上搜索 tcp keepalive 即可,网上有很多文章,这里不再赘述。
应用层心跳
除了使用 TCP 协议本身的保活开关机制,还可以在应用层主动发送心跳数据包,那么在应用层主动发送心跳数据包的方式和 TCP 协议本身的保活机制有什么区别呢?
应用层的心跳数据包会耗费更多的带宽,因为 TCP 协议的保活机制发送的是数据长度为零心跳包,而应用层的心跳数据包长度则必然会大于 0。
应用层的心跳数据包可以带一些应用所需要的数据,随应用自己控制,而 TCP 协议的保活机制则是对于应用层透明的,无法利用心跳携带数据。
双向心跳
那么是否只是一端向另一端发送心跳就行了呢?显然不行。因为两端都有可能发生异常断开的情况。所以 TCP 连接的两端必须都向对端发送心跳。
小结
TCP 中不使用心跳通常来说并没有什么问题,但是一旦遇到了连接异常断开,那么就会出现问题。所以任何一个完善的 TCP 应用都应该使用心跳。
数据校验
问题
我面试时还经常会问的一个问题是 TCP 如何保证数据的正确性,保证数据内容不会出错。大部分人就会开始说丢包重传、接收确认之类的东西,但这些都扯偏了,只要少数人能够正确回答题目要问的问题:首部校验。
对于能答上这个问题的人,我会进一步问,这个校验机制能够确保数据传输不会出错吗?
答案是不能,但是至今为止我没有遇到任何一个面试者能够正确回答这个问题。
原因
TCP 协议中规定,TCP 的首部字段中有一个字段是校验和,发送方将伪首部、TCP 首部、TCP 数据使用累加和校验的方式计算出一个数字,然后存放在首部的校验和字段里,接收者收到 TCP 包后重复这个过程,然后将计算出的校验和和接收到的首部中的校验和比较,如果不一致则说明数据在传输过程中出错。这就是TCP 的数据校验机制。
但是这个机制能够保证检查出一切错误吗?显然不能。因为这种校验方式是累加和,也就是将一系列的数字(TCP 协议规定的是数据中的每 16 个比特位数据作为一个数字)求和后取末位。
但是小学生都知道 A+B=B+A,假如在传输的过程中有前后两个 16 比特位的数据前后颠倒了(至于为什么这么巧合?我不知道,也许路由器有 bug?也许是宇宙中的高能粒子击中了电缆?反正这个事情只要不是严格的不可能事件,就有可能会发生),那么校验和的计算结果和颠倒之前是一样的,那么接收端肯定无法检查出这是错误的数据。
至于应用层收到这个数据后会怎么样,不清楚,只能看运气,立刻崩溃那是比较好的结果了。
亚马逊的曾经就遇到过一次这种故障,损失惨重。
解决方案
既然 TCP 自带的校验算法并不靠谱,我们就需要在应用层自己建立一套新的数据校验机制。
MD5
最简单的就是使用 MD5 校验,在发送数据前将数据使用 MD5 加密,并将 MD5 摘要一起发送,接收端接收数据后将数据再次用 MD5 加密,如果得到的摘要和收到的摘要一致说明数据正确。
亚马逊的处理方式就是这样。
是否绝对安全
同时使用 TCP 的加和校验和 MD5 加密,双管齐下,由于他们的加密原理大相径庭,所以基本不可能出现某种传输错误但是依然能通过双重校验。当然了这种情况出现的可能性到底是不是 0 需要严格的数学证明,但是我水平有限所以无法给出。但是你依然可以显然的看出这种情况出现的概率比采用一种校验机制被巧合的错误通过的概率要小很多个数量级。
当然除了 MD5 校验,还可以使用其他的加密校验算法加密。
是否要处理校验漏洞
TCP 数据的校验漏洞是个很冷门的知识,可能只有刨根问底的人才会对此有过思考,因为普通的人遇到它的概率实在小得可怜,只有那种到了很大处理规模的服务器上才可能见到一次,所以通常的网络开发中不处理这个问题也没有什么。
什么时候应该考虑处理这种情况也并没有什么标准,我认为当你的服务器出现校验漏洞会造成很大的损失的时候你就必须要处理它了,像上文亚马逊的那次故障损失惨重,如果你的服务器不会有什么严重损失,就让他宕机一次也无所谓。
小结
TCP 数据校验的知识并不是网络开发中的关键点,但是通过这个问题可以看出一个人对 TCP 协议的了解以及思考深度。
总结
以上就是我觉得对于 TCP 新手来说容易出错的地方,希望掌握这些知识能够帮助开发出更高质量的网络程序。