OpenVPN多处理之-多队列TUN多线程

时间:2023-03-09 17:37:00
OpenVPN多处理之-多队列TUN多线程

1.有一点不正确劲

在改动了那个TUN驱动后,我在想,为何我总是对一些驱动程序进行修修补补而从来不从应用程序找解决方式呢?我改动了那个TUN驱动,可是能保证我的改动对别的应用一样可用吗?难道TUN驱动就OpenVPN一家在用?这绝不可能,既然我想到了这个方法,肯定别人也想到了,仅仅所以网上没有资料,是由于这些牛人不屑于此罢了。
       使用原生的没有改动的TUN驱动,怎样?Well,let's go on!
       问题在哪里,问题在假设我启动多个OpenVPN进程,那么它们每个的multi_instance链表就是独立的,假设一个数据包被发送了OpenVPN实例1,而其multi_instance却在实例2,那么这次通信将失败!
       我为何要启动多个OpenVPN进程?由于OpenVPN不支持多处理。
       我为何要支持多处理?由于我有一台超级猛的机器,多核心CPU,多队列网卡。
       支持多处理就一定要多个进程吗?不,线程也行,可是须要改OpenVPN代码。
       改一下不能够吗?能够,可是它太乱了。
       乱到什么程度?乱到和OpenSSL一样的程度。
       你有几年编程经验?06年開始至今。
       你刷过5个人以上吃完火锅的油锅油碗吗?常常!
       ......
       開始吧!改掉OpenVPN,让它支持多线程!
       其实,是TUN驱动的多队列实现给了我动力,人家原本写得那么通用,我为了迎合OpenVPN居然改动了驱动,添�了ifdef OVPN宏(我自打2008年就痛恨宏,之前就职的一家公司,两个人-当中一个是经理,还由于宏打了一架...当然理由是别的,但明眼人肯得出就是由于经理让人家用大量的宏来定义代码的逻辑,人家不肯),为何不改动应用呢,为何不改动OpenVPN呢??连刷锅洗碗这样的无聊的事情都做过,一做就是几个小时,改下OpenVPN岂不是要比刷锅洗碗更好??这就是契机,这就是动力。除了不想改TUN驱动之外,不想写不论什么内核模块来支持OpenVPN的多处理也是动力之中的一个,为了支持一个服务的多处理,居然要写一个内核模块,那这个服务也太失败了,你见过哪个应用公布的时候还要携带一个内核模块的吗?

2.OpenVPN多线程模型设计

改动之前,我忘掉了关于Intel 82599以及全部关于多队列的事情,由于那些东西总是让我想起性能调优,思路瞬间就被引导到运维那边了。我该考虑一些程序猿应该考虑的东西。
       OpenVPN眼下是一个大循环支撑的统计多路复用的半双工流程,从名字上就能够看出它的优劣,优势在于统计多路复用,支持个别流量突发,劣势在于半双工,即同一时候仅仅能有一个方向的动作。它的统计多路复用体如今select/poll的调用上,而半双工体如今大循环中顺序调用process incoming link,process outgoing tun,process incoming tun,process outgoing link之上,单进程单线程嘛,同一时候当然仅仅能有一个操作,要么从tun读取,解析,写入socket,要么从socket读取,解析,写入tun。怎样将这些进行拆分,就体如今3个我眼下想出来的多线程模型上。
       为何不使用多进程,是由于多进程的共享内存太麻烦。其实,我的第一个想法就是:仅仅要将multi_instance链表共享就好了,即多个进程能够看到同一个链表,对于虚拟IP地址池,我倒不是非常在意,毕竟它可在client-connect脚本中在全局pool中分配,这二者在多个进程间共享了之后,管理接口的问题也攻克了。起初我想把multi_instance链表放在shmget得到的shared memory中,可是位于shared memory中的字段不能是指向本地地址空间的指针类型,所以这太复杂了,于是我将思路放在双工的拆分上。

2.1.生产者/消费者模型

