Linux 网络堆栈的排队机制

在任何网络堆栈或设备中,数据包的队列都是非常重要。这些队列使得不在同一时刻加载的模块能够相互通信,并且能提高网络性能,同时也会间接影响到网络延时的长短。本文章通过阐述IP数据包在Linux网络中的排队机制,来解释两个问题:

  • BQL一类新特性是如何减小网络延时的。
  • 如何控制已减小延时后的缓存。

下面这张图(和它的变形)将会在文中不断的出现,用以说明具体的概念。

figure_1_v2
figure1

驱动队列(环形缓存区)

驱动队列位于IP数据栈和网卡之间。驱动队列使用先进先出算法,并通过环形缓存区实现—可以暂时把环形缓存区当做一个固定大小的缓存器。这个队列中不含任何来自包(分组)的数据,直接参与排队的是描述符(descriptor)。这些描述符指向 “内核套接字缓存”(socket kernel buffers,简写为SKBs),SKB中含有在整个内核处理过程中都要使用的数据包。

figure_2_v2
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的对比图。

figure_3_v2
figure3

虽然接下来我要将重点放在传输路径(transmit path)上了,但是这里还是要再强调一下,Linux在数据接收端同样有类似TSO、UFO和GSO的优化措施。这些接收端优化措施同样也能将每个数据包的大小限制增大。具体来说,类型接收装卸(generic receive offload,简称GRO)使网卡能够将接收到的若干数据包合并成一个大数据包后,再传给IP数据栈。在传送数据包时,GRO能将原始数据包重组,使之符合IP数据包首尾连接的属性。GRO同样也会带来副作用:较大的数据包在传送时,可能会被拆分成了若干较小的数据包,这时,就会有多个数据包在同一数据流中同时进行排队。较大的数据包如果发生了这样的“微拆分”(micro burst),会对数据流之间的延时产生不利影响。

饿死和延时

虽然设置驱动队列—即在IP数据栈和硬件网卡间排队,非常便利,但这样做也带来“饿死和延时”的问题。

当网卡开始从驱动队列中取数据包时,如果恰好这时驱动队列为空队列,那么硬件其实就失去了一次传输数据的机会,也就将系统的吞吐量降低了。我们把这种情况叫做“饿死”(starvation)。需要注意的是,如果驱动队列为空,而此时系统又没有数据需要传输时,则不能称为“饿死”—-这是系统的正常情况。如何避免“饿死”是一个很复杂的问题,因为IP数据栈将数据包传入驱动队列的过程,和硬件网卡从驱动队列中取数据包的过程常常不是同时发生的。更加糟糕的是,这两个过程间的间隔时间很不确定,常常随着系统负载和网络接口物理介质等外部环境而变化。比如说,在一个非常繁忙的系统中,IP数据栈就很少有机会能把数据包加入到驱动队列缓存中,此时,很可能在驱动队列对更多数据包排队前,网卡就已经从驱动队列中取数据了。因此,如果驱动队列能变得更大的话,出现“饿死”的几率就会得到减小,并且系统吞吐量会相应提高。

虽然较大的队列能够保证高吞吐量,但是队列变大的同时,大量的延时情况也会出现。

figure_4_v2
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机制会在以下两个事件发生时启动:

  1. 数据包进入驱动队列排队时。
  2. 通过物理介质的传输已经完成时。

下面是简化后的BQL算法。LIMIT指的是BQL计算出来的限制值。

注意,进行排队的数据大小可以超过LIMIT,因为数据在进行LIMIT检查以前,就已经排队了。因为非常大的字节,能够通过TSO、UFO和GSO优化,一次性进行排队,所以就造成了进行排队数据过大的问题。如果你更加重视延时,也许你会将这些优化特性去除掉。文章后面有介绍去除的办法。

BQL机制的第二阶段,在硬件完成传输了以后启动。

如代码所示,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机制。

排队准则(QDisc)

