cs144-sp23, Lab Checkpoint 1: stitching substrings into a byte stream

Lab Checkpoint 0: networking warmup 直接写了 Markdown 以记录 Networking by hand 的部分, 另外完成了 ByteStream 部分的框架。 而看完 Lab1 的实验要求之后, 感觉需要具体分析并厘清思路, 故而在此做一个详细的记录。

Kiprey 2021 年的 Lab1 的记录给予了我帮助和启发。

该实验通过打印信息进行调试会更有效, 可以自己在 test 文件中增加自己的测试样例。

1. 实验说明

CS144 Labs'structure
CS144 Labs'structure

Lab1 的实验需要完成 ByteStream 外部的 StreamReassembler 部分。 Transmission Control Protocol (TCP) 的传输实现是一种可靠的顺序字节流, 尽管底层网络是以 “best effort” 的形式在进行报文的传输。 这意味着在报文传输的过程中, 数据包中的信息很可能会丢失、 重排、 替换、 或者重复。 这需要 TCP 为数据包提供可靠的底层逻辑。

TCP 的发送端会将字节流分割成短的 segments, 因此这些数据能够契合报文的容量需求。 但正如前述所说, 底层网络可能会使得这些 segments 变得不那么可靠, 因而 StreamReassembler 部分需要实现的, 就是在发送端确保发送的数据是连续的字节流。

2. 要求

StreamReassembler 需要遵循一定的规则。 starter code 中提供了两个公有函数对接口进行了一定的限制。

// Insert a new substring to be reassembled into a ByteStream.
void insert( uint64_t first_index, std::string data, 
            bool is_last_substring, Writer& output );
// How many bytes are stored in the Reassembler itself?
uint64_t bytes_pending() const;

另外, 根据实验说明, 原则上 Reassembler 需要处理以下几种情况:

  • 获取的字节流正好是 ByteStream 所需的下一组字节, 需要将这些字节直接用 Writer 写入到 ByteStream 的缓存中。
  • ByteStream 很有可能也会存满, 此时若有一部分字节完成 Reassembler 处理需要传递给 ByteStream, 则该部分也需要缓存, 实际上就是该部分也需要占用 Reassembler 内部的缓存空间, 这个内部的缓存空间就是 unassembled 的部分。
  • 当收到的字节流能够符合当前的空余空间但在在其更早的字节却不存在的时候 (也就是数据传输产生了空洞, 不连续) 需要将当前收到的字节流在 Reassembler 中进行缓存。
  • Reassembler 会丢弃那些超出当前空余空间的字节。 并且, 传入 Reassembler 中的子列 (substring) 可能会存在重复以及重叠, 或者有一部分已经被存入 ByteStream 的缓存中, 这些情况都需要考虑并进行处理。

关于存储空间指导书给了下图的提示。

Memory usage limitation of Reassembler and ByteStream
Memory usage limitation of Reassembler and ByteStream

ReassemblerByteStream 的内存上限是之前 Lab0 中规定的 capacity 的大小。 对于 Reassembler 而言, capacity = first_unacceptable_index - first_unpopped_indexByteStream 相对于 Reassembler 更为底层, 位于网络收发的端口, 负责实际的收发工作。 所以对于途中的几个 first 开头的索引其具备如下含义:

  • first_unpopped_index: 已经排序整理且验证正确的部分的起始索引, 这部分(绿色区块)存储在 ByteStream 的缓存中等待发送。
  • first_unassembled_index: 尚未排序的整理验证的部分, 可称其为子列 (substrings)的起始索引, 这部分(红色区块)存储在 Reassembler 的内部缓存区域。
  • first_unacceptable_index: 需要被丢弃的部分的起始索引。

3. 实现思路

依据 Memory usage limitation of Reassembler and ByteStream 图中所示的几个 index, 其中最为关键的两个应当是 first_unpopped_indexfirst_unacceptable_index。 框架程序提供的两个方法函数是不够用的, 我们需要定义几个私有变量以便存储关键信息:

  • unassembled_index_: 表示 first_unassembled_index
  • unassembled_substrings_: 表示 unassembled 部分的缓存集, 为 map 类型, 可以将存储的 first_index 与其对应的 data 进行映射, 并提高查找速度。
  • is_closed_: 表示接受端已收到最后一节 substring, 并且 pending_buffer_ 也已清空, 此时应当终止此次数据的接收。

