Python 基础体系 · 第 59/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python Socket 编程:TCP、UDP、地址、半关闭、超时和协议帧
Socket 是操作系统提供的网络通信抽象。Python 的 socket 模块把 BSD Socket 接口映射成 Python 对象:创建 Socket,绑定或连接地址,发送和接收字节,最后关闭资源。它不是 HTTP 客户端,也不是消息队列;Socket 只负责在两个端点之间提供字节或数据报的传输能力,消息边界、编码、校验、请求响应关系等都需要应用协议自行定义。Python 3.14 的 socket 模块仍然是对底层系统 Socket API 的直接封装,因此部分行为会受操作系统网络栈影响。(docs.python.org)
本文从以下问题展开:
- Socket、地址族、Socket 类型和协议分别是什么;
- TCP 的连接、监听、接受、收发与关闭过程;
- 为什么 TCP 没有消息边界,以及如何设计协议帧;
- UDP 与 TCP 的数据模型有什么根本差异;
- IPv4、IPv6、DNS 解析和
getaddrinfo()如何影响地址处理; - 阻塞、非阻塞、超时分别改变了什么;
shutdown()的半关闭语义,以及它和close()的区别;- 如何编写可运行、可诊断、能正确处理部分收发和异常的 Socket 程序。
一、Socket 到底表示什么
1. Socket 是通信端点,不是连接本身
一个 Socket 可以理解为应用程序持有的通信端点。它通常包含:
- 地址族,例如 IPv4 的
AF_INET; - Socket 类型,例如 TCP 使用的
SOCK_STREAM; - 协议,例如通常由系统根据地址族和类型选择;
- 本地地址;
- 对端地址;
- 接收缓冲区和发送缓冲区;
- 阻塞或非阻塞状态;
- 超时配置;
- 一组操作系统级 Socket 选项。
创建 Socket 的基本形式是:
import socket
sock = socket.socket(
socket.AF_INET,
socket.SOCK_STREAM,
)
这里:
AF_INET表示 IPv4 地址族;SOCK_STREAM表示字节流类型;- 对于常见组合,
AF_INET + SOCK_STREAM通常对应 TCP; socket.socket()返回一个 Socket 对象。
Python 文档将 AF_* 和 SOCK_* 常量定义为表示地址族和 Socket 类型的枚举值;实际可用的地址族还取决于操作系统和 Python 构建环境。(docs.python.org)
TCP Socket 通常使用:
socket.socket(socket.AF_INET, socket.SOCK_STREAM)
UDP Socket 通常使用:
socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
其中 SOCK_STREAM 和 SOCK_DGRAM 是最常用的两种类型。前者提供连续字节流,后者以独立数据报为单位进行通信。(docs.python.org)
2. 地址由地址族决定
地址不是所有 Socket 都使用同一种 Python 数据结构。
IPv4 地址是二元组:
("127.0.0.1", 9000)
其中:
"127.0.0.1"是主机地址;9000是端口号。
IPv6 地址通常是四元组:
("::1", 9000, 0, 0)
四个字段分别对应主机、端口、流信息和作用域标识。后两个字段在许多普通场景中可以使用 0,但涉及带作用域的 IPv6 地址时,不能简单假定它们永远无关。(docs.python.org)
Unix 域 Socket 使用文件系统路径,例如:
"/tmp/example.sock"
它不通过 IP 地址和端口通信,而是通过本机内核中的 Unix 域 Socket 机制通信。
因此,下面两种写法不是等价的:
("127.0.0.1", 9000) # IPv4
"/tmp/service.sock" # Unix domain socket
它们需要使用不同的地址族:
socket.socket(socket.AF_INET, socket.SOCK_STREAM)
socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
3. localhost、0.0.0.0 和 "" 的含义不同
服务器绑定地址时经常看到:
sock.bind(("127.0.0.1", 9000))
这表示只监听本机回环地址。其他机器即使能够访问该主机,也不能通过这个监听地址连接。
sock.bind(("0.0.0.0", 9000))
这表示绑定 IPv4 的所有本地接口。
Python 文档还允许使用空字符串表示 IPv4 的 INADDR_ANY:
sock.bind(("", 9000))
这与绑定所有 IPv4 接口的意图相同,但它不是 IPv6 的通用写法。若程序需要同时支持 IPv4 和 IPv6,应显式使用 getaddrinfo() 枚举候选地址,而不是依赖某个特殊字符串。(docs.python.org)
二、TCP 的数据模型:可靠字节流
1. TCP 不是“发送消息”,而是发送字节序列
假设客户端执行:
sock.sendall(b"hello")
sock.sendall(b"world")
服务器不能依赖下面任何一种接收结果:
b"hello"
b"world"
也可能先后收到:
b"hell"
b"oworld"
或者:
b"hello"
b"world"
甚至一次收到:
b"helloworld"
TCP 只保证接收端看到的字节顺序与发送端写入的顺序一致。它不保留应用层每次 send() 或 sendall() 调用对应的边界。
Python Socket HOWTO 明确指出,send() 和 recv() 操作的是网络缓冲区,不保证一次处理调用方提供或期望的全部字节;应用程序必须根据协议继续调用,直到完整数据被处理。(docs.python.org)
这也是 TCP 编程中最重要的事实:
TCP 给你的是一个有序字节流,不是消息队列。
2. TCP 服务器的两个 Socket
TCP 服务器至少涉及两类 Socket:
监听 Socket
监听 Socket 用于:
bind()
listen()
accept()
它的职责是等待新连接,不负责和客户端交换业务数据。
已连接 Socket
accept() 返回:
conn, addr = server_socket.accept()
其中:
conn是与某一个客户端通信的已连接 Socket;addr是客户端地址;- 后续
recv()和sendall()都应作用在conn上,而不是监听 Socket。
Python 官方示例也特别强调,服务器不能在监听 Socket 上调用 sendall() 或 recv();这些操作应使用 accept() 返回的新 Socket。(docs.python.org)
TCP 服务器的基本状态转换可以表示为:
stateDiagram-v2
[*] --> CREATED: socket()
CREATED --> BOUND: bind()
BOUND --> LISTENING: listen()
LISTENING --> CONNECTED: accept()
CONNECTED --> HALF_CLOSED: shutdown(SHUT_WR)
HALF_CLOSED --> CLOSED: close()
CONNECTED --> CLOSED: close()
LISTENING --> CLOSED: close()
客户端则通常经历:
sequenceDiagram
participant C as Client
participant S as Server
C->>C: socket()
S->>S: socket()
S->>S: bind()
S->>S: listen()
C->>S: connect()
S-->>S: accept() 返回 conn
C->>S: send bytes
S-->>C: send bytes
C->>C: shutdown(SHUT_WR)
S-->>S: recv() 返回 b""
S-->>C: send response
C->>C: close()
S->>S: close()
监听 Socket 和连接 Socket 的分工,是很多初学者第一次写并发 TCP 服务器时出错的原因:关闭一个客户端连接时,不能误关闭整个监听 Socket,否则服务器将无法接受后续连接。
3. send() 和 sendall() 的区别
send() 返回本次实际发送的字节数:
sent = sock.send(data)
如果 data 长度为 1000,返回值可能是 1000,也可能小于 1000。对于阻塞 Socket,常见情况下它会发送较多数据,但不能把“一次调用发送全部数据”当作协议保证。
如果要确保整个字节串都被交给 Socket,可以使用:
sock.sendall(data)
sendall() 会持续发送,直到全部数据发送完成,或者发生错误。它不返回已发送字节数;发生异常时,调用方不能简单地依靠返回值知道精确进度。
手动实现等价逻辑如下:
def send_all(sock: socket.socket, data: bytes) -> None:
view = memoryview(data)
sent_total = 0
while sent_total < len(view):
sent = sock.send(view[sent_total:])
if sent == 0:
raise ConnectionError("socket connection broken")
sent_total += sent
这里的 sent == 0 是异常路径,而不是“暂时没有发送数据”。对于阻塞 TCP Socket,如果连接仍然正常,send() 通常会等待或发送部分数据;返回零表示连接已经不能继续发送。
4. recv(n) 的三个结果
data = conn.recv(4096)
它有三类结果:
返回非空字节串
b"abc"
表示当前收到了部分或全部可读数据。它不表示一条完整业务消息已经到达。
返回空字节串
b""
对于 TCP,这表示对端已经关闭了发送方向,当前连接上不会再收到更多字节。(docs.python.org)
抛出异常
例如:
socket.timeout
ConnectionResetError
OSError
这表示发生了超时、连接重置或其他 Socket/操作系统错误,不应与正常 EOF 混为一谈。
常见错误代码:
try:
data = conn.recv(4096)
except TimeoutError:
print("本次接收超时")
except ConnectionResetError:
print("对端强制重置连接")
except OSError as exc:
print(f"Socket 错误: {exc}")
在 Python 3.14 中,socket.timeout 是 TimeoutError 的别名,因此可以捕获 TimeoutError。(docs.python.org)
三、TCP 协议帧:解决消息边界问题
1. 为什么必须设计帧
假设客户端发送两个 JSON:
{"id": 1}
{"id": 2}
如果直接编码后写入 TCP:
sock.sendall(b'{"id": 1}')
sock.sendall(b'{"id": 2}')
接收端可能得到:
b'{"id": 1}{"id": 2}'
也可能先得到:
b'{"id": 1}{"id"'
此时接收端无法仅凭 recv() 的返回结果判断第一条 JSON 是否结束。
因此应用协议必须定义“帧”,也就是:
一段能够被接收端明确识别边界的字节序列。
常见方法有三种:
- 固定长度;
- 分隔符;
- 长度前缀。
Python Socket HOWTO 也将固定长度、分隔符、长度字段和关闭连接作为解决消息边界的基本方式。(docs.python.org)
2. 固定长度帧
例如每条消息固定 32 字节:
def recv_exact(sock: socket.socket, size: int) -> bytes:
chunks: list[bytes] = []
received = 0
while received < size:
chunk = sock.recv(size - received)
if not chunk:
raise EOFError(
f"expected {size} bytes, got {received}"
)
chunks.append(chunk)
received += len(chunk)
return b"".join(chunks)
如果协议规定每条消息必须是 32 字节:
frame = recv_exact(conn, 32)
接收步骤是:
- 第一次
recv()收到 10 字节; - 第二次收到 8 字节;
- 第三次收到 14 字节;
- 累计达到 32 字节后,才交给业务层解析。
固定长度的优点是解析简单,缺点是短消息需要填充,长消息需要拆分,而且协议扩展不灵活。
3. 分隔符帧
文本协议常用换行符:
PING\n
PONG\n
接收端需要维护缓存:
def recv_lines(sock: socket.socket):
buffer = bytearray()
while True:
chunk = sock.recv(4096)
if not chunk:
if buffer:
raise EOFError("connection ended in the middle of a frame")
return
buffer.extend(chunk)
while True:
separator = buffer.find(b"\n")
if separator < 0:
break
line = bytes(buffer[:separator])
del buffer[:separator + 1]
yield line
这里不能写成:
line = sock.recv(4096).decode()
因为一次 recv() 可能包含半行、恰好一行或多行。
分隔符协议还必须规定:
- 分隔符是否允许出现在正文中;
- 正文是否需要转义;
- 单帧最大长度;
- 收到超长缓存时如何拒绝。
否则攻击者可以持续发送没有换行符的数据,使接收端缓存无限增长。
4. 长度前缀帧
长度前缀是二进制协议中最常见的方案之一。格式如下:
+----------------+----------------------+
| 4 字节长度 | payload |
+----------------+----------------------+
假设长度字段使用大端序、无符号 32 位整数,则 Python 可以用 struct 编码:
import struct
HEADER = struct.Struct("!I")
MAX_PAYLOAD = 1024 * 1024
def pack_frame(payload: bytes) -> bytes:
if len(payload) > MAX_PAYLOAD:
raise ValueError("payload too large")
return HEADER.pack(len(payload)) + payload
def recv_exact(sock: socket.socket, size: int) -> bytes:
chunks: list[bytes] = []
received = 0
while received < size:
chunk = sock.recv(size - received)
if not chunk:
raise EOFError(
f"expected {size} bytes, got {received}"
)
chunks.append(chunk)
received += len(chunk)
return b"".join(chunks)
def recv_frame(sock: socket.socket) -> bytes:
raw_header = recv_exact(sock, HEADER.size)
(length,) = HEADER.unpack(raw_header)
if length > MAX_PAYLOAD:
raise ValueError(f"frame too large: {length}")
return recv_exact(sock, length)
struct 用于在 Python 值和打包后的二进制数据之间转换。格式字符串中的 ! 表示网络字节序,即大端序;I 表示 4 字节无符号整数。(docs.python.org)
完整推导
若要发送:
payload = b"hello"
则:
len(payload) == 5
长度字段为:
HEADER.pack(5)
在网络字节序下,4 字节表示为:
00 00 00 05
最终发送内容是:
00 00 00 05 68 65 6c 6c 6f
接收端的步骤是:
- 先读取 4 字节;
- 解析出长度
5; - 再读取恰好 5 字节;
- 得到
b"hello"; - 剩余字节保留给下一帧。
若网络把整个内容拆成:
00 00
00 05 68
65 6c 6c 6f
recv_frame() 仍然能够正确工作,因为它按协议要求累计读取,而不是把一次 recv() 当成一帧。
反例:不校验长度
下面的代码有严重问题:
length = int.from_bytes(recv_exact(sock, 4), "big")
payload = recv_exact(sock, length)
如果对端发送长度 0xFFFFFFFF,程序会尝试等待或分配极大的数据。即使没有立即分配内存,也可能长时间占用连接和任务。
因此长度字段必须满足:
0 <= length <= MAX_PAYLOAD
长度字段是协议输入,不是可信配置。解析顺序必须是:
读取固定大小头部
↓
解析长度
↓
检查上限
↓
读取正文
↓
交给业务解析
不能先根据未经校验的长度申请资源。
四、一个完整的 TCP 长度前缀示例
下面的程序可以在本机运行。它使用 TCP、4 字节大端长度字段和 UTF-8 文本。
1. 服务器
# tcp_server.py
from __future__ import annotations
import socket
import struct
HEADER = struct.Struct("!I")
MAX_PAYLOAD = 1024 * 1024
def recv_exact(sock: socket.socket, size: int) -> bytes:
chunks: list[bytes] = []
received = 0
while received < size:
chunk = sock.recv(size - received)
if not chunk:
raise EOFError(
f"peer closed: expected {size}, got {received}"
)
chunks.append(chunk)
received += len(chunk)
return b"".join(chunks)
def recv_frame(sock: socket.socket) -> bytes:
header = recv_exact(sock, HEADER.size)
(length,) = HEADER.unpack(header)
if length > MAX_PAYLOAD:
raise ValueError(f"payload too large: {length}")
return recv_exact(sock, length)
def send_frame(sock: socket.socket, payload: bytes) -> None:
if len(payload) > MAX_PAYLOAD:
raise ValueError("payload too large")
sock.sendall(HEADER.pack(len(payload)) + payload)
def serve(host: str = "127.0.0.1", port: int = 9000) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((host, port))
listener.listen()
print(f"listening on {host}:{port}")
while True:
conn, addr = listener.accept()
with conn:
conn.settimeout(10.0)
print(f"connected: {addr}")
try:
while True:
payload = recv_frame(conn)
text = payload.decode("utf-8")
print(f"request: {text}")
response = f"echo: {text}".encode("utf-8")
send_frame(conn, response)
except EOFError:
print(f"peer closed: {addr}")
except TimeoutError:
print(f"idle timeout: {addr}")
except UnicodeDecodeError:
print(f"invalid UTF-8 from {addr}")
except ValueError as exc:
print(f"protocol error from {addr}: {exc}")
except OSError as exc:
print(f"socket error from {addr}: {exc}")
if __name__ == "__main__":
serve()
启动:
python tcp_server.py
预期输出:
listening on 127.0.0.1:9000
2. 客户端
# tcp_client.py
from __future__ import annotations
import socket
import struct
HEADER = struct.Struct("!I")
def recv_exact(sock: socket.socket, size: int) -> bytes:
chunks: list[bytes] = []
received = 0
while received < size:
chunk = sock.recv(size - received)
if not chunk:
raise EOFError("server closed early")
chunks.append(chunk)
received += len(chunk)
return b"".join(chunks)
def send_frame(sock: socket.socket, payload: bytes) -> None:
sock.sendall(HEADER.pack(len(payload)) + payload)
def recv_frame(sock: socket.socket) -> bytes:
header = recv_exact(sock, HEADER.size)
(length,) = HEADER.unpack(header)
return recv_exact(sock, length)
def main() -> None:
with socket.create_connection(
("127.0.0.1", 9000),
timeout=5.0,
) as sock:
send_frame(sock, "你好,Socket".encode("utf-8"))
response = recv_frame(sock)
print(response.decode("utf-8"))
if __name__ == "__main__":
main()
运行:
python tcp_client.py
预期输出:
echo: 你好,Socket
这里的 create_connection() 是比直接调用 connect() 更高层的客户端辅助函数。对于主机名,它会解析 IPv4 和 IPv6 地址,并依次尝试候选地址,直到连接成功;传入的 timeout 会在连接前设置到 Socket 上。(docs.python.org)
3. 这个示例的生命周期
客户端:
- 创建 Socket;
- 连接服务器;
- 发送 4 字节长度和正文;
- 读取 4 字节长度;
- 按长度读取响应;
- 退出
with代码块,关闭 Socket。
服务器:
- 创建监听 Socket;
- 设置
SO_REUSEADDR; - 绑定地址;
- 开始监听;
- 接受一个客户端;
- 循环读取协议帧;
- 返回响应帧;
- 客户端关闭后,
recv_exact()发现 EOF; - 关闭当前连接;
- 回到
accept(),等待下一个客户端。
这个服务器是串行的:处理一个客户端期间,主循环不会接受其他客户端。要支持并发,可以为每个 conn 创建线程,或者将 Socket 设置为非阻塞并配合 selectors / select。但并发模型不会改变协议帧规则:无论使用线程、进程还是事件循环,TCP 仍然只是字节流。
五、UDP:以数据报为单位
1. UDP 不需要连接建立
UDP Socket 使用:
sock = socket.socket(
socket.AF_INET,
socket.SOCK_DGRAM,
)
发送时直接指定目标地址:
sock.sendto(b"hello", ("127.0.0.1", 9001))
接收时同时得到数据和来源地址:
data, addr = sock.recvfrom(65535)
UDP 服务器通常只需要:
socket()
bind()
recvfrom()
sendto()
它没有 TCP 的 listen() 和 accept()。
2. UDP 保留数据报边界
如果发送方执行:
sock.sendto(b"abc", target)
sock.sendto(b"def", target)
接收方两次 recvfrom() 通常分别得到两个数据报:
b"abc"
b"def"
这与 TCP 不同:UDP 的接收单位是数据报,而不是连续字节流。
但数据报边界不等于可靠性保证。应用仍然需要考虑:
- 数据报可能丢失;
- 数据报可能重复;
- 数据报可能乱序;
- 单个数据报过大时可能被丢弃或产生分片风险;
- 接收缓冲区太小会导致正文被截断。
因此 UDP 适合“单个消息可以独立处理,偶尔丢失可接受,或应用层自己实现可靠性”的场景。
3. 一个完整的 UDP 回显程序
服务器:
# udp_server.py
import socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind(("127.0.0.1", 9001))
print("UDP server listening on 127.0.0.1:9001")
while True:
data, addr = sock.recvfrom(65535)
print(f"received {data!r} from {addr}")
sock.sendto(b"echo: " + data, addr)
客户端:
# udp_client.py
import socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(2.0)
sock.sendto(b"hello udp", ("127.0.0.1", 9001))
data, addr = sock.recvfrom(65535)
print(data, addr)
预期输出类似:
b'echo: hello udp' ('127.0.0.1', 9001)
这里没有 connect() 也能发送数据。UDP Socket 也可以调用 connect(),但它不会建立 TCP 那种握手连接;它主要用于设置默认对端,使后续可以使用 send() 和 recv(),同时让内核只接收该对端的数据报。不能因为 UDP Socket 调用了 connect(),就把它当成可靠连接。
4. TCP 与 UDP 的选择依据
TCP 的核心抽象是:
连接 + 有序字节流
UDP 的核心抽象是:
独立数据报 + 地址
如果业务消息必须按顺序完整到达,通常需要 TCP,或者在 UDP 之上自行实现确认、重传、排序和拥塞控制。
如果业务允许丢失,且每个数据报都可以独立解释,UDP 可以避免 TCP 连接状态和字节流拆包问题。但这并不意味着 UDP 一定更快;实际表现取决于消息大小、网络路径、丢包率、重传策略和应用负载。
六、地址解析:不要把主机名当作单一 IP
1. 主机名需要解析
下面的代码不是立即使用 IP 地址:
("example.com", 443)
主机名需要经过 DNS 或本地名称解析,得到一个或多个候选地址。
使用 socket.getaddrinfo() 可以获取适合创建 Socket 的完整结果:
import socket
results = socket.getaddrinfo(
"localhost",
9000,
family=socket.AF_UNSPEC,
type=socket.SOCK_STREAM,
)
for family, socktype, proto, canonname, sockaddr in results:
print(family, socktype, proto, sockaddr)
返回项包括:
(address family, socket type, protocol, canonical name, socket address)
最后一个字段 sockaddr 可以直接传给对应地址族的 bind() 或 connect()。
2. 服务器应遍历候选地址
一个同时考虑 IPv4 和 IPv6 的监听流程如下:
import socket
def create_listener(host: str | None, port: int) -> socket.socket:
infos = socket.getaddrinfo(
host,
port,
family=socket.AF_UNSPEC,
type=socket.SOCK_STREAM,
flags=socket.AI_PASSIVE,
)
last_error: OSError | None = None
for family, socktype, proto, _, sockaddr in infos:
sock = socket.socket(family, socktype, proto)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind(sockaddr)
sock.listen()
return sock
except OSError as exc:
last_error = exc
sock.close()
raise OSError("could not create listener") from last_error
调用:
listener = create_listener(None, 9000)
host=None 配合 AI_PASSIVE 表示生成适合服务器绑定的地址。官方 HOWTO 给出的 IPv4/IPv6 示例也是通过 getaddrinfo() 遍历地址族,并在创建或绑定失败时继续尝试下一个结果。(docs.python.org)
3. DNS 结果不是永久不变的
如果程序使用主机名:
sock.connect(("service.internal", 9000))
实际连接哪个 IP,可能受到 DNS 返回顺序和主机配置影响。Python 文档明确提醒,使用主机名时可能出现非确定行为;如果需要确定性,可以使用数值地址。(docs.python.org)
但生产服务通常不能简单地把所有主机名替换成固定 IP,因为服务发现、故障转移和地址变更都依赖名称解析。更合理的做法是:
- 连接失败时记录解析结果和最终尝试地址;
- 不要无限缓存 DNS 结果;
- 连接池需要考虑地址变化;
- IPv4/IPv6 失败时区分解析失败、连接失败和服务端拒绝。
七、阻塞、非阻塞与超时
1. 阻塞 Socket
默认情况下,Socket 是阻塞的:
sock.setblocking(True)
调用:
data = sock.recv(4096)
可能一直等待,直到:
- 收到数据;
- 对端关闭;
- 发生 Socket 错误。
阻塞并不等于错误。一个线程阻塞在 recv() 上,可能只是当前没有数据。
2. 非阻塞 Socket
sock.setblocking(False)
等价于:
sock.settimeout(0.0)
而:
sock.setblocking(True)
等价于:
sock.settimeout(None)
Python 3.14 文档明确给出了这些等价关系。(docs.python.org)
非阻塞模式下,connect()、accept()、send() 和 recv() 可能在当前无法完成时立即返回异常,例如:
BlockingIOError
这不是网络断开,而是表示“现在还不能完成,需要稍后重试”。
如果手写非阻塞循环而不等待事件,代码可能变成:
while True:
try:
data = sock.recv(4096)
except BlockingIOError:
continue
这会持续占用 CPU,因为程序没有等待 Socket 变为可读。
正确方向是使用 select 或 selectors。select.select() 通过可读、可写和异常列表等待 Socket 状态变化,并且可以带超时。(docs.python.org)
3. Socket 超时
sock.settimeout(5.0)
表示后续阻塞 Socket 操作在等待超过 5 秒后抛出超时异常。若设置为 None,恢复阻塞模式;设置为 0.0,进入非阻塞模式。(docs.python.org)
需要注意:Socket 超时通常是“某个阻塞操作等待太久”,不是完整业务请求的总截止时间。
例如:
sock.settimeout(5.0)
# 可能每次 recv 都在 5 秒内收到一点数据
while not complete:
chunk = sock.recv(4096)
如果对端每 4 秒发送 1 个字节,这个循环可能持续很长时间。每次 recv() 都没有超过 5 秒,但整个请求已经耗时远超预期。
因此需要区分:
- 连接超时:建立连接最多等待多久;
- 读取空闲超时:两次数据之间最多允许多久;
- 写入超时:发送缓冲区等待多久;
- 请求总超时:从开始到业务完成最多允许多久。
对于简单同步代码,可以使用单调时钟实现总截止时间:
import socket
import time
def recv_exact_with_deadline(
sock: socket.socket,
size: int,
deadline: float,
) -> bytes:
chunks: list[bytes] = []
received = 0
while received < size:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("overall receive deadline exceeded")
sock.settimeout(remaining)
chunk = sock.recv(size - received)
if not chunk:
raise EOFError("peer closed before frame completed")
chunks.append(chunk)
received += len(chunk)
return b"".join(chunks)
调用:
deadline = time.monotonic() + 10.0
payload = recv_exact_with_deadline(sock, 100, deadline)
这里的 deadline 是绝对时间点,而不是每轮重新计算的 10 秒时长。若每轮都写成:
sock.settimeout(10.0)
则部分读取会不断刷新等待窗口,无法限制整个帧的耗时。
4. 超时后的 Socket 是否还能继续使用
超时表示本次操作没有在规定时间内完成。对于简单的连接请求,通常可以关闭 Socket 并重新连接。
对于已经开始接收协议帧的连接,超时后的处理取决于协议:
- 如果只发生短暂空闲,可以继续读取;
- 如果请求已无法判断边界,应该关闭连接;
- 如果写入超时,必须考虑部分数据已经发送,不能盲目重试同一请求;
- 如果协议支持请求 ID 和幂等语义,才更容易安全重试。
“超时就重试”不是通用规则。TCP 的字节流可能已经交付了部分请求,而客户端只是没有及时收到响应;重新发送可能导致服务端执行两次。
八、半关闭:只关闭发送方向
1. shutdown() 的三个模式
sock.shutdown(socket.SHUT_RD)
sock.shutdown(socket.SHUT_WR)
sock.shutdown(socket.SHUT_RDWR)
含义分别是:
SHUT_RD:禁止后续接收;SHUT_WR:禁止后续发送;SHUT_RDWR:同时禁止发送和接收。
Python 文档将 shutdown() 定义为关闭连接的一侧或两侧。(docs.python.org)
2. 什么是半关闭
TCP 连接有两个方向:
客户端 → 服务器
服务器 → 客户端
客户端执行:
sock.shutdown(socket.SHUT_WR)
表示:
客户端不会再发送数据,但仍然可以接收服务器数据。
服务器继续读取时,最终会看到:
b""
这表示客户端的发送方向结束,而不是一定表示双方所有通信都结束。
半关闭适合这样的协议:
- 客户端发送完整请求;
- 客户端不知道请求长度,使用 EOF 表示请求结束;
- 客户端调用
shutdown(SHUT_WR); - 服务器读取到
b"",确认请求完整; - 服务器发送响应;
- 客户端继续接收响应。
示例:
with socket.create_connection(("127.0.0.1", 9000)) as sock:
sock.sendall(b"request body")
sock.shutdown(socket.SHUT_WR)
chunks = []
while True:
data = sock.recv(4096)
if not data:
break
chunks.append(data)
response = b"".join(chunks)
服务器端:
with conn:
chunks = []
while True:
data = conn.recv(4096)
if not data:
break
chunks.append(data)
request = b"".join(chunks)
conn.sendall(b"response for: " + request)
这里 EOF 承担了“请求正文结束”的作用。
3. 半关闭不等于关闭 Socket
下面两段代码语义不同:
sock.shutdown(socket.SHUT_WR)
和:
sock.close()
前者只停止本端发送,后者释放本地 Socket 资源。通常需要:
sock.shutdown(socket.SHUT_WR)
sock.close()
但 shutdown() 可能因为连接已关闭或状态不适用而抛出异常,因此清理代码需要谨慎处理:
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
finally:
sock.close()
Python 文档建议显式关闭 Socket,或使用 with 语句;同时指出 close() 释放资源,但若希望及时关闭连接,可以在 close() 前调用 shutdown()。(docs.python.org)
4. 什么时候不应该用半关闭
如果协议已经有长度前缀或分隔符,就不需要依靠连接 EOF 判断一条消息结束:
长度前缀 + 正文
此时客户端发送一帧后可以继续复用连接。过早执行:
sock.shutdown(socket.SHUT_WR)
会让后续请求无法发送。
因此:
- 单次请求、请求结束由 EOF 表示:可以使用半关闭;
- 长连接、多次请求:应使用协议帧,不要用半关闭表示每条消息结束;
- 双向持续通信:通常保持两个方向都打开,直到协议明确结束。
九、makefile():方便的文本和行协议接口
Socket 可以转换成类文件对象:
with socket.create_connection(("127.0.0.1", 9000)) as sock:
reader = sock.makefile("rb")
line = reader.readline()
对于按行协议,这比手动维护 bytearray 更方便。但它引入了缓冲层:
writer = sock.makefile("w", encoding="utf-8")
writer.write("PING\n")
writer.flush()
如果不调用 flush(),数据可能仍在 Python 的文件对象缓冲区中,服务器就收不到完整请求。官方 HOWTO 也警告,类文件 Socket 使用写操作后必须刷新缓冲区,否则对端可能一直等待。(docs.python.org)
还要注意:
makefile()返回的文件对象和原始 Socket 共享底层连接;- 关闭文件对象不一定立即关闭原始 Socket;
- Socket 必须处于阻塞模式,且发生超时时,文件对象内部缓冲区可能处于不一致状态。(docs.python.org)
因此,makefile() 适合简单的阻塞式文本协议;对于复杂超时、并发和二进制帧处理,直接使用 recv() 通常更容易控制状态。
十、生产代码需要明确的协议和故障路径
1. 协议层必须定义长度上限
长度前缀协议至少需要明确:
字节序:大端还是小端
长度字段:4 字节还是 8 字节
长度含义:只包含正文,还是包含头部
最大帧长度:例如 1 MiB
编码:UTF-8、JSON、Protocol Buffers 或其他格式
错误响应:协议错误后继续,还是立即断开
例如:
4 字节无符号大端长度
正文长度范围:0 到 1 MiB
正文:UTF-8 编码 JSON
那么接收逻辑必须拒绝:
- 不足 4 字节就 EOF;
- 长度大于 1 MiB;
- UTF-8 解码失败;
- JSON 语法错误;
- 字段类型不符合约定。
2. 不要把 EOF、超时和连接重置当成同一类错误
三者的意义不同:
| 结果 | 含义 |
|---|---|
recv() 返回 b"" |
对端正常结束发送方向 |
TimeoutError |
本次等待超过期限 |
ConnectionResetError |
连接被异常重置 |
BrokenPipeError |
向已关闭或不可写方向发送 |
OSError |
其他操作系统级 Socket 错误 |
不同错误决定不同恢复方式:
- EOF:结束当前协议会话;
- 超时:根据协议决定继续等待、取消请求或关闭连接;
- 重置:通常关闭连接并记录原因;
- 写入失败:不要无条件重放非幂等请求;
- 协议错误:通常关闭连接,避免继续解析错位字节。
3. 监听 Socket 应设置合理选项
常见服务器代码:
sock.setsockopt(
socket.SOL_SOCKET,
socket.SO_REUSEADDR,
1,
)
它可以减少服务器重启时因为旧连接状态导致的绑定失败。但它不是“强制夺取其他进程正在使用的端口”的通用开关,具体行为受操作系统影响。
绑定前应确认:
sock.bind((host, port))
失败时记录:
- 主机和端口;
- 地址族;
OSError.errno;- 运行用户;
- 是否已有进程监听;
- 是否绑定了错误的接口。
不要为了掩盖绑定失败而无限重试。端口冲突、权限不足和地址不存在通常需要修复配置或进程状态。
4. 关闭资源必须覆盖每条路径
推荐使用上下文管理器:
with socket.socket(...) as sock:
...
连接 Socket 也应使用:
conn, addr = listener.accept()
with conn:
handle_client(conn)
这样即使 recv()、协议解析或业务处理抛出异常,也能执行关闭逻辑。Python 文档推荐显式调用 close() 或使用 with,不应依赖垃圾回收自动释放 Socket。(docs.python.org)
十一、诊断 Socket 程序时应该观察什么
1. 先确认监听地址
服务器打印:
print(listener.getsockname())
对于 IPv4,可能得到:
('127.0.0.1', 9000)
如果客户端运行在另一台机器上,而服务器绑定的是 127.0.0.1,连接失败是预期结果。服务器应根据部署场景绑定具体内网地址或所有接口。
2. 再确认协议阶段
在客户端和服务端分别记录:
connect start
connect success
send header: length=42
send payload: 42 bytes
recv header: 4 bytes
decoded length: 42
recv payload: 17/42 bytes
不要只记录“请求失败”。Socket 故障通常需要定位到:
- DNS 解析;
- TCP 连接;
- TLS 握手;
- 写入请求;
- 读取帧头;
- 读取正文;
- 解码;
- 业务处理;
- 发送响应。
3. 使用 repr() 观察不可见字节
调试协议时:
print(repr(data))
比:
print(data.decode())
更可靠,因为它能显示:
b'PING\r\n'
b'\x00\x00\x00\x05hello'
这样可以发现:
\r\n和\n不一致;- 长度字段字节序错误;
- 字符串末尾混入空字节;
- UTF-8 解码前后的数据差异。
十二、最容易出现的错误
错误一:把一次 recv() 当成完整响应
data = sock.recv(4096)
return json.loads(data)
错误原因:TCP 没有消息边界,JSON 可能被拆分,也可能多个 JSON 粘在一起。
修正方式:使用长度前缀、分隔符或其他明确帧格式。
错误二:把 send() 当成完整发送
sock.send(data)
错误原因:send() 返回实际处理的字节数,可能小于 len(data)。
修正方式:
sock.sendall(data)
或者手动循环处理返回值。
错误三:用 sleep() 代替超时
sock.setblocking(False)
while True:
try:
data = sock.recv(4096)
break
except BlockingIOError:
time.sleep(1)
这会让响应延迟至少受到轮询间隔影响,也没有正确处理连接关闭、错误和总截止时间。
修正方式是使用 selectors 或 select 等待 I/O 就绪,并为业务操作定义截止时间。
错误四:把 TCP 当成请求队列
sock.sendall(b"request-1")
sock.sendall(b"request-2")
然后服务器假设每次 recv() 只得到一个请求。这是不成立的。两个请求可能合并在一次读取中,也可能其中一个被拆成多次读取。
修正方式:为每个请求添加帧头、分隔符或固定长度。
错误五:半关闭后继续发送
sock.shutdown(socket.SHUT_WR)
sock.sendall(b"more data")
SHUT_WR 的语义就是禁止后续发送。若连接需要继续发送多条消息,应使用长度前缀等长连接协议,而不是使用 EOF 作为每条消息的边界。
结语
Socket 编程的难点不在于记住几个方法名,而在于准确区分不同层次:
地址族:数据如何表示地址
Socket 类型:数据以流还是数据报传输
TCP/UDP:传输层的通信语义
阻塞/非阻塞/超时:调用如何等待
shutdown/close:连接方向和本地资源如何结束
协议帧:应用如何识别消息边界
TCP 程序必须处理部分发送、部分接收和 EOF;UDP 程序必须处理数据报的独立边界以及可能的丢失、重复和乱序;跨 IPv4/IPv6 时应通过 getaddrinfo() 处理地址候选;使用超时时应区分单次 I/O 超时和整个请求截止时间;使用半关闭时必须明确它表示的是一个方向结束,而不是整条连接立即消失。
只要把“Socket 提供什么”和“应用协议还必须补充什么”分开,TCP、UDP、超时和协议帧就不再是互相孤立的 API,而是一套可以推导、验证和诊断的状态系统。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 队列与背压:queue、asyncio.Queue、容量和关闭协议
- 下一篇:Python HTTP 客户端:连接池、超时、重试、流式和 TLS
- 延伸:Python asyncio 网络流:TCP、Reader、Writer、TLS 与背压
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论