我们已经了解到,驱动队列只是简单的先入先出队列,它不能将来自不同数据流的包区分开来。这样的设计能使网卡驱动软件变得小巧并且有更高的效率。需要注意的是,一些更加先进的以太网和无线网卡可以支持多种相互独立的传送队列,但是它们实际上都是非常类似的先进先出队列而已。系统中更高层负责在其中选择一种队列进行使用。

夹在IP数据栈和驱动队列中间的,是排队准则(QDisc)层(见Figure1),它负责实现Linux内核中的(数据传输)交通管理功能,比如交通分类,优先级别和速率协商。QDisc层的配置比起其他层要更加困难一些。要了解QDisc层,必须了解三个非常重要的概念:排队规则,类和过滤器。

排队准则(QDisc)是Linux对交通队列的抽象,但是比“先入先出”要复杂一些。通过网络接口,QDisc能独立地进行复杂的队列管理,而不需要改变IP数据栈或者网卡驱动的运行模式。每个网络接口都会默认被设为pfifo_fast QDisc ,pfifo_fast QDisc在TOS的基础上,实现了简单的三带优先机制(three band prioritization scheme)。虽然是默认机制,但是这并不代表它是最好的机制,因为pfifo_fast QDisc的队列深度很大(在下面txqueuelen中会有解释),并且它也不能区分不同数据流。

第二个和QDisc紧密相关的概念是类。单个QDisc能通过实现不同类,对数据中的不同部分进行不同处理。例如,层级表示桶(the Hierarchical Token Bucket (HTB))QDisc,能使用户配置500Kbps 和 300Kbps的类,并且控制数据使之进入用户希望其进入的类中。并不是所有QDisc都实现这种功能,我们一般把能够这样分类的QDisc称为——可分类的QDiscs。

过滤器(也叫分类器)是一种分类的机制,用来将数据分类到特定QDisc或类中。过滤器的种类很多,它们的复杂度也都不相同。u32 是其中最普通也最容易使用的一种。现在还没有针对u32的文档,但是你可以在这里找到一个例子one of my QoS scripts。

在LARTC HOWTO and the tc man pages,你可以了解到更多关于QDiscs、类和过滤器的细节。

传输层和排队准则间的缓存

你可能已经注意到了,在原来的图例中,排队准则层之上就没有数据包的队列了。这意味着网络数据栈要么直接把数据包放入排队准则中,要么把数据包推回它的上一层(比如套接字缓存的情况),如果这时队列已满的话。接下来一个很明显的问题是,当栈有很多数据包要发送的时候该怎么办?当TCP的拥塞窗口很大,或者某个应用正在快速发送UDP数据包时,就会出现要发送很多数据包的情况。对于只有一个队列的QDisc来说,类似问题在图例4中就已经出现过了。这里的问题是,一个带宽很大或者包速率很大的数据流会把队列里的所有空间占满,造成数据包丢失和延时的问题。更糟的是,这样很可能会使另一个缓存产生,进而产生一个静止队列(standing queue),造成更严重的延时并使TCP的RTT和拥塞窗口的计算出现问题。由于Linux默认使用仅有一个队列的pfifo_fast QDisc(因为大多数数据流的TOS=0),所以这样的问题非常常见。

Linux3.6.0(2012-09-30)出现后,Linux内核增加了TCP小队列(TCP small queue)的机制,用于解决该问题。TCP小队列对每个TCP数据流中,能够同时参与排队的字节数做出了限制。这样会产生意想不到的作用,能使内核将数据推回原先的应用,使得应用能更有效地优先处理写入套接字的请求。目前(2012-12-28),除TCP之外的其他传输协议的单个数据流,还是有可能阻塞QDsic层的。

另一种能部分解决传输层阻塞的办法,是使用有许多队列的QDsic层,最好是对每一个数据流都能有一个队列。SFQ和fq_codel的QDsic都能够很好地解决这个问题,使每个数据流都分到一个队列。

