Linux 4.6内核对TCP REUSEPORT的优化

繁忙了一整天,下班回家总会有些许轻松,这是肯定的。时间不等人,只要有剩余的时间,就想来点自己喜欢的东西。下班的班车上,用手机那令人遗憾的屏幕目睹了Linux 4.6的一些新特性,让我感兴趣的有两点,第一是关于reuseport的,这也是本文要阐释的,另外一个是关于KCM(Kernel Connection Multiplexor)的,而这个是我本周末计划要写的内容,这些都是回忆,且都是我本身经历过的,正巧天气预报说今晚有暴雨,激起了一些兴趣,于是信手拈来,不足之处或者写的不明之处,还望有人可以指出。我想从Q&A说起,这也符合大众的预期,如果你真的能理解我的Q&A想说什么,那么Q&A之后接下来的内容,不看也罢。

Q&A

当有人问起我关于reuseport的一些事的时候,我们的对话基本如下:
Q1:什么是reuseport?
A1:reuseport是一种套接字复用机制,它允许你将多个套接字bind在同一个IP地址/端口对上,这样一来,就可以建立多个服务来接受到同一个端口的连接。

Q2:当来了一个连接时,系统怎么决定到底是哪个套接字来处理它?
A2:对于不同的内核,处理机制是不一样的,总的说来,reuseport分为两种模式,即热备份模式和负载均衡模式,在早期的内核版本中,即便是加入对reuseport选项的支持,也仅仅为热备份模式,而在3.9内核之后,则全部改为了负载均衡模式,两种模式没有共存,虽然我一直都希望它们可以共存。

【我并没有进一步说什么是热备份模式和负载均衡模式,这意味着我在等待提问者进一步发问】

Q3:什么是热备份模式和负载均衡模式呢?
A3:这个我来分别解释一下。
热备份模式:即你创建了N个reuseport的套接字,然而工作的只有一个,其它的作为备份,只有当前一个套接字不再可用的时候,才会由后一个来取代,其投入工作的顺序取决于实现。
负载均衡模式:即你创建的所有N个reuseport的套接字均可以同时工作,当连接到来的时候,系统会取一个套接字来处理它。这样就可以达到负载均衡的目的,降低某一个服务的压力。

Q4:到底怎么取套接字呢?
A4:这个对于热备份模式和负载均衡模式是不同的。
热备份模式:一般而言,会将所有的reuseport同一个IP地址/端口的套接字挂在一个链表上,取第一个即可,如果该套接字挂了,它会被从链表删除,然后第二个便会成为第一个。
负载均衡模式:和热备份模式一样,所有reuseport同一个IP地址/端口的套接字会挂在一个链表上,你也可以认为是一个数组,这样会更加方便,当有连接到来时,用数据包的源IP/源端口作为一个HASH函数的输入,将结果对reuseport套接字数量取模,得到一个索引,该索引指示的数组位置对应的套接字便是工作套接字。

Q5:那么会不会第一个数据包由套接字m处理,后续来的数据包由套接字n处理呢?
A5:这个问题其实很容易,仔细看一下算法就会发现,只要这些数据包属于同一个流(同一个五元组),那么它每次HASH的结果将会得到同一个索引,因此处理它的套接字始终是同一个!

Q6:我怎么觉得有点玄呢?【这最后一个问题是我在一个同事的启发下自问自答的...】
A6:确实玄!TCP自己会保持连接,我们暂且不谈。对于UDP而言,比如一个事务中需要交互4个数据包,第一个数据包的元组HASH结果索引到了线程1的套接字的问题,它理所当然被线程1处理,在第二个数据包到达之前,线程1挂了,那么该线程的套接字的位置将会被别的线程,比如线程2的套接字取代!在第二个数据包到达的时候,将会由线程2的套接字来处理之,然而线程2并不知道线程1保存的关于此连接的事务状态...

关于REUSEPORT的实现

以上基本就是关于reuseport的问答,其实还可以引申出更多的有趣的问题和答案,强烈建议这个作为面试题存在。
        我们来看下Q6/A6,这个问题确实存在,但是却不是什么大问题,这个是和实现相关的,所以并不是reuseport机制本身的问题。我们完全可以用数组代替链表,并且维持数组的大小,即使线程n挂了,它的套接字所在的位置并不被已经存在的套接字占据,而必须是被新建的替补线程(毕竟线程n已经挂了,要新建一个补充)的套接字占据,这样就能解决问题,所需的仅仅是任何线程在挂之前把状态信息序列化,然后在新线程启动的时候重新反序列化信息即可。

到此为止,我没有提及任何关于Linux 4.6内核对TCP reuseport优化的任何信息,典型的标题党!
        然而,以上几乎就是全部信息,如果说还有别的,那就只能贴代码了。事实上,在Linux 4.6(针对TCP)以及Linux 4.5(针对UDP)的优化之前,我上述的Answer是不准确的,在4.5之前,Linux内核中关于reuseport的实现并非我想象的那样,然而为了解释概念和机制,我不得不用上述更加容易理解的方式去阐述,原理是一回事,实现又是一回事,请原谅我一直以来针对原理的阐述与Linux实现并不相符。
        可想而知,4.5/4.6的所谓reuseport的优化,它仅仅是一种更加自然的实现方式罢了,相反,之前的实现反而并不自然!回忆三年来,有多少人问过我关于reuseport的事情,其中也不乏几位面试官,如果被进一步追问“Q:你确定Linux就是这么实现的吗?”那么我一定回答:“不!不是这么实现的,Linux的实现方法很垃圾!”,然后就会听到我滔滔不绝的阐释大量的形而上的东西,最终在一种不那么缓和的气氛中终止掉对话。

总结一下吧,事实上Linux 4.5/4.6所谓的对reuseport的优化主要体现在查询速度上,在优化前,不得不在HASH冲突链表上遍历所有的套接字之后才能知道到底取哪个(基于一种冒泡的score打分机制,不完成一轮冒泡遍历,不能确定谁的score最高),之所以如此低效是因为内核将reuseport的所有套接字和其它套接字混合在了一起,查找是平坦的,正常的做法应该是将它们分为一个组,进行分层查找,先找到这个组(这个很容易),然后再在组中找具体的套接字。Linux 4.5针对UDP做了上述优化,而Linux 4.6则将这个优化引入到了TCP。

设想系统中一共有10000个套接字被HASH到同一个冲突链表,其中9950个是reuseport的同一组套接字,如果按照老的算法,需要遍历10000个套接字,如果使用基于分组的算法,最多只需要遍历51个套接字即可,找到那个组之后,一步HASH就可以找到目标套接字的索引!

Linux 4.5之前的reuseport查找实现(4.3内核)

以下是未优化前的Linux 4.3内核的实现,可见是多么地不直观。它采用了遍历HASH冲突链表的方式进行reuseport套接字的精确定位:

    result = NULL;
    badness = 0;
    udp_portaddr_for_each_entry_rcu(sk, node, &hslot2->head) {
        score = compute_score2(sk, net, saddr, sport,
                      daddr, hnum, dif);
        if (score > badness) { // 冒泡排序
            // 找到了更加合适的socket,需要重新hash
            result = sk;
            badness = score;
            reuseport = sk->sk_reuseport;
            if (reuseport) {
                hash = udp_ehashfn(net, daddr, hnum,
                           saddr, sport);
                matches = 1;
            }
        } else if (score == badness && reuseport) { // reuseport套接字散列定位
            // 找到了同样reuseport的socket,进行定位
            matches++;
            if (reciprocal_scale(hash, matches) == 0)
                result = sk;
            hash = next_pseudo_random32(hash);
        }
    }

之所以要遍历是因为所有的reuseport套接字和其它的套接字都被平坦地插入到同一个表中,事先并不知道有多少组reuseport套接字以及每一组中有多少个套接字,比如下列例子:
reuseport group1-0.0.0.0:1234(sk1,sk2,sk3,sk4)
reuseport group2-1.1.1.1:1234(sk5,sk6,sk7)
other socket(sk8,sk9,sk10,sk11)

假设它们均被HASH到同一个位置,那么可能的顺序如下:
sk10-sk2-sk3-sk8-sk5-sk7-...
虽然sk2就已经匹配了,然而后面还有更精确的sk5,这就意味着必须把11个套接字全部遍历完后才知道谁会冒泡到最上面。

Linux 4.5(针对UDP)/4.6(针对TCP)的reuseport查找实现

我们来看看在4.5和4.6内核中对于reuseport的查找增加了一些什么神奇的新东西:

    result = NULL;
    badness = 0;
    udp_portaddr_for_each_entry_rcu(sk, node, &hslot2->head) {
        score = compute_score2(sk, net, saddr, sport,
                      daddr, hnum, dif);
        if (score > badness) {
            // 在reuseport情形下,意味着找到了更加合适的socket组,需要重新hash
            result = sk;
            badness = score;
            reuseport = sk->sk_reuseport;
            if (reuseport) {
                hash = udp_ehashfn(net, daddr, hnum,
                           saddr, sport);
                if (select_ok) {
                    struct sock *sk2;
                    // 找到了一个组,接着进行组内hash。
                    sk2 = reuseport_select_sock(sk, hash, skb,
                            sizeof(struct udphdr));
                    if (sk2) {
                        result = sk2;
                        select_ok = false;
                        goto found;
                    }
                }
                matches = 1;
            }
        } else if (score == badness && reuseport) {
        // 这个else if分支的期待是,在分层查找不适用的时候,寻找更加匹配的reuseport组,注意4.5/4.6以后直接寻找的是一个reuseport组。
        // 在某种意义上,这回退到了4.5之前的算法。
            matches++;
            if (reciprocal_scale(hash, matches) == 0)
                result = sk;
            hash = next_pseudo_random32(hash);
        }
    }

我们着重看一下reuseport_select_sock,这个函数是第二层组内查找的关键,其实不应该叫做查找,而应该叫做定位更加合适:

struct sock *reuseport_select_sock(struct sock *sk,
                   u32 hash,
                   struct sk_buff *skb,
                   int hdr_len)
{
    ...
    prog = rcu_dereference(reuse->prog);
    socks = READ_ONCE(reuse->num_socks);
    if (likely(socks)) {
        /* paired with smp_wmb() in reuseport_add_sock() */
        smp_rmb();

        if (prog && skb) // 可以用BPF来从用户态注入自己的定位逻辑,更好实现基于策略的负载均衡
            sk2 = run_bpf(reuse, socks, prog, skb, hdr_len);
        else
            // reciprocal_scale简单地将结果限制在了[0,socks)这个区间内
            sk2 = reuse->socks[reciprocal_scale(hash, socks)];
    }
    ...
}

也不是那么神奇,不是吗?基本上在Q&A中都已经涵盖了。

我自己的reuseport查找实现

当年看到了google的这个idea之后,Linux内核还没有内置这个实现,我当时正在基于2.6.32内核搞一个关于OpenVPN的多处理优化,而且使用的是UDP,在亲历了折腾UDP多进程令人绝望的失败后,我移植了google的reuseport补丁,然而它的实现更加令人绝望,多么奇妙且简单(奇妙的东西一定要简单!)的一个idea,怎么可能实现成了这个样子(事实上这个样子一直持续到了4.5版本的内核)??

因为我可以确定系统中不会有任何其它的reuseport套接字,且我可以确定设备的CPU个数是16个,因此定义数组如下:

#define MAX        18
struct sock *reusesk[MAX];

每当OpenVPN创建一个reuseport的UDP套接字的时候,我会将其顺序加入到reusesk数组中去,最终的查找算法修改如下:

    result = NULL;
    badness = 0;
    udp_portaddr_for_each_entry_rcu(sk, node, &hslot2->head) {
        score = compute_score2(sk, net, saddr, sport,
                      daddr, hnum, dif);
        if (score > badness) {
            result = sk;
            badness = score;
            reuseport = sk->sk_reuseport;
            if (reuseport) {
                hash = inet_ehashfn(net, daddr, hnum,
                            saddr, htons(sport));
#ifdef EXTENSION
                // 直接取索引指示的套接字
                result = reusesk[hash%MAX];
                // 如果只有一组reuseport的套接字,则直接返回,否则回退到原始逻辑
                if (num_reuse == 1)
                    break;
#endif
                matches = 1;
            }
        } else if (score == badness && reuseport) {
            matches++;
            if (((u64)hash * matches) >> 32 == 0)
                result = sk;
            hash = next_pseudo_random32(hash);
        }
    }

非常简单的修改。除此之外,每当有套接字被销毁,除了将其数组对应的索引位设置为NULL之外,对其它索引为的元素没有任何影响,后续有新的套接字被创建的时候,只需要找到一个元素为NULL的位置加进去就好了,这就解决了由于套接字位置变动造成数据包被定向到错误的套接字问题(因为索引指示的位置元素已经由于移动位置而变化了)。这个问题的影响有时是剧烈的,比如后续所有的套接字全部向前移动,将影响多个套接字,有时影响又是轻微的,比如用最后一个套接字填补设置为NULL的位置,令人遗憾的是,即使是4.6的内核,其采用的也是上述后一种方式,即末尾填充法,虽然只是移动一个套接字,但问题依然存在。幸运的是,4.6内核的reuseport支持BPF,这意味着你可以在用户态自己写代码去控制套接字的选择,并可以实时注入到内核的reuseport选择逻辑中。

Q7&A7,Q8&A8

最后一个问题
Q7:有没有什么统一的方法可以应对reuseport套接字的新增和删除呢?比如新建一个工作负载线程,一个工作线程挂掉这种动态行为。
A7:有的。那就是“一致性HASH”算法。

首先,我们可以将一个reuseport套接字组中所有的套接字按照其对应的PID以及内存地址之类的唯一标识,HASH到以下16bits的线性空间中(其中第一个套接字占据端点位置),如下图所示:

这样N个socket就将该线性空间分割成了N个区间,我们把这个HASH的过程称为第一类HASH!接下来,当一个数据包到达时,如何将其对应到某一个套接字呢?此时要进行第二类hash运算,这个运算的对象是数据包携带的源IP/源端口对,HASH的结果对应到前述16bits的线性空间中,如下图所示:

我们将第二类HASH值左边的第一个第一类HASH值对应的套接字作为被选定的套接字,如下图所示:

可以很容易看出来,如果第一类HASH值的节点被删除或者新添加(意味着套接字的销毁和新建),受到影响的仅仅是该节点与其右边的第一个第一类HASH节点之间的第二类HASH节点,如下图所示:

这就是简化版的“一致性HASH”的原理。如果想在新建,销毁的时候,一点都不受影响,那就别折腾这些算法了,还是老老实实搞数组吧。
        理解了原理之后,我们看一下怎么实现这个mini一致性HASH。真正的一致性HASH实现起来太重了,网上也有很多的资料可查,我这里只是给出一个思路,谈不上最优。根据上图,我们可以看到,归根结底需要一个“区间查找”,也就是说最终需要做的就是“判定第二类HASH结果落在了由第一类HASH结果分割的哪个区间内”,因此在直观上,可以采用的就是二叉树区间匹配,在此,我把上述的区间分割整理成一颗二叉树:

日耳曼向左,罗马向右!接下来就可以在这个二叉树上进行二分查找了。

Q8:在reuseport的查找处理上,TCP和UDP的区别是什么?
A8:TCP的每一条连接均可以由完全的五元组信息自行维护一个唯一的标识,只需要按照唯一的五元组信息就可以找出一个TCP连接,但是对于Listen状态的TCP套接字就不同了,一个来自客户端的SYN到达时,五元组信息尚未确立,此时正是需要找出是reuseport套接字组中到底哪个套接字来处理这个SYN的时候。待这个套接字确定以后,就可以和发送SYN的客户端建立唯一的五元组标识了,因此对于TCP而言,只有Listen状态的套接字需要reuseport机制的支持。对于UDP而言,则所有的套接字均需要reuseport机制的支持,因为UDP不会维护任何连接信息,也就是说,协议栈不会记录哪个客户端曾经来过或者正在与之通信,没有这些信息,因此对于每一个数据包,均需要reuseport的查找逻辑来为其对应一个处理它的套接字。

你知道每天最令人悲哀的事情是什么吗?那就是重复着一遍又一遍每天都在重复的话。

时间: 2024-07-30 02:46:19

Linux 4.6内核对TCP REUSEPORT的优化的相关文章

如何通过操作系统查看内核对tcp参数的解释

[[email protected] ~]# yum install -y kernel-doc [[email protected] ~]#  cd /usr/share/doc/kernel-doc-2.6.32/Documentation/networking [[email protected] networking]# vi ip-sysctl.txt

Linux配置支持高并发TCP连接(socket最大连接数)

