任务分析
今天的任务是完成 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。