如何控制Linux中的队列长度

驱动队列

对于以太网设备,可以用ethtool命令来控制队列长度。ethtool提供底层的接口数据,并能控制IP数据栈和驱动的各种特性。

-g 能够将驱动队列的参数展示出来:

从上面可以看出,网卡驱动默认在传输队列中有256个描述符。前面我们提到过,为了免缓存丢包等情况,应该减小驱动队列的大小,这同时也能减小延时。引入BQL后,就没有必要再去调整驱动队列的大小了(下文中有配置BQL的介绍)。

ethtool也能用于调整优化特性,如TSO、UFO和GSO。-k 指令能展示出现在卸货(offload)状态,-K能调整这些状态。

因为TSO, GSO, UFO 和 GRO大大增加了能够参与排队的字节数,如果相比于吞吐量你更在意延时的话,你就应该禁用这些优化。一般来说禁用这些优化,不会使你感到CPU和吞吐量收到了影响,除非你的系统要处理的数据率很大。

字节队列限制(BQL)

BQL算法能够自我适应,所以应该没什么必要去经常调整它。但是,如果你很关注最大在低码率上的最大延时的话,可能你会想要比现有LIMIT更大的上限值。在/sys 的目录中可以找到配置BQL的文件,目录的具体地址取决于网卡的位置和名字。在我的服务器上,eth0的目录是:

里面的文件有:

  • hold_time:修改LIMIT间的时间(millisecond为单位)。
  • inflight:参与排队但没有发送的字节数。
  • limit:BQL计算出的LIMIT值。如果该网卡不支持BQL机制,则为0。
  • limit_max:能够修改的LIMIT最大值。调低该值能减小延时。
  • limit_min:能够修改的LIMIT最小值。调高该值能增大吞吐量。

为能够参与排队的字节数设置一个上限,改一下limit_max 文件就行了:

什么是txqueuelen?

在前面我们已经提到了减小网卡传输队列大小的作用。当前的队列大小能够通过ip和ifconfig命令获得。但是令人困惑地是,两个命令得到的队列长度不同:

默认的传输队列长度为1000包,在低带宽下这已经是很大的缓存了。

有趣的是,这个变量到底控制着什么东西呢?我以前也不知道,于是花了很多时间阅读内核代码。就我所知,txqueuelen只是丢某些排队准则的默认队列长度。这些排队准则有:

  • pfifo_fast (Linux default queueing discipline)
  • sch_fifo
  • sch_gred
  • sch_htb (only for the default queue)
  • sch_plug
  • sch_sfb
  • sch_teql

再来看图例1,txqueuelen参数控制着上面准则的队列大小。对于大多数其中的队列,tc命令行中的limit参数,都超过了txqueuelen的默认大小。总之,如果你不使用上面的队列准则,或者队列长度超过了exqueuelen大小,txqueuelen都是没有意义的。

再说一句,还有一点使我非常困惑,ifconfig命令显示的是如mac地址一类的底层网络接口细节,但是txqueuelen显示的却是更高层的QDisc层的信息。如果ifconfig显示驱动队列的大小可能会更合理些。

通过ip或者ifconfig命令能够控制传输队列的长度:

注意,ip命令使用 ‘txqueuelen’,但在显示接口细节时使用的是‘qlen’。

队列准则

前面介绍过,Linux 内核有很多QDsic,每个QDisc有自己的包队列排队方法。在这篇文章里描述清楚如何配置这些QDisc的细节是不可能的。想要了解所有细节,可以参考man page(man tc)。在‘man tc qdisc-name’ (ex: ‘man tc htb’ or ‘man tc fq_codel’)中,能够找到每个QDisc的细节。LARTC也能找到许多有用的资源,但是缺少一些特性的资料。

