uTorrent 传输协议(uTP)由 Ludvig Strigeus,Greg Hazel,Stanislav Shalunov,Arvid Norberg 和 Bram Cohen 设计。
设计原因#
uTP 协议的动机是让 BitTorrent 客户端不会中断互联网连接,同时仍然充分利用未使用的带宽。
BitTorrent 流量通常是后台传输,其优先级应该低于检查电子邮件,办公和浏览网页,但是当使用常规 TCP 连接时,BitTorrent 会快速填满发送缓冲区,为所有交互式流量增加数秒的延迟。 BitTorrent 使用多个 TCP 连接的事实使其在与其他服务竞争带宽时具有不公平的优势,这夸大了 BitTorrent 占用上传带宽的效果。这样做的原因是 TCP 在连接之间均匀分配可用带宽,并且一个应用程序使用的连接越多,它获得的带宽份额就越大。
这个问题的传统解决方案是将 BitTorrent 客户端的上传速率限制在上行带宽容量的 80% 。为其余上下行流量留下了一些空间。此解决方案的主要缺点是:
- 用户需要配置他 / 她的 BitTorrent 客户端,它不会即开即用。
- 用户需要知道他 / 她的互联网连接的上限容量。此容量可能会发生变化,尤其是在可能连接到大量不同网络的笔记本电脑或手机上。
- 20% 的余量是比较随意的,会浪费带宽。每当没有交互式流量与 BitTorrent 竞争时,额外的 20% 就会被浪费掉。每当存在竞争的交互式流量时,它不能只需要使用 20% 的容量。
uTP 通过使用调制解调器队列大小作为其发送速率的控制器来解决此问题。当队列变得太大时,它会控制流量。这允许它在没有竞争时利用全部上传容量,并且在有大量交互式流量时允许它减少到几乎为零。
概述#
本文档假定读者对 TCP 和基于窗口的堵塞控制的工作原理有一定的了解。 uTP 是一种分层在 UDP 之上的传输协议。因此,它必须(并且有能力)实现自己的网络堵塞控制。与 TCP 相比,主要区别在于基于延迟的堵塞控制。
uTP 是建立在 UDP 之上的传输协议,因此它需要自己实现拥塞控制机制。与 TCP 相比,uTP 的主要区别在于基于延迟的拥塞控制。具体细节可参考拥塞控制部分的描述。类似于 TCP,uTP 采用基于窗口的拥塞控制。每个套接字都有一个 max_window,用于确定套接字在任何给定时间内可同时传输的最大字节数。已发送但尚未确认的任何数据包都被认为是在传输过程中。
- cur_window 表示当前传输过程中的字节数。只有当 cur_window + packet_size 小于等于 min (max_window, wnd_size) 时,套接字才能发送数据包。 packet_size 表示数据包的大小,可能会有不同的取值。
- wnd_size 是对方端口所广告的窗口大小。它设置了传输中的数据包数量的上限。
- 如果 max_window 小于数据包大小,并且通过调整数据包传输速率使得平均 cur_window 小于等于 max_window,实现可能违反上述规则。
- 每个套接字保存了与其他端点的最后一次延迟测量状态(reply_micro)。每当接收到一个数据包时,通过将时间戳(以微秒为单位)减去主机当前时间来更新该状态。
- 每次发送数据包时,套接字的 reply_micro 值将放置在数据包头部的 timestamp_difference_microseconds 字段中。
- 与 TCP 不同,uTP 中的序列号和 ACK 是基于数据包而不是字节的。这意味着在重新发送数据时,uTP 无法对其进行重新封装。
- 每个套接字都保持着下一个用于发送数据包的序列号(seq_nr)和上次接收到的数据包的序列号(ack_nr)的状态。最老的未确认数据包序列号为 seq_nr – cur_window 。
header 格式#
版本 1 标头:
0 4 8 16 24 32
+-------+-------+---------------+---------------+---------------+
| type | ver | extension | connection_id |
+-------+-------+---------------+---------------+---------------+
| timestamp_microseconds |
+---------------+---------------+---------------+---------------+
| timestamp_difference_microseconds |
+---------------+---------------+---------------+---------------+
| wnd_size |
+---------------+---------------+---------------+---------------+
| seq_nr | ack_nr |
+---------------+---------------+---------------+---------------+
所有字段均按网络字节顺序(大端序)排列。
版本#
这是协议版本。当前版本为 1 。
connection_id#
这是一个随机的唯一数字,用于标识属于同一连接的所有数据包。每个套接字都有一个用于发送数据包的连接 ID 和一个用于接收数据包的不同连接 ID 。启动连接的终结点决定使用哪个 ID,返回路径具有相同的 ID + 1 。
timestamp_microseconds#
这是发送此数据包的时间戳的 “微秒” 部分。这是在 posix 上使用 gettimeofday()和在 windows 上使用 QueryPerformanceTimer()设置的。此时间戳的分辨率越高越好。设置的越接近实际传输时间越好。
timestamp_difference_microseconds#
这是本地时间与上次接收数据包(在收到最后一个数据包时)中的时间戳之间的差异。这是从远程对等体到本地计算机的链路的最新单向延迟测量。当套接字是新打开的并且还没有任何延迟样本时,必须将其设置为 0 。
wnd_size#
播发的接收窗口。这是 32 位宽,以字节为单位指定。窗口大小是当前正在进行的字节数,即已发送但未确认的字节数。通告的接收窗口允许另一端限制窗口大小,如果它不能更快地接收,如果它的接收缓冲区正在填满。发送数据包时,应将其设置为套接字接收缓冲区中剩余的字节数。
extension#
扩展标头链接列表中第一个扩展的类型。 0 表示无扩展名。
目前有一个扩展:
- 选择性确认
扩展是链接的,就像 TCP 选项一样。如果扩展字段不为零,则紧跟在 uTP 标头后面的两个字节:
0 8 16
+---------------+---------------+
| extension | len |
+---------------+---------------+
其中 extension 指定链表中下一个扩展名的类型,0 终止列表。并 len 指定此扩展的字节数。未知扩展可以通过简单地前进 len bytes 来跳过。
SELECTIVE ACK#
选择性 ACK 是一种扩展,可以非顺序地选择性地 ACK 数据包。其有效负载是至少 32 位的位掩码,以 32 位的倍数表示。每个位表示发送窗口中的一个数据包。发送窗口之外的位将被忽略。设置位指定数据包已接收,清除位指定数据包尚未接收。标题如下所示:
0 8 16
+---------------+---------------+---------------+---------------+
| extension | len | bitmask
+---------------+---------------+---------------+---------------+
|
+---------------+---------------+
请注意,扩展的 len 字段引用字节,在此扩展中,字节必须至少为 4,并且是 4 的倍数。
仅当接收的流中至少跳过一个序列号时,才会发送选择性 ACK 。因此,掩码中的第一个位表示 ack_nr + 2 。 ack_nr + 1 假定在发送此数据包时已被丢弃或丢失。设置位表示已接收的数据包,清除位表示尚未接收的数据包。
位掩码的字节顺序相反。第一个字节以相反的顺序表示数据包 [ack_nr + 2, ack_nr + 2 + 7] 。字节中最低有效位表示 ack_nr + 2,字节中最高有效位表示 ack_nr + 2 + 7 。掩码中的下一个字节以相反的顺序表示 [ack_nr + 2 + 8,ack_nr + 2 + 15],依此类推。位掩码不限于 32 位,但可以是任何大小。
下面是位掩码的布局,表示选择性 ACK 位域中表示的前 32 个数据包确认:
0 8 16
+---------------+---------------+---------------+---------------+
| 9 8 ... 3 2 | 17 ... 10 | 25 ... 18 | 33 ... 26 |
+---------------+---------------+---------------+---------------+
图中的数字将位掩码中的位映射到要添加到 ack_nr 的偏移量,以便计算位正在确认的序列号。
type#
类型字段描述数据包的类型。它可以是以下之一:
ST_DATA = 0
常规数据包。套接字处于连接状态,并且有要发送的数据。 ST_DATA 数据包始终具有数据有效负载。
ST_FIN = 1
完成连接。这是最后一个数据包。它关闭连接,类似于 TCP FIN 标志。此连接的序列号永远不会大于此数据包中的序列号。套接字将此序列号记录为 eof_pkt 。这允许套接字等待可能仍然丢失的数据包,即使在收到 ST_FIN 数据包后也会无序到达。
ST_STATE = 2
状态数据包。用于传输没有数据的 ACK 。不包含任何有效负载的数据包不会增加 seq_nr 。
ST_RESET = 3
强制终止连接。类似于 TCP RST 标志。远程主机没有任何此连接的状态。它是过时的,应该终止。
ST_SYN = 4
与 TCP SYN 标志类似,此数据包启动连接。序列号初始化为 1 。连接 ID 初始化为随机数。 syn 数据包是特殊的,在此连接上发送的所有后续数据包(ST_SYN 的重新发送除外)都以连接 ID + 1 发送。连接 ID 是另一端应在其响应中使用的 ID 。
收到 ST_SYN 时,应使用数据包标头中的 ID 初始化新套接字。套接字的发送 ID 应初始化为 ID + 1 。返回通道的序列号初始化为随机数。另一端需要 ST_STATE 数据包(仅 ACK)作为响应。
seq_nr#
这是此数据包的序列号。与 TCP 相反,uTP 序列号不是指字节,而是数据包。序列号告诉另一端数据包应以何种顺序返回应用层。
ack_nr#
这是数据包的发送方上次在另一个方向上收到的序列号。
连接设置#
下图说明了启动连接的交换和状态。 c.* 表示套接字本身中的状态,pkt.* 表示数据包标头中的字段。
initiating endpoint accepting endpoint
| c.state = CS_SYN_SENT |
| c.seq_nr = 1 |
| c.conn_id_recv = rand() |
| c.conn_id_send = c.conn_id_recv + 1 |
| |
| |
| ST_SYN |
| seq_nr=c.seq_nr++ |
| ack_nr=* |
| conn_id=c.rcv_conn_id |
| >-------------------------------------------> |
| c.receive_conn_id = pkt.conn_id+1 |
| c.send_conn_id = pkt.conn_id |
| c.seq_nr = rand() |
| c.ack_nr = pkt.seq_nr |
| c.state = CS_SYN_RECV |
| |
| |
| |
| |
| ST_STATE |
| seq_nr=c.seq_nr++ |
| ack_nr=c.ack_nr |
| conn_id=c.send_conn_id |
| <------------------------------------------< |
| c.state = CS_CONNECTED |
| c.ack_nr = pkt.seq_nr |
| |
| |
| |
| ST_DATA |
| seq_nr=c.seq_nr++ |
| ack_nr=c.ack_nr |
| conn_id=c.conn_id_send |
| >-------------------------------------------> |
| c.ack_nr = pkt.seq_nr |
| c.state = CS_CONNECTED |
| |
| | connection established
.. ..|.. .. .. .. .. .. .. .. .. .. .. .. .. .. .. ..|.. ..
| |
| ST_DATA |
| seq_nr=c.seq_nr++ |
| ack_nr=c.ack_nr |
| conn_id=c.send_conn_id |
| <------------------------------------------< |
| c.ack_nr = pkt.seq_nr |
| |
| |
V V
连接由其 conn_id 标头标识。如果新连接的连接 ID 与现有连接冲突,则连接尝试将失败,因为 ST_SYN 数据包在现有流中将是意外的,并被忽略。
丢包#
如果序列号为( seq_nr – cur_window )的数据包尚未确认(这是发送缓冲区中最早的数据包,下一个数据包预计被确认),但已通过该数据包 3 个或更多数据包(通过选择性 ACK),则假定该数据包已丢失。同样,当收到 3 个重复的确认时, ack_nr 假定 + 1 已丢失(如果已发送具有该序列号的数据包)。
这也适用于选择性确认。在选择性确认消息中确认的每个数据包都计为一个重复的确认,如果为 3 个或更多,则应触发重新发送至少包含 3 个数据包的数据包。
当数据包丢失时, max_window 乘以 0.5 以模拟 TCP 。
超时#
每个被确认的数据包,无论是落在范围(last_ack_nr 、 ack_nr] 还是被选择性 ACK 消息显式确认,都应用于更新(往返时间)和 rtt rtt_var (rtt 方差)测量值。 last_ack_nr 这里是当前数据包之前在套接字上收到的最后一个 ack_nr,ack_nr 是当前接收的数据包中的字段。
仅针对 rtt 仅发送一次的数据包更新 和 rtt_var 。这避免了确定哪个数据包被确认的问题,第一个还是第二个。
rtt 并通过 rtt_var 以下公式计算,每次确认数据包时:
delta = rtt - packet_rtt
rtt_var += (abs(delta) - rtt_var) / 4;
rtt += (packet_rtt - rtt) / 8;
与套接字关联的数据包的默认超时也会每次更新 rtt 并 rtt_var 更新。它设置为:
timeout = max(rtt + rtt_var * 4, 500);
其中以毫秒为单位指定超时。即数据包的最小超时为 1/2 秒。
每次套接字发送或接收数据包时,它都会更新其超时计数器。如果在上次超时计数器重置后的毫秒 timeout 内没有数据包到达,套接字将触发超时。它会将其 packet_size and max_window 设置为最小的数据包大小(150 字节)。这允许它再发送一个数据包,如果窗口大小降至零,这就是套接字再次启动的方式。
初始超时设置为 1000 毫秒,稍后根据上述公式进行更新。对于超时的每个连续数据包,超时将加倍。
数据包大小#
为了尽可能减少对慢速拥塞链路的影响,uTP 将其数据包大小调整为每个数据包 150 字节。使用这么小的数据包的好处是不会阻塞慢速上行链路,并且序列化延迟较长。使用这么小的数据包的代价是数据包标头的开销变得很大。在高速率下,使用大数据包大小,在慢速率下,使用小数据包大小。
拥塞控制#
uTP 拥塞控制的总体目标是使用单向缓冲区延迟作为主要拥塞测量,以及数据包丢失(如 TCP)。关键是要避免在发送数据时使用完整的发送缓冲区运行。对于 DSL / 电缆调制解调器来说,这是一个特别的问题,其中调制解调器中的发送缓冲区通常具有容纳数秒数据的空间。 uTP(或任何后台流量协议)的理想缓冲区利用率是以 0 字节缓冲区利用率运行。即任何其他流量可以随时发送,而不会受到后台流量阻塞发送缓冲区的阻碍。实际上,uTP 目标延迟设置为 100 毫秒。每个套接字的目标是永远不会在发送链接上看到超过 100 毫秒的延迟。如果是这样,它将节流回去。
这有效地使 uTP 屈服于任何 TCP 流量。
这是通过在通过 uTP 发送的每个数据包中包含高分辨率时间戳来实现的,接收端计算其自己的高分辨率计时器与其接收的数据包中的时间戳之间的差异。然后将此差异反馈给数据包的原始发送方(timestamp_difference_microseconds)。此值作为绝对值没有意义。机器中的时钟很可能不同步,尤其是没有达到微秒级的分辨率,并且数据包的传输时间也包含在这些时间戳的差异中。但是,与以前的值相比,该值很有用。
每个套接字在最后两分钟内保持最低值的滑动最小值。此值称为 base_delay,用作基准,即主机之间的最小延迟。从每个数据包的时间戳差异中减去 base_delay 时,您可以测量套接字上的当前缓冲延迟。这种测量称为 our_delay 。它有很多噪音,但用作驱动程序来确定是增加还是减少发送窗口(控制发送速率)。
CCONTROL_TARGET 是 uTP 在上行链路上接受的缓冲延迟。目前,延迟目标设置为 100 毫秒,off_target 实际测量的延迟与目标延迟的距离(根据 CCONTROL_TARGET – our_delay 计算)。
套接字结构中的窗口大小指定了我们在连接上总共可能具有的运行(未确认)字节数。发送速率与此窗口大小直接相关。传输中的字节越多,发送速率越快。在代码中,窗口大小称为 max_window 。它的大小大致由以下表达式控制:
delay_factor = off_target / CCONTROL_TARGET;
window_factor = outstanding_packet / max_window;
scaled_gain = MAX_CWND_INCREASE_PACKETS_PER_RTT * delay_factor * window_factor;
其中,第一个因素将 off_target 缩放到目标延迟单位。
然后将 scaled_gain 添加到 max_window:
max_window += scaled_gain;
如果 off_target 大于 0,这将使窗口变小,如果偏离目标小于 0,则窗口增大。
如果 max_window 小于 0,则设置为 0 。窗口大小为零表示套接字可能不会发送任何数据包。在此状态下,套接字将触发超时并强制窗口大小为一个数据包大小,并发送一个数据包。有关详细信息,请参阅有关超时的部分。