KPBOT / CS144 Lab3 笔记 — TCPSender:超时重传与滑动窗口

Created Thu, 23 Jul 2026 00:00:00 +0000 Modified Thu, 23 Jul 2026 00:00:00 +0000

任务分析

今天的任务是完成 Lab3,实现一个 TCPSender。

TCPSender 比 TCPReceiver 更加复杂,因为涉及到定时器来进行超时重传机制。

标准的 TCPSender 是一个 GBN 与 SR 都有借鉴的机制,当然更倾向于 GBN,但又有不同。

GBN:

发送窗口:
[0][1][2][3]

ACK 1 丢失

timeout

重传:
[1][2][3]

TCP:

发送窗口:
[0][1][2][3]

ACK=3

表示:
0,1,2 已经收到

直接滑动窗口

这里就不多赘述了。

CS144 因为 TCPSegment 是 ACK 而不是 SACK,不会返回一个 ack 区间,所以很明显它是让我们做一个累积重传的类 GBN 机制的 TCPSender(当然,不是真的 GBN 机制)。

这极大地简化了工作量,毕竟加入 SR 机制的话,每一个发送请求都得维护一个计时器,然后计时器需要定义一个单独的数据结构来存发送数据的编号。

那么什么是 GBN 呢?GBN 的全称是 Go-Back-N。也就是说打出去的一个个包,只维护对滑动窗口头部那个包的 ACK 状态,只需要维护一个 timer 去进行超时重传——这个 timer 只用于记录最老的处于发送中状态的 seg 的 timer 值。当超时发生时,对窗口内所有已经发送但没有 ACK 的包进行重传,从而维护整个序列的完整性。缺点也很明显:会导致不必要重传。

那么什么是 SR 呢?SR 是 Selective Repeat。对打出去的每个包都维护一个 timer,当 ACK 超时后,单独对对应的包进行重传。通过这个机制可以做到对丢包的精准重传,在网络波动很大的场景有很大优势。但缺点就是实现复杂,并且需要缓存大量乱序的状态,对空间要求比较高。

明白利弊后,开始着手实现以类 GBN 为主的累积 ACK 重传的 TCPSender。

各函数实现思路

私有变量的定义

和 TCPReceiver 一样,TCP 的输入与输出都需要确认 SYN 与 FIN 状态,所以为了维护这个状态,需要定义 _syn_flag_fin_flag

然后考虑重传机制。由于是累积重传,只需维护一个 timer 去触发重传,所以只需要一个变量记录当前的 time 值,与一个 _timer_running 来维护这个 timer 是否启用(_segments_outstanding 已空的话自然就不需要启动 timer 占用资源了)。

维护一个 _window_size 和一个 _segments_outstanding 来记录滑动窗口的大小与正在发送中的 segments(即我们的 TCP 包)。

一个 _recv_ackno 来维护窗口 top ackno。

_retransmission_timeout 用于维护超时重传的阈值。

_consecutive_retransmission 维护超时重传的次数。

这大概就是变量设计。

fill_window 与 ACK 空请求

Lab3 startcode 中 fill_window 的要求如下:

//! \brief create and send segments to fill as much of the window as possible

尽可能地填满 segment 并打出包。

首先考虑 SYN 的发送——假设还没有建立 SYN 同步请求,直接打出 SYN 包即可:

if (!_syn_flag) {
    TCPSegment seg;
    seg.header().syn = true;
    send_segment(seg);
    _syn_flag = true;
    return;
}

其中 send_segment 是对打包行为的抽象——获取要打出的 seg,然后对其包装:包装 seq 值、更新内部维护的 next_seqno、更新正在传输中的 bytes 数,然后根据定时器占用情况初始化定时器:

void TCPSender::send_segment(TCPSegment &seg) {
    seg.header().seqno = wrap(_next_seqno, _isn);
    _next_seqno += seg.length_in_sequence_space();
    _bytes_in_flight += seg.length_in_sequence_space();
    _segments_out.push(seg);
    _segments_outstanding.push(seg);
    if (!_timer_running) {
        _timer_running = true;
        _timer = 0;
    }
}

