Linux3.5内核对路由子系统的重构对Redirect路由以及neighbour子系统的影响

几年前,我记得写过好几篇关于Linux去除对路由cache支持的文章,路由cache的下课来源于一次对路由子系统的重构,具体原因就不再重复说了,本文将介绍这次重构对Redirect路由以及neighbour子系统的影响。

事实上,直到最近3个月我才发现这些影响是如此之大,工作细节不便详述,这里只是对关于开放源代码Linux内核协议栈的一些实现上的知识进行一个汇总,以便今后查阅,如果有谁也因此获益,则不胜荣幸。

路由项rtable,dst_entry与neighbour

IP协议栈中,IP发送由两部分组成:

IP路由的查找

要想成功发送一个数据包,必须要有响应的路由,这部分是由IP协议规范的路由查找逻辑完成的,路由查找细节并不是本文的要点,对于Linux系统,最终的查找结果是一个rtable结构体对象,表示一个路由项,其内嵌的第一个字段是一个dst_entry结构体,因此二者可以相互强制转换,其中重要的字段就是:rt_gateway
  rt_gateway只是要想把数据包发往目的地,下一跳的IP地址,这是IP逐跳转发的核心。到此为止,IP路由查找就结束了。

IP neighbour的解析

在IP路由查找阶段已经知道了rt_gateway,那么接下来就要往二层落实了,这就是IP neighbour解析的工作,我们知道rt_gateway就是neighbour,现在需要将它解析成硬件地址。所谓的neighbour就是逻辑上与本机直连的所有网卡设备,“逻辑上直连”意味着,对于以太网而言,整个以太网上所有的设备都可以是本机的邻居,关键看谁被选择为发送当前包的下一跳,而对于POINTOPOINT设备而言,则其邻居只有唯一的一个,即对端设备,唯一意味着不需要解析硬件地址!值得注意的是,无视这个区别将会带来巨大的性能损失,这个我将在本文的最后说明。

声明:

为了描述方便,以下将不再提起rtable,将路由查找结果一律用dst_entry代替!下面的代码并不是实际上的Linux协议栈的代码,而是为了表述方便抽象而成的伪代码,因此dst_entry并不是内核中的dst_entry结构体,而只是代表一个路由项!这么做的理由是,dst_entry表示的是与协议无关的部分,本文的内容也是与具体协议无关的,因此在伪代码中不再使用协议相关的rtable结构体表示路由项。


Linux内核对路由子系统的重构

在Linux内核3.5版本之前,路由子系统存在一个路由cache哈希表,它缓存了最近最经常使用的一些dst_entry(IPv4即rtable)路由项,对数据包首先以其IP地址元组信息查找路由cache,如果命中便可以直接取出dst_entry,否则再去查找系统路由表。
  在3.5内核中,路由cache不见了,具体缘由不是本文的重点,已有其它文章描述,路由cache的去除引起了对neighbour子系统的副作用,这个副作用被证明是有益的,下面的很大的篇幅都花在这个方面,在详细描述重构对neighbour子系统的影响之前,再简单说说另一个变化,就是Redirect路由的实现的变化。
  所谓的Redirect路由肯定是对本机已经存在的路由项的Redirect,然而在早期的内核中,都是在不同的位置比如inet_peer中保存重定向路由,这意味着路由子系统与协议栈其它部分发生了耦合。在早期内核中,其实不管Redirect路由项存在于哪里,最终它都要进入路由cache才能起作用,可是在路由cache完全没有了之后,Redirect路由保存的位置问题才暴露出来,为了“在路由子系统内部解决Redirect路由问题”,重构后的内核在路由表中为每一个路由项保存了一个exception哈希表,一个路由项Fib_info类似于下面的样子:
Fib_info {
  Address nexhop;
  Hash_list exception;
};
这个exception表的表项类似下面的样子:
Exception_entry {
  Match_info info;
  Address new_nexthop;
};
这样的话,当收到Reidrect路由的时候,会初始化一个Exception_entry记录并且插入到相应的exception哈希表,在查询路由的时候,比如说最终找到了一个Fib_info,在构建最终的dst_entry之前,要先用诸如源IP信息之类的Match_info去查找exception哈希表,如果找到一个匹配的Exception_entry,则不再使用Fib_info中的nexhop构建dst_entry,而是使用找到的Exception_entry中的new_nexthop来构建dst_entry。
    在对Redirect路由进行了简单的介绍之后,下面的篇幅将全部用于介绍路由与neighbour的关系。

重构对neighbour子系统的副作用

