提问于 2025-08-16。最终做了一个较为简化的原型程序,测得结果参见 FreeBSD TCP 的两个问题。暂未开源,开源计划未知。思路供参考。

另误打误撞发现 Linux TCP 可能存在一处与重传超时有关的 Data Race 问题,但并未验明。有兴趣可以联系讨论。

我问了 Gemini 这个问题,光是问题就阐述得很长。


#问题

我现在正在做对 TCP 协议实现的验证功能。我的做法是这样的:先根据 RFC793/RFC9293 的描述,构建一套 TCP 协议的连接状态转移的 spec,可以理解为一个状态机;然后从这套状态机出发,生成状态转移中发生的事件,包括用户调用、消息收发。具体参考 RFC793 的 EventProcess 部分,有 Listen, Connect, Send, Recv, Shutdown, SegmentArrive(Close/Listen/SynSent/SynReceived/Established/FinWait1/…),TimeWaitTimeout, RetransmissionTimeout 等等。

状态机的状态,总体来说是一对 Socket 的状态,包括它们各自的 TCP 状态(CLOSE, LISTEN, ESTABLISHED, FINWAIT1, CLOSEWAIT 等),重传队列,segment receive buffer,stream receive buffer,send buffer,以及传输控制块(snd.nxt, snd.una, rcv.nxt,etc)

由此,事件还会进一步细化为以下的类型:

  • server call invoke
  • server call return
  • client send segment
  • client receive segment

目前,我对 TCP 的建模已经完成,它可以随机地在状态机中游走,生成序列。现在我将它与现实的 TCP 实现进行比较,目前在 linux TCP 上做实验。我的比较方式是这样的。首先有一端是 linux TCP 实现端,称为 server,另一端是测试框架端,称为 client。我按顺序处理状态机生成的序列:

  • 如果事件是在 server 上发生,则
    • 如果是 Call invoke 事件,则让server 进行对应的调用;
    • 如果是 Call return 事件,则等待直到 server 的调用返回。
  • 如果事件是在 client 上发生,则
    • 如果是 Send segment,则发送对应的 segment
    • 如果是 Receive segment,则等待对应的 segment 到来然后返回

client 通过 RAW socket 直接构造 IP datagram 进行发送,并接受 tcp server 端的 datagram并解析。

这一套机制目前能够勉强运转,其核心问题在于 linux TCP 对于事件的处理带有一定的不确定性,最终导致 client 端有可能在 receive segment 时接收不到期待的 segment。譬如 linux TCP 可能会根据延迟、窗口等不明确的具体运行时的变量,自行决定多个 send call 是否被合并作一个 segment 处理;或是在 client 卡着 ACK 不发送的情况下,linux TCP 会在 send() usercall 调用成功的同时,依然只对之前未确认的 segment 进行重传。

事实上就是,我的 model/状态机几乎不可能与真实的事件序列严格匹配。

目前有模糊的想法,即在某些情况下把 Linux TCP 产生的真实 segment 输入到状态机当中,再进行下一步的生成。但这似乎违背了初衷:初衷是对 TCP 协议的行为进行验证;这样做相当于直接接受了 TCP 实现产生的行为是正确的。

希望从以下几个可能的方面得到解惑:

1、是否可以在一定程度上控制 linux TCP 的行为,使其符合生成的 trace?比如对于每一个 send 调用,其内容永远不被合并到一个 segment 发送?

2、是否可以通过模糊的方式进行 segment 的匹配,使得这样的对比序列的方式至少可以执行下去?

3、如果说把 linux TCP 产生的真实 segment 输入到状态机当中,那么在什么情况下可以这样做,使得我的验证框架能够保留一定的验证能力?

#回答

您好,您提出的问题非常专业且切中要害,是进行网络协议一致性测试时必然会遇到的核心挑战:如何处理规范(Spec/Model)的确定性与现实实现(Implementation)的非确定性之间的鸿沟

您的测试框架设计思路非常清晰,即基于模型(状态机)生成测试序列,然后通过一个测试驱动(Test Driver)来执行和观察真实系统的响应,这在测试领域被称为“Model-Based Testing”。您遇到的问题是这个领域的经典问题。

下面我将针对您提出的三个问题,结合协议测试的理论与实践,为您提供一些深入的解答和建议。


#1. 是否可以在一定程度上控制 Linux TCP 的行为,使其符合生成的 trace?

是的,可以在一定程度上“驯服”Linux TCP 的行为,减少其不确定性,使其更接近一个理想化的模型。但这无法做到 100% 的控制。以下是一些关键的控制手段:

#a. 控制 Segment 的合并(Nagle 算法)

