lab3-TCP sender
在上个实验中,我们实现了TCP的接收方,在这次实验中我们将实现TCP发生方。由于TCP协议是全双工通信,要求通信实体两端都可以各自发送数据报或者接收数据报:发送方负责发送带有数据报的分组,收到接收方的ack确认报文以及超时重传以确保TCP协议的可靠性,接收方负责接收可能不按序到达的分组并且将其推入重组器重组,同时,接收方收到信号还需要向发送方发送一个确认分组。

通过阅读文档,我们可以知道TCP-sender需要满足的核心功能:
- 记录接收端发送给它的接收窗口大小_(从
TCPReceiverMessages
)_中获取,用于做拥塞控制 - 在窗口大小的允许下持续从
ByteStream
中读取数据,在必要的时候添加SYN
和FIN
标记(在连接开始时与连接结束时携带) - 追踪哪些以及发送过的数据报是没有收到接收方的确认的,这部分报文可能需要超时重传
- 没有收到确认的报文在足够长的时间后依然没有得到确认,发送方有责任将其重传
文档中将TCPsend需要处理的事情和特殊情况总结的很到位了,我们先慢慢理清楚思路以及讨论特殊情况

请注意,TCP发送方在发送第一个数据报时SYN一定为true,如果发送方一直没有收到接收方对这个携带SYN的特殊报文的确认,那么发送方发送的后续报文要一直携带SYN标志,知道收到接收方的确认,同理,FIN标志也是一样的。
请注意TCPSender
的 tick
方法每隔几毫秒会被调用一次,参数表示自上次调用以来经过了多少毫秒。你需要用它来维护 TCPSender
存活的总时间。不能调用任何操作系统或 CPU 的时间函数 ,所有时间相关的信息只能通过 tick
方法获取,这样可以保证程序是可测试且确定性的 。所以在构造TCPSender
时,会传入一个初始重传超时时间(RTO)
参数,表示重传未确认 TCP 段之前等待的毫秒数。这个 RTO 会随时间变化,但初始值始终不变,代码中会将其保存在一个名为 initial_RTO_ms
的成员变量中。
每当发送一个包含数据的段(即序列空间长度非零的段,不论是首次发送还是重传),如果定时器未在运行 ,就启动定时器,使其在 RTO 毫秒后过期。一旦所有未确认的数据都被确认 (即没有 outstanding segment),就停止定时器。
如果在调用tick
方法时,重传定时器已过期,那么我们需要重新发送最早的,尚未被完全确认的分组,(即序列号最小的未确认段),我们需要设计一个数据结构,以便快速找到它,这里我们需要利用先进先出的特性,而且只需要找到最早的未确认分组,很容易想到使用队列进行存储,内部元素类型就是发送出去的报文数据 TCPSenderMessage
。如果窗口大小不为0,那么我们需要重传报文并且将RTO的值设置为原来的两倍,这里设置为原来的两倍是课程要求,然后重置计数器,使得记录的时间复位为0。
当发送方收到接收方的确认时,我们需要将计时器的RTO重置为默认值,如果sender还有未完成的数据,重启计时器使其工作,重置重传计数器。
下面我们根据框架代码对要求实现的函数进行分析。

在push
方法中,我们需要不断从流中读取字节,直到填满发送窗口、或是填充的数据使得当前报文的负载长度达到了 TCPConfig
中规定的上限。所以push
必须尽快将所有缓存的数据全部发送出去,利用滑动窗口机制,可以同时发送多个分组。也就是说我们需要不断地从流中读取字节并组装报文,当读取的数据达到了报文段长度限制后马上把这个报文发送出去,再继续执行读取-组装工作 ,直到流中没有更多数据、或者累计发送出去的字节数达到了接收方的窗口大小。请注意 SYN 和 FIN 字节也要被计入字节序号当中。故窗口大小不足、并且还需要发出 FIN 时,必须把 FIN
字节的发送推迟到下次报文发送。
注意下面的 FAQ 中提及了一个特殊行为:当接收方告知其窗口大小为 0 时,我们需要假装窗口大小为 1,并依然正常发送报文过去,避免陷入死锁。


receive()
方法负责根据接收方发送来的确认报文,如果ackno的值大于缓冲区队首报文段中的所有字节序号,也就是说只有队首的报文被全部确认后,才能把这个报文弹出缓冲区。在这个使用中我们不需要根据ackno的值截断队首的报文段,统一设计为该ackno大于该分组末尾的字节序号时才pop出去。如果新的确认报文将缓冲区全部清空了(全部数据都被确认),那么就要停止计时器。
tick()
方法主要的点就是不要使用现实世界的时间,在这个方法中根据计时器是否过期判断是否需要重传数据。文档推荐我们将超时计时器设计为一个独立的类,所以我们应该在tick类中调用这个计时器类。