以下是网上摘录的关于在路由cache移除之后对neighbour的影响:
Neighbours
>Hold link-level nexthop information (for ARP, etc.)
>Routing cache pre-computed neighbours
>Remember: One “route” can refer to several nexthops
>Need to disconnect neighbours from route entries.
>Solution:
  Make neighbour lookups cheaper (faster hash, etc.)
  Compute neighbours at packet send time ...
  .. instead of using precomputed reference via route
>Most of work involved removing dependenies on old setup

事实上二者不该有关联的,路由子系统和neighbour子系统是两个处在上下不同层次的子系统,合理的方式是通过路由项的nexthop值来承上启下,通过一个唯一的neighbour查找接口关联即可:
dst_entry = 路由表查找(或者路由cache查找,通过skb的destination作键值)
nexthop = dst_entry.nexthop
neigh = neighbour表查找(通过nexthop作为键值)
然而Linux协议栈的实现却远远比这更复杂,这一切还得从3.5内核重构前开始说起。

重构前

在重构前,由于存在路由cache,凡是在cache中可以找到dst_entry的skb,便不用再查找路由表,路由cache存在的假设是,对于绝大多数的skb,都不需要查找路由表,理想情况下,都可以在路由cache中命中。对于neighbour而言,显而易见的做法是将neighbour和dst_entry做绑定,在cache中找到了dst_entry,也就一起找到了neighbour。也就是说,路由cache不仅仅缓存dst_entry,还缓存neighbour。
  事实上在3.5内核前,dst_entry结构体中有一个字段就是neighbour,表示与该路由项绑定的neighour,从路由cache中找到路由项后,直接取出neighbour就可以直接调用其output回调函数了。
  我们可以推导出dst_entry与neighbour的绑定时期,那就是查找路由表之后,即在路由cache未命中时,进而查找路由表完成后,将结果插入到路由cache之前,执行一个neighbour绑定的逻辑。
  和路由cache一样,neighbour子系统也维护着一张neighbour表,并执行着替换,更新,过期等状态操作,这个neighbour表和路由cache表之间存在着巨大的耦合,在描述这些耦合前,我们先看一下整体的逻辑:
func ip_output(skb):
        dst_entry = lookup_from_cache(skb.destination);
        if dst_entry == NULL
        then
                dst_entry = lookup_fib(skb.destination);
                nexthop = dst_entry.gateway?:skb.destination;
                neigh = lookup(neighbour_table, nexthop);
                if neigh == NULL
                then
                        neigh = create(neighbour_table, nexthop);
                        neighbour_add_timer(neigh);
                end
                dst_entry.neighbour = neigh;
                insert_into_route_cache(dst_entry);
        end
        neigh = dst_entry.neighbour;
        neigh.output(neigh, skb);
endfunc
---->TO Layer2
试看以下几个问题:
如果neighbour定时器执行时,某个neighbour过期了,可以删除吗?
如果路由cache定时器执行时,某条路由cache过期了,可以删除吗?

如果可以精确回答上述两个问题,便对路由子系统和neighbour子系统之间的关系足够了解了。我们先看第一个问题。
  如果删除了neighbour,由于此时与该neighbour绑定的路由cache项可能还在,那么在后续的skb匹配到该路由cache项时,便无法取出和使用neighbour,由于dst_entry和neighbour的绑定仅仅发生在路由cache未命中的时候,此时无法执行重新绑定,事实上,由于路由项和neighbour是一个多对一的关系,因此neighbour中无法反向引用路由cache项,通过dst_entry.neighbour引用的一个删除后的neighbour就是一个野指针从而引发oops最终内核panic。因此,显而易见的答案就是即便neighbour过期了,也不能删除,只能标记为无效,这个通过引用计数可以做到。现在看第二个问题。
  路由cache过期了,可以删除,但是要记得递减与该路由cache项绑定的neighbour的引用计数,如果它为0,把neighbour删除,这个neighbour就是第一个问题中在neighbour过期时无法删除的那类neighbour。由此我们可以看到,路由cache和neighbour之间的耦合关系导致与一个dst_entry绑定的neighbour的过期删除操作只能从路由cache项发起,除非一个neighbour没有同任何一个dst_entry绑定。现修改整体的发送逻辑如下:
func ip_output(skb):
        dst_entry = lookup_from_cache(skb.destination);
        if dst_entry == NULL
        then
                dst_entry = lookup_fib(skb.destination);
                nexthop = dst_entry.gateway?:skb.destination;
                neigh = lookup(neighbour_table, nexthop);
                if neigh == NULL
                then
                        neigh = create(neighbour_table, nexthop);
                        neighbour_add_timer(neigh);
                end
                inc(neigh.refcnt);
                dst_entry.neighbour = neigh;
                insert_into_route_cache(dst_entry);
        end
        neigh = dst_entry.neighbour;
        # 如果是INVALID状态的neigh,需要在output回调中处理
        neigh.output(neigh, skb);
endfunc
   
func neighbour_add_timer(neigh):
        inc(neigh.refcnt);
        neigh.timer.func = neighbour_timeout;
        timer_start(neigh.timer);
endfunc

func neighbour_timeout(neigh):
        cnt = dec(neigh.refcnt);
        if cnt == 0
        then
                free_neigh(neigh);
        else
                neigh.status = INVALID;
        end
endfunc

func dst_entry_timeout(dst_entry):
        neigh = dst_entry.neighbour;
        cnt = dec(neigh.refcnt);
        if cnt == 0
        then
                free_neigh(neigh);
        end
        free_dst(dst_entry);
endfunc
我们最后看看这会带来什么问题。
  如果neighbour表的gc参数和路由cache表的gc参数不同步,比如neighbour过快到期,而路由cache项到期的很慢,则会有很多的neighbour无法删除,造成neighbour表爆满,因此在这种情况下,需要强制回收路由cache,这是neighbour子系统反馈到路由子系统的一个耦合,这一切简直太乱了:
func create(neighbour_table, nexthop):
retry:
        neigh = alloc_neigh(nexthop);
        if neigh == NULL or neighbour_table.num > MAX
        then
                shrink_route_cache();
                retry;
        end
endfunc

关于路由cache的gc定时器与neighbour子系统的关系,有一篇写得很好的关于路由cache的文章《 Tuning Linux IPv4 route cache》 如下所述:
You may find documentation about those obsolete sysctl values:
net.ipv4.route.secret_interval has been removed in Linux 2.6.35; it was used to trigger an asynchronous flush at fixed interval to avoid to fill the cache.
net.ipv4.route.gc_interval has been removed in Linux 2.6.38. It is still present until Linux 3.2 but has no effect. It was used to trigger an asynchronous cleanup of the route cache. The garbage collector is now considered efficient enough for the job.
UPDATED: net.ipv4.route.gc_interval is back for Linux 3.2. It is still needed to avoid exhausting the neighbour cache because it allows to cleanup the cache periodically and not only above a given threshold. Keep it to its default value of 60.


这一切在3.5内核之后发生了改变!!

重构后

经过了重构,3.5以及此后的内核去除了对路由cache的支持,也就是说针对每一个数据包都要去查询路由表(暂不考虑在socket缓存dst_entry的情形),不存在路由cache也就意味着不需要处理cache的过期和替换问题,整个路由子系统成了一个完全无状态的系统,因此,dst_entry再也无需和neighbour绑定了,既然每次都要重新查找路由表开销也不大,每次查找少得多的neighbour表的开销更是可以忽略(虽然查表开销无法避免),因此dst_entry去除了neighbour字段,IP发送逻辑如下:
func ip_output(skb):
        dst_entry = lookup_fib(skb.destination);
        nexthop = dst_entry.gateway?:skb.destination;
        neigh = lookup(neighbour_table, nexthop);
        if neigh == NULL
        then    
                neigh = create(neighbour_table, nexthop);
        end
        neigh.output(skb);
endfunc
路由项不再和neighbour关联,因此neighbour表就可以独立执行过期操作了,neighbour表由于路由cache的gc过慢而导致频繁爆满的情况也就消失了。
  不光如此,代码看上去也清爽了很多。

一个细节:关于POINTOPOINT和LOOPBACK设备的neighbour

有很多讲述Linux neighbour子系统的资料,但是几乎无一例外都是在说ARP的,各种复杂的ARP协议操作,队列操作,状态机等,但是几乎没有描述ARP之外的关于neighbour的资料,因此本文在最后这个小节中准备补充关于这方面的一个例子。还是从问题开始:
一个NOARP的设备,比如POINTOPOINT设备发出的skb,其neighbour是谁?
在广播式以太网情况下,要发数据包到远端,需要解析“下一跳”地址,即每一个发出的数据包都要经由一个gateway发出去,这个gateway被抽象为一个同网段的IP地址,因此需要用ARP协议落实到确定的硬件地址。但是对于pointopoint设备而言,与该设备对连的只有固定的一个,它并没有一个广播或者多播的二层,因此也就没有gateway的概念了,或者换句话说,其下一跳就是目标IP地址本身。
  根据上述的ip_output函数来看,在查找neighbour表之前,使用的键值是nexthop,对于pointopoint设备而言,nexthop就是skb的目标地址本身,如果找不到将会以此为键值进行创建,那么试想使用pointopint设备发送的skb的目标地址空间十分海量的情况,将会有海量的neighbour在同一时间被创建,这些neighbour将会同时插入到neighbour表中,而这必然要遭遇到锁的问题,事实上,它们的插入操作将全部自旋在neighbour表读写锁的写锁上!!
  neigh_create的逻辑如下:
struct neighbour *neigh_create(struct neigh_table *tbl, const void *pkey,
                   struct net_device *dev)
{
    struct neighbour *n1, *rc, *n = neigh_alloc(tbl);
  ......
    write_lock_bh(&tbl->lock);
  // 插入hash表
    write_unlock_bh(&tbl->lock);
    .......
}
在海量目标IP的skb通过pointopoint设备发送的时候,这是一个完全避不开的瓶颈!然而内核没有这么傻。它采用了以下的方式进行了规避:
__be32 nexthop = ((struct rtable *)dst)->rt_gateway?:ip_hdr(skb)->daddr;
if (dev->flags&(IFF_LOOPBACK|IFF_POINTOPOINT))
  nexthop = 0;
这就意味着只要发送的pointopint设备相同,且伪二层(比如IPGRE的情况)信息相同,所有的skb将使用同一个neighbour,不管它们的目标地址是否相同。在IPIP Tunnel的情形下,由于这种设备没有任何的二层信息,这更是意味着所有的通过IPIP Tunnel设备的skb将使用一个单一的neighbour,即便是使用不同的IPIP Tunnel设备进行发送。
但是在3.5内核重构之后,悲剧了!
  我们直接看4.4的内核吧!
static inline __be32 rt_nexthop(const struct rtable *rt, __be32 daddr)
{
    if (rt->rt_gateway)
        return rt->rt_gateway;
    return daddr;
}
static int ip_finish_output2(struct net *net, struct sock *sk, struct sk_buff *skb)
{
  ......
    nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr);
    neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
    if (unlikely(!neigh))
        neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);
    if (!IS_ERR(neigh)) {
        int res = dst_neigh_output(dst, neigh, skb);
        return res;
    }
  ......
}
可以看到,dev->flags&(IFF_LOOPBACK|IFF_POINTOPOINT)这个判断消失了!这意味着内核变傻了。上一段中分析的那种现象在3.5之后的内核中将会发生,事实上也一定会发生。
  遭遇这个问题后,在没有详细看3.5之前的内核实现之前,我的想法是初始化一个全局的dummy neighbour,它就是简单的使用dev_queue_xmit进行direct out:
static const struct neigh_ops dummy_direct_ops = {
    .family =        AF_INET,
    .output =        neigh_direct_output,
    .connected_output =    neigh_direct_output,
};
struct neighbour dummy_neigh;
void dummy_neigh_init()
{
    memset(&dummy_neigh, 0, sizeof(dummy_neigh));
    dummy_neigh.nud_state = NUD_NOARP;
    dummy_neigh.ops = &dummy_direct_ops;
    dummy_neigh.output = neigh_direct_output;
    dummy_neigh.hh.hh_len = 0;
}

static inline int ip_finish_output2(struct sk_buff *skb)
 {
  ......
     nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr);
    if (dev->type == ARPHRD_TUNNEL) {
        neigh = &dummy_neigh;
    } else {
        neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
    }
     if (unlikely(!neigh))
         neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);
  ......
 }
后来看了3.5内核之前的实现,发现了:
if (dev->flags&(IFF_LOOPBACK|IFF_POINTOPOINT))
  nexthop = 0;
于是决定采用这个,代码更少也更优雅!然后就产生了下面的patch:
diff --git a/net/ipv4/ip_output.c b/net/ipv4/ip_output.c
--- a/net/ipv4/ip_output.c
+++ b/net/ipv4/ip_output.c
@@ -202,6 +202,8 @@ static int ip_finish_output2(struct net *net, struct sock *sk, struct sk_buff *s

        rcu_read_lock_bh();
        nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr);
+       if (dev->flags & (IFF_LOOPBACK | IFF_POINTOPOINT))
+               nexthop = 0;
        neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
        if (unlikely(!neigh))
                neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);


你可能感兴趣的:(linux内核)