在任何网络堆栈或设备中,数据包的队列都是非常重要。这些队列使得不在同一时刻加载的模块能够相互通信,并且能提高网络性能,同时也会间接影响到网络延时的长短。本文章通过阐述IP数据包在Linux网络中的排队机制,来解释两个问题:
BQL一类新特性是如何减小网络延时的。
如何控制已减小延时后的缓存。
下面这张图(和它的变形)将会在文中不断的出现,用以说明具体的概念。
figure1
驱动队列(环形缓存区)
驱动队列位于IP数据栈和网卡之间。驱动队列使用先进先出算法,并通过环形缓存区实现—可以暂时把环形缓存区当做一个固定大小的缓存器。这个队列中不含任何来自包(分组)的数据,直接参与排队的是描述符(descriptor)。这些描述符指向 “内核套接字缓存”(socket kernel buffers,简写为SKBs),SKB中含有在整个内核处理过程中都要使用的数据包。
figure2
进入驱动队列的数据,来自IP数据栈,在IP数据栈里所有的IP数据包都要进行排队。这些数据包可以从本地获得,当某个网卡在网络中充当路由器时,数据包也可以从网卡上接收,找到路由后再发出去。从IP数据栈中进入驱动队列的数据包,先由硬件使之出列,再通过数据总线发送到网卡上,以进行传输。
驱动队列的用处在于,只要系统有数据需要传输时,数据能够马上被传送到网卡进行及时传输。大致意思就是,驱动队列给了IP数据栈一个排队的地方,通过硬件来对数据进行不同时的排队。实现这个功能的另一种做法是,只要当物理传输媒介准备好传输数据时,网卡便马上向IP数据栈申请数据。但是因为对IP数据栈的数据申请,不可能马上得到相应,所以这种办法会浪费掉大量宝贵的传输资源,使吞吐量相应地降低。还有另一种正好相反的办法—在IP数据栈准备好要传输的数据包后,进行等待,直到物理传输媒介做好传输数据的准备为止。但是这种做法同样也不理想,因为在等待时IP数据栈被闲置,没有办法做别的工作。
栈中的超大数据包
多数网卡都有最大传输单位(MTU),用来表示能够被物理媒介传输的最大帧数。以太网的默认MTU为1500字节,也有一些支持Jumbo Frames的以太网MTU能够达到9000多字节的。在IP数据栈中,MTU同时也是数据包传输的极限大小。比如,有个应用需要向TCP接口传送2000字节的数据,这时,IP数据栈就必须创建两个数据包来传送它,因为单个MTU小于2000字节。所以在进行较大数据的传输时,MTU如果相对较小,那么大量数据包就会被创建出来,并且它们都要在物理媒介上传输到驱动队列中。
为了避免因为MTU大小限制而出现的大量数据包,Linux内核对传输大小进行了多项优化:TCP段装卸(TCP segmentation offload,简称TSO),UDP碎片装卸(UDP fragmentation offload,简称UFO)和类型化段装卸(generic segmentation offload ,简称GSO)。这些优化办法,使得IP数据栈能够创建比MTU更大的数据包。对于IPv4来说,优化后能够创建出最大含65536的数据包,并且这些数据包和MTU大小的数据包一样能够进入驱动队列排队。在使用TSO和UFO优化时,由网卡将较大的数据包拆分成能够传输的小数据包。对于没有该硬件拆分功能的网卡,GSO优化能够通过软件来实现相同的功能,在数据包进入驱动队列前迅速完成数据包拆分。
我在前面提过,驱动队列中能包含描述符的数量是一定的(但描述符可以指向不同大小的数据包)。所以,TSO,UFO和GSO等优化措施将数据包增大,也不完全是件好事,因为这些优化也会使驱动队列中进行排队的字节数增大了许多。图像3是一个与图像2的对比图。
figure3
虽然接下来我要将重点放在传输路径(transmit path)上了,但是这里还是要再强调一下,Linux在数据接收端同样有类似TSO、UFO和GSO的优化措施。这些接收端优化措施同样也能将每个数据包的大小限制增大。具体来说,类型接收装卸(generic receive offload,简称GRO)使网卡能够将接收到的若干数据包合并成一个大数据包后,再传给IP数据栈。在传送数据包时,GRO能将原始数据包重组,使之符合IP数据包首尾连接的属性。GRO同样也会带来副作用:较大的数据包在传送时,可能会被拆分成了若干较小的数据包,这时,就会有多个数据包在同一数据流中同时进行排队。较大的数据包如果发生了这样的“微拆分”(micro burst),会对数据流之间的延时产生不利影响。
饿死和延时
虽然设置驱动队列—即在IP数据栈和硬件网卡间排队,非常便利,但这样做也带来“饿死和延时”的问题。
当网卡开始从驱动队列中取数据包时,如果恰好这时驱动队列为空队列,那么硬件其实就失去了一次传输数据的机会,也就将系统的吞吐量降低了。我们把这种情况叫做“饿死”(starvation)。需要注意的是,如果驱动队列为空,而此时系统又没有数据需要传输时,则不能称为“饿死”—-这是系统的正常情况。如何避免“饿死”是一个很复杂的问题,因为IP数据栈将数据包传入驱动队列的过程,和硬件网卡从驱动队列中取数据包的过程常常不是同时发生的。更加糟糕的是,这两个过程间的间隔时间很不确定,常常随着系统负载和网络接口物理介质等外部环境而变化。比如说,在一个非常繁忙的系统中,IP数据栈就很少有机会能把数据包加入到驱动队列缓存中,此时,很可能在驱动队列对更多数据包排队前,网卡就已经从驱动队列中取数据了。因此,如果驱动队列能变得更大的话,出现“饿死”的几率就会得到减小,并且系统吞吐量会相应提高。
虽然较大的队列能够保证高吞吐量,但是队列变大的同时,大量的延时情况也会出现。
figure4
在图像4中,单个带宽较大的TCP段几乎把驱动队列占满,我们把它称为“块(阻碍)交通流”(bulk traffic flow)(蓝色部分)。在最后进行排队的,是来自VoIP或游戏的“交互数据流”(×××部分)。像VoIP或游戏一类的交互式应用,一般会在固定的时间间隔到达时,发送较小的数据包。这对延时是非常敏感的。并且这时,传输带宽较大的数据,会使包传送率(packet rate)增高而且会产生更大的数据包。较高的包传送率会很快占满队列缓存,进而阻碍交互性数据包的传输。为了进一步说明这种情况,我们先做出如下假设:
网络接口的传输速率为 5Mbit/sec (5000000 bits/sec)。
每个“块交通流”中的数据包(分组)大小为1500bytes(或12000bits)。
每个“交互交通流”中的数据包(分组)大小为500bytes。
驱动队列共能容纳128个描述符。
现在有127个“块(阻碍)”数据包,和1个交互数据包最后进行排队。
127个数据传输完毕时,交互数据包才能进行传输。在以上假设下,将所有127个块数据包传输完毕共需要(127 * 12,000) / 5,000,000 = 0.304 秒 (以每ping计算延时则为304 毫秒 )。这样的延时完全无法满足交互式应用的需求,并且这个时间中还没有包含完成所有传输所需的时间—因为我们只计算了完成127个块数据包传输的时间而已。之前我曾提到过,在驱动队列中,数据包(分组)的大小在TSO等优化下能够超过1500bytes。所以这也让延时问题变得更严重了。
由超过规定大小的缓存而引起的较大延时,也被称为Bufferbloat。在Controlling Queue Delay 和the Bufferbloat中对这个问题有更详细的阐述。
综上所述,为驱动队列选择正确的大小是一个Goldilocks问题—不能定的太大因为会有延时,也不能定的太小因为吞吐量会降低。
字节队列限制(BQL)
字节队列限制(BQL)是最近在linux内核(> 3.3.0)中出现的新特性,它能为驱动队列自动分配合适的大小以解决前面提到过的问题。BQL机制在将数据包进行排队时,会自动计算当前系统下,能够避免饿死所需的最小驱动队列缓存大小,再决定是否对数据包进行排队。如前文所述,进行排队的数据越少,对数据包的最大延时也越小。
需要注意的是,驱动队列的实际大小并没有被BQL改变。BQL只是限制了当前时刻能够进行排队的数据多少(以字节计算)而已。任何超过大小限制的那一部分数据,都会被BQL阻挡在驱动队列之外。BQL机制会在以下两个事件发生时启动:
数据包进入驱动队列排队时。
通过物理介质的传输已经完成时。
下面是简化后的BQL算法。LIMIT指的是BQL计算出来的限制值。
1 2 3 4 5 6 |
**** ** After adding packets to the queue ****
if the number of queued bytes is over the current LIMIT value then disable the queueing of more data to the driver queue |
注意,进行排队的数据大小可以超过LIMIT,因为数据在进行LIMIT检查以前,就已经排队了。因为非常大的字节,能够通过TSO、UFO和GSO优化,一次性进行排队,所以就造成了进行排队数据过大的问题。如果你更加重视延时,也许你会将这些优化特性去除掉。文章后面有介绍去除的办法。
BQL机制的第二阶段,在硬件完成传输了以后启动。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
**** ** When the hardware has completed sending a batch of packets ** (Referred to as the end of an interval) ****
if the hardware was starved in the interval increase LIMIT
else if the hardware was busy during the entire interval (not starved) and there are bytes to transmit decrease LIMIT by the number of bytes not transmitted in the interval
if the number of queued bytes is less than LIMIT enable the queueing of more data to the buffer |
如代码所示,BQL主要是在测试系统此时是否出现了饿死。如果出现了饿死,则LIMIT会增加,以使更多数据能够进去队列进行排队。如果系统在测试时间内一直都十分繁忙,而且仍有字节在等着传入队列中,则此时队列太大了,当前系统不需要这么大的队列,所以LIMIT会减小,以控制延时。
下面举一个实例,来帮助大家理解BQL是如何控制用于排队的数据大小的。在我的其中一个服务器上,默认的驱动队列大小是256个描述符。因为以太网MTU大小为1500bytes,所以此时驱动队列能对256 * 1,500 = 384,000 bytes进行排队(如果使用TSO、GSO则排队字节会更多)。但是,BQL此时计算出的LIMIT值为3012bytes。所以,BQL大大限制了能够进入队列的数据大小。
有关BQL非常有趣的一点,能够从B—byte 这个字母看出来。跟驱动队列的大小和其他数据包队列的大小单位不同,BQL以byte(字节)为单位进行操作。这是因为字节的数目,与其在物理介质上传输所需时间,有非常直接的关系。而数据包和描述符的数目与该时间则关系不大,因为数据包和描述符的大小都是不一样的。
BQL通过将排队数据的大小进行限制,使之保持在能避免饿死出现的最小值,来减少网络延时。BQL还有一个重要的特性,它能使原本在驱动队列中进行排队(使用FIFO算法排队)的数据包,转移到“排队准则”(queueing discipline (QDisc))上来进行排队。QDisc能够实现比FIFO复杂得多的排队算法策略。下一小节将重点介绍Linux的QDisc机制。