0.Overview
Lab2 部分要求我们实现一个 TCP receiver。
实现的 TCP 接收器需要实现以下两个功能:
- 使用
send()
方法将期待的下一个字节序号回传给发送方; - 告知发送方接收端的缓冲容量,也称为接收窗口大小。
下一字节序号和接收窗口大小共同构成了一个左闭右开的接收区间 [first_unassembled, first_unassembled + window_size)
,只有这个区间内的字节才会被接收端接收。
核心算法部分(流重组 Reassembler
和字节流缓冲区 ByteStream
)已经在先前的两个实验中实现了,在这次实验的部分需要补充 TCP 报文传输的回传确认。
1.Getting Started
Lab2 一如既往的要求代码复用,因此就需要将 checkpoint2-startercode 分支的代码合并到已经写好的部分中。这部分就略过了。
2.Checkpoint 2: The TCP Receiver
2.1 Translating between 64-bit indexes and 32-bit seqnos
在先前的实验中,我们的代码实现是建立在字节流序号是 64 位无符号数(下称 uint64)的基础上的;但实际为了减少报文长度,TCP 报文的字节流序号的类型是 32 位无符号数(下称 uint32)。
我们都知道如果为每个字节做一个编号,那么以 uint64 为基础的字节流长度最大可以达到 2 64 2^{64} 264 Bytes = 16 EB,即使是以 100 Gbits/s 的速率传输也需要花费 50 年左右才能传输完。但是 uint32 的最大长度只有 4GB,很明显,在实际使用时报文内的字节序号会不可避免地产生“回绕”(即这个值始终在对 2 32 2^{32} 232 取模)。
因此为了实现 uint32 的字节序号(sequence numbers)到 uint64 的绝对字节序号(absolute sequence numbers)之间的转换,我们首先需要对字节序号做一个封装。
此外,为了避免被攻击、提高 TCP 的健壮性,也是为了判断当前传输的字节是否属于上一个连接,TCP 设定当前传输的字节的序号的起始计数值(ISN)并不是 0,而是一个位于 [ 0 , 2 32 − 1 ] \left[ 0, 2^{32} - 1 \right] [0,232−1] 范围内的一个随机值,因此这让 seqno 到 absolute seqno 的映射更加复杂。
文档这里还提到了,TCP 在建立确认时发送的 SYN 就是 ISN + 1 的值,发送该序号也意味着传输开始。
文档还给出了不同字节序号之间的对应关系。可以看出 absolute seqno 和 stream index 之间只相差了 1 位的偏移量,因此我们并不关心这里的转换,而是将注意力放在上方的 seqno 和 absolute seqno 之间。
stream index 是表示数据字节的序号,不会记录 SYN 与 FIN 这两个用于控制 TCP 传输的字节。
代码中的 Wrap32
就是负责进行两个不同字节序列之间互相转化的类。在静态方法 wrap()
中,文档要求实现从 absolute seqno 到 seqno 的转换,也就是根据给定的起始数值点 ISN,将当前的绝对字节序号转换为 TCP 报文中的 32 位字节序号。
wrap()
方法的实现相当简单。由于是从 64 位数到 32 位数的转换,并且参照上面给出的字节映射关系就知道:只需要将 n 加在 zero_point 上即可,上文提到的“回绕”会因为 uint32 的位数截断自动进行(也就是说将一个 64 位数转换为 32 位数的过程是在对 uint64 取
2
32
2^{32}
232 的模)。
unwrap()
方法的实现比较复杂。文档要求在这个函数中,利用给定的 checkpoint
值和起始点 zero_point
,找到最接近 checkpoint
的 absolute seqno。
什么是
checkpoint
?
文档中指出,有大量 64 位无符号整数(下称 uint64)在 m o d 2 32 \bmod 2^{32} mod232 的情况下的值是相等的,这种现象称作“这些数对 2 32 2^{32} 232 同余”。
如果要确定一个 uint32 的余数值对应哪一个 uint64 整数,我们就需要有一个 64 位无符号整数checkpoint
帮我们定位我们所期望的最接近某个无符号 32 位整数的 uint64 的整数的数值。
实现时,我们所跟踪的绝对字节序号(absolute seqno)本身就是一个最合适的checkpoint
。
从数学角度上看,这一操作过程实质上是在求同余方程 f ( x ) ≡ k ( m o d 2 32 ) f(x) \equiv k \pmod{2^{32}} f(x)≡k(mod232) 的解集中最靠近checkpoint
的数,其中 k ∈ Z ∩ [ 0 , 2 32 ) , x ∈ Z ∩ [ 0 , 2 64 ) k \in \mathbb{Z} \cap \left[ 0, 2^{32} \right),x \in \mathbb{Z} \cap \left[ 0, 2^{64} \right) k∈Z∩[0,232),x∈Z∩[0,264).
既然解空间是有限范围内的同余方程解集,其实打表嗯搜也不是不行。← \leftarrow ← 实际上是真的不行(
我们知道,seqno 是一个模
2
32
2^{32}
232 数,并且这个数的起始计数点是 ISN 而非 0。因此要找到给定最接近 checkpoint
的数的最好办法就是计算 checkpoint
(checkpoint
是一个 absolute seqno)在变为 seqno 的情况下到当前 seqno 的偏移量,然后在 checkpoint
中加上这个偏移量;也就是:
c
h
e
c
k
p
o
i
n
t
+
(
c
k
p
t
_
m
o
d
−
r
a
w
_
v
a
l
u
e
_
)
checkpoint + \left( ckpt\_mod - raw\_value\_ \right)
checkpoint+(ckpt_mod−raw_value_)。
当然也可以使用另一种思路:计算要加上多少个 2 32 2^{32} 232 才最靠近
checkpoint
,这个方法需要做到以下几点:
- 因为 ISN 不一定从零开始,所以还要额外计算一个偏移量;
- 无法利用 checkpoint 的位置信息,因此需要试探三个点: r a w _ v a l u e _ + o f f s e t − 2 32 raw\_value\_ + offset - 2^{32} raw_value_+offset−232, r a w _ v a l u e _ + o f f s e t raw\_value\_ + offset raw_value_+offset 和 r a w _ v a l u e _ + o f f s e t + 2 32 raw\_value\_ + offset + 2^{32} raw_value_+offset+232;
- 需要使用乘法。
当然第 2 点可以使用 ckpt_mod 的信息,但是都用了 ckpt_mod 为什么不直接计算 checkpoint 的偏移量?
在 [ 0 , 2 32 ] \left[ 0, 2^{32} \right] [0,232] 范围内,一个数可以加上任意多的 2 32 2^{32} 232 而仍停留在原位,这会导致对应的 seqno 从 uint32 提升到 uint64、并且要找出其距离某个数的最小值时出现两种可能的取值:要么在下图区间 0 点左侧(减去一个 2 32 2^{32} 232),要么在区间 0 点的右侧。并且当最小值在左侧时,我们只需要将右侧的值减去一个 2 32 2^{32} 232 就可以得到结果,因此实际上我们只需要计算右侧的值(并且右侧的值更好求,原因下文会提及)。
这里本质上是在试探 a b s o l u t e _ s e q n o − 2 32 absolute\_seqno - 2^{32} absolute_seqno−232 和 a b s o l u t e _ s e q n o absolute\_seqno absolute_seqno 哪个更靠近
checkpoint
。
并且因为我们在围绕ckpt_mod
确定最终值,所以我们只用试探两个点: c h e c k p o i n t + ( c k p t _ m o d − r a w _ v a l u e _ ) − 2 32 checkpoint + \left( ckpt\_mod - raw\_value\_ \right) - 2^{32} checkpoint+(ckpt_mod−raw_value_)−232 和 c h e c k p o i n t + ( c k p t _ m o d − r a w _ v a l u e _ ) checkpoint + \left( ckpt\_mod - raw\_value\_ \right) checkpoint+(ckpt_mod−raw_value_)。
下图给出的就是“最近的数在区间 0 点左侧的情况”。
从这张图中我们还可以知道一点:当图中的 ckpt_mod
小于 raw_value_
时,无符号减法:
c
k
p
t
_
m
o
d
−
r
a
w
_
v
a
l
u
e
_
ckpt\_mod - raw\_value\_
ckpt_mod−raw_value_ 的值会变得非常大;但是这只是将目标结果“搬到”了区间段
[
2
32
,
2
33
)
\left[ 2^{32}, 2^{33} \right)
[232,233) 中,结果值在
[
0
,
2
32
)
\left[ 0, 2^{32} \right)
[0,232) 数值范围内的“绝对位置”是始终保持不变的,我们只需要减去一个
2
32
2^{32}
232 就可以在 uint64 的数值范围下得到最靠近 checkpoint
的值,即我们所期望的正确结果。
计算右侧的 seqno 对应的值时,我们可以假定 raw_value_
总比 ckpt_mod
大,进而计算:
c
h
e
c
k
p
o
i
n
t
+
(
r
a
w
_
v
a
l
u
e
_
−
c
k
p
t
_
m
o
d
)
checkpoint + \left( raw\_value\_ - ckpt\_mod \right)
checkpoint+(raw_value_−ckpt_mod);这里的
c
k
p
t
_
m
o
d
ckpt\_mod
ckpt_mod 是 absolute seqno 基于 ISN 时的 32 位值,这个值可以由上文的 Wrap32::wrap()
直接求得。
那么我们该如何判断最小值是出现在左侧还是右侧?观察上图可以知道:当 raw_value_
与 ckpt_mod
的差值小于一半区间长度(
2
31
2^{31}
231)时,意味着最小值在区间 0 点的右侧;否则就需要多减去一个
2
31
2^{31}
231 使得计算结果最小。
但是要注意一个边界条件:当 c h e c k p o i n t < 2 32 checkpoint < 2^{32} checkpoint<232 时,区间 0 点左侧是不存在的,这个时候的最小值只能取在区间 0 点右侧。因此我们要把这个条件筛去:在 uint64 的数值计算下, c h e c k p o i n t + ( r a w _ v a l u e _ − c k p t _ m o d ) < 2 32 checkpoint + \left( raw\_value\_ - ckpt\_mod \right) < 2^{32} checkpoint+(raw_value_−ckpt_mod)<232。(所以说右侧的值会更好求)
根据以上分析结果,unwrap()
的实现方式就已经呼之欲出了。并且文档还提及:合理的 Wrap32::wrap()
实现应该只有一行,Wrap32::unwrap()
则应该在 10 行以下。
如果你对数学比较敏锐,可以把上面的问题用更加数学化的方式表达出来:
已知整数 a a a 和 k k k,其中 a ∈ Z ∩ [ 0 , 2 64 ) 且 k ∈ Z ∩ [ 0 , 2 32 ) a \in \mathbb{Z} \cap \left[0, 2^{64} \right) 且 k \in \mathbb{Z} \cap \left[ 0, 2^{32} \right) a∈Z∩[0,264)且k∈Z∩[0,232),现要求同余方程 f ( x ) ≡ k ( m o d 2 32 ) f(x) \equiv k \pmod{2^{32}} f(x)≡k(mod232) 的解集中最靠近 a a a 的整数,其中 , x x x 为未知数且 x ∈ Z ∩ [ 0 , 2 64 ) x \in \mathbb{Z} \cap \left[ 0, 2^{64} \right) x∈Z∩[0,264).
例如,当 k = 1 , a = 0 k = 1,a = 0 k=1,a=0 时,解 x = 1 x = 1 x=1;当 k = 1 , a = 4294967295 k = 1,a = 4294967295 k=1,a=4294967295 时,解 x = 4294967297 x = 4294967297 x=4294967297。
2.2 Implementing the TCP receiver
在这部分,我们要根据实现好的 Wrap32
完成 TCP 的接收器。最终的实现如果是合理的,行数应该不会超过 15 行。
我们要做的事一共两个:
- 根据接收到的信息将字节序列提交给
Reassembler
处理、并由它推入流中; - 向发送方回传期盼的下一个字节序号 ackno、以及窗口大小 window size。
接下来文档介绍了接收和回传时承载报文信息的结构体 TCPSenderMessage
和 TCPReceiverMessage
:当接收器接受信息时,接受的是 TCPSenderMessage
,而回传信息时需要使用 TCPReceiverMessage
。
在前者中,三个符号位 SYN
、FIN
和 RST
共同控制 TCP 连接的行为,具体描述如下:
- 若
SYN
是第一次为 true,seqno
中的值是 ISN,否则seqno
中的值就是payload
的第一个字节的序号(SYN
为 false 是无效数据); - 若
FIN
为 true,表示这是最后一段数据; - 若
RST
为 true,表示链路出了问题,需要中断传输。
TCPReceiverMessage
要比前者简单很多,具体描述如下:
ackno
使用了std::optional
,因为当接收方没有接收到SYN
为 true 的报文时不应该返回任何应答序号;windows_size
指定当前窗口大小,可以看出这里的类型被限定为uint16_t
,所以如果窗口大小超过了 uint16 的最大值(65535)就会溢出,并且如果发生溢出就取最大值;RST
用于告知发送方我的流缓冲是否出现问题,若有则要中断传输。
然后是 receiver 的 receive()
方法,这个方法在每次收到新信息时都会被调用,并且我们需要使其满足两种功能:
- 在
SYN
第一次为 true 时设置 ISN 的值; - 在
FIN
为 true 时告知Reassembler
这是最后一个分组(别忘了 Lab1 的is_last_substring
)。
查看 starter-code 可以发现,实验只要求我们补充 receive()
和 send()
方法,后者就是用于回传信息的。这里需要注意的是:我们不需要自己在 receive()
中调用 send()
,这种事是交给上层应用的。
因为每条连接的初始值都是随机值,所以我们必然要维护一个 ISN 值;再考虑到当连接建立请求 SYN
尚未发来时 ISN 的值是不能确认的,所以我们可以模仿 TCPReceiverMessage
,将我们维护的 ISN 设置为 std::optional<Wrap32> ISN
。这样做有个好处:可以通过判断 ISN
是否有值(使用 has_value()
)判断是否已经收到了连接确认请求,而不必额外维护一个 bool
标志。
另外我们都知道为了计算绝对序列号 absolute seqno,我们还需要维护一个正在期待的下一个字节的序号作为 checkpoint,而且这个值也需要回传给发送方。不过这里我们也不需要额外设置一个 uint64_t
的私有成员,我们可以使用 Writer::bytes_pushed()
方法检查当前有多少个字节已经进入流中,并且这个值恰好就是一个表示正在期盼的下一个字节的 absolute seqno 序号。
最后还有三点要注意的:
SYN
字节本身是需要被计入 absolute seqno 中的,所以上面提到的 absolute seqno 还需要加上ISN.has_value()
的值(这里利用了bool
隐式转换为数值类型加减的性质);FIN
关闭请求要在写端方法Writer::is_closed()
为 true 时才能响应,否则会导致错误,并且在 TCP 中FIN
字节也要计入 absolute seqno;- 要拦截一些错误的报文段(例如刚接收了第一个
SYN
报文段,下一个报文段的 seqno 还是等于 ISN),这个错误检测似乎是今年的新内容。
3.Extra Credit
在最后呢,课程还鼓励大家查看 test/
中的测试用例(包括但不限于 Lab2),并补充一些有可能出错但是 CS144 提供的测试代码中没有的部分,然后在课程结束前向仓库发起一个 pull request。
可惜我没有这个学分拿(笑)。
总体上来讲,从 Lab0 到现在的进程都是一种“自顶向下”的方向,并且由于核心算法已经在前两个实验中编写完了,所以后续的实验一般都会更加底层、并且代码量不太大(我猜的)。