计网实验(四)CS144 Lab4
旧版lab4任务书地址
-
目标:
- 实现
TCPConnection
类,它将之前实验中完成的TCPSender
和TCPReceiver
组合起来。 - 处理连接级别的全局事务和状态管理。
- 最终你的 TCP 实现将能够通过用户态的 UDP 封装 (TCP-in-UDP) 或直接的 IP 数据报 (TCP/IP) 与互联网上的其他计算机进行通信。
- 实现
-
核心警告:
TCPConnection
本身的代码量预计不大(小于100行或100-150行)。- 实验的难度主要取决于你之前实现的
TCPSender
和TCPReceiver
是否健壮。 如果它们有很多bug,调试这个实验会非常耗时。 - 强烈建议尽早开始,不要拖到最后。
-
TCPConnection
的主要职责 (Section 3, 4):-
接收段 (Receiving segments -
segment_received
方法):- RST 标志处理:如果收到带 RST 标志的段,立即终止连接,设置输入输出流为错误状态,
active()
返回 false。 - 将段传递给
TCPReceiver
:让TCPReceiver
处理它关心的字段(seqno, SYN, payload, FIN)。 - 如果段有 ACK 标志,将信息传递给
TCPSender
:告知TCPSender
ackno
和window_size
。 - 确保响应:如果收到的段占用了序列号空间(即不是纯ACK),
TCPConnection
需要确保至少发送一个段作为回复(通常是 ACK 段,用于更新对方的ackno
和窗口信息)。 - 处理"Keep-Alive"段 (特殊情况):如果收到一个不占用序列号空间、但序列号等于接收方期望的
ackno - 1
的段,这可能是一个keep-alive探测。TCPConnection
应该回复一个空段(通过_sender.send_empty_segment()
),即使这个探测段的序列号可能是“无效的”。
- RST 标志处理:如果收到带 RST 标志的段,立即终止连接,设置输入输出流为错误状态,
-
发送段 (Sending segments):
- 当
TCPSender
的segments_out
队列中有段时,TCPConnection
需要将其取出。 - 在发送前,向
TCPReceiver
索取ackno
和window_size
。 - 如果
TCPReceiver
提供了有效的ackno
,则在出站段上设置 ACK 标志,并填入ackno
和window_size
。 - 将准备好的段放入
TCPConnection
自己的_segments_out
队列,等待外部调用者(如CS144TCPSocket
)来真正发送它们。
- 当
-
时间流逝 (Time passes -
tick
方法):- 通知
TCPSender
时间流逝(调用_sender.tick()
)。 - 检查是否需要中止连接并发送 RST 段:如果
TCPSender
的连续重传次数超过了TCPConfig::MAX_RETX_ATTEMPTS
。 - 处理连接的干净关闭(详见 Section 5)。
- 通知
-
-
FAQs 和特殊情况 (Section 4):
inbound_stream()
:已在头文件实现,无需额外工作。- 不需要复杂数据结构:主要工作是“连接”已有的
TCPSender
和TCPReceiver
。 - 发送段的方式:将段
push
到TCPConnection
的_segments_out
队列。 - 时间感知:通过
tick()
方法。 - 何时发送 RST 段:
TCPSender
连续重传次数超限。TCPConnection
的析构函数被调用,但连接仍然active()
。
- 如何构造带 RST 的段:可以通过调用
_sender.send_empty_segment()
或_sender.fill_window()
来让TCPSender
生成一个带有正确序列号的段,然后TCPConnection
再设置其 RST 标志。 - ACK 标志和
ackno
:- 出站段:只要
TCPReceiver
能提供ackno
(通过_receiver.ackno().has_value()
判断),就应该在出站段上设置 ACK 标志和相应的ackno
、window_size
。 - 入站段:只有当入站段的 ACK 标志被设置时,才将其
ackno
和window_size
提供给TCPSender
。
- 出站段:只要
- 状态名称:参考 Lab 2 和 Lab 3 的图表,这些状态是根据已有模块的公共接口推断出来的,不需要额外创建状态变量。
- 窗口大小字段限制:如果
TCPReceiver
想通告一个大于 TCP 头部win
字段能表示的最大值的窗口,应发送能表示的最大值 (std::numeric_limits<uint16_t>::max()
)。
-
连接的结束 (The end of a TCP connection - Section 5) - 核心难点:
-
目标是判断连接何时完全“完成” (
active()
返回false
)。 -
不干净关闭 (Unclean shutdown):发送或接收到 RST 段,连接立即死亡。
-
干净关闭 (Clean shutdown):确保双方数据都可靠送达。涉及四个先决条件:
- Prereq #1: 入站流已完全组装并结束 (FIN 已收到并处理)。
- Prereq #2: 出站流已被应用层关闭,并且包含 FIN 的段已发送给对端。
- Prereq #3: 出站流(包括其 FIN)已被对端完全确认。
- Prereq #4: 本地
TCPConnection
确信远程对端能够满足其 Prereq #3(即远程对端收到了本地对其 FIN 的确认)。这是最复杂的部分。
-
Prereq #4 的两种满足方式:
-
Option A: Lingering after both streams end (逗留)
- Prereqs #1, #2, #3 都满足。
- 本地端需要等待一段时间(至少
10 * _cfg.rt_timeout
,即 10 倍初始RTO),从最后一次收到远程对端的数据段算起。 - 这是为了确保远程对端收到了本地对其FIN的ACK,避免远程对端不必要的重传。
- 在这段时间内,
TCPConnection
仍然active
,并可能需要响应进入的段(例如,发送ACK)。 _linger_after_streams_finish
变量: 初始为true
。
-
Option B: Passive close (被动关闭)
- Prereqs #1, #2, #3 都满足。
- 并且,远程对端是第一个结束其流的一方 (即入站流先结束,在本地发送 FIN 之前)。
- 这种情况下,本地端可以100%确定远程对端能满足其Prereq #3,因为本地端在发送自己的FIN(满足Prereq #2)的同时,也ACK了远程的FIN(满足Prereq #1)。远程对端对本地FIN的ACK(满足Prereq #3)意味着远程对端一定看到了本地对它FIN的ACK。
- 此时,连接可以立即结束,不需要逗留。
_linger_after_streams_finish
变量: 如果入站流在出站流的EOF之前结束(即TCPConnection
尚未发送FIN段时,入站流就结束了),则此变量应设为false
。
-
-
实践总结 (Section 5.1):
TCPConnection
有一个成员变量_linger_after_streams_finish
(初始为true
)。- 如果入站流在
TCPConnection
到达其出站流的 EOF 之前结束,则_linger_after_streams_finish
设为false
。 - 当 Prereqs #1, #2, #3 都满足时:
- 如果
_linger_after_streams_finish
为false
,连接立即“完成” (active()
返回false
)。 - 否则(为
true
),需要逗留:只有当从最后一次收到段开始,经过了足够长的时间 (10 * _cfg.rt_timeout
)后,连接才“完成”。
- 如果
-
-
测试 (Section 6):
- 鼓励手动测试和使用 Wireshark 进行调试。
- 提供了使用
tcp_ipv4
进行客户端/服务器通信的示例。 - 测试零窗口情况:使用
-w 1
参数给tcp_ipv4
,强制接收方容量为1,测试发送方在零窗口探测时是否卡住。
-
性能 (Section 7):
- 要求性能至少达到 “0.10 Gbit/s” (100 Mbps)。
- 可能需要优化
ByteStream
或StreamReassembler
。
-
webget
Revisited (Section 8):- 修改 Lab 0 的
webget.cc
,使用你自己的CS144TCPSocket
代替内核提供的TCPSocket
。 - 需要包含
tcp_sponge_socket.hh
。 - 在
get_URL()
函数末尾添加socket.wait_until_closed()
,因为用户态TCP在程序退出后连接状态不会被内核维护。
- 修改 Lab 0 的
-
开发和提交建议 (Section 9, 10):
- 主要修改
tcp_connection.cc
和tcp_connection.hh
。 - 遵循良好的编程和 Git 实践。
- 提供了使用 sanitizers 和 gdb 进行调试的建议。
- 提交前运行
make format
,git status
,make
,make check
。 - 撰写
writeups/lab4.md
报告。
- 主要修改
核心挑战:
- 正确实现连接关闭逻辑 (Section 5),特别是区分何时需要逗留 (
TIME_WAIT
状态的简化版) 以及何时可以立即关闭。这是TCPConnection
最复杂的部分。 - 确保
TCPSender
和TCPReceiver
的健壮性,因为TCPConnection
严重依赖它们。 - 细致地处理各种标志位(RST, ACK, SYN, FIN)的设置和检查,以及它们与
TCPSender
和TCPReceiver
的交互。
第一步:理解 TCPConnection
的核心角色和组件
- 文档 Section 1 (Overview) 和 Figure 1:
TCPConnection
是TCPSender
和TCPReceiver
的“包装器”和协调者。- 它负责处理连接的全局事务。
- 关键组件: 内部包含
_sender
和_receiver
对象。 - 输出队列:
_segments_out
用于存放准备发往网络的段。
tcp_connection.hh
:- 确认成员变量
_cfg
,_receiver
,_sender
,_segments_out
,_linger_after_streams_finish
。这些是基础。
- 确认成员变量
第二步:分析对外接口(Public API)并映射到内部组件
这一步是“连线”工作,将 TCPConnection
的方法调用传递给 _sender
或 _receiver
。
- Writer接口 (
connect
,write
,remaining_outbound_capacity
,end_input_stream
):connect()
: 发起连接,显然是_sender
的职责(发送SYN)。- 思路: 调用
_sender
的某个方法(如fill_window()
或send_empty_segment()
,文档暗示fill_window
会处理初始SYN)来产生SYN段。
- 思路: 调用
write(data)
: 写入数据,也是_sender
的职责。- 思路: 将
data
写入_sender.stream_in()
,然后调用_sender.fill_window()
尝试将数据打包成段发送。
- 思路: 将
remaining_outbound_capacity()
: 查询还能写多少,直接问_sender
的输入流。- 思路:
_sender.stream_in().remaining_capacity()
。
- 思路:
end_input_stream()
: 关闭出站流,_sender
发送FIN。- 思路:
_sender.stream_in().end_input()
,然后调用_sender.fill_window()
尝试发送FIN。
- 思路:
- Reader接口 (
inbound_stream
):inbound_stream()
: 获取入站流,直接从_receiver
获取。- 思路:
_receiver.stream_out()
(已在头文件实现)。
- 思路:
- Accessor接口 (for testing):
bytes_in_flight()
:_sender.bytes_in_flight()
。unassembled_bytes()
:_receiver.unassembled_bytes()
。time_since_last_segment_received()
: 需要TCPConnection
自己维护一个计时器变量。- 思路: 添加
_time_since_last_segment_received_ms
成员变量。
- 思路: 添加
第三步:分析核心事件处理方法 (segment_received
, tick
)
这是 TCPConnection
真正发挥协调作用的地方。
-
segment_received(const TCPSegment &seg)
(文档 Section 3 “Receiving segments”):- 重置“上次收到段的时间”:
_time_since_last_segment_received_ms = 0;
(这是为active()
中的逗留计时服务的)。 - RST处理: 如果
seg.header().rst
为真,则连接死亡。- 思路: 设置
_sender
和_receiver
的流为错误状态,更新TCPConnection
的活动状态(可能通过一个内部标志_rst_flag_received_or_sent = true
)。
- 思路: 设置
- 传递给
_receiver
:_receiver.segment_received(seg);
- 引申思考
_linger_after_streams_finish
: 文档 Section 5.1 提到,如果入站流在出站流EOF前结束,则_linger_after_streams_finish = false
。入站流是否结束是由_receiver.stream_out().input_ended()
判断的。出站流EOF是指_sender.stream_in().eof()
。 - 思路: 在调用
_receiver.segment_received(seg)
之后,检查_receiver.stream_out().input_ended()
是否因此变为真。如果为真,并且此时!_sender.stream_in().eof()
(即本地还没打算关闭出站流),则设置_linger_after_streams_finish = false;
。
- 引申思考
- 处理ACK: 如果
seg.header().ack
为真,将ackno
和window_size
传递给_sender
。- 思路:
_sender.ack_received(seg.header().ackno, seg.header().win);
- 思路:
- Keep-Alive响应 (特殊情况): 文档明确给出了判断条件和响应方式。
- 思路: 实现文档给出的
if
条件,如果满足,调用_sender.send_empty_segment();
- 思路: 实现文档给出的
- 确保回复: 如果收到的段消耗了序列号空间 (data, SYN, or FIN),TCP通常需要回复一个ACK。
- 思路: 如果
seg.length_in_sequence_space() > 0
,可以调用_sender.send_empty_segment();
来确保一个ACK被准备好。
- 思路: 如果
- 尝试发送: 在上述处理后,
_sender
的状态可能允许发送更多数据(例如,窗口更新了,或者有ACK要发)。- 思路: 调用
_sender.fill_window();
- 思路: 调用
- 收集出站段: 将
_sender
内部队列中的段取出,进行TCPConnection
级别的处理(添加ACK信息),然后放入_segments_out
。- 思路: 创建一个私有辅助函数
_fill_sender_segments_and_queue_them()
(或类似名称,之前我称之为_send_segments()
)。
- 思路: 创建一个私有辅助函数
- 重置“上次收到段的时间”:
-
tick(const size_t ms_since_last_tick)
(文档 Section 3 “When time passes”):- 更新计时器:
_time_since_last_segment_received_ms += ms_since_last_tick;
- 通知
_sender
:_sender.tick(ms_since_last_tick);
- 检查
_sender
是否放弃 (连续重传超限): 如果_sender.consecutive_retransmissions() > TCPConfig::MAX_RETX_ATTEMPTS
。- 思路: 发送RST段 (创建私有辅助函数
_send_rst_and_terminate()
),设置错误状态,标记连接死亡。
- 思路: 发送RST段 (创建私有辅助函数
- 检查
active()
状态:active()
的逻辑依赖于流的状态和逗留计时。tick
是驱动逗留计时器检查的地方。- 思路: 在
tick
的末尾,如果连接因为逗留超时而变为非活动状态,这里会体现出来。
- 思路: 在
- 收集出站段:
_sender
可能因为tick
(内部RTO超时) 而产生需要重传的段。- 思路: 调用
_fill_sender_segments_and_queue_them()
。
- 思路: 调用
- 更新计时器:
第四步:处理出站段的细节 (文档 Section 3 “Sending segments”)
这是 _fill_sender_segments_and_queue_them()
辅助函数的核心逻辑。
- 当
_sender.segments_out()
不为空时,取出每个段。 - 关键: 在将段放入
_segments_out
之前,向_receiver
索取当前的ackno
和window_size
。 - 如果
_receiver.ackno().has_value()
为真,则设置取出的段的ACK
标志,并填入_receiver.ackno().value()
和_receiver.window_size()
。 - 将处理好的段放入
TCPConnection
的_segments_out
。
第五步:实现 active()
方法 (文档 Section 5 “The end of a TCP connection”)
这是最复杂的部分,需要仔细理解 Prereqs 和两种关闭选项。
- 立即不活动的情况:
- 如果已发送或接收到RST。
- 思路: 检查内部的
_rst_flag_received_or_sent
标志。
- 思路: 检查内部的
- 如果已发送或接收到RST。
- 干净关闭的先决条件检查:
- Prereq #1:
_receiver.stream_out().input_ended()
(入站流FIN已处理) - Prereq #2:
_sender.fin_sent()
(我假设TCPSender会提供这个状态,或者通过_sender.stream_in().eof() && 所有字节都已发送
来判断,确保FIN在飞行中或已ACK) - Prereq #3:
_sender.bytes_in_flight() == 0
(所有出站数据,包括FIN,都被确认)
- Prereq #1:
- 判断是否满足所有三个先决条件:
- 如果都满足:
- 如果
!_linger_after_streams_finish
,则active()
返回false
。 - 如果
_linger_after_streams_finish
,则检查_time_since_last_segment_received_ms >= 10 * _cfg.rt_timeout
。如果满足,active()
返回false
;否则返回true
。
- 如果
- 如果 Prereqs 未全部满足,
active()
通常返回true
(除非有其他更早的终止条件如RST)。
- 如果都满足:
第六步:实现析构函数 ~TCPConnection()
- 文档明确指出:如果连接仍然
active()
,析构时需要发送RST。- 思路: 检查
active()
。如果为true
,调用_send_rst_and_terminate()
。
- 思路: 检查
第七步:实现RST发送辅助函数 _send_rst_and_terminate()
- 让
_sender
生成一个空段(或使用fill_window
如果它能保证生成一个段)。 - 从
_sender.segments_out()
中取出这个段。 - 清空
_segments_out
(因为RST优先,旧段作废)。 - 设置该段的
rst
标志为true
。 - 关键: 这个RST段本身不需要ACK信息(因为是强制终止),但序列号需要正确。
_sender
产生的段应该有正确的序列号。 - 将RST段放入
_segments_out
。 - 设置
_sender
和_receiver
的流为错误状态。 - 标记
_rst_flag_received_or_sent = true
。