跳到内容

第 4 卷 / 第 2 章 / 第 2 课

2.2 消息边界、Deadline 与 Backpressure ​

同一连接连续调用两次 send,对端应用只会读到一条连续字节流。TCP 负责可靠、有序地交付字节,却不承诺保留两次调用的消息边界。把一次 recv 当成一条消息,测试环境也许暂时配合,生产环境通常很快翻脸。

这一课实现四字节长度前缀协议。先用本地 socket 连续写入,再用可控的输入适配器强制短读;随后解释 timeout、deadline、pipelining 和 backpressure 怎样影响连接状态。

1. 四种常见 framing ​

固定长度 ​

每条消息恰好 N 字节。解析最简单,但短消息浪费空间,变长字段需要额外规则。适合硬件记录或已知尺寸 block。

分隔符 ​

以换行或特殊序列结束:

text
PING\r\n
SET key value\r\n

实现必须处理分隔符跨越两次 recv、payload 中转义、最大行长和未结束行。不能无限等待并增长 buffer。

长度前缀 ​

text
[4-byte length][payload]

可以传二进制和空 payload。读取 header 后必须先验证上限,再分配和读取 body;否则一个 0xffffffff 就可能触发内存耗尽。

语法与协议定义边界 ​

JSON 对象可以靠完整语法识别结束,但字符串转义、嵌套和顶层数字都需要明确的解析规则;HTTP 则有自己的消息长度规则。Protobuf wire format 本身不能确定连续消息的分界,仍需长度前缀等外层 framing。数据“有格式”不等于一读就是一条完整消息。

2. 正确读取固定字节数 ​

recv_exact 必须区分:

  • 收满 N 字节;
  • 开始新 header 前遇到 EOF,表示消息流正常结束;
  • header 内部或已声明的 body 未读满时遇到 EOF,表示截断 frame。
python
import socket
import struct
import threading

HEADER = struct.Struct("!I")
MAX_FRAME = 1024 * 1024

def recv_exact(connection, length, allow_clean_eof=False):
    if length < 0:
        raise ValueError("length must be nonnegative")
    data = bytearray()
    while len(data) < length:
        chunk = connection.recv(length - len(data))
        if chunk == b"":
            if allow_clean_eof and len(data) == 0:
                return None
            raise EOFError(
                f"stream ended after {len(data)} of {length} bytes"
            )
        data.extend(chunk)
    return bytes(data)

def encode_frame(payload):
    if len(payload) > MAX_FRAME:
        raise ValueError("frame too large")
    return HEADER.pack(len(payload)) + payload

def recv_frame(connection):
    header = recv_exact(
        connection,
        HEADER.size,
        allow_clean_eof=True,
    )
    if header is None:
        return None

    (length,) = HEADER.unpack(header)
    if length > MAX_FRAME:
        raise ValueError(f"declared frame too large: {length}")
    return recv_exact(connection, length)

def fragmented_writer(connection, frames):
    with connection:
        encoded = b"".join(encode_frame(frame) for frame in frames)
        for byte in encoded:
            connection.sendall(bytes([byte]))
        connection.shutdown(socket.SHUT_WR)

left, right = socket.socketpair()
left.settimeout(2)
right.settimeout(2)
expected = [b"alpha", b"", b"omega"]
writer = threading.Thread(
    target=fragmented_writer,
    args=(left, expected),
)
writer.start()

with right:
    actual = []
    while True:
        frame = recv_frame(right)
        if frame is None:
            break
        actual.append(frame)

writer.join(timeout=3)
assert not writer.is_alive()
assert actual == expected
print(actual)

这个 Python 3.8+ 例子使用 socketpair 创建本机连接;在常见 Unix 上默认是 AF_UNIX 字节流,不经过 TCP。其他平台的实现可能不同。逐字节 sendall 仍可能被接收端合并,不能证明所有短读路径都测到了。!I 是 network byte order 的无符号 32 位整数,长度按字节计,不是字符数。

接着运行下面的适配器,强制每次返回不超过指定字节数。它复用上面的 encode_frame 和 recv_frame,把“刚好读短了”变成可重复输入:

python
class ChunkReader:
    def __init__(self, data, max_chunk):
        if max_chunk <= 0:
            raise ValueError("max_chunk must be positive")
        self.data = data
        self.max_chunk = max_chunk

    def recv(self, length):
        count = min(length, self.max_chunk)
        part, self.data = self.data[:count], self.data[count:]
        return part

wire = b"".join(encode_frame(p) for p in [b"alpha", b"", b"omega"])
for chunk_size in [1, 2, 3, 4, len(wire)]:
    reader = ChunkReader(wire, chunk_size)
    frames = []
    while (frame := recv_frame(reader)) is not None:
        frames.append(frame)
    assert frames == [b"alpha", b"", b"omega"]
print("all chunk sizes passed")

