ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

协议栈如何发送消息?套接字、管道与TCP分段机制详解

协议栈如何发送消息?套接字、管道与TCP分段机制详解 我手上正好有《网络是怎样连接的》1.4.2 这一节的精读笔记这次聊聊委托协议栈发送消息这段内容。很多人读这本书时最卡壳的地方就是套接字到底是什么协议栈接手之后数据是怎么一步一步被吞下去、变成一个个分组发出去的为什么书上非要强调管道这个词这些其实就是网络通信从抽象到具体的关键一步。先明确一点这一节并不是教你写代码而是帮你建立一个操作系统视角下的通信模型。搞清楚这个模型你后面遇到 TCP 连接异常、Socket 编程卡顿、协议栈超时这类实际问题时才不会被各种报错牵着鼻子走。不管你是刚啃完第一章的新手还是已经在用 Socket 调接口、写网关程序的老手这一节的底层逻辑都值得花半小时认真捋一遍。1. 内容整体设计与思路拆解1.1 为什么这一节要放在委托协议栈这个位置上《网络是怎样连接的》这本书的叙事顺序是从浏览器发起请求开始一路顺着应用层往下钻。到 1.4 节的时候浏览器已经把 HTTP 请求消息生成完毕接下来面临的问题是谁来把这段消息送出去答案就是委托操作系统内部的协议栈。很多人会忽略一个关键点浏览器本身就是个应用程序它没有权限直接操作网卡。所有网络数据的收发都必须通过操作系统内核提供的套接字接口再由内核里的 TCP/IP 协议栈去完成实际的数据封装、拆分、重传和发送。这种应用层不碰硬件的设计是整台计算机能够稳定、安全地运行网络功能的前提。协议栈在这里承担的角色有点像公司里负责物流的部门业务员浏览器把包裹HTTP 消息填好单子交给物流部协议栈物流部决定包裹怎么装箱、走哪条路线、到哪儿中转最后交给快递员网卡送出去。整个过程业务员完全不需要知道包裹是被装进了集装箱还是纸箱子。1.2 管道的概念才是理解这一节的地基书中在解释协议栈如何委托发送消息时用了管道这个词。这个比喻特别关键因为它是理解 TCP面向字节流特性的钥匙。所谓管道就是你和水管那头的人约定好我这边往里面灌水你那边打开水龙头接水。水流本身没有边界不会精确地分成一段一段的。数据也一样当你调用 Socket 的 write 方法往套接字里写入一段数据时协议栈不会把这批数据当作一个整体、一个段来对待而是把它当一串连续的字节流分批切割、装上 TCP 头部、IP 头部、MAC 头部然后交给网卡发送。这和我以前踩过的一个坑直接相关。刚开始写传输层代码时我天真的以为发送一次就接收一次。后来用抓包工具一看发现数据被拆成了好几个小段接收方那边也分好几次才收齐。当时我第一反应是网络是不是坏了后来才明白TCP 本来就是按字节流处理的应用层所谓的消息边界完全需要自己来定义。你发的是一块数据但协议栈眼里只有一串字节。1.3 客户端的三阶段操作就是这本书的调兵遣将抛开教科书里那些繁复的术语应用程序客户端使用协议栈发送消息本质上就三个阶段创建套接字把通信的出入口打开。这一步相当于跟操作系统申请一个文件描述符并指定使用 TCP 还是 UDP。TCP 有连接、可靠、有序UDP 无连接、不可靠、但轻快。绝大多数场景——网页、文件传输、API 调用——走 TCP所以握手和挥手细节几乎都围绕 TCP 展开。连接阶段也就是三次握手。客户端把这个套接字和对端的套接字用一段虚拟的管道接起来。这个管道并不是物理世界一根真实的线缆而是两台主机内核各自维护的一组状态信息。只要两端的操作系统都认为这条连接存在管道就算建立。收发数据。一旦管道建立客户端就可以往里面灌数据了。这一阶段最考究细节因为数据量大时协议栈不是一次性把所有数据塞给网卡而是按照 TCP 的最大报文段长度MSS和窗口大小来动态调整发送节奏这其中的逻辑这一节里写得非常清楚。三个阶段层层递进互相依赖。只有把创建套接字和连接搞明白了后面收发数据中那些缓冲区和拆分逻辑才有落点。2. 核心细节解析与实操要点2.1 套接字到底是什么文件描述符与句柄思维套接字这个词英文叫 Socket。很多初学者一看到这个词就懵其实把它理解成门或者接口是最合适的。当应用程序调用 socket 创建一个套接字时操作系统会返回一个整数这个整数就是文件描述符。这里要强调一个底层逻辑在 Unix/Linux 世界里一切皆文件。网络套接字在 Linux 内部本质上也是一个文件和其他普通文件一样可以用 read、write 这类系统调用来操作。唯一的区别是操作普通文件时数据落在磁盘上操作套接字文件时数据会流经内核协议栈最终跑到网卡上。这个抽象非常巧妙它把网络通信和文件 IO 统一了。你在 Java 里写 Socket 编程时面向流操作很方便在 C 语言里用 read 和 write 处理 Socket也不是巧合是内核设计如此。实操中文件描述符还有一个很实际的影响数量有限。Linux 默认允许单个进程打开的文件描述符数量通常为 1024。如果你的高并发服务器每个连接占用一个套接字那么这个限制很快就会触顶。我记得以前调一个高并发服务压力测试到一定量级日志里疯狂报Too many open files排查了半天才发现是文件描述符的限制。这就是套接字作为文件特性带来的一个经典坑。2.2 套接字的协议选择为什么必须在创建时就定死创建套接字时系统调用通常是这样的socket(AF_INET, SOCK_STREAM, 0)。第一个参数是协议族这里指定 IPv4第二个参数是套接字类型SOCK_STREAM 表示流式套接字对应 TCP第三个参数一般填 0由内核根据前两个参数自动选择默认协议。关键点在于套接字类型和协议是绑定的一旦确定便不可更改。如果你写了 SOCK_STREAM那内核就知道你要用 TCP内部就会准备发送缓冲区、接收缓冲区并启用 TCP 的状态管理机制。如果你写的是 SOCK_DGRAM那就对应 UDP内核便不会去维护连接状态也就没有三次握手、四次挥手这套动作。很多新手在做协议选型时容易想当然。比如做实时性要求高的音视频传输听说 UDP快就直接选了 UDP却忽略了 UDP 的丢包和乱序问题。实际上现在很多成熟方案用的是基于 UDP 的自定义可靠传输比如 QUIC既保留了 UDP 的灵活又自己实现了拥塞控制和重传逻辑。搞清楚套接字层面的 TCP/UDP 差异你后续做技术决策时心里就有谱。2.3 连接操作的三次握手UDP 不需要TCP 必须做书中对连接操作的描述非常重要。它用了一个非常通俗的说法连接操作的本质并不是真的有一条线路被接通而是在通信双方的内核中分别记录下对方的信息并准备相应的资源。这就是很多人理解的盲区。三次握手的具体过程客户端发送一个 SYN 包包里面携带一个初始序列号 ISN用来表示我从这个序号开始发送数据。服务端收到之后如果愿意建立连接就回一个 SYNACK 包表示我收到你的请求了我也准备好接收你的数据我的初始序列号是这个。客户端再回一个 ACK 包表示我也收到你的确认了咱们正式开始。之所以需要三次而不是两次核心在于 TCP 需要确认双方的收发能力都是正常的而且需要同步彼此的初始序列号。两次握手无法防止迟到的旧连接请求对服务端造成资源浪费。实操中三次握手最直观的观察方式就是用 Wireshark 抓包。我在实验室里经常让学生做这个实验开一个简单的 HTTP 服务然后从客户端访问抓包过滤 tcp.flags.syn你会看到完整的握手过程。通过这种可视化观察书上的理论瞬间就立体了。2.4 数据收发阶段缓冲区、MSS 与拆分的三角关系连接一旦建立应用层数据就可以开始传输了。这一部分在书中写得很有层次感也很贴近实际。关键点在于 MTUMaximum Transmission Unit最大传输单元和 MSSMaximum Segment Size最大报文段长度的区分。MTU 是以太网帧的最大载荷长度通常为 1500 字节算上以太网帧头、IP 头、TCP 头之后TCP 实际能承载的数据量就是 MSS典型值是 1460 字节。如果应用层一次性写入 4000 字节协议栈不会傻乎乎的把这 4000 字节塞进一个大段里而是拆成 3 个 TCP 段每个段 1460、1460、剩下 1080 左右分别封装后交给 IP 层。为什么必须这么拆因为底层网络设备路由器、交换机、网卡有 MTU 限制强行发一个大包会被链路层切碎或者直接被丢弃反而降低传输效率。这就像快递公司规定单个包裹不能超过 20 公斤你硬把一个 60 公斤的货物全塞一个箱子里快递员根本没法送。除了拆分成段协议栈还引入了一个滑动窗口机制用来做流量控制和丢包重传的节奏管理。接收方会在 ACK 包里带上自己接收缓冲区剩余的大小即窗口值发送方根据这个值来调整自己能发多少数据。这个机制保证了发送方不会被接收方的处理能力拖垮也正是这套机制保证了 TCP 的可靠性和稳定性。3. 实操过程与核心环节实现3.1 用一段最简单的代码走通创建到发送前面说的都是原理现在把原理落地成代码。我用 Python 写一段最基础的程序完整展示创建套接字 - 连接 - 发送 - 接收 - 关闭的全过程。用 Python 不是因为它是推荐生产语言而是因为这个语言的语法简洁适合读者快速对照原理。import socket # 1. 创建套接字AF_INET 表示 IPv4SOCK_STREAM 表示 TCP client_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 2. 连接三路握手在这里发生 server_addr (192.168.1.100, 8080) client_socket.connect(server_addr) # 3. 发送数据往“管道”里灌字节流 message Hello, TCP! client_socket.send(message.encode(utf-8)) # 4. 接收响应 response client_socket.recv(1024) print(Received:, response.decode(utf-8)) # 5. 关闭套接字 client_socket.close()这段代码虽然短但几乎每一行都在对应书里的知识点。socket 函数创建套接字connect 触发三次握手send 之后数据并不是立即上网线而是先进内核缓冲区由协议栈决定什么时候切成 MSS 大小、什么时候真正通过网卡发送。recv 则阻塞等待服务端的数据返回来。close 关闭时会触发 TCP 的四次挥手。3.2 用 Wireshark 观察管道的建立过程很多人读这一节时对三次握手总是忘得很快因为纯文字太抽象了。我强烈建议你亲手抓一次包看着屏幕上出现 SYN、SYNACK、ACK 三个包这个知识点你就彻底忘不掉了。实操步骤打开 Wireshark选择电脑连接网络的网卡设置过滤条件为 tcp.port 8080。运行上面那段 Python 代码。停止抓包观察前三个 TCP 报文。你会在 Wireshark 的摘要列看到如下信息。第一个包本地端口 - 服务器端口Flags 字段显示 SYNSeq 是一个随机生成的初始序号。第二个包服务器端口 - 本地端口Flags 字段显示 SYNACK同时带有服务器的 Seq 和自己的 Ack。第三个包本地端口 - 服务器端口Flags 字段显示 ACK里面带着确认号。整个交互一秒钟都不到但你在包列表里能看到非常清晰的三次交互。这就是管道建立过程的真实样子没有一条实际的线缆被接通只是两台主机通过这三个报文在内核里建立了连接状态。3.3 发送大文件时观察 MSS 的拆分效果之前提到过应用层一次写入大块数据协议栈会按照 MSS 拆分成多个段。这个现象也可以在抓包过程中直接观察到。比如你用 socket 发送一段 5000 字节的数据然后看 Wireshark 的包列表会发现有好几个 TCP 分段每个分段载荷大约 1460 字节最后一个分段包含剩余的小尾巴。我当初第一次做这个实验时特别惊讶地发现除了数据段包与包之间还会有大量的 ACK 包。这是因为 TCP 的可靠性要求接收方每收到一段数据就要回一个确认包。收到 3 段数据接收方就会把 ACK 打包到后续的反馈中延迟确认机制下可以不立即回复。这个现象也是后来排查网络慢发小包多这类问题的观察入口。3.4 管道的双向性发送和接收是同时进行的书中还有一个容易被忽略的点但实际在排查问题时非常关键TCP 连接是全双工的管道并不是单向的。客户端可以同时发送和接收服务端也一样。这意味着连接建立以后任何一端都可以随时发起数据发送不需要等另一端先发完。这一点在实际业务开发里的影响很大。比如在长连接通信中客户端和服务器需要同时维护一个读线程和一个写线程。读线程负责收数据写线程负责发数据。很多人第一次写长连接程序时总是在一个线程里先 recv 再 send结果遇到服务端主动推送消息时客户端收不到——因为它的线程正堵在 send 上。这其实就是不理解全双工导致的。正确的做法是接收必须独立出来不能让发送动作阻塞了接收动作。4. 常见问题与排查技巧实录4.1 连接建立了但 send 出去的数据 丢了现象程序调用 send 成功数据也没有报错但对端就是收不到。排查思路send 成功只能说明数据已经进入内核发送缓冲区完全不等于已经到达对端。缓冲区里堆积的数据会由协议栈后续慢慢发送。若网络拥堵数据可能长时间滞留在缓冲区。我遇到过最典型的情况是发送端一次性塞了很多小数据包但是接收端迟迟没有调用 recv于是 TCP 的接收窗口逐渐变小最终变成 0发送方的数据就全堵在缓冲区里了。看起来像是丢了其实只是停在那里等待窗口恢复。排查建议结合 Wireshark 抓包看看是否有 TCP Window Full 或者 Zero Window 的标记。如果有基本可以确认是应用层消费数据的速度赶不上接收速度。解决办法是确保接收方持续、及时地读取数据而不是排查发送方。4.2 端口明明没被占为什么 bind 报错现象程序重启提示 Socket error错误信息类似通常每个套接字地址协议/网络地址/端口只允许使用一次。排查思路这个报错是 Windows 平台特有出现在 bind 时。它通常是因为上一条连接的 TCP 连接还没有完全关闭属于 TIME_WAIT 状态。TIME_WAIT 是 TCP 四次挥手结束后主动关闭方进入的一种状态会持续大约 2 个最大报文段生存时间MSL在 Windows 上一般是 120 秒。如果服务器程序频繁重启或者大量短连接被主动关闭会在短时间内积累很多 TIME_WAIT 状态的套接字导致端口无法立刻复用。解决办法有几个一是让程序使用 SO_REUSEADDR 套接字选项。二是在设计协议时尽量使用长连接而不是频繁创建短连接。三是如果是压测环境可以调整系统 TCP 参数缩短 TIME_WAIT 时长但生产环境不建议随意改。用代码设置套接字选项的方法也很简单client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)Linux 下同样有效不过在 Linux 上报错信息一般更直接就是 Address already in use。4.3 套接字操作超时Linux 下的经典疑难杂症现象在 Linux 上跑网络程序偶尔会遇到 Socket operation timed out 或者 Connection timed out 这样的错误。排查思路这个超时和执行一个系统调用卡住再返回超时不同它有多个方向。比如 connect 超时常见原因是目标 IP 地址根本不可达比如对方主机在防火墙后面SYN 包被丢弃。比如 send 超时常见原因是发送缓冲区满且 TCP 重传多次后失败。也可以给套接字设置超时时间避免某个阶段一直卡住导致线程被白白占住。# 连接超时 5 秒 client_socket.settimeout(5)这种设超时的思路在实际工程里很重要。网络环境不可能永远理想不能指望一次 connect 就立刻成功必须有超时和重试机制。我在写网关程序时所有对外连接都会设置超时并配合断线重连逻辑。这也是这个报错名字频繁出现在热搜词里的原因众多做打印机、嵌入式设备联网开发的人都在踩这个坑。4.4 协议栈选择错LwIP 和 CANopen 的连带问题热搜词里有 stm32 网关 lwip 协议栈还有使用 can 时要移植 canopen 协议栈吗。可见不少做嵌入式开发的读者也因为协议栈的概念踩过坑。简单说LwIP 是嵌入式环境里常用的轻量级 TCP/IP 协议栈它在单片机上实现 TCP/IP 协议族。CANopen 是基于 CAN 总线的高层应用协议解决的是设备之间如何互相理解数据含义的问题。两者服务的网络层级完全不同。两者不需要互相移植。如果你做的是一个 STM32 网关一边接 CAN 总线一边接以太网那么你这颗芯片里可能同时需要 LwIP处理以太网和 TCP/IP也需要 CANopen处理 CAN 应用层。但它们是独立模块只是在一个系统里共存绝不是移植 CANopen 到 LwIP这种操作。理解协议栈是分层的这个思想对排查嵌入式网络问题很有帮助。很多嵌入式开发者看到数据发不出去第一反应是去查硬件电路实际上往往是以太网协商、LwIP 内存池配置或者 CANopen 对象字典同步出了问题。4.5 用问题速查表秒定位常见故障结合前面的分析我把常见的套接字问题整理成一张速查表方便你在排查时快速对照。故障现象可能原因优先排查方向connect 超时目标不可达、防火墙拦截 SYN先 ping 测试连通性再看过滤规则发送成功但对方收不到接收窗口为 0、数据堵在缓冲区看是否接收端消费太慢抓包确认窗口字段bind 报端口被占用TIME_WAIT 残留、端口被其他进程使用改用 SO_REUSEADDR或查看端口占用情况客户端 recv 一直阻塞服务端没发送数据、连接半关闭检查服务端逻辑确认是否调用过 shutdown 或 close断线后重连失败TCP 状态未清理、不允许地址重用设置 SO_REUSEADDR或适当增大重连等待时间这张表不算完整但对于新手排查 90% 的常见网络编程问题已经够用了。理论上排查网络问题有一个基本铁律先分层再从底往上或从上往下逐层验证。链路层通不通看 ping 和 ARP网络层通不通看 IP 连通性传输层通不通看端口和协议状态应用层通不通看业务数据和日志。不要一上来就怀疑应用代码也不要一上来就怀疑网线按顺序排查反而是最快的。5. 从《网络是怎样连接的》看协议栈设计的精髓5.1 分层思想为什么协议栈不是一块铁板很多人读完这一节记住了套接字缓冲区分拆MSS这些名词但容易忽略一个更底层的设计思想网络协议是分层的。每一层只关心自己的职责层与层之间通过固定的接口协作。TCP 层负责可靠传输和流量控制它不关心上层传过来的是 HTTP 报文还是 SSH 会话它只管把字节流拆分成段确保按序到达。IP 层负责寻址和路由它不关心 TCP 的序号和窗口只管把包送到目标 IP。以太网驱动与网卡层负责实际的电平信号把帧转换为比特流发到网线上。这套分层设计的直接好处是可替换性。LwIP 可以在 MCU 上实现 TCP/IPLinux 自带的内核协议栈也实现 TCP/IP但它们对上层的接口几乎一样。你写的代码不太需要了解底层用的是哪种协议栈实现只要按照套接字接口来调用理论上就能在不同系统间移植。5.2 协议栈的缓冲哲学一切都是为了性能与可靠重读这一节你会发现缓冲区这个词出现的频率极高。发送缓冲区、接收缓冲区、滑动窗口、重传队列全部围绕着缓冲两个字运转。为什么需要缓冲因为应用层产生数据的速度和网卡发送数据的速度往往不在一个节拍上。如果没有缓冲区数据生成了就必须立刻发网卡一忙就丢系统一堵就崩。有了缓冲区应用层只管写协议栈根据网络状况动态调整发送节奏接收方缓冲区则平滑掉网络抖动带来的突发流量。套接字缓冲区大小太大会导致内存浪费太小会导致吞吐量上不去。实际调优时如果网络环境中存在大量长肥管道适当调大缓冲区往往能显著提升吞吐量。在 Linux 上可以用 setsockopt 调整 SO_SNDBUF 和 SO_RCVBUF但需要内核参数配合。除非遇到吞吐量瓶颈否则保持默认值通常更安全。5.3 错误处理网络编程和普通文件 IO 的最大区别最后这一点是这本书里比较隐晦但在工程中极为重要的网络 IO 和文件 IO 的错误处理完全不是一个量级。写文件时磁盘故障概率相对低大多数错误是参数错误或权限问题。但网络传输不一样路由闪断、对端崩溃、连接超时、数据包丢失、半包粘包这些都是常态。所以使用协议栈时你必须给每个可能阻塞的系统调用设置超时必须考虑 connect 失败后如何重试必须处理 send 时缓冲区不够导致的返回 0 或异常必须处理接收时出现半包或者多个包拼在一起的情况。这些话题很多初学者觉得太繁琐实际上这才是网络编程的核心。协议栈把数据从 A 搬到 B过程中的各种异常几乎全部要靠应用层去兜底。把 1.4.2 这一节读透再配合实际项目中的踩坑与排查自己对网络是怎么连接这件事的认知就不再只是停留在词汇表里而是真正变成可用的工程直觉。
返回列表