您提到的“多个 send call 是否被合并作一个 segment 处理”主要是由 Nagle 算法导致的。您可以通过设置 socket 选项来禁用它:

  • TCP_NODELAY: 这是最直接的方法。在一个 socket上设置 TCP_NODELAY 选项后,send()write() 系统调用会尝试立即将数据包发送出去,而不会等待之前的小数据包被对端 ACK。
    • 用法: 在您的 server 端代码中,对 accept 后的新 socket 设置此选项。
    • 效果: 设置后,每次 send() 调用(如果数据量小于 MSS)通常会对应一个独立的 TCP Segment。这可以极大地提高您的模型与实现行为的一致性。

#b. 控制 ACK 的发送时机(Delayed ACK)

Linux TCP 默认会启用延迟确认(Delayed ACK),即收到数据后不会立即发送 ACK,而是会等待一小段时间(例如 40ms),期望能将 ACK 和它自己要发送的数据“捎带”在一个 Segment 中。这会影响您在 client 端 Receive segment 的时机。

  • TCP_QUICKACK: 这个 socket 选项可以用来临时禁用 Delayed ACK。
    • 用法: 调用 setsockopt 设置 TCP_QUICKACK 为 1 后,内核会立即发送一个 ACK。但请注意,这个选项是临时的,它会在下一次收到数据后被内核重置。因此,如果您希望每次收到数据都立即 ACK,您需要在每次 recv() 之后都重新设置 TCP_QUICKACK
    • 效果: 这使得您可以更精确地控制 ACK 的时序,让 server 端的行为更容易预测。

#c. 控制 TCP Segmentation Offload (TSO/GSO)

即使禁用了 Nagle,一个非常大的 send() 调用(大于 MSS)依然会被 TCP 协议栈分割成多个 Segments。更底层地,网卡驱动还可能有 TSO/GSO (Generic Segmentation Offload) 功能,即协议栈将一个大的数据块传给网卡,由网卡硬件来负责切分成 TCP Segment。

  • 控制方法:
    1. 应用层控制: 在您的测试用例生成中,确保每次 send() 调用的数据量都小于路径的 MSS (Maximum Segment Size)。这样可以避免 TCP 主动分片。
    2. 系统级控制: 可以通过 ethtool 命令关闭网卡的 TSO/GSO 功能 (ethtool -K <interface> tso off gso off)。这在测试环境中是可行的,能确保分片行为完全由内核 TCP 协议栈控制。

#d. 控制重传超时 (RTO)

重传行为由 RTO 决定,而 RTO 是根据动态变化的 RTT(Round-Trip Time)计算的。在测试环境中,可以尝试固定 RTO 来减少不确定性。

  • 控制方法: 这通常比较困难,但可以通过 ip route 命令为特定路由设置固定的 RTT 和 RTO 初始值。例如: ip route change 192.168.1.0/24 dev eth0 rtt 10ms rto_min 20ms 这需要 root 权限,并且仅在受控的网络环境中有效。

小结: 通过组合使用 TCP_NODELAYTCP_QUICKACK、控制发送数据大小以及在测试环境中关闭 TSO/GSO,您可以显著降低 Linux TCP 的行为不确定性,让您的 trace 匹配成功率大大提高。


#2. 是否可以通过模糊的方式进行 segment 的匹配?

完全正确,模糊匹配(Fuzzy Matching)或者说基于属性的匹配(Property-Based Matching)是解决这个问题的正道。与其期待一个完全一致的 Segment,不如验证收到的 Segment 是否满足“某些关键属性”。

您的 Receive segment 事件不应该等待一个字节完全相同的 Segment,而应该等待一个满足特定条件的 Segment

#a. 匹配什么,忽略什么?

  • 必须严格匹配的:
    • 关键控制位 (Flags): SYN, FIN, RST。这些标志直接定义了连接状态的转移,必须精确匹配。
    • ACK 确认号 (ack_seq): ack_seq 必须准确地确认了 client 发送的最后一个字节。这是协议正确性的核心。
  • 需要模糊匹配的:
    • 序列号和数据 (seq 和 payload): 当期待数据时,不应期待一个特定 seqlen 的包。而应该维护一个“期望接收的数据流窗口”,例如 [rcv.nxt, rcv.nxt + rcv.wnd)。收到的任何 Segment,只要其数据范围 [seg.seq, seg.seq + seg.len) 在这个窗口内,并且是顺序的,就应该被接受。
    • 实现方式: client 端需要自己实现一个简化的接收缓冲区(Stream Receive Buffer)。当收到一个数据包时,将其放入缓冲区,然后检查缓冲区中是否已经凑齐了模型所期望的下一个应用层数据块(例如,一个完整的 send 调用对应的数据)。
  • 应该忽略的:
    • 窗口大小 (window_size): 除非您正在专门测试流量控制,否则窗口大小是动态变化的,不应作为匹配的刚性条件。
    • TCP 选项 (Options): 像 Timestamps, SACK 等选项,其具体值是高度动态的,除非测试目标是这些选项本身,否则应该忽略其具体值,只检查其是否存在即可。
    • 源端口号 (Source Port): 对于 server 发来的包,源端口是固定的。但对于 client 来说,它无关紧要。