下面是一些使用的tc命令的小技巧:

  • 如果数据包没有经过过滤器分类,HTBQDisc的默认队列会接收所有的数据包。其他QDisc比如DRR会把所有未分类的数据都接收进来。可以通过“tc qdisc show”中的direct_packets_stat查看到所有没有分类然后直接进入到队列中的数据包。
  • HTB类仅对没有带宽分配的分类起作用。所有带宽分配会在查看它们的叶子和相关的优先级时发生。
  • QDisc设施(infrastructure)会鉴别出带有大小数目(major and minor numbers )QDisc和类,它们被冒号分开。大数(major number)是QDisc的标识符,小数(minor number)标识QDisc中的类。tc命令使用十六进制来表示这些数字。因为很多字符串在十六和十进制中都一样(10以内),所以许多用户一直不知道tc使用十六进制表示法。可以在my tcscripts里看看我是如何对付它的。
  • 如果你使用的是ADSL,并且基于ATM(几乎所有DSL服务都是基于ATM的,但也有例外,比如基于VDSL2的),你很可能会想要加入“linklayer adsl”选项。它的意思是将IP数据包分解成53个字节大小的ATM块的上限。
  • 如果你正在使用PPPoE,那你很可能会想通过‘overhead’参数来调整PPPoE的上限。

TCP 小队列

每个套接字TCP队列的大小限制,可以通过下面的/proc文件来查看和控制:

我觉得一般情况下都不需要修改这个文件。

你控制不了的超大队列

不幸的是,并不是所有影响网络性能超大队列都能够被我们控制。很常见的是,问题往往出在服务提供商提供的装置和其他配套的设备(比如DSL或者电缆调制器)上。当服务提供商的装置本身有问题时,就没什么办法了,因为你不可能控制向你传输的数据。但是,在逆向上,你可以控制数据流使之比链路率(link rate)稍微低些。这会让装置中的队列不会有几对数据包出现。很多家庭路由器都有链路率限制的功能,你能够通过它来控制你的数据流低于链路率。如果你将Linux Box作为路由器,控制数据流同时也会使内核的排队机制运行地更加高效。你能找到很多在线的tc脚本,比如the one I use with some related performance results。

概要

在每个使用分组交换的网络中,数据包缓存的排队机制都是非常必要的。控制这些缓存的包大小是至关重要的,能直接影响到网络延时等问题。虽然静态的缓存大小分配对于降低延时也很有效,但是更好的方法还是用更加智能的方法动态控制包的大小。实现动态控制的最好方法是使用动态机制,比如BQL和active queue management(AQM)技术,比如Codel。本文描述了数据包时如何在Linux的网络栈中进行排队的,并且介绍了相关特性的配置方法,给出了降低延时的建议。

相关链接

Controlling Queue Delay  – 对网络排队机制作和Codel算法出了很好的介绍,

Presentation of Codel at the IETF – Controlling Queue Delay 一文的视频版本

Bufferbloat: Dark Buffers in the Internet – 早先介绍数据流阻塞的文章

Linux Advanced Routing and Traffic Control Howto (LARTC)  – 可能是目前为止最好的tc命令使用文档,只是有些过时,没有包含fq_codel等特性。

TCP Small Queues on LWN

Byte Queue Limits on LWN

感谢

感谢Kevin Mason, Simon Barber, Lucas Fontes and Rami Rosen对文章所作出的帮助。

1 收藏 2 评论

关于作者:马帅

(新浪微博:@聪明magicshine) 个人主页 · 我的文章

相关文章

可能感兴趣的话题



直接登录
最新评论
  • zhuyie   2014/03/31

    “驱动队列位于IP数据栈和网卡直接。驱动队列使用先进先出算法,” --》 直接 应为 之间 吧?

  • gavintobaby   2014/08/11

    从上面可以看出,网卡驱动默认在传输队列中有256个描述符。前面我们提到过,为了免缓存丢包等情况,应该减小驱动队列的大小,这同时也能减小延时

    减小延时可以理解,为什么还可以避免缓存丢包?

跳到底部
返回顶部