从零入门计算机网络系列(十四):TCP报文与字节流本质——从数据封装到报文边界
上一篇文章中我们已经对UDP协议进行了比较深入的讲解从报文格式到核心特性再到一些容易忽略的细节相信大家对UDP已经有了更清晰的认识。不过传输层可不只有UDP还有一个我们经常接触、却远比UDP复杂的协议——TCPTransmission Control Protocol传输控制协议。从本文开始我们就要正式进入TCP的学习了。和UDP不同TCP涉及的知识点非常多背后的设计思想也更加复杂。所以接下来我会花费相当多的篇幅带大家一步步深入TCP的底层原理弄清楚它究竟是如何实现可靠传输的。而且这部分内容也是校招面试中的高频考点密集区。无论是三次握手、四次挥手还是确认应答、超时重传、滑动窗口、流量控制和拥塞控制都是面试官非常喜欢考察的知识点。不过我还是想强调一点理解原理远比死记硬背八股文重要。毕竟面试官稍微换个问法或者多追问几句如果只是机械地背答案很容易就答不上来了。而真正理解了TCP为什么要这样设计、每种机制究竟解决了什么问题很多面试题自然也就能想明白了。所以这一系列文章我们不急着背结论而是尽量从问题出发一点点推导出TCP背后的设计思路。好了话不多说我们正式开始目录一、重新认识网络报文从内核管理到封装与解包1.1 网络报文如何管理从“先描述再组织”说起1.2 封装与解包的本质仅仅是移动data指针吗1.3 回顾网络报文的封装过程从数据组织到并发处理二、TCP报头与字节流深入理解数据交付与报文边界2.1 TCP报头的两个核心问题如何交付数据如何分离首部与载荷2.2 为什么TCP报头没有独立的数据长度字段从报文段到字节流的本质一、重新认识网络报文从内核管理到封装与解包前面我们在学习UDP和HTTP协议时已经接触过不少报文相关的知识。不过对于报文我们似乎一直都在关注它的格式、字段以及如何发送和接收却很少思考另一个问题这些报文到了操作系统内部究竟是如何被管理的呢我们先来看一个比较常见的网络通信场景。在实际开发中客户端和服务器之间往往是一种N:1多对一的关系。也就是说一台服务器可能同时为成百上千甚至更多的客户端提供网络服务。那么问题来了当大量客户端同时向服务器发送数据时会发生什么呢很显然服务器需要接收和处理来自不同客户端的网络报文。而这些报文不一定能够被应用程序立即处理部分报文还需要暂时存放在操作系统内核的相关缓冲区或队列中。这样一来随着并发请求数量不断增加操作系统内部就可能需要维护大量等待处理的报文。既然存在大量报文那操作系统就必须想办法对它们进行管理毕竟操作系统不可能把这些报文随意堆放在那里总得知道哪些报文已经到达、哪些还在等待处理以及应该将它们交给哪个套接字。所以接下来我们就换一个角度不再只是把报文看成一串需要传输的二进制数据而是站在操作系统的角度重新理解报文的组织与管理方式。而这恰恰是我们继续深入TCP底层原理时一个非常重要的切入点。1.1 网络报文如何管理从“先描述再组织”说起前面我们已经知道当大量客户端同时向服务器发送数据时操作系统内部就可能需要管理大量的网络报文。那么问题来了面对这么多报文操作系统究竟是怎么管理它们的呢其实还是我们之前学习操作系统时反复强调的那套思想——先描述再组织什么意思呢很简单。操作系统想要管理某个对象就得先把这个对象的相关信息描述出来再利用合适的数据结构将它们组织起来实现统一管理。对于网络报文来说同样如此。在Linux内核中有一个非常重要的数据结构叫做struct sk_buff也就是我们常说的Socket Buffer简称SKB。它主要用于描述网络数据包记录与报文相关的各种信息并关联实际存放报文数据的内存缓冲区。而且在struct sk_buff中还包含两个比较重要的成员struct sk_buff *next; struct sk_buff *prev;看到这里大家应该觉得很熟悉吧没错这不就是我们之前学习过的双向链表吗next用于指向链表中的下一个sk_buff节点。prev用于指向链表中的上一个sk_buff节点。有了这两个指针Linux内核就可以根据需要将多个sk_buff组织成双向链表队列方便进行报文的插入、删除和管理。当然内核并不是所有场景都使用双向链表管理报文还会根据具体需求采用其他数据结构。不过这里真正值得我们关注的是操作系统管理网络报文的思想先用结构体描述报文再通过数据结构组织这些报文。这样一来原本需要管理的大量网络报文就可以转化为对一个个内核数据结构的管理。由此我们就能得到一个非常重要的理解从Linux内核管理报文的角度来看可以把一个报文抽象为描述报文的结构体 存放实际数据的内存缓冲区。不过这里需要注意sk_buff本身主要保存的是元数据并不是把整个报文内容直接存放在结构体内部。那么结构体和实际存放报文数据的内存之间又是怎么关联起来的呢这就要继续看看sk_buff的内部结构了。把这个结构抽象出来看在sk_buff中有四个非常重要的成员分别是head指向主要线性数据缓冲区的起始位置。data指向当前有效数据的起始位置。tail标记当前线性数据区域的结束位置。end标记主要数据缓冲区的边界。这里需要说明head和data是指针而tail和end在现代Linux内核的部分配置下使用的是偏移量并不一定都是指针。不过为了方便理解我们可以先把它们看作标记缓冲区不同位置的边界。那么这块缓冲区里面究竟放了些什么呢在一个典型的网络报文中线性数据区域可能包含以下内容预留头空间Headroom→ 数据链路层报头 → IP 协议报头 → TCP/UDP 协议报头 → 应用层数据 → 预留尾空间Tailroom当然这只是为了方便理解而画出的简化布局。实际报文在不同协议层之间传递时相关报头的位置可能发生变化而且Linux还支持非线性sk_buff报文数据不一定全部存放在同一块连续内存中。那么为什么要设计出head、data、tail、end这些成员呢因为网络报文在协议栈中传递时经常需要添加或者移除不同协议层的报头。比如应用层数据向下交付时传输层可能需要添加TCP报头网络层需要添加IP报头到了数据链路层还需要添加对应的帧头。如果每增加一层报头都重新申请内存、复制整份数据那岂不是太浪费资源了所以Linux内核可以利用sk_buff提供的缓冲区管理机制通过调整数据边界等方式高效地完成报头的添加和移除减少不必要的数据复制。说白了sk_buff不只是用来描述一个网络报文更重要的是它为Linux内核提供了一套统一管理和操作网络数据的机制。到这里我们就能进一步理解操作系统中那句非常经典的话了先描述再组织。先通过struct sk_buff描述网络报文再利用链表等数据结构组织和管理这些报文。至于实际的数据内容则通过关联的内存缓冲区进行保存。这样一来操作系统就能够更加高效地管理网络协议栈中大量流转的报文。1.2 封装与解包的本质仅仅是移动data指针吗前面我们已经了解了struct sk_buff的基本结构知道了Linux内核是如何通过结构体和内存缓冲区来描述、管理网络报文的。不过讲到这里大家有没有想过一个问题我们之前一直在说数据从应用层向下传递时需要不断添加协议报头而数据从底层向上传递时又需要逐层去掉对应的报头。那么这个过程在操作系统内部究竟是怎么实现的呢难道每经过一层协议操作系统都要重新申请一块内存再把原来的数据复制过去吗如果每次都这样操作那开销可就太大了其实在Linux内核中sk_buff提供了一种非常巧妙的处理方式通过调整data指针的位置就可以高效地完成部分报头的添加与剥离操作而不必每次都复制整个数据包。我们先来看数据封装的过程。1数据封装data指针向低地址方向移动假设现在有一份应用层数据需要通过UDP协议进行发送。那么当数据交给传输层时就需要在应用层数据前面添加一个UDP报头。我们知道UDP报头的大小固定为8字节。在sk_buff的线性缓冲区中如果前面已经预留了足够的头空间Headroom就可以让data指针向低地址方向移动8字节为UDP报头腾出位置。抽象成代码就是data - sizeof(struct udphdr);注意这里只是帮助大家理解指针移动的原理并不是建议直接这样修改内核中的 skb-data。在Linux内核中通常可以使用skb_push()完成类似的操作struct udphdr *uh; uh (struct udphdr *)skb_push(skb, sizeof(struct udphdr));这里的skb_push()会将data向低地址方向移动同时更新sk_buff中记录的数据长度。这样一来原本位于data后面的应用层数据不需要整体移动只需要利用新腾出来的空间填写UDP报头即可。比如我们可以通过获取到的uh指针访问UDP报头字段uh-source htons(source_port); uh-dest htons(dest_port);当然这只是用于理解报头填充的局部示例真正构建UDP报文时还需要正确设置长度、检验和等字段并确保缓冲区具有足够的头空间以及相应的写入权限。这里还有一个细节如果只是向数据区头部添加报头通常不需要移动tail所表示的数据结束位置。因为我们是在已有数据的前面扩展空间而不是在数据末尾追加内容。所以从原理上看封装就是让data向前移动为新的协议报头预留位置再将对应的字段填写进去。2数据解包data指针向高地址方向移动既然封装是添加报头那么解包自然就是反过来的过程。当一个网络数据包从底层向上传递时操作系统需要根据协议格式逐层解析对应的报头。比如网络层完成IP报头的相关处理后需要将数据交给传输层。这时候就可以在适当的处理阶段将data指针向高地址方向移动跳过已经处理的协议报头。如果我们仍然以UDP报头为例抽象成代码就是data sizeof(struct udphdr);而在Linux内核中可以使用skb_pull()来完成类似的操作skb_pull(skb, sizeof(struct udphdr));这个函数会将data向高地址方向移动并相应减少当前sk_buff的有效数据长度。注意这里所谓的“剥离报头”并不一定是把报头对应的字节从内存中真正删除而是通过调整数据起始位置让后续处理逻辑不再把这部分内容当作当前有效载荷。不过实际的网络解包过程也不只是简单移动指针还需要完成协议字段解析、长度检查、检验和校验等工作。所以我们可以这样理解封装根据协议需要在数据前面预留空间添加对应的协议报头。解包解析当前协议报头并在合适的时候调整数据起始位置将有效载荷交给上层协议继续处理。说白了data指针的移动是Linux内核高效处理网络报文封装与解包的重要手段之一。3报文在不同协议层之间是如何传递的看到这里可能有人会产生这样一种理解报文在哪一层就应该放在哪一层对应的队列中。那么网络协议栈有四层是不是就意味着内核中固定存在四个sk_buff队列呢其实并不是这样。虽然我们经常将TCP/IP网络协议栈划分为应用层、传输层、网络层和数据链路层但这只是一种便于理解网络通信的分层模型并不代表Linux内核必须按照这四层分别维护四个固定的报文队列。实际情况要复杂一些。在Linux内核中网络报文会根据协议处理流程在不同的函数、模块以及必要的缓冲队列之间传递。比如网卡接收队列、Socket接收队列以及网络设备发送队列等都可能涉及sk_buff的组织和管理。有些处理过程需要将报文加入队列等待有些则可以直接将sk_buff交给下一阶段的处理函数并不一定每经过一个协议层都要入队一次。因此我们不能简单地认为网络协议栈有四层操作系统内部就一定存在四个对应的sk_buff队列。不过有一点是可以确定的sk_buff为Linux网络协议栈提供了统一的报文描述与缓冲区管理机制使数据能够在不同协议处理阶段之间高效传递。而data指针的调整则是其中一个非常重要的实现技巧。到这里我们也就能够把前面学习过的知识联系起来了。操作系统通过sk_buff描述报文通过相关数据结构组织报文再利用缓冲区管理机制完成协议报头的添加、解析以及数据交付。这样一来网络协议栈就不需要在每次封装和解包时都重新复制整份报文数据从而减少不必要的内存操作提高网络通信的处理效率。1.3 回顾网络报文的封装过程从数据组织到并发处理前面我们已经从Linux内核的角度了解了sk_buff的基本结构以及如何通过调整 data 指针实现协议报头的添加与剥离。现在我们不妨回过头来再看看之前对网络报文封装过程的理解。在最开始学习Socket网络编程时我们通常会把数据发送过程理解为应用程序创建缓冲区填入需要发送的数据 → 调用发送接口 → 操作系统将数据交给传输层处理 → 添加对应的协议报头 → 继续向下交付。这个理解当然没什么问题只不过当时我们更多关注的是数据如何从应用层一步步交付给底层并没有深入了解操作系统内部究竟是怎么管理这些报文的。现在有了sk_buff的知识我们就可以把这个过程理解得更加具体一些。以普通的Socket发送过程为例应用程序先在用户空间准备好数据再通过send()、sendto()等接口发起发送请求。操作系统收到请求后会根据具体的协议处理流程分配或组织相应的内核缓冲区并将用户数据复制或映射到内核能够处理的区域随后完成协议报头的构建与数据发送。当然具体实现还会受到协议类型和发送方式的影响并不是所有场景都必须重新复制整份数据。整个过程和思路我已经整理成了一张板书大家可以结合前面的内容看一看。不过讲到这里我突然想到一个问题如果应用层正在解析、处理某个报文会不会影响操作系统继续从网络中接收新的报文呢为什么这个问题其实挺有意思的。我们知道应用层程序通常运行在用户态而网络数据的接收涉及网卡、驱动程序以及操作系统内核中的网络协议栈。当网卡收到数据时并不需要等应用程序把上一个报文处理完才能继续接收新的网络数据。在现代操作系统中网络数据的接收通常可以借助硬件中断、NAPI轮询以及DMA直接内存访问等机制完成。其中DMA能够让设备与内存之间进行数据传输减少CPU直接参与数据搬运的开销。而操作系统则可以通过中断、软中断以及相关调度机制对收到的数据进行后续处理。也就是说应用程序处理报文和操作系统接收网络数据并不需要严格按照“一方处理完另一方才能开始”的顺序执行。比如应用程序正在解析上一批收到的数据时网卡依然可以继续接收新的网络数据内核也可以将符合条件的报文放入相应的接收队列等待应用程序后续读取。这也是为什么我们之前强调操作系统需要通过sk_buff等数据结构对大量网络报文进行统一管理。不过这里有一个细节需要注意。能够并发处理并不代表应用层的处理速度完全不会影响网络接收。如果应用程序处理数据的速度太慢长时间不读取Socket中的数据那么内核接收缓冲区就可能逐渐被占满。对于UDP来说缓冲区不足可能导致后续到达的数据报被丢弃而对于TCP来说还可能通过接收窗口等流量控制机制限制发送方继续发送数据。所以操作系统可以在应用程序处理数据的同时继续接收网络报文但这种并发能力并不是无限的。说到这里我们就能把之前学习的操作系统知识与网络协议联系起来了。硬件负责接收数据内核负责组织和管理报文应用程序则负责读取并处理数据。三者相互配合才能完成整个网络通信过程。而我们前面学习的sk_buff正是Linux内核管理网络报文的重要数据结构之一。二、TCP报头与字节流深入理解数据交付与报文边界TCP的全称是传输控制协议Transmission Control Protocol。人如其名TCP的核心职责就是对数据的传输过程进行更加精细、可靠的控制。不过在正式分析TCP的报头结构之前我们不妨先换个角度从操作系统的层面重新理解一下 TCP究竟是怎么传输数据的。还记得前面学习文件系统时我们讲过的文件缓冲区吗在Linux中操作系统会通过struct file等数据结构管理打开的文件。当应用程序调用write()向普通文件写入数据时在常见的缓冲I/O场景下数据通常会先进入内核的页缓存而不是每次都立即写入磁盘。其实TCP的数据发送过程也有类似的设计思想。对于TCP来说操作系统同样会维护相应的发送缓冲区和接收缓冲区。假设应用程序想通过TCP向另一台主机发送一段数据那么它就可以调用write()或send()将需要发送的数据交给操作系统。write(sockfd, buffer, strlen(buffer));在常见的发送流程中数据会先被复制到内核的TCP发送缓冲区随后由TCP协议栈根据当前的连接状态、发送窗口等条件决定什么时候发送、发送多少数据。也就是说应用程序调用write()并不意味着数据会立刻通过网卡发送出去更不代表对方已经成功接收。对于阻塞式Socket来说只要发送缓冲区有足够的可用空间数据通常就能较快地交给内核应用程序也就可以继续执行后面的业务逻辑。当然如果发送缓冲区已经满了write()也可能阻塞等待对于非阻塞Socket则可能出现部分写入或者返回EAGAIN等情况。所以我们不能简单地认为TCP的write()永远不会阻塞。不过这里真正值得关注的是应用程序的数据写入操作与 TCP 底层实际的数据发送过程并不要求完全同步进行。我们可以把这个过程理解成一种比较复杂的“缓冲与刷新”机制。应用程序负责把数据交给操作系统而TCP协议栈则负责后续的数据组织、发送、确认以及必要时的重传等工作。这样一来应用程序就不需要每发送一段数据都等待对方确认接收后才能继续执行从而提高整体的通信效率。而在接收端TCP同样会维护接收缓冲区。网络数据到达后内核会完成相应的协议处理再将数据放入接收缓冲区等待应用程序通过read()或recv()读取。不过这里还有一个非常重要的细节TCP 是面向字节流的协议并不会保留应用程序每次write()所对应的消息边界。比如发送端连续调用两次write()并不意味着接收端一定要调用两次read()才能把数据读取完整。接收端可能一次读到两次写入的数据也可能需要多次读取才能获得完整内容。因此如果应用程序需要传输具有明确结构的消息就得自行设计应用层协议完成数据的序列化、报文边界识别与反序列化。到这里我们就能发现TCP的发送与接收并不是简单地把一份数据从A主机搬到B主机而是涉及缓冲区管理、数据组织以及一系列传输控制机制。那么问题来了TCP究竟是如何管理这些数据的它又是怎么保证数据可靠到达对方的呢想要真正理解这些问题我们就得从TCP的报头结构入手逐步分析它背后的设计思想。接下来我们就正式进入TCP协议的核心内容。2.1 TCP报头的两个核心问题如何交付数据如何分离首部与载荷前面我们已经了解了TCP的基本工作方式接下来就正式看看TCP报头究竟长什么样。这里我画了一张 TCP 报文格式的抽象图大家可以结合图中的字段来看一下。其实不管是之前学习的UDP还是现在的TCP网络协议在设计时都需要解决两个非常核心的问题如何分离操作系统收到一个报文后怎么知道哪部分是协议报头哪部分才是真正的有效载荷如何交付分用报文已经到达主机操作系统又该怎么知道应该把数据交给哪个应用程序我们先来看比较容易理解的一个问题。1如何交付还记得前面学习UDP时我们是怎么解决这个问题的吗没错就是通过报头中的目的端口号。TCP同样如此它的报头中也有一个16位的目的端口号字段。当操作系统收到TCP报文后就可以提取目的端口号再结合源 IP、目的 IP、源端口号等信息找到对应的TCP Socket最终让应用程序读取数据。这里需要注意对于已经建立的TCP连接操作系统并不是只靠目的端口号就能唯一确定对应连接的而是需要结合通信双方的地址和端口信息。好了交付的问题解决了。那么报头和有效载荷又该怎么分离呢2如何分离这个问题就稍微有点意思了。我们观察TCP的报文格式会发现其中有一个非常重要的字段叫做4位首部长度Data Offset。顾名思义它就是用来表示TCP报头长度的。但是问题来了。4位二进制能够表示的数值范围只有00001111也就是十进制的015。如果一个数值代表1个字节那TCP报头最多只能有15个字节这显然不够用啊毕竟TCP光是固定的基本报头就已经占用了20个字节。怎么办呢其实TCP 的设计者采用了一个很巧妙的办法不再让首部长度字段以1字节为单位而是以4字节为一个基本单位。也就是说首部长度字段中的数值并不直接表示字节数而是表示报头占用了多少个4字节。这样一来TCP报头的实际长度就可以通过下面这个公式计算TCP 报头长度 首部长度字段的值 × 4 字节比如当首部长度字段的值为5时5 × 4 20 字节这就表示TCP报头一共有20个字节。如果这个字段的值为15那么15 × 4 60 字节此时TCP报头的长度就达到了60字节。不过虽然4位二进制理论上能够表示015但TCP报头不能小于固定的20字节。所以首部长度字段真正有效的取值范围是515而TCP报头的实际长度范围就是2060字节。对应的二进制范围为0101 1111这里还有一个细节。TCP固定报头占用20字节剩下的部分就是可变长度的选项区域Options以及必要的填充Padding。因此我们也可以通过下面这个公式计算选项与填充区域的总长度选项与填充长度 首部长度 × 4 − 20注意要求按照4字节对齐的是TCP报头的总长度并不是整个TCP报文。如果选项本身的长度不是 4 字节的整数倍就可以通过填充字节完成对齐。讲到这里大家应该就能明白TCP为什么需要这个4位的首部长度字段了。3报头与有效载荷的分离过程既然已经知道TCP是怎么表示报头长度的那么分离报头和有效载荷就不难了。整个过程可以这样理解读取固定报头TCP的基本报头至少有20字节因此需要先获取并检查这部分数据。提取首部长度从报头中读取4位首部长度字段假设它的值为x。计算报头总长度通过x × 4得到完整TCP报头的长度并检查其是否有效。计算选项区域长度使用4 × x − 20得到选项和填充部分的总长度。分离有效载荷在确认整个TCP报头完整后跳过相应的报头长度剩下的TCP段数据就是有效载荷。这样一来操作系统就能准确区分TCP报头与真正需要交付给上层的数据。不过首部长度字段只能告诉我们TCP报头有多长并不能直接告诉我们整个TCP报文有多长。实际解析时还需要结合 IP 层提供的数据长度信息确定TCP段的完整范围。说到这里我们就能发现TCP和UDP虽然采用了不同的报头设计但它们解决问题的思路其实是相通的。UDP使用固定的8字节报头而TCP则通过首部长度字段支持2060字节的可变长度报头。说白了UDP靠固定长度区分报头和数据TCP则通过首部长度字段来确定两者的边界。不过TCP能够区分协议报头和有效载荷并不意味着它能够自动区分应用层每次发送的消息边界。毕竟TCP本质上还是一个面向字节流的协议。而这也正是我们后面继续理解TCP时需要牢牢记住的一个重要特点。2.2 为什么TCP报头没有独立的数据长度字段从报文段到字节流的本质前面我们已经知道TCP可以通过报头中的4位首部长度字段准确区分协议报头和有效载荷。不过看到这里可能有人又会产生一个疑问既然TCP报头中只有首部长度没有整个报文的长度那操作系统怎么知道这次应该读取多少数据呢会不会把当前报文的有效载荷和下一个报文的报头混在一起其实这里我们需要区分两个概念TCP报文段的边界和应用层消息的边界。先来看TCP报文段。前面我们已经介绍过Linux内核会通过struct sk_buff来描述和管理网络数据包。每个 sk_buff 都维护着相应的数据长度等信息内核并不是毫无边界地接收和解析数据。不过sk_buff并不一定与一个TCP报文段严格一一对应内核还可能通过GRO、GSO等机制合并或拆分网络数据。那么TCP报头为什么没有专门设计一个报文段长度字段呢原因其实很简单TCP报文段封装在IP数据报中而IP层本身就提供了确定数据长度的信息。比如在普通IPv4数据报中我们可以通过IP首部中的总长度字段计算TCP报文段的长度TCP 报文段长度 IP 总长度 - IP 首部长度 TCP 有效载荷长度 TCP 报文段长度 - TCP 首部长度这样一来TCP就不需要在自己的报头中再额外设计一个字段来重复记录整个报文段的长度了。不过真正值得我们关注的还不是这个问题。为什么TCP不需要记录应用层每一条消息的长度呢这就涉及TCP一个非常重要的设计理念——面向字节流Byte Stream。与UDP不同TCP并不保留应用程序每次发送数据时的消息边界。它主要负责向应用程序提供可靠、有序的字节流传输服务而不是按照应用层的一条条业务消息进行交付。我们可以从两个角度来理解。1不关心这是应用层与传输层之间的职责分工TCP是传输层协议它主要负责连接管理、可靠传输、流量控制以及拥塞控制等工作。至于应用层交付的数据究竟是什么TCP并不关心。比如这些数据可能是一张图片、一段JSON也可能是一个HTTP请求。对TCP来说这些都只是需要传输的字节。至于字节背后代表什么业务含义那是应用层需要考虑的事情。所以TCP不需要专门为应用层设计一个记录业务消息大小的字段。2不理解TCP并不知道应用层消息的边界TCP看到的是连续的字节序列并不知道哪些字节属于一条完整的业务消息。比如应用程序连续发送了两段数据send(sockfd, Hello, 5, 0); send(sockfd, World, 5, 0);那么接收端就一定会先收到Hello再收到World 吗还真不一定注意这里并不是说TCP会把字节顺序打乱而是说接收端每次recv()读取到的数据范围不一定与发送端每次send()的范围相同。接收端可能一次读取到HelloWorld也可能分成多次读取Hel loWo rld但无论怎么读取只要连接正常完成可靠传输最终得到的字节顺序仍然是HelloWorld。这就是TCP面向字节流的特点保证字节流的顺序却不保证应用层消息的边界。那么问题来了如果我们发送的是两条完整的业务报文接收端该怎么判断它们各自从哪里开始、到哪里结束呢这就需要应用层自己制定规则了。比如我们可以通过固定长度、特殊分隔符或者在应用层报头中添加长度字段等方式确定每条消息的边界。而我们平时经常听到的TCP 粘包、半包问题本质上就是应用程序不能直接把一次send()当作一条完整消息也不能把一次recv()当作接收到一条完整消息。说白了TCP负责把字节可靠、有序地送到对方而如何从这些字节中还原出一条条完整的业务消息则需要应用层协议自行解决。所以TCP报头中没有专门记录应用层消息长度的字段并不是协议设计不完善而是因为TCP从一开始就没有打算替应用层管理消息边界。理解了这一点我们也就真正抓住了TCP面向字节流的核心思想。好了到这里本篇关于TCP协议的内容就先告一段落了。这一篇作为我们深入学习TCP的开篇主要从操作系统的角度出发重新认识了网络报文的组织与管理方式。从Linux内核中的sk_buff结构到“先描述再组织”的管理思想再到通过调整data指针完成协议报头的封装与解包相信大家对网络报文在内核中的处理过程已经有了更加清晰的认识。随后我们又正式接触了TCP的报头结构与面向字节流的特性弄清楚了TCP如何通过首部长度字段区分报头与有效载荷以及为什么TCP报头不需要单独记录整个报文段的长度。当然这里面还有一个非常重要的知识点TCP负责可靠、有序地传输字节流但并不负责维护应用层消息的边界。至于一条完整的业务消息应该从哪里开始、到哪里结束还得由应用层协议自行解决。其实回过头来看这些知识点看似零散背后却有着紧密的联系。从报文的内存管理到协议报头的设计再到数据的发送与接收每一种机制都有它存在的原因。而我们学习TCP真正重要的也不是死记硬背几个字段、几条结论而是要搞清楚TCP 为什么要这样设计它究竟解决了哪些问题又是通过什么方式解决的毕竟只有把这些底层原理真正弄明白后面再去理解TCP的可靠传输机制思路才会更加清晰。不过这还只是TCP的开始。接下来我们还会继续深入TCP的其他核心机制包括序列号与确认应答、超时重传、连接管理、滑动窗口等内容。这些不仅是TCP协议的重点也是校招面试中经常出现的高频考点。还是那句话理解原理比死记硬背八股文更重要。当我们能够顺着问题一步步推导出答案时很多看似复杂的知识点其实也就没那么难了。好了本篇文章就先聊到这里。如果文章中有什么理解不到位或者讲错的地方也欢迎大家在评论区指出一起交流、一起进步感谢大家的阅读我们下篇文章再见