None 表示新 header 之前的 EOF;b"" 是有效空 payload。若混用 if not frame,空消息会被错当成结束。适配器只验证解析逻辑,不能代替真实 socket 的超时和关闭测试。

3. Header 也可能被拆开 ​

不少错误实现会写:

python
length = struct.unpack("!I", connection.recv(4))[0]

recv(4) 允许只返回 1 至 3 字节,并不会替你等待长度字段完整。header 和 body 都要走精确读取循环;不能用“一次调用在本机总能读满”的观察替代接口约定。

另一种错误是把 EOF 当成“暂时没数据”:

python
while True:
    if connection.recv(4096) == b"":
        continue

这会在已关闭连接上忙循环。blocking socket 暂时没数据时会阻塞或 timeout;返回空字节表示有序 EOF。

4. 长度字段属于不可信输入 ​

读取到长度后,在分配前检查:

  • 是否超过协议最大 frame;
  • 是否允许零长度;
  • 长度计算在所用整数类型中是否可能溢出;
  • 解压后的大小是否另有上限;
  • 一个连接允许多少在途 frame;
  • 总 buffer 是否受全局预算控制。

“最大 1 MiB”只是示例。真实上限应由业务语义、内存预算与代理链限制共同决定。

压缩协议还要防高压缩比 payload。wire length 很小不等于解压内存小;应同时限制压缩输入、解压输出和 CPU 工作量。

5. Per-operation timeout 与总 deadline ​

给每次 recv 设置 5 秒 timeout,不代表整个请求最多 5 秒。对端可以每 4 秒发一个字节,让一条 1 MiB 消息持续数周。

下面沿用第 2 节的 HEADER 和 MAX_FRAME,用 monotonic clock 计算剩余预算。同一个绝对 deadline 同时传给 header 和 body,不能在进入 body 时重新加 5 秒:

python
import time

def recv_exact_before(connection, length, deadline, allow_clean_eof=False):
    if length < 0:
        raise ValueError("length must be nonnegative")
    data = bytearray()
    previous_timeout = connection.gettimeout()
    try:
        while len(data) < length:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise TimeoutError("message deadline exceeded")
            connection.settimeout(remaining)
            chunk = connection.recv(length - len(data))
            if chunk == b"":
                if allow_clean_eof and not data:
                    return None
                raise EOFError("truncated message")
            data.extend(chunk)
        return bytes(data)
    finally:
        connection.settimeout(previous_timeout)

def recv_frame_before(connection, deadline):
    header = recv_exact_before(connection, HEADER.size, deadline, True)
    if header is None:
        return None
    (length,) = HEADER.unpack(header)
    if length > MAX_FRAME:
        raise ValueError("frame too large")
    return recv_exact_before(connection, length, deadline)

# Use one budget for both header and body:
# frame = recv_frame_before(connection, time.monotonic() + 5)

函数会恢复原来的 socket timeout,前提是这个 socket 的读写与超时设置由当前操作独占。并发任务不能相互改 timeout。截止时间限制的是这里的读取等待;若要包含解析、排队和下游调用,调用链也必须传递同一预算。

还需要决定:

  • timeout 后连接中的半条 frame 如何处理?
  • 是否关闭连接,还是协议支持安全 resynchronize?
  • request deadline 是否包含排队和下游调用?
  • response 已部分发送时能否重试?
  • 取消怎样传递给 worker 和 I/O?

deadline 是状态机的一部分,不是给 socket 加一个数字。

6. Backpressure:接收缓冲区满了以后 ​

长度前缀解决消息边界;慢客户端还会让缓冲区堆积。简化后的发送路径是:

  1. 字节先进入本机 socket send buffer;
  2. TCP 按接收窗口与拥塞窗口发送;
  3. 对端内核放入 receive buffer;
  4. 对端应用最终 recv。

若对端读得慢,buffer 会逐级填满。blocking sendall 最终阻塞,nonblocking send 返回 would-block,async writer 的待发送队列会增长。

错误做法是每个 producer 无限制地把响应追加进用户态列表。这样把网络 backpressure 变成进程 OOM。

需要明确:

  • 每连接最大 queued bytes;
  • 全局最大 queued bytes;
  • 超限时暂停读取上游、拒绝新请求还是关闭慢连接;
  • 哪些消息可以丢弃或合并;
  • 写入 deadline;
  • 公平调度,避免一个大响应饿死小响应。

Python asyncio 的 StreamWriter.write 会尝试发送,未能立即发送的部分进入 transport buffer;await drain() 在写缓冲达到 high-water mark 后等待其降到 low-water mark。下面的 Python 3.11+ 片段限制每次提交量,并让一个异步数据源随下游速度推进:

python
import asyncio

async def send_chunks(writer, chunks):
    async with asyncio.timeout(5):
        async for chunk in chunks:
            if len(chunk) > 64 * 1024:
                raise ValueError("producer chunk exceeds 64 KiB")
            writer.write(chunk)
            await writer.drain()