该模型为了支持全双工,须要两个资源池A,B,每个方向一个。处理逻辑包括下面4个部分:
a.从TUN读取的数据,放入资源池A,必要时唤醒资源等待者,返回继续;
b.从资源池A取出一个资源,若无则等,将取出的资源写入socket,返回继续;
c.从socket读取的数据,放入资源池B,必要时唤醒资源等待者,返回继续;
d.从资源池B取出一个资源,若无则等,将取出的资源写入TUN,返回继续。
能够看出,这样的模型须要4簇线程且须要2把锁,每个资源一把锁。以上的每个部分都能够多线程并发处理,但要注意避免惊群。然而假设线程过多,锁的开销就会过大,其实,生产者/消费者模型假设搞不好,反而会减少性能,门槛较高,线程间通信成本高,已经不太适合高并发环境。
       最根本的放弃它的原因在于,这样对OpenVPN代码的改动太过激烈,说不定OpenVPN社区立即就推出多线程版本号了,我何必费劲整改呢?再说,我不是受虐狂,尽管我总是喜欢找捷径,不喜欢依照某个定好的计划或者规则做事,可是理由是充分的,那就是终于的结果肯定能保质保量。在此吐嘈片刻,国人有个毛病,那就是假设对于一件交给你的任务,你不花大量时间和大量精力就搞定了,别人就是认为一定有问题,一定在投机取巧,反而那些天天非常忙碌,事情拖得恰到优点(即刚好到达deadline)的人更获青睐,反过来呢,假设一件交给别人的任务,你作为旁观者帮忙去处理,使用了不花时间和精力的巧妙办法解决,别人就会为你鼓掌,而且採纳你的方案。这样的现象非常严重,必须要重视。

2.2.全双工模型

假设上一种方案对OpenVPN的改动太过剧烈,那么我们就把4个部分合并成两个部分,把仅有的两把锁去掉,详细来讲就是将a,b合并,c,d合并,这样就不须要资源池了,锁也不须要的,从TUN获取的数据直接写到socket,反过来也一样。这样的粒度粗糙了一些,可是能够更好利用CPU的cacheline。有时候并非线程的粒度越细越好,最好的方式就是一个流水线作业一贯究竟,在这一点上没有什么基本原则,适合你的就是最好的。
       这样的方式须要2簇线程,不须要锁,大大减少了线程间通信开销,为何要放弃呢?由于我懒惰,我必须把incoming tun,ougoing link合并成一簇线程,将incoming link,outgoing tun合并成还有一簇线程,加之OpenVPN的代码组织实在反模块化,全部的变量结构体到处都有身影,到处被引用,还循环引用...,我被它的风格搞疯了,以至于我有一种”仅仅要有人资助我的月工资,我就请一个月长假去重构OpenVPN”的欲望(这样的事情当然不能在家里做,一定要在没有家人没有同事没有领导没有电话的地方,所以是不可能的...)“。
       我放弃它,寻找一种经过思考的,改动最少的办法,一定有的。

2.3.多线程半双工模型

OpenVPN的模型就是半双工,假设非要改称全双工,势必要改动整个框架,这活儿全然就是编程,没有意思。试想,之前我不是执行OpenVPN多个实例成功了吗?而且也成功运用了支持多队列的TUN网卡,多个实例就是多个OpenVPN进程,彼此不共享不论什么东西,甚至socket都各是各的。因此我必须改动TUN驱动才干支持这样的多进程隔离的多实例方案,如今我不想改动TUN驱动了,我想用原生的那个驱动,我仅仅能改动OpenVPN,问题在哪儿??问题在于TUN驱动可能把属于OpenVPN进程1的multi_instance数据转发到OpenVPN进程2。那么怎样解决它呢?
       最小的改动就是维持OpenVPN半双工模型,使用多个线程,每个线程函数就是原生OpenVPN的main函数!如今的目的就是共享multi_instance链表和虚拟IP地址池!
       非常好办!将其设置成全局变量不就能够了吗?
       假设你不喜欢全局变量,全然能够使用參数传递的方式。
       因此,除了multi_instance链表和ifconfig_pool,全部的其他的数据结构都是线程独享的,由于原来的main居然做了线程函数,化名为main_real!

3.REUSEPORT来帮忙

Linux 3.9+内核的REUSEPORT机制让我彻底扔下了借用random DNAT实现的负载均衡,嘲笑了自己写的负载均衡模块,放弃了重量级别的IPVS。其实REUSEPORT在socket查找级别就能够实现针对同一个5元组随机到固定的socket的映射,而这个机制省下了我超级多的精力和愤慨,节省了大量的工作量,感谢!
       事情是这样的。上面我已经给出了OpenVPN多线程的改动方案,可是socket是一个问题!既然到了这一步,我就希望连我写的那个负责OpenVPN多实例之间的负载均衡模块也去掉!OK!可是我怎样能在多个线程间分发bind同一个IP地址和port的socket数据。办法有二,其一就是主线程创建socket,那么每个线程都会保有它,都能够对其进行IO,但这样会引发惊群现象(请自行bing,Linux内核对于UDP的select依旧没有解决,对于TCP的accept,仅仅是简单的使用exclusive wake up攻克了....),因此我希望让全部的线程自己建立自己的socket,要么bind不同的port,靠我的负载均衡来做接下来的事,要么bind同样的IP地址和port,靠REUSEPORT的random逻辑来做(其实取代了我的负载均衡模块,它能够在多个元组同样的socket间做选择)。我的系统内核正好是3.9.6版本号,完美支持,感谢!

4.针对OpenVPN的改动

我总是这样,将代码放在最后,非常多人不喜欢啰里啰唆的说教和理论阐述,他们仅仅想知道怎么做。但其实,假设每次都是拿来主义而没有自己的思考,那颓败仅仅是一个时间问题,最近看《重说中国近代史》,讲到洋务运动,硬件设施全部拿来,可是却在甲午慘败,于是虽说不上洗心革面但起码来了迟到的新政,本末深挖,情况才好转。所以不要为了做事而做事,做每一件事前,知其然,知其所以然。
       该上代码了,总的来讲改动的不多,假设多了,那就不是出自我手了。改动main,将main又一次包装:

int main(int argc, char *argv[])
{
        int i = 0;
        struct argve arg;
        init_static();
        real_hash = hash_init (256,
                                get_random(),
                                mroute_addr_hash_function,
                                mroute_addr_compare_function);
        virt_hash = hash_init (256,
                                get_random(),
                                mroute_addr_hash_function,
                                mroute_addr_compare_function);
        iter_hash = hash_init (1,
                                get_random(),
                                mroute_addr_hash_function,
                                mroute_addr_compare_function);
        arg.argc = argc;
        arg.argv= argv;
        for (i = 0; i < THREAD; i++) {
                pthread_t pid;
                pthread_create(&pid, NULL, main_real, (void *)&arg);
        }
        while (1) {
                sleep(1);
               // 我想想让主线程变成一个特殊的管理线程呢?还是想让它和工作线程一样?
        };
}

在main中,初始化了hash链表全局变量,当然终于代码要更优美些,这些初始化都是懒惰初始化的,或者用过度设计的说法就是单例模式!那么main_real是什么呢?非常easy,就是原生OpenVPN的main,将返回值改了一下,将參数封装了一下而已。因此,全部的OpenVPN原来的东西还是一样,多个OpenVPN线程(main_real)除了hash表和ifconfig_pool是共享的之外,其他的数据结构都在自己的线程栈上分配,也就是说都是线程独享的。下面是全局变量的定义:

extern struct hash *real_hash;
extern struct hash *virt_hash;
extern struct hash *iter_hash;
extern struct ifconfig_pool *ifconfig_pool;;
#define THREAD  NUM_OF_CPU+2
struct argve {
        int argc;
        char **argv;
};
void *
main_real (void *arg);

剩下的事情就都是multi.c/h的了。在multi.c中改动全部初始化multi_context的hash,vhash,iter的操作,将其改为简单的赋值操作就可以,本应该同样的改动ifconfig_pool的初始化操作,改为赋值操作,可是我使用了懒惰初始化的方法,其实这样的方式更好:

if (dev == DEV_TYPE_TAP) {
    if (ifconfig_pool == NULL) {
        ifconfig_pool = ifconfig_pool_init (IFCONFIG_POOL_INDIV,
                    t->options.ifconfig_pool_start,
                    t->options.ifconfig_pool_end,
                    t->options.duplicate_cn);
    }
    m->ifconfig_pool = ifconfig_pool;
} else if (dev == DEV_TYPE_TUN) {
...
}

hash表的初始化操作也能够这么做。最后须要做的就是针对以上的hash链表以及ifconfig_pool等全局变量进行操作的时候使用锁了,由于多个线程会共享这些数据结构。作为让代码看起来好看一点的一种优化,我将诸如options的初始化等操作统一到了主线程中。

5.执行效果

像往常一样,modprobe tun,这是最酷的,由于这是原生的TUN驱动,没改动过的。然后就和普通的启动OpenVPN方式一样的了,一点都没有变,那么变化的是什么呢?变化的是,在多核心CPU环境下,再也不是一个CPU工作,其他CPU打酱油了。哦,对了,为了让多线程OpenVPN取得最优化性能,我临时添加�了两个配置选项,一个是线程数量,还有一个是是否绑定CPU。当然,以后我还会添加�很多其他的配置选项。

6.多线程OpenVPN的优化

上面描写叙述的多线程OpenVPN版本号并没有给出不论什么的优化措施,仅仅是在编程层面上实现了一个OpenVPN进程中包括多个线程并发处理流量。一般而言,优化是最后的事情,这也是事实,逻辑都没有通过,还谈什么优化!

6.1.前置知识:Linux的RPS

Intel 82599等系列高端网卡支持多队列,即数据包到来的时候,能够依据其5元组信息将一个flow分发到某一个队列,每个队列都能够绑定一个CPU核心,那么分派到该队列的数据包到来时就会中断该CPU,硬中断返回后软中断在同一个CPU上继续进入协议栈处理,数据包一步步进入socket层进而分发到某个应用程序,可是这个应用程序可能在别的CPU核心上执行,然而该数据包的数据已经cache到了处理中断和软中断的那个CPU上,执行流切换到别的CPU上,这些cache全部报废...
       为了解决问题,当然希望将软中断直接调度到应用程序所在的CPU核心(能将硬中断调度于其上当然更好,可是要考虑不支持多队列的网卡,也要考虑网卡的多队列机制并没有统一的编程接口)上,这就是RPS机制。应用程序会在自己所在的CPU和自己所处理的数据流元组的hash值之间建立一个映射关系,并插入一个表,数据包接收中断中,在调度软中断之前会以数据包的元组hash值查该表,找到相应的CPU,然后将处理该数据包的软中断调度到这个CPU上。这样就能尽可能保证数据包从内核协议栈開始一直到应用程序始终使用一个CPU,最大化了cache命中率。

6.2.前置知识:Linux的RFS

RPS机制有个问题,那就是应用程序可能会在告知内核自己的CPU与关联的元组hash之间的映射后,读取同一个socket的执行于其他CPU的其他进程或者线程可能会改动掉这个映射,假设改动过于频繁,就会导致一个数据流的数据被多个多个进程/线程乱序接收,比方线程1接收数据包1,2,线程2接收数据包3,线程3接收数据包4,5,6,线程4接收数据包7,8...
       面相应用程序频繁迁移或者多进程/多线程同一时候处理一个socket的情形,软中断调度总不能被牵着鼻子走,而是要有一套自己的策略。面对上述的问题,调度软中断的时候无非就是要做一个抉择,是继续使用上次处理该数据流的CPU处理该数据包,还是听从socket的设置,将处理迁移到新的CPU上。RFS就是针对这样的情形对RPS的一个补丁,它的决策是这样的:假设当前还存在等待软中断处理(即还在排队等待调度)的被调度到上一次处理该流的CPU上且属于同一流的数据包,则继续使用上一次处理该流的CPU处理(即将该数据包排入上一次处理该流的CPU队列),反之,假设当前排在上一次处理该流的CPU等待软中断处理的队列里的数据包已经没有了,则採用新的CPU来处理,这个策略避免了同属于一个数据流的数据包乱序到达不同的CPU。这就是RFS,正如中间字母F所代表的Flow一样,它是基于流的调度,而RPS则是基于包的调度,仅仅要有socket更新了Flow的CPU,则立即将软中断调度到新的CPU上处理。

6.3.同一个CPU完毕OpenVPN的两个半双工处理

理解了上面的RPS/RFS机制,我们来看一下OpenVPN怎样利用它。
首先请考虑一下核心网的转发设备(而不是那些处于数据中心的服务器类设备)比方路由器,交换机是怎样进行数据转发的-临时不考虑硬件转发,其实,这些设备在某一个网口收到数据包以后,基本都是一贯究竟的,也就是说一个CPU核心会从接收一直负责到从还有一个网卡将包发出,这是由于这期间没有经过socket这样的接口层,而我们知道,socket作为一个资源IO的接口,其上面的应用和下面的协议栈仅仅就资源的转交达成协议,并没有假设不论什么其他细节。所以才会出现App-pingpong以及其导致的cacheline-pingpong等问题,而对于三层及三层下面的转发设备而言,没有这样的问题。当然,假设对于微内核实现的内核协议栈,也是有这样的问题的,由于以上仅仅是一个样例,因此我都是以Linux为例。
       执行OpenVPN的设备实际上也是一个转发设备,数据包从物理网卡接收,到达OpenVPN进程,这个通过RPS/RFS以及Intel 82599的ATR能够保证一个线程都是一个CPU处理的,此时将每个OpenVPN线程绑定在特定的CPU核心,数据经过解密后,发往TUN字符设备,然后调用netif_rx_ni模拟接收,一直到数据包从某个物理网卡发出去,也能保证其一直都是一个CPU完毕的,回程流量也是一样的道理,因此优化效果是不错的。然而,唯一的问题是,由于多个OpenVPN线程reuse了同样的IP地址和port,所以在UDP socket lookup的时候,会有一次散列,即真正处理该数据包的CPU不一定就是那个处理软中断的CPU,可是这在RFS机制下将不是问题,由于socket lookup时同样tuple的散列是同样的,所以不会构成问题。

7.总结

如今,我仅仅须要在Linux 3.9+的内核执行下面的命令行,就能够启动一个多线程高性能的OpenVPN的服务端:
openvpn --dev tap0 --mode server --proto udp --lport 1234 --ca ca.pem --cert server.pem --key serverkey.pem --tls-server --dh dh.pem --server 12.12.0.0 255.255.0.0 --duplicate-cn
须要说明的是,之所以使用--dev tap0明白指定网卡名称是由于这样就能够利用TUN网卡的多队列性质,之所以使用--duplicate-cn是由于我不想签发太多的证书,之所以使用openvpn命令而不是我自己写的程序是由于我改动openvpn,但没有改它的名字。