跳到内容

第 4 卷 / 第 2 章 / 第 1 课

2.1 TCP Socket 的生命周期 ​

上一章追踪了数据怎样到达目标主机。Socket 是程序真正能操作的把手:它不是另一条协议,也不是端口的别名,而是进程向内核网络栈发出 connect、accept、send 和 recv 请求的句柄。

这一课先运行一个只走 loopback 的实验,再沿着真实返回值解释短读、EOF、半关闭和错误。等你能让一条连接正确结束,下一课再处理更麻烦的事:TCP 字节流里没有应用消息边界。

1. 服务端与客户端的基本状态 ​

TCP 服务端常见调用链:

text
socket
  ↓
bind local address
  ↓
listen
  ↓
accept ──> connected socket ──> recv/send
  │
  └──────> 下一次 accept

客户端常见调用链:

text
socket
  ↓
connect remote address
  ↓
send/recv
  ↓
shutdown/close

accept 不会把 listening socket 变成连接;它返回一个新的 fd/socket。关闭单个 connected socket 不会停止 listener,关闭 listener 也不会自动关闭已经 accept 的会话。

2. 在 loopback 上完成第一次通信 ​

python
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 让系统选一个空闲端口:

bash
python3 socket-lifecycle.py
# HELLO SOCKET
# recv 次数可能随运行环境变化

这个例子写入两次,每次 recv 最多取 7 字节。总共 12 字节至少需要两次非空读取,再加一次读到 EOF,所以打印的调用数至少为 3,但不固定。服务端等待 EOF 才回复;若删掉客户端的 shutdown(SHUT_WR),双方会互相等待,随后超时。这里的 2 秒是各次阻塞操作的超时,不是整个实验的总 deadline。

3. TCP 提供字节流,不提供消息 ​

客户端:

python
client.sendall(b"hello ")
client.sendall(b"socket")

如果服务端改为每次最多读 4096 字节,可能看到:

text
"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:

python
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,让同一连接持续传输有长度、有上限的消息。

参考资料 ​

Built with VitePress | Software Systems Atlas