ARTICLE DETAIL

资讯详情

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

数据收发全流程拆解:从Socket到网络包的协议栈之旅

数据收发全流程拆解:从Socket到网络包的协议栈之旅 打开浏览器输入网址按下回车页面出来了。就这几秒你发出去的数据已经在网络里跑了个来回。很多人觉得这个过程理所当然真让拆开讲能讲清楚的人并不多。《网络是怎样连接的》是本很好的书它把这种“理所当然”一点点掰开揉碎。这次我精读的是1.4.1节“数据收发操作的整体流程”这一节是整个网络原理的分水岭前面讲浏览器怎么生成HTTP请求消息从这一节开始讲的是真正的数据收发操作——消息怎么离开你的电脑怎么被当作一个“网络包”送出去。适合刚把网络分层概念背下来但还串不成一条线的同学也适合写了几年代码但对socket调用背后发生了什么一知半解的开发者。这篇文章会把协议栈的工作流程、socket的生命周期、抓包验证和问题排查一次性讲通。1. 这一节到底在讲什么从请求消息到网络包的交接1.1 浏览器交出控制权之后谁在干活浏览器生成了HTTP请求消息之后它的事情暂时就做完了。接下来接管的是操作系统内部的协议栈。协议栈不是单独的硬件也不是一个进程它是内核里的一组网络处理程序的统称你平时听说的TCP、UDP、IP都在这一层实现。应用程序怎么把消息交给协议栈靠的是Socket库。写C语言的调socket()、connect()、send()、recv()写Python的用socket模块底层都是同一套东西。浏览器也是这么干的它在代码里调用了Socket库的一组函数消息就从应用层落到了内核的缓冲区里。这里有个关键点浏览器和协议栈之间不是“直接递东西”而是通过一种叫描述符的编号来对接描述符背后才是真正的套接字对象。我见过不少初学者把“数据收发”理解成“网卡把数据发出去”。这是对了一半。在数据真正触网之前还有一大段发生在内核里的操作流程——套接字的创建、连接状态的建立、数据的拆分和封装——这些才是1.4.1节的真正主角。网卡只是最后那个把电信号送出去的执行者。1.2 协议栈四层分工TCP、UDP、IP、网卡驱动协议栈内部不是一大坨代码它有明确的分工。可以记成四层TCP模块、UDP模块、IP模块、网卡驱动模块。四者的职责差异很大理解了它们各自管什么再看整体流程就清楚了。TCP模块负责建立和断开连接、发送和接收数据、确认与重传它面向的是“连接型”的可靠传输。UDP模块就简单得多它不建立连接、不确认、不重传就是把数据丢出去适合DNS查询、音视频通话这类允许少量丢失的场景。IP模块负责把TCP或UDP交下来的内容加工成能在网络上传输的包给它配上源地址、目的地址和分片信息还要跟网卡驱动沟通。网卡驱动最底层它把IP模块打包好的二进制数据转成电信号或者光信号从物理线上发出去。分工有个很直白的类比TCP像是快递公司里负责下单、揽收、签收确认的客服IP像是干线运输的调度网卡驱动则是那个开着车把包裹送到站点的司机。你关心的是包裹最终能不能到但每次寄件这几层全都得跑一遍。1.3 为什么说套接字是整个流程的中枢前面提到描述符真正要紧的是背后的套接字Socket。套接字在收发流程里的位置相当于办公室的工位你在这个工位上干活别人要找你得知道你在哪个工位。套接字里保存着通信必备的信息对方的IP地址和端口号本机的IP地址和端口号数据收发缓冲区的位置当前处于什么状态连接中、已连接、关闭中这些信息不是零散记在各个进程里的而是集中放在协议栈分配的一块内存结构里。我们常听到的“一个TCP连接”本质上就是通信双方各有一个这样的套接字彼此记录了对端的信息并且状态保持同步。你可以把套接字理解成银行里开的账户创建套接字等于开户拿到一个账号connect()等于告诉银行你要转账给谁send()等于填汇款单close()等于销户。后面要讲的整个数据收发操作流程就是围绕这个账户的“开户—转账—销户”过程展开的。2. 数据收发的四个阶段socket生命周期拆解2.1 创建套接字收发数据的“出入口”套接字的创建由socket()完成。这一步干的事情是协议栈在内核里分配一块内存给这个套接字初始化收发缓冲区实际上缓冲区是发多少分配多少但套接字结构一开始就存在了然后返回一个描述符给应用程序。从这一刻起应用手里这个编号就可以用来指代这条未来的通信连接。创建的时候还要传一个参数指定是走TCP还是UDP。这个选择不是随意的取决于应用的需求。比如HTTP要可靠地拿到网页那就选TCPSocket库会给出流式套接字SOCK_STREAMDNS查询只有一问一答选UDP更快对应数据报套接字SOCK_DGRAM。我贴一段最朴素的Python代码对照着看创建这一步import socket # 创建套接字AF_INET表示IPv4SOCK_STREAM表示走TCP sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) print(socket created, fd , sock.fileno())运行之后你会看到一个数字这就是描述符。协议栈那边的套接字对象你现在看不见摸不着但它已经在内存里待命了。2.2 连接阶段三次握手到底在确认什么接下来调用connect()传入服务器的IP和端口。connect()触发的是一次著名的三次握手。为什么要这么麻烦因为TCP要保证“你那边真的有人在听”同时双方还要协商一些参数。握手的三次消息分别是SYN、SYNACK、ACK。第一次客户端发SYN内容里带着一个随机生成的初始序号ISN。第二次服务器回SYNACKSYN表示“我收到了我也在准备连接”ACK是对客户端序号的确认客户端序号1“1”的意思是“我已经准备好接收从下一个字节开始的数据”。同时服务器在SYN里带上自己的初始序号因为它也要发数据也需要自己的编号起点。第三次客户端回ACK确认服务器的序号。到此双方都知道“对方在而且序号起点对齐了”连接建立。这个过程有个极容易忽略的细节MSS协商。它是放在SYN消息的选项字段里带过去的。双方各自声明自己这边能接收的最大单段数据长度实际传输时取较小的那个值。这个值跟你电脑的MTU最大传输单元有关以太网普遍是1500字节去掉IP头20字节、TCP头20字节之后MSS通常就是1460字节。也就是说一次最多发1460字节的“货物”多了就得拆。作为对照如果你在服务器上抓包会看到客户端先发来一个SYN包然后服务器回一个SYNACK然后客户端再补一个ACK。我后面第3节会实际操作一遍先留个印象。2.3 收发阶段分段、封装与确认连接建立后应用调用send()把数据交给协议栈。这里开始进入1.4.1节最精彩的部分。数据到了协议栈手上不是“原样打包送走”的。协议栈要做三件大事分段、封装、交给IP模块。分段把数据按MSS大小切割。一个大文件被切成若干段每段不超过1460字节。封装给每个段加上TCP头。TCP头里最核心的是序号、ACK号、确认号、窗口大小。序号表示“这段数据是从我发送的哪个字节开始的”ACK号表示“我已经正确收到你的哪个字节之前的所有数据”。交给IP模块IP模块给每个TCP段加上IP头和MAC头。IP头带源IP、目的IP和协议编号6表示TCP17表示UDP。MAC头带源MAC地址和下一跳设备的MAC地址。这一层封装完才是一个真正能上网的“包”。这里有一个很多人没想通的问题为什么需要IP和MAC两层地址简单说IP地址用于在互联网范围找到最终目的地MAC地址用于在每段物理链路内找到下一跳。包从一个设备转到下一个设备MAC头会不断更换IP头则一直不变直到终点。接收方走的是逆流程。网卡收到包网卡驱动把包传给IP模块IP模块检查IP头里的目的IP是不是本机是的话拆掉IP头把TCP段交给TCP模块。TCP模块检查TCP头里的端口号和序号确认数据完整后把数据按顺序放进接收缓冲区然后给发送方回一个ACK表示“收到了下一个字节是几号”。这个ACK至关重要发送方如果没收到会在超时后重发这个段。你看所谓“可靠传输”是靠这个“收到并确认”的循环撑起来的。2.4 断开阶段四次挥手与资源释放数据收发完成后需要关闭连接。调用close()协议栈开始四次挥手。为什么是四次因为TCP连接是全双工的意味着两边都能主动说话所以每个方向都要单独关闭。第一种FIN表示“我不再发数据了”对端回ACK确认对端也会发一个FIN表示“我的数据也发完了”你再回ACK连接正式终止。四次挥手里有个教科书上必考、现实中常踩坑的点TIME_WAIT。主动关闭方在发完最后一次ACK后不会立刻扔掉套接字而是要等大约2倍的MSL时间Linux下默认约60秒。这么做的原因有两个一是怕自己最后这个ACK丢了对方会重发FIN你需要留着套接字再回一次二是如果立即关闭、又马上用同一个端口建立新连接网络里残留的旧数据包可能会被当成新连接的数据造成数据错乱。很多做服务器的人都被TIME_WAIT折磨过。大量短连接关闭后会积压一堆TIME_WAIT状态的套接字占着本地端口不释放新连接反而没端口可用。这里面有优化手段我会在第4节细讲。这里先明确TIME_WAIT不是bug是保证可靠性的代价。3. 实操验证让协议栈的每一步都现出原形3.1 用netstat观察套接字的一生书看完不验证是空谈。我一个很有效的实操办法netstat观察socket状态。在终端跑下面的命令netstat -nat | head -20其中-n表示不反解域名显示数字地址-a显示所有连接和监听端口-t只显示TCP。你看到的输出大致长这样Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 192.168.1.101:54321 93.184.216.34:443 ESTABLISHED tcp 0 0 127.0.0.1:8080 127.0.0.1:54322 ESTABLISHED tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTENLocal Address是本地IP和端口Foreign Address是远端IP和端口State是当前套接字状态。对照书里的四个阶段你可以做一个小实验先用Python起一个永不退出的连接然后在这段连接建立前后分别跑netstat就能看到状态从SYN_SENT变成ESTABLISHED等程序结束再看状态变成TIME_WAIT通常是主动关闭方或者CLOSE_WAIT被动关闭方没关。这一轮下来“套接字状态”这个抽象概念就直接长在你脑子里了。3.2 Wireshark抓包看三次握手与HTTP请求netstat只能看到现象想看协议栈内部到底发了什么得上Wireshark。我个人建议学网络抓包是绕不开的实操环节。起一个本地HTTP服务来制造流量这里用Python一行命令python3 -m http.server 8000然后打开浏览器访问 http://127.0.0.1:8000/ 。Wireshark上选择回环接口lo过滤框里输入tcp.port 8000。你会看到一串TCP包按顺序点开第一个包Flags里有SYN说明是握手第一步。第二个包Flags里SYNACK都亮了是第二步。点开TCP options里的MSS字段能看到本机声明的MSS值比如1460。第三个包只有ACK握手完成。接着是HTTP GET请求和响应数据。请求通常很小一个段就发完了响应如果是大页面会被切成好几个TCP段每段的长度都顶着MSS上限。这一步做完“TCP头”“MSS”“分段”这些词就从概念变成能亲眼看见的东西了。我特别建议你把抓包文件多留几份后面排查问题会经常用到同样的套路先看建立连接的三步再看数据段的序号和ACK号最后看断开连接的四步。3.3 从抓包结果看MSS协商与分段说到MSS协商抓包是最直接的验证方式。Wireshark里点开任意一个SYN包展开“Transmission Control Protocol”字段再展开“Options”你会找到一个MSS选项显示值是多少。写这篇文章时我本地环境是1460这是以太网1500 MTU算出来的常规结果。再验证分段行为我建议抓一次大文件下载的包。在本地HTTP服务里放一个10 MB的测试文件浏览器访问后下载过滤条件不变你会看到成串的数据包每个包长度要么正好是1460的倍数要么在尾部出现一个不足1460的短包。这些连续包里的TCP序号是严格递增的后一个包的seq 前一个包的seq 前一个包的payload长度。一条公式足以把“分段-编号-重组”的机制串起来了。有个干扰项需要注意Wireshark显示的包长度Length跟MSS不是一回事。Length是包含IP头和TCP头的整个包的长度通常是1514字节以太网帧头14字节 IP头20字节 TCP头20字节 数据1460字节。别把Length直接当成MSS去理解两者差着固定的头部开销。4. 常见问题与排查技巧实录4.1 描述符耗尽套接字也怕“取号太多”一个进程能持有的描述符数量有上限系统默认通常比较保守。你可以看自己的上限ulimit -n我见过不少线上故障就是程序里每来一个请求就socket()一次处理完忘了close()时间一长描述符耗尽后面所有socket()直接失败报错“Too many open files”。这不仅是代码bug更是对套接字生命周期不熟悉导致的。排查思路很固定用lsof -p 进程号 | wc -l看进程持有的描述符数量用ss -s看系统当前整体连接数。如果数量一直不降那基本是套接字没释放。代码层面能做的就是勤close()、用完放finally里、尽量用连接池。从协议栈层面看每个未释放的套接字都占着内核内存所谓“连接泄漏”泄漏的就是这些结构。4.2 TIME_WAIT积压主动断开的一方为什么“赖着不走”服务器端因为主动断开连接而积压TIME_WAIT是高频问题。前面讲了TIME_WAIT的作用这里讲实操应对。首先绝大多数情况下不需要处理默认几十秒后自然消失。但如果写入量大、每秒创建大量短连接的服务TIME_WAIT会积到几千个占用大量本地端口默认范围通常只有几万个端口最终表现为“cannot assign requested address”。常见的做法有这几类打开Linux的tcp_tw_reusesysctl net.ipv4.tcp_tw_reuse1它允许客户端复用处于TIME_WAIT状态下的端口发起新连接。注意这个选项只对主动连接方有效服务器开启收益不大。应用层改造用长连接替代短连接减少无谓的断开。设置net.ipv4.tcp_fin_timeout缩短TIME_WAIT的等待时间。这个要谨慎改太短会有数据错乱风险不是一个全局通用的优化手段。注意任何对TCP行为的调整最好在严格测试后才能上线。TIME_WAIT是可靠性设计的一部分擅自缩短等于人为削弱TCP的兜底能力。我在生产环境踩过一次把fin_timeout改成5秒短时间看不出问题后来在高丢包链路上出现连接数据异常才意识到是这个设置惹的祸。4.3 能Ping通但打不开网页从协议栈分工找原因这大概是网络排查里最常见的诡异现象。理解了协议栈分工原因就很好猜Ping走的是ICMP协议它直接由IP模块处理根本不需要TCP握手也不需要什么端口而网页走的是TCP的80/443端口必须经过connect()的三次握手。所以“能Ping通”只能说明网络链路和IP层的转发是通的不能说明TCP端口是通的。排查下一步就是测端口telnet ip 80或者在Linux上跑nc -vz ip 80。如果卡住说明目标机器的防火墙或者中间网络设备把TCP的连接请求过滤了。如果telnet能通但网页还是打不开再查HTTP层的状态码、代理设置、TLS握手。这里的分层排查法本质就是顺着协议栈的流程一层一层确认谁没干活。实践中我还遇到过更隐蔽的情况能Ping通IP但Ping不通域名。这说明IP层没问题问题出在DNS解析也就是应用把域名交给协议栈之前先要查DNS。DNS是UDP协议走的是另一条路UDP模块跟TCP完全无关。遇到“打不开网页”的问题我会习惯性地先做三连测ping IP、ping 域名、telnet 端口。这三步分别卡在IP层、DNS层、TCP层一轮下来基本能定位方向。4.4 数据顺序不乱序号机制的兜底能力最后聊聊数据顺序问题。应用层一次调用send()发了10 KB数据对端可能分8个段才收全接收方能拼出原始顺序靠的是TCP头的序号。这不只是一个原理细节更是排查“收到的数据错乱”时的第一怀疑对象。排查时建议回到抓包层面。抓完整的收发链路检查每个包的seq值是否连续ACK号是否会倒退回旧值。如果发现seq重复说明发送方重传了如果发现接收方连续回重复ACK通常3个说明中间丢了包接收方在催促重传。这两个是排查数据乱序、重复、延迟问题时的核心线索。我自己用过一个非常笨但有效的办法在应用层的数据里加一个自增序号字段收到数据后先检查序号连续性。这样能从应用层快速判断是否发生了乱序或重复如果应用层序号断裂再回到抓包链路找原因。这个习惯帮我避免了好几次在不确定是不是网络问题时瞎调代码的弯路。写在最后的一个实际体会读完《网络是怎样连接的》1.4.1这一节我最大的收获不是记住了TCP三次握手有几步而是建立了一张“全链路图”应用数据怎么进套接字、怎么被TCP分段、怎么被IP封装、怎么被网卡发出去对方又是怎么一层层拆开收下来的。没有这张图netstat输出里的状态、Wireshark抓包里的时序、排查问题时的分层思路全都是孤立的知识点。有了这张图你看到的每一个现象都能落到具体环节上。精读网络原理这类的书最忌讳走马观花最好的读法是每看完一节就抓包验证一遍把书里的流程变成你亲眼见过的操作再带着经验去读下一节后面1.4.2和1.4.3的细节才真正接得住。
返回列表