CS144(2024 Winter)Lab Checkpoint 3: the TCP sender

本文详细描述了在Lab4中如何基于Lab3的代码实现,构建一个支持TCP协议的TCPSender,涉及全双工通信、超时重传、滑动窗口机制、ARQ以及确认报文处理等关键技术。
摘要由CSDN通过智能技术生成

0.Overview

check3.pdf

与 Lab2 相反的是,此次实验要我们实现一个 TCPSender。

我们都知道 TCP 协议是全双工通信,信道两端的发送方和接收方各自都能够收发信息。在 TCP 中,接收方接收到信息的同时还需要向发送方发送一个确认分组;同理,不仅需要发送数据负载,还需要在确认分组迟迟不到(丢失确认/数据丢包)时重传分组。

在完成了 Lab3 的工作后,Lab4 的工作将会结合之前的实验代码,完成一个 TCP 协议的完整实现。

overview

1.需求分析

Lab3 的实现因为发送方的行为比较复杂(指 TCP 的超时重传和滑动窗口机制),所以代码需求也比较多。

1.1 核心流程

文档告诉我们 TCPSender 的核心需求如下:

  1. 记录接收端告知它的窗口大小(从 TCPReceiverMessages 中读取 window sizes);
  2. ByteStream 中读取 payload,在窗口大小允许的情况下追加控制位 SYN 和 FIN(仅在最后一次发出消息时),并持续填充报文的 payload 部分直到窗口已满或没东西可以读,再将其发送出去;
  3. 追踪哪些已发的报文是没收到回复的,这部分报文被称为“未完成的字节”;
  4. 未完成的字节在足够长的时间后依然没得到确认,重传(也就是指 TCP 的自动重传部分)。

tcpsender_introduce

这里首先需要明确一点:sender 发出的报文段中的 SYN 一定为 true 的情况只有一个:在第一次发出报文时,后续报文的 SYN 值完全取决于接收方有没有发来对第一个报文的确认(这时的 SYN 是可以为 false 的)。

关于超时重传部分,文档介绍到:TCPSender 的所有者会周期性地调用 tick() 方法告知距离上次调用该方法经过了多少毫秒(ms)。如果这个时间超过了重传时间间隔 RTO,立刻重发最早没被确认的报文,并且只发一次。

此外,为了避免大家面向样例编程,目前提供的测试样例都是不完全的,完整的测试将在 Lab4 完成了 TCP 的整个实现后提供。(所以要好好考虑当前的代码实现,减少后面倒回来修改代码的次数)

more_details_about_ARQ

接下来详细查看一下关于自动重传协议 ARQ,我们需要做些什么。

1.2 ARQ 的需求

文档中关于 TCPSender 如何知道丢失了数据、需要重传的情况说明得非常详细,这里一条一条慢慢理清思路。

在开始前,需要说明两点:由于我们需要重传一些已经发送过的数据,所以我们必然需要有一个缓冲区用于存储这些数据。考虑到重传确认机制永远只对最早的未完成数据生效(满足 FIFO),那么这个缓冲区可以使用 std::queue 实现,内部元素类型就是发送出去的报文数据 TCPSenderMessage

另外,由于报文中的 seqno 和 absolute seqno 是不兼容的,并且我们要知道确认报文中的确认序号到底确认到了哪个字节,所以我们还需要维护一个记录了已确认到了哪个字节序号的数值 acked_seqno (这个东西随便你怎么命名),这个值显然等于上文提到的缓冲区队首的报文首字节序号。

不过你不维护这个东西也可以,无非是每次遍历缓冲区时都要使用队首的 seqno 的 unwrap() 方法解除封装。

很自然的,为了能够知道当前发出去的报文中的首字节序号是什么,我们还需要有一个记录了已发送到哪个字节序号的数值 sent_seqno

  1. 上文提到过,TCPSendertick() 方法会被周期性地调用,并通过它的函数参数告知当前经过了多长时间。文档这里警告我们:不要通过系统调用获取现实世界的时间(无论是 glibc 的 time() 还是 STL 的 std::chrono::system_clock::now()),应该也必须使用给定的函数参数 uint64_t ms_since_last_tick 获取时间信息。

  2. 其次,TCPSender 对象在被构造时会接受一个初始值,这个值将作为这个对象的初始重传时间间隔。

