程序执行 sendall(b"network-00"),没有异常,随后打印“发送成功”。此时能否确定服务端已经读取这十个字节?能否确定它已经执行请求,甚至把结果写入磁盘?

这几个问题需要不同的观察点。发送接口返回、对端传输层接收、对端应用读取、业务处理完成,分别对应请求路径上的不同事件。即使它们在一次运行中相隔很短,也不能用前一个事件代替后一个事件的证据。

本篇从一个只监听 127.0.0.1 的回显服务开始。客户端发送固定十字节,服务端读取后原样返回,客户端再逐字节核对。实验只依赖 Python 标准库,不需要真实账号、外部网站或网络管理权限。前置知识是基本程序、整数、字节数组和函数调用;二进制、网络字节序及并发顺序在后文自测。

从监听端口到一条连接

IP 地址定位通信端点所在的网络接口或主机语境,端口进一步区分传输层端点。实验中的 127.0.0.1 是 IPv4 回环地址,连接在本机完成。客户端和服务端可以运行在不同进程里,也可以像附件一样处于同一进程的不同线程。把两者放入一个进程只是方便启动、同步和清理,并没有把 socket 交换替换成函数直接返回。

服务端先创建 socket,调用 bind 绑定回环地址与端口,再调用 listen,最后通过 accept 得到已经建立连接的 socket。监听 socket 与接受连接后得到的 socket 分工不同:前者等待新连接,后者参与这条连接的数据交换。附件使用端口 0,让操作系统分配空闲端口,避免读者碰巧已有服务占用某个固定端口。

客户端创建自己的 socket,调用 connect 指定服务端地址和端口。成功连接后,这条 TCP 连接包含本地与远端的 IP、端口及各自的协议状态。端口号不是进程永久不变的身份证;进程退出、端口重用和连接生命周期需要分别处理。TCP 状态与关闭行为将在第 16 篇展开。

RFC 9293 §2.2规定的服务是可靠、有序的字节流。应用向流写入字节,对端应用从流读取字节。一次写入不意味着对端恰好用一次读取收到相同长度;TCP 段的划分也不构成应用消息边界。附件用已知的十字节长度结束读取,变长消息和长度前缀留到第 02 篇。

返回发生在哪一层

一条跨主机请求的抽象路径可以画成下面的形式。图中的缓冲和处理位置用于说明责任,不能当作某个内核版本的数据结构布局。

1
2
3
4
5
6
7
8
客户端应用     客户端 socket / TCP      网络路径      服务端 TCP / socket     服务端应用
| 提交字节 | | | |
|------------------>| | | |
| sendall 返回 |---- 传输字节 ---->|---------------->| |
| |<--------- 传输确认 ----------------| |
| | | |--- recv 返回 ----->|
| | | | | 处理请求
|<---------------------------- 应用回复 --------------------------------------|

这不是严格报文时序图。sendall 返回相对某个 ACK 的先后次序并未由图规定;实现、缓冲状态和调度都会影响观测。实验的回环路径也不经过图中通常意义上的物理网络,不能由它推导交换机或路由器的转发行为。

Python sendall 文档说明,它会持续发送直到全部完成或发生错误,成功返回 None。这里的完成是发送接口的完成,不包含服务端应用读取、业务处理或持久化承诺。调用者也不能把异常解释为“一字节都没有发出去”:异常发生时,接口没有提供此次总共成功发送多少字节的返回值。

服务端 TCP 可以先接收数据并保存在接收缓冲中,应用稍后才调用 recv。因此,“发送返回”和“应用读取”之间可以存在一段时间,期间客户端仅凭发送返回无法得知服务端的处理进度。暂停读取实验用一个事件屏障主动制造这段间隔,不依赖某次运行的偶然线程调度。

TCP 确认属于传输层。即使额外抓到了 ACK,也需要把确认的字节区间与这次连接对应起来,不能据此断言业务已经执行。相反,应用回复的含义由应用协议定义:回显协议返回原字节,只能支持这个回显操作完成;如果需要证明某项业务持久化,协议必须定义持久化之后才能返回的结果,并且实验还需观察持久化状态。

