前言
从去年年底开始大约花了半年时间去啃《TCP/IP协议 详解》这本书。虽然整体过了一遍,也给了我一些基础能够参与到网络相关话题下的讨论,但对于这样一本全面详细的指导书,我并没有掌握的很好。抛开没有记住的内容,很多问题在第一次阅读的时候会以理所当然的态度给跳过。
最近重新拿起这本书,审视一些第一遍没有考虑到的问题。我选择将一些思考的问题记录下来,分节整理。当然,要捋完整本书需要一些时间,一篇文章的篇幅也有限,这篇文章主要讲述了五个问题字数已经过万,所以后续我会分篇一一来总结。更新的内容目录会添加到《TCP/IP协议 详解》思考总结目录里,有兴趣的朋友可以在这里搜索找到对应的文章。
因为本人水平有限,只是一名普通的软件开发而非专业的网工人员,文章难免有所缺漏。如果读者有任何疑问,望不吝赐教。
有任何问题欢迎评论留言,我会尽力给予回答。
网络分层
计算机网络采用了分层结构,但是不同地方关于划分的标准并不是完全一致。主流的模型分别是TCP/IP五层模型(在TCP/IP协议详解中分为四层,硬件相关的数据链路层和物理层被合并在一起)
和OSI的七层模型。两者的差异就在于会话层和表示层的缺失上。
会话层的功能是会话控制和同步,表示层是解决两个系统间交换信息的语法和语义的问题,以及数据表示转化,加解密和压缩解压缩的功能。在OSI七层模型中将这两层从广泛意义上的应用层里独立出来,主要的目的还是让这两层的逻辑代码可以为所有的应用程序共享,同时瘦身应用程序。
但是现实的情况是这两层在各种应用程序当中很难设定一个统一的标准,每一个不同的程序关于表示和会话的需求各不相同。这意味着相关的逻辑和代码是无法复用的,那么独立出来也就无从谈起了。这部分的逻辑最终都交给应用开发者在应用层决策实现了。
关于网络分层的原因,实际大部分软件系统都是分层架构,这一切都是为了工程上实现/调试/维护的方便。之前在一个关于为什么Linux为什么还要坚持使用宏内核的问题下我看到一段话非常的有意思。
因为Linus可以把这些乱七八糟的东西全都一个人写了,一遍写对了,还能稳定跑起来无bug,而我们这些渣渣做不到,只能依靠保护模式来防止几百个工程师写出来的那一坨垃圾动不动蓝屏,自己弱却去质疑天才的做法,和明知自己弱还要模仿天才的做法,都是认不清现实的表现。 ============================================================================== 工程这个东西是很有意思的,我们说科学是掌握规律,技术是利用规律克服大自然的限制,而工程,却是利用技术来克服人自身的限制。技术会告诉你,造个金字塔,把石头垒成四棱锥就行了,如果你是个力大无穷的巨人,或者是个能意念移物的魔法师,你就啪啪啪把石头搬过来堆起来就完事了。但我们是凡人,我们力量很小,我们很弱,所以我们需要滚木,需要滑轮,需要绳索来帮忙,做了许多额外的麻烦事情,只为了克服我们肉体的自身限制。体力上有限制,智力上同样有限制。软件工程很大程度上就是解决我们人类智力上限制的问题,软件工程师在面对不知所谓的kernel dump的时候会无助,会哭泣,在面对无休止的接口变动的时候会歇斯底里,面对改一行代码系统就全挂的窘境束手无策,所以我们需要微内核、微服务这样的框架来约束系统,降低系统的复杂性,让我们所有犯的错误都能保持在可控的范围内,让因为我们的愚笨而写出的有bug的代码也能勉勉强强运行起来而不是分分钟crash,哪怕这些方法额外增加了许多工作量、还降低了效率。但是,总是有超人存在的,我们人要造一个纪念碑,设计一堆方案,superman会说,哈?这个事情,不是只要我去把那个石头举起来,然后飞过来,放在这里不就好了嘛?体力上差距这么大的超人也许不存在,但智力上差距这么大的超人却是存在的,所以要记住工程方法只是为了拯救我们这些凡人,对于超人来说,他们是不需要这些的,他们要做的仅仅是“搬起来,放下去”而已。 可以多人开发这件事情在软件工程上是至关重要的,如果一个工程必须每个程序员都完全理解整个工程的架构才能着手开发,那这个工程一定做不成,不说完全理解一个庞大项目本身不可能这个问题,大家理解也会有偏差,写出来代码合不到一起,这就是为什么需要有严格的模块拆分,而且接口必须控制在解耦的、可以理解的、数量尽量少的范围内,而且通过框架来避免错误相互扩散。
即使是一次看似非常简单的数据传输,背后实际需要做的工作也非常多。我经常会感叹网络的强大,无论需要传输的是一段文字,一张图片,一段视频,又或是无论我和通信的对方相距多远,数据都可以稳当的送达给对方。如何打包处理大小不同用户的数据;如何去和对方建立连接;如何在不可靠的信道上提供可靠的数据传输?处理这里面繁杂的逻辑,是一个大问题。前辈们给出的解决办法是将大问题分割成若干个小问题,交由不同的层去解决。每一层相互独立,互不干扰,只关心自身的任务;处理结束之后将结果交由下一层继续处理。
这样做的好处非常明显:
- 每一层只需要专注自己本身,而不必关心整个网络的结构,这让问题简化了不少,每一层的自由度也很高;
- 其他层面的操作对于本层而言可以视作一个黑盒,它隐藏了具体的实现细节,只提供了一套可供调用的API,这样只要外部接口不变,内部做修改对其他地方毫无影响;
- 相关逻辑的代码可以重复被使用,提高了开发的效率;
- 同时也给调试提供了便捷,允许逐层排查确认问题。
那么划分层级的标准又是如何呢?
上图我们可以看出应用层的程序是用户的一个进程,而下层则是交由操作系统(内核)处理的,尽管这不是绝对的,但大多数情况都是这样的,我们可以简单的这样去认为。应用层的程序更多的是关心业务逻辑的处理,而不是数据在网络中的传输活动;而下层对应用程序毫不知情,但它们需要处理所有的通信细节。
链路层(或者说数据链路层和物理层)是和硬件设备以及相关的驱动打交道的这一部分。
如果只看这张图,运输层和网络层之间的区别并不是那么的明显。百度百科上给出的答案是:
- 网络层:使用权数据路由经过大型网络;
- 运输层:提供终端到终端的可靠连接。
这样的说法是完全错误的!因为这是基于一个大的前提:网络层的协议是IP协议,而运输层的协议是TCP协议。但实际情况是在网络发展初期,各种协议层出不穷,TCP/IP也只是其中非常普通的一个部分,至少在OSI模型(五层模型)落地的时候谁也不会料到今天TCP/IP会占据主流地位。所以这并不是划分运输层和网络层的理由。
要理解这两层的含义,我们需要把视野从单个网络放大到一组网络
上图中我们可以划分出一个端系统(两边的主机)和中间系统(路由器)。在这张图中可以明确看出应用层和运输层的协议都是端对端协议,也就是只有端系统会使用这两层协议的内容;而网络层的协议是逐跳协议,两个端系统和每一个中间系统都需要使用到它。
不同就在于网络层服务的对象不仅仅是端系统,也包括中间节点。提供服务的目的是将分组尽可能快的从源节点传输到目的节点,但不为此提供任何可靠性保证。如果我们略去运输层直接将数据交给应用层,是否可以?当然可以!这也是为什么我们会觉得运输层和网络层区分不明显的原因。但这样的做法是非常不值得推荐的,因为数据经过不可靠信道传输之后状态是未知的,这一部分的数据在交由应用程序之前我们还需要进行处理,包括但不限于数据校验,安全性检查,可靠性传输服务的提供。所以我们要在网络层和应用层之间建立一个端到端的连接,允许我们在这之间对数据能够有操作的空间和余地。而运输层就是负责建立,管理和维护这一部分的连接的。
在TCP/IP协议簇当中,IP协议负责将分组从源节点传输到目的节点,而TCP在上层提供了可靠性服务。但这并不是说可靠性服务一定要由运输层完成。和TCP对应的UDP是以不可靠传输闻名天下。我们可以将UDP看作一个最原始的TCP数据包,在去除了各项feature之后,UDP相比TCP有了轻便,简洁的优势。这让它在一些网络状况不佳、及时性要求较高的业务场景中有了发挥的机会,TCP的三次握手以及拥塞控制在这些情况下反而变成了一种负担。如果我们需要一些可靠传输的需求而又不想使用大而全的TCP,我们可以将这一部分的逻辑移交到应用层来实现。对于应用开发者而言,可以更加自由的控制数据报传输的逻辑。Ethernet Card + Driver + IP + UDP + TFTP就是一个很好的例子。
谈一谈可靠性是什么
在谈及计算机网络的时候可靠性是一个无法回避的概念。
我们经常会将运输层上的TCP和udp协议进行比较:TCP是一个可靠传输的协议,而UDP则是不可靠的。在很长的一段时间里,我简单的把这个可靠性理解为:UDP通信有一定的概率失败,TCP通信是肯定会成功的。这是一个错误非常明显的理解,非常容易举出反例:如果你的电脑是脱机状态,那么无论选择什么样的网络协议你都无法和外界进行通信。
那么TCP提供的可靠性传输究竟是什么?
第一次意识到我的错误,是在开发一款蓝牙产品的时候。多个蓝牙设备自组网络,我通过指定Mesh ID(类似IP地址,用于指明Mesh网络中具体哪一个设备)发送消息给直连的蓝牙设备,来控制任一一台Mesh网络中的蓝牙设备。boss测试程序的时候给我提出了一个问题,为什么我明明点击了这个按钮发送了开的命令,应用内也显示打开了这个设备,但实际这个设备并没有打开?
问题其实很简单:这个产品有一个缺陷,在同时发送多条消息的时候可能会造成信道拥塞,发送的消息可能会丢失没有送达。在点击打开按钮的同时程序会发送一条消息,但是因为当时底层可能正在频繁通讯导致这条消息没有送达,应用内又将设备状态置成打开,造成了这种错误。最后解决的办法是在底层限制了通讯的速率来避免这种情况的发生。那么读者可能会问:为什么你不能让设备返回你一条打开成功的消息之后再改变设备在应用内的状态呢?这是因为Mesh网络本身承载能力有限,为此所有的消息是没有返回的,所以我无法等收到消息然后再在回调里修改设备的状态。这就类似UDP的数据报一样,发出以后,发送方就不会再去关心。
这个Bug让我第一次感受到了TCP的美好:每一条消息发送成功以后都会返回一条Ack来告知发送方已送达。我重新定义了一遍可靠性:能够明确告知发送方发送结果。
就算没有发送成功不会有Ack消息,我们也可以设置超时时间来认定发送失败
但很显然这并不全面,TCP做的远不止这些
这一次帮助我意识到问题的是一个BugCocoaAsyncSocket UDP收发数据包大小限制,我开始重新审视TCP报文里的参数。
上图是TCP和UDP报文的格式。TCP的报文中包含了非常多的选项可以设置,而UDP非常的简单,除了目的地址和端口以外,只有Data Len
和一个Checksum
(如果为了追求极致的速度你甚至可以关闭Checksum
这一选项,这在TCP中是不可能的)。之前蓝牙项目的消息有两个特征:精短;每一条消息独立互不干扰。那么数据报非常大(大到一条消息放置不下)的情况要如何处理?多个有序数据报在网络中传递发生乱序,丢包该怎么办?
了解TCP的朋友应该知道TCP提供了拥塞避免
,快速重传
等机制来应对处理未知信道带来的这些问题。当然,这其中每一部分拿出来都可以说上很多,不在这个问题下反复纠结。但是我们应该要明确的一点是,在应用开发者看不见的地方,TCP已经为我们解决了很多不可靠信道带来的传输问题。关于可靠性我们应该为它加上一点:能够自主应对大部分数据传输过程中出现的问题,包括但不限于丢包,乱序等等。
以上我们讨论的内容其实都是围绕消息是否送达,但实际传输过程中我们还需要关心送达的内容是否准确。
这就是为什么我们需要校验和的原因
在谈论这一部分之前,我们需要认清网络传输的本质是什么。无论你需要计算机传输的是什么样的资源文件,最后都会变成一串01000101010101...的比特流,在互联网的血管里流淌。回想一下描述文件大小的单位,就算一个简单的文件实际也是一串非常长的数据。要想保证它在层层传递的过程中不出现差错,这几乎是不可能实现的。
这也就是为什么多层协议上都引入了数据校验这一部分。在数据链路层有FCS;网络层IP协议和运输层的TCP/UDP都提供了Checksum
。目的就是为了检测出因为网卡软硬件Bug、电缆不可靠、信号干扰而造成信号失真造成的数据错误。
可是同样一件事需要几层同时去做吗?
当然!第一个原因在于链路层CRC不能完全检测出错误;第二,每一层校验覆盖的数据范围是不一样的:IP协议Checksum
只覆盖IP首部,而传输层只针对自己的数据包。那么为什么设计成一次校验完成避免多次校验的浪费?IP协议Checknum
只覆盖IP首部的原因在于IP首部的信息传输过程中会被多次修改的,包括TTL
以及某些情况下对源地址的修改。其次,我们需要理解分而治之是网络中的一个重要概念。因为你无法保证执行校验的上层一定需要这个校验的结果,即时性和可靠性是无法尽善尽美的,只能取一个平衡。最好的就是大家各自完成各自的任务,就像一个可以自由组合的积木,还是那句话:分而治之。
有了校验可以认为数据更加安全了吗
这其实是把安全性和可靠性混淆在了一起。所有的校验操作是防君子而防不了小人,它可以检测硬件故障、软件bug、信号干扰、线路差、人为误操作这些非主观原因造成的错误,但是却无法防止别人恶意去篡改你的数据内容。道理很简单,篡改了数据的同时,可以将重新校验的结果覆盖上去。可靠性提供的是:避免非主观因素造成的数据错误。
但是如何避免被别人攻击篡改数据内容,这是安全性相关的内容,需要别的机制来提供相关的服务。以我们熟悉的TCP举例说明,它提供的是一个可靠但不安全的传输服务。
谈一谈分片
链路层对于数据帧的长度都有一个限制,我们称之为MTU。如果需要传输的数据报长度大于链路层的MTU,那么我们就需要将数据报分成若干长度小于MTU的数据报,这个过程叫做分片。因为数据报在发送的过程中需要经过不同的网络,链路层的MTU不尽相同,所以分片不仅发生在源主机端,也可能会发生在中间路由上。
上述所说的中间路由发生分片,是IPV4的环境下;在IPV6中只在源端分片重复,如果数据报长度大于中间路由的MTU,路由会直接返回一条ICMPv6
too big
给源主机端。这一部分我们后面会再说到
我们首先要明确一点为什么要把分片放在IP层
发展至今,IP协议几乎和网络层划上了等号。虽然网络层仍有其他协议的存在,但是可以说能走到最终用户面前的协议,网络层都是选择IP协议。所以分片操作放在IP协议上,可以为所有的上层协议服务,而不必再依次去运输层的协议中实现,相关的逻辑代码复用效率很高。在这里为什么我们不再像讨论Check num
那样去讨论分而治之,原因在于分片的根源是MTU的限制,任何一种协议都无法避免大数据报传输过程中的分片。协议可以决策自己是否实现这部分逻辑,但必须保证这部分的逻辑一定要实现。而IP协议就是这一个必须实现的保障。
另外,IP协议比起上层协议还有一个很大的优势就在于它更贴近链路层,这让IP协议可以感知到底层的MTU。而上层协议既不关心,也无法直接关心到底层MTU。
注意这里说的是直接关心,也就是说上层协议如果想要关心MTU,可以去设计获得,但这个信息并不是必要的。
总结来说,IP层实现分片是一个成本最低的选项。因为物理层去做分片,需要和硬件打交道,如果处理的复杂或者不符合大众标准很可能就是路越走越窄最后把自己走死了;而上层处理和我之前说的一样,运输层去各自实现成本很高,应用层自己去做对于应用开发者来说非常痛苦。
作为一名应用开发者,我承认分片是一件简单但是折磨人的工作。在实现蓝牙OTA升级功能的时候,我需要将升级包.bin文件拆开依次发送给设备,中间需要处理分片,对齐,填充等等工作。这是一份不难但是非常考验耐心的活。这只是简单的点对点传输,如果情况复杂,需要处理的工作就会更多。要上层各自去实现分片,实在不是一个很好的选择。
分包真的只有IP协议去实现了吗
并不尽然。虽然我们说IP协议去做分片是一个成本较低的选择,但实际还是有其他协议做了类似的操作。链路层中有类似Atm
协议;在TCP协议中,MSS(Maximum Segment Size,最大报文长度)
实际就是一个分片机制。
链路层协议作者并不熟悉,实际也并未接触。我们后续讨论以TCP为对象。
TCP协议中的MSS
和分片有一些简单的不同:分片是因为数据报长度大于MTU所以被动去分割数据报;而MSS
是三次握手过程中双方协商的结果,提前分割数据避免分片发送。TCP之所以提供这样一个option来避免IP层分片,相信有部分原因在于IP分片并不如我们所假设的那么完美。
我们之前提到IP协议去做分片确实复用率非常高,成本非常低,但实际情况中这些操作给负责分片和重组的主机和路由的cpu带来了非常大的压力。负责分组的终端的IP层需要去拆分数据报,用ID值相同的IP Fragmented Packet将数据发送出去;而重组终端的IP协议IP层根据ID,MF位,Fragment Offset信息进行重组,得到完整数据提交给上层。需要着重强调的是我们无法保证分片后的数据按照顺序依次到达目的终端,并且IP层没有类似超时重传的可靠性支持,其中任一一个分片数据丢失都会引起整个数据报的丢失!!!!
乱序和丢包的问题确实非常的麻烦,这里的麻烦更多的在于出现问题需要解决的成本过高。类似UDP只依靠IP协议分片处理大数据报的,实际是非常不推荐的。
如果上层提前分割了数据,IP层只要为每一个数据报加上IP头发出即可。每一个数据相互独立,由上层提供了可靠性支持。相比IP层分片的苦苦挣扎,这样做确实简单了很多。
但是这并不能完全的避免分片的发生。如RFC 879
所说:TCP provides an option that may be used at the time a connection is established (only) to indicate the maximum size TCP segment that can be accepted on that connection.
。这是一个在三次握手过程中协商的结果,不保证通讯过程中一定不会发生变化。
那么是否还有其他的方式来避免分片
Path MTU Discovery(传输路径MTU发现)
就是为此服务的。
在这一段的开始我们提到分片也可以发生在中间路由。举一个简单的例子:我们从源主机发生了一个IP包 = 1500,在互联网上逐跳传递,在中间的某台路由发送出去的时候,因为该接口MTU只有1000 < 1500,那么这个包就被分成两个IP分片发出去了。但是作为源主机对此毫不知情的,后续它仍然会按1500的大小发送IP包,那么每一个大于1000的IP包都会被分片。
为此我们可以在IP头中设置DF(Don‘t Fragement)
为1来让中间路由不要分片,如果IP包的大小大于中间路由的mtu,那么直接丢弃并通过ICMP告知源主机。源主机再根据ICMP中提供的信息修改IP包的大小。
如果后续还碰到更小的MTU怎么办?旁友,你听过递归不咯?
在ICMP发送至源主机的过程中,可能会被拦截或者丢失,那么这个IP包(包括后续大小超过中间路由MTU的IP包)就相当于静悄悄的被丢弃了,TCP连接会被中断。为此要么修改配置允许相关type的ICMP包的通过,要么关闭PMTUD
,毕竟允许分片的话双方还是可以正常通信的。
IPv6中有关分片和IPv4有什么区别吗
IPv6中只会在源端和目的端分片重组,拒绝中间节点来进行分片。如果数据报大小小于中间节点的MTU,那么中间节点会以ICMPv6 type=2的消息来告诉源端这个情况。这个操作看起来就像IPv4中DF=1一样。据此我们可以认为:避免分片是一种共识。
根据Wiki - IPv6 packet中有关IPv6的描述,端节点负责PMTUD
来查询允许发送数据报的最大长度,让上层协议来限制Paylod
的大小。如果上层协议不支持,那么发送长度不大于1280的数据报来避免分片。
IPv6要求链路层的最小MTU是1280
TCP为什么需要三次握手
在思考为什么握手需要三次这个问题之前,我们应该先考虑的是
为什么TCP的建立需要握手。
TCP是一种可靠传输控制协议,它的主要任务是在可靠传输数据的基础之上,尽可能的提高传输的效率。但是问题在于传输的信道并不可靠,我们面对的是一个未知的网络环境,无法确定信道的可靠性。假如说信道百分百的可靠,那么完全不需要握手的过程,我们只需要按照UDP的方式简单的将消息发出即可,因为我们知道无论何时何地我们只要发出消息对方一定能够收到。但是在广域网中这种完美的情况几乎不存在,为了解决在不可靠的信道上完成可靠传输这样一个问题,那么我们必须要在双方传输数据之前,就某些问题达成一致。回到TCP中来解释也就是通信双方约定起始的Seq。
为什么需要的次数是三次,而不是两次、四次或者更多?
首先我们先明确一点,在双方通信的过程当中,一条消息单方面的确认需要两次通信,也就是一次单向握手的过程。过程如下
当消息已收到
传递到A之后,那么作为A这一方就可以确定消息已经被B接收到了。但是这个时候作为B,它成功收到了A发来的消息但是它并不知道消息已送达这条消息是否成功抵达A处。
回到TCP中来看,建立连接的开始需要握手:我们需要验证通信双方之间的信道是否通畅,虽然中间只有一条信道,但是这条信道是双向的(非常简单的例子,一条马路允许双向的通行),我们需要同时验证A->B以及B->A都是通畅的,双方各有一个Seq需要和对方确认。
那么也就是说两次握手肯定是没有办法实现连接建立前双方协商在某些方面达成一致的需求,因为这只能满足一方确认消息,另一方无法确认。如果需要双方都确认某一条消息,那么必然需要两次单向握手的过程。从TCP的角度来解释,发送方SYN+ACK之后,响应方要初始化信道必须也要一次SYN+ACK。
那么三次握手是如何来的呢。出于优化的目的,响应方将对发送方SYN的ACK和自身的SYN合并成了一条。所以说三次握手这个说法虽然贴切的描述了握手的过程,但并不准确。事实其实是双方各一次的握手,各一次的确认,只不过其中一次握手和确认合并在一起。因为这样一个合并导致双向握手+双向确认的过程变成了三次握手。
四次挥手为什么不优化成三次挥手?
理由也非常的简单:通信是双向的,如果响应方接受了发送方的连接请求,那么必然也要发起一个单向握手来确认和发送方的连接;但是连接的关闭是允许半关闭的!任何一方都可以拆除自己发往对方数据的那个通道,而同时还要保证对方发往自己的数据能被正常处理。也就是说拆除阶段的第二次单向握手并不一定是要在第一次单线握手之后立马执行的,那么中间两次FIN+ACK的合并也就无从谈起了。
为什么应用层还需要设计心跳
当TCP连接建立成功以后,它可以长时间处于空闲状态没有任何数据的交流。这个时间短则几分钟,长则几个小时,几天。这段时间里只要双方的主机没有重启进程依然存在,无论中间网络发生什么样的情况连接都不会被中断。
这样一个说法似乎很难被理解,因为这和我们生活接触的实际例子不太一样。比如水管断开会漏水,电线断开会停电。而TCP连接实质是通信双方的主机保持的一个状态,中间的连接是一个虚构的存在,这样一个链接无论是发生波动抑或某一端异常断开,通信的双方是没有办法感知的。
上面所说的情况在实际情况中经常会发生。我们假设通信的双方是服务器-客户端
,提供服务的一端我们认为是服务器,服务器通常会一直保持运行状态并同时为多个客户端服务;发出请求的一方是客户端。通常来说客户端关闭程序或是手动关机的时候系统会为我们主动断开连接,发送一个FIN包给服务器。但是如果客户端突然崩溃或客户直接强制关闭了计算机,这个时候系统来不及发送FIN包,就会留下一个半开放连接在这里。如果服务器再次向这个已经非正常关闭的客户端发送消息,那么它会收到一个RST的回复。但是如果恰巧服务器正处在等待客户端回应的状态,那么它会一直等待下去
这样的情况显然是不可以接受的。因为服务器打开一个链接的同时,会以一个客户的身份占用着某一部分的资源,这样的一个半开放链接会导致这一部分的资源永远得不到释放。为了应对解决这一个问题,TCP设计了保活功能,也就是SO_KEEPALIVE选项。
TCP的心跳包是一个有争议的选项,这只是一个option,而不是一个必须实现的标准。通常情况下我们是在服务器打开这个选项,客户端被动响应,默认间隔时间7200s。但这不是绝对的,如果有需求客户端同样可以打开。之所以在服务器设置这个选项是因为它需要长时间保持在工作状态,并且同时为多个客户端服务,而客户端作为个人使用会经常(异常)关闭。检测出半开放的连接并删除它,释放资源对于服务器是非常必要的。
NFS双方都打开了SO_KEEPALIVE,而Telnet和Rlogin则只有服务器打开了这个选项。
TCP保活机制的缺陷
保活机制是非常有必要的,但关于是否应该在TCP中提供一直争论不休。在Host Requirement
中提供了3个不使用保活定时器的理由
- 在出现短暂差错的情况下,可能会使一个运行正常的连接释放掉。(比如中间路由崩溃并重新启动时会发送一个保活探查,会让TCP误认客户端已经崩溃)
- 耗费不必要的带宽
- 在按分组计费的情况下会在互联网下花费更多的金钱
抛开历史的包袱来看,带宽和计费相关的问题已经不再需要我们担心,但TCP的保活机制仍然是一个不被推荐的选项。
首先我们需要明确的是,TCP的保活只能够检测连接是否存活,但是否可用是未知的。做一个简单的比方,TCP的保活就像水道工,它只关心水管是否畅通能否通水,但水厂能否供水它无法保证。回到TCP上来解释就是进程可能死锁或者拥塞,操作系统是正常收发TCP消息的,但服务端繁忙无法提供服务。
其次,默认检测的时间是7200s=120min=2h。这个间隔实际上是非常长的,这么迟缓的响应能力在大部分的应用场景下是无法接受的。当然我们可以手动去修改SO_KEEPLIVE选项的参数,但这是系统级的变量,修改意味着会影响所有运行的程序。
有朋友提到这个参数在socket中可以为pre socket单独设置,类似Buffer。
除此之外,还有一个比较棘手的问题是keep alive的数据包有可能会被运营商拦截。如果仅仅依赖TCP的保活机制,那么在这种情况服务器可能会释放掉一个运行正常的连接。
总结
考虑到以上的问题,很多上层的协议都提供了心跳机制来维持连接(比如MQTT
中的PINGREQ
和PINGRESP
)。这并不是一种重复设计或者浪费!我们必须要明确的是TCP作为运输层的协议,它提供的是一个host级别的可靠传输服务,TCP所有任务实际的本质就是传输(就像我们提到的只是检测连接是否存活)。如果在业务场景中有需要心跳机制来处理的逻辑,这部分的实现应该交由应用层来完成。作为应用层的开发人员,应当把TCP简单看成一个负责网络传输的外部API,虽然它被广泛应用但并不完美可靠,应用层关于自身差错纠错的逻辑是不应该被省略的。