切换深浅色
第 2 章 从程序到网络
浏览器配置本地代理后,为什么代理软件要“监听 127.0.0.1 的某个端口”?一台服务器既可以接收浏览器,又可以主动连接网站,这两件事由哪些对象负责?配置里显示端口,并不意味着程序只持有一个“端口对象”;应用必须建立、维护和释放多个通信端点。
2.1 进程、内核与文件描述符
直观解释与严谨定义
一个正在执行的程序以进程(process)的形式运行,拥有执行状态、地址空间和操作系统分配的资源。操作系统内核(kernel)负责管理资源和提供受控接口;普通应用通常在用户态(user space)运行,通过系统调用(system call)请求内核执行相应操作。本书的 Socket 路径以 Linux 为主要实现环境,不把其他系统的资源表示强行等同于 Linux。
可以把文件描述符(file descriptor,FD)理解为进程向内核提交的一张资源编号,但编号不是资源内容,也不是内存指针。Linux 成功打开文件得到的 FD 是非负整数,索引该进程的描述符表。描述符引用对应的打开文件描述(open file description),后者持有文件偏移和状态等信息。[L-OPEN] Socket 创建后也返回文件描述符,后续接口凭它识别通信对象。[L-SOCKET]
编号的意义局限于进程的描述符表。两个进程都持有编号 3,不证明它们操作同一个资源;一个进程关闭编号 3 后,新建资源可能再次获得编号 3。不能把“编号相同”当成“连接相同”。这个生命周期问题在服务器管理大量连接时尤其重要。
底层机制与语言封装
在 C 风格接口中,程序保存整数 FD,再调用 read、write 或 Socket 专用接口。Python socket.socket 对象封装相应资源,fileno() 返回底层描述符;对象本身还提供超时等语言接口。[PYSOCKET] 因此“Socket 对象”和“FD 整数”不是同一种东西,直接关闭底层 FD 可能使上层对象的资源状态失效。
读取文件与读取 Socket 可以使用相似的字节缓冲区,但相似接口不代表相同语义。普通文件有已存储内容与当前位置,网络 Socket 的内容可能尚未到达;读取何时返回、什么时候等候、什么时候表示结束由对象和接口规定。共享读写接口的好处是程序可以统一管理资源,代价是必须了解每种资源的契约。
系统调用提供的是应用与内核的边界,不是应用与远端业务逻辑的直接通道。把字节交给内核,不表示远端服务器已经检查请求;内核的 TCP 层也不知道一个端口字段是否符合 VLESS 规则。稍后将分别讨论连接状态、数据传递与业务响应。
完整实例:资源编号与资源数据
考虑一个进程打开内容为 41 42 的本地文件。假设系统为它分配 FD 3,这个数字只是解释对象关系的示意值,实际编号由运行环境决定。读取最多两字节得到 41 42,关闭 FD 后,变量中仍可保存整数 3,但原来的资源引用已经失效。
| 时刻 | 应用的表示 | 系统状态 |
|---|---|---|
| 打开前 | 文件路径 | 尚无这次打开产生的资源引用 |
| 打开后 | FD 3(示意) | 进程的描述符表引用打开文件描述 |
| 读取后 | 新字节缓冲区 41 42 | 文件偏移已按读取推进 |
| 关闭后 | 旧整数变量可仍为 3 | 该引用已关闭 |
| 新资源再次分配编号 3 | 新引用与旧整数数值相同 | 不能据编号相同认定仍是旧文件 |
如果没有新的资源复用编号,继续使用已关闭 FD 会得到无效描述符错误。并发程序则可能更危险:另一个操作已经把编号 3 分配给新 Socket,持有旧整数的代码可能错误地操作新连接。资源对象和生命周期的管理,正是比单独记住一个整数更完整的做法。
这个例子没有网络操作,但 Socket 对象同样需要正确持有与释放。关闭语言对象、关闭底层 FD 和关闭通信方向是不同动作,后面分别说明。
常见误解与本节结论
FD 不是网络端口:端口属于 IP 传输端点的标识,FD 属于进程内部的资源引用。打开一个 FD 也不意味着建立一个 TCP 连接,例如本节的文件实例,下一节的 Socket 创建也可能尚未连接。
内核执行某个操作与远端完成某个操作必须分开判断。读写系统调用、连接状态和应用响应各有自己的成功条件。本节的结论是:程序通过资源引用请求内核处理字节,但字节在哪里、什么时候可用以及何时应该关闭,仍需相应接口的精确定义。
2.2 Socket、地址族与通信端点
实际问题与严谨定义
一个代理进程同时服务十个浏览器连接,是否需要十个不同的监听端口?通常不需要。监听端点可以相同,各个已建立 TCP 连接由本地和远端的地址、端口组合区分。先把“进程资源”和“网络标识”分开,才能理解这种共享。
套接字(socket)是操作系统提供的通信端点抽象。创建时选择地址族(address family)、通信类型(socket type)以及必要的协议参数。地址族规定地址结构与通信域;通信类型规定主要服务形态。Linux socket() 返回一个新 FD,此时不代表已经绑定本地地址或连到远端。[L-SOCKET]
| 创建参数 | 通信域 | 典型服务 | 是否为本章 TCP 例子 |
|---|---|---|---|
| AF_INET + SOCK_STREAM | IPv4 | 默认协议参数下使用 TCP | 是 |
| AF_INET6 + SOCK_STREAM | IPv6 | 默认协议参数下使用 TCP | 是,后续可改用 ::1 |
| AF_INET + SOCK_DGRAM | IPv4 | 默认参数下使用 UDP 数据报 | 否 |
| AF_UNIX + SOCK_STREAM | 本机 Unix 域 | 本地流式通信 | 否,不使用 IP/TCP |
因此 SOCK_STREAM 本身不能等同于 TCP;还要看通信域与实际选定协议。普通 TCP Socket 由内核实现 TCP,不是应用自己构造 IP/TCP 首部。第 20 章的 RAW 承载也不意味着此处使用原始套接字;当时将重新核验它的层次。
端点与连接的精确区分
对一个已建立的普通 TCP 连接,应用常用四元组
表示两端。例如服务器的本地为 127.0.0.1:9000,第一个客户端远端为 127.0.0.1:53001,另一个为 127.0.0.1:53002。这两个连接共享服务器端口,远端端口不同;客户端 Socket 和服务器 Socket 对同一连接观察到的“本地/远端”方向相反。
这些端口是说明四元组的示意值,不保证操作系统实际选择 53001 或 53002。在不同网络命名空间或协议域中,标识还需要相应上下文;网络命名空间是隔离网络接口、路由与 Socket 命名等状态的系统环境。不能跨隔离环境只用四元组声称是同一个连接。
Socket 对象与 TCP 连接并非始终一一对应。新创建对象尚未连接;监听对象等待建立连接;真正收发某一连接数据的对象才对应已连接端点。Socket 的角色由它经过的操作及状态决定,不是创建后永远不变的标签。
回环地址、通配地址与端口零
本章使用 127.0.0.1,表示 IPv4 回环网络,通信在本机进行。IPv6 回环地址为 ::1。回环绑定限制网络可达范围,但不等于只有当前进程能访问;同一网络环境中的其他本地进程仍可能连接。
绑定地址 0.0.0.0 是 IPv4 通配地址,通常表示接收所有本地 IPv4 接口上的相应连接;它不是需要发给某个远端主机的地址。:: 是 IPv6 通配地址。IPv6 监听是否同时接受 IPv4 映射连接与系统及配置有关,不能在跨平台例子中默认相同。
绑定端口零的含义是请求内核分配一个可用本地端口,不是声明服务器最终在端口零监听。绑定后可以用 getsockname() 查询实际地址。[PYSOCKET] 这使本地服务可以避免占用固定端口,也避免与已有代理的 7890、1080 等端口冲突。下一节才介绍绑定操作。
完整实例:一个对象的状态不能从编号推断
进程创建 IPv4 流式 Socket 后可以持有一个有效 FD,却仍没有远端端点。对这个对象查询对端,Linux 接口可以报告 ENOTCONN,意思是尚未连接;这与 FD 无效是不同错误。[PYSOCKET、L-CONNECT]
| 对象阶段 | 有资源引用? | 有指定本地入口? | 有 TCP 对端? |
|---|---|---|---|
| 刚创建 Socket | 有 | 本例尚未绑定 | 无 |
| 下一节完成绑定 | 有 | 有 | 仍可没有 |
| 下一节转入监听 | 有 | 有 | 监听对象不对应某个已连接会话 |
| 接受后产生连接对象 | 有 | 有本地端点 | 有远端端点 |
这张表展示角色转变,不要求固定 FD 值。一个代理进程持有多个 Socket,既可以用监听对象接入浏览器,又可以用另一个对象连接目标;这些资源的目的与网络标识必须分别记录。
回环地址只限制网络可达范围,不提供代理用户身份认证。即使对端是 127.0.0.1,也不能据此推断是某一个特定浏览器进程。
常见误解与本节结论
端口不是进程内部的 FD,一个监听端口也不是只能容纳一个连接。服务器能同时监听并主动连接其他服务器,是因为它可持有多个角色不同的 Socket。第 5 章的代理通常正是把一个入站连接和一个出站连接关联起来,再在两者之间转发字节。
本节结论:先说明通信域与类型,再说明对象状态和端点。只看到一个 socket() 调用,尚不能判断它监听哪里、连接哪里或是否已有数据。
2.3 服务器的三个动作:bind、listen 与 accept
实际问题与直观解释
服务器需要一个稳定的入口,但每个访问者都要有独立的数据通道。把监听对象理解为等候入口、已连接对象理解为某个访问者的具体会话,可以建立直觉。这个类比不意味着进程必须在入口逐字节接收全部数据;内核负责 TCP 建立与队列,应用从队列取出连接后才处理应用字节。
服务器的基本动作是:创建 Socket → 指定本地地址 → 转入监听状态 → 获取一个已建立连接。前三步使用监听对象,最后一步返回另一个对象。客户端主动建立连接的动作叫 connect,下一节具体介绍;此处只需知道客户端向服务器的本地端点发起 TCP 建立过程。
严谨定义与底层机制
bind 把一个 Socket 与本地地址关联。对于 IPv4 TCP,本地地址包含 IP 与端口;bind(('127.0.0.1',0)) 请求回环地址上的自动分配端口。[L-BIND、PYSOCKET] 绑定成功并未把对象转成监听对象,也未建立远端连接。
listen 把合适的流式 Socket 置于接受传入连接的监听状态。backlog 表示等候队列的相关容量要求,不表示服务器总共最多处理几个连接。在 Linux 上,它对应等待应用接受的已完成连接队列长度,上限还可能被系统限制;未完成 TCP 握手的队列另有机制。[L-LISTEN] 应用不能把 listen(1) 解读成“只能接待一个用户”,也不能把它直接等同于“只允许一个未完成握手”。
accept 从监听对象上取出一个待接受的连接,返回新的已连接 Socket 和远端地址。原监听对象保持监听,后续还可以再次 accept。[L-ACCEPT] 没有待接受连接时,阻塞式调用会等候;非阻塞式调用则按相应错误报告暂时无连接。Python 可通过超时限制等候时间。
| 对象 | 状态与用途 | 是否用于这一连接的正文读写 |
|---|---|---|
| listener | 已绑定、已监听;执行 accept | 否 |
| conn | accept 返回;具有两端信息 | 是 |
| client socket | 客户端 connect 的对象 | 是,与 conn 对应同一连接的另一端 |
三个对象即使由同一进程管理也应保持区分。远端地址通常表示 TCP 对端,而不是可信的代理用户身份;用户认证需要另外的协议。
完整实例:入口与两条连接
假设监听 127.0.0.1:9000,客户端 A 的本地端口是 53001,B 是 53002。内核处理两个连接建立过程,应用调用两次 accept,取得 conn_A 和 conn_B。两对象的本地端点都可以是 127.0.0.1:9000,远端不同。监听 FD 不会被 B 替换,A 的正文也不需要从监听对象读取。
关闭 conn_A 只释放 A 连接的这个应用引用,不代表关闭监听端口。关闭 listener 则停止使用这个入口接收新连接,并不等于自动把已经取出的所有 conn 对象关闭。服务程序必须为每个对象管理生命周期,尤其要在错误分支中释放已接受对象。
此处端口是示意取值,FD 由运行时分配。由于不依赖具体 FD 和端口,正确程序用对象与查询结果关联会话,而不用硬编码 fd=4 或猜客户端端口。
具体状态例子:绑定成功以后仍可能等待
服务器绑定 127.0.0.1,端口填写 0;内核分配一个可用端口,假设查询结果为 127.0.0.1:49152。49152 是解释端口零行为的示意结果,不是协议规定的固定取值。服务器随后监听这个实际端口。
没有任何客户端建立连接时,等待连接的调用可以保持等候;应用若规定 100 毫秒的接口超时,期限到达后报告超时。这个超时意味着“期限内没有取得连接对象”,不意味着绑定失败,也不意味着 TCP 请求已经遭到拒绝。
在未配置复用选项的同一网络环境中,另一个对象试图绑定已经占用的入口可能得到 EADDRINUSE。这一错误与空队列超时不同:前者是本地地址使用冲突,后者是取得连接的等待条件尚未满足。使用复用选项时还要查相应规则,不能用一个默认例子推断全部配置。
如果真实部署意外绑定通配地址,服务器可能从其他网络接口接收连接。检查入口地址是理解访问边界的一部分,不应仅看到端口号就判断只有本机可达。
常见误解与本节结论
accept 不是在应用层把一个请求从 TCP 中取出,它取得的是一个连接对象;该连接后面可能承载多个应用请求。客户端的 TCP 建立成功也可能发生在服务程序实际调用 accept 之前,内核先维护待接受连接。
本节结论:bind 定入口,listen 改角色,accept 取出独立连接对象。三个操作成功都不能证明请求的格式或认证通过。连接层与应用层的区别将在完整收发实例中再次出现。
2.4 connect 与建立连接:成功发生在哪一层
实际问题与直观解释
客户端可以显示“已连接服务器”,随后却收到协议错误或认证失败。这没有矛盾:TCP 建立与应用请求处理有不同的成功条件。客户端先取得可用通信路径,之后才把协议字节交给服务器解释。
把 connect 理解为请求内核建立到指定远端的关联比较合适,但不是“把业务请求发送给服务器并等它批准”。在本节讨论的阻塞式 TCP 场景中,正常返回表示连接建立;不会自动产生 HTTP 请求、代理身份字段或目标网站连接。[L-CONNECT]
严谨定义与本地端点选择
主动建立 TCP 连接的一方称为主动打开(active open),监听一方进行被动打开(passive open)。客户端创建 IPv4 流式 Socket 后,指定远端 IP 与端口并调用 connect。如果未预先绑定本地端点,内核通常按路由及地址分配规则选择合适本地地址和端口。[L-CONNECT、PYSOCKET]
远端端口来自客户端的连接目标,本地临时端口来自本地选择,两者没有必要相同。例如连接本机 127.0.0.1:9000,客户端本地可以是 127.0.0.1:53001。53001 是示意运行时值,不是 TCP 协议的固定值。
如果调用参数是主机名而不是数值 IP,语言或辅助接口可能先解析名称;名称解析与 TCP 建立不是同一步。后续 DNS 与代理目标讨论会区分由哪一方解析。此节使用数值回环地址,把解析问题从连接状态中分离出来。
底层机制:TCP 三次握手
TCP 建立时,两端需要确认通信状态和初始序号。同步序号标志 SYN 与确认标志 ACK 位于 TCP 首部;初始序号用于标识字节流中的位置,完整流与确认规则在第 3 章展开。[R9293 §3.5]
用确定性的示意序号说明基本过程:客户端初始序号为 1000,服务器为 9000。客户端发 SYN,服务器发带 SYN 和 ACK 的响应,客户端再确认服务器的 SYN。SYN 占用序号空间的一位,因此对应确认值为 1001 和 9001。真实初始序号由实际系统产生,本书这些数字不冒充抓包结果。
| 步骤 | 方向 | 标志及示意序号 | 解决的问题 |
|---|---|---|---|
| 1 | 客户端 → 服务器 | SYN,SEQ=1000 | 发起连接并提出客户端起始位置 |
| 2 | 服务器 → 客户端 | SYN+ACK,SEQ=9000,ACK=1001 | 确认客户端 SYN,提出服务器起始位置 |
| 3 | 客户端 → 服务器 | ACK,ACK=9001 | 确认服务器 SYN |
这不是完整 TCP 报文转储,尚省略端口、窗口、选项和校验和等字段;只用于解释建立状态。不能拿这个表当作可直接发送的 TCP 报文。
完整过程:内核建立与应用接受
正在绘制图表…
查看图表原文
sequenceDiagram
participant C as 客户端应用
participant CK as 客户端内核
participant SK as 服务器内核
participant S as 服务器应用
S->>SK: bind 与 listen
C->>CK: connect 指定服务器端点
CK->>SK: SYN
SK->>CK: SYN 与 ACK
CK->>SK: ACK
CK-->>C: connect 建立成功
S->>SK: accept
SK-->>S: 返回已连接对象
Note over C,S: 此时尚未证明任何应用请求成功
服务器内核先完成相应建立过程并维护待接受连接,应用随后取得连接对象。实际调度中,服务器可以早已在阻塞的 accept 中等候,图中把调用画在后面只用于强调两种事件不同。不能据图推断实际服务器总是在客户端正常返回后才调用 accept。
客户端观察到的端点为“本地 53001、远端 9000”;服务器接受对象观察到“本地 9000、远端 53001”。两者对应同一 TCP 连接,方向相反。监听对象仍存在,并可接受其他客户端。
连接成功以后的三个不同确认
客户端把一段字节交给发送接口,首先要确认的是本地接口接受了多少字节。远端 TCP 层确认接收字节,又是另一项确认。最后应用解析内容、认证或完成业务并返回协议响应,是第三项确认。任何一项都不能自动替代下一项。
例如,客户端建立到本地代理的 TCP 连接,只证明该端点的 TCP 建立成功;代理随后仍可能拒绝 SOCKS5 方法、VLESS 身份或目标连接。代理成功接受用户后,目标服务器又可能因应用条件返回错误。第 5—8 章会分别标出这些边界。
阻塞、进行中与失败
阻塞式 connect 可以等候建立、报告拒绝或超时。非阻塞式调用可能报告 EINPROGRESS,表示建立尚在进行;程序需要通过就绪通知和相应 Socket 错误查询判断最终结果,不能把“可以写”单独当成建立成功。[L-CONNECT]
ECONNREFUSED 通常说明连接被拒绝,ETIMEDOUT 表示等待建立达到期限;路由或网络不可达也有相应错误。错误可能由本地网络状态或远端/中间设备造成,不能只凭一个名字证明目标业务程序处于某个状态。连接失败后如何重建对象应按平台接口契约处理,而不是继续把失败对象当成正常流。
本节结论与常见误解
connect 不会读取代理请求头,也不替用户进行应用认证。三次握手的网络往返不是应用层握手的往返;稍后的 TLS 与代理握手会在已存在的传输路径之上增加自己的状态。
本节结论:连接端点、TCP 建立、服务器应用接受和业务成功是四件可独立分析的事。分开它们,才能准确定位“能连上但不能使用”的故障。
2.5 read、write、send 与 recv:调用结果不是消息边界
实际问题与直观解释
客户端准备了十二字节请求,却可能第一次只读到两字节响应头,或者一次读到多条内容。网络读取接口的参数通常表示缓冲区容量或最多读取的字节数,不表示它已经知道应用协议的一条消息有多长。
写入也要区分请求与结果。应用请求写十二字节,接口可能只接受五字节;程序应保留剩余七字节,再按顺序继续。这个过程并未把请求变成两条业务消息,因为 TCP 的服务对象是有序字节流,下一章给出完整定界分析。[R9293 §2.2]
严谨定义与接口差异
对 Linux 的已连接 TCP Socket,read/write 是一般读写接口,recv/send 是可指定 Socket 标志等参数的专用接口。在没有额外标志的相应场景,后两者与一般接口具有相似的数据方向与返回含义;这不意味着所有通信类型、所有标志下都等价。[L-SEND、L-RECV]
| 事件 | 应用向接口提出什么 | 正常结果说明什么 |
|---|---|---|
| 发送 | 一个字节缓冲区及长度 | 本次接受的字节数 |
| 接收 | 可容纳至多 n 字节的空间,n>0 | 本次得到的字节及其长度 |
| 暂无可读数据 | 取决于阻塞/超时/非阻塞模式 | 等候、超时或暂不可读状态 |
| 有序结束接收方向 | 队列中的字节已读完 | 对流式读取返回零字节 |
| 连接异常 | 相应错误状态 | 不能继续当成正常数据结果 |
C 风格接口常用返回的整数表达数量,Python 的 recv 返回字节对象,通过其长度得到数量;其错误通常用异常报告。[PYSOCKET] 本书讨论语义而不要求编写具体调用程序。
底层机制:发送与接收缓冲区
通常路径中,应用交给发送接口的内容进入本地内核管理的发送队列,TCP 按状态、流量限制及拥塞控制决定何时传输。对端内核处理收到的数据,按 TCP 流位置交付可读取的内容;对端应用随后从接收队列取字节。具体是否复制、怎样缓存与哪些优化可用,属于实现与接口条件,不能把所有路径都写成固定次数的内存拷贝。
发送成功不证明对端应用已经消费数据。发送队列可以尚有内容,甚至后续出现连接错误;TCP 对端确认接收,也不证明它的业务代码已经把数据写入数据库或认证成功。需要确认业务结果时,应读取应用协议的响应。
如果发送队列暂时没有空间,阻塞式写入可以等待;非阻塞式可以报告暂时不能继续。此时保留未发送后缀,再等条件满足,不能丢掉它,也不能不加区分地把全部请求重新发送,造成重复字节。
完整例子:十二字节请求的不同接口分块
使用第 1.7 节的教学请求:
text
01 01 00 06 01 bb e4 bd a0 e5 a5 bd假设发送接口先接受五字节,再接受七字节,这只是允许出现的一种分块,不是预测实际系统行为。
| 发送次序 | 流中区间 | 本次数据 | 累计接受字节数 |
|---|---|---|---|
| 第一次 | [0,5) | 01 01 00 06 01 | 5 |
| 第二次 | [5,12) | bb e4 bd a0 e5 a5 bd | 12 |
接收方又可能按完全不同的边界得到内容:
| 接收次序 | 流中区间 | 本次数据 | 此时已知信息 |
|---|---|---|---|
| 第一次 | [0,2) | 01 01 | 版本与命令,还不知道正文长度 |
| 第二次 | [2,6) | 00 06 01 bb | 头部已完整,知道需六字节正文 |
| 第三次 | [6,12) | e4 bd a0 e5 a5 bd | 正文已完整,可解码为你好 |
三个接收结果拼接后与十二字节请求完全相同。发送两次、接收三次不违反可靠性,也不是需要通过某个“防粘包 TCP 选项”消除的错误。如何把这些分块恢复成应用消息,由协议字段与解析状态决定。
精确读取的逻辑与限制
若已经知道需要
若在
即使使用等待尽量填满缓冲区的选项,断连、错误或其他条件仍可导致提前返回,且接口不知道应用语义。[L-RECV] 因而完整性检查不能依赖“这次应当一次读完”。
零长度、错误与上层封装
对本节正常已连接 TCP 读取,请求长度大于零、未自行关闭接收方向且已耗尽正常队列时,返回零字节表示对端有序结束这个发送方向。不能把“暂时没有数据”也当成返回空内容:阻塞模式等候,非阻塞模式报告相应暂不可读状态。
这个判断不能推广到 UDP:某些数据报服务允许零长度数据报;若本来请求读取零字节,得到零也不能用来判断结束。[L-RECV] 此外,TLS 等上层读取接口有自己的关闭与错误规则,不宜直接套用裸 TCP 语义。
Python 的 sendall 等辅助接口可以负责重复提交直至全部数据被本地接口接受,或报告错误,但仍不证明远端应用成功;发生错误时,也未必能从该接口得知远端实际处理了多少内容。[PYSOCKET] 自动重试业务请求之前,需要考虑操作重复产生的结果,而不是把任何传输异常都解释成“服务器什么也没收到”。
本节结论
读取上限、实际读取长度、应用消息长度是三个量;写入请求长度、接口接受长度、远端业务消费长度同样是三个量。明确这些数量,才能理解字节流的定界、代理的双向转发以及后续性能分析。
2.6 完整通信实例:一个 HTTP 请求怎样跨过两个进程
场景与来源说明
假设一台机器上有客户端应用和一个教学 HTTP 服务器。服务器监听回环地址 127.0.0.1:9000,并配置为对本例主机名返回文本 OK。客户端直连此端点,发送第 1 章的 HTTP 请求。这里的报文按 HTTP/1.1 语法构造,端点、调度和读写分块均为明确的场景假设,不冒充实际抓包。[R9112 §2.1、§3、§4]
选用回环只是为了把外部路由和 DNS 因素暂时排除;机制同样说明远端服务器的通信边界。应用写下 Host: example.com 不会让 TCP 自动改连该域名:本例的实际 TCP 对端始终由连接操作指定为回环服务器。
第一步:服务器准备入口
服务器创建 IPv4 流式 Socket,绑定指定本地地址并监听。进程持有 listener,内核维护监听状态;此时可能还没有客户端,也没有已连接对象。这个入口地址不是请求正文,不会由 bind 自动写进 HTTP 字节。
客户端另创建 Socket,并请求建立到 127.0.0.1:9000 的 TCP 连接。内核为未预先绑定的客户端选择本地临时端口。假设是 53001,则两端视图如下:
| 观察方 | 本地 | 远端 |
|---|---|---|
| 客户端连接对象 | 127.0.0.1:53001 | 127.0.0.1:9000 |
| 服务器接受对象 | 127.0.0.1:9000 | 127.0.0.1:53001 |
服务器取得接受对象 conn,listener 仍保持监听。业务读写使用 conn,不从 listener 读取该连接的 HTTP 请求。
第二步:客户端构造并提交请求字节
完整可见表示为:
http
GET / HTTP/1.1
Host: example.com精确字节与第 1 章相同,共 37 字节:
text
47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31 0d 0a
48 6f 73 74 3a 20 65 78 61 6d 70 6c 65 2e 63 6f 6d 0d 0a
0d 0a应用把这段字节保存在自己的缓冲区,交给 Socket 写入接口。接口接受的数量与本地队列条件有关,客户端保留尚未接受的后缀。内核处理 TCP 数据传送,服务器内核再把按序可交付的内容放入相应连接的接收路径。
此时线上不是只存在孤立的 HTTP 字节:在 IP 网络上,TCP 数据属于 TCP 报文段,报文段还包含传输首部,外层还有 IP 等结构。首部中的序号、确认、窗口、校验和等取决于实际状态。本例只给出确定的应用字节,不能通过这 37 字节推导一个唯一的完整 IP 数据包。
第三步:服务器从流中恢复请求
假设服务器第一次得到前十字节,内容是 GET / HTTP;第二次得到剩余 27 字节。这种分块是合法的示意情况,不一定对应两个 TCP 报文段。第一次还没有完整请求行,服务器不能仅凭这十字节判定请求结束。
随着内容到齐,服务器识别请求行、字段行及最后的空行,按 HTTP 规则解释方法、目标、版本和 Host。对于本例没有请求体的请求,相关规则使它可以确定已得到本例所需完整内容;完整的 HTTP 消息长度优先规则将在第 9 章系统说明。
在这个阶段,服务器也必须检查输入上限和合法语法,而不是无限等待任意长度的头部。TCP 保证按流顺序交付正常数据,不替 HTTP 执行这些限制。
第四步:服务器构造响应
服务器完成本例业务,返回状态 200、两字节正文 OK。200 是三字符的 HTTP 状态码文本,不是一个值为 200 的单字节字段。
http
HTTP/1.1 200 OK
Content-Length: 2
OK线上每行末尾使用 CRLF,正文 OK 后本例不额外添加换行。完整确定字节为:
text
48 54 54 50 2f 31 2e 31 20 32 30 30 20 4f 4b 0d 0a
43 6f 6e 74 65 6e 74 2d 4c 65 6e 67 74 68 3a 20 32 0d 0a
0d 0a 4f 4b| 偏移区间 | 长度 | 内容 | 解释 |
|---|---|---|---|
| [0,15) | 15 | HTTP/1.1 200 OK | 状态行的可见内容 |
| [15,17) | 2 | 0d 0a | 状态行结束 |
| [17,34) | 17 | Content-Length: 2 | 声明正文长度两字节 |
| [34,36) | 2 | 0d 0a | 字段行结束 |
| [36,38) | 2 | 0d 0a | 头部结束 |
| [38,40) | 2 | 4f 4b | 正文 OK |
总长 40 字节,但 Content-Length 的数值是 2,因为它声明本例正文的长度,不包括 38 字节头部。[R9112 §6.2—6.3] 若把整个响应总长写入该字段,客户端将继续等待并不存在的正文数据。
第五步:客户端恢复响应含义
客户端读到内容后,先解释状态行与字段,再按长度取两字节正文。这可能需要多次读取;一次读取也可能同时得到头部与正文。客户端只有完成这一步,才取得本例应用层的成功响应 OK。
没有必要为了识别这两字节正文而等到 TCP 连接关闭。持久连接可以在同一连接上继续处理后续请求,完整规则仍由 HTTP 管理。这体现了 TCP 连接寿命与某条应用消息寿命不同。
数据处理顺序图
正在绘制图表…
查看图表原文
sequenceDiagram
participant C as 客户端应用
participant CK as 客户端内核
participant SK as 服务器内核
participant S as 服务器应用
Note over C,S: TCP 已建立,服务器已取得连接对象
C->>CK: 提交 37 字节 HTTP 请求
CK->>SK: TCP 传递有序字节
S->>SK: 从连接对象读取
SK-->>S: 按可用数据返回若干字节
S->>S: 恢复请求并完成业务
S->>SK: 提交 40 字节 HTTP 响应
SK->>CK: TCP 传递响应字节
C->>CK: 从连接对象读取
CK-->>C: 返回可用响应字节
C->>C: 恢复状态、长度与 OK 正文
图中的“一次箭头”表示一个逻辑阶段,不声称一定只发生一个系统调用或一个报文段。请求、响应、接口分块和网络分段属于不同层面的边界。
实例的安全与角色边界
本例的裸 TCP 不提供应用内容的保密性;看见应用字节的人可以读出 Host 和正文。第 13—16 章才增加安全目标和 TLS。正确长度计算与语法解析不能替代这种保护。
TCP 服务端口 9000、HTTP 的 Host 文本、以后代理请求中的目标端口,是不同层的地址信息。本例服务器没有因为 Host 写着 example.com 就自动充当代理;代理的职责在第 5 章定义,不能从一个 HTTP 字段凭空推导转发行为。
本节结论
一个完整通信过程应当同时标出参与进程、资源对象、实际连接端点、确定的应用字节、解析规则和业务完成条件。只有这六项一起明确,才能把“浏览器发出了什么”连到“服务端最终恢复了什么”。
2.7 半关闭、超时与错误:两个方向需要分别管理
实际问题与直觉
一种服务把客户端发送方向的结束视为“请求正文已经发送完”,然后才生成响应。客户端如果在发完请求后直接销毁整个连接,可能失去接收响应的机会;如果一直保持发送方向却等服务器回复,两端又可能彼此等候。
解决这种场景的关键是区分两个方向。TCP 提供双向通信,一端不再发送,不意味着另一端也必须停止发送。半关闭(half-close)指结束其中一个通信方向,而保留另一方向的正常使用。[R9293 §3.6.1]
严谨定义:shutdown 与 close
在 Linux Socket 接口中,shutdown 可以请求关闭接收方向、发送方向或两者,分别对应 SHUT_RD、SHUT_WR、SHUT_RDWR。对于 TCP,正常关闭发送方向使对端在读取完已交付的字节后看到该方向结束;本端仍可接收另一方向的数据。[L-SHUTDOWN]
close 则释放当前资源引用。关闭 FD 与传输方向管理不是同一概念:对象可能有多个引用,关闭某个引用未必立即结束底层对象;实际连接的结束还受未发送数据、实现选项和错误条件影响。不能把“变量不再使用”当成“所有网络数据已被对端应用处理”。
有序结束通常使用 TCP 的 FIN 标志,复位使用 RST 标志。FIN 表示发送方不再有后续流数据,其接收方仍应处理此前的有序字节;RST 表示连接被中止等异常状态,不能当作一个正常的业务成功响应。[R9293 §3.6、§3.5.2]
完整实例:发完请求仍接收结果
假设客户端要发送六字节 e4 bd a0 e5 a5 bd,服务器按本例应用约定读到客户端方向结束后才处理正文。
| 时刻 | 客户端 → 服务器 | 服务器 → 客户端 | 应用判断 |
|---|---|---|---|
| 初始 | 可发送 | 可发送 | 连接正常 |
| 客户端交付六字节 | 六字节进入传输路径 | 尚无响应 | 不能凭一次读取判断正文结束 |
| 客户端正常结束发送方向 | 前六字节之后不再有数据 | 仍可传输 | 客户端保留接收方向 |
| 服务器读完六字节再读到结束 | 请求方向已结束 | 仍可传输 | 本例按约定确认正文结束 |
| 服务器返回 4f 4b | 不再需要上行 | 返回 OK | 客户端读取结果 |
| 服务器结束其发送方向 | 已结束 | 响应后结束 | 双方释放不再使用的资源 |
这些是逻辑事件,不保证与各自一个报文段一一对应。TCP 的 FIN 占用序号空间的一位,因此确认 FIN 与确认应用数据需要按 TCP 顺序规则处理;这里不构造依赖实际状态的完整报文。
如果应用协议已经通过长度字段确定正文结束,就未必需要用发送方向结束来定界。例如上一节 HTTP 响应的 Content-Length: 2 足以识别两字节正文,不要求每次回复后销毁连接。结束方向能否作为定界条件由应用协议决定。
为什么代理需要传递结束方向
考虑客户端通过一个转发程序访问上述服务。转发程序具有客户端侧连接和服务器侧连接,分别转发两个方向的字节。当它从客户端侧读到有序结束后,应按该场景结束服务器侧的发送方向,使服务器知道请求结束,同时保留服务器响应返回客户端的路径。
如果转发程序只停止读取,却不把结束传播给服务器,服务器可能一直等候后续内容;如果立即关闭两侧全部方向,客户端可能看不到响应。这是双向转发的生命周期问题,不是单纯“复制缓冲区”能解决的。第 5 章将把它纳入完整代理流程。
超时需要按等待事件命名
| 超时范围 | 正在等待什么 | 到期不能证明什么 |
|---|---|---|
| 连接建立 | connect 的最终建立结果 | 不证明对方应用永久不可用 |
| 接口读取 | 本次有可返回的数据 | 不证明对方已正常结束 |
| 完整头部 | 应用协议头在规定时间内到齐 | 不证明已经收到的前缀不合法 |
| 完整响应 | 应用成功/失败结果 | 不证明服务器从未执行操作 |
| 空闲期限 | 某段时间没有活动 | 不证明传输层一定已经断开 |
接口一次读取期限与整个业务期限不同。每次读到少量字节后重置期限,可能使完整请求永远达不到总期限;相反,给整个大响应设置过短绝对期限,也可能中断仍在正常推进的传输。期限政策应结合请求上限、预计业务与资源容量,不把某个固定秒数当成协议普适常量。
TCP 自身的重传和超时也不同于应用接口超时。应用可以在 TCP 仍尝试恢复时决定放弃业务等待;一旦放弃,需要明确关闭或回收哪些对象,不能仅退出处理逻辑却留下引用。
错误的层次与重试边界
EAGAIN/EWOULDBLOCK 在相应非阻塞场景表示暂时不能完成;应用应等候合适的就绪条件,而不是把它当作永久故障,也不应空转耗尽 CPU。[L-SEND、L-RECV] 对端复位、本地无效描述符和业务状态码则分别属于不同问题。
连接出错之后,已经由对端业务处理的部分可能无法仅从传输错误判定。如果请求“增加账户余额一次”已执行,但成功响应丢失,盲目重发会使业务执行两次。这个例子只说明重试需考虑重复效果,不表示本书实现该业务。传输可靠性与业务去重属于不同层。
同样,正常结束不能保证应用消息完整:若声明十字节正文而实际只有六字节随后有序结束,TCP 层可以正常关闭,应用层仍应判定正文截断。结构检查必须在完整消息层进行。
本节结论
资源释放、通信方向结束、TCP 状态、接口超时与业务完成必须分别表达。对代理而言,正确传递数据之外还要正确传递方向性的结束,并保留尚需返回的数据路径;对客户端而言,传输失败不能自动推断业务从未发生。
2.8 接口成功与通信成功的距离
一条完整通信路径中的角色
| 层次或对象 | 它负责的事 | 本例中不能替它完成的事 |
|---|---|---|
| 应用协议 | 规定请求、响应和字段解释 | 不自动建立 TCP 连接 |
| 进程 | 持有资源、生成和解释字节 | 不凭普通 Socket 自动取得加密 |
| 文件描述符 | 引用进程的已打开资源 | 不等于 IP 端口或用户身份 |
| 监听 Socket | 提供被动接受连接的入口 | 不作为某条连接正文的读写对象 |
| 已连接 Socket | 提供双向字节传递接口 | 不保留应用写入边界 |
| TCP 实现 | 维护连接状态和有序流传递 | 不理解 HTTP、VLESS 的业务条件 |
| 应用响应 | 表达对应业务的结果 | 不能被本地发送返回值替代 |
对象关系决定数据路径。服务端创建一个监听入口,从中接受多个独立连接对象;客户端主动创建连接。代理可以同时承担这两种角色,但需要分别维护不同连接、不同发送方向与资源生命周期。
本章的完整实例建立了什么
第 1 章的确定性字节现在有了传递位置:应用缓冲区 → 本地接口与内核传输路径 → 对端内核 → 对端应用缓冲区。这个逻辑顺序不等于固定次数的复制,也不等于一条应用请求只有一个 TCP 报文段。
在 HTTP 例子中,TCP 的实际端点由连接操作指定,Host 是应用字段;发送 37 字节请求不证明服务器已经处理,收到并解析 40 字节响应才得到本例 OK。响应中的长度为正文两字节,不能把头部计入正文声明。
在半关闭例子中,结束上行仍允许下行返回,转发程序需要传递结束方向而保留响应路径。连接中止、超时、消息截断和业务失败应分别报告,不统一归为“网络不通”。
常见误解对照
| 说法 | 更精确的表述 |
|---|---|
| 创建 Socket 就是建立连接 | Socket 可尚未绑定、正在监听或已连接,需看状态 |
| 一个端口只能对应一个用户 | 已建立连接还由远端端点及网络上下文区分 |
| listen 的 backlog 就是总连接上限 | 它涉及等候队列,不能替代已接受连接的资源管理 |
| accept 取得一个代理请求 | 它取得连接对象,协议请求要从流中解析 |
| connect 成功就是认证成功 | 它只说明相应传输建立,应用握手另外判断 |
| send 成功就是业务执行成功 | 本地接受、对端传输接收、对端业务完成不同 |
| recv 的容量就是消息长度 | 它是接口读取上限,消息边界由应用规则确定 |
| 对端停止发送就是全部通信结束 | 正常半关闭仍可保留反向传输 |
| 超时说明服务器没有执行请求 | 响应可能延迟或丢失,业务状态可能不确定 |
与下一章的衔接
本章已经说明为什么一次写入和一次读取不具有相同边界。下一章不再靠“读取刚好返回一条请求”的运气,而是按固定长度、长度前缀、分隔符和类型—长度—值编码建立可增量恢复的消息规则。上层协议的缓冲状态,必须服从已明确的字节流契约。
本章参考资料
- [R9293] RFC 9293:§2.2 传递的字节流,§3.5 连接建立,§3.6 与 §3.6.1 关闭及半关闭。RFC 的服务概念和 Linux 系统调用不应未经区分视为同一套函数。
- [L-OPEN] open(2):描述符与打开文件描述。
- [L-SOCKET] socket(2):通信域、类型与资源创建。
- [L-BIND] bind(2):指定本地地址。
- [L-LISTEN] listen(2):监听与 Linux backlog 语义。
- [L-ACCEPT] accept(2):创建已接受连接对象与保持监听对象。
- [L-CONNECT] connect(2):建立、错误、非阻塞进行中及最终状态查询。
- [L-SEND] send(2)、[L-RECV] recv(2):字节数量、错误与零长度的条件。
- [L-SHUTDOWN] shutdown(2):方向性关闭。
- [PYSOCKET] Python socket 与 [PYHOWTO] Socket Programming HOWTO:语言封装的对象、超时、端点查询和发送辅助接口,用于核对语言封装的接口语义。
- [R9112] RFC 9112:§2—4 请求响应语法,§6.2—6.3 消息长度。完整 HTTP 语义与定界在第 9 章展开。
本章所有示意 FD、临时端口、序号和接口分块都明确标明性质;应用字节由协议语法构造,没有编造随机网络状态或生产抓包。资料统一登记见 参考资料。