0.Overview
与 Lab2 相反的是,此次实验要我们实现一个 TCPSender。
我们都知道 TCP 协议是全双工通信,信道两端的发送方和接收方各自都能够收发信息。在 TCP 中,接收方接收到信息的同时还需要向发送方发送一个确认分组;同理,不仅需要发送数据负载,还需要在确认分组迟迟不到(丢失确认/数据丢包)时重传分组。
在完成了 Lab3 的工作后,Lab4 的工作将会结合之前的实验代码,完成一个 TCP 协议的完整实现。
1.需求分析
Lab3 的实现因为发送方的行为比较复杂(指 TCP 的超时重传和滑动窗口机制),所以代码需求也比较多。
1.1 核心流程
文档告诉我们 TCPSender
的核心需求如下:
- 记录接收端告知它的窗口大小(从
TCPReceiverMessages
中读取 window sizes); - 从
ByteStream
中读取 payload,在窗口大小允许的情况下追加控制位 SYN 和 FIN(仅在最后一次发出消息时),并持续填充报文的payload
部分直到窗口已满或没东西可以读,再将其发送出去; - 追踪哪些已发的报文是没收到回复的,这部分报文被称为“未完成的字节”;
- 未完成的字节在足够长的时间后依然没得到确认,重传(也就是指 TCP 的自动重传部分)。
这里首先需要明确一点:sender 发出的报文段中的 SYN 一定为 true 的情况只有一个:在第一次发出报文时,后续报文的 SYN 值完全取决于接收方有没有发来对第一个报文的确认(这时的 SYN 是可以为 false 的)。
关于超时重传部分,文档介绍到:TCPSender
的所有者会周期性地调用 tick()
方法告知距离上次调用该方法经过了多少毫秒(ms)。如果这个时间超过了重传时间间隔 RTO,立刻重发最早没被确认的报文,并且只发一次。
此外,为了避免大家面向样例编程,目前提供的测试样例都是不完全的,完整的测试将在 Lab4 完成了 TCP 的整个实现后提供。(所以要好好考虑当前的代码实现,减少后面倒回来修改代码的次数)
接下来详细查看一下关于自动重传协议 ARQ,我们需要做些什么。
1.2 ARQ 的需求
文档中关于 TCPSender
如何知道丢失了数据、需要重传的情况说明得非常详细,这里一条一条慢慢理清思路。
在开始前,需要说明两点:由于我们需要重传一些已经发送过的数据,所以我们必然需要有一个缓冲区用于存储这些数据。考虑到重传确认机制永远只对最早的未完成数据生效(满足 FIFO),那么这个缓冲区可以使用 std::queue
实现,内部元素类型就是发送出去的报文数据 TCPSenderMessage
。
另外,由于报文中的 seqno 和 absolute seqno 是不兼容的,并且我们要知道确认报文中的确认序号到底确认到了哪个字节,所以我们还需要维护一个记录了已确认到了哪个字节序号的数值 acked_seqno
(这个东西随便你怎么命名),这个值显然等于上文提到的缓冲区队首的报文首字节序号。
不过你不维护这个东西也可以,无非是每次遍历缓冲区时都要使用队首的 seqno 的 unwrap()
方法解除封装。
很自然的,为了能够知道当前发出去的报文中的首字节序号是什么,我们还需要有一个记录了已发送到哪个字节序号的数值
sent_seqno
。
-
上文提到过,
TCPSender
的tick()
方法会被周期性地调用,并通过它的函数参数告知当前经过了多长时间。文档这里警告我们:不要通过系统调用获取现实世界的时间(无论是 glibc 的time()
还是 STL 的std::chrono::system_clock::now()
),应该也必须使用给定的函数参数uint64_t ms_since_last_tick
获取时间信息。 -
其次,
TCPSender
对象在被构造时会接受一个初始值,这个值将作为这个对象的初始重传时间间隔。
-
为了实现自动重传,
TCPSender
自然是需要一个超时计时器,这个计时器需要记录 RTO 的大小,并在累计的时间大于 RTO 后将计时器的状态转为“已过期”。文档推荐将这个timer
设计为一个辅助类。 -
每次发出一个带有负载的非零长报文(零长是指既没有 SYN、FIN,也没有 payload 的
TCPSenderMessage
对象,但 RST 的值依然由流对象本身决定),如果计时器没有启动,就需要启动它。
- 接受确认报文时,如果所有的未完成数据都已经被确认,那么停止计时器。
什么叫“所有的未完成数据都被确认”呢?
意思就是说接收到了一个新的确认报文,这个确认报文的确认序号大到足以清空数据缓冲区,就可以认为“所有未完成数据都已经被确认”。
但是这里有个小坑:这个“足够大的确认序号”不能超过
TCPSenderMessage
自己记录的下一个待发字节序号;否则就表示接收方发来了一个还没发出的报文的确认(在测试集中可以看到这种情况的报文是需要抛弃的)。
- 如果调用
tick()
方法时重传计时器过期:- 重发最早的未被确认的报文(所以说要有一个缓冲区存储这些报文);
- 如果窗口大小不为零:
- 重传报文,并把这次重传计入一个重传计数器中;
- 将 RTO 的值乘以 2(课程的要求比较简单)。
- 重置计时器,使得记录的时间复位为 0。
- 当接收方确认成功接收新数据时(也就是说
TCPReceiverMessage
中的ackno
的值要大于缓冲区队首的字节序号):- 将计时器的 RTO 重置为默认值;
- 如果 sender 还有未完成的数据,重启计时器使其继续工作;
- 重置重传计数器。
2.代码分析
上面就是 TCPSender
在面对超时重传时需要做的所有事情。根据以上的分析,接下来分析一下给定的代码框架。
2.1 push()
方法
在 push()
方法中,我们需要从流中不断地读取字节,直到填满发送窗口、或是填充的数据使得当前报文的负载长度达到了 TCPConfig
中规定的上限。
实际实现时,push()
方法必须能够尽快将所有缓存的数据全部发送出去(滑动窗口机制,连续发送多个分组),也就是说我们需要不断地从流中读取字节并组装报文,当读取的数据达到了报文段长度限制后马上把这个报文发送出去,再继续执行读取-组装工作,直到流中没有更多数据、或者累计发送出去的字节数达到了接收方的窗口大小。
因为不对 TCP 报文长度做限制就交由底层协议传输,极有可能会因为中途丢包导致报文段不完整,进而导致了大量重传行为的发生。
文档这里提到, SYN 和 FIN 字节也要被计入字节序号当中。故窗口大小不足、并且还需要发出 FIN 时,必须把 FIN
字节的发送推迟到下次报文发送。
正如前文提过的,只有传输第一个报文时才计算 SYN 的字节序号。
注意下面的 FAQ 中提及了一个特殊行为:当接收方告知其窗口大小为 0 时,我们需要假装窗口大小为 1,并依然正常发送报文过去,避免陷入死锁。
关于设置
TCPSenderMessage
中的 RST 的时机,使用ByteStream::has_error()
方法即可,即流错误必然要求重置连接。
2.2 receive()
和 tick()
方法
receive()
方法负责根据接收方发来的确认报文,更新 TCPSender
的缓冲区。更新条件是:当 ackno
的值大于缓冲区队首报文段中的所有字节序号,也就是说只有队首的报文被全部确认后,才能把这个报文弹出缓冲区;因此不要根据 ackno
的值去截断队首的报文负载,没有全部确认的话统一视而不见(视而不见包括不更新计时器、不重置重传计数器)。
在这个方法中,如果新的确认报文将缓冲区全部清空了(全部数据都被确认),那么就要停止计时器。
tick()
方法正如之前介绍的,不要使用现实世界的时间,在这个方法中根据计时器是否过期判断是否需要重传数据。
2.3 make_empty_message()
方法
make_empty_message()
正如其名,创建并返回一个零长的报文。如果有看过 TCPSenderMessage::sequence_length()
的代码实现就会知道,一个零长的报文有以下特征:
- 控制位 SYN、FIN 全部都是 false;
payload
是空的;- RST 的值取决于流对象是否有错误。
但是 TCPSenderMessage
中的 seqno
必须是当前 TCPSender
要发送的下一个字节序号。
2.4 FAQs
FAQ 中提供了额外的信息。
- 在一切刚开始的时候(指刚构造
TCPSender
对象),假定接收方的窗口大小为 1; - 不要裁剪缓冲区的字节数据,即使它们中的部分已经得到了确认(这里说的是可以但没必要);
- 同样的,即使缓冲区中有多个完整且互相可以连接在一起的字节数据,也没有必要将它们拼在一起;
- 如果发送了零长报文(调用
make_empty_message()
方法得到的那些东西),不要重传它,也不要启动计时器。
3.程序实现
那么到这里我们可以总结几个关键的函数。
3.1 在 push()
方法中要做的事
- 在第一次调用该方法时,发送一个 1 字节长度的连接请求报文(因为此时
TCPSender
假定接收方窗口大小为 1),并将这个字节计入“未完成的字节数量”中(也就是没有收到确认的字节数量); - 不断的组装报文,并始终保持以下任意一个条件为真:
- 每个报文的负载
payload
长度不大于TCPConfig::MAX_PAYLOAD_SIZE
的值; - 没有收到确认的字节数量不能超过接收方告知的窗口大小。
- 每个报文的负载
- 将已发送、且没有收到确认的以下字节计入“未完成的字节数”中:
- 第一个 SYN 字节、最后一个 FIN 字节、以及所有
payload
中的字节数。
- 第一个 SYN 字节、最后一个 FIN 字节、以及所有
- 每次从
ByteStream
中读取字节后都要检查读端是否已经结束,并把存在流中的 EOF 字符丢弃(还记得 Lab1 的要求吗?),再视情况决定是否发送 FIN 字节; - 当 FIN 已发出、或者
ByteStream
中没有数据、亦或者发出的数据已经填满了滑动窗口,拒绝发出任何报文; - 如果有发出一个非零长报文,且超时计时器没有启动,那么就启动计时器(实际上
push()
方法不会发出非零长报文,这里只需要认为“只要有报文发出就启动计时器”)。
3.2 在 receive()
方法中要做的事
- 拒绝
ackno
的值是不存在的、或者是超过了当前已发出的最后一个字节序号的值的报文; - 根据报文信息更新当前记录的窗口大小;
- 报文中的
ackno
不大于缓冲区队首的首字节序号seqno
+payload.size()
的值时,跳过更新缓冲区; - 在不满足上条条件时,将缓冲区队首的报文弹出,并在缓冲区非空的前提下检查下一个队首元素;
- 检查 SYN 连接请求是否被确认,并根据检查结果设置以后发出的报文中 SYN 的值(也就是说允许后续发出的报文中 SYN 为 false 的情况);
- 如果缓冲区被清空,停止计时器,否则只重置计时器;
- 只要缓冲区有报文被弹出,就将重传计数器置零。
3.3 在 tick()
方法中要做的事
- 将
ms_since_last_tick
的值加到计时器上; - 在更新计时器后,检查计时器是否已经过期;
- 如果过期,重传缓冲区队首元素,递增重传计数器,并将计时器的 RTO 增加为原来的两倍;
- 否则退出函数。
分析了这么多,最后根据这些结果照着写代码就 ok 了;剩下的大头看看测试用例反馈的信息就够了。
写在很久之后:我的这段代码写得相当复杂且不直观,但实际上这些复杂的代码逻辑可以使用一个状态机模型简洁地替换掉。
这个状态机的状态大概可以用以下四种状态描述:建立连接、组装报文、关闭连接、关机;其中初始状态指向建立连接
,并在发出 SYN 标志后立即跳到组装报文
,如果本次连接中能够发出 FIN 标志则直接跳入关机
,否则跳入关闭连接
后再跳转至关机
。
这只是一个粗略的思路(因为我没写代码),如果觉得我给的代码太复杂的话可以参考如上构想重写一下。
课程依然鼓励大家查看 tests/
中的测试用例,并补充一些缺失但有助于完善实现的测试用例。