三个场景怎样构成对照

附件提供独立的 refusedpausedecho 场景,也支持一次运行全部场景。所有网络地址都在回环范围,消息固定为十字节合成数据;socket 操作与线程等待都设置了五秒超时,运行结束由上下文管理器关闭 socket。

1
python3 source/_posts/2026-09-19-计算机网络00-一次请求的路径与先修自测/experiment.py --case all

从仓库根目录执行这条命令。下载单文件后也可运行 python3 experiment.py --case all。使用 --help 查看选项,使用 --case paused 单独检查发送返回与读取之间的关系。脚本不修改路由、防火墙、系统证书或其他服务。

实验程序:experiment.py。本次原始输出:run.jsonl。输出每行是一个 JSON 对象,event 标识事件,detail 记录字节数、字节内容或差异,t_ns 来自单调时钟。

没有监听者时

refused 场景绑定一个回环端口,但故意不调用 listen。保留绑定可以防止实验期间其他程序重新占用端口。客户端随后连接这个地址,预期观察连接拒绝;如果实际得到超时,就记录环境差异,不能把超时改写成拒绝。

本次 Python 3.14.4、Darwin 27.0.0、arm64 环境下,实际输出为:

1
2
refused.connect_begin
refused.environment_gap: timeout instead of connection refusal

五秒等待结束后,程序记录了 TimeoutError 对应的缺口。此次运行没有复现 ConnectionRefusedError。没有包级证据,不能判断超时由本机过滤、内核行为还是其他因素造成;也不能由这个结果推导公网目标的行为。复跑时如果出现 refused.connect_error 且详情为 ConnectionRefusedError,才算在该环境观察到拒绝结果。

这里的判断边界很具体:连接没有成功完成,所以没有进行这条连接上的十字节回显。但连接失败不等于目标机器关机,也不等于域名解析失败。本实验直接使用 IP,根本没有域名解析步骤;失败发生在连接阶段。

建立连接后暂停读取

服务端完成 accept 后,停在 read_allowed.wait(...),此时还没有进入 recv。客户端等到服务端已经接受连接,再发送十字节。发送返回后检查 app_read 事件仍未设置,记录 paused.reader_still_gated,最后释放读取屏障。

这条同步关系比“先睡半秒再读取”更适合作为反例。睡眠长度受调度影响,日志可能只是在一次执行中碰巧按预期排列;事件屏障把读取许可明确放在发送返回之后。若小写入未能在超时内完成,脚本会失败,而不会伪造预期事件。

本次原始日志包含以下顺序:

1
2
3
4
5
6
paused.connect_returned
paused.server_accepted
paused.sendall_returned: 10
paused.reader_still_gated
paused.server_read: 6e6574776f726b2d3030
paused.reply_verified: 6e6574776f726b2d3030

十六进制 6e6574776f726b2d3030network-00 的字节表示。服务端读取发生在释放屏障之后,所以这次运行确实构造出了“发送返回,应用尚未读取”的状态。它只证明这个十字节、本机、当前缓冲条件下的反例,不能推出任意大小的写入都不会阻塞。发送大量数据直到缓冲耗尽,会引入接收窗口与背压问题,属于第 18 篇。

读取并收到回显

echo 场景从开始就允许服务端读取。服务端用循环收齐十字节,再把这些字节发回;客户端也循环收齐十字节,并检查 reply == PAYLOAD。本次这条断言通过,日志记录了 echo.reply_verified

循环不能省略。recv(bufsize)至多返回指定数量的字节,不保证每次正好填满。对本实验这样参数大于零的 TCP 读取,返回 b'' 表示读到了流结束;如果十字节尚未收齐,脚本抛出 EOFError。暂时没有数据则表现为等待或超时,不应把空字节串当成“再等一下”。

客户端收到正确回显,结合这个自建服务的代码和服务端读取日志,可以确认本次应用字节交换完成。服务没有数据库、磁盘提交或下游 RPC,因此结果不包含这些系统的保证。这是本机应用交换实测,不是完整 HTTPS 服务链的真实端到端验收。

