标题:TCP:学得越多越不懂 出处:Felix021 时间:Mon, 06 Apr 2020 13:25:38 +0000 作者:felix021 地址:https://www.felix021.com/blog/read.php?2215 内容: 周末小课堂又开张了,这次我们来聊一聊TCP协议。 == 握手 == 多少有点令人意外的是,大多数程序员对TCP协议的印象仅限于在创建连接时的三次握手。 严格地说,“三次握手”其实是一个不太准确的翻译,英文原文是 "3-way handshake",意思是握手有三个步骤。 不过既然教科书都这么翻译,我就只能先忍了。 “三次握手”的步骤相信各位都非常熟悉了: 引用 A: 喂,听得到吗 (SYN) B: 阔以,你呢 (SYN-ACK) A: 我也阔以,开始唠吧 (ACK) (咦,这不是远程面试的开场白吗) 那么问题来了:为什么不是2次握手或者4次握手呢? == 3次 == 针对“为什么不是4次”,知乎的段子手是这么回答的: 引用 A: 喂,听得到吗 (SYN) B: 阔以,你呢 (SYN-ACK) A: 我也阔以,你呢 (SYN-ACK) B: ...我不想和傻*说话 (FIN) 由此可见知乎质量的下降。 实际上,上面省略了真正重要的信息,在握手过程中传输的,不是“你能不能听得到”,而是: 引用 A: 喂,我的数据从x开始编号 (SYN) B: 知道了,我的从y开始编号 (SYN-ACK) A: 行,咱俩开始唠吧 (ACK) 协商一个序号的过程需要一个来回(告知 + 确认),理论上需要2个来回(4次),互相确认了双方的初始序号(ISN,Initial Sequence Number),才能真正开始通信。 由于第二个来回的“告知”可以和前一次的“确认”合并在同一个报文里(具体怎么结合后面讲),因此最终只需要3次握手,就可以建立起一个tcp链接。 这也解释了为什么不能只有2次握手:因为只能协商一个序号。 不过话说回来,知乎段子手的回复也不是全在抖机灵:毕竟,发起方怎么才能确认接收方已经知道发起方知道接收方知道了呢?即使发起方再问一遍,接收方又怎么知道发起方知道了接收方知道了呢? 很遗憾,结论是:无论多少个来回都不能保证双方达成一致。 由于实践中丢包率通常不高,因此最合理的做法就是3次握手(2个来回),少了不够,多了白搭;同时配上相应的容错机制。 例如 SYN+ACK 包丢失,那么发起方在等待超时后重传SYN包即可。 引用 想想看,如果最后一个ACK丢了会怎样? 然后问题又来了:为什么需要协商初始序号,才能开始通信呢? == 可靠 == 我们都知道,tcp是一个“可靠”(Reliable)的协议。 这里“可靠”指的不是保证送达,毕竟网络链路中存在太多不可靠因素。 在 IETF 的 RFC 793(TCP协议)中,Reliability的具体定义是:TCP协议必须能够应对网络通信系统中损坏、丢失、重复或者乱序发送的数据。 引用 Reliability: The TCP must recover from data that is damaged, lost, duplicated, or delivered out of order by the internet communication system. https://tools.ietf.org/html/rfc793 为了保证这一点,tcp需要给每一个 [字节] 编号:双方通过三次握手,互相确定了对方的初始序号,后续 [每个包的序号 - 初始序号] 就能标识该包在字节流中所处的位置,这样就可以通过重传来保证数据的连续性。 举个例子: * 发送方(ISN=4000) * 发出 4001、4002、4003、4004 * (假设每个包只有1字节的数据) * 接收方 * 收到 4001、4002、4004 * 4003因为某种原因没有抵达 * 这时上层应用只能读到4001、4002中的信息 由于接收方没有收到4003,因此给发送方的ACK中,序号最大值是4003(表示收到了4003之前的数据)。 过了一段时间(Linux下默认是1s),发送方发现4003一直没被ACK,就会重传这个包。 当接收方最终收到 4003 以后,上层应用才可以读到4003和4004,从而保证其收到的消息都是可靠的。(以及,接收方需要给发送方ACK,序号是4005) 注意:虽然ISN=4000,但是发送方发送的第一个包,SEQ是4001开始的,TCP协议规定 SYN 需要占一个序号(虽然SYN并不是实际传输的数据),所以前面示意图中ACK的seq是 x+1 。同样,FIN也会占用一个序号,这样可以保证FIN报文的重传和确认不会有歧义。 但是,为什么序号不能从 0 开始呢? == 可靠² == 真实世界的复杂性总是让人头秃。 我们知道,操作系统使用五元组(协议=tcp,源IP,源端口,目的IP,目的端口)来标识一个连接,当一个包抵达时,会根据这个包的信息,将它分发到对应的连接去处理。 一般情况下,服务器的端口号通常是固定的(如http 80),而操作系统会为客户端随机分配一个最近没有被使用的端口号,因此包总能被分发到正确的连接里。 但在某些特殊的场景下(例如快速、连续地开启和关闭连接),客户端使用的端口号也可能和上一次一样(或者用了其他刚断开的连接的端口号)。 而TCP协议并不对此作出限制: 引用 The protocol places no restriction on a particular connection being used over and over again. ... New instances of a connection will be referred to as incarnations of the connection. 那么: * 如果前一个连接的包,因为某种原因滞留在网络中,这会儿才送达,客户端可能无法区分(其sequence number在本连接中可能是有效的)。 * 恶意第三方伪造报文的难度很小。注意,在这个场景里,第三方并 [不需要] 处于通信双方的链路之间,只要他发出的报文可以抵达通信的一方即可。 因此我们需要精心挑选一个ISN,使得上述case发生的可能性尽可能低。 注意:不是在tcp协议的层面上100%避免,因为这会导致协议变得更复杂,实现上增加额外的开销,而在绝大多数情况下是不必要的。如果需要“100%可靠”,需要在应用层协议上增加额外的校验机制;或者使用类似IPSec这样的网络层协议来保证对包的有效识别。 那么,ISN应该如何挑选呢? == ISN生成器 == 说起来其实很简单: TCP协议的要求是,实现一个大约每 4 微秒加 1 的 32bit 计数器(时钟),在每次创建一个新连接时,使用这个计数器的值作为ISN。 假设传输速度是 2 Mb/s,连接使用的sequence number大约需要 4.55 小时才会溢出并绕回(wrap-around)到ISN。即使提高到 100 Mb/s,也需要大约 5.4 分钟。 而一个包在网络中滞留的时间通常是有限的,这个时间我们称之为MSL(Maximum Segment Lifetime),工程实践中一般认为不会超过2分钟。 所以我们一般不用担心本次连接的早期segment(tcp协议称之为 old duplicates)导致的混淆。 注:在家用千兆以太网已经逐渐普及、服务器间开始使用万兆以太网卡的今天,wrap-around的时间已经降低到32.8s(千兆)、3.28s(万兆),这个假定已经不太站得住脚了,因此 rfc1185 针对这种高带宽环境提出了一种扩展方案,通过在报文中加上时间戳,从而可以识别出这些 old duplicates。 主要风险在于前面提到的场景:前一个连接可能传输了较多数据,因此其序列号可能大于当前连接的ISN;如果该连接的报文因为某种原因滞留、现在又突然冒出来,当前连接将无法分辨。 因此,TCP协议要求在断开连接时,TIME-WAIT 状态需要保留 2 MSL 的时间才能转成 CLOSED(如下图底部所示)。 点击在新窗口中浏览此图片 https://www.felix021.com/blog/attachment.php?fid=525 (tcp连接状态图,截取自rfc 793) 那么问题又来了:为什么只有 TIME-WAIT 需要等待 2MSL,而LAST-ACK不需要呢? == 报文 == 针对TCP协议可以提的问题太多了,写得有点累,所以这里不打算继续自问自答了。 但写了这么多,还没有看一下TCP报文是什么结构的,实在不应该,这里还是祭出 rfc 793 里的 ascii art(并顺便佩服rfc大佬的画图功力) 点击在新窗口中浏览此图片 https://www.felix021.com/blog/attachment.php?fid=526 简单介绍下: * 一行是4个字节(32 bits),header一般共5行(options和padding是可选的) * 第一行包含了源端口和目的端口 * 每个端口16bits,所以端口最大是65535 * 源IP和目的IP在IP报文头里 * 第二行是本次报文的Sequence Number * 第三行是ACK序列号 * 第四行包含了较多信息: * 数据偏移量:4字节的倍数,最小是0101(5),表示数据从第20个字节开始(大部分情况) * 控制位(CTL):一共6个,其中的ACK、SYN、FIN就不介绍了 * RST是Reset,遇到异常情况时通知对方重置连接(我们敬爱的防火墙很爱用它) * URG表示这个报文很重要,应该优先传送、接收方应该及时给上层应用。URG的数据不影响seq,实际很少被用到,感兴趣的话可以参考下RFC 854(Telnet协议) * PSH表示这个报文不应该被缓存、应当立即被发送出去。在交互式应用中比较常用,如ssh,用户每按下一个键都应该及时发出去。注意和Nagle算法可能会有一些冲突。 * 窗口大小:表示这个包的发送方当前可以接受的数据量(字节数),从这个包里的ack序号开始算起。**用于控制滑动窗口大小的关键字段就是它了。** 举个例子,三次握手的第二步,SYN和ACK合并的报文就是这么生成的: * Sequence Number填入从ISN生成器中获取的值 * Acknowledgement Number填入 [发送方的序号 + 1] * 将控制位中的ACK位、SYN位都置1 写不动了,真是没完没了(相信看到这里的同学已经不多了),但是TCP协议中还有很多有意思的设计本文完全没有涉及,文末我给出一些推荐阅读的链接,供感兴趣的同学参考。 == 总结 == * TCP“三次握手”翻译不准确 * 握手的目的是双方协商初始序列号ISN * 序列号是用于保证通信的可靠性 * 不使用 0 作为ISN可以避免一些坑 * TCP报文里包含了端口号、2个序列号、一些控制位、滑动窗口大小 * 我在字节跳动网盟广告业务线(穿山甲),由于业务持续高速发展,长期缺人。关于字节跳动面试的详情,可参考我之前写的 * 《程序员面试指北:面试官视角》 * https://mp.weixin.qq.com/s/Byvu-w7kyby-L7FBCE24Uw ~ 投递链接 ~ 后端开发(上海) https://job.toutiao.com/s/sBAvKe 后端开发(北京) https://job.toutiao.com/s/sBMyxk 广告策略研发(上海) https://job.toutiao.com/s/sBDMAK 其他地区、职能线 https://job.toutiao.com/s/sB9Jqk == 推荐阅读 == [1] RFC 793:TRANSMISSION CONTROL PROTOCOL https://tools.ietf.org/html/rfc793 [2] Coolshell - TCP 的那些事儿 (上 & 下) https://coolshell.cn/articles/11564.html https://coolshell.cn/articles/11609.html [3] 知乎 - TCP 为什么是三次握手,而不是两次或四? https://www.zhihu.com/question/24853633 Generated by Bo-blog 2.1.0