根据本项目定义,seg 打出直接就是放入 _segments_out 这个队列就行。而 _segments_outstanding 则是自己维护的一个滑动窗口,记录正在打出的 segments。

然后考虑其它类型数据的 fill。首先获取窗口大小(在 TCP 协议中 window 的大小是动态改变的,依赖于接收端返回的 window_size,可以动态调节流量大小,避免拥塞)。然后计算可以支持发送数据的大小 remain——即窗口内未发送数据的量。

只要 remain != 0 且当前没有发送过挥手请求(!_fin_flag),就一直循环填充:

while ((remain = window - (_next_seqno - _recv_ackno)) != 0 && !_fin_flag)

循环内要做的事就是从 _stream 取数据,不断填充尽可能大的 seg 然后打出去;或是 _stream 已经结束,那就打 FIN 请求:

void TCPSender::fill_window() {
    if (!_syn_flag) {
        TCPSegment seg;
        seg.header().syn = true;
        send_segment(seg);
        _syn_flag = true;
        return;
    }
    size_t window = _window_size > 0 ? _window_size : 1;
    size_t remain;
    while ((remain = window - (_next_seqno - _recv_ackno)) != 0 && !_fin_flag) {
        TCPSegment seg;
        size_t size = min(TCPConfig::MAX_PAYLOAD_SIZE, remain);
        string payload = _stream.read(size);
        seg.payload() = Buffer(std::move(payload));
        if (seg.length_in_sequence_space() < window && _stream.eof()) {
            _fin_flag = true;
            seg.header().fin = true;
        }
        if (seg.length_in_sequence_space() == 0) {
            return;
        }
        send_segment(seg);
    }
}

然后是发送 ACK 请求——也就是一个没有填充数据的请求,ACK 头直接是 TCP 流自己决定的,所以只需要获取 wrap 的 seq 到要发送的空 seg 里就行:

void TCPSender::send_empty_segment() {
    TCPSegment seg;
    seg.header().seqno = wrap(_next_seqno, _isn);
    _segments_out.push(seg);
}

ACK 接收与 timer 处理

ACK 接收后 timer 处理都是本次任务的大难点。

首先来看 ACK receiver。第一步肯定是将接收到的 ack_seqno 转换为绝对序列,由于之前已经实现过 unwrap,所以转换快速略过。

然后是判断 ackno 的合法性——即 ackno 是否超过了我们当前发过的 seqno 的值(它不可能超过,我们都没发,它怎么 ACK)。当出现这个问题时这个 ACK 肯定不合法,返回 false。

然后判断 ackno 是否小于等于 _recv_ackno——小于等于说明它的 ACK 绝对被接收过了,直接返回 true。

处理完异常值后就可以将当前 ackno 记录为 _recv_ackno 了。

然后是处理 _segments_outstanding。因为 TCP 的 ACK 是累计 ACK——即 ACK 的值代表这个值前的全部数据已经收到——所以 _segments_outstanding 的处理也就异常直接:不断找出 _segments_outstanding 中最前面的一个 seg,检查其序列 + 长度区间是否超过了 ACK。若没有,就将这个 seg pop 即可,同时更新 _bytes_in_flight。一直取到 _segments_outstanding 为空或者遇到第一个超范围的 seg 为止。

然后调用 fill_window 继续填充新的滑动窗口,然后清除超时状态与超时阈值。如果 _segments_outstanding 没有全部清空,依旧需要开启定时器并将定时器重置,不断监控 front seg。

