lab1--Reassembler
前言
我们在接下来的实验中需要实现一个TCP分组的receiver:一个可以接收TCP分组并且将其转化为可靠的字节流给应用程序的socket接口接收。在TCP分组的发送过程中,我们知道传输层需要依靠下层提供的功能再加上本层实现的协议才可以成功完成TCP分组的发送。由于物理网络设备对每个帧有限制_(MTU)_,标准以太网的MTU是1500字节,由于IP数据报头部占据20字节,TCP分组头部占据20字节,所以我们能够单次发送的TCP分组载荷的大小为1460字节。
因为TCP协议是可靠数据传输协议,所以我们需要考虑分组在发送过程中所遇到的特殊情况,比如说:分组到达失序,分组传输过程中丢失,某些分组重复到达。所以接收方必须将这些分组重组成它们开始时的顺序字节流。我们在lab1中正是要在lab0实现的byteStream中实现一个重组器,为之后的TCP/IP协议服务。
/*
* Insert a new substring to be reassembled into a ByteStream.
* `first_index`: the index of the first byte of the substring
* `data`: the substring itself
* `is_last_substring`: this substring represents the end of the stream
* `output`: a mutable reference to the Writer
*
* The Reassembler's job is to reassemble the indexed substrings (possibly out-of-order
* and possibly overlapping) back into the original ByteStream. As soon as the Reassembler
* learns the next byte in the stream, it should write it to the output.
*
* If the Reassembler learns about bytes that fit within the stream's available capacity
* but can't yet be written (because earlier bytes remain unknown), it should store them
* internally until the gaps are filled in.
*
* The Reassembler should discard any bytes that lie beyond the stream's available capacity
* (i.e., bytes that couldn't be written even if earlier gaps get filled in).
*
* The Reassembler should close the stream after writing the last byte.
*/
void insert( uint64_t first_index, std::string data, bool is_last_substring );
// How many bytes are stored in the Reassembler itself?
// This function is for testing only; don't add extra state to support it.
uint64_t count_bytes_pending() const;
这是给出框架代码中需要我们实现的函数。其中insert函数就是将不可靠分组交付给重组器的,其中first_index表示分组数据第一个字节的序号,和之后实验中TCPsender和TCPreceiver中的序列号seqno_对应。is_last_substring表示该分组是不是最后一个分组
需求分析
文档中提出我们实现的重组器应该满足这几种情况:
- 重组器如果正好接收到下一个字节分组,此时将分组与之前已经重组好的分组合并之后再尽快写入字节流中
- 接收到一个缺失了部分前面分组导致分组不连续并且字节分组的大小不超过
ByteStream的容量,就将分组存储到重组器的缓冲区中等待其他分组将其重组 - 接收到了一个
first_index在可接受的字节序号范围外的字节分组(超出了处理能力),立即丢弃,因为Reassembler不存储任何不能立即推入ByteStream的字节
文档中还给了一张重组器的示意图:
其中红色区域表示分组不超过重组器容量下,因为失序到达而缓存在重组器中的分组。绿色区域表示已经经过重组之后有序的分组,但是还未pop出去。蓝色区域表示的是已经pop出的分组,这意味着socket接口已经读取了这些分组,所以这些分组可以在内存中被回收。
分析问题
我们在理解文档的需求之后,就可以开始分析如何实现。很容易想到,我们首先需要一个uint64_t类型的变量维护分组重组器所期待的下一个字节的序号,当到的TCP分组的first_index正好等于重组器所期待的序号时,立即将这个分组推入ByteStream中,并且根据first_index + data.length()更新重组器所期待的下一个字节序号。
其实这个问题可以转化为区间的合并问题,根据上述示意图,有效区间的范围是[first_unpoped_index, first_unacceptable_index),重组器已经重组好的有序分组的范围是[first_unpoped_index, expected_index,因此我们需要根据到达分组的[first_index, first_index + data.length()区间与已知区间进行合并,如果没有已知区间与其重叠,就将其放入重组器缓冲区,如果与已有区间有重叠部分,我们应该优先删除重叠字符串的尾部,这样效率更高
因为字符串实质上是一个字符数组,删除中间数据需要移动后面的字符到前面删除的部分以保持数据的连续,但是删除后面的字符就只是单纯删除,不需要移动数据,所以我们在调整字符串时应该优先删除字符串尾部数据
接下来我们考虑实现这个问题需要什么样的数据结构,这里的思路参考了这篇博客的实现,这是区间合并类问题,我们可以使用std::vector维护区间信息,但是std::vector在删除元素时有很大的劣势,因为它在删除元素之后还需要移动部分元素以保证数据的连续。可以使用std::unordered_map维护区间的first_index和data,我们可以利用哈希表高效的查询和删除性能,但我们在合并区间过程中需要查找与当前插入区间可能有关系的作用区间,这就意味着我们必须要变量整张表查询对应区间,时间复杂度O(n),无异于链表的时间复杂度,而且链表在插入和删除方面效率高,且在我们的规划下,链表的中的元素顺序应该是按照区间顺序排列的,所以我们可以使用lower_bound和upper_bound进行二分查找,提高效率的同时降低了编程难度,所以这里我采用链表进行实现。
所以我在原本的框架代码中新加了这些私有变量用于实现重组器:
uint64_t bytes_pending_ {}; // 当前存储在重组器中的字节总数
uint64_t expected_index_ {}; // 重组器期待的下一个字节的下标,可用于合并
bool has_last_substring_ {false}; // 是否已接收到表示流结束的子串
// 该数据结构表示当前重组器,仅保存索引和数据内容,最后子串标记改为全局状态
std::list<std::pair<uint64_t, std::string>> buffer_ {};
这里我看到insert函数有三个参数:uint64_t *first_index*, std::string *data*, bool *is_last_substring*就想着把这三个参数和为一个tuple 放入list中,这样可以存储插入数据的全部信息,但是由于is_last_substring这个参数维护的是全局信息,所以我们可以单独将其提取出来作为一个标志变量。
我代码的实现放在我的github仓库中,个人感觉编写代码过程中最难的部分是实现重组器缓冲区有序插入的部分,下面是代码测试部分