文章目录
- 0. 任务书说明
- 1. 前置知识
- 1.1 TCP Segment Structure (概念层面)
- 1.2 TCP Sender Responsibilities (发送方职责)
- 1.3 Retransmission Timer and RTO Calculation (Simplified Version for this Lab)
- 1.4 Flow Control (流量控制)
- 1.5 Connection Establishment (SYN related)
- 1.6 Connection Termination (FIN related)
- 1.7 State Management (发送方状态)
- 1.8 Interaction with ByteStream
- 1.9 Absolute vs. Relative Sequence Numbers
- 1.10 "Bytes in Flight"
- 1.11 Distinction between "Zero Window" and "Full Window"
- 2. 指数退避
- 3. 关键状态
- 4. 实现
- 5. 核心架构
- 6. 算法优化
- 8. 核心组件说明
- 9. 数据结构与辅助组件
- 10. 组件间的关系
- TCP 发送器的完整工作流程:push() 和 receive() 方法的交互示例
0. 任务书说明
目标: 实现 TCPSender
,TCP 连接的另一半,负责将出站字节流转换为报文段。
概述 (0 Overview):
- 回顾:
- 检查点 0:
ByteStream
。 - 检查点 1 & 2:
Reassembler
和TCPReceiver
,用于将不可靠数据报中的报文段转换为入站字节流。
- 检查点 0:
- 本次任务: 实现
TCPSender
。 - 最终目标 (检查点 4): 将之前的工作组合成一个完整的 TCP 实现 (
TCPPeer
),包含TCPSender
和TCPReceiver
。
开始 (1 Getting started):
- 与之前检查点类似,获取启动代码。
检查点 3: TCP 发送方 (2 Checkpoint 3: The TCP Sender):
TCPSender
的职责:- 跟踪接收方的窗口(通过接收传入的
TCPReceiverMessage
中的ackno
和window sizes
)。 - 尽可能填充窗口:从
ByteStream
读取数据,创建新的 TCP 报文段(如果需要,包括 SYN 和 FIN 标志),并发送它们。应持续发送报文段,直到窗口已满或出站ByteStream
没有更多数据。 - 跟踪已发送但尚未被接收方确认的报文段(称为“未完成”outstanding 报文段)。
- 如果自发送以来经过了足够长的时间且未完成报文段仍未被确认,则重新发送它们。
- 跟踪接收方的窗口(通过接收传入的
- 基本原理 (ARQ - Automatic Repeat Request): 发送接收方允许发送的内容(填充窗口),并持续重传直到接收方确认每个报文段。
2.1 TCPSender 如何知道报文段丢失了? (How does the TCPSender know if a segment was lost?)
TCPSender
发送TCPSenderMessage
。TCPSender
必须跟踪其未完成的报文段,直到它们占用的序号被完全确认。tick
方法会被周期性调用,指示时间的流逝。TCPSender
负责检查其未完成报文段的集合,并判断最早发送的报文段是否“未完成时间过长”而未被确认。如果是,则需要重传。- “未完成时间过长”的规则:
tick
方法会带一个参数,告知自上次调用以来经过了多少毫秒。用此维护TCPSender
已存活的总毫秒数。不要调用操作系统的时间函数。- 构造
TCPSender
时,会给定一个重传超时 (RTO) 的“初始值”。RTO 是重发未完成 TCP 报文段前等待的毫秒数。RTO 的值会随时间变化,但“初始值”不变 (保存在initial_RTO_ms_
)。 - 实现重传计时器 (retransmission timer): 一个可以在特定时间启动的警报,当 RTO 经过后会“到期”。时间流逝的概念来自
tick
方法的调用。 - 每次发送包含数据(序号空间长度非零)的报文段时(无论是首次发送还是重传),如果计时器未运行,则启动它,使其在 RTO 毫秒后到期(使用当前的 RTO 值)。
- 当所有未完成数据都被确认后,停止重传计时器。
- 如果调用
tick
且重传计时器已到期:- (a) 重传最早(最低序号)未被 TCP 接收方完全确认的报文段。
- (b) 如果窗口大小非零:
- i. 跟踪连续重传次数,并递增它。
TCPConnection
将使用此信息判断连接是否无望。 - ii. 将 RTO 的值加倍 (指数退避 exponential backoff),以减缓在糟糕网络上的重传。
- i. 跟踪连续重传次数,并递增它。
- © 重置重传计时器并启动它,使其在 RTO 毫秒后到期(考虑到 RTO 可能刚刚翻倍)。
- 当接收方给发送方一个
ackno
,确认了新数据的成功接收时:- (a) 将 RTO 重置为其“初始值”。
- (b) 如果发送方仍有未完成数据,则重新启动重传计时器,使其在 RTO 毫秒后到期(使用当前的 RTO 值)。
- © 将“连续重传次数”重置为零。
2.2 实现 TCP 发送方 (Implementing the TCP sender):
- 四个需要处理的重要事件/方法:
void push(const TransmitFunction& transmit)
:- 被要求从出站字节流填充窗口。
- 尽可能多地读取流并发送
TCPSenderMessage
,只要有新字节可读且窗口中有可用空间。 - 通过调用提供的
transmit()
函数发送。 - 确保每个发送的
TCPSenderMessage
完全适合接收方窗口。 - 使每个单独的报文尽可能大,但不超过
TCPConfig::MAX_PAYLOAD_SIZE
。 - 使用
TCPSenderMessage::sequence_length()
计算报文段占用的序号总数(SYN 和 FIN 标志各占一个序号)。 - 窗口大小为零的特殊情况: 如果接收方通告窗口大小为零,
push
方法应假装窗口大小为一。发送方可能最终发送一个被拒绝的单字节,但这也可以促使接收方发送新的确认报文段,显示其窗口中已打开更多空间。这是唯一的特殊情况处理。
void receive(const TCPReceiverMessage& msg)
:- 从接收方收到消息,传达窗口的新左边缘 (
ackno
) 和右边缘 (ackno + window size
)。 TCPSender
应检查其未完成报文段的集合,并移除任何已被完全确认的报文段(即ackno
大于报文段中的所有序号)。
- 从接收方收到消息,传达窗口的新左边缘 (
void tick(uint64_t ms_since_last_tick, const TransmitFunction& transmit)
:- 时间流逝。可能需要重传未完成报文段。
TCPSenderMessage make_empty_message() const
:- 生成并发送一个序号设置正确的零长度报文。
- 用于对等方需要发送
TCPReceiverMessage
(例如确认)但需要附带一个TCPSenderMessage
的情况。 - 这样的空报文段不占用序号,不需要作为“未完成”进行跟踪,也不会被重传。
2.3 常见问题和特殊情况 (FAQs and special cases):
TCPSender
在receive
方法通知前应假设接收方窗口大小为多少? 一。- 如果确认仅部分确认了某个未完成报文段怎么办? 简单处理:将每个报文段视为完全未完成,直到它被完全确认。
- 如果发送了三个包含 “a”, “b”, “c” 的独立报文段且它们都未被确认,之后能用一个包含 “abc” 的大报文段重传吗? 简单处理:单独跟踪每个未完成报文段,当重传计时器到期时,再次发送最早的未完成报文段。
- 是否应在“未完成”数据结构中存储空报文段并重传? 否。只有那些传递数据(即在序号空间中消耗一定长度)的报文段才应被跟踪并可能重传。
1. 前置知识
1.1 TCP Segment Structure (概念层面)
- Sequence Numbers (序列号): 理解字节流如何被分割成段,并且每个字节在流中都有一个唯一的序列号。SYN 和 FIN 标志各自消耗一个序列号。
- Acknowledgment Numbers (确认号, ackno): 接收方用 ackno 来告知发送方它期望接收的下一个字节的序列号。它隐含地确认了所有在 ackno 之前的字节都已成功接收。
- Window Size (窗口大小): 接收方通告给发送方的可用缓冲区大小,表示发送方在收到下一个确认前可以发送多少字节的数据。这是流量控制的核心。
- SYN Flag: 用于建立连接的第一个包,消耗一个序列号。
- FIN Flag: 用于关闭连接的一个方向,消耗一个序列号。
- Payload: 段中实际携带的应用数据。
1.2 TCP Sender Responsibilities (发送方职责)
- Segmentation (分段): 将来自应用层的
ByteStream
切分成适合网络传输的 TCP 段。 - Sequence Number Assignment: 为每个段正确分配序列号。
- Sending Data: 将段发送给网络层。
- Managing the Send Window:
- 跟踪已发送但未确认的数据量 (bytes in flight)。
- 确保发送的数据量不超过接收方通告的窗口大小。
- “Filling the window”: 在窗口允许且有数据可发的情况下,尽可能多地发送数据。
- Retransmission Timeout (RTO) Management:
- 为已发送的段启动计时器。
- 如果超时,重传丢失的段。
- Reliable Data Transfer: 通过序列号、确认和重传确保所有数据最终按序、无差错地到达接收方。
1.3 Retransmission Timer and RTO Calculation (Simplified Version for this Lab)
- Initial RTO: 构造时给定的初始超时值。
- Timer Start: 当发送包含数据的段(包括SYN/FIN)时,如果计时器未运行,则启动。
- Timer Stop: 当所有已发送数据都被确认时。
- Timeout Event:
- 重传最早的未确认段。
- Exponential Backoff (指数退避): 如果接收方窗口非零,将 RTO 加倍。
- 增加连续重传计数。
- 用新的 RTO 重启计时器。
- New Acknowledgment Event:
- 将 RTO 重置回初始值。
- 重置连续重传计数。
- 如果仍有未确认数据,用初始 RTO 重启计时器。
1.4 Flow Control (流量控制)
- 理解接收方窗口 (
rwnd
) 的概念,以及发送方如何根据它来限制发送速率。 - Zero Window Probing: 当接收方通告窗口为0时,发送方需要定期发送小的探测段(实验中要求假定窗口为1发送一个字节),以发现窗口何时重新打开。注意,这只是
push
方法的临时行为,不改变实际记录的窗口大小。
1.5 Connection Establishment (SYN related)
- 第一个发送的段通常是 SYN 段。
- SYN 段消耗一个序列号,需要被确认。
- 发送方在发送第一个数据段(或空的SYN段)时会设置SYN标志。
1.6 Connection Termination (FIN related)
- 当发送方的
ByteStream
被close()
或end_input()
时,发送方需要发送一个 FIN 段。 - FIN 段消耗一个序列号,需要被确认。
- 发送方在发送最后一个包含数据的段或者一个单独的FIN段时会设置FIN标志。
1.7 State Management (发送方状态)
- Next Sequence Number to Send (
next_seqno_
): 下一个要分配给新数据的序列号。 - Outstanding Segments: 维护一个已发送但尚未被确认的段的集合/队列。需要能够找到"最早的"未确认段进行重传。
- Receiver’s Window Information: 记录从接收方收到的最新
ackno
和window_size
。
1.8 Interaction with ByteStream
- 如何从
ByteStream
中读取数据。 - 如何知道
ByteStream
是否已关闭(以便发送FIN)。 - 如何知道
ByteStream
是否为空。
1.9 Absolute vs. Relative Sequence Numbers
- 虽然实验中可能使用绝对序列号(从0开始的
WrappingInt32
),但要理解TCP的序列号是32位无符号整数,会回绕。实验库会处理回绕的比较。
1.10 “Bytes in Flight”
- 指已经发送出去但尚未收到确认的字节数。这个值必须小于等于接收方的窗口大小。计算方式通常是
next_seqno_absolute - ackno_absolute
(需要考虑SYN/FIN)。
1.11 Distinction between “Zero Window” and “Full Window”
- Zero Window: 接收方通告的
window_size
为0。发送方不能发送新数据(除了零窗口探测)。 - Full Window: 接收方通告的
window_size
大于0,但是bytes_in_flight
已经等于或接近window_size
,导致发送方不能再发送新数据。
这个实验简化了的一些真实 TCP 的复杂性,例如:
- Congestion Control (拥塞控制): 真实的 TCP 有复杂的拥塞控制算法 (慢启动、拥塞避免、快速重传、快速恢复)。这个实验中,主要的"退避"行为是通过 RTO 的指数退避来实现的,没有显式的拥塞窗口 (
cwnd
)。 - SACK (Selective Acknowledgment): 真实 TCP 可以选择性确认收到的乱序段,帮助发送方更精确地知道哪些段丢失了。本实验不要求实现 SACK,段必须被完全(连续地)确认。
- Fast Retransmit: 真实 TCP 在收到多个重复 ACK 后会触发快速重传,而不是等待 RTO 超时。本实验不要求。
- Nagle’s Algorithm / Delayed ACKs: 一些优化传输小数据包的算法,本实验不涉及。
2. 指数退避
2.1 什么是指数退避?
指数退避是一种算法,用于在发生冲突或失败(在 TCP 中主要是指数据包丢失导致超时)后,逐渐增加重试操作之间的时间间隔。其核心思想是:如果第一次重试失败了,那么网络(或系统)可能正处于繁忙或不稳定的状态,立即再次重试很可能还会失败,并且会进一步加剧问题。因此,应该等待更长的时间再进行下一次尝试。每次连续失败后,等待时间以指数级增长(通常是翻倍)。
2.2 为什么 TCP 要使用指数退避?
在 TCP 通信中,当发送方发送一个数据段后,如果在 RTO (Retransmission Timeout) 时间内没有收到确认,发送方会认为该数据段丢失了。这种丢失可能由多种原因造成,其中一个主要原因是网络拥堵。
- 避免雪崩式拥堵 (Congestion Collapse): 如果没有指数退避,当网络开始拥堵并导致丢包时,多个发送方可能会同时超时并立即重传。这些重传的数据包会进一步涌入已经拥堵的网络,使得网络状况更加恶化,形成恶性循环,最终可能导致网络完全瘫痪。
- 适应网络状况: 指数退避允许 TCP 发送方在检测到丢包时"退让",给网络一个恢复的机会。通过逐渐增加重传间隔,发送方能够更温和地探测网络容量,并逐步适应当前的网络条件。
2.3 在 CS144 Lab Checkpoint 3 中,指数退避的规则
根据文档 Section 2.1, point 6(b)ii:
-
触发条件:
tick
方法被调用。- 重传计时器已过期。
- 发送方因此重传了一个段。
- 并且,接收方的窗口大小(
window_size
)为非零。
-
动作:
- 将当前 RTO 值加倍 (Double the value of RTO)。
- 这个操作通常在增加连续重传次数之后。
2.4 关键点和注意事项
-
只在实际重传且窗口非零时发生:
- 如果计时器过期,但由于某种原因(例如,逻辑错误或特殊情况)没有实际重传任何段,则不应执行指数退避。
- 非常重要:如果接收方通告的窗口大小为零 (
window_size == 0
),即使发生超时重传(通常是零窗口探测段),也不应该执行指数退避(即不加倍 RTO)。这是因为零窗口表示接收方暂时没有能力接收数据,这更多是接收方的问题,而不是网络拥堵。在这种情况下,发送方会定期发送小的探测段,如果对探测段的响应也超时,RTO 仍然按正常逻辑计时和处理,但不进行指数退避式的加倍。- 文档中明确写着 “If the window size is nonzero … Double the value of RTO”。
-
何时重置回初始 RTO:
- 当发送方收到一个新的确认(
ackno
比之前收到的任何ackno
都大)时,表明数据成功到达接收方,网络可能不再像之前那样拥堵。此时,当前 RTO 会被重置回initial_RTO_ms_
(Section 2.1, point 7a)。这意味着指数退避的效果被清除了。 - 同时,连续重传次数也会被重置为 0 (Section 2.1, point 7c)。
- 当发送方收到一个新的确认(
-
连续性: 指数退避是针对连续的超时重传。如果一次超时导致 RTO 加倍,然后下一次发送的数据成功被确认了,那么 RTO 就会重置。如果之后再次发生超时,RTO 又会从
initial_RTO_ms_
开始,如果再次连续超时,才会再次加倍。
2.5 示例流程
假设 initial_RTO_ms_ = 100ms
,接收方窗口大小 > 0。
- 发送段A,启动计时器(100ms)。
- 100ms后,段A超时。
- 重传段A。
- 连续重传次数变为1。
- 当前 RTO 变为 100 * 2 = 200ms。
- 用新的 RTO (200ms) 重启计时器。
- 200ms后,段A再次超时。
- 重传段A。
- 连续重传次数变为2。
- 当前 RTO 变为 200 * 2 = 400ms。
- 用新的 RTO (400ms) 重启计时器。
- 假设在接下来的400ms内,收到了对段A的确认(或者对段A之后的数据的确认,这意味着段A也被确认了)。
- 当前 RTO 重置回
initial_RTO_ms_
(100ms)。 - 连续重传次数重置为0。
- 如果还有未确认数据,用 100ms 重启计时器。
- 当前 RTO 重置回
3. 关键状态
RTO 的核心思想是:发送方为每个发出的、需要确认的段启动一个计时器。如果在RTO时间内没有收到该段(或其覆盖的序列号)的确认,就认为该段丢失了,需要重传。
根据文档,RTO 的具体逻辑如下:
3.1 需要跟踪的几个关键状态
initial_RTO_ms_
: RTO 的初始值。在TCPSender
构造时设定,并且在特定条件下会重置回这个值。- 当前 RTO 值 (
current_RTO_ms_
): 实际用于计时器设置的 RTO 值。它会根据网络状况动态变化(主要是指数退避)。 - 重传计时器状态:
- 是否正在运行?
- 如果正在运行,它何时过期?(基于
TCPSender
内部累计的总毫秒数)
- 连续重传次数 (
consecutive_retransmissions_count_
): 用于判断连接是否 hopeless,以及配合指数退避。
3.2 RTO 逻辑的触发和行为
-
启动计时器 (Starting the Timer):
- 条件: 当发送一个包含数据(即序列号长度大于0,包括SYN或FIN)的段时(无论是首次发送还是重传)。
- 动作:
- 如果计时器当前没有运行,则启动它。
- 计时器将设置为在
current_RTO_ms_
毫秒后过期。
- (Section 2.1, point 4)
-
停止计时器 (Stopping the Timer):
- 条件: 当所有已发送(outstanding)的数据都已经被确认。
- 动作: 停止重传计时器。
- (Section 2.1, point 5)
-
计时器过期 (Timer Expires - 在
tick
方法中检测):- 条件:
tick
方法被调用,并且检测到重传计时器已经过期。 - 动作:
- a. 重传: 重传最早的(序列号最小的)那个尚未被完全确认的段。
- b. 如果接收方窗口大小非零:
- i. 增加连续重传次数:
consecutive_retransmissions_count_
加 1。 - ii. RTO 指数退避 (Exponential Backoff): 将当前 RTO 值 (
current_RTO_ms_
) 加倍。这是为了在网络拥堵或丢包严重时减少发送频率。
- i. 增加连续重传次数:
- c. 重置并重启计时器:
- 使用更新后(可能已加倍)的
current_RTO_ms_
来重新启动计时器。
- 使用更新后(可能已加倍)的
- (Section 2.1, point 6)
- 条件:
-
收到新的确认 (New Acknowledgment - 在
receive
方法中处理):- 条件: 收到一个
ackno
,该ackno
确认了新的数据(即,这个ackno
比之前收到的任何ackno
都要大)。 - 动作:
- a. RTO 重置: 将当前 RTO 值 (
current_RTO_ms_
) 重置回initial_RTO_ms_
。 - b. 如果仍有未确认的数据 (outstanding data): 重新启动重传计时器。此时计时器将使用刚刚重置为
initial_RTO_ms_
的 RTO 值。 - c. 重置连续重传次数: 将
consecutive_retransmissions_count_
重置为 0。
- a. RTO 重置: 将当前 RTO 值 (
- (Section 2.1, point 7)
- 条件: 收到一个
3.3 总结和关键点
- 时间来源:
tick()
方法是唯一的时间推进机制。计时器的"过期"是相对于TCPSender
内部维护的总运行时间。 - 数据段才启动计时器:只有发送了消耗序列号的段(包含SYN、有效载荷或FIN)才会启动或重启计时器。空ACK段的发送不影响RTO计时器。
- 指数退避的目的:在丢包时,发送方会认为网络可能拥堵,通过加倍RTO来降低重传频率,避免进一步加剧拥堵。
- 新ACK重置RTO:收到新的有效ACK表明网络状况尚可,之前的拥堵可能已经缓解,所以将RTO重置回初始值,并将连续重传计数清零。
- "窗口大小非零"才加倍RTO:这是个重要细节。如果接收方窗口为0,发送方会进行零窗口探测(发送一个字节的段)。如果这种探测段超时,不应该执行指数退避。因为这更多是接收方处理能力问题,而不是网络拥堵。文档明确指出了这一点。
- 最早的段:当超时发生时,总是重传飞行队列中序列号最小的那个未确认段。
4. 实现
4.1 RTO
4.2 核心组件
4.2.1 RTO状态变量
- initial_RTO_ms_:初始超时值,在构造时设定,作为重置RTO的基准值
- current_RTO_ms_:当前使用的超时值,会根据网络状况动态调整
- timer_running_:标记计时器是否在运行
- time_elapsed_:自计时器启动后经过的毫秒数
- consecutive_retx_:连续重传计数,用于指数退避和判断连接是否应该放弃
4.2.2 未确认段队列
- 队列按发送顺序存储段,最早发送的段在队首
- 当计时器超时时,会重传队首的段(最早的未确认段)
- 当收到确认消息时,从队首开始移除已经被确认的段
4.3 工作流程
4.3.1 push()方法 - 发送数据
目的: 从ByteStream读取数据并发送尽可能多的段,填充接收方的窗口
关键步骤:
- 检查FIN标志: 若已发送FIN,则不再发送新数据,直接返回
- 计算有效窗口: 特殊处理零窗口情况(视为1),确定可用空间
- 循环创建发送段: 直到填满窗口或无更多数据可发送
- 创建新消息并设置序列号
- 视情况设置SYN标志(连接开始)
- 计算可发送的数据量(受窗口和MAX_PAYLOAD_SIZE限制)
- 从ByteStream读取数据
- 视情况设置FIN标志(流结束)
- 通过transmit回调发送段
- 将段添加到未确认队列
- 启动计时器: 如有数据发送且计时器未运行,启动重传计时器
特殊处理:
- 零窗口处理(视为1)
- SYN和FIN标志占用序列号空间
- 段大小限制在MAX_PAYLOAD_SIZE以内
4.3.2 tick()方法 - 时间推进与超时处理
目的: 处理时间推进,检测超时,执行重传
关键步骤:
- 检查计时器: 确认计时器是否在运行
- 更新时间: 累加经过的时间(time_elapsed_ += ms)
- 检查超时: 判断是否达到重传条件(time_elapsed_ ≥ current_RTO_ms_)
- 重传段: 如超时,重传队首未确认段
- 窗口检查: 如窗口大小>0
- 增加连续重传计数
- 将RTO值翻倍(指数退避)
- 重置计时器: 无论窗口大小,都重置计时器(time_elapsed_ = 0)
特殊处理:
- 零窗口特例: 不执行指数退避
- RTO翻倍限制于窗口非零情况
tick()方法实现了计时器的核心逻辑:
-
检查计时器是否运行,如果运行则更新已经过时间:
time_elapsed_ += ms_since_last_tick
-
检查计时器是否超时:
if (time_elapsed_ >= current_RTO_ms_) { // 计时器超时处理 }
-
计时器超时时的处理:
- 重传最早的未确认段(队列中的第一个段)
- 如果窗口大小大于0:
- 增加连续重传计数
- 执行指数退避(将current_RTO_ms_值加倍)
- 重置计时器(time_elapsed_ = 0),但保持计时器运行状态
4.3.3 receive()方法 - 处理确认消息
目的: 处理从接收方收到的确认消息,更新窗口和移除已确认段
关键步骤:
- 更新窗口大小: 记录接收方通告的新窗口大小
- 检查确认号: 确认是否包含ackno字段
- 验证确认号: 转换为绝对序列号并验证有效性
- 检查新确认: 判断是否确认了新数据(比之前的ackno大)
- 处理确认号:
- 更新内部ackno_记录
- 循环移除已完全确认的段
- 更新outstanding_seqno_计数
- 重置状态: 如有新确认
- 重置RTO为初始值
- 重置连续重传计数为0
- 重置计时器或在无未确认段时停止计时器
特殊处理:
- 序列号转换(Wrap32与绝对序列号)
- 确认号有效性验证
- 段的完全确认判定(所有序列号小于ackno)
4.4 RTO状态流转
- 初始状态 → 运行状态:当发送包含数据的段且计时器未运行时
- 运行状态 → 超时状态:当经过RTO时间后
- 超时状态 → 初始状态:重传段并重置计时器后
- 运行状态 → 初始状态:当所有段都被确认,停止计时器
4.5 特殊情况处理
-
窗口大小为0时:
- 在push()方法中,将窗口大小视为1(仅在push方法中这样处理)
- 在重传时不执行指数退避(不加倍RTO值)
-
空消息(零序列号长度):
- 不加入未确认队列
- 不会触发计时器的启动或重置
5. 核心架构
5.1 状态管理设计
TCP发送器需要维护的关键状态可以更优化地组织为三个逻辑组:
5.1.1 连接状态管理
next_seqno_
:下一个待分配的绝对序列号bytes_in_flight_
:已发送未确认的字节数(更准确的性能指标)syn_sent_
/fin_sent_
:连接建立/终止标志stream_ended_
:输入字节流是否已结束
5.1.2 流量控制状态
last_ackno_received_
:最近收到的确认号(绝对序列号)receiver_window_size_
:接收方通告的窗口大小available_window_space_
:实际可用窗口空间(优化计算)zero_window_probe_active_
:是否处于零窗口探测模式
5.1.3 重传机制状态
current_RTO_ms_
/initial_RTO_ms_
:当前/初始RTO值timer_running_
:计时器运行状态标志time_elapsed_ms_
:计时器启动后的累计时间consecutive_retransmissions_
:连续重传计数
5.2 数据结构优化
5.2.1 未确认段队列
struct OutstandingSegment {
TCPSenderMessage message; // 完整TCP消息
uint64_t send_time; // 发送/重传时间
uint64_t absolute_seqno; // 起始绝对序列号(快速查询)
uint64_t sequence_length; // 序列号空间长度(预计算)
};
std::deque<OutstandingSegment> outstanding_segments_;
- 使用
deque
而非vector
以优化队首移除操作 - 预计算并存储序列长度,避免重复计算
- 存储绝对序列号便于快速比较和区间检查
6. 算法优化
6.1 窗口管理优化
6.1.1 窗口填充策略
// 有效窗口计算
uint64_t effective_window = window_size_ > 0 ? window_size_ : 1;
uint64_t available_window = std::min(
effective_window,
ackno_ + effective_window > next_seqno_ ?
ackno_ + effective_window - next_seqno_ : 0
);
// 分段策略
uint64_t max_segment_size = std::min(
TCPConfig::MAX_PAYLOAD_SIZE,
available_window
);
- 高效处理边界情况(窗口大小为0,窗口完全填满)
- 自适应段大小,根据可用窗口动态调整
6.1.2 零窗口探测优化
// 只在真正需要时发送零窗口探测
if (window_size_ == 0 && !outstanding_segments_.empty() &&
time_since_last_segment > current_RTO_ms_) {
// 发送单字节段作为探测
send_zero_window_probe(transmit);
}
- 精确控制探测时机,避免过度探测
- 区分常规重传和零窗口探测,分别处理
6.2 重传机制优化
6.2.1 计时器管理优化
void start_timer() {
timer_running_ = true;
time_elapsed_ms_ = 0;
timer_start_time_ = current_time_ms_; // 记录启动时刻
}
bool timer_expired() const {
return timer_running_ &&
time_elapsed_ms_ >= current_RTO_ms_;
}
- 添加显式的计时器状态检查方法
- 使用相对时间和绝对时间混合策略提高精度
6.2.2 智能重传策略
// 计时器到期处理
if (timer_expired()) {
// 只重传最早的未确认段
const auto& oldest_segment = outstanding_segments_.front();
transmit(oldest_segment.message);
// 记录重传时间和更新重传次数
oldest_segment.send_time = time_elapsed_ms_;
// 指数退避(仅在窗口非零时)
if (window_size_ > 0) {
consecutive_retransmissions_++;
current_RTO_ms_ *= 2; // 指数退避
}
// 重置计时器
reset_timer();
}
- 精确跟踪每个段的发送/重传时间
- 优化指数退避策略,避免非必要的RTO增长
6.3 段确认处理优化
6.3.1 高效确认处理
// 快速路径: 检查是否有新数据被确认
bool new_data_acked = ackno > last_ackno_received_;
// 移除已确认段
while (!outstanding_segments_.empty()) {
auto& segment = outstanding_segments_.front();
// 使用预计算的绝对序列号范围快速判断
if (segment.absolute_seqno + segment.sequence_length <= ackno) {
bytes_in_flight_ -= segment.sequence_length;
outstanding_segments_.pop_front();
} else {
break;
}
}
// 仅在确认新数据时重置RTO
if (new_data_acked) {
current_RTO_ms_ = initial_RTO_ms_;
consecutive_retransmissions_ = 0;
// 条件性重启计时器
if (!outstanding_segments_.empty()) {
reset_timer();
} else {
stop_timer();
}
}
- 使用快速路径检查优化常见情况
- 预计算和缓存序列号范围减少运行时计算
- 批量处理已确认段,提高效率
8. 核心组件说明
8.1 Checkpoint 0: ByteStream
- 实现基本的字节流抽象,包含Writer和Reader两个接口
- 提供容量控制机制,限制可缓存的数据量
- 作为整个协议栈的基础数据流容器
- 类似于TCP套接字提供给应用层的流接口
8.2 Checkpoint 1: Reassembler
- 负责接收带索引的字节子串并按顺序重组
- 处理网络中可能出现的乱序、重复数据
- 使用ByteStream作为输出目标
- 将不连续的数据片段重组为连续的字节流
8.3 Checkpoint 2: TCPReceiver
- 处理32位TCP序列号(Wrap32)与64位内部索引的转换
- 接收TCP段(包含SYN, 数据载荷, FIN等)
- 使用Reassembler重组数据流
- 生成确认信息(ackno)和窗口大小(window)
- 实现TCP接收端的核心功能
8.4 Checkpoint 3: TCPSender (本次实验)
- 从ByteStream读取数据并创建TCP段
- 管理SYN和FIN等连接控制标志
- 遵循接收方通告的窗口限制发送数据
- 跟踪未确认的段并实现超时重传
- 实现指数退避(exponential backoff)的RTO机制
- 确保可靠数据传输
8.5 Checkpoint 4: TCPConnection
- 整合TCPSender和TCPReceiver
- 管理TCP连接的状态变化
- 处理三次握手和四次挥手等连接操作
- 实现完整的TCP协议状态机
- 向应用层提供可靠的双向字节流
9. 数据结构与辅助组件
9.1 TCP协议数据结构
- TCPSenderMessage: 发送方消息格式
- TCPReceiverMessage: 接收方确认消息格式
- Wrap32: 处理32位序列号的封装类型
- 窗口管理: 流量控制机制
10. 组件间的关系
10.1 层次关系
- ByteStream → Reassembler → TCPReceiver
- ByteStream → TCPSender
- TCPSender + TCPReceiver → TCPConnection
- TCPConnection → 应用层
10.2 数据流关系
- 发送方向: 应用层 → ByteStream → TCPSender → 网络层
- 接收方向: 网络层 → TCPReceiver → Reassembler → ByteStream → 应用层
10.3 功能依赖
- TCPSender依赖ByteStream读取数据
- TCPReceiver依赖Reassembler重组数据
- Reassembler依赖ByteStream存储数据
- TCPConnection同时依赖TCPSender和TCPReceiver
TCP 发送器的完整工作流程:push() 和 receive() 方法的交互示例
通信场景设置
假设我们有以下初始条件:
- 发送器初始序列号(ISN) = 1000
- 初始重传超时(RTO) = 100ms
- 接收方初始窗口大小 = 10 字节
- 待发送的数据:“HelloWorld!”(12个字节)
- 发送器初始状态:
next_seqno_ = 1000 ackno_ = 0 window_size_ = 10 outstanding_seqno_ = 0 timer_running_ = false syn_sent_ = false fin_sent_ = false
第一步:首次调用 push()
应用程序调用 push()
方法开始发送数据:
sender.push(transmit_function);
push() 方法执行流程:
// 检查 FIN 标志
if (fin_sent_) { // 为 false,继续
return;
}
// 计算窗口
uint64_t effective_window = (window_size_ > 0) ? window_size_ : 1; // = 10
available_window = 10;
// 循环发送数据
while (available_window > 0) {
// 创建第一个段(包含 SYN)
TCPSenderMessage msg1;
msg1.seqno = Wrap32::wrap(1000, 1000); // = 1000
msg1.SYN = true; // 设置 SYN 标志
syn_sent_ = true;
available_window--; // 为 SYN 减少窗口空间,现在是 9
// 计算可发送的数据量
uint64_t payload_size = min(9, MAX_PAYLOAD_SIZE); // = 9
// 读取数据
string payload = "HelloWorl"; // 9 字节数据
msg1.payload = payload;
// 发送段
transmit(msg1);
// 更新状态
uint64_t msg1_size = 1 + 9; // SYN + 数据
outstanding_seqno_ += msg1_size; // = 10
next_seqno_ += msg1_size; // = 1010
// 添加到未确认队列
outstanding_segments_.emplace_back(msg1, time_elapsed_);
// 启动计时器
if (!timer_running_) {
timer_running_ = true;
time_elapsed_ = 0;
}
// 更新窗口
available_window -= 9; // = 0
// 窗口用尽,退出循环
break;
}
发送结果:
- 发送了一个包含 SYN 和 9 字节数据 “HelloWorl” 的段
- 发送器状态更新为:
next_seqno_ = 1010 outstanding_seqno_ = 10 syn_sent_ = true timer_running_ = true time_elapsed_ = 0 outstanding_segments_ = [SYN+"HelloWorl"]
- 剩余未发送数据:“d!”(2字节)
第二步:接收第一个确认
接收方收到 SYN+“HelloWorl”,发送确认消息:
- ackno = 1010(确认收到了 SYN 和 9 字节数据)
- window_size = 8(可继续接收 8 字节)
发送器接收到这个消息:
sender.receive(receiver_message);
receive() 方法执行流程:
// 更新窗口大小
window_size_ = 8;
// 处理确认号
uint64_t abs_ackno = 1010; // 已转换为绝对序列号
// 检查有效性
if (abs_ackno > next_seqno_) { // 1010 <= 1010,是有效确认
// 继续处理
}
// 检查是否是新确认
bool new_data_acked = (abs_ackno > ackno_); // 1010 > 0,是新确认
// 更新确认号
ackno_ = 1010;
// 移除已确认的段
// 检查 SYN+"HelloWorl":seg_end = 1000 + 10 = 1010
// 1010 <= 1010,已完全确认,移除
outstanding_seqno_ -= 10; // = 0
outstanding_segments_.pop_front(); // 队列为空
// 处理新确认
if (new_data_acked) {
// 重置 RTO
current_RTO_ms_ = initial_RTO_ms_; // = 100ms
consecutive_retransmissions_ = 0;
// 处理计时器
if (!outstanding_segments_.empty()) {
reset_timer();
} else {
stop_timer(); // 停止计时器
}
}
处理结果:
- 窗口大小更新为 8
- 确认号更新为 1010
- 未确认队列清空
- 计时器停止
- 发送器状态更新为:
ackno_ = 1010 window_size_ = 8 outstanding_seqno_ = 0 timer_running_ = false outstanding_segments_ = []
第三步:再次调用 push() 发送剩余数据
应用程序再次调用 push()
方法:
sender.push(transmit_function);
push() 方法执行流程:
// 检查 FIN 标志
if (fin_sent_) { // 为 false,继续
return;
}
// 计算窗口
uint64_t effective_window = (window_size_ > 0) ? window_size_ : 1; // = 8
available_window = 8;
// 循环发送数据
while (available_window > 0) {
// 创建第二个段
TCPSenderMessage msg2;
msg2.seqno = Wrap32::wrap(1010, 1000); // = 1010
// 计算可发送的数据量
uint64_t payload_size = min(8, MAX_PAYLOAD_SIZE); // = 8
// 读取剩余数据
string payload = "d!"; // 2 字节,全部剩余数据
msg2.payload = payload;
// 检查是否需要设置 FIN
// 流已结束且有空间放置 FIN
bool should_set_fin = true;
available_window--; // 为 FIN 减少窗口空间,现在是 7
// 设置 FIN 标志
msg2.FIN = true;
fin_sent_ = true;
// 发送段
transmit(msg2);
// 更新状态
uint64_t msg2_size = 2 + 1; // 数据 + FIN
outstanding_seqno_ += msg2_size; // = 3
next_seqno_ += msg2_size; // = 1013
// 添加到未确认队列
outstanding_segments_.emplace_back(msg2, time_elapsed_);
// 启动计时器
if (!timer_running_) {
timer_running_ = true;
time_elapsed_ = 0;
}
// FIN 已发送,退出循环
break;
}
发送结果:
- 发送了一个包含 2 字节数据 “d!” 和 FIN 标志的段
- 发送器状态更新为:
next_seqno_ = 1013 outstanding_seqno_ = 3 fin_sent_ = true timer_running_ = true time_elapsed_ = 0 outstanding_segments_ = ["d!"+FIN]
- 所有数据已发送完毕
第四步:网络丢包和超时重传
假设第二个段在传输过程中丢失。经过一段时间,发送器的 tick()
方法被调用:
// 经过 100ms
sender.tick(100, transmit_function);
tick() 方法执行流程:
// 检查计时器状态
if (timer_running_) { // 为 true,继续
// 更新时间
time_elapsed_ += 100; // = 100ms
// 检查是否超时
if (time_elapsed_ >= current_RTO_ms_) { // 100 >= 100,触发超时
// 重传最早的未确认段
if (!outstanding_segments_.empty()) {
// 重传 "d!"+FIN
transmit(outstanding_segments_.front().message);
// 指数退避
if (window_size_ > 0) { // 为 8,条件成立
consecutive_retransmissions_++; // = 1
current_RTO_ms_ *= 2; // = 200ms
}
}
// 重置计时器
time_elapsed_ = 0;
}
}
执行结果:
- 重传了 “d!”+FIN 段
- RTO 值增加为 200ms
- 连续重传计数增加为 1
- 计时器重置,继续运行
- 发送器状态更新为:
current_RTO_ms_ = 200 consecutive_retransmissions_ = 1 time_elapsed_ = 0
第五步:接收最终确认
接收方收到重传的 “d!”+FIN 段,发送最终确认:
- ackno = 1013(确认所有数据和 FIN)
- window_size = 10(窗口恢复)
发送器接收到这个消息:
sender.receive(final_ack);
receive() 方法执行流程:
// 更新窗口大小
window_size_ = 10;
// 处理确认号
uint64_t abs_ackno = 1013; // 已转换为绝对序列号
// 检查有效性
if (abs_ackno > next_seqno_) { // 1013 <= 1013,是有效确认
// 继续处理
}
// 检查是否是新确认
bool new_data_acked = (abs_ackno > ackno_); // 1013 > 1010,是新确认
// 更新确认号
ackno_ = 1013;
// 移除已确认的段
// 检查 "d!"+FIN:seg_end = 1010 + 3 = 1013
// 1013 <= 1013,已完全确认,移除
outstanding_seqno_ -= 3; // = 0
outstanding_segments_.pop_front(); // 队列为空
// 处理新确认
if (new_data_acked) {
// 重置 RTO
current_RTO_ms_ = initial_RTO_ms_; // = 100ms
consecutive_retransmissions_ = 0;
// 处理计时器
if (!outstanding_segments_.empty()) {
reset_timer();
} else {
stop_timer(); // 停止计时器
}
}
最终状态:
- 窗口大小更新为 10
- 确认号更新为 1013
- 未确认队列清空
- 计时器停止
- RTO 重置为初始值
- 连续重传计数重置为 0
- 发送器状态更新为:
ackno_ = 1013 window_size_ = 10 outstanding_seqno_ = 0 timer_running_ = false current_RTO_ms_ = 100 consecutive_retransmissions_ = 0 outstanding_segments_ = []
第六步:尝试再次发送数据
应用程序再次调用 push()
方法:
sender.push(transmit_function);
push() 方法执行流程:
// 检查 FIN 标志
if (fin_sent_) { // 为 true,终止发送
return;
}
结果:
- 因为 FIN 已发送,表示流已结束,不再发送数据
- 发送器状态保持不变