arq_details1

  1. 为了实现自动重传,TCPSender 自然是需要一个超时计时器,这个计时器需要记录 RTO 的大小,并在累计的时间大于 RTO 后将计时器的状态转为“已过期”。文档推荐将这个 timer 设计为一个辅助类。

  2. 每次发出一个带有负载的非零长报文(零长是指既没有 SYN、FIN,也没有 payload 的 TCPSenderMessage 对象,但 RST 的值依然由流对象本身决定),如果计时器没有启动,就需要启动它。

arq_details2

  1. 接受确认报文时,如果所有的未完成数据都已经被确认,那么停止计时器。

什么叫“所有的未完成数据都被确认”呢?

意思就是说接收到了一个新的确认报文,这个确认报文的确认序号大到足以清空数据缓冲区,就可以认为“所有未完成数据都已经被确认”。

但是这里有个小坑:这个“足够大的确认序号”不能超过 TCPSenderMessage 自己记录的下一个待发字节序号;否则就表示接收方发来了一个还没发出的报文的确认(在测试集中可以看到这种情况的报文是需要抛弃的)。

  1. 如果调用 tick() 方法时重传计时器过期:
    1. 重发最早的未被确认的报文(所以说要有一个缓冲区存储这些报文);
    2. 如果窗口大小不为零
      1. 重传报文,并把这次重传计入一个重传计数器中;
      2. 将 RTO 的值乘以 2(课程的要求比较简单)。
    3. 重置计时器,使得记录的时间复位为 0。

arq_details3

  1. 当接收方确认成功接收新数据时(也就是说 TCPReceiverMessage 中的 ackno 的值要大于缓冲区队首的字节序号):
    1. 将计时器的 RTO 重置为默认值;
    2. 如果 sender 还有未完成的数据,重启计时器使其继续工作;
    3. 重置重传计数器。

arq_details4

2.代码分析

上面就是 TCPSender 在面对超时重传时需要做的所有事情。根据以上的分析,接下来分析一下给定的代码框架。

2.1 push() 方法

push() 方法中,我们需要从流中不断地读取字节,直到填满发送窗口、或是填充的数据使得当前报文的负载长度达到了 TCPConfig 中规定的上限。

实际实现时,push() 方法必须能够尽快将所有缓存的数据全部发送出去(滑动窗口机制,连续发送多个分组),也就是说我们需要不断地从流中读取字节并组装报文,当读取的数据达到了报文段长度限制后马上把这个报文发送出去,再继续执行读取-组装工作,直到流中没有更多数据、或者累计发送出去的字节数达到了接收方的窗口大小。

因为不对 TCP 报文长度做限制就交由底层协议传输,极有可能会因为中途丢包导致报文段不完整,进而导致了大量重传行为的发生。

文档这里提到, SYN 和 FIN 字节也要被计入字节序号当中。故窗口大小不足、并且还需要发出 FIN 时,必须把 FIN 字节的发送推迟到下次报文发送。

正如前文提过的,只有传输第一个报文时才计算 SYN 的字节序号。

注意下面的 FAQ 中提及了一个特殊行为:当接收方告知其窗口大小为 0 时,我们需要假装窗口大小为 1,并依然正常发送报文过去,避免陷入死锁。

关于设置 TCPSenderMessage 中的 RST 的时机,使用 ByteStream::has_error() 方法即可,即流错误必然要求重置连接。

push_method

2.2 receive()tick() 方法

receive() 方法负责根据接收方发来的确认报文,更新 TCPSender 的缓冲区。更新条件是:当 ackno 的值大于缓冲区队首报文段中的所有字节序号,也就是说只有队首的报文被全部确认后,才能把这个报文弹出缓冲区;因此不要根据 ackno 的值去截断队首的报文负载,没有全部确认的话统一视而不见(视而不见包括不更新计时器、不重置重传计数器)。

在这个方法中,如果新的确认报文将缓冲区全部清空了(全部数据都被确认),那么就要停止计时器。