这里假设只有一个任务向 writer 写入,chunks 是按需产生字节块的异步迭代器。drain 不是对端已读确认,high-water mark 也不是硬内存上限:最后一次 write 可以超出阈值一个块。数据源预取、其他连接和 kernel buffer 仍要单独预算。超时或取消后,由连接所有者决定关闭及清理,不能无条件重发。

7. Request/response 顺序与 request ID ​

最简单协议每连接一次只允许一个请求:

text
send request A
receive response A
send request B
receive response B

pipelining 允许 A、B 同时在途。如果 response 必须按请求顺序返回,慢 A 会阻塞已完成的 B;如果允许乱序返回,每条 frame 就要带 request ID:

text
[length][request-id][type][payload]

还要定义:

  • ID 是否可复用,何时可复用;
  • response、error、cancel 怎样关联;
  • server push 使用哪个 ID 空间;
  • 同一 ID 重复出现如何处理;
  • 连接重建后旧 ID 是否仍有意义。

HTTP/2、gRPC 与 QUIC stream 已经解决了大量多路复用状态,普通应用不应轻易重造。

8. Blocking、nonblocking 与 readiness ​

nonblocking socket 在暂时不能完成时返回 would-block。epoll/kqueue 等 readiness API 告诉 event loop 某 fd “现在可能可读/可写”,不保证下一次操作一定完成全部请求。

事件处理器仍要:

  • 循环 read 到 would-block 或达到公平预算;
  • 保存半个 header/body 的 parser 状态;
  • 只在有待发送数据时关注 writable;
  • 处理 hangup/error 与剩余可读数据;
  • 避免一个永远活跃的连接垄断 loop;
  • 在关闭前取消 timer 和业务任务。

edge-triggered 模式通常要求 drain 到 would-block,否则可能等不到下一次边沿。level-triggered 会在条件仍成立时继续通知,实现更直观但也可能反复唤醒。

若 edge-triggered 处理器为了公平提前停下,必须把仍可能有数据的连接留在应用 ready queue,或使用正确的重新注册策略继续处理,不能直接等新边沿。

async/await 把状态机藏进编译器/runtime,不会消除这些协议规则。

9. TLS 会再增加一层 framing ​

这里讨论 TLS over TCP:TLS 把应用字节包装成 record。一次应用 write 可能对应多个 TLS record 和 TCP segment;一次 TCP recv 也可能只含半个 TLS record。QUIC 使用 TLS 握手与派生密钥,但不使用这套 TLS record 承载。

应用应通过 TLS library 的 read/write API,让库维护 record、认证和重组状态,不要对加密 socket 的底层 TCP 字节自行切片。TLS clean shutdown 与 TCP EOF 也有区别,安全敏感协议需要判断是否收到规范的 close notification。

10. UDP framing 的不同边界 ​

UDP 保留 datagram boundary,所以一个长度前缀通常不用于跨多个 recvfrom 重组同一个 datagram。但应用仍可能在一个 datagram 内放多条 record,或把大消息分成多个 datagram。

自定义 UDP 分片需要 message ID、片号、总数、timeout、去重、内存上限和拥塞控制。丢一个片可能让整个消息不可用。若需求是可靠、安全、多路流传输,优先评估 QUIC 而不是逐步复刻它。

11. 协议测试矩阵 ​

不要只测“正常发送一条消息”。至少覆盖:

输入预期
header 每次只到 1 字节正确重组
body 分成任意 chunk正确重组
两个 frame 合并到一次 recv解析两条
零长度 frame按协议接受或拒绝
header 中途 EOF截断错误
body 中途 EOF截断错误
length 超上限分配前拒绝
慢速逐字节输入总 deadline 生效
对端不读 responsequeued bytes 有界
cancel 与 close 同时发生只释放一次,状态一致

property-based test 可以随机切分同一编码字节串,检查每种已测切分是否得到相同消息序列。随机样本能扩大覆盖,但不能证明所有切分都已测过。

12. 小结 ​

TCP 协议设计必须主动补上字节流没有提供的东西:

  • framing 定义消息边界;
  • recv_exact 处理 header/body 的任意切分;
  • 长度和解压结果必须在分配前受限;
  • operation timeout 不能代替 end-to-end deadline;
  • backpressure 让慢下游限制上游,而不是无限堆内存;
  • pipelining 需要取消和响应顺序规则,乱序响应还需要 request ID 等关联机制;
  • readiness/async 仍要维护半包、错误与关闭状态;
  • 测试要随机化 chunk boundary,并覆盖截断与慢客户端。

到这里,协议已经给连续字节划出消息边界,也能在缓冲区达到上限时向上游传播压力。下一章先看 TCP 连接怎样建立和关闭,再追踪序号、ACK、流量控制与重传。

参考资料 ​

Built with VitePress | Software Systems Atlas