CS144_2020_Fall_lab4(站在全局的角度实现TCPConnection)
现在你已经实现了大部分TCP的功能,就差临门一脚了,一定要坚持下来。
我们已经完成了发送端和接收端,那么我们还需要一个连接函数将他们连接起来,来构造出真正有生命的TCP,lab4就是来实现这个愿望。
先附图。
图中标志了在TCP传输过程中每个行为对TCP状态造成的影响,
需要关注的点有4个。
需要关注的点有4个。
- 通读实验指导书,关注第5小节TCP是如何断开连接的,以及TIME_WAIT。
- 搞懂TCP状态的流转过程,关于每个状态的具体定义,可以参考TCP/IP State Transition Diagram (RFC793).
在tcp_state.cc文件里的TCPState::TCPState(const TCPState::State state)函数中,有关于这个实验框架对于TCP状态的定义,可以读一读,有助于理解状态图的流转过程。
当然你也可以硬着头皮写,然后debug找错,也是可以理解各个状态的,只是会有些痛苦(我就是这么干的)
我们可以在他的tcp_state文件里面看到对应的TCP变化,
稍微看一下就会发现,其实就是对应最上面那个图。
有耐心可以一个一个看一下反正我没有。
3. lab4依赖前面的几个lab的实现,如前面写错了,在lab4进行debug会很痛苦,而且不容易找。
4. lab4后面的测试是使用shell脚本写的。我一开始上来不知道怎么找问题,后来STFW发现,如果要对某个测试用例单独进行调试的话,可以在命令行里输入
ctest --output-on-failure -V -R '要单独运行的测试名字'
然后在另一个窗口上运行下面这条命令,使用tshark抓包,然后分析原因。
sudo tshark -Pw /tmp/debug.raw -i tun144
这些就是后话了,我们往下看实验指导书吧
1 概述
你来到了实验中最amazing的部分。
在Lab0中,你实现了具有流量控制功能的字节流(ByteStream)。
在Lab1、Lab2、Lab3中,你实现了传输层的基本功能。
在Lab4中,你将以一种全局视角,去实现TCPConnection模块。TCPConnection会将TCPSender和TCPReceiver整合起来,进而解决传输层的双向连接问题。
IP层会把TCP的segment给包起来,生成IP datagram,如此一来你就可以使用TCP/IP协议和世界上任何一台计算机进行通信了。
让我们再来回顾一下Figure1.
CAUTION:TCPConnection的作用是把你之前lab里实现过的sender和receiver整合起来,TCPConnection模块本身实现起来可能100行代码都不到。如果你之前实现的sender和receiver代码足够健壮,这个lab对你来说是小菜一碟。但如果不是,你可能会花大量的时间去看报错信息和debug。(我们不鼓励你去看测试代码去面向测试用例编程,除非你没别的法子了。)根据以往的经验,建议早点做实验,不要等到DDL的时候再去尝试。
3 Lab4: TCP Connection
本周,你将实现一个能跑的TCP。之前的工作里,你已经实现了sender和receiver。你接下来的任务就是把sender和receiver整合起来,进而与互联网上的任何一台计算机进行连接和通信。
回顾:TCP为字节流提供了双向的、具有流量控制功能的可靠传输。在两方建立了TCP连接的这段时间里,它们都即是发送者(从网络字节流里发送数据)又是接收者(从网络字节流里接收数据)。
我们也将通信的双方称作“终端”,或者“节点”。你的TCPConnection就是建立连接的一个节点。TCPConnection的作用是发送和接收segment,确保发送方和接收方能够从收到的segment中获取到它们所关心的字段。
让我们来看看TCPConnection的一些基本逻辑。
接收segment
如图1所示,当`segment_received`函数被调用的时候,就意味着TCPConnection从互联网中接收到了TCPSegment。于是TCPConnection需要考察segment的信息并进行如下处理:
1. 如果`RST`(reset)标志位为真,将发送端stream和接受端stream设置成error state并终止连接
2. 把segment传递给TCPReceiver,这样的话,TCPReceiver就能从segment取出它所关心的字段进行处理了:seqno,SYN,payload,FIN。
3. 如果ACK标志位为真,通知TCPSender有segment被确认,TCPSender关心的字段有ackno和window_size。
4. 如果收到的segment不为空,TCPConnection必须确保至少给这个segment回复一个ACK,以便远端的发送方更新ackno和window_size。
5. 在TCPConnection里的`segment_received()`中,有一个特殊情况需要做额外的处理:给“keep-alive” segment回复消息。对面的终端可能会发送一个不合法的seqno,来探测你的TCP连接是否仍然alive(如果alive,那么你当前的window状态是什么样子的)。即使这些segment不包含任何有效的seqno,你的TCPConnection依然要给这些“keep-avlie”的segment回复消息。代码大概长这个样子:
if (_receiver.ackno().has_value() and (seg.length_in_sequence_space() == 0)
and seg.header().seqno == _receiver.ackno().value() - 1) {
_sender.send_empty_segment();
// 在发送segment之前,
// TCPConnection会读取TCPReceiver中关于ackno和window_size相关的信息。
// 如果当前TCPReceiver里有一个合法的ackno,
// TCPConnection会更改TCPSegment里的ACK标志位,
// 将其设置为真。
}
发送Segment
TCPConnection发送segment的规则如下:
1. 当TCPSender把segment放入它的待发送队列中的时候,TCPSender需要将该sengment发送出去。
2. 在发送segment之前,TCPConnection会读取TCPReceiver中关于ackno和window_size相关的信息。如果当前TCPReceiver里有一个合法的ackno,TCPConnection会更改TCPSegment里的ACK标志位,将其设置为真。
随着时间的流逝,操作系统会定期调用TCPConnection的tick方法。当tick方法被调用,TCPConnection需要做如下几件事情:
1. 告诉TCPSender时间正在流逝。
2. 如果同一个segment连续重传的次数超过`TCPConfig::MAX_RETX_ATTEMPTS`,终止连接,并且给对方发送一个reset segment(一个RST为真的空segment)。
3. 尽可能地干净利落地结束该连接(参考Section5)。
综上,TCPSegmnet的数据结构如下图所示,有些字段是sender写入的,有些字段是receiver写入的,我们用不同的颜色标记了出来。
有关TCPConnection的详细信息请参考[TCPConnection的接口文档](https://cs144.github.io/doc/lab4/class_t_c_p_connection.html)。建议仔细阅读。TCPConnection的大多数工作都是在调用TCPSender和TCPReceiver的API,而这些API你在之前的lab中已经实现过了。
不过也有一些隐晦的地方,需要你去仔细思考,你必须以收发双方的整体角度来看待TCP连接。最难的部分是应该在何时终止TCPConnection的连接并发送终止连接的RST segment。
接下来是FAQ和一些你需要处理的边界case。
4 FAQ与边界case
- 这个lab的代码量是多少?
100~150行。当你实现完之后,测试框架会对你的TCP实现和交互逻辑进行大量的测试,测试强度和Linux内核的TCP差不多。
- 从哪开始?
可以先去实现一些普通的函数。比如remaining_outbound_capacity(), bytes_in_flight(), unassembled_bytes()
接着你可以去实现writer的逻辑:connect(), write(), and_end_input_stream()。某些方法需要与TCPSender的ByteStream进行交互。
You might choose to start running the test suite (make check_lab4) before you have fully implemented every method; the test failure messages can give you a clue or a guide about what to tackle next.
- 如何实现接受消息的字节流的read逻辑?
TCPConnection::inbound_stream()已经实现好了。你不需要在读操作上做额外的实现。
- TCPConnection会用到什么fancy的数据结构或者算法吗?
不。大部分的工作都已经在TCPSender和TCPReceiver里实现好了,你只要把两者整合起来,并且去解决一些不能简单地划归给sender或者receiver的工作。
- TCPConnection到底是如何发送segment的?
和TCPSender往_segment_out队列里添加元素的逻辑很像。一旦你的TCPConnection的连接建立,你应该尽快让元素入队。紧接着,持有该队列的所有者就会尽快让segment出队(使用公共方法segment_out())并且把该segment发送出去。
- TCPConnection是如何知道时间正在流逝的?
与TCPSender的tick()函数一样,TCPConnection也有一个tick函数,会被周期性地调用。注意不要使用系统自带的time包——那样的话代码框架则无法进行测试。
- 当一个segment是空的且RST标志位为真的时候,TCPConnection该做什么?
RST意味着连接终止。如果你收到了一个RST为真的segment,你应该把入向和出向的ByteStream的error flag设置成真,之后TCPConnection::active()应始终返回false。
- 我应该在何时发送RST为真的segment从而结束连接?
两种情况:
1. 当sender连续重传同一个segment过多的时候。(不能超过TCPConfig::MAX_RETX_ATTEMPTS)
2. TCPConnection的析构函数被调用,且active仍然为true的时候。
发送一个RST为真的segment,和接收一个这样的segment,产生的效果是一样的:连接断开、active()为false,ByteStream设置成error state。
- 等等,一个RST为真的segment,这个segment里其他的字段值是什么?比如说seqno?
任何一个发送出去的segment都应该有一个正确的seqno。通过调用send_empty_segment()方法,你可以强行生成带一个合法seqno的segment。或者,你可以在调用fill_window()函数的时候,根据一些条件进行判断,去决定要不要把RST设置为真。
- segment里,ACK标志位的意义是什么?不是已经有ackno字段里吗?
几乎每个TCPSegment都包含了ackno和ACK。不过在连接刚建立的时候,也有一些例外,比如receiver要确认一些事件的时候。
对于要发送的segment,你肯定想尽可能地去把ackno和ACK标志位的值给设置上。当你的TCPReceiver结构体ackno()方法会返回一个合法的数值的时候,你就可以把ackno和ACK标志位给设置上。你可以调用ackno().has_value()方法进行判断ackno()是否返回了合法数值。
对于接收到的segment,如果ACK字段为真,你可以进一步去考察ackno的值,然后把ackno和window_size的值传给TCPSender。
- 代码框架里面,不同的state都表达了什么含义(比如“stream_started”或者“stream_ongoing”)?
你可以再去看看Lab2、Lab3手册里的图。
要额外强调的是,这些state只是用来测试和debug用的。你不需要在代码里面显式地使用这些状态。你也不需要关心这些state变量的值。state只是把你模块里的状态给暴露出来。
- 当TCPReceiver的window size的大小超出了TCPSegment::header().win字段的表示范围,怎么办?
取能够表示的最大值,参考std::numeric_limits。
- TCP连接正常结束的时候,active()应该返回false吗?
参考本小节的Section5.
更多的FAQ请参考https://cs144.github.io/lab_faq.html。
5 TCP连接的尽头:如何达成断开连接共识?
TCPConnection一个很重要的功能就是判断什么时候连接已经结束。当连接结束,TCPConnection会将连接中的本地端口号释放,然后把这个连接看作是history,停止给收到的segment回复ACK,且`active()`始终返回false。
有两种方式会让连接终止。一种是不优雅的关闭,TCPConnection发送或者收到了RST segment。这种情况下,要把出向和入向的ByteStream设置成error state,令`active()`始终返回false。
那么何为优雅地关闭呢?这意味着active()返回false,但是state并不是"error" state。这种情况更复杂,但是事情将变得更加漂亮,因为双端字节流都准确可靠地传达了它们已经完全地接收到了对方信息的消息。在Section5.1中,我们将详细描述当这种优雅结束连接的情形发生时,会带来什么样的结果。你现在就可以直接跳到Section5.1去看。
不过,在讲5.1之前,我们还要谈论**两军问题(Two Generals' Problem)**。尽管不可能使收发双方都能达到优雅地退出的状态,但是TCP已经尽力地试图在接近这种理想状态了。让我们看看到底咋回事。
从一方的角度来看,要想优雅地关闭与远端的连接(之后我们称自己的角度是本地,另一方是远端),需要满足如下4个前提条件:
1. 入向stream已经被完全地接收、排列整齐,并被上层调用者读取完毕。
2. 出向stream已经被上层应用关闭,即stream不会再被写入新的字节,且stream里的字节流已经全被发送了出去(包括声明stream结束的FIN的segment也已经被发送)。
3. 出向stream发出的segment都收到了来自远端的ACK。
4. 本地的TCPConnection要充分确认远端的状态是满足前提条件3的。有点类似脑筋急转弯。我们来考察两种方案:
**方案A. 当双向stream结束后,先呆一会儿。** 当前提条件1~3满足,远端似乎看上去也收到了本地的ACK。问题在于本地TCP不知道远端到底有没有收到ACK(TCP不给ACK回复ACK)。不过如果本地等待一段时间之后,它应该能够确认远端收到了它的ACK了,毕竟远端已经有段时间没有重传任何数据了。
特别地,我们可以规定,当前提条件1~3满足的时候,从本地从远端收到最后一个segment开始,TCPConnection要等待10* the_initial_retransmission_timeout的时间。the initial retransmission timeout的值可以通过_cfg.rt_timeout获得。我们称这种双端stream结束后的等待为“lingering”,它的目的是确保远端不会尝试重传任何我们已经返回过ACK的segment。
也就是说我们的TCPConnection需要额外地为这个端口stay alive一段时间,当它收到发过来的segment的时候,仍然可以回复重复的ACK(即便TCPSender和TCPReceiver的stream已经关闭,并且都已经完成了它们该完成的所有工作)。
PS: 在TCP的生产环境中,linger的时间一般来说是60~120秒。实际上这是一段相当长的时间,没有人愿意等这么久,尤其是当你还想用这个端口号去启动本地的另一个服务的时候。不过Socket编程里,有一个选项是SO_REUSEADDR,可以让Linux强制把一个Socket服务绑定到正在被使用中的端口号上。
**方案B. 被动关闭。**当前提条件1~3满足,有一种情况,本地是能够百分之百确定远端是满足了前提条件3的。你会问这怎么可能呢,在不返回ACK的情况下?答案是当远端首先关闭了它自己的stream。
为什么这个规则能work?
这有点像是一个脑筋急转弯问题。你不需要想太多。
如果你实在好奇的话,可以去读两军问题,以及一些在不可靠网络上构建可靠传输的相关文献。
上述规则之所以能够work,是因为本地TCP在接收到带FIN的segment(前提条件1满足),重排并处理之后,本地会回复一个ACK(进而前提条件2会被满足)。
假设当前本地要关闭出向的stream,于是它发送了一个FIN,远端确认了这个FIN(前提条件3满足)。
当本地收到ACK的时候,这意味着本地知道了远端已经符合了前提条件3.
因此,这种情况下,本地是不需要linger的。
是不是够绕的?你做完实验后,也可以尝试在实验报告里用自己话解释一下这个事情。
具体地说,在TCPSender发送带FIN的segment之前,如果TCPConnection的入向stream已经早早地结束了,那么TCPConnection就不需要做等待一段时间再关闭连接的操作了
5.1 TCP关闭连接的简易版实用指南
你的TCPConnection有一个成员变量_linger_after_streams_finish
,以及一个成员方法state()
,state()
会把_linger_after_streams_finish
的值给暴露出来以便我们测试。_linger_after_streams_finish
一开始的时候应该是true。如果出向字节流还没有到EOF的时候,入向stream就关闭了字节流,当前_linger_after_streams_finish
应该设置为false。
一旦前提条件1~3被满足,如果_linger_after_streams_finish
为假,连接就会被立刻终止(active()
从此之后返回false)。否则的话你需要linger,自最后一个segment收到之后,等待一段时间(10倍的_cfg.rt_timeout
),然后结束连接。
总结一下吧,在connect.cc中,有几个重要的变量,_active,用于表示连接的状态,rst:出现异常,告诉对端异常关闭的头标志,time_now,此时时间,_linger_after_streams_finish,是否是后结束的一方。经过上面的文档阅读,我想你已经大概了解了TCP挥手的过程。_
现在请闭着眼睛再过一下这个过程,想一下为什么。
其实在我看来这和人类聊天结束没什么区别,假如我在和某个人聊天,我已经说完了,没有话了,我就会告诉对方,我已经说完了。但是这个时候又不能立马跑,因为对方还没说完,需要对对方说的话进行回复还。对方接收到一个fin标志后告诉我,我知道你说完了,但是我还有话说,会继续说,你需要确认,直到把话说完。然后告诉你,我也说完了,我要关闭了,我对这个回复进行确认后,对方收到这个ack就可以立马睡觉了,而我们这边由于不知道对方是否睡觉,只能不断确认他发过来的ack,直到当你在接收到一个seg后,对方10* the_initial_retransmission_timeout的时间没有回复,那我也就没必要等了,因为这么长时间没回复肯定是去睡觉了,上一个接收的seg就是最后一个seg,那我也可以睡觉去了。这个时候我就可以关闭连接了。
6 测试
先说一些我出现的比较离谱的问题,我在测试的时候,死活不能debug,因为我们日常是使用cout进行输出调试的,但是他后面都是脚本编的测试用例,我以为不能通过cout来测试,而且确实不能输出出来,但是后来我无意间发现了cerr函数,我用他倒可以直接输出出终端,于是我把所有cout改成cerr之后,竟然多过了好多测试点,这是我怎么都想不明白的原因,到现在也不懂,当然,用GDB可以规避掉这些问题。他的脚本写的还是挺有意思的,在本地开了两个端口tun144和tun145互相发收数据,我们可以ifconfig查看。如果有时间还真想看看他是怎么搭建这个大框架的。
另外我在做lab4的时候,卡在第五十几个测试点卡拉一上午,而且tshark并不能抓到这个传出去的包,我特别费解,于是我装了一个wireshark进行抓包,发现我的目的地址竟然是环回地址,这里我到现在也不是很明白。
再到后面出现了一个非常非常抽象的问题,如图
真的太挑战我的debug能力了,疯狂揉搓他的txrx.sh这个shell脚本,也让我学会了很多shell语句,用于输出我的错误信息,最后真是把数据报转成16进制一位一位进行对比,才找到问题,tmd竟然是基础数据流,也就是byte_stream.cc里面的peek_output函数写反了,而且竟然能过三个lab的测试用例,及其神奇。总之最后结果是好的
通过那一刻还是很兴奋滴
附上代码
tcp_connection.hh
tcp_connection.cc
为了实现自动化测试,我们鼓励你去把玩自己的TCP,并且使用wireshark去debug。
首先我们可以进行手工测试。你可以打开多个窗口,然后切换到sponge/build
文件夹下。
一个窗口运行
./apps/tcp_ipv4 -l 169.254.144.9 9090
该命令会使你的TCPConnection像一个server正在运行(使用IPv4进行通信,ip为169.254.144.9,端口号为9090),你会看到
DEBUG: Listening for incoming connection...
接着你可以使用wireshark进行抓包。在另一个窗口里,运行
sudo tshark -Pw /tmp/debug.raw -i tun144
这条命令会抓取server里所有发送或者是接收到的TCP segment,并且将它们存储在文件/tcp/debug.raw
里。你可以用wireshark软件打开这个文件,并且查看每一个segment的细节。
在第三个窗口,运行一个client:
./apps/tcp_ipv4 -d tun145 -a 169.254.145.9 169.254.144.9 9090
这条命令会使你的TCPConnection表现得像一个client,然后试图去连接正在监听的server。
在server的窗口,你会看到
New connection from 169.254.144.1:52518.
在client的窗口,你会看到
Successfully connected to 169.254.144.9:9090.
现在你可以在任意一个窗口(client或者server)里,随便敲击一串字符,并且按回车。你会发现你输入的字符串会出现在另外一个窗口里。
现在你可以尝试结束任意一方的字节流。在client的窗口,按Ctrl-D。于是client的出向字节流会被终止。现在client的窗口应该长这样:
DEBUG: Outbound stream to 169.254.144.9:9090 finished (1 byte still in flight).
DEBUG: Outbound stream to 169.254.144.9:9090 has been fully acknowledged.
server窗口应该会长这样:
DEBUG: Inbound stream from 169.254.144.1:52518 finished cleanly.
最后,在server的窗口,你也可以通过Ctrl-D的方式终止字节流,然后你会看到:
DEBUG: Waiting for clean shutdown... DEBUG: Outbound stream to 169.254.144.1:52518 finis
DEBUG: Outbound stream to 169.254.144.1:52518 has been fully acknowledged.
DEBUG: TCP connection finished cleanly.
done.
然后client的窗口长这样:
DEBUG: Inbound stream from 169.254.144.9:9090 finished cleanly.
DEBUG: Waiting for lingering segments (e.g. retransmissions of FIN) from peer...
DEBUG: Waiting for clean shutdown...
十秒钟后,client的窗口会打印出如下信息:
DEBUG: TCP connection finished cleanly.
done.
如果哪一步出了岔子,就说明你的实现逻辑有问题。
测试滑动窗口很小的情况:如果你担心你的TCPSender能不能处理window_size为0的状况,你可以在上述的命令中加上参数-w 1
。这样的话,TCPReceiver的窗口大小就是1。当你在另一端输入“hello”的时候,只有一个比特会被发送出去,紧接着接收端的窗口大小会变为0。
确保到此为止你的实现不会有任何问题。接下来,你就可以发送任意的字符串了,比如说“hello how are you doing”,即使窗口大小为1,这个字符串也能正常地被一个一个接收到。(也就是说第一个segment无法被完整地接收到?)
7 性能
当你运行make check_lab4
并通过里面的全部测试之后,就可以提交你的代码了。
接下来,你可以对你的代码进行性能测试,确保你的比特流传输速度至少可以达到100MB/s。在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
至此,完结撒花!