0.Overview
check1 要求实现的是 TCP 的流重组器。
大家都知道(不知道的现在也知道了),在网络模型中位于运输层以下的所有数据报传输都只提供“尽最大努力交付”服务(即"best-effort");这意味着一个正常的数据报在传输过程中可能丢失、可能重复发送好几次、也可能出现比特差错。而 TCP 是可靠传输协议,因此要在一个不可靠的传输服务上提供一个可靠的流交付,就必须有一个负责接收并处理所有网络流数据报的组件。
check1 的代码会基于 check0 中已经编写好的 ByteStream
对象进行字节流交付。
1.Getting started
由于整个 Lab 都是复用代码的,所以需要把分支 check1-startercode merge 一下。
上面说过 Lab 是有代码复用的,因此直接使用最后一个代码分支的代码就可以一次性获得所有的实验内容。(不过在撰写时今年的 cs144 还在进行中,只能等它放完了)
2. Putting substrings in sequence
正如 Overview 中说过的,TCP 需要一个组件将不可靠的数据报交付转换为可靠的字节流传输。文档中提到了 TCP 会为它的数据报中的每个字节提供一个独一无二的序号(序号从 0 开始),这个序号标识了当前字节在一整个数据报中的位置;我们需要按照这个序号将所有字节按顺序组装在一起,并交付给 ByteStream
的 Writer
端。
public 接口中的 insert
就是把一个不可靠的流交付给重组器的,并且 first_index
标识了 data
的第一个字节的序号,is_last_substring
指明了这个分组是否是最后一个分组。
2.1 需求分析
文档里提到,实现的 Reassembler
必须能处理以下三种情况:
- 接收到流的下一个字节分组,此时要尽快(或者说立刻)将字节推入
ByteStream
中; - 接收到一个缺失了部分字节的字节分组,并且字节分组的大小不超过
ByteStream
的容量,必须将字节分组存储在Reassembler
的缓冲区中; - 接收到了一个
first_index
在可接受的字节序号范围外的字节分组(超出了处理能力),立即丢弃,因为Reassembler
不存储任何不能立即推入ByteStream
的字节; - 当
Reassembler
获得了缺失的字节分组时,必须立刻将缓冲区中所有能够被推入的字节立即推入(当然推入过程中如果超出了容量,就需要被截断了); - 补充说明一下:对于
first_index
在可接受范围内的、但是字节尺寸又比容量本身大的分组,需要截断后存储在缓冲区或者推入流中。
文档还很贴心地给了个流交付时的示意图。红色表示的是长度在 capacity 容许范围内的、已接收的无序字节分组,绿色表示的是重组后、已经 push 但尚未 pop 的数据,蓝色的就是已经被 pop 的数据。
很显然我们在实现时不能把所有无序的数据全部存在缓冲区(前面已经提过了)。然后建议是多结合一下需求分析,尽量完全理解这个流交付的过程的细节。
2.2 细节事项
文档的 FAQs 部分给出了一些比较细致的行为细节,这里只给出几点比较重要的:
- 对于最终的实现,只要速度超过 0.1 Gbit/s 就是可接受的,并且具体的实现完全取决于你自己的想法(远超 check0 的自由);
- 不需要考虑传给你的 data 是否存在字节差错,这不是 check1 该考虑的;
- 字节交付应该尽快;
insert()
函数要能处理一些重叠的数据分组(指存在部分字节是已经收到过的数据分组);- 在
Reassembler
的缓冲区部分中,所有的数据字节分组都应该是唯一的,(不能重复存储重叠的数据分组,而是只保留一个副本,这里很关键); - 实现时不应该涉及
reader()
的调用。
FAQs 部分还提到了调用 ./scripts/lines-of-code
计算代码行数,一个合理的 Reassembler
实现的行数在 85 左右(也就是 50-60 行的代码就是 ok 的),合理的 ByteStream
实现则在 111 行。
也不知道它这里的 85 行是怎么做到的,可能是单纯我比较菜,我写的比它说明的要多不少。虽然极致的压行也能做到()
我实现的 BytesStream
还算合理,Reassembler
就感觉写的挺复杂的,大体代码写好后修了好久的 bug。
2.3 程序实现
根据前面的分析结果,我们可以得出以下结论:
- 需要记录序号的变化,思路是使用一个
uint64_t
类型的变量记录Reassembler
正在期待的下一字节序号,只要加上推入的字节分组的长度就能不断得到下一个期待的字节序号(其实 TCP 的报文传输中就提到了期待的字节序号这个概念); - 对于超出
ByteStream
容纳能力的数据分组,使用上面提到的变量进行分类,尽快找出应当抛弃的数据分组,并且把缺失数据、未超过处理能力的分组推入缓冲区; - 有序、且唯一地存储缺失数据的字节分组,这部分比较复杂,下面详细讲讲。
从 2.1 给出的处理示意图中,我们可以知道很重要的一点:一个字节分组可以唯一的由区间段 [ first_index, first_index + data.size() )
描述(左闭右开),这个区间段也同时指出这个分组在整段字节序列中的位置。因为我们不关心也无法从字节分组的内容中获取有用信息,所以我们可以把对字节分组的处理视作对区间段数据的操作。那么有序且无重复地存储缺失数据的过程就可以视作在若干区间段中、插入一个新的区间段,并保持整体的有序,这就是一个插入区间问题。
那么我们在插入新数据分组时要做的很简单:找到所有和新数据存在交集的区间段,取并集后把原先的数据删除,把并集保持有序地放回缓冲区。
这个有序存储的缓冲区该怎么实现?首先我们的需求是要做到有序插入,并且还有频繁的遍历需求(找交集再取并,保证存储的无序字节的唯一性),那么我们就有如下几个选择:有序插入的 std::map
、std::set
,和很简单的 std::list
(其实用 std::vector
也行,但是数组在删除元素方面明显处于劣势)。
很可惜,我们定位到具体的元素时需要比较 first_index
、first_index + data.size()
的大小(即判断当前区间和容器内的区间是否存在交集),但是无论是 std::map
还是 std::set
的查找(lower_bound
和 upper_bound
)都不支持传递自定义谓词,而允许使用自定义谓词的 algorithm 算法模板的查找开销又是 O(n),与使用 std::list
时无异。结果就导致:这两个数据结构的查找、插入性能固然优秀,但是我们完全利用不上。所以只需要简单地使用 std::list
作为数据缓冲区即可,即我们只需要维护一个有序的、无重复的链表。
在合并区间时,我们要知道:对于 std::string
来说,删除尾部字符的时间开销比插入要小;所以此时我们可以判断待合并的字节分组中有哪些是重复出现的数据,并选中重复数据出现在队尾的那个分组,再把这些数据从分组中删去、把不重复的数据有序插入到链表中即可。
为什么要“选中重复数据出现在队尾的那个分组”呢?这是因为
std::string
的底层实现使用的是数组,对于数组来说,删除末尾元素不会导致内存重新分配、或者是搬移数组元素。优先去除末尾元素的思路能够带来可观的优化结果。
3.代码测试
ehhh 其实在有序插入部分花了非常多的时间,然后不仅代码行数写了很多、性能感觉上也比较堪忧(感觉速度应该能做到和 ByteStream
接近 1:1 的),也许后续会改一改。不过只要合理使用移动语义,一般速度都不会差到哪里去。