CS144(2024 Winter)Lab Checkpoint 1: stitching substrings into a byte stream

0.Overview

check1.pdf

check1 要求实现的是 TCP 的流重组器。

大家都知道(不知道的现在也知道了),在网络模型中位于运输层以下的所有数据报传输都只提供“尽最大努力交付”服务(即"best-effort");这意味着一个正常的数据报在传输过程中可能丢失、可能重复发送好几次、也可能出现比特差错。而 TCP 是可靠传输协议,因此要在一个不可靠的传输服务上提供一个可靠的流交付,就必须有一个负责接收并处理所有网络流数据报的组件。

check1 的代码会基于 check0 中已经编写好的 ByteStream 对象进行字节流交付。

using_check0_BytesStream

1.Getting started

由于整个 Lab 都是复用代码的,所以需要把分支 check1-startercode merge 一下。

上面说过 Lab 是有代码复用的,因此直接使用最后一个代码分支的代码就可以一次性获得所有的实验内容。(不过在撰写时今年的 cs144 还在进行中,只能等它放完了)

2. Putting substrings in sequence

正如 Overview 中说过的,TCP 需要一个组件将不可靠的数据报交付转换为可靠的字节流传输。文档中提到了 TCP 会为它的数据报中的每个字节提供一个独一无二的序号(序号从 0 开始),这个序号标识了当前字节在一整个数据报中的位置;我们需要按照这个序号将所有字节按顺序组装在一起,并交付给 ByteStreamWriter 端。

注意一下这里提到:TCP 会将数据分片为不超过 1460 字节的片段,所以你不需要考虑序号是否会超出底层数据类型能记录的上限。

lab_requires

public 接口中的 insert 就是把一个不可靠的流交付给重组器的,并且 first_index 标识了 data 的第一个字节的序号,is_last_substring 指明了这个分组是否是最后一个分组。

public_interface

2.1 需求分析

文档里提到,实现的 Reassembler 必须能处理以下三种情况:

  1. 接收到流的下一个字节分组,此时要尽快(或者说立刻)将字节推入 ByteStream 中;
  2. 接收到一个缺失了部分字节的字节分组,并且字节分组的大小不超过 ByteStream 的容量,必须将字节分组存储在 Reassembler 的缓冲区中;
  3. 接收到了一个大小超过了 ByteStream 的容量的字节分组(超出了处理能力),立即丢弃,因为 Reassembler 不存储任何不能立即推入 ByteStream 的字节;
  4. Reassembler 获得了缺失的字节分组时,必须立刻将缓冲区中所有能够被推入的字节立即推入(当然推入过程中如果超出了容量,就需要被截断了)。

object's_requires

文档还很贴心地给了个流交付时的示意图。红色表示的是长度在 capacity 容许范围内的、已接收的无序字节分组,绿色表示的是重组后、已经 push 但尚未 pop 的数据,蓝色的就是已经被 pop 的数据。

很显然我们在实现时不能把所有无序的数据全部存在缓冲区(前面已经提过了)。然后建议是多结合一下需求分析,尽量完全理解这个流交付的过程的细节。

plot

2.2 细节事项

文档的 FAQs 部分给出了一些比较细致的行为细节,这里只给出几点比较重要的:

  1. 对于最终的实现,只要速度超过 0.1 Gbit/s 就是可接受的,并且具体的实现完全取决于你自己的想法(远超 check0 的自由);
  2. 不需要考虑传给你的 data 是否存在字节差错,这不是 check1 该考虑的;
  3. 字节交付应该尽快;
  4. insert() 函数要能处理一些重叠的数据分组(指存在部分字节是已经收到过的数据分组);
  5. Reassembler 的缓冲区部分中,所有的数据字节分组都应该是唯一的,(不能重复存储重叠的数据分组,而是只保留一个副本,这里很关键);
  6. 实现时不应该涉及 reader() 的调用。

notices1

notices2

FAQs 部分还提到了调用 ./scripts/lines-of-code 计算代码行数,一个合理的 Reassembler 实现的行数在 85 左右(也就是 50-60 行的代码就是 ok 的),合理的 ByteStream 实现则在 111 行。

也不知道它这里的 85 行是怎么做到的,可能是单纯我比较菜,我写的比它说明的要多不少。

counts_lines_of_code

我实现的 BytesStream 还算合理,Reassembler 就感觉写的挺复杂的,大体代码写好后修了好久的 bug(后续会抽空重写一下吧)。

lines_of_code

2.3 程序实现

根据前面的分析结果,我们可以得出以下结论:

  1. 需要记录序号的变化,思路是使用一个 uint64_t 类型的变量记录 Reassembler 正在期待的下一字节序号,只要加上推入的字节分组的长度就能不断得到下一个期待的字节序号(其实 TCP 的报文传输中就提到了期待的字节序号这个概念);
  2. 对于超出 ByteStream 容纳能力的数据分组,使用上面提到的变量进行分类,尽快找出应当抛弃的数据分组,并且把缺失数据、未超过处理能力的分组推入缓冲区;
  3. 有序、且唯一地存储缺失数据的字节分组,这部分比较复杂,下面详细讲讲。

从 2.1 给出的处理示意图中,我们可以知道很重要的一点:一个字节分组可以唯一的由区间段 [ first_index, first_index + data.size() ) 描述(左闭右开),这个区间段也同时指出这个分组在整段字节序列中的位置。因为我们不关心也无法从字节分组的内容中获取有用信息,所以我们可以把对字节分组的处理视作对区间段数据的操作。那么有序且无重复地存储缺失数据的过程就可以视作在若干区间段中、插入一个新的区间段,并保持整体的有序,这就是一个插入区间问题。

那么我们在插入新数据分组时要做的很简单:找到所有和新数据存在交集的区间段,取并集后把原先的数据删除,把并集保持有序地放回缓冲区。

这个有序存储的缓冲区该怎么实现?首先我们的需求是要做到有序插入,并且还有频繁的遍历需求(找交集再取并,保证存储的无序字节的唯一性);尽管 unordered_mapmap的插入和查找很优秀,但是受到遍历需求的约束,我们并不能利用这两个数据结构优秀的查找和插入性能,所以只需要简单地使用 std::list 作为数据缓冲区即可,即我们只需要维护一个有序的、无重复的链表。

3.代码测试

ehhh 其实在有序插入部分花了非常多的时间,然后不仅代码行数写了很多、性能还比较堪忧(感觉合理的速度应该和 ByteStream 做到接近 1:1 的),也许后续会改一改。不过只要合理使用移动语义,一般速度都不会差到哪里去。

speed_test