这个实验是完成 tcp sender。详细要求查看 实验文档.

(tip:上面这个链接资源是pdf,在pc端可以在线查看,mobile端点击会跳转到下载)

其实一开始整个实验文档的 tcp sender 描述部分读完一遍整个人是懵的,特别是关于 重传器 的实现。但是前后翻来覆去重复看,以及结合 tcp_sender.hhtcp_sender.cc 里面各个部分的注释。最后还是能理清每个函数到底需要如何去实现。

所以如果有读者是还没吃透实验文档的情况下就到处查找攻略,我还是更建议先自己再好好研究一下。

我的实验代码在 这里

下面记录一下我自己的思路。

# 整体思路

首先除了官方给出的 5 个初始 private 变量之外,这个实验自己还是要添加不少变量进去的,我自己是前前后后陆续添加了多达 12 个变量。

然后官方文档中提到,记录 in flight 的报文段需要使用一个数据结构。我一开始想的是用 std::map,key = seqno,value = TCPsegment。

不过后来意识到,因为采用的是累计ack的机制,所以用最简单的 queue 就可以解决。不需要单独追踪每一个发出去的 segment。

最核心的三个函数是 fill_window()ack_received()tick()。而且每个函数的分工都非常明确:

  • fill_window:只要发送方从接收方得知的 win_size 是非零的,就尝试从 _stream 中读取数据,然后填充到 _segment_out 的发送队列中。值得注意的是 fill_window 负责发送的段还应该包括 syn 和 fin 的设置。

(在 tcp_sender 的视角中,只要放入发送队列就相当于发出去了,之后写 tcp_connection 会处理这个队列的,现在不必担心。)

  • ack_receive():输入的参数是从接收方得到的 ack 和 win_size 信息。通过这两个信息更新当前 tcp_sender 的关联变量信息即可。

值得注意是这个实验实现的 tcp 采用的是 ARP 协议的累计确认模式,也就是说当接收到某个 ack,代表前面的所有段都以及被 receiver 接收到了(回想一下上个实验 receiver 的实现,确实如此)。

这个特性会让这个 ack 函数的实现简单很多:我只需要把接收到的最新 ackno 记录下来,并把所有 seqno 小于这个 ackno 的 segments 从 queue 中 pop 即可。

  • tick():输入的参数是距离上一次 tick 经过的时间。这里主要的问题是怎么实现文档里面提到的 timer。其实很简单,使用一个 unsigned int 变量来作为 _timer,然后每次调用 tick 的时候,把 ms_since_last_tick 累积到 _timer 中即可。如果 _timer 的值超过了超时时间,就触发重传。这个实验要求实现的重传机制是很简单的:总是重传最前一个还没被 ack 的段即可。

三个函数里面还有很多细节这里就不过多赘述,比如在 ack 和 tick 中维护 time_out的值 和 连续重传次数 的值。这些都是照着文档实现就可以。

# 注意事项

这里补充一些个人想到的细节或者是踩过的坑。

  • 关于 _timer

在文档中关于 ack_received() 的实现,有这么一句描述:

“If the sender has any outstanding data, restart the retransmission timer so that it will expire after RTO milliseconds (for the current value of RTO)”

如果有任何飞行中的报文段,才需要 restart timer。如果说 timer 仅仅是用一个 unsigned int 在维护的话,那么 restart 的含义就只能是让这个变量归零。

但是这里我们设想一种情况:如果在某一个 ack_receiver 之后,飞行中的段清空了,此时 timer 归零。

然后接下来的很长一段时间,发送端的应用程序可能出了一点小问题,他一直没有发送新的数据,但是同时也没有关闭写入流。那么此时 timer 就会一直工作下去,但是这个 timer 记录的是哪个报文段的超时时间呢?并没有与之匹配的报文段!所以这里是会有一个逻辑 bug 的。

所以除了 unsigned int 之外我还维护了一个 bool 变量,用来标志 timer 是否应该工作。

还有一点我在其他人的实现中没有提及的地方是:

在 Q&A 中,有一个问题如下:

Q: What should my TCPSender assume as the receiver’s window size before I’ve gotten an ACK from the receiver? A: One byte.

也就是说,window_size 应该初始化为 1。但是这样会给 tick 重传 syn 的时候带来问题:

在 syn 被 ack 之前。window_size 是 0(初始值为 1,发送 syn 后变成 0)。然后 tick() 中维护重传次数和RTO的值有一个进入条件是 “If the window size is nonzero”。这样会导致 syn 无法重传。

这里我的解决方案只是简单地或上重传 syn 的情况,暂时没有想到更好的方案。

  • 关于 send_empty_segment()

这里虽然实验文档说发送空段函数可以用于发送 ack,不过实际上并不需要设置任何的 flag。你也可以设置,因为他没说不可以,对测试是不会有影响的。

但是这个空段的 seqno 需要设置为跟 _next_seqno 一致。

这一个特性我一开始没注意到,当测试程序调用 AckRecived 的时候就会有发送一个 seqno = 0 的错误。我还一直以为是 fill_window() 写错了 因此 de 了很长时间的 bug…

最后发现测试程序里的 AckRecived 在 ack_received 失败的时候会调用这个发送空段函数:

void AckReceived::execute(TCPSender &sender, std::deque<TCPSegment> &) const {
    if (not sender.ack_received(_ackno, _window_advertisement.value_or(DEFAULT_TEST_WINDOW))) {
        sender.send_empty_segment();
    }
    sender.fill_window();
}
  • 关于 ack_received()

实验文档中有这么一句话:

The TCPSender may need to fill the window again if new space has opened up.

其实也很简单,就是在 ack_received() 的结尾手动调用一次自己写的 fill_window()。不过从上一点我们得知,测试程序在 ack 之后其实本身就会调用一次 fill_window。所以我试了一下发现我注释掉手动调用这一行也是没有影响的。

不过这只是个人基于好奇心的小尝试,试过之后还是反注释还原了,以免有面向测试案例编程之嫌(doge)。

  • 关于 fill_window

这里有 3 个需要注意的点:

一是虽然实验文档里面没有说,第一次发送 syn 时能否附带报文段。但是其实是默认不可以的,一个原因是 tcp 作为一个可靠传输协议,就是需要 syn 成功了之后才能传输内容;另一个更重要的、也是直接可以在实验文档中分析得到的细节是:win_size 默认大小是 1,而在 syn 被 ack 之前 win_size 是不会更新的。所以除了单走一个 syn 别无选择。

二是发送报文段的时候一定是得用循环来发送的,因为发送一个报文段受 TCPConfig::MAX_PAYLOAD_SIZE 的限制,但是 _stream 中现存的字节流可能远比这个限制大。请注意这个函数的目的就如他的名字一样:填满 win_size,所以只要 win_size 还没填完并且还有内容可以发送,我们就要尽可能地发送。

三是 fill_window 他只负责发送,不负责重传。这表面上是一句废话,重传本就是 tick 在负责的,但是其实还有一个隐藏的可能出错的地方:那就是 fill_window 绝对不会再次发送他发送过的段,包括一个单独成段的 fin。

这里我在最后一次 debug 才想起来引入了 bool _fin_sent 变量,这个实验也是终于在 11.29 的凌晨一点划上句号。

# 结语

如果有读者读到这里,你会发现我在总体思路中按照 tcp_sender.cc 内的顺序从上往下说了一遍,在注意事项中又按照从下往上的顺序说了一遍。

这便是这个实验完成的曲折过程:实验文档上下来回看、参考资料上下来回看、自己的代码上下来回看,最后才成功通过了全部测试程序。

这里放一些我个人参考过的资料:

  1. kangyu blog
  2. pku flyingpig solution

不过我只有在最后 debug 阶段卡住了很久才会去参考别人的答案,然后学习思路并在自己代码上修改。一开始的整体代码设计还是全靠自己,所以这个实验完成之后成就感还是很大的。

听说接下来的 Lab 4 会很难,然后随后的 Lab 5 & 6 又非常简单。做好心理准备迎接 tcp 最后攻坚了!