TCP的话题
撰写于:2015年11月30日
作者:silex Wi-Fi专家
TCP 是支撑当今互联网的核心技术,可以说是人类历史上最为成功且被广泛使用的通信协议。这次,我想针对这个 TCP,稍微介绍一下它的内部原理。
什么是 TCP/IP
TCP(Transmission Control Protocol,传输控制协议)是通常被称为TCP/IP的通信协议的一部分。TCP/IP 以网络层的 IP(Internet Protocol,互联网协议)、传输层的 UDP(User Datagram Protocol,用户数据报协议)和 TCP 为核心,在大多数情况下,其下层会实现用于解析 MAC 地址的 ARP等辅助协议,上层则会实现用于自动分配 IP 地址的 DHCP等辅助协议群。
作为 “网络层” 的 IP,具有将指定数据从 A 地点(发送源地址)传送到 B 地点(目标地址)的功能。IP协议存在使用 32 比特地址的 IPv4 和使用 128 比特地址的 IPv6,不过,TCP 以及 UDP 都采用了可同时适用于 IPv4 和 IPv6 的实现方式。
IP协议的工作仅仅是 “将数据送达地址”,既不解析数据内容含义,也不验证数据是否实际送达。IP 头部中有一个 8 比特的 “协议” 字段(※在 IPv6 中称为有效载荷),该字段数值决定了所承载数据的解析方式。具有代表性的协议值主要有以下 3 种。
0x01: ICMP
0x06: TCP
0x11: UDP
※注:有关协议有效载荷编号列表,请参阅以下网址:http://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml
ICMP(Internet Control Message Protocol,互联网控制消息协议)是为网络控制而制定的协议,不过如今它几乎不被用于网络控制方面了,主要是被应用于 PING(准确地说是ICMP ECHO)功能。
一般来说,UDP 被解释为 “不可靠的传输层协议”。所谓 “不可靠”,意思是 “不进行数据到达确认”。由于 UDP 在协议层没有到达确认的流程,所以与 TCP 相比,它的开销更低,而且 UDP 的特点还在于能够支持一对多的广播或多播传输。
UDP 与 “纯粹的” IP 的不同之处在于,除了地址之外,它还拥有发送源和目标的 “端口号(16 比特)”,通过这一点,在一个地址上能够让多个不同的服务同时进行通信。
TCP 被解释为 “可靠的传输层协议”,它除了端口号之外,还提供附带数据到达确认的通信机制。在收到通信对方发来的数据接收确认之前,会自动反复进行重传,当重传的时间或次数超过设定限度时,就会通知出现错误。与此相对,UDP 是 “发送后就不管了” 的模式,所以只要发送成功,就会向发送方返回 “正常结束” 的信息,至于发送出去的数据是否到达对方手中则并未进行确认。
与单纯的 IP 和 UDP 不同,TCP 是一种 “有状态的(Stateful)” 协议,在通信对等体之间(发送源 / 目标的地址:端口对)存在着虚拟线路的连接和断开流程。这种虚拟线路被称为 “连接(Connection)”,而在程序中用于处理连接的标识符(句柄)则被称为 “套接字(Socket)” 。TCP 数据包为了控制连接的状态,具备 “标志(Flag)”、“序号(Sequence)”、“确认号(ACK)”、“窗口(Window)” 等字段,这些内容将是本次博客的主要话题。
TCP头部结构
TCP 状态控制
TCP 的状态控制在RFC 793(1981)的图 6中有一张著名的状态迁移图。如果能理解这个状态迁移图,就相当于理解了 TCP 的一半,但其中也有一些不太容易理解的地方。
TCP连接状态迁移图
蓝色箭头表示被动的(Passive)状态转移,红色箭头表示主动的(Active)状态转移。
虚线箭头表示 “虽然在规格上有定义,但通常不太会经历” 的状态转移。
表示状态的四边形边框较粗的是 “会在很长时间内维持” 的稳定状态。
边框为细线条的四边形是 “在通信过程中临时经过” 的状态。
TCP 连接前
状态转移图中的 “CLOSED”是指连接不存在的状态,也可以说是 “无状态的状态”。当应用程序创建套接字(s = socket (AF_INET, SOCK_STREAM, 0))时,仅仅是 “生成了句柄” 而已,实际的连接尚未建立。为了将连接与套接字关联起来,首先需要给套接字分配 “自己这一方” 的端口号,这是通过 bind (s, sa, sa_len) 函数来实现的。
由于 bind 操作仅仅是为 “自己这一方” 分配端口号,所以此时连接尚未建立。要建立连接,就必须指定 “对方那一方” 的端口号。为此,存在两种途径:(A) 等待对方发起连接(listen);(B) 主动从自己这一方连接到对方的端口(connect) 。前者被称为被动打开(Passive Open),后者被称为主动打开(Active Open)。通常情况下,服务器端以被动 / 监听(Passive/Listen)模式运行,客户端则以主动 / 连接(Active/Connect)模式与之配对运行。
另外,根据 TCP 的规范,还存在一种双方同时明确指定对方端口号并通过 connect 操作进行连接的流程(C),但实际上这种方式基本不会被使用。
TCP 连接
当执行 connect 操作时,会发送一个设置了 SYN 标志位的数据包。当这个数据包被处于监听(listen)状态的套接字接收后,就会返回一个设置了 SYN+ACK 标志位的数据包。32 比特的序列号(SEQ)表示在 TCP 中数据的连续编号。接收方会根据实际接收到的数据的字节数来增加序列号,并将增加后的序列号作为 “确认号(ACK)” 返回给发送方。
SEQ/ACK是针对 “数据” 而非 “数据包” 的连续编号,这是 TCP 的一大特点。借此,在通信路径上对数据包的拆分与合并能够更加灵活(※)。SYN是用于 SEQ 编号初始同步的标志,但多数时候被理解为 “连接建立请求”。为增加连接“劫持” 的难度,建议 SEQ 的初始值不要设为 0 或 1,而是用随机数生成。
※注:在传输单位为数据包(数据报)的 UDP 中,就不会出现这样的情况。
TCP 连接状态和窗口控制
当 SYN 和 ACK 完成交换,连接建立起来后,就会进入 “ESTABLISHED” 状态,此时便可以进行数据的发送和接收了。发送方会在 TCP 头部之后发送长度(LEN)大于等于 0 的数据,接收到数据的一方会将 ACK 编号增加 LEN 的值,然后返回 ACK 确认。在 TCP 中,ACK 并非是一个独立的功能,它总是包含在 TCP 头部当中,并且能够 “搭便车” 参与数据通信,这也是 TCP 的特点之一。
发送方无需等待 ACK 的到来就可以发送后续数据。究竟可以提前发送多少数据,这取决于接收方的缓冲区容量,这一情况由一个叫做 “窗口(WIN)” 的值来表示。窗口值在 SYN/SYNACK 交换时进行交互,发送方每发送一次数据就会使窗口值减小。例如,若窗口初始值为 2000,数据包数据最大长度(MSS)为 1024字节,那么在第一次数据发送时可以发送 1024 字节的数据,但第二次就只能发送 976(=2000-1024)字节的数据了(※注)。当发送方的窗口值变为 0 时,发送操作就会停止,并且在接下来收到 ACK 之前,发送操作处于 “暂停” 状态。
※注:实际上,会应用诸如Congestion Avoidance和Slow Start Algorithm等机制,情况会更复杂一些。
窗口控制
MSS 是 “Maximum Segment Size” 的缩写,指的是无需进行分割和重新组合就能够进行发送和接收的数据的最大长度。通常情况下,它是参照本地链路的网络规范来确定的(例如,IEEE802.3 有线网络的最大数据长度(MTU)为 1500 字节,减去 IP 和 TCP 头部的大小后,MSS 就等于 1420 字节)。由于在连接时还不知道对方的 MSS,所以常见的做法是在 SYN 以及 SYNACK 中,将本地链路的 MTU 作为 TCP MSS option(RFC879)添加进去。
接收方每接收一次数据就会增大WIN值,并根据需要将其作为ACK返回给发送方。这一过程被称为 “Window Update”。如果发送方因 WIN = 0 而处于停止状态,那么通过Window Update操作,通信将重新恢复。
如果 WIN = 0 而停止的连接,其 Window Update 数据包在通信线路上丢失,就可能会陷入 “双方相互等待” 的死锁状态(※注)。因此,对于因 WIN = 0 而停止的连接,推荐在发送方每隔一定时间(数十秒至数分钟)发送 LEN = 0 的数据包,以促使接收方返回 ACK。这一操作被称为 “Window Probe”。
※注:因为只有当 WIN = 0 时 Window Update 数据包 “偶然” 丢失的情况下才会出现这种问题,所以这会成为一种重现频率低且棘手的故障。在我曾经主要负责打印服务器开发的时期,还特意植入了使 Window Update 数据包消失的代码,以此来确认通信不会因此而停止。另外,后面将要提到的 KeepAlive 机制,附带也具有避免因 Window Update 数据包丢失而导致通信停止的效果。
Window Probe
当在通信线路上数据包丢失时,接收方会将 “最后接收到的 SEQ+LEN” 作为 ACK 返回,发送方依据这个就能够得知数据包丢失以及有重传的必要。然而,当窗口大小较大且数据包频繁丢失时,在窗口内会出现像 “被虫蛀” 一样的数据包丢失情况,此时单纯使用 “最后的 SEQ+LEN” 方式就无法实现高效的重传。这一情况在无线网络中尤为明显,后来在 RFC3517 中定义了一种名为选择性 ACK(Selective ACK 或 SACK)的扩展规范。SACK 是通过位图来表示数据的接收状态,借助它,在数据包频繁丢失时,通信效率能够得到提升,但相应地,其实现过程也会变得较为复杂。在像IoT这种通信数据量较少,并且要求实现资源最小化的情况下,把 WIN 大小设置得较小(几千字节),不实现 SACK 功能,而仅采用传统的 SEQ/ACK 实现方式,或许会更有优势。
TCP 断开
TCP 的通信结束存在三种模式。分别是由 FIN 导致的正常断开、由 RST 导致的强制断开,以及因通信中断而引发的超时(Time Out)断开。
通过 FIN 正常断开连接
FIN 并非意味着 “断开连接请求”,而是表示 “数据发送结束”。当接收到来自连接对方的 FIN(D)时,会返回最后一个 SEQ 编号加 1 的 ACK(Last ACK)。从那之后,接收方的 recv () 函数将返回 EOF(-1),不过 send () 函数仍然有效。这通常被称为单向关闭,在状态转移图中,发送 FIN 的一方会进入 FINWAIT2 状态,接收 FIN 的一方会进入 CLOSE_WAIT 状态。
在 BSD 套接字 API 中,调用 close () 函数时,套接字会与应用程序断开连接,同时会发送 FIN ,从而开始断开连接的流程(E)。当想要在保留套接字功能的同时发送 FIN,也就是有意使用单向关闭功能时,则要用到 shutdown () 函数。shutdown () 函数的 how 参数可以取 SHUT_RD (0)、SHUT_WR (1)、SHUT_RDWR (2) 这几个值中的一个。当 how 参数的值为 SHUT_WR 时,会进入 “发送 FIN 并停止后续的发送操作,但仍可继续接收数据” 这样一种单向关闭的状态。
在一般的套接字应用程序实现中,发送方调用 close () 函数来启动连接断开流程。接收方在收到 recv () 函数返回的错误后会调用 close () 函数,接着接收方也会返回 FIN,至此连接断开得以完成。
通过 RST 强制断开连接
FIN 是通过应用程序的 API 调用实现的 “温和断开(Gentle Disconnect)”,而 RST 则是用于强制断开连接。当接收到 RST 时,TCP 缓冲区中尚未发送的数据以及已接收但未处理的数据都会被清除。像这样,在 RST 强制断开的情况下,TCP “数据传输的可靠性” 这一特性就会丧失,因此,一般情况下应用程序不会故意发出 RST(※注)。
※注:不过,在 BSD 套接字 API 中,通过将套接字选项 SO_LINGER 设置为 l_onoff = 1、l_linger = 0 ,然后调用 close () 函数,也能够选择以 RST 方式断开连接。
超时检测和断开连接
TCP 的超时断开发生在发送方的情况,在发送数据后的一定时间内没有接收到 ACK 确认时就会触发。相反,接收方无法确定是 “通信已经中断” 了,还是 “仅仅是数据没有发送过来” 而已。如果使用 recv () 函数处于等待接收数据的状态,而这时突然将连接的对方设备断电,那么 recv () 函数所在的连接就会一直处于永久等待数据的状态。这虽然不会立即造成致命的故障,但处于永久等待数据状态的进程连接可能会逐渐耗尽系统资源,进而成为导致内存泄漏的原因,所以不能对此置之不理。
为了防止这种情况发生,在应用程序端需要设置数据接收超时(在 BSD 套接字中可使用 select () 函数来实现)。作为 TCP 的选项,还有一种 “KeepAlive” 功能,即使在没有有效数据收发的情况下,也会每隔一定时间(比如 30 分钟)发送长度为 0 的数据包来进行存活确认。不过,KeepAlive 并非是必须实现的功能,即便在实现了该功能的情况下,默认也是关闭的,并且推荐不使用该功能(※注)。
※注:这一推荐是基于 IETF 的方针,也可以说是一种理念或者偏好,即 “仅仅为了进行存活确认而发送不带有有效数据的数据包,这属于一种干扰并且是没有必要的”。
KeepAlive 的工作方式与前面提到的 Window Probe 非常相似,根据不同的实现体系,甚至存在一些协议栈,一旦禁止 KeepAlive,连 Window Probe 功能也会随之停止,这属于存在的一种漏洞情况。正因为 TCP/IP 的 KeepAlive 超时机制不太可靠,所以当要求具备确定性时,按照惯例,就像前面所说的那样,需要在应用层实现接收超时机制。
其他方面
URG
在 TCP 头部中,除了 SEQ、ACK、WIN之外,还包含一个 16 比特宽度的名为 URG (Urgent Pointer)的值。原本它是为了实现一种功能而设计的,即紧急数据(Out of band, OOB)会比普通的数据通信优先进行传输(应用程序会先接收紧急数据,而不是先接收已接收并存储在缓冲区中的数据),其规格是通过 URG 标志来指示在 SEQ+ URG 所指向的 “应到达位置” 处包含有相应数据。在过去的 BSD Unix 系统中,曾使用它来通过 TELNET 传达中止操作(Ctrl+C)等情况,但如今,URG 功能已经成为了一种 “虽然存在但不被使用” 的无用部分(类似阑尾一样的存在)。
TCP 和 IPv6
TCP(UDP 也是如此)在设计上几乎不依赖于 IPv4 层,并且几乎以相同的规格也能在 IPv6 上运行。唯一的依赖点在于头部的校验和计算中需要包含发送和接收地址这一点(※注),在这里,有必要在协议栈内部根据是 IPv4 还是 IPv6 来改变处理方式。
※注:这被称为伪首部(pseudo-header)。
TCP与安全
TCP 本身并不具备安全(加密)扩展选项。虽然曾有各种各样形式的带加密功能的 TCP 被提出,但没有一种得以广泛应用。作为 IETF的官方观点,是认为通过 IP 及 IPv6 层的安全标准(IPsec)来实现 TCP 的安全性,然而在实际情况中,处于 TCP 上层的 SSL/TLS正作为 “类似加密版 TCP 的东西” 被广泛使用。
TCP 的局限性
当然,TCP 并非万能,它也有许多局限性。TCP 说到底是一种实现两点之间、一对一的连续数据传输的协议,对于一对多的组播传输以及不连续数据的处理并不擅长(或者说,基本上无法处理)。所谓 “不连续数据”,例如在流式视频直播中,即便网络出现暂时的卡顿然后恢复,卡顿期间中断的部分可以跳过,从当前最新的画面继续进行传输,就是这样一种功能。针对这类用途,已经提出了诸如 RTP (Real-time Transport Protocol)和 SCTP (Stream Control Transmission Protocol)等协议,不过感觉它们目前还缺乏广泛普及的势头。
总结
最初的 TCP 规范,即 RFC793,于 1981 年 9 月发布。从那以后的 30 多年里,TCP 作为互联网的主干协议,可以说被全世界的人们所使用,承担了从高度的科学信息数据、巨额的金融交易,到数不清的垃圾邮件广告,再到搞笑视频等各种各样类型的数据传输工作。TCP 自身也多次修订了重传算法,还不断进行了诸如MSS Option, Window Scaling , SACK等后续追加的规范扩展,但其核心算法(SEQ、ACK、WIN)始终保持原始形态。能允许后续追加扩展的这种包容性,也同样彰显了其设计的卓越之处。
然而,TCP/IP(v4)在取得历史性成功的同时,却没有一个理想的后继者。扩大了地址空间的 IPv6 在规范制定 20 多年后仍迟迟未能普及,原本应该与IPv6同步普及的IPsec,也未能成为主流,反而SSL/TLS成为了事实上的加密标准。支持实时组播的传输层也尚未得到很好的普及。这种状况究竟只是暂时的(虽说时间已经很长了)停滞,还是实际上 TCP/IP(v4)已经 “在实用上足够”,而其他的都是不必要的多余发明,这还有待我们在今后继续观察。
相关文章
Silex 产品信息
我们的技术和产品旨在建立设备之间的通信,并确保这种通信能够顺畅地稳定进行。而且,更重要的是,使用 Silex 技术和产品的客户可以绝对放心。 我们正是致力于实现这一目标。