另外也对框架代码所给的两个函数的变量与功能进行说明:

  1. insert

     void insert( uint64_t first_index, std::string data, 
             bool is_last_substring, Writer& output );
    

    传输 substringByteStream 的主体方法, 包含大部分逻辑实现。

    • first_indexsubstring 数据的第一个 index, 需要和上述 unassembled_index_ 进行区分。
    • data: 需要进行传输的 substring 数据。
    • is_last_substring: 最后一条 substring 的标志变量。
    • outputByteStream 中实现的 Writer 类, 将数据写入(push)传输缓存, 这部分信息会被远端的 Reader 类导出(pop)接收。
  2. bytes_pending

     uint64_t bytes_pending() const;
    

    剩余仍存留在 unassembler 缓存部分的数据大小, 该部分和 [first_unassembled_index, first_unacceptable_index] 区间的 available_capability 毫无关系, 需要区分。 available_capability 表示当前能够接收的 substring 最大的大小。

3.0 符号说明与特殊处理

符号说明

  • 重叠部分: 以 = 表示子列和存于 unassembled_substrings_ 中或已传入 ByteStream 中的数据重叠的 index
  • 当前数据: 以 > 表示当前数据。
  • front数据: 以 F 表示与当前数据最邻近的保存在 unassembled_substrings_ 中的那段数据, 这段数据的 first_index 比当前传入数据的 first_index 要小。
  • rear数据: 以 R 表示与当前数据最邻近的保存在 unassembled_substrings_ 中的那段数据, 这段数据的 first_index 比当前传入数据的 first_index 要大。

特殊情况

对于特殊情况可以跳过后续繁琐的处理逻辑, 加快处理速度。 对于传入的 substring 而言, 有如下几种特殊情况可以跳过处理直接返回:

  • 数据重复, 这意味着 first_index + data.size() - 1 < unassembled_index_, 传入的 substring 已经是 ByteStream 的一部分, 无需重复传输数据。
  • 缓存不足first_index_ >= unassembled_substrings_ + available_capability, 相当于 first_index 处于 first_unacceptable_index 之后的区间, 意味着该 substring 需要被丢弃。 或者 available_capability == 0, 此时已经没有空间用于处理。
  • 空子列substring 传入的 data 的大小为零。

3.1 unassembled_index_ >= first_index

这意味着此时传入的 substring 的一部分是已经传入 ByteStream 中的, 或者全都已经传入了 ByteStream。 还有可能有一部分和已经在 unassembled_substrings_ 中缓存的数据重叠, 那么重叠的部分就需要被丢弃。

  • 部分重叠

    这种情况和有 rear 部分的处理会差不多, 所以在代码实现上做了整合, 实时细节在 3.2 节讨论, 唯一有区别的就是在此时需要截断 [first_index, unassembled_index_] 之间的数据。

    进行截断后的数据是从 unassembled_index 起始的, 需要检查截断后的 data 的长度, 若截断后的 data 的长度超过了 available_capability 那么需要取 min(data.size() - overlapped_length, available_capability())

          first_index  unassembled_index_
              v                v      REPEATED_IN_MAP
      --------+----------------+-----------+---+------+------
              |================|>>>>>>>>>>>|===|RRRRRR|      
      --------+----------------+-----------+---+------+------
                                                      ^
                                          first_index + data.size() - 1
    
          first_index  unassembled_index_
              v                v      REPEATED_IN_MAP
      --------+----------------+-----------+---+------+------
              |================|>>>>>>>>>>>|===|>>>>>>|      
      --------+----------------+-----------+---+------+------
                                                      ^
                                          first_index + data.size() - 1
    
  • 全部重叠

    这种情况是部分重叠的特殊子集, 已经在特殊情况中处理掉了。

          first_index     unassembled_index_
              v                   v
      --------+-------------------+--------------------------
              |===================|           ...            
      --------+-------------------+--------------------------
                                  ^
                      first_index + data.size() - 1
    

3.2 unassembled_index_ < first_index