tick() 方法正如之前介绍的,不要使用现实世界的时间,在这个方法中根据计时器是否过期判断是否需要重传数据。

receive_tick_methods

2.3 make_empty_message() 方法

make_empty_message() 正如其名,创建并返回一个零长的报文。如果有看过 TCPSenderMessage::sequence_length() 的代码实现就会知道,一个零长的报文有以下特征:

  1. 控制位 SYN、FIN 全部都是 false;
  2. payload 是空的;
  3. RST 的值取决于流对象是否有错误。

但是 TCPSenderMessage 中的 seqno 必须是当前 TCPSender 要发送的下一个字节序号。

make_empty_message_method

2.4 FAQs

FAQ 中提供了额外的信息。

  1. 在一切刚开始的时候(指刚构造 TCPSender 对象),假定接收方的窗口大小为 1;
  2. 不要裁剪缓冲区的字节数据,即使它们中的部分已经得到了确认(这里说的是可以但没必要);
  3. 同样的,即使缓冲区中有多个完整且互相可以连接在一起的字节数据,也没有必要将它们拼在一起;
  4. 如果发送了零长报文(调用 make_empty_message() 方法得到的那些东西),不要重传它,也不要启动计时器。

FAQs

3.程序实现

那么到这里我们可以总结几个关键的函数。

3.1 在 push() 方法中要做的事

  1. 在第一次调用该方法时,发送一个 1 字节长度的连接请求报文(因为此时 TCPSender 假定接收方窗口大小为 1),并将这个字节计入“未完成的字节数量”中(也就是没有收到确认的字节数量);
  2. 不断的组装报文,并始终保持以下任意一个条件为真:
    1. 每个报文的负载 payload 长度不大于 TCPConfig::MAX_PAYLOAD_SIZE 的值;
    2. 没有收到确认的字节数量不能超过接收方告知的窗口大小。
  3. 将已发送、且没有收到确认的以下字节计入“未完成的字节数”中:
    • 第一个 SYN 字节、最后一个 FIN 字节、以及所有 payload 中的字节数。
  4. 每次从 ByteStream 中读取字节后都要检查读端是否已经结束,并把存在流中的 EOF 字符丢弃(还记得 Lab1 的要求吗?),再视情况决定是否发送 FIN 字节;
  5. 当 FIN 已发出、或者 ByteStream 中没有数据、亦或者发出的数据已经填满了滑动窗口,拒绝发出任何报文;
  6. 如果有发出一个非零长报文,且超时计时器没有启动,那么就启动计时器(实际上 push() 方法不会发出非零长报文,这里只需要认为“只要有报文发出就启动计时器”)。

3.2 在 receive() 方法中要做的事

  1. 拒绝 ackno 的值是不存在的、或者是超过了当前已发出的最后一个字节序号的值的报文;
  2. 根据报文信息更新当前记录的窗口大小;
  3. 报文中的 ackno 不大于缓冲区队首的首字节序号 seqno + payload.size() 的值时,跳过更新缓冲区;
  4. 在不满足上条条件时,将缓冲区队首的报文弹出,并在缓冲区非空的前提下检查下一个队首元素;
  5. 检查 SYN 连接请求是否被确认,并根据检查结果设置以后发出的报文中 SYN 的值(也就是说允许后续发出的报文中 SYN 为 false 的情况);
  6. 如果缓冲区被清空,停止计时器,否则只重置计时器;
  7. 只要缓冲区有报文被弹出,就将重传计数器置零。

3.3 在 tick() 方法中要做的事

  1. ms_since_last_tick 的值加到计时器上;
  2. 在更新计时器后,检查计时器是否已经过期;
    1. 如果过期,重传缓冲区队首元素,递增重传计数器,并将计时器的 RTO 增加为原来的两倍;
    2. 否则退出函数。

分析了这么多,最后根据这些结果照着写代码就 ok 了;剩下的大头看看测试用例反馈的信息就够了。

tcp_sender

课程依然鼓励大家查看 tests/ 中的测试用例,并补充一些缺失但有助于完善实现的测试用例。

speed_test

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值