Linux QOS实现框架分析

Linux中的QOS分为入口(Ingress)部分和出口(Egress)部分,入口部分主要用于进行入口流量限速(policing),出口部分的QOS用于队列调度(queuing scheduling)。 

以下分析所参考的linux内核版本为2.6.21。


1. Ingress QOS

         IngressQOS在内核的入口点有两个,但是不能同时启用,这取决于内核编译选项。当打开了CONFIG_NET_CLS_ACT时,入口点在src/net/core/dev.c的netif_receive_skb函数中,代码片段如下:

#ifdef CONFIG_NET_CLS_ACT

if (pt_prev) { 
ret = deliver_skb(skb, pt_prev, orig_dev); 
pt_prev = NULL; /* noone else should process thisafter*/
} else { 
skb->tc_verd = SET_TC_OK2MUNGE(skb->tc_verd);
}

ret = ing_filter(skb);

if (ret == TC_ACT_SHOT || (ret == TC_ACT_STOLEN)) {
kfree_skb(skb);
goto out;
}

skb->tc_verd = 0;
ncls:
#endif

进入ing_filter后,代码片段如下:
if ((q = dev->qdisc_ingress) != NULL)
result = q->enqueue(skb, q);
我们可以看到,在这里判断了是否在设备上配置了ingress调度规则,如果配置了则调用其enqueue函数进行处理。在这里实际上调用的是src/net/core/sched/sch_ingress.c中的ingress_enqueue函数。
当没有打开CONFIG_NET_CLS_ACT,而是打开了CONFIG_NET_CLS_POLICE和CONFIG_NETFILTER时,就会在netfilter的PREROUTING钩子点处调用ing_hook函数,该函数的代码片段如下:
if (dev->qdisc_ingress) {
spin_lock(&dev->queue_lock);
if ((q = dev->qdisc_ingress) != NULL)
fwres = q->enqueue(skb, q);
spin_unlock(&dev->queue_lock);
}

       可以看出与ing_filter的处理部分类似,最终都是调用了ingress qdisc的enqueue函数(即ingress_enqueue函数)。 ing_hook函数是在src/net/sched/sch_ingress.c文件中进行注册的,在sch_ingress文件中定义了一个结构实例ingress_qdisc_ops,如下所示:  

static struct Qdisc_ops ingress_qdisc_ops = {

.next  = NULL,

 .cl_ops  = &ingress_class_ops,

.id  ="ingress",

.priv_size = sizeof(struct ingress_qdisc_data),

.enqueue = ingress_enqueue,

.dequeue = ingress_dequeue,

.requeue = ingress_requeue,

.drop  =ingress_drop,

 .init  = ingress_init,

 .reset  = ingress_reset,

.destroy = ingress_destroy,

.change  = NULL,

.dump  =ingress_dump,

.owner  =THIS_MODULE, 

};


       所有的qdisc都会有这样的一个对象实例,在模块初始化时会调用register_qdisc函数将自己的struct Qdisc_ops结构实例注册到链表中,该链表头是qdisc_base,定义在sch_api.c文件中,static struct Qdisc_ops*qdisc_base。

       当通过tc qdisc命令配置了ingress qdisc规则时,会调用到ingress_init函数,进入ingress_init函数代码片段如下:

#ifndef CONFIG_NET_CLS_ACT

#ifdef CONFIG_NETFILTER

if (!nf_registered) {

if (nf_register_hook(&ing_ops) < 0) { 

printk("ingress qdisc registration error\n");

return -EINVAL;

}

nf_registered++; 

if (nf_register_hook(&ing6_ops) < 0) { 

printk("IPv6 ingress qdisc registration error," \ 

"disabling IPv6 support.\n");

} else 

nf_registered++;

 }

#endif

#endif


我们看到当没有定义CONFIG_NET_CLS_ACT,但是定义了CONFIG_NETFILTER时,会调用nf_register_hook函数注册ing_ops和ing6_ops结构实例,ing_ops和ing6_ops的定义如下:

static struct nf_hook_ops ing_ops = {

.hook          = ing_hook,

.owner  =THIS_MODULE,

.pf            = PF_INET,

.hooknum        = NF_IP_PRE_ROUTING,

.priority      = NF_IP_PRI_FILTER + 1,

};


static struct nf_hook_ops ing6_ops = {

.hook          = ing_hook,

.owner  =THIS_MODULE,

.pf            = PF_INET6,

.hooknum       = NF_IP6_PRE_ROUTING,

.priority      = NF_IP6_PRI_FILTER + 1,

};

针对IPV4和IPV6协议,注册的hook函数都是ing_hook,hook点是在PREROUTING,优先级低于NF_IP_PRI_FILTER。

当前比较推荐第一种使用方法,即打开CONFIG_NET_CLS_ACT选项,在netif_receive_skb函数中进入ingress的处理流程。

        下面我们再跟踪一下ingress的enqueue处理流程,进入ingress_enqueue函数,代码片段如下:
 result = tc_classify(skb, p->filter_list,&res);
……

在ingress_enqueue函数中最主要的就是调用tc_classify函数进行分类操作,进入tc_classify函数,代码片段如下:
for ( ; tp; tp = tp->next) {
if ((tp->protocol == protocol ||
tp->protocol == __constant_htons(ETH_P_ALL))&&
(err =tp->classify(skb, tp, res)) >= 0) { 
……

其中tp是一个指向struct tcf_proto类型的指针,struct tcf_proto中包含了与filter相关的参数和函数指针。tp->classify调用了与该filter规则相相关联的classify函数,如果我们使用的是FW类型的filter,那么对应的classify函数就是fw_classify,该函数定义在src/net/sched/cls_fw.c文 件中。以FW分类器为例,我们再进入fw_classify函数,代码片段如下:
struct fw_head *head = (struct fw_head*)tp->root;
struct fw_filter *f;
int r;
u32 id = skb->mark;

if (head != NULL) { 
id &= head->mask;
for (f=head->ht[fw_hash(id)]; f; f=f->next) { 
 if (f->id == id) {
*res = f->res; 
#ifdef CONFIG_NET_CLS_IND
if (!tcf_match_indev(skb, f->indev))
continue;
#endif /* CONFIG_NET_CLS_IND */ 
r = tcf_exts_exec(skb, &f->exts, res);
if (r < 0)
continue;
return r;
}
}
} else {
/* old method */
if (id && (TC_H_MAJ(id) == 0 || !(TC_H_MAJ(id^tp->q->handle)))){
res->classid = id;
res->class = 0;
return 0;
}
 }
return -1;

如上面代码所示,当filter中有规则时,遍历规则表,寻找与skb->mark(由ebtables或ip(6)tables来配置)相匹配的表项,如果找到了则会进一步调用tcf_exts_exec函数对扩展的action进行处理。再进入tcf_exts_exec函数,代码片段如下:

#ifdef CONFIG_NET_CLS_ACT
if (exts->action)
returntcf_action_exec(skb, exts->action, res);
#elif defined CONFIG_NET_CLS_POLICE
if (exts->police)
returntcf_police(skb, exts->police);
#endif

如上面代码所示,当定义了CONFIG_NET_CLS_ACT选项,并且存在扩展action时调用tcf_action_exec函数进行处理,当定义了CONFIG_NET_CLS_POLICE选项,并且存在police扩展时会调用tcf_police函数。实际上CONFIG_NET_CLS_POLICE是一套老的police机制,它假定了在ingress处理中只能进行police操作。CONFIG_NET_CLS_ACT是一套新的机制,该机制称为action扩展,即在filter规则处理之后,进一步进行额外的action处理,而Traffic Policing就属于其中的一种,它对应的文件是src/net/sched/act_police.c。如果我们通过tc filter命令配置fw的规则时用action选项指定了police作为扩展action,那么调用tcf_action_exec函数后就会最终调用到tcf_police函数。下面举个例子说明扩展action的配置方法:

tc filter add dev eth0 parent ffff: protocol all prio1 handle 100fwaction police rate 1000Kbit burst 2000k drop
从上面的命令可以看出,直接在fw后面加上action关键字即可配置action扩展。

下面我们再看看ingress qdisc是如何配置到接口上的,在这里只关注内核的处理流程,不关注tc工具与内核如何进行通信。通过tc命令进行配置后,会通过netlink接口调用内核中已经注册的一系列配置函数。
tcqdisc和tc clsss配置命令对应的配置函数在src/net/sched/sch_api.c的pktsched_init函数中进行了初始化注册,该函数在linux系统初始化的时候会被调用到。代码片断如下:

if (link_p) {
link_p[RTM_NEWQDISC-RTM_BASE].doit = tc_modify_qdisc; 
  link_p[RTM_DELQDISC-RTM_BASE].doit = tc_get_qdisc; 
  link_p[RTM_GETQDISC-RTM_BASE].doit = tc_get_qdisc; 
  link_p[RTM_GETQDISC-RTM_BASE].dumpit = tc_dump_qdisc; 
  link_p[RTM_NEWTCLASS-RTM_BASE].doit = tc_ctl_tclass; 
  link_p[RTM_DELTCLASS-RTM_BASE].doit = tc_ctl_tclass; 
  link_p[RTM_GETTCLASS-RTM_BASE].doit = tc_ctl_tclass; 
  link_p[RTM_GETTCLASS-RTM_BASE].dumpit = tc_dump_tclass;
}

通过以上注册的一系列函数,就可以完成tc qdisc和class的命令配置。 
tc filter配置命令对应的配置函数在src/net/sched/cls_api.c的tc_filter_init函数中进行了初始化注册,该函数也会在系统初始化的时候被调用到。代码片段如下:
 

if (link_p) { 
  link_p[RTM_NEWTFILTER-RTM_BASE].doit = tc_ctl_tfilter; 
  link_p[RTM_DELTFILTER-RTM_BASE].doit = tc_ctl_tfilter; 
  link_p[RTM_GETTFILTER-RTM_BASE].doit = tc_ctl_tfilter; 
  link_p[RTM_GETTFILTER-RTM_BASE].dumpit = tc_dump_tfilter; 
 } 

通过以上注册的一系列函数,就可以完成tc filter的命令配置。
例如在控制台调用tc qdisc add dev eth0 handle ffff: ingress命令后,最终会调用到内核的tc_modify_qdisc函数,进入该函数后可以找到以下的代码片段: 
 if (clid == TC_H_INGRESS) 
  q = qdisc_create(dev, tcm->tcm_parent, tca, &err); 
 else 
   q = qdisc_create(dev, tcm->tcm_handle, tca, &err); 
    其中,qdisc_create函数用于创建struct Qdisc结构实例,在qdisc_create函数中进行了几个关键操作: 

(1)查找structQdisc_ops结构实例 
struct Qdisc_ops *ops;
ops =qdisc_lookup_ops(kind); //用于查找与kind值相对应的struct Qdisc_ops的结构实例。

关于struct Qdisc_ops结构,在上文中已经大概描述过,他在模块初始化的时候通过调用register_qdisc函数注册到链表中,并且每一个qidsc都会对应一个struct Qdisc_ops结构实例,但是并不是每一个qdisc都会对应一个struct Qdisc结构实例,只有那些已经配置在接口上的qdisc实例才会和struct Qdisc实例相对应。

(2)分配structQdisc结构实例
struct Qdisc *sch;
sch =qdisc_alloc(dev, ops); //用于分配并初始化struct Qdisc结构实例

(3)将新分配的structQdisc结构实例加入到设备qdisc链表中
list_add_tail(&sch->list,&dev->qdisc_list);

执行完qdisc_create函数后,会继续执行qdisc_graft函数,之后是dev_graft_qdisc函数,最后会让dev->qdisc_ingress指针指向刚才创建的struct Qdisc结构实例,这样基本上就完成了ingress qdisc的配置。
如前文所述,在打开了CONFIG_NET_CLS_ACT配置选项后,还必须通过tc filter命令配置polic扩展action,否则进入ingress处理流程后将什么都不会做。

2. Egress QOS
出口队列调度的入口点在src/net/core/dev.c的dev_queue_xmit函数中,代码片段如下: 

q = rcu_dereference(dev->qdisc); 
#ifdef CONFIG_NET_CLS_ACT 
skb->tc_verd = SET_TC_AT(skb->tc_verd,AT_EGRESS); 
#endif 
if (q->enqueue) { 
  /* Grab device queue */ 
  spin_lock(&dev->queue_lock); 
  q = dev->qdisc; 
if (q->enqueue) { 
rc = q->enqueue(skb, q);
qdisc_run(dev);
spin_unlock(&dev->queue_lock);
rc = rc == NET_XMIT_BYPASS ? NET_XMIT_SUCCESS : rc;
goto out; 
}
spin_unlock(&dev->queue_lock);
}

从上面的代码中可以看出,通过q = rcu_dereference(dev->qdisc)可以获取到设备上rootqdisc的指针q(struct Qdisc *),在下面的处理过程中并没有判断q是否为NULL,这就说明设备上一定会存在egress qdisc,这一点和ingress是不同的,一个设备上可以没有ingress qdisc,即dev-> qdisc_ingress指针一般是NULL,除非通过tc qdisc命令配置了ingress qdisc。

我们看一下默认情况下dev->qdisc是如何配置的,在注册网络设备时会调用register_netdevice函数,在register_netdevice函数中又会调用dev_init_scheduler函数,在该函数中的代码片段如下:
dev->qdisc = &noop_qdisc; 
dev->qdisc_sleeping = &noop_qdisc; 
INIT_LIST_HEAD(&dev->qdisc_list); 
 
    新注册一个接口后,dev->qdisc指针指向noop_qdisc结构实例,这是一个特殊的qdisc,它什么也不做。当创建好设备,用ifconfig up命令把设备拉起后,会调用到内核的src/net/core/dev.c中的dev_open函数,在dev_open函数中又会调用到src/net/sched/sch_generic.c中的dev_activate函数,代码片段如下:
if (dev->qdisc_sleeping == &noop_qdisc) {
struct Qdisc *qdisc; 
  if (dev->tx_queue_len) { 
   
qdisc = qdisc_create_dflt(dev, &pfifo_fast_ops, 
        TC_H_ROOT); 

   if (qdisc == NULL) { 
    printk(KERN_INFO "%s: activation failed\n", dev->name); 
    return; 
   } 
   write_lock(&qdisc_tree_lock); 
   list_add_tail(&qdisc->list, &dev->qdisc_list); 
   write_unlock(&qdisc_tree_lock); 
  } else { 
   qdisc =  &noqueue_qdisc; 
  } 
  write_lock(&qdisc_tree_lock); 
 
dev->qdisc_sleeping = qdisc; 
  write_unlock(&qdisc_tree_lock); 
 } 
 …… 
 rcu_assign_pointer(dev->qdisc, dev->qdisc_sleeping); 
 
 从上面的代码可以看出,当把设备拉起时给设备配置的默认root qdisc为pfifo_fast。之后我们可以在控制台调用tc qdisc add……命令配置其他的qdisc,配置过程与配置ingress qdisc的过程类似,在这里就不再赘述了。 
     
下面我们以prio qdisc为例,看一下出口队列调度的大概流程。 
假设我们已经通过tc qdisc命令在接口上配置了prio qdisc作为root qdisc,那么在dev_queue_xmit函数中调用了rc = q->enqueue(skb, q)后,就会调用到与prio qdisc对应的enqueue函数。对于prio qdisc来说,对应的enqueue函数是prio_enqueue(src/net/sched/sch_prio.c), 在prio_enqueue函数中代码片段如下: 
 qdisc =
prio_classify(skb, sch, &ret); //进行分类选择,找出子qdisc 
  
 /*调用子qdisc的enqueue函数*/ 
 if ((ret = qdisc->enqueue(skb, qdisc)) == NET_XMIT_SUCCESS) { 
  sch->bstats.bytes += skb->len; 
  sch->bstats.packets++; 
  sch->q.qlen++; 
  return NET_XMIT_SUCCESS; 
 } 
     
在上面的代码中首先通过调用prio_classify函数查找子qdisc,然后再调用子qdisc对应的enqueue函数。在这里我们要补充一些概念,在Linux QOS中qidsc分为两种,一种是有分类(classful)的qdisc,另一种是无分类(classless)的qdisc,有分类和无分类的qdisc都可以做为设备的root qdisc,但是有分类的qdisc通过它的分类(class)又可以嫁接出子qdisc,子qdisc可以是有分类的qdisc也可以是无分类的qdisc,最后的叶子qdisc则必须是无分类的qdisc,一般常用pfifo/bfifo做为叶子qdisc。因此可以利用这种组合的特性利用有分类qdisc和无分类qdisc组合出复杂的调度方式。下图简单的描述了他们之间的关系。 

继续描述上面的prio qdisc,我们已经将prio qdisc配置成了root qdisc,在配置prio qdisc的过程中需要制定bands参数,这个参数指明了prio qdisc上的class数目,默认是3个class。配置了prio qdisc后会调用prio_init函数,该函数中会调用prio_tune函数,然后在prio_tune函数中会为prio qidsc创建默认的子qdisc,代码片段如下:
for (i=0; ibands; i++) { 
  if (q->queues[i] == &noop_qdisc) { 
   struct Qdisc *child; 
   
child = qdisc_create_dflt(sch->dev, &pfifo_qdisc_ops, 
        TC_H_MAKE(sch->handle, i + 1)); 

   if (child) { 
    sch_tree_lock(sch); 
    child = xchg(&q->queues[i], child); 
 
    if (child != &noop_qdisc) { 
     qdisc_tree_decrease_qlen(child, 
         child->q.qlen); 
     qdisc_destroy(child); 
    } 
    sch_tree_unlock(sch); 
   } 
  } 
 } 

从上面的代码可以看出,默认为prio qdisc创建的子qdisc为pfifo qdisc。 
通过上面你的描述,我们了解了root qdisc和child qdisc之间的关系,下面我们继续描述prio_classify函数,代码片段如下所示: 
 struct prio_sched_data *q = qdisc_priv(sch); 
 u32 band = skb->priority; 
 struct tcf_result res; 
 
 *qerr = NET_XMIT_BYPASS; 
 if (TC_H_MAJ(skb->priority) != sch->handle) { 
#ifdef CONFIG_NET_CLS_ACT 
  switch (
tc_classify(skb, q->filter_list, &res) ) { 
  case TC_ACT_STOLEN: 
  case TC_ACT_QUEUED: 
   *qerr = NET_XMIT_SUCCESS; 
  case TC_ACT_SHOT: 
   return NULL; 
  }; 
 
  if (!q->filter_list ) { 
#else 
  if (!q->filter_list || tc_classify(skb, q->filter_list, &res) ) { 
#endif 
   if (TC_H_MAJ(band)) 
    band = 0; 
   return q->queues[q->prio2band[band&TC_PRIO_MAX]]; 
  } 
  band = res.classid
 } 
 band = TC_H_MIN(band) - 1; 
 if (band > q->bands) 
  return q->queues[q->prio2band[0]]; 
 
  return q->queues[band];

从上面的代码可以看出,首先会用skb->priority字段的高16位的值和sch->handle值进行比较,如果不相等的话就会调用tc_classify分类匹配函数进行处理。假如我们使用的是fw分类方法的话,就会调用到fw_classify函数,该函数的处理流程我们在前文中已经描述过了,在此不再赘述。 
当找到子qdisc的指针后,就调用子qdisc的enqueue函数,如前文所述prio qidsc默认的子qdisc为pfifo qdisc,因此在这里会调用pfifo qdisc对应的enqueue函数pfifo_enqueue
(src/net/sched/sch_fifo.c),进入pfifo_enqueue函数,代码片段如下: 
 struct fifo_sched_data *q = qdisc_priv(sch); 
 
 if (likely(skb_queue_len(&sch->q) < q->limit)) 
  return
qdisc_enqueue_tail (skb, sch); 
 
 return qdisc_reshape_fail (skb, sch); 

  用当前的队列长度和队列总长度进行比较(按数据包数量进行比较),如果没有超出队列长度限制则把数据包缓冲到队列的尾部,否则就调用qdisc_reshape_fail函数丢弃数据包。至此就完成了数据包的enqueue操作。 
    数据包入队操作完成后,接下来在dev_queue_xmit函数中会调用qdisc_run函数进行队列调度和出队列操作,在该函数中会调用__qdisc_run函数,在该函数中代码片段如下:
while ( qdisc_restart (dev) < 0&& !netif_queue_stopped(dev))
/* NOTHING */; 
out: 
 clear_bit(__LINK_STATE_QDISC_RUNNING, &dev->state); 
 
    可以看到在这里循环调用了qdisc_restart函数(src/net/sched/sch_generic.c),直到函数返回值不为负值或设备状态处于队列停止状态为止。进入qdisc_restart函数,代码片段如下: 
 if (((skb = dev->gso_skb)) || ((
skb = q->dequeue(q)) )) { 
   …… 
  { 
   spin_unlock(&dev->queue_lock); 
 
   if (!netif_queue_stopped(dev)) { 
    int ret; 
 
   
ret = dev_hard_start_xmit(skb, dev); 
    if (ret == NETDEV_TX_OK) { 
     if (!nolock) { 
      netif_tx_unlock(dev); 
     } 
     spin_lock(&dev->queue_lock); 
      return -1; 
    } 
    if (ret == NETDEV_TX_LOCKED && nolock) { 
     spin_lock(&dev->queue_lock); 
     goto collision; 
    } 
   } 
   …… 
   spin_lock(&dev->queue_lock); 
   q = dev->qdisc;  
  } 
requeue: 
  if (skb->next) 
   dev->gso_skb = skb; 
  else 
    q->ops->requeue(skb, q); 
  netif_schedule(dev); 

  return 1; 
 } 
 BUG_ON((int) q->q.qlen < 0); 
 return q->q.qlen; 

在这里调用了dequeue函数做出队列操作,对于prio qdisc来说会调用prio_dequeue函数,该函数的代码片段如下: 
 struct sk_buff *skb; 
 struct prio_sched_data *q = qdisc_priv(sch); 
 int prio; 
 struct Qdisc *qdisc; 
 
 for (prio = 0; prio < q->bands; prio++) { 
  qdisc = q->queues[prio]; 
  skb = qdisc->dequeue(qdisc); 

  if (skb) { 
   sch->q.qlen--; 
   return skb; 
  } 
 } 
 return NULL; 

这里对prio qdisc的所有bands(class)进行遍历,由于高优先级的class对应的的prio值也较小,因此直接遍历数组即可对优先级调度进行控制。最后还是会调用到子qidsc的dequeue函数获取数据包指针。当获取到的skb非空时直接返回,下次重新调用该函数时又会从高优先级的class开始遍历,这样就保证了每一次的dequeue操作总是从高优先级的class开始。 
dequeue操作完成后,会调用dev_hard_start_xmit函数将数据包发送出去,如果发送成功则最终返回-1,在__qdisc_run函数中通过循环又会进入到qdisc_restart函数,直到队列为空为止。我们注意到在特殊情况下可能会有调用dev_hard_start_xmit发包不成功的情况,如果是因为发送设备忙碌造成的发送不成功则会进入requeue流程,将数据包重新缓冲到队列里,然后调用netif_schedule函数启用网络发包软中断处理流程,在软中断处理流程中会调用net_tx_action函数,最终又会调用qdisc_run进入队列调度流程。 
 
3. 总结 
  以上对Ingress和Egress QOS的实现流程和框架进行了粗略分析,通过以上分析,希望读者可以从大的流程上了解QOS的实现框架。由于内核中实现了多种qdisc,在此不能一一赘述,有兴趣的读者可以参考本文的分析流程,切入到自己感兴趣的部分进行深入分析即可。但是万变不离其中,不论是哪种qdisc,都不会超出本文所描述的框架和流程,只不过在队列调度算法上会比较复杂罢了。

你可能感兴趣的:(Linux QOS实现框架分析)