unassembled_substrings_ 中的数据有些是连续的, 有些则不是连续的, 我们在保存数据的时候是以当前要保存的 data 与该 datafirst_index 值进行映射的。 那么, 最复杂的情况是, 当前的这段数据正好恰在两段已缓存在 unassembled_substrings_ 之间。 这种情况也是需要进行数据截断的, 当前数据可能会超出最大索引值的范围, 这种情况发生在 first_index + data.size() - 1 > unassembled_index_ + cap - 1 的时候。

  1. 讨论传入数据的 first_index 比保存在 unassembled_substrings_ 中最邻近的数据的 fisrt_index 小的那部分数据。 可将 index 值较大段的数据称为_rear_index_, 当前数据的 index 仍称作 first_index。 可通过 map::lower_bound(first_index) 获取大于等于 first_index 的迭代器。

    • 没有重叠

      则当前的 substring 可以直接保存在 unassembled_substrings_ 中, 无需做额外的处理, 判断 first_index + data.size() - 1 < rear_index 即可。

                            first_index  rear_index
                                v            v
        ----------------------- +----+-------+-------------------+-----------------
                                |>>>>|       |RRRRRRRRRRRRRRRRRRR|       ...
        ----------------------- +----+-------+-------------------+-----------------
                                     ^                           ^
                        first_index + data.size() - 1    rear_index + rear_data.size() - 1
      
    • 部分重叠, 与 3.1 节中的情况类似, 需要截断当前的 substring 重复的部分即可, 若截断 rear 部分重复的数据, 则还需要重新更新 unassemble_substrings_, 效率不高。 需要满足 first_index + data.size() - 1 < rear_index + rear_data.size() - 1 的条件, 否则就会变成全部重叠的情况。 此时的重叠部分长度就是 first_index + data.size() - rear_index

                    first_index  rear_index
                        v           v
        ----------------+-----------+-------+-----------------------+-------------
                        |>>>>>>>>>>>|=======|RRRRRRRRRRRRRRRRRRRRRRR|    ...
        ----------------+-----------+-------+-----------------------+-------------
                                            ^                       ^
                                first_index + data.size() - 1  rear_index + rear_data.size() - 1
      
    • 全部重叠 以 rear 存在全部重叠为例, 这种情况就直接将 rearunassembled_substrings_ 中移除即可。

      需要注意处理完一个最邻近的 substring 后, 可能新的最邻近的 substring 也需要进行处理, 这一般发生在 substring 产生全部重叠的时候, 这能确保当前保存的 substring 的正确性。 更新迭代器时需要更新为 map::lower_bound(rear_index + rear_data.size() - 1), 才能跨过当前的 rear 找到 next_rear 继续进行处理, next_rear 也可能还在当前的 data 的覆盖范围。

                 first_index  rear_index
                     v           v
     ----------------+-----------+-------+------------+---+------+-------------
                     |>>>>>>>>>>>|=======|>>>>>>>>>>>>?????>>>>>>|    ...
     ----------------+-----------+-------+------------+---+------+-------------
                                         ^                       ^
                             rear_index + rear_data.size() - 1  first_index + data.size() - 1
    
  2. 讨论当前传入数据与保存在 unassembled_substrings_first_index 比当前数据的 first_index 小的最邻近那部分数据的关系。 可将 index 较小段的数据称为 front_index, 当前数据的 index 仍称作 first_index。 与 rear 不同的是, 最多只有一个 front 数据存在。 若 map::lower_bound(first_index) 得到的迭代器不是 unassembled_substrings_ 的头部, 说明存在 front, 只需要将该位置所在的迭代器向前更新一步即可。 相较从头向后扫描查找与 front 重叠, 这种办法仅需一次操作即可完成。

    • 没有重叠

      则当前的 substring 可以直接保存在 unassembled_substrings_ 中, 无需做额外的处理。

        unassembled_index_  front_index   first_index
                v              v            v
        --------+--------------+----+-------+-------------------+-----------------
                |              |FFFF|       |>>>>>>>>>>>>>>>>>>>|       ...
        --------+--------------+----+-------+-------------------+-----------------
                                    ^                           ^
                        front_index + front_data.size() - 1    first_index + data.size() - 1
      
    • 部分重叠

      3.1 节中的情况类似, 只需要截断当前的 substring 重复的部分即可, 也可以截断 front 重复的部分。

        unassembled_index_  front_index  first_index  
                v              v            v
        --------+--------------+------------+---+---------------+-----------------
                |              |FFFFFFFFFFFF|===|>>>>>>>>>>>>>>>|       ...
        --------+--------------+------------+---+---------------+-----------------
                                                ^               ^
                                                    first_index + data.size() - 1
                                    front_index + front_data.size() - 1
      
    • 全部重叠

      此时的处理会和 rear 部分有所区别, 调用 erase 处理 data 时是从前向后, 而 rear 是从后向前。

                    front_index  first_index
                        v           v
        ----------------+-----------+-------+-----------------------+-------------
                        |FFFFFFFFFFF|=======|FFFFFFFFFFFFFFFFFFFFFFF|    ...
        ----------------+-----------+-------+-----------------------+-------------
                                            ^                       ^
                                first_index + data.size() - 1  front_index + front_data.size() - 1
      