make_empty_message()
正如其名,创建并返回一个零长的报文。在TCPSenderMessage::sequence_length()
方法中,我们可以知道一个零长报文有以下特征:SYN
和FIN
都是false,数据报的负载payload
是空的。
在FAQS
中还有一些额外的信息:
TCPSenderMessage
中的seqno
必须是当前TCPSender
要发送的下一个字节序号。- 刚开始连接时,假定接收方的窗口大小为 1。
- 不要裁剪缓冲区的字节数据,即使它们中的部分已经得到了确认。
- 同样的,即使缓冲区中有多个完整且互相可以连接在一起的字节数据,也没有必要将它们拼在一起。
- 如果发送了零长报文(
make_empty_message()
),不要重传它,也不要启动计时器。
接下来我们总结一下我们需要在各个待完成的函数中要做的事情
push
方法
不断的组装报文,并且需要满足每个报文的负载payload
长度不大于TCPConfig::MAX_PAYLOAD_SIZE
,没有收到确认的字节数量不能超过接收方告知的窗口大小。需要将已经发送但是还未收到确认的字节计入待确认的字节中
,每次从 ByteStream
中读取字节后都要检查读端是否已经结束,因为这是发送FIN的标志。当 FIN 已发出、或者 ByteStream
中没有数据、亦或者发出的数据已经填满了滑动窗口,拒绝发出任何报文。还要记得在发送长度非0的报文时启动超时计时器(如果没有启动的话)
receive()
方法
要根据接收方发送的信息更新当前发送窗口的大小,这是为了与接收端协调,做拥塞控制。报文中的 ackno
不大于缓冲区队首的首字节序号 seqno
+ payload.size()
的值时,跳过更新缓冲区。检查 SYN 连接请求是否被确认,并根据检查结果设置以后发出的报文中 SYN 的值(测试用例要求)。只要缓冲区有报文被弹出,就将重传计时器置零。
tick
方法
将 ms_since_last_tick
的值加到计时器上。每次更新计时器时,要检查计时器是否已经过期。如果过期的话就重传缓冲区队首元素,递增重传计数器,并将计时器的 RTO 增加为原来的两倍。
下面开始讲解代码的实现:
首先我们按照文档的要求,先设计一个代表超时重传计时器的类出来,然后在TCPsender方法中嵌入这个类的字段表示计时器,再在tick
方法调用这个类的方法实现。
C++
class ReTreansmitTimer
{
public:
explicit ReTreansmitTimer( uint64_t init_rto_time ) : RTO_( init_rto_time ) {};
bool is_expired() const { return is_open_ && allTime_passed_ >= RTO_; }
bool is_open() const { return is_open_; }
// 激活一个分组的计时器,返回引用可以支持链式调用
ReTreansmitTimer& open();
// 将超时重传时间变为两倍
ReTreansmitTimer& timeout();
// 重置当前计时器
ReTreansmitTimer& reset();
// 当前计时器经过了多少时间
ReTreansmitTimer& tick( uint64_t ms_since_last_tick );
private:
uint64_t RTO_ {}; // 超时重传时间
uint64_t allTime_passed_ {}; // 计时器启动之后所经过的总时间
bool is_open_ { false }; // 定时器是否打开
};
在设计这个类时我们需要实现的计时器操作有,打开计时器、将超时重传时间设置未原来的两倍、重置当前计时器以及当前计时器从激活到现在经过了多少时间。注意这里的函数返回引用是为了进行链式调用,例如timer_.tick( ms_since_last_tick ).is_expired()
。
接下来是我额外在TCPsender中补充的字段:
C++
TCPSenderMessage make_message( uint64_t seq, bool syn, std::string payload, bool fin ) const;
ByteStream input_;
Wrap32 isn_;
uint64_t initial_RTO_ms_;
uint16_t window_size_ { 1 };
bool zero_window_ {}; // 表示发送方拥塞窗口大小是不是为0,如果为0的话就不加倍超时重传时间
uint64_t acked_seq_ { 1 }; // 表示发送方收到接收方想要的下一个数据的序列号
uint64_t seq_num_in_flight_ {}; // 表示已经发送但是未被确认的数据的数目
uint64_t consecutive_retransmissions_ {}; // 表示超时重发的次数
uint64_t seq_num_ {}; // 表示发送方将要发送的下一个字符,也可使用queue.front()
// 用于存储发送数据报的队列,利用先进先出的特性
std::queue<TCPSenderMessage> outstanding_segment_ {};
ReTreansmitTimer timer_ { initial_RTO_ms_ };
/*设置四个标志位,前两个是表示在连接过程中已经确定的状态位,后两个表示是否发送过SYN_和FIN_
用与应对特殊情况*/
bool SYN_ { false }, FIN_ { false }, send_FIN_ { false }, send_SYN_ { false };
其中最基本的成员有std::queue<TCPSenderMessage> outstanding_segment_ {};
,这个队列存放了我们发送出去的TCP数据报,这些数据报还未收到确认,在push
方法中向队列推入消息,在receive
方法中根据接收到的确认号不断的从队列中弹出消息。seq_num_in_flight_
和consecutive_retransmissions_
变量是为了在方法
C++
uint64_t sequence_numbers_in_flight() const; // For testing: how many sequence numbers are outstanding?
uint64_t consecutive_retransmissions() const; // For testing: how many consecutive retransmissions have happened?
中调用返回的变量,在push
和receive
方法中进行更新。timer_
变量就是在上一个类中编写的超时计时器,在push
方法中我们发送对应信息时要开启一个总的超时计时器,每次调用tick方法时,我们会根据定时器是否过期来选择重传队首的数据,将重传次数++,然后加倍计时器的超时时间。window_size_
表示发送窗口的大小,可以使用这个变量协调拥塞控制,在push
方法中,发送数据时要检测窗口大小是否允许,在receive
方法中,在接收数据时还要根据接收方的窗口大小调整自己的窗口大小,来做拥塞控制。
ack_no_
和seq_num_
变量就是发送方和接收方用来协调接收与发送进度的工具。注意我还添加了一个方法TCPSenderMessage make_message( uint64_t seq, bool syn, std::string payload, bool fin ) const;
,这提供了一层抽象,让我们更方便地构造要发送的数据分组,还可以更快捷的实现make_empty_message()
方法,代码可读性与可维护性更强。
剩下的一些变量是我在进行测试时发现有一些特殊情况添加的,这里也鼓励大家多看测试代码,因为测试样例很多,可以面向测试用例编程......
这里是我的代码的完整实现,下面是测试用例通过情况:
