Linux socket hash查找的持续优化历程

最新的Linux 4.17内核为TCP Listener socket的查找增加了一个2nd hash,这大大提升了协议栈收到连接建立请求包时查询Listener socket的效率。我们先从下面的汇总中管中窥豹:https://lkml.org/lkml/2018/1/31/589

4) Add a 2nd listener hash table for TCP, similar to what was done
for UDP. From Martin KaFai Lau.

然后,在接下来的篇幅里我们来看一下到底发生了什么以及是如何发生的。我会回顾Linux内核socket查找逻辑的发展史,当然全面探讨实现细节并不是本文的目的,但理解实现细节在版本升级过程中的关键转变,以及这种转换背后的动机,对我们理解一个设计以及亲自完成一个高性能的设计方案至关重要。

声明一点,本文不是一个Changelog汇总,只是在我工作中接触到的base内核进行分析,其中包括2.6.8,2.6.32,3.10,4.7,4,14等,主要关注UDP和TCP Listener的socket查找。用这些版本的源代码分析举例并不意味着所描述的关键转变就是在这些版本中发生的,只是说我手头拥有这些版本的源码,方便而已。

让我们先从2.6.8内核版本的内核说起一直到如今的4.17内核版本,以此2.6.8内核版本为base,看看事情是如何悄悄地起变化的。


2.6.8版本内核的全局rwlock

这个版本是故事的起始,这也是我毕业后接触的第一个Linux内核版本,所以多占一些篇幅,至少要把问题描述清楚。

如果一个UDP包被传输层HOOK接收,即udp_rcv接收,内核是如何将该数据包对应到一个可以处理它的socket的呢?答案显然是基于它的五元组信息来查找对应的socket。

然而,UDP是无连接的,在实际收到UDP报文之前,接收端并不能预测UDP报文来自何方,因此接收端的UDP socket只能按照其bind的端口来进行hash,查找的过程中为了保护这个hash表不被更改,显然需要一把rwlock来保护,查找过程如下:

__inline__ struct sock *udp_v4_lookup(u32 saddr, u16 sport, u32 daddr, u16 dport, int dif)
{
    struct sock *sk;

    read_lock(&udp_hash_lock); // 注意!这是一把全局的读写锁!
    sk = udp_v4_lookup_longway(saddr, sport, daddr, dport, dif);
    if (sk)
        sock_hold(sk);
    read_unlock(&udp_hash_lock);
    return sk;
}

其中udp_v4_lookup_longway内部会按照目标端口的hash定位一条hash链,然后遍历查找之,逻辑如下:

// 一共才128个buckets!!
#define UDP_HTABLE_SIZE     128
unsigned short hnum = ntohs(skb.dport);
sk_for_each(sk, node, &udp_hash[hnum & (UDP_HTABLE_SIZE - 1)]) {
    if(MATCH(...))
        return sk;
}

这个逻辑很OK,没有任何问题,一般人都能写出这样的逻辑。但是主要一个细节请注意:为什么要加rwlock??

我们知道,这把锁肯定是后加上去的,一开始的时候并没有多个CPU,后来有了多核SMP的概念后也没有内核抢占,再后来为了降低调度的响应延迟增加了内核抢占….所以说,几乎所有的问题都是多核惹的祸,短平快的方案当然就是先增加一把全局大锁,保证系统的可用性和结果的正确性,至于优化,永远都是最后面的事情。在Linux内核领域,根本就不存在什么多核编程,所有与SMP相关的东西都是某种不得已的权衡手段,Linux内核一开始并不是为SMP设计的。

加入这个全局的udp_hash_lock之后,在SMP情况下,如果此时有写者在写socket hash表,那么所有的CPU执行在这里其执行流都将完全被串行化!如果查找过程在udp_hash_lock保护的区间耽搁太久,整个系统的吞吐将会被严重拉低!这就是很大的问题。幸亏这是一把rwlock而不是spinlock!

此外,UDP的全局hash bucket有128个slot,这足够吗?也许你担心仅仅按照bind的端口来hash的话,最终bind同一个端口的socket冲突链会不会太长,最蹩脚的优化(因为你很难看到什么方案是一开始设计的时候就考虑优化的,对于安全也是一样!)总是从问题开始,见招拆招,考虑以下的两个因素:

  • 那个时候内核不支持reuseport,因此你无法让多个socket绑定同一个IP和端口;
  • 如果非要在同一个端口提供服务,必须绑定不同的IP地址。

上面的第一点直接催生了reuseport的实现,第二点则直接催生了2nd hash,和原始的按端口hash不同的是,2nd hash按照绑定的IP地址和端口对一起hash。这就是后面版本的优化措施。结束2.6.8版本的分析之前,再看一下TCP的情况。

我们来看TCP,TCP在连接建立之前也是无连接的。我们知道,TCP socket分为两种,一种是Listener socket,另一种是Established socket,对于Established socket好说,完全可以用全部的五元组信息将每一个已经建立的连接hash到足够均匀的bucket中,五元组全部参与hash运算可以提供更多的hash随机因素,然而对于Listener socket,它并不知道谁会过来建立连接,事实上,任何人都可以用任何IP和端口前来建立连接,所以对于Listener socket而言,它和UDP的处理一样,只能按照bind的端口来hash,查找期间用全局锁保护:

// 注意,才32个buckets,有点糟糕!
#define TCP_LHTABLE_SIZE    32  /* Yes, really, this is all you need. */
inline struct sock *tcp_v4_lookup_listener(u32 daddr, unsigned short hnum,
                       int dif)
{
    struct sock *sk = NULL;
    struct hlist_head *head;

    read_lock(&tcp_lhash_lock); // 全局读写锁
    head = &tcp_listening_hash[num & (TCP_LHTABLE_SIZE - 1)];
    if (!hlist_empty(head)) {
        struct inet_opt *inet = inet_sk((sk = __sk_head(head)));
        // 这里的逻辑是一个trick...!!!
        if (inet->num == hnum && !sk->sk_node.next &&
            (!inet->rcv_saddr || inet->rcv_saddr == daddr) &&
            (sk->sk_family == PF_INET || !ipv6_only_sock(sk)) &&
            !sk->sk_bound_dev_if)
            goto sherry_cache;
        sk = __tcp_v4_lookup_listener(head, daddr, hnum, dif);
    }
    if (sk) {
sherry_cache:
        sock_hold(sk);
    }
    read_unlock(&tcp_lhash_lock);
    return sk;
}

比UDP更加糟糕,TCP Listener socket只有32个hash buckets…面临的问题和UDP同样,但是更加严重,然而,问题是,问题是,TCP此后关于这方面的优化总是慢UDP一步甚至几步(1.reuseport首先在UDP实现,然后才是TCP;2.UDP实现了2nd hash很多年后,TCP才支持;3.UDP的Quic协议首先实验了BBR后,才移到TCP…),上述UDP的那两个问题并没有催生TCP进行同样方向的优化,我们现在知道,直到4.16/4.17的内核,TCP才引入了2nd hash,而在此之前,很多人都已经意识到了问题的所在却都没有给出炫酷的解决方案。给出一个链接请参考:
The revenge of the listening socketshttps://blog.cloudflare.com/revenge-listening-sockets/

小结

2.6.18版本,base版本,大rwlock

2.6.32版本内核的slot spinlock

2.6.8版本之后,socket查找逻辑被优化了,以2.6.32内核为例,主要表现为以下两个方面:

1.两把全局的rwlock锁被拆分了
  • udp_hash_lock
    rwlock不再保护全局的执行流,而是保护slot:
struct udp_hslot {
    struct hlist_nulls_head head;
    spinlock_t      lock; // rwlock换成了spinlock,why,因为读操作改为rcu了
} __attribute__((aligned(2 * sizeof(long))));
  • tcp_lhash_lock
    和UDP的情况类似:
    struct inet_listen_hashbucket {
    spinlock_t      lock;
    struct hlist_nulls_head head;
    };
2. 查找操作用RCU替换rwlock

这是个创举,因为本身这种socket操作就满足以下的两个特征:
1. 读多写少
2. 读写需要并行
如果仅仅是读多写少,那么可以用读写锁,然而还有读写并行的特征,那么RCU锁便是一个不二之选了,我们知道RCU锁的特点就是读写互不阻塞。以往的rwlock在有写者的时候,会阻塞读者。

因此逻辑成了下面的样子,本节仅仅以UDP举例:

static struct sock *__udp4_lib_lookup(struct net *net, __be32 saddr,
        __be16 sport, __be32 daddr, __be16 dport,
        int dif, struct udp_table *udptable)
{
    struct sock *sk, *result;
    struct hlist_nulls_node *node;
    unsigned short hnum = ntohs(dport);
    unsigned int hash = udp_hashfn(net, hnum);
    struct udp_hslot *hslot = &udptable->hash[hash];
    int score, badness;

    rcu_read_lock(); // 这里进入RCU read section
begin:
    result = NULL;
    badness = -1;
    sk_nulls_for_each_rcu(sk, node, &hslot->head) {
        score = compute_score(sk, net, saddr, hnum, sport,
                      daddr, dport, dif);
        if (score > badness) {
            result = sk;
            badness = score;
        }
    }
    ... // null list之前分析过,这里不相关,不赘述,详见后面关于4.7内核的小节
    if (result) {
        // 强引用计数检查,因为有RCU的操作,若rwlock则不必
        if (unlikely(!atomic_inc_not_zero(&result->sk_refcnt)))
            result = NULL;
        else if (unlikely(compute_score(result, net, saddr, hnum, sport,
                  daddr, dport, dif) < badness)) {
            sock_put(result);
            goto begin;
        }
    }
    rcu_read_unlock();
    return result;
}

RCU代替rwlock后,slot的读写便对称了,因此rwlock就换成了spinlock。每一个hash lost的spinlock仅仅作为该slot的插入和删除保护之用,具体可以参见udp_lib_get_port,和udp_lib_unhash,以后者为例,看下代码:

void udp_lib_unhash(struct sock *sk)
{
    if (sk_hashed(sk)) {
        struct udp_table *udptable = sk->sk_prot->h.udp_table;
        unsigned int hash = udp_hashfn(sock_net(sk), sk->sk_hash);
        struct udp_hslot *hslot = &udptable->hash[hash];
        // lock住slot
        spin_lock_bh(&hslot->lock);
        if (sk_nulls_del_node_init_rcu(sk)) {
            inet_sk(sk)->num = 0;
            sock_prot_inuse_add(sock_net(sk), sk->sk_prot, -1);
        }
        spin_unlock_bh(&hslot->lock);
    }
}

使用了RCU之后,链表的断开必须用强序的RCU API,元素的删除必须用call_rcu API,这是读写并行所要求的。相反,2.6.8的rwlock版本代码超级简单,但也很粗暴。

小结

2.6.32版本,rwlock拆分到slot spinlock,RCU代替rwlock。

3.10版本内核的UDP 2nd hash以及reuseport

继续进化!

注意在分析2.6.8内核时的一个问题:

  • 如果非要在同一个端口提供服务,必须绑定不同的IP地址。

如果服务器需要多个进程提供服务,除了dup socket fd之外,还可以bind不同的IP地址,进化到3.10的时候,还有一个reuseport特性可用,在3.10之前,远未实现reuseport的版本,一个UDP 2nd hash的优化patch放了出来:http://lists.openwall.net/netdev/2009/11/08/34

This patch series address UDP scalability problems, we failed to solve in 2007
(commit 6aaf47fa48d3c44 INET : IPV4 UDP lookups converted to a 2 pass algo)
we had to revert a bit later.

One of the problem of UDP is its use of a single hash table, with
a key based on local port value only. When many IP addresses are used,
it is possible to have a chain with very large number N of sockets,
lookup time being N/2 in average.

Size of hash table has no effect on this, since all sockets are
chained in one particular slot.

It seems Lucian Adrian Grijincu & Octavian Purdila from IXIACOM have
real workloads hitting hard this problem and posted a preliminary
patch/RFC, using a second hash, but based on (local address).

I took part of Lucian ideas and my previous patches, and cooked
a clean upgrade path.

With following patches, we might handle 1.000.000+ udp sockets
in linux without major slowdown, and no penalty for common cases.

这也是一个创举,在reuseport之前的一个创举,充分利用了绑定不同IP地址的socket的IP地址在hash计算中的影响。查找逻辑变成了下面的样子:

    rcu_read_lock();
    // 只有在slot中需要遍历的socket过多的时候才会启用2nd hash查找
    if (hslot->count > 10) {
        // 除了本地端口,将IP地址也加入了hash运算
        hash2 = udp4_portaddr_hash(net, daddr, hnum);
        slot2 = hash2 & udptable->mask;
        hslot2 = &udptable->hash2[slot2];
        // 再次确认2nd hash的冲突链比仅port hash的冲突链短!
        if (hslot->count < hslot2->count)
            goto begin;
        // 在2nd hash计算结果的slot里面查找
        result = udp4_lib_lookup2(net, saddr, sport,
                      daddr, hnum, dif,
                      hslot2, slot2);
        if (!result) {
            // 如果没有找到,则地址退化到0.0.0.0,继续
            hash2 = udp4_portaddr_hash(net, htonl(INADDR_ANY), hnum);
            slot2 = hash2 & udptable->mask;
            hslot2 = &udptable->hash2[slot2];
            if (hslot->count < hslot2->count)
                goto begin;

            result = udp4_lib_lookup2(net, saddr, sport,
                          htonl(INADDR_ANY), hnum, dif,
                          hslot2, slot2);
        }
        rcu_read_unlock();
        return result;
    }
begin:
    result = NULL;
    badness = 0;

这意味着,一个socket在执行bind的时候,不但要将其按照传统方式使用port作为hash加入到1st hash slot中,还要加入IP地址再算个hash,加入到2nd hash slot中,如果没有bind IP地址,就用INADDR_ANY来计算。


上面的优化措施中可见精细化控制是多么必要,然而事情的进展并不如想象的那样顺利,主要表现在:

  • 上述的优化并没有在TCP Listener socket的查找中实现!
  • 上述的优化效果被4.9内核新引入的reuseport所弱化!

先不说第一点关于TCP的,因为迟到的总会到来。重点看下reuseport的影响。

reuseport绝对是创举中的创举,将负载均衡模式引入到了socket,使得多个socket绑定同一个IP地址/端口对成为可能,这样系统就更加scalable了,关于reuseport这里不多说,重点看它的影响。

不管是RCU代替rwlock还是引入2nd hash(short hash),其最终目的都是缩短执行时间,不影响系统其它执行流增加可扩展性,特别是2nd hash的引入,用IP地址元素增加hash的随机因子,从而让IP端口对的hash结果分布更加均匀,这是好事,目标是美好的。然而reuseport打破了美梦。

reuseport可以让千百个socket绑定同一个IP端口对,这意味着即便是将IP地址引入到hash的计算,其计算结果最终还是会落入到同一个slot!典型的刷墙的把屋拆了的效果!

    sk_nulls_for_each_rcu(sk, node, &hslot->head) {
        score = compute_score(sk, net, saddr, hnum, sport,
                      daddr, dport, dif);
        if (score > badness) {
            result = sk;
            badness = score;
            reuseport = sk->sk_reuseport;
            if (reuseport) {
                hash = inet_ehashfn(net, daddr, hnum,
                            saddr, htons(sport));
                matches = 1;
            }
        } else if (score == badness && reuseport) {
            // 撸一遍就要不断地重复计算,试想1000个reuseport socket的情景!将reuseport收集起来,来一个一次性取模不更好??
            matches++;
            if (((u64)hash * matches) >> 32 == 0)
                result = sk;
            hash = next_pseudo_random32(hash);
        }
    }

怎么办?

我在往2.6.32上移植reuseport特性的时候,注意到了这个问题,所以我自己实现了一套reuseport。我当时是把所有reuseport的socket单独集合成一个挂在slot节点元素下面的链表,只要找到一个,只需要处理表头即可,这样如果你有1000个reuseport和4个非reuseport的socket被hash到了同一个slot,那么最终需要遍历的只有1+4=5个socket!

过了大约三四年,我在4.6内核里发现了类似的代码。

小结

3.10版本,引入2nd hash计算,引入reuseport

4.7版本内核的关键转变点

4.7版本内核对socket查找的优化可谓是短小精悍,去年的时候,我写过一篇文章专门谈这个,可以直接超链接过去:
Linux 4.7内核针对syncookie性能所做的优化https://blog.csdn.net/dog250/article/details/73013732
这篇文章里谈到了null hash node和RCU如何取代原子判断的,代码看起来更加整洁和清爽。

小结

4.7版本,为TCP Listener socket引入SOCK_RCU_FREE标记,以call_rcu的方式取消了对引用计数的原子判断。

4.14版本内核的new reuseport

其实在4.6版本内核就改了,所谓的new reuseport,参见:
关于Linux UDP/TCP reuseport 二三事https://blog.csdn.net/dog250/article/details/80458669
以及其中给出的其它链接。这里不再赘述。

简而言之,4.6持续到现在内核版本的reuseport实现中,将之前reuseport对2nd hash的拆台效果去除了,即便你有10000个reuseport socket,也不会影响hash slot的遍历时间。如果我们的hash算法足够好,假设是完美hash,O(1)的时间复杂度,那么3.9,3.10的reuseport实现将socket查找的这个O(1)提升到了O(n),到了4.6之后,reuseport的实现将socket查找的时间复杂度又降到了O(1)。

小结

优化reuseport到O(1)

4.17版本内核的TCP 2nd listener hash

迟到的总会到来的。

4.17版本迎来了TCP Listener socket查找的2nd hash计算:

struct sock *__inet_lookup_listener(struct net *net,
                    struct inet_hashinfo *hashinfo,
                    struct sk_buff *skb, int doff,
                    const __be32 saddr, __be16 sport,
                    const __be32 daddr, const unsigned short hnum,
                    const int dif, const int sdif)
{
    unsigned int hash = inet_lhashfn(net, hnum);
    ...

    if (ilb->count <= 10 || !hashinfo->lhash2)
        goto port_lookup;

    /* Too many sk in the ilb bucket (which is hashed by port alone).
     * Try lhash2 (which is hashed by port and addr) instead.
     */

    hash2 = ipv4_portaddr_hash(net, daddr, hnum);
    ilb2 = inet_lhash2_bucket(hashinfo, hash2);
    if (ilb2->count > ilb->count)
        goto port_lookup;

    result = inet_lhash2_lookup(net, ilb2, skb, doff,
                    saddr, sport, daddr, hnum,
                    dif, sdif);
    if (result)
        return result;

    /* Lookup lhash2 with INADDR_ANY */

    hash2 = ipv4_portaddr_hash(net, htonl(INADDR_ANY), hnum);
    ilb2 = inet_lhash2_bucket(hashinfo, hash2);
    if (ilb2->count > ilb->count)
        goto port_lookup;

    return inet_lhash2_lookup(net, ilb2, skb, doff,
                  saddr, sport, daddr, hnum,
                  dif, sdif);

port_lookup: // 原始的仅用port做hash计算的逻辑。
    ...
}

注意,即便是到了4.17,INET_LHTABLE_SIZE的定义,依然如下:

#define INET_LHTABLE_SIZE   32

小结

为TCP Listener socket引入2nd hash计算。

总结

TCP Listener socket和UDP socket由于不知谁会从哪个接口来访,其hash计算的最深度层次也就是限于IP地址和端口二元组,对于UDP而言,其实还可以深度解析应用层数据包的内容来进行hash,类似我在OpenVPN的SID优化那里做的那样,但是对于TCP而言,由于Fastopen这种目前也并不是很普及,很难在第一个syn包中拿到可以参与连接hash计算的持久化信息。

然后reuseport socket却可以一锅端,不管多少个,都可以将其独立出去,然后作为一个整体参与socket的查找。

so,现如今,迄至4.17,不管是reuseport socket还是非reuseport socket,不管是TCP Listener socket还是UDP socket,超级多的socket被hash到同一个slot的情况,很难重现了!从2.6.8到4.17,昔日的问题已经不再是问题,不知情的就不要乱说了。

此外,从本文描述的这个socket查找优化进化场景来看,有一种范式值得一提,即一条关于锁的优化范式,一般而言都会有以下的过程:

  1. 一开始上来一个spinlock保证可用;
  2. 接着分析读写对称程度用rwlock替换spinlock;
  3. 然后将rwlock分拆成读和写两侧面;
  4. 读侧用RCU替换rwlock;
  5. 写侧用spinlock保护,并用RCU API进行实际写操作;
  6. 进一步差分细化spinlock的粒度,比如per cpu化。

可以明确看到的是Netfilter的nf_conntrack(together with NAT)逻辑中涉及到的各种spinlock,RCU就遵循了这个范式来持续进化。

后记

这两周选择睡在客厅的地上,也就没有了蚊帐的遮挡,于是就更不需要睡觉了,于是时间也就更加充裕,上班时间足足提前了将近一个小时,大概六点半就能到公司,当然了,下班时间也提前了半个小时,有点尴尬了…

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值