Linux配置支持高并发TCP连接(socket最大连接数)及优化内核参数 2011-08-09 15:20:58|  分类:LNMP&&LAMP|  标签:内核调优  文件系统调优  高并发调优  socket连接  ip_conntract  |字号大中小 订阅 1.修改用户进程可打开文件数限制在 Linux平台上,无论编写客户端程序还是服务端程序,在进行高并发TCP连接处理时,最高的并发数量都要受到系统对用户单一进程同时可打开文件数量的限制(这是因为系统为每个TCP连接都要创建一个s

Linux C中内联汇编的语法格式及使用方法(Inline Assembly in Linux C)

在阅读Linux内核源码或对代码做性能优化时,经常会有在C语言中嵌入一段汇编代码的需求,这种嵌入汇编在CS术语上叫做inline assembly.本文的笔记试图说明Inline Assembly的基本语法规则和用法(建议英文阅读能力较强的同学直接阅读本文参考资料中推荐的技术文章 ^_^). 注意:由于gcc采用AT&T风格的汇编语法(与Intel Syntax相对应,二者的区别参见这里),因此,本文涉及到的汇编代码均以AT&T Syntax为准. 1. 基本语法规则 内联汇编(或称嵌入汇

AIX/Linux/Solaris/HP-UXAIX查看系统TCP keepalive值

查看系统TCP keepalive值: AIX: $ no -a | grep keep HP-UX and Solaris: $ ndd -get /dev/tcp tcp_keepalive_interval Linux: $ sysctl -a | grep keep AIX/Linux/Solaris/HP-UXAIX查看系统TCP keepalive值

Linux 小知识翻译 - 「TCP/IP」

上次说了「协议」相关的话题,这次专门说说「TCP/IP」协议. 这里的主题是「TCP/IP」到底是什么?但并不是要说明「TCP/IP」是什么东西,重点是「TCP/IP」究竟有什么意义,在哪里使用「TCP/IP」.这正是之前没有提到的内容. TCP或IP,根据上次的介绍,都是协议,也就是通信时的规则.但是,「TCP/IP」很容易被误解,因为TCP/IP并不是单独的一个协议,而是一系列协议的集合,目前是作为互联网的标准被使用的. 上次也说了,单独一个协议是没法完成通信的.只有多个协议一起使用,才能完

linux视频学习3(linux安装,shell,tcp/ip协议,网络配置)

linux系统的安装: 1.linux系统的安装方式三种: 1.独立安装linux系统. 2.虚拟机安装linux系统. a.安装虚拟机,基本是一路点下去. b.安装linux. c.linux 安装的时候,分区是关键. /boot 分区 100M. /swap 交换分区.一般是物理内存的2倍,不超过256M. /root 根分区.尽可能的大. 3.双系统安装. 2 linux下的shell 3.TCP/IP 协议 4.samba服务器.主要是linux和window的交互.

Linux 套接字编程 - TCP连接基础

第五章的内容,实现一个echo服务器和对应的客户端,主要收获: 0. TCP socket编程主要基本步骤 1. SIGCHLD信号含义(子进程退出时向父进程发送,提醒父进程对其状态信息进行一个获取),waitpid 和 wait在使用上的差异,前者可以配置参数设定为非阻塞方式调用,更加灵活. 2. 信号处理函数与其过程(尤其是信号发生后不列队这个性质),相同的信号多次发生(间隔非常接近的话)可能仅会调用一次信号处理函数 3. 信号处理对慢系统(阻塞)调用如accept等的影响(如果信号处理设置

基于linux的web服务器的iptables防火墙安全优化设置

安全规划:开启 80  22 端口并 打开回路(回环地址 127.0.0.1) #iptables –P INPUT ACCEPT #iptables –P OUTPUT ACCEPT #iptables –P FORWARD ACCEPT 以上几步操作是为了在清除所有规则之前,通过所有请求,如果远程操作的话,防止远程链接断开. 接下来清除服务器内置规则和用户自定义规则: #iptables –F #iptables -X 打开ssh端口,用于远程链接用: #iptables –A INPUT

Linux内核导论——网络:TCP效率模型和安全问题

TCP速度与带宽 有人认为TCP可以以带宽的速度发送数据,最起码用带宽扣除TCP包头损耗就是TCP传输可以达到的最大速度.这个理论是正确的,但很多时候TCP的速度却达不到带宽. TCP由于采用拥塞避免算法,并不总是以实际带宽的大小来传输数据.尤其是在共享带宽的时候,带宽到底有多大,这是个不好说的问题. TCP受制于系统资源,还需要设置缓存大小,上层应用接收不及时的话缓存满了,TCP想快也快不了. 对于大量短连接,很大的带宽开销用来维护TCP连接,而不是传输数据. 还有很多情况TCP可能都无法建立