#b. 示例:

假设您的模型生成了如下序列:

  1. server: send("hello") -> 期待 server 发送一个带 “hello” 的包。
  2. server: send("world") -> 期待 server 发送一个带 “world” 的包。

传统的严格匹配会失败,因为 Linux TCP 可能会发出一个包含 “helloworld” 的包。

模糊匹配的做法:

  1. 模型生成 server: send("hello")。测试驱动调用 send(sock, "hello", 5)
  2. Client 的 Receive segment 事件不再是“等待一个 seq=X, len=5, data='hello' 的包”,而是“等待凑齐’hello’这段数据”。
  3. Client 开始接收。它可能会收到一个 seq=X, len=10, data='helloworld' 的包。
  4. Client 的匹配逻辑检查:
    • 收到的数据 helloworld 的前 5 个字节是 hello。匹配成功!
    • helloworld 存入 client 的接收缓冲区。
    • 此时,第一个 Receive 事件完成。
  5. 模型继续生成 server: send("world")
  6. Client 的 Receive segment 事件现在是“等待凑齐’world’这段数据”。
  7. Client 检查其接收缓冲区,发现 world 已经存在。于是,这个 Receive 事件也立即完成。

这种方式将匹配的粒度从单个网络包(Segment)提升到了应用层的数据块(Stream),这与 TCP 的本质(流式协议)是相符的。


#3. 如果把 Linux TCP 产生的真实 segment 输入到状态机当中,如何保留验证能力?

这是非常高级且有效的测试思想,通常被称为**在线测试(On-the-fly Testing)**或**运行时监控(Runtime Monitoring)**。您完全没有违背初衷,反而让您的验证框架变得更加强大和实用。

关键思想是:您的模型不应再作为测试用例的“生成器”,而应作为真实系统行为的“裁决者”或“预言机”(Oracle)。

#a. 工作流程的转变:

  1. 从“生成-执行”到“引导-观察-验证”:
    • 引导 (Guide): 您的模型不再生成一个严格的事件序列。而是根据当前状态,告诉测试框架:“现在,一个合法的外部事件可以是用户调用send(),或者收到一个ACK包,或者重传计时器超时”。它提供一个合法输入/事件的集合
    • 执行 (Act): 测试框架从这个合法集合中选择一个事件来执行。例如,选择 用户调用send(),于是就去调用 Linux server 的 send()
    • 观察 (Observe): 测试框架捕获 Linux TCP 产生的真实输出(一个或多个 TCP Segments)。
    • 验证 (Verify):
      1. 将观察到的真实输出(如收到的 Segment S’)作为输入,喂给您的状态机模型。
      2. 模型进行状态转移。
      3. 核心验证点
        • 合法性检查 (Legality Check): 在模型当前的状态下,接收到 Segment S’ 是否是一个合法的、允许的转移?如果模型认为在当前状态下根本不应该收到这样的包(例如,在一个已经收到 FIN 的连接上收到了新的数据包),那么您就发现了一个协议实现缺陷。这就是验证能力所在!
        • 状态一致性检查 (State Consistency Check): 转移后,模型的新状态是否满足协议的不变量(Invariants)?例如,snd.una <= snd.nxt 是否依然成立?各种缓冲区的状态是否逻辑自洽?

#b. 这种方式的优势:

  • 拥抱不确定性: 它不再试图消除不确定性,而是将其作为测试的一部分。无论 Linux TCP 因为什么内部原因(调度、负载等)选择发送这个包而不是那个包,只要它发送的包是符合 RFC 规范的,测试就会继续。
  • 更强的发现能力: 这种方法可以探索到更多由静态序列生成无法覆盖的、由真实系统动态行为触发的边缘状态和路径。如果 Linux TCP 产生了一个模型完全没预料到的合法行为,您的模型会学习并跟随这条路径,继续进行验证。如果它产生了一个不合法的行为,测试就会立即失败并报告错误。
  • 解决了同步问题: 由于模型的状态总是由真实系统的输出驱动更新的,两者之间始终保持同步,避免了 trace 分歧导致测试无法继续的问题。

结论: 您应该大胆地采用这种方法。它并没有削弱您的验证能力,而是将验证的焦点从“你是否严格按照我写的剧本演?”(Trace a a’ la Trace B)转移到了“你的每一个即兴表演是否都符合戏剧(RFC)的基本规则?”(Is Action a’ valid in State S?)。后者显然是一种更深刻、更有效的验证。

希望以上分析能为您提供清晰的思路和方向。您的项目非常有价值,祝您成功!