3.3 补充

最后将数据加入 map 缓存的时候需要特别判断 data.size() > 0, 因为在之前的处理中很可能 data 已经没有实际数据存在了, 这样就不需要将这部分数据放到 map 结构中。 除此之外, 在调用 output.push 的时候需要判断前后的 output.bytes_pushed 的情况, 很可能有一部分数据并没有被传入 ByteStream 的缓存中, 需要我们手动将这部分数据再保存在 map 中, 但不要忘了调用 erase 将之前的 push 不完整的那段数据从 map 中删除。 在遍历 map 查找可以传入 ByteStream 的数据的时候, 一旦发现 unassembled_index 和获取的 sub_index 不一致了, 就可以直接跳出循环, 这意味着数据已经不连续了, 没有必要继续数据传递的进程了。

Note
  • reassembler_win.cc 测试不通过, 可能是迭代器更新不对, 以及 lower_bound 可能用了 upper_bound 没找到对应的位置。
  • reassembler_speed_test.cc 测试发现读取和写入数据不一致, 可能就是没有考虑 push 时仅有一部分数据被传入了 ByteStream 而导致数据丢失。

4. 测试结果

cs144@cs144-ubuntu22:~/cs144-sp23/minnow$ cmake --build build --target check1
Test project /home/cs144/cs144-sp23/minnow/build
      Start  1: compile with bug-checkers
 1/17 Test  #1: compile with bug-checkers ........   Passed    4.03 sec
      Start  3: byte_stream_basics
 2/17 Test  #3: byte_stream_basics ...............   Passed    0.02 sec
      Start  4: byte_stream_capacity
 3/17 Test  #4: byte_stream_capacity .............   Passed    0.03 sec
      Start  5: byte_stream_one_write
 4/17 Test  #5: byte_stream_one_write ............   Passed    0.03 sec
      Start  6: byte_stream_two_writes
 5/17 Test  #6: byte_stream_two_writes ...........   Passed    0.02 sec
      Start  7: byte_stream_many_writes
 6/17 Test  #7: byte_stream_many_writes ..........   Passed    0.06 sec
      Start  8: byte_stream_stress_test
 7/17 Test  #8: byte_stream_stress_test ..........   Passed    0.03 sec
      Start  9: reassembler_single
 8/17 Test  #9: reassembler_single ...............   Passed    0.02 sec
      Start 10: reassembler_cap
 9/17 Test #10: reassembler_cap ..................   Passed    0.02 sec
      Start 11: reassembler_seq
10/17 Test #11: reassembler_seq ..................   Passed    0.03 sec
      Start 12: reassembler_dup
11/17 Test #12: reassembler_dup ..................   Passed    0.04 sec
      Start 13: reassembler_holes
12/17 Test #13: reassembler_holes ................   Passed    0.02 sec
      Start 14: reassembler_overlapping
13/17 Test #14: reassembler_overlapping ..........   Passed    0.02 sec
      Start 15: reassembler_win
14/17 Test #15: reassembler_win ..................   Passed    0.30 sec
      Start 16: compile with optimization
15/17 Test #16: compile with optimization ........   Passed    1.57 sec
      Start 17: byte_stream_speed_test
             ByteStream throughput: 2.26 Gbit/s
16/17 Test #17: byte_stream_speed_test ...........   Passed    0.25 sec
      Start 18: reassembler_speed_test
             Reassembler throughput: 8.10 Gbit/s
17/17 Test #18: reassembler_speed_test ...........   Passed    0.36 sec

100% tests passed, 0 tests failed out of 17

Total Test time (real) =   6.87 sec
Built target check1