| 祝顺民
2003 年 6 月 15 日 本文描述了linux 2.4.x内核中对qos支持的设计与实现,并且对缺省的数据包调度机制pfifo进行了详细的分析。 在传统的tcp/ip网络的路由器中,所有的ip数据包的传输都是采用fifo(先进先出),尽最大努力传输的处理机制。在早期网络数据量和关键业务数据不多的时候,并没有体现出非常大的缺点,路由器简单的把数据报丢弃来处理拥塞。但是随着计算机网络的发展, 数据量的急剧增长,以及多媒体,voip数据等对延时要求高的应用的增加。路由器简单丢弃数据包的处理方法已经不再适合当前的网络。单纯的增加网络带宽也不能从根本上解决问题。所以网络的开发者们提出了服务质量的概念。概括的说:就是针对各种不同需求,提供不同服务质量的网络服务功能。提供qos能力将是对未来ip网络的基本要求。 1.linux内核对qos的支持 linux内核网络协议栈从2.2.x开始,就实现了对服务质量的支持模块。具体的代码位于net/sched/目录。在linux里面,对这个功能模块的称呼是traffic control ,简称tc。 首先我们了解一下linux网络协议栈在没有tc模块时发送数据包的大致流程。如图1。
注:上图的分层是按照linux实现来画,并没有严格遵守osi分层 从上图可以看出,没有tc的情况下,每个数据包的发送都会调用dev_queue_xmit,然后判断是否需要向af_packet协议支持体传递数据包内容,最后直接调用网卡驱动注册的发送函数把数据包发送出去。发送数据包的机制就是本文开始讲到的fifo机制。一旦出现拥塞,协议栈只是尽自己最大的努力去调用网卡发送函数。所以这种传统的处理方法存在着很大的弊端。 为了支持qos,linux的设计者在发送数据包的代码中加入了tc模块。从而可以对数据包进行分类,管理,检测拥塞和处理拥塞。为了避免和以前的代码冲突,并且让用户可以选择是否使用tc。内核开发者在上图中的两个红色圆圈之间添加了tc模块。(实际上在tc模块中,发送数据包也实现对af_packet协议的支持,本文为了描述方便,把两个地方的af_packet协议处理分开来了)。 下面从具体的代码中分析一下对tc模块的支持。 net/core/dev.c: dev_queue_xmit函数中略了部分代码: int dev_queue_xmit(struct sk_buff *skb){………………. q = dev->qdisc; if (q->enqueue) { /*如果这个设备启动了tc,那么把数据包压入队列*/ int ret = q->enqueue(skb, q); /*启动这个设备发送*/ qdisc_run(dev); return; } if (dev->flags&iff_up) {…………. if (netdev_nit) dev_queue_xmit_nit(skb,dev); /*对af_packet协议的支持*/ if (dev->hard_start_xmit(skb, dev) == 0) { /*调用网卡驱动发送函数发送数据包*/ return 0; } }………………} | 从上面的代码中可以看出,当q->enqueue为假的时候,就不采用tc处理,而是直接发送这个数据包。如果为真,则对这个数据包进行qos处理。  
| 回页首 | |
2.tc的具体设计与实现 第一节描述了linux内核是如何对qos进行支持的,以及是如何在以前的代码基础上添加了tc模块。本节将对tc的设计和实现进行详细的描述。 qos有很多的拥塞处理机制,如fifo queueing(先入先出队列),pq(优先队列),cq(定制队列),wfq(加权公平队列)等等。qos还要求能够对每个接口分别采用不同的拥塞处理。为了能够实现上述功能,linux采用了基于对象的实现方法。
上图是一个数据发送队列管理机制的模型图。其中的qos策略可以是各种不同的拥塞处理机制。我们可以把这一种策略看成是一个类,策略类。在实现中,这个类有很多的实例对象,策略对象。使用者可以分别采用不同的对象来管理数据包。策略类有很多的方法。如入队列(enqueue),出队列(dequeue),重新入队列(requeue),初始化(init),撤销(destroy)等方法。在linux中,用qdisc_ops结构体来代表上面描述的策略类。 前面提到,每个设备可以采用不同的策略对象。所以在设备和对象之间需要有一个桥梁,使设备和设备采用的对象相关。在linux中,起到桥梁作用的是qdisc结构体。 通过上面的描述,整个tc的架构也就出来了。如下图:
加上tc之后,发送数据包的流程应该是这样的: (1) 上层协议开始发送数据包 (2) 获得当前设备所采用的策略对象 (3) 调用此对象的enqueue方法把数据包压入队列 (4) 调用此对象的dequeue方法从队列中取出数据包 (5) 调用网卡驱动的发送函数发送 接下来从代码上来分析tc是如何对每个设备安装策略对象的。 在网卡注册的时候,都会调用register_netdevice,给设备安装一个qdisc和qdisc_ops。 int register_netdevice(struct net_device *dev){………………….dev_init_scheduler(dev);………………….}void dev_init_scheduler(struct net_device *dev){…………. /*安装设备的qdisc为noop_qdisc*/ dev->qdisc = &noop_qdisc;…………. dev->qdisc_sleeping = &noop_qdisc; dev_watchdog_init(dev);} 此时,网卡设备刚注册,还没有up,采用的是noop_qdisc,struct qdisc noop_qdisc ={ noop_enqueue, noop_dequeue, tcq_f_builtin, &noop_qdisc_ops, };noop_qdisc采用的数据包处理方法是noop_qdisc_ops,struct qdisc_ops noop_qdisc_ops ={ null, null, "noop", 0, noop_enqueue, noop_dequeue, noop_requeue,}; | 从noop_enqueue,noop_dequeue,noop_requeue函数的定义可以看出,他们并没有对数据包进行任何的分类或者排队,而是直接释放掉skb。所以此时网卡设备还不能发送任何数据包。必须ifconfig up起来之后才能发送数据包。 调用ifconfig up来启动网卡设备会走到dev_open函数。 int dev_open(struct net_device *dev){…………….dev_activate(dev);……………..}void dev_activate(struct net_device *dev){…………. if (dev->qdisc_sleeping == &noop_qdisc) { qdisc = qdisc_create_dflt(dev, &pfifo_fast_ops); /*安装缺省的qdisc*/}……………if ((dev->qdisc = dev->qdisc_sleeping) != &noqueue_qdisc) {……………./*.安装特定的qdisc*/ }……………..} | 设备启动之后,此时当前设备缺省的qdisc->ops是pfifo_fast_ops。如果需要采用不同的ops,那么就需要为设备安装其他的qdisc。本质上是替换掉dev->qdisc指针。见sched/sch_api.c 的dev_graft_qdisc函数。 static struct qdisc *dev_graft_qdisc(struct net_device *dev, struct qdisc *qdisc){…………… oqdisc = dev->qdisc_sleeping; /* 首先删除掉旧的qdisc */ if (oqdisc && atomic_read(&oqdisc->refcnt) <= 1) qdisc_reset(oqdisc); /*安装新的qdisc */ if (qdisc == null) qdisc = &noop_qdisc; dev->qdisc_sleeping = qdisc; dev->qdisc = &noop_qdisc; /*启动新安装的qdisc*/ if (dev->flags & iff_up) dev_activate(dev);…………………} | 从dev_graft_qdisc可以看出,如果需要使用新的qdisc,那么首先需要删除旧的,然后安装新的,使dev->qdisc_sleeping 为新的qdisc,然后调用dev_activate函数来启动新的qdisc。结合dev_activate函数中的语句: if ((dev->qdisc = dev->qdisc_sleeping) != &noqueue_qdisc) | 可以看出,此时的dev->qdisc所指的就是新的qdisc。(注意,上面语句中左边是一个赋值语句。) 在网卡down掉的时候,通过调用dev_close -> dev_deactivate重新使设备的qdisc为noop_qdisc,停止发送数据包。 linux中的所有的qos策略最终都是通过上面这个方法来安装的。在sch_api.c中,对dev_graft_qdisc函数又封装了一层函数(register_qdisc),供模块来安装新的qdisc。如red(早期随即检测队列)模块,就调用register_qdisc来安装red对象(net/sched/sch_red.c->init_module())。  
| 回页首 | |
3. linux缺省策略对象pfifo_fast_ops分析
在linux中,如果设备启动之后,没有配置特定的qos策略,内核对每个设备采用缺省的策略,pfifo_fast_ops。下面的pfifo_fast_ops进行详细的分析。 上图中的信息可以对应于pfifo_fast_ops结构体的每个部分: static struct qdisc_ops pfifo_fast_ops ={ null, null, "pfifo_fast", /*ops名称*/ 3 * sizeof(struct sk_buff_head), /*数据包skb队列*/ pfifo_fast_enqueue, /*入队列函数*/ pfifo_fast_dequeue, /*出队列函数*/ pfifo_fast_requeue, /*重新压入队列函数*/ null, pfifo_fast_init, /*队列管理初始化函数*/ pfifo_fast_reset, /*队列管理重置函数*/}; | 在注册pfifo_fast_ops的时候首先会调用pfifo_fast_init来初始化队列管理,见qdisc_create_dflt函数。 static int pfifo_fast_init(struct qdisc *qdisc, struct rtattr *opt){……… for (i=0; i<3; i++) skb_queue_head_init(list+i); /*初始化3个优先级队列*/……….} | init函数的作用就是初始化3个队列。 在注销一个qdisc的时候都会调用qdisc的ops的reset函数。见dev_graft_qdisc函数。 static voidpfifo_fast_reset(struct qdisc* qdisc){………….. for (prio=0; prio < 3; prio++) skb_queue_purge(list+prio); /*释放3个优先级队列中的所有数据包*/…………..} | 在数据包发送的时候会调用qdisc->enqueue函数(在qdisc_create_dflt函数中已经将qdisc_ops的enqueue,dequeue,requeue函数分别赋值于qdisc分别对应的函数指针)。 int dev_queue_xmit(struct sk_buff *skb){………………. q = dev->qdisc; if (q->enqueue) { /* 对应于pfifo_fast_enqueue 函数*/ int ret = q->enqueue(skb, q); /*启动这个设备的发送,这里涉及到两个函数pfifo_fast_dequeue ,pfifo_fast_requeue 稍后介绍*/ qdisc_run(dev); return; }……………} | 入队列函数pfifo_fast_enqueue: static intpfifo_fast_enqueue(struct sk_buff *skb, struct qdisc* qdisc){………….. list = ((struct sk_buff_head*)qdisc->data) + prio2band[skb->priority&tc_prio_max]; /*首先确定这个数据包的优先级,决定放入的队列*/ if (list->qlen <= skb->dev->tx_queue_len) { __skb_queue_tail(list, skb); /*将数据包放入队列的尾部*/ qdisc->q.qlen++; return 0; }……………..} | 在数据包放入队列之后,调用qdisc_run来发送数据包。 static inline void qdisc_run(struct net_device *dev){ while (!netif_queue_stopped(dev) && qdisc_restart(dev)<0) /* nothing */;} | 在qdisc_restart函数中,首先从队列中取出一个数据包(调用函数pfifo_fast_dequeue)。然后调用网卡驱动的发送函数(dev->hard_start_xmit)发送数据包,如果发送失败,则需要将这个数据包重新压入队列(pfifo_fast_requeue),然后启动协议栈的发送软中断进行再次的发送。 static struct sk_buff *pfifo_fast_dequeue(struct qdisc* qdisc){………….. for (prio = 0; prio < 3; prio++, list++) { skb = __skb_dequeue(list); if (skb) { qdisc->q.qlen--; return skb; } }……………….} | 从dequeue函数中可以看出,pfifo的策略是:从高优先级队列中取出数据包,只有高优先级的队列为空,才会对下一优先级的队列进行处理。 requeue函数重新将数据包压入相应优先级队列的头部。 static intpfifo_fast_requeue(struct sk_buff *skb, struct qdisc* qdisc){ struct sk_buff_head *list; list = ((struct sk_buff_head*)qdisc->data) + prio2band[skb->priority&tc_prio_max]; /*确定相应优先级的队列*/ __skb_queue_head(list, skb);/*将数据包压入队列的头部*/ qdisc->q.qlen++; return 0;} |
 
| 回页首 | |
总结: qos是当前一个非常热门的话题,几乎所有高端的网络设备都支持qos功能,并且这个功能也是当前网络设备之间竞争的一个关键技术。linux为了在在高端服务器能够占有一席之地,从2.2.x内核开始就支持了qos。本文在linux 2.4.0的代码基础上对linux如何支持qos进行了分析。并且分析了linux内核的缺省队列处理方法pfifo的实现。  
| 回页首 | |
参考资料  
| 回页首 | |
关于作者  | | |  | 祝顺民,网名:getmoon。目前从事防火墙开发,致力于网络的研究和开发,分析linux内核。经常出没于 www.linuxforum.net的内核板块。希望于爱好者们共同探讨。email: getmoon@163.com | |