这里给出CS 144 Lab 4: the summit (TCP in full)的翻译。

实验资料:

实验四:顶峰(summit)(完整的TCP)

0. 合作政策

编程作业必须是你自己的工作:你必须编写你为编程作业提交的所有代码,但我们作为作业的一部分提供给你的代码除外。请不要复制和粘贴来自StackOverflow,GitHub或其他来源的代码。如果你的代码基于你在网上或其他地方找到的样例,请在提交的源代码中的注释中引用URL。

与他人合作:你不能把你的代码给别人看,也不能看别人的代码,更不能看往年的解决方案。你可以与其他学生讨论作业,但不要抄袭任何人的代码。如果你与其他学生讨论作业,请在你提交的源代码中的注释中列出他们的姓名。请参阅课程管理讲义了解更多详细信息,如果有任何不清楚的地方,请在Piazza上询问。

Piazza:请随时在Piazza上提问,但请不要发布任何源代码。

1. 概述

在实验0中,你实现了流量控制的字节流(ByteStream)的抽象概念。

在实验1、2和3中,你实现了该抽象概念与互联网提供的抽象概念之间的转换工具:不可靠的数据报(IP或UDP)。

现在,你已经接近顶峰:一个可以工作的TCPConnection,它结合了你的TCPSenderTCPReceiver,并能以至少100Mbit/s的速度与其他TCP实现对话。

图1显示了整体设计:

图1:TCP实现中的模块和数据流的安排。

2. 开始

你的TCPConnection实现将使用与你在实验0-3中使用的相同的Sponge库,并增加了类和测试。我们将给你提供支持代码,用于将TCP段读写到用户数据报(“TCP-over-UDP”)和互联网数据报(“TCP/IP”)的有效载荷中。我们还将给你一个类(CS144TCPSocket),它可以包装你的TCPConnection,使其表现得像一个正常的流套接字,就像你在实验0中用来实现webget的TCPSocket。为了开始进行作业:

  1. 请确保你已经提交了你在实验3中的所有解决方案。请不要修改libsponge目录顶层以外的任何文件,或者webget.cc。否则,你可能会在合并实验4的启动代码时遇到麻烦。
  2. 在实验作业的存储库中,运行git fetch来检索实验作业的最新版本。
  3. 通过运行git merge origin/lab4-startercode,下载实验4的启动代码。
  4. build目录中,编译源代码:make(编译时可以运行make -j4以使用四个处理器)。
  5. build目录外,打开并开始编辑writeups/lab4.md文件。这是你实验报告的模板,将包含在你提交的内容中。

3. 实验4:TCP连接

本周,你将完成构建一个与互联网上数十亿台计算机和移动设备兼容的工作TCP实现。你已经完成了大部分的工作:你已经实现了发送方和接收方。本周你的工作是将它们”连接”起来,成为一个对象(TCPConnection),并处理一些对连接来说是全局性的管家任务。

回顾一下:TCP可靠地传递一对受流量控制的字节流,每个方向一个。两方参与TCP连接,每一方同时作为”发送方”(自己的出站字节流)和”接收方”(入站字节流)行动:

双方(上图中的”A”和”B”)被称为连接的”端点”,或”对等方”。你的TCPConnection作为其中一个对等方,负责接收和发送数据段,确保发送方和接收方被告知并有机会对他们关心的传入和传出段的字段作出贡献。

接收段TCPConnection将接收来自互联网的TCPSegment,并且

  • 如果ACK标志被设置,告诉TCPSender关于它在传入段上所关心的字段:acknowindow_size,并且
  • 将段交给TCPReceiver,这样它就可以检查它所关心的传入段的字段:seqno, syn, payload, fin

发送段TCPConnection将通过互联网发送TCPSegment

  • 每当TCPSender将一个段push到它的传出队列中时,它就会在传出段上设置它负责的字段。(seqno, syn, payload, fin)。
  • 在发送段之前,TCPConnection会向TCPReceiver询问它负责的传出段的字段:acknowindow_size。如果有ackno(请记住,TCPReceiver::ackno()返回一个可选值。),它将设置ack标志和TCPSegment中的字段。

因此,每个TCPSegment的整体结构看起来像这样,”发送方 “和”接收方”字段用不同的颜色显示:

TCPConnection的完整接口在类文档中。请花一些时间来阅读。你的大部分实现将涉及到将TCPConnection的公共API与TCPSenderTCPReceiver中的适当例程进行”连接”。你希望尽可能将任何繁重的工作推迟到你已经实现的发送方和接收方。话虽如此,但并不是所有的事情都那么简单,有一些微妙的地方涉及到整体连接的”全局”行为。最难的部分是决定何时完全终止一个TCPConnection并宣布它不再是”活动的”。

下面是一些常见问题和你需要处理的边缘情况的细节。

4. 常见问题和特殊情形

  • 你们希望有多少代码?

    总的来说,我们预计实现(在tcp_connection.cc中)总共需要大约100-150行的代码。当你完成后,测试套件将广泛地测试你自己的实现以及Linux内核的TCP实现的交互性。

  • 我应该如何开始?

    最好的开始方式可能是将一些”普通”方法与TCPSenderTCPReceiver中的适当调用连接起来。这可能包括像remaining_outbound_capacity()bytes_in_flight()以及unassembled_bytes()

    然后你可以选择实现”writer”的方法:connect()write()end_input_stream()。其中一些方法可能需要对出站的ByteStream(由TCPSender拥有)做一些事情,并告知TCPSender

    你可能会选择在你完全实现每个方法之前开始运行测试套件(make check);测试的失败信息可以给你一个线索或指南,告诉你接下来要处理什么。

  • 应用程序如何从入站流中读取?

    TCPConnection::inbound_stream()已经在头文件中实现了。

  • TCPConnection是否需要任何花哨的数据结构或算法?

    不,它真的不需要。繁重的工作都是由你已经实现的TCPSenderTCPReceiver完成的。这里的工作实际上只是把所有的东西连接起来,处理一些难以轻易融入发送方和接收方的连接范围内的微妙问题。

  • TCPConnection如何实际发送一个段?

    类似于TCPSender,把段push到_segments_out队列中。就你的TCPConnection而言,当你把它push到这个队列上时,就认为它已经发送了。很快,所有者会出现并pop它(使用公共的segments_out()访问器方法)并真正发送它。

  • TCPConnection如何了解时间的流逝?

    TCPSender类似——tick()方法将被定期调用。请不要使用任何其他方式来获得时间,tick方法是你对时间流逝的唯一访问,这样可以保持事情的确定性和可测试性。

  • 如果一个传入段设置了RST标志,TCPConnection会做什么?

    这个标志(“重置”)表示连接立即终止。如果你收到一个带有RST的段,你应该在入站和出站的ByteStreams上设置错误标志,并且任何后续对TCPConnection::active()的调用都应该返回false。

  • 什么时候应该发送一个设置了RST标志的段?

    有两种情况下,你会想中止整个连接。

    1. 如果发送方连续发送了太多的重传而没有成功(超过了TCPConfig::MAX_RETX_ATTEMPTS,即8)。
    2. 如果在连接仍处于活动状态时调用TCPConnection析构函数(active()返回true)。

    发送一个设置了RST的段与接收一个段的效果类似:连接已断开且不再active(),两个ByStream都应设置为错误状态。

  • 等等,但我如何生成一个可以设置RST标志的段?序列号是什么?

    任何流出的段都需要有适当的序列号。你可以通过调用TCPSendersend_empty_segment()方法,强制TCPSender生成一个具有适当序列号的空段。或者你可以通过调用它的fill_window()方法让它填充窗口(如果它有未完成的信息要发送,例如,来自流的字节或SYN/FIN)。

  • ACK标志的作用是什么?不是一直有一个ackno吗?

    • 几乎每个TCPSegment都有一个ackno,并且设置了ACK标志。例外的情况是在连接的最开始,在接收方有任何需要确认的东西之前。
    • 在传出段中,你要尽可能地设置acknoACK标志。也就是说,只要TCPReceiverackno()方法返回一个std::optional<WrappingInt32>的值,你就可以用has_value()测试。
    • 在传入段中,只有当ACK字段被设置时,才需要查看ackno。如果ACK字段被设置,就把这个ackno(和窗口大小)给TCPSender
  • 在接收段时,如果TCPReceiver抱怨说该段没有与窗口重叠,是不可接受的(segment_received()返回false),我应该怎么做?

    在这种情况下,TCPConnection需要确保向对等方发回一个段,给出当前的ackno和窗口大小。这有助于纠正对等方的困惑。

  • 好的,很好。如果TCPConnection收到了一个段,而TCPSender抱怨说ackno无效(ack_received()返回false),该怎么办?

    同样的答案!

  • 如果TCPConnection收到了一个网段,而且一切都很好呢?那我还需要回复吗?

    如果该段占用了任何序列号,那么你需要确保它被确认——至少需要向对等方发送一个带有适当的序列号和新的acknowindow_size的段。你可能不需要做任何事情来强制这样做,因为TCPSender通常会在ack_received()中决定发送一个新的段(因为窗口中已经打开了更多的空间)。但是,即使TCPSender没有更多的数据要发送,你也需要确保传入的段以某种方式被确认。

  • 如果TCPConnection只是确认每个网段,即使它不占用任何序列号,又如何呢?

    这可不是个好主意!两个对等方最终会来回发送无限多的acks。

  • 如何解读这些”状态”名称(如”流开始(stream started)”或”流进行中(stream ongoing)”)?

    请查看libsponge/tcp_helpers/tcp_state.hhtcp_state.cc文件。

  • 如果TCPReceiver想公布一个比TCPSegment::header().win字段大的窗口尺寸,我应该发送什么?

    发送你能发送的最大值。你可能会发现std::numeric limits类有帮助。

  • TCP连接何时最终”完成”?active()什么时候可以返回false?

    请看下一节。

  • 如果本PDF发布后有更多常见问题,我可以在哪里阅读?

    请定期查看网站(https://cs144.github.io/lab_faq.html)和Piazza。

5. TCP连接的结束:共识需要工作

TCPConnection的一个重要功能是决定TCP连接何时完全”完成”。当这种情况发生时,该实现会释放其对本地端口号的独占申明,停止发送回复传入段的确认,认为该连接已成为历史,并让其active()方法返回false。

有两种方式可以结束一个连接。在一个不干净的关闭中,TCPConnection发送或接收一个设置了RST标志的段。在这种情况下,出站和入站的ByteStream应该都处于错误状态,而active()可以立即返回false。

一个干净的关闭是我们如何在没有错误的情况下达到”完成”(active() = false)。这比较复杂,但这是件美好的事情,因为它尽可能地确保两个ByteStream中的每一个都被可靠地完全交付给接收方。在下一节(§§5.1)中,我们给出了干净的关闭发生时的实际情况,所以如果你愿意,可以随意跳过前面的内容。

酷,你还在这里。由于”Two Generals Problem“的存在,不可能保证两个对等方都能实现干净的关闭,但是TCP已经非常接近了。情况是这样的。从一个对等方(一个TCPConnection,我们称之为”本地”对等方)的角度来看,在其与”远程”对等方的连接中,有四个前提条件可以实现干净的关闭:

  • 前提条件#1 入站流已完全组装并已结束。

  • 前提条件#2 出站流已被本地应用程序结束,并完全发送(包括它结束的事实,即一个带有FIN的段)到远程对等方。

  • 前提条件#3 出站流已被远程对等方完全确认。

  • 前提条件#4 本地TCPConnection确信远程对等方能满足前提条件#3。这是令人头疼的部分。有两种可选的方法可以实现这一点:(等待修改为徘徊)

    • 选项A:在两个流结束后徘徊。前提条件#1到#3都是真的,而且远程对等方似乎已经得到了本地对等方对整个流的确认。本地对等方并不确定这一点——TCP无法可靠地传递acks(它不接受acks)。但是本地对等方非常确信远程对等方已经得到了它的acks,因为远程对等方似乎没有重传任何东西,而且本地对等方已经等待了一段时间来确定。

      具体来说,当前提条件#1到#3得到满足,并且本地对等方从远程对等方收到任何网段后,至少已经过了10倍的初始重传超时(_cfg.rt_timeout),连接就完成了。这被称为在两个流结束后的”徘徊”,以确保远程对等方没有试图重传我们需要确认的东西。这确实意味着TCPConnection需要保持一段时间的活跃状态,保持对本地端口号的独占要求,并可能发送acks以响应传入的段,甚至在TCPSenderTCPReceiver完全完成其工作且两个流都结束之后。

      • 在一个生产型的TCP实现中,等待计时器(也被称为时间等待计时器或最大段寿命(MSL)的两倍)通常是60或120秒。在一个连接有效完成后,保留一个端口号的时间可能很长,特别是如果你想启动一个新的服务器,绑定到同一个端口号,没有人愿意等待两分钟。SO_REUSEADDR socket选项本质上是让Linux忽略保留,对于调试或测试来说是很方便的。
    • 选项B:被动关闭。前提条件#1到#3都是真的,而且本地对等方100%确定远程对等方可以满足前提条件#3。如果TCP不确认确认,这怎么可能呢?因为远程对等方是第一个结束其流的人

      为什么这个规则有效?这是脑筋急转弯,你不需要进一步阅读就能完成这个实验,但思考起来很有趣,而且能触及”Two Generals Problem”的深层原因,以及在不可靠的网络中对可靠性的固有限制。这样做的原因是,在收到并组装了远程对等方的FIN(前提条件#1)后,本地对等方发送了一个比以前发送的序列号更大的段(至少,它必须发送自己的FIN段以满足前提条件# 2),该段也有一个ackno,承认远程对等方的FIN位。远程对等方承认该段(满足前提条件#3),这意味着远程对等方一定也看到了本地对等方对远程对等方的FIN的ack。这就保证了远程对等方一定能够满足它自己的前提条件#3。所有这些都意味着本地对等方可以满足前提条件#4,而不需要等待。

      呜呼! 我们说过这是一个脑筋急转弯。在你的实验报告中加分:你能找到一个更好的方法来解释这个问题吗

      底线是,如果TCPConnection的入站流在TCPConnection发送FIN段之前就结束了,那么TCPConnection就不需要在两个流结束后等待

5.1 TCP连接的结束(实践总结)

实际上这意味着你的TCPConnection在流结束后有一个叫做_linger_after_streams_finish的成员变量,通过state()方法暴露给测试程序。这个变量一开始是true。如果入站流在TCPConnection到达其出站流的EOF之前结束,则需要将此变量设置为false

在满足前提条件#1到#3的任何一点上,如果_linger_after_streams_finish为false,连接就”完成”了(并且active()应该返回false)。否则,你需要等待:只有在收到最后一个网段后经过足够的时间(10 × _cfg.rt_timeout),连接才会完成。

6. 性能

在你完成了你的TCP实现,并且通过了make check运行的所有测试之后,请提交!然后,测量你的系统的性能,使其至少达到每秒100兆比特。

在build目录中,运行./apps/tcp benchmark。如果一切顺利的话,你会看到像这样的输出:

user@computer:~/sponge/build$ ./apps/tcp_benchmark 
CPU-limited throughput : 1.78 Gbit/s 
CPU-limited throughput with reordering: 1.21 Gbit/s

为了获得实验的全部学分,你的性能需要在两条线上至少达到”0.10Gbit/s”(每秒100兆比特)。你可能需要对你的代码进行剖析,或者对它慢的地方进行推理,你可能需要改进一些关键模块(如ByteStreamStreamReassembler)的实现来达到这一点。

在你的报告中,请报告你所取得的速度数据(有无重新排序)。

如果你愿意,欢迎你尽可能地优化你的代码,但请不要以牺牲CS144的其他部分为代价,包括本实验的其他部分。如果你的性能超过100Mbit/s,我们不会给你加分——你所做的任何超出这个最低限度的改进都只是为了你自己的满意和学习。如果你在不改变任何公共接口的情况下实现了比我们快的速度,我们很愿意向你了解你是如何做到的。

(我们在2011年英特尔酷睿i7-2600K CPU @ 4.40GHz上运行我们的参考实现,使用Ubuntu 19.04,Linux 5.0.0-31-generic #33-Ubuntu,带有针对Meltdown/Spectre/等的默认缓解措施,以及带有默认编译器标志的g++ 8.3.0,进行默认(“发布”)构建。CPU限制的吞吐量(第一行)为7.18 Gbit/s,(第二行,有重新排序)为6.84 Gbit/s。)

7. webget重温

胜利的时刻到了! 还记得你在实验0中写的webget.cc吗?它使用了由Linux内核提供的TCP实现(TCPSocket)。我们希望你能把它改成使用你自己的TCP实现,而不需要改变其他任何东西。我们认为你所需要做的就是:

  • #include "tcp_sponge_socket.hh"替换#include "socket.hh"
  • TCPSocket类型改为CS144TCPSocket
  • 在你的get_URL()函数的末尾,添加一个对socket.wait_until_closed()的调用。

>

为什么要这样做?通常情况下,Linux内核负责等待TCP连接达到”干净关闭”(并放弃它们的端口保留),即使在用户进程退出后也是如此。但由于你的TCP实现都在用户空间,除了你的程序,没有其他东西可以跟踪连接状态。添加这个调用使套接字等待,直到你的TCPConnection报告active() = false

重新编译,并运行make check webget来确认你已经完成了完整的闭环:你已经在你自己完整的TCP实现之上写了一个基本的web获取器,而且它仍然成功地与一个真正的webserver对话。如果你有问题,试着手动运行程序:./apps/webget cs144.keithw.org /hasher/xyzzy。你会在终端上得到一些调试输出,可能会有帮助。

8. 开发和调试建议

  1. tcp_connection.cc文件中实现TCPConnection的公共接口(以及任何你想要的私有方法或函数)。你可以在tcp_connection.hh中向TCPConnection类添加任何你喜欢的私有成员。

  2. 我们希望总共有100-150行的代码。你不需要任何花哨的数据结构或算法;

  3. 你可以用make check测试你的代码(编译后)。这将运行一个相当全面的测试套件(159个测试)。你的TCP实现可以通过Linux的TCP实现,或通过其自身,通过各个方向上的各种数据包丢失和数据传输组合,无错误地传输文件。

    • 在测试名称中,”c”表示你的代码是客户端(发送第一个Syn的对等方),而”s”表示你的代码是服务器。字母”u “意味着它正在测试TCP-over-UDP,而”i”正在测试TCP-over-IP(TCP/IP)。字母”n”表示它正试图与Linux的TCP实现互操作。”S”表示你的代码正在发送数据;”R”表示你的代码正在接收数据,而”D”表示数据正在双向发送。在测试名称的末尾,小写的”l”表示在接收(传入段)方向有数据包丢失,大写的”L”表示在发送(传出段)方向有数据包丢失。
  4. 请重新阅读Lab 0文档中关于”使用Git”的部分以及在线常见问题解答,并记住将代码保存在Git仓库中,它是在master分支上分发的。使用好的提交消息进行小规模的提交,这些消息可以识别更改的内容以及更改的原因。

  5. 请努力使你的代码对将对其进行风格评分的CA来说可读。对变量使用合理而清晰的命名规则。使用注释来解释复杂或微妙的代码片段。使用”防御性编程”——明确地检查函数的先决条件或不变量,如果有什么问题,就抛出一个异常。在设计中使用模块化识别常见的抽象和行为,并在可能的情况下将其分解。重复的代码块和庞大的函数将使你很难理解代码。

  6. 也请遵守Lab 0文件中描述的 “现代C++”风格。cppreference网站(https://en.cppreference.com)是一个很好的资源,尽管你不需要C++的任何复杂功能来做这些实验。(你有时可能需要使用move()函数来传递一个不能被复制的对象)。

  7. 如果你得到一个segmentation fault,那么一定是出了问题!我们希望你的写作风格是使用安全的编程实践,使分段故障极不寻常(不使用malloc(),不使用new,不使用指针,在你不确定的地方进行安全检查,抛出异常,等等)。要进行调试,可以使用cmake配置构建目录

    -DCMAKE BUILD TYPE=RelASa

    使编译器的”净化器”能够检测内存错误和未定义行为,并在它们发生时给你一个漂亮的诊断。你也可以使用valgrind工具。你还可以用

    cmake .. -DCMAKE BUILD TYPE=Debug

    记住只在调试时使用这些设置,因为它们会大大降低程序的编译和执行速度。恢复到“Release”模式的最可靠/最愚蠢的方法是阐述build目录并创建一个新(build)目录。

9. 提交

  1. 在你的提交中,请只对libsponge顶层的.hh和.cc文件进行修改。在这些文件中,请随意添加必要的私有成员,但请不要改变任何类的公共接口。
  2. 在提交任何作业之前,请按顺序运行这些。
    • (a) make format(使编码风格正常化)
    • (b) git status(检查是否有未提交的修改,如果有,请提交!)
    • (c) make(确保代码可以编译)
    • (d) make check_lab4(确保自动测试通过)
  3. 在writeups/lab4.md中写一份报告。这个文件应该是一个大约20到50行的文件,每行不超过80个字符,以使其更容易阅读。该报告应包含以下部分。

    • (a) 程序结构和设计。描述你的代码中所体现的高层次结构和设计选择。你不需要详细讨论你从启动代码中继承了什么。借此机会突出重要的设计方面,并在这些方面提供更多的细节,以便你的评分助教理解。我们强烈建议你通过使用小标题和提纲,使这篇报告尽可能可读。请不要简单地将你的程序翻译成一段英文。
    • (b) 实现方面的挑战。描述你认为最麻烦的代码部分,并解释原因。反思一下你是如何克服这些挑战的,以及是什么帮助你最终理解了给你带来麻烦的概念。你是如何试图确保你的代码维护你的假设、不变式和先决条件的,你在哪些方面发现这很容易或很困难?你是如何调试和测试你的代码的?
    • (c) 剩余的错误。尽量指出并解释代码中仍然存在的任何错误(或未处理的边缘情况)。
  4. 在你的报告中,请同时填写这项任务花了你多少时间以及任何其他评论。

  5. 当准备提交时,请按照https://cs144.github.io/submit。在提交之前,请确保你已经提交了所有你想要的东西。
  6. 如果有任何问题,请尽快在周二晚上的实验课上告诉课程组成员,或者在Piazza上发帖提问。祝你好运!