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)

本文从以下问题展开:

  1. Socket、地址族、Socket 类型和协议分别是什么;
  2. TCP 的连接、监听、接受、收发与关闭过程;
  3. 为什么 TCP 没有消息边界,以及如何设计协议帧;
  4. UDP 与 TCP 的数据模型有什么根本差异;
  5. IPv4、IPv6、DNS 解析和 getaddrinfo() 如何影响地址处理;
  6. 阻塞、非阻塞、超时分别改变了什么;
  7. shutdown() 的半关闭语义,以及它和 close() 的区别;
  8. 如何编写可运行、可诊断、能正确处理部分收发和异常的 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_STREAMSOCK_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. localhost0.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.timeoutTimeoutError 的别名,因此可以捕获 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 是否结束。

因此应用协议必须定义“帧”,也就是:

一段能够被接收端明确识别边界的字节序列。

常见方法有三种:

  1. 固定长度;
  2. 分隔符;
  3. 长度前缀。

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

接收端的步骤是:

  1. 先读取 4 字节;
  2. 解析出长度 5
  3. 再读取恰好 5 字节;
  4. 得到 b"hello"
  5. 剩余字节保留给下一帧。

若网络把整个内容拆成:

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. 这个示例的生命周期

客户端:

  1. 创建 Socket;
  2. 连接服务器;
  3. 发送 4 字节长度和正文;
  4. 读取 4 字节长度;
  5. 按长度读取响应;
  6. 退出 with 代码块,关闭 Socket。

服务器:

  1. 创建监听 Socket;
  2. 设置 SO_REUSEADDR
  3. 绑定地址;
  4. 开始监听;
  5. 接受一个客户端;
  6. 循环读取协议帧;
  7. 返回响应帧;
  8. 客户端关闭后,recv_exact() 发现 EOF;
  9. 关闭当前连接;
  10. 回到 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 变为可读。

正确方向是使用 selectselectorsselect.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""

这表示客户端的发送方向结束,而不是一定表示双方所有通信都结束。

半关闭适合这样的协议:

  1. 客户端发送完整请求;
  2. 客户端不知道请求长度,使用 EOF 表示请求结束;
  3. 客户端调用 shutdown(SHUT_WR)
  4. 服务器读取到 b"",确认请求完整;
  5. 服务器发送响应;
  6. 客户端继续接收响应。

示例:

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)

这会让响应延迟至少受到轮询间隔影响,也没有正确处理连接关闭、错误和总截止时间。

修正方式是使用 selectorsselect 等待 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 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。