## 参考 https://doraemonzzz.com/2022/01/30/2022-1-30-CS144-Lab1%E7%BF%BB%E8%AF%91/ ## 目标 TCP发送方将其字节流分为短段(每个子串不超过1,460字节),以便它们各自在一个数据报中。但网络可能会对这些数据报进行重新排序,或丢弃它们,或多次传送它们。接收方必须将这些片段重新组合成它们开始时的连续的字节流。 ## 直观理解 ### 故事背景:组装书页 想象有一家图书装订厂,需要把一堆**散页**的书页(很可能"乱序"地送到厂里)**组合**成一本**有序**的书。每一页都有自己的**页码**(类似我们的 `index`),我们最终要把它们**按页码顺序**排列好,然后**装订**成一本完整的书(类似输出 `ByteStream`)。 在这个故事中: 1. **散页** = 分段的字节数据(`push_substring` 传入的 `data`)。 2. **页码** = 数据对应的逻辑下标(参数 `index`)。 3. **装订好的书** = 最终输出流(`_output`)。 4. **装订厂的工作台** = `StreamReassembler` 的内部缓冲区(`buffer` + `bitmap`)。 5. **最大可工作台容量** = 代码里的 `capacity`。 --- ### 缓冲区(buffer + bitmap)就是一本"占位"的大本子 - 你可以把 `StreamReassembler` 内部的 `buffer` 想象成一个**摆满空白插槽的大本子**:它有固定的"页数"(对应 `capacity`),这些页面现在都是空白(或填着 `'\0'`),等待我们把真正的书页插入进来。 - 为了标记哪些页面已经插好了"真正的内容",哪些页面还是空的,`bitmap` 里存了**是否有效**的标识位(真 / 假)。当我们把一页(一个字节)插进去以后,就把对应位置的标识位设为 `true`;还没插入的地方则是 `false`。 --- ### 收到新散页:如何"插入"到工作台上 #### 1. 确定散页对应的位置(index 与 offset) - 当我们收到一个新的散页(字符串 `data`),还带着它在整本书中的起始页码 `index`,先要判断: - 如果 `index` 在我们当前需要的下标(`unass_base`)**之后**,说明这是**更后面的书页**,需要放到工作台上相应的"插槽"处(要计算和 `unass_base` 的偏移量 `offset`)。 - 如果 `index` 比当前需要的下标要早,但它的末尾部分仍然延伸到我们需要的区域,那么就只取它还没被我们"组装走"的那部分插入工作台。 #### 2. 不能装就丢吗?——容量检查 - `capacity` 相当于工作台能放下的插槽总数,但是**并不是**我们实时可用的容量,还要考虑已经写到 `_output` 但仍旧占着"坑"的缓冲区。如果没有空间了,多出的那部分就等于"溢出"(实际代码里你可能会直接忽略或截断,不会放进来)。 #### 3. 真正插入 - 我们拿着散页 `data` 的内容,一字节一字节去对应到 `buffer[i + offset]` 中,同时把 `bitmap[i + offset]` 设为 `true` 表示这里已经有了有效数据。 - `unass_size` 就相当于我们"已经拿到但还没正式装订"的散页字节总数。 --- ### check_contiguous():能装订多少就装订多少 - 每当插入新的散页后,可能会触发"已经可以连续组合起来"的部分(就像书页1、2、3 已经都到位了,那就可以马上把它们**装订**进书中)。 - `check_contiguous()` 就是干这件事的: 1. 它会从 `buffer` 的最前面(对应我们要找的 `unass_base` 下标起),**如果数据是连着的**(`bitmap.front()` 为 `true`),就把这一部分字节拿出来连成 `tmp`。 2. 一直拿到遇到"空"的位置(`false`)或者到 `buffer` 尾部,就停止。 3. 然后把这串连续有效的内容(`tmp`)一次性写入 `_output`(相当于把这几页纸装订到书里去了)。 4. 同时,我们也要把 `unass_base` 和 `unass_size` 更新:因为这部分数据已经变成**正式装订**的页面了,不能再占着 `buffer` 里了。 - 在代码里,你能看到会 `pop_front()` 一些元素,并把 `unass_base` 往后挪、把 `unass_size` 减少,就是做了"已经装订好就剥离工作台"的工作。 --- ### eof 与结束判断 - 当我们收到一个标识 `eof = true` 时,就说明这是我们期望的最后一批散页,但不代表我们**马上**结束。只有当**全部**需要的字节(`unass_size == 0`)都装订进书里了,才可以**正式收尾** (`_output.end_input()`)。 --- ### 小结 1. **buffer + bitmap**:就像一个"临时存放散页"的工作台。 - `buffer[i]` 存某字节。 - `bitmap[i]` 标记那个字节是否有效。 2. **unass_base**:表示我已经装订好的最末端下标(下一页要找的书页页码)。 3. **push_substring(...)**:想办法把新到的散页放到工作台合适的位置,同时避免溢出。 4. **check_contiguous()**:检查是不是可以拿一些**连续的**书页,装订到书中 `_output`。 5. **unassembled_bytes()**:告诉你工作台上还有多少散页没被正式装订。 6. **eof**:当标记最后一批散页到了,但若工作台上还有欠缺的页面,就还不能结束。等到全部装订完才 end。 --- 你可以把这段逻辑与书的装订过程联系起来,就会发现"收到乱序页 -> 暂存到工作台相应位置 -> 连续的部分一次装订 -> 直到最后"整个流程十分自然。这样,你对代码里 `bytestream`、`buffer`、`bitmap` 以及 `unass_base`、`check_contiguous()` 做的事情就有更直观的把握了。祝学习愉快! 在这个 StreamReassembler 中,buffer 和 bitmap 是用来处理乱序数据的两个关键数据结构: 1. buffer(缓冲区) ```cpp std::deque<char> buffer; //!< The unassembled strings ``` - 存储还未能按序重组的数据内容 - 使用 deque 是因为需要在两端进行操作(前端取出数据,后端添加数据) - 大小为 capacity,未使用的位置用 '\0' 填充 1. bitmap(位图) ```cpp std::deque<bool> bitmap; //!< buffer bitmap ``` - 标记 buffer 中对应位置是否已经存储了有效数据 - true 表示该位置已有数据 - false 表示该位置尚未收到数据 - 与 buffer 一一对应 工作原理: 1. 当收到新数据片段时: ```cpp buffer[i + offset] = data[i]; // 存储数据 bitmap[i + offset] = true; // 标记该位置已有数据 ``` 1. 检查连续数据时(check_contiguous): ```cpp while (bitmap.front()) { // 如果第一个位置有数据 tmp += buffer.front(); // 取出数据 buffer.pop_front(); // 移除已处理数据 bitmap.pop_front(); // 移除对应标记 // 在末尾补充空位 buffer.push_back('\0'); bitmap.push_back(false); } ``` 这样设计的好处: 1. 可以处理乱序到达的数据片段 2. 避免重复处理相同位置的数据 3. 能够快速判断哪些位置的数据已经到达 4. 方便检查是否有连续的数据可以输出 例如,假设收到以下片段: ```Java index: 5, data: "def" index: 0, data: "abc" ``` buffer和bitmap会帮助正确组装成"abcdef"。 ## 新数据在 unass 点后 让我用 ASCII 图解释 `min(len, _capacity - _output.buffer_size() - offset)` 这个计算: ```Java 【整体视图】 capacity = 10 (总容量) 假设收到新数据:"HELLO" (len=5),从位置8开始 (offset=2): |<-------------- capacity=10 ------------>| +---+---+---+---+---+---+---+---+---+---+ | A | B | C | D | E | F | | | H | E | 新数据位置示意 +---+---+---+---+---+---+---+---+---+---+ |<--- buffer_size=6 --->| |offset| |-可用空间-| 计算可用空间: _capacity = 10 (总容量) _output.buffer_size() = 6 (已使用空间) offset = 2 (新数据的偏移量) 可用空间 = 10 - 6 - 2 = 2 (只能放入 "HE","LLO" 放不下) real_len = min(len=5, 可用空间=2) = 2 ``` 再举个例子: ```Java 情况1:缓冲区较空 capacity = 10 +---+---+---+---+---+---+---+---+---+---+ | A | B | | | | | | | | | +---+---+---+---+---+---+---+---+---+---+ |<->| buffer_size=2 |->| offset=1 |<---- 可用空间=7 ---->| len = 5 ("HELLO") real_len = min(5, 7) = 5 // 全部数据都能放入 情况2:缓冲区较满 capacity = 10 +---+---+---+---+---+---+---+---+---+---+ | A | B | C | D | E | F | G | H | | | +---+---+---+---+---+---+---+---+---+---+ |<-------- buffer_size=8 -------->| |->| offset=1 |->| 可用空间=1 len = 5 ("HELLO") real_len = min(5, 1) = 1 // 只能放入一个字符 ``` 这个计算就像是在玩俄罗斯方块: 1. `capacity` 是游戏屏幕的总高度 2. `buffer_size` 是已经堆积的方块高度 3. `offset` 是新方块要放的位置到顶部的距离 4. `real_len` 是实际能放下的新方块数量 这样可以确保: 1. 不会超过总容量 2. 考虑了已使用空间 3. 考虑了新数据的位置 4. 取最小值保证安全 ### 新数据与 unass 有部分重叠的情况 ```Java 假设当前状态: unass_base = 5 (从位置5开始未重组) 收到数据:"WORLD" (len=5) 从位置3开始 (index=3) 【初始状态】 index=3 ↓ +---+---+---+---+---+---+---+---+---+---+ | A | B | C | W | O | R | L | D | | | 新数据 +---+---+---+---+---+---+---+---+---+---+ |<----- data="WORLD" ----->| ↑ unass_base=5 offset = unass_base - index = 5 - 3 = 2 意味着要跳过 "WO",只处理 "RLD" 【计算实际可用长度】 len = 5 (WORLD的长度) len - offset = 3 (要处理的实际长度,即"RLD") real_len = min(3, capacity - buffer_size) 【处理后】 +---+---+---+---+---+---+---+---+---+---+ | A | B | C | D | E | R | L | D | | | +---+---+---+---+---+---+---+---+---+---+ buffer: 数据内容 bitmap: 1 1 1 1 1 1 1 1 0 0 ↑ ↑ ↑ 新存储的数据 ``` 再举个更复杂的例子: ```Java 【情况1:部分重叠,部分超出容量】 capacity = 10 unass_base = 5 收到:"HELLOWORLD" (index=3) +---+---+---+---+---+---+---+---+---+---+ | A | B | C | H | E | L | L | O | W | O | 当前缓冲区 +---+---+---+---+---+---+---+---+---+---+ |<------ 新数据 "HELLOWORLD" ------>| ↑ unass_base=5 offset = 5 - 3 = 2 (跳过"HE") len - offset = 8 (要处理"LLOWORLD") real_len = min(8, 剩余容量) 【情况2:有重叠且有已存在数据】 +---+---+---+---+---+---+---+---+---+---+ | A | B | C | D | E | x | x | O | W | O | 当前缓冲区 +---+---+---+---+---+---+---+---+---+---+ bitmap: 1 1 1 1 1 0 0 1 1 1 ↑ ↑ ↑ 要填充的空位 ``` 代码逻辑解释: ```cpp // 1. 计算偏移量 offset = unass_base - index; // 跳过重叠部分 // 2. 计算实际可处理长度 real_len = min(len - offset, _capacity - _output.buffer_size()); // 3. 如果无法完全存储,取消 EOF 标记 if (real_len < len - offset) { _eof = false; } // 4. 存储数据,但跳过已有数据 for (size_t i = 0; i < real_len; i++) { if (bitmap[i]) continue; // 跳过已有数据 buffer[i] = data[i + offset]; // 存储新数据 bitmap[i] = true; // 标记为已使用 unass_size++; // 更新未重组数据大小 } ``` 这就像拼拼图: 1. 找到新片段应该放的位置 2. 跳过已经有的部分 3. 把新的部分放进去 4. 标记哪些位置已经填好了