字节与时间的先修自测

network-00 含十个 ASCII 字符,编码后也是十字节。一般文本没有这种一一对应关系:例如“网”的 UTF-8 编码为 e7 bd 91,是三个字节。协议中的长度如果定义为字节数,就应先编码再计算 len(payload),不能直接使用字符数。

十六进制每位表示四个二进制位,两个十六进制数字表示一个八位字节。0x01 0x02 按高位在前组合得到 1 × 256 + 2 = 258。如果把两个字节倒过来解释,则得到 2 × 256 + 1 = 513,错误不会自动被语言运行时识别。

网络字节序规定高位字节在前。Python 的 struct.pack("!I", 258) 生成四字节 00 00 01 02! 指定网络字节序、标准大小且无对齐填充,I 在这种格式下是四字节无符号整数。附件启动时对编码及解码执行断言。这个长度格式属于应用自行约定的格式,不是 TCP 首部。

单位同样需要写全。一个八位字节等于八个比特,十字节负载是八十比特;MBMb 的含义不同,MiB 又采用二进制单位。把文件字节数除以以 bit/s 标注的链路速率之前,要先统一单位。第 01 篇将把这个换算带入发送、传播和排队时延。

日志使用 time.monotonic_ns(),其绝对起点没有业务含义。两次读数相减可以表示本机相应代码区间经过的时间,但差值还包含线程调度、解释器执行及打印开销。本次记录没有把差值命名为网络 RTT,也没有给出吞吐排名。跨机器日志更不能直接相减来求单向时延,除非另外量化时钟同步误差。

并发也不等于日志必然交错。某次运行中 server_acceptedconnect_returned 谁先打印,取决于两端得到执行机会的时刻。实验只对同步屏障建立的关系作强断言,其他顺序按当次观测记录。业务时序图应该先标出程序强制的先后关系,再放入观测时间。

练习与核对方法

练习一:解码长度。 收到四字节 00 00 01 02,约定格式为 !I,后面只收到两字节内容。声明长度是多少?是否已经收到完整消息?如果发送内容换成“网”,长度字段应该填多少?先手算,再用 struct 和 UTF-8 编码检查。

核对:声明长度是 258 字节,收到两字节还差 256 字节。UTF-8 的“网”长度为三字节,长度字段应是 00 00 00 03。长度头本身的四字节是否计入消息长度必须由协议定义;第 02 篇的约定只计算消息体。

练习二:标出未知。 只给出 paused.sendall_returned 这一行日志,分别判断“对端 TCP 已收到全部字节”“对端应用已经读取”“服务端处理完成”“结果持久化完成”。再加入读取屏障源码与 paused.reader_still_gated,哪些判断发生改变?

核对:孤立的发送返回不能证明列出的四项。加入屏障证据后,可以明确应用尚未读取;TCP 接收进度依然缺少传输层观测。服务端没有执行持久化操作,不能因为之后收到回显就把持久化标成完成。把程序改成读取后不回显,可进一步构造“已经读取但客户端仍收不到回复”的反例,改动与结果需另存日志。

验证记录与边界

本次记录日期为 2026-09-19,运行环境是 Python 3.14.4、Darwin 27.0.0 arm64。三场景驱动实际运行完成,网络字节序断言通过;暂停读取与正常回显两场景通过字节一致性检查。未监听端口场景得到超时,连接拒绝仍待补证。

本篇没有抓包,没有观察 TCP ACK、以太网帧、路由转发或拥塞窗口。loopback 实测只覆盖本机两个 socket 的应用交换。日志打印先后不能替代包级时序,脚本正常退出也不表示所有预期场景均已满足:必须检查有无 environment_gap。第 03 篇会把端点日志与报文观察对应起来。

一手参考资料

资料在本篇写作时于 2026-09-19 核查。Python 在线文档是 3.14 分支的滚动文档,本地实际版本单独记录为 3.14.4,不把文档站补丁版本当成本机版本。