bool TCPSender::ack_received(const WrappingInt32 ackno, const uint16_t window_size) {
    size_t abs_ackno = unwrap(ackno, _isn, _recv_ackno);
    if (abs_ackno > _next_seqno) {
        return false;
    }
    _window_size = window_size;
    if (abs_ackno <= _recv_ackno) {
        return true;
    }
    _recv_ackno = abs_ackno;
    while (!_segments_outstanding.empty()) {
        TCPSegment seg = _segments_outstanding.front();
        if (unwrap(seg.header().seqno, _isn, _next_seqno)
            + seg.length_in_sequence_space() <= abs_ackno) {
            _bytes_in_flight -= seg.length_in_sequence_space();
            _segments_outstanding.pop();
        } else {
            break;
        }
    }
    fill_window();
    _retransmission_timeout = _initial_retransmission_timeout;
    _consecutive_retransmission = 0;
    if (!_segments_outstanding.empty()) {
        _timer_running = true;
        _timer = 0;
    }
    return true;
}

timer 处理与指数退避

tick 由外部时钟不断调用,向其填充时间值。timer 累积这个时间值直到 timeout,于是便可以开启超时重传(假设 _segments_outstanding 未空)。

对于超时 seg,只需重新将其 push 到 _segments_out 即可。然后重置计时器,设定 timeout *= 2_consecutive_retransmission += 1

其中每次超时把重传超时时间(RTO, Retransmission Timeout)乘 2,叫指数退避(exponential backoff),主要目的是避免网络拥塞时发送端持续增加网络压力。

例如:

初始: RTO = 1000ms

第一次超时:
  等待 1s,没有收到 ACK
  重传
  RTO = 2000ms

第二次超时:
  等待 2s
  重传
  RTO = 4000ms

第三次超时:
  等待 4s
  重传
  RTO = 8000ms

原因:假设发生超时,有两种可能:

1. 数据包丢了:

sender ----X---- receiver

重传可以恢复。

2. ACK 只是延迟:

sender -------- packet -------->
              (网络拥堵)

receiver
        <-------- ACK

ACK 还在路上,但 sender 认为丢了,于是重发。

如果 sender 不增加 RTO:

每1秒:
发送 -> 等待 -> 重传 -> 发送 -> 等待 -> 重传

网络拥塞时大量 sender 都这样,会进一步增加流量:

拥塞 → 丢包 → 重传 → 更多拥塞 → 更多丢包

形成拥塞崩溃(congestion collapse)


指数退避的思想:

网络越可能拥塞,越应该减少发送频率。

所以:

正常:      1s 等待
第一次失败: 2s 等待
第二次失败: 4s 等待
第三次失败: 8s 等待

给网络恢复时间。

void TCPSender::tick(const size_t ms_since_last_tick) {
    _timer += ms_since_last_tick;
    if (_timer >= _retransmission_timeout && !_segments_outstanding.empty()) {
        _retransmission_timeout *= 2;
        _timer_running = true;
        _timer = 0;
        _segments_out.push(_segments_outstanding.front());
        _consecutive_retransmission++;
    }
    if (_segments_outstanding.empty()) {
        _timer_running = false;
    }
}

这一点是 TCP 超时重传防止流量拥塞的流控制核心。

总结

Lab3 实现的 TCPSender 核心就是三件事:

1. 填充窗口(fill_window)

从 ByteStream 读数据,按 MSS 切分成 segment,塞满接收方通告的窗口大小。SYN 和 FIN 各占一个 sequence number,需要特殊处理。

2. 处理 ACK(ack_received)

TCP 使用累积 ACK——收到 ACK=N 意味着 N 之前的字节全部到达。据此清理 _segments_outstanding 中已确认的 segment,释放窗口空间继续发送。

3. 超时重传(tick)

维护单一定时器,追踪最老的未确认 segment。超时触发重传,同时 RTO 翻倍(指数退避),避免拥塞崩溃。收到有效 ACK 后重置 RTO 和重传计数。

整体数据流:

ByteStream → fill_window → segments_out → 网络
                                            |
                              ack_received ←─┘
                                    |
                              清理 outstanding
                              重置 timer
                              继续 fill_window

到此为止,TCP 的发送端和接收端都已实现。Lab4 将把 Sender 和 Receiver 组装成完整的 TCPConnection。