2.1 TCP Socket 的生命周期
上一章追踪了数据怎样到达目标主机。Socket 是程序真正能操作的把手:它不是另一条协议,也不是端口的别名,而是进程向内核网络栈发出 connect、accept、send 和 recv 请求的句柄。
这一课先运行一个只走 loopback 的实验,再沿着真实返回值解释短读、EOF、半关闭和错误。等你能让一条连接正确结束,下一课再处理更麻烦的事:TCP 字节流里没有应用消息边界。
1. 服务端与客户端的基本状态
TCP 服务端常见调用链:
socket
↓
bind local address
↓
listen
↓
accept ──> connected socket ──> recv/send
│
└──────> 下一次 accept客户端常见调用链:
socket
↓
connect remote address
↓
send/recv
↓
shutdown/closeaccept 不会把 listening socket 变成连接;它返回一个新的 fd/socket。关闭单个 connected socket 不会停止 listener,关闭 listener 也不会自动关闭已经 accept 的会话。
2. 在 loopback 上完成第一次通信
import socket
import threading
def receive_until_eof(connection):
chunks = []
calls = 0
while True:
chunk = connection.recv(7)
calls += 1
if chunk == b"":
return b"".join(chunks), calls
chunks.append(chunk)
def serve(listener, result):
try:
connection, peer = listener.accept()
with connection:
connection.settimeout(2)
request, calls = receive_until_eof(connection)
result["peer"] = peer
result["recv_calls"] = calls
connection.sendall(request.upper())
except Exception as error:
result["error"] = error
def receive_all(connection):
chunks = []
while True:
chunk = connection.recv(4096)
if chunk == b"":
return b"".join(chunks)
chunks.append(chunk)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind(("127.0.0.1", 0))
listener.listen()
listener.settimeout(2)
address = listener.getsockname()
observed = {}
thread = threading.Thread(
target=serve,
args=(listener, observed),
)
thread.start()
try:
with socket.create_connection(address, timeout=2) as client:
client.sendall(b"hello ")
client.sendall(b"socket")
client.shutdown(socket.SHUT_WR)
response = receive_all(client)
finally:
thread.join(timeout=3)
assert not thread.is_alive()
if "error" in observed:
raise observed["error"]
assert response == b"HELLO SOCKET"
print(response.decode())
print(f"server recv calls: {observed['recv_calls']}")把上面整个代码块保存为 socket-lifecycle.py,用 Python 3 运行。只监听 127.0.0.1,端口 0 让系统选一个空闲端口:
python3 socket-lifecycle.py
# HELLO SOCKET
# recv 次数可能随运行环境变化这个例子写入两次,每次 recv 最多取 7 字节。总共 12 字节至少需要两次非空读取,再加一次读到 EOF,所以打印的调用数至少为 3,但不固定。服务端等待 EOF 才回复;若删掉客户端的 shutdown(SHUT_WR),双方会互相等待,随后超时。这里的 2 秒是各次阻塞操作的超时,不是整个实验的总 deadline。
3. TCP 提供字节流,不提供消息
客户端:
client.sendall(b"hello ")
client.sendall(b"socket")如果服务端改为每次最多读 4096 字节,可能看到:
"hello " + "socket"
"hel" + "lo sock" + "et"
"hello socket"只要字节顺序相同,这些切分都正确。在已连接 TCP socket 上,一次 recv(4096) 可能:
- 返回 1 到 4096 字节;
- 返回当前已有部分,不等待“业务消息完整”;
- 被 signal 中断或按语言封装重试;
- 在 timeout 后抛出异常;
- 返回空字节串,表示对端发送方向已正常到达 EOF。
因此“send 一次,recv 一次”只在偶然的本地小数据测试里看似成立。应用必须定义 framing:长度前缀、分隔符、固定长度或自描述格式。
这里对 EOF 的判断要求请求读取的长度大于 0;recv(0) 本来就会返回空结果。Python 3.5 起,信号处理器未抛异常时,recv 等方法通常会自动重试被中断的系统调用。非阻塞 socket 暂时无数据则抛 BlockingIOError,也不是 EOF。
4. sendall 与短写
底层 send/write 允许只接受部分缓冲区。原因可能是 socket send buffer 暂时没有足够空间,或 nonblocking socket 只能立即接受一部分。
在 blocking 或 timeout 模式下,Python sendall 继续发送,直到数据全部被本地网络栈接受或发生异常。它不会自行把 nonblocking socket 改成 blocking,也不适合替代事件循环的待发送状态。它处理短写,但不保证:
- 对端应用已经读取;
- 数据已持久化;
- 对端会发送业务确认;
- 调用永远不会阻塞;
- 多个线程的 message 不会交织。
C 程序需要自己维护 offset,并处理 EINTR、短写和 EAGAIN。发送大响应时还要设计 backpressure,不能让每个慢客户端无限占用内存。
sendall 报错时无法从返回值知道已经发了多少,整条消息直接重发可能重复业务操作。Python 3.5 起,它使用该次调用的总发送超时,不会在每次成功短写后重置预算;这仍不等于整个请求的 deadline。
5. EOF、close 与半关闭
实验中的客户端先结束请求的发送方向,再等服务端返回大写响应。半关闭保留了另一个方向,适合这里用 EOF 标识请求结束的协议。
正式地说,shutdown(SHUT_WR) 关闭本端发送方向,同时保留接收方向。实验中的客户端用它告诉服务端“请求字节结束了”,随后仍能读取响应。
在常见 Unix 描述符模型中,close 释放这个 fd 的引用;同一进程的 dup 或 fork 后其他进程仍可持有同一个 socket。最后一个引用释放才触发相应底层关闭,TCP 后续状态还可能由内核继续维护。关闭 fd 不保证待发数据已到达对端。
对 TCP 来说:
recv == b""表示对端发送方向有序关闭;- timeout 表示截止时间内没有完成,不等于对端已关闭;
- connection reset 表示连接被异常终止;
- connection refused 通常表示目标可达,但该 endpoint 没有 listener 或被主动拒绝;
- broken pipe/reset during send 表示不能再按原连接发送。
不同语言把 errno 映射为不同异常,生产代码要按操作和协议状态处理,不能把全部错误重试一遍。
6. 地址不能只写一个 IPv4 字符串
getaddrinfo 接收主机名、服务名和 socket 条件,返回一组候选地址。下面展示最简单的串行尝试,避免只取第一个 IPv4:
import socket
import math
def connect_any(host, service, timeout):
if not math.isfinite(timeout) or timeout <= 0:
raise ValueError("timeout must be finite and positive")
errors = []
candidates = socket.getaddrinfo(
host,
service,
family=socket.AF_UNSPEC,
type=socket.SOCK_STREAM,
)
for family, socktype, protocol, _, address in candidates:
connection = None
try:
connection = socket.socket(family, socktype, protocol)
connection.settimeout(timeout)
connection.connect(address)
return connection
except OSError as error:
errors.append(error)
if connection is not None:
connection.close()
raise OSError(f"all addresses failed: {errors}")这个 timeout 按候选地址计算,不包含 getaddrinfo 的等待;多个地址失败时总耗时会累加,返回的 socket 也保留该超时,由调用方关闭。成熟 dual-stack 连接器可采用 Happy Eyeballs,把候选连接尝试错开后并行推进,成功后取消其余尝试。普通 resolver 只负责解析,不能据此认为 TCP 连接竞争也已经实现;优先检查所用客户端库。
服务端绑定 0.0.0.0 或 :: 表示相应地址族的 wildcard,不表示监听“互联网所有机器”。实际可达性还受路由、防火墙、namespace、云安全组和 NAT 控制。
7. listen backlog 与 accept queue
listen(backlog) 请求内核为待接受连接维护队列,参数语义与上限受操作系统实现和配置影响。它不是“最多同时服务多少客户端”:
- 已 accept 的连接不再占同一待接受位置;
- 应用并发上限由 worker/event loop 和资源限制决定;
- SYN 处理与已完成握手队列的实现细节可能分开;
- 内核可截断过大的 backlog;
- 应用 accept 太慢时,新连接会延迟、重传或失败。
排查连接建立失败时,要同时观察 SYN、握手完成、accept 速率、fd 上限和应用队列。
8. SO_REUSEADDR 不是端口共享开关
开发服务器重启后,bind 可能因旧连接状态或已有 socket 失败。SO_REUSEADDR 在常见 Unix TCP server 场景能放宽部分本地地址复用限制,但精确语义跨平台不同。
它不意味着任意两个进程都能安全监听同一 endpoint。SO_REUSEPORT 又是另一选项,内核可能把新 flow 分配给多个 listener;负载分配、权限与平台支持都需查对应文档。
不要用这些选项掩盖“旧进程仍在运行”或“绑定了错误地址”。先用 ss/lsof 确认实际 listener。
9. TCP 与 UDP 的 Socket 语义不同
UDP socket 发送 datagram。一次成功的 recvfrom 取出一个 datagram;缓冲区太小时可能返回截断数据,某些平台会报错,剩余部分都不能靠下一次读取补回。同样要注意,零长度 UDP datagram 合法:收到 b"" 不能照搬 TCP 的 EOF 判断。
UDP 不自动提供:
- 连接握手;
- 可靠重传;
- 按序交付;
- 去重;
- 流量控制;
- 拥塞控制。
调用 UDP connect 通常只是固定默认 peer,并让内核按 peer 过滤/报告部分错误,不会在网络上建立 TCP 式连接。
应用选择 UDP 后,要么接受这些语义,要么使用 QUIC 等已经定义恢复、拥塞控制与安全的协议,避免随意造一个“不丢包的 UDP”。
10. 并发模型在 accept 之后才开始分叉
常见服务端模型:
| 模型 | 优点 | 主要边界 |
|---|---|---|
| 每连接一个线程 | 控制流直观 | 线程栈、调度与数量上限 |
| 固定 worker pool | 并发受控 | 阻塞任务可占满 pool |
| event loop/nonblocking | 大量空闲连接开销低 | 状态机、CPU 任务阻塞 loop |
| async/await | 写法接近顺序流程 | 仍需 backpressure 与取消 |
| 多进程 | 故障/内存隔离更强 | IPC、共享 listener 与部署 |
模型本身不决定吞吐。请求是 CPU-bound、I/O-bound 还是长连接空闲,才决定资源瓶颈。无论哪种模型,都要限制连接数、请求大小、空闲时间和每客户端在途工作。
11. 资源与安全边界
一个对外 listener 至少需要防御:
- 客户端连接后永远不发送完整请求;
- 声明巨大长度却慢速发送;
- response 读得很慢,使 server send buffer 堵塞;
- 打开大量连接耗尽 fd、内存或 worker;
- 发送畸形 framing 让解析器越界;
- 在关闭和取消竞态中重复使用已释放连接状态。
设置 timeout 只是一个环节。还需要最大 frame/request、每连接 buffer 上限、并发限制、全局 deadline、认证前工作预算和一致的资源清理。
12. 小结
TCP socket 的正确心智模型是“有状态的全双工字节流 endpoint”:
- listening socket 与 accept 返回的 connected socket 不同;
- TCP 保序但不保留
send边界; recv必须循环,空结果表示发送方向 EOF;sendall处理短写,不代表对端业务成功;shutdown可以半关闭一个方向;- 地址解析应支持多个候选和 IPv4/IPv6;
- backlog、worker 数、fd 与 buffer 是不同资源;
- UDP 保留 datagram 边界,却不提供 TCP 的可靠语义。
现在这条 loopback 连接能发送、读到 EOF,并在半关闭后返回响应。下一节进入2.2 消息边界、Deadline 与 Backpressure,让同一连接持续传输有长度、有上限的消息。