1.有一点不对劲

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

2.Open×××多线程模型设计

修改之前,我忘掉了关于Intel 82599以及所有关于多队列的事情,因为那些东西总是让我想起性能调优,思路瞬间就被引导到运维那边了。我该考虑一些程序员应该考虑的东西。
       Open×××目前是一个大循环支撑的统计多路复用的半双工流程,从名字上就可以看出它的优劣,优势在于统计多路复用,支持个别流量突发,劣势在于半双 工,即同时只能有一个方向的动作。它的统计多路复用体现在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把锁,每个资源一把锁。以上的每一个部分都可以多线程并发处理,但要注意避免惊群。然而如果线程过多,锁的开销就会 过大,事实上,生产者/消费者模型如果搞不好,反而会降低性能,门槛较高,线程间通信成本高,已经不太适合高并发环境。
       最根本的放弃它的原因在于,这样对Open×××代码的修改太过激烈,说不定Open×××社区马上就推出多线程版本了,我何必费劲整改呢?再说,我不是 受虐狂,虽然我总是喜欢找捷径,不喜欢按照某个定好的计划或者规则做事,但是理由是充分的,那就是最终的结果肯定能保质保量。在此吐嘈片刻,国人有个毛 病,那就是如果对于一件交给你的任务,你不花大量时间和大量精力就搞定了,别人就是觉得一定有问题,一定在投机取巧,反而那些天天很忙碌,事情拖得恰到好 处(即刚好到达deadline)的人更获青睐,反过来呢,如果一件交给别人的任务,你作为旁观者帮忙去处理,使用了不花时间和精力的巧妙办法解决,别人 就会为你鼓掌,并且采纳你的方案。这种现象很严重,必须要重视。

2.2.全双工模型

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

2.3.多线程半双工模型

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

3.REUSEPORT来帮忙

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

4.针对Open×××的修改

我总是这样,将代码放在最后,很多人不喜欢啰里啰唆 的说教和理论阐述,他们只想知道怎么做。但事实上,如果每次都是拿来主义而没有自己的思考,那颓败只是一个时间问题,近期看《重说中国近代史》,讲到洋务 运动,硬件设施全部拿来,可是却在甲午惨败,于是虽说不上洗心革面但起码来了迟到的新政,本末深挖,情况才好转。所以不要为了做事而做事,做每一件事前, 知其然,知其所以然。
       该上代码了,总的来讲修改的不多,如果多了,那就不是出自我手了。修改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是什么呢?很简单,就是原生Open×××的main,将返回值改了一下,将参数封装了一下而已。因此,所有的 Open×××原来的东西还是一样,多个Open×××线程(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驱动,没修改过的。然后就和普通的启动Open×××方式一样的了,一点都没有变,那么变化的是什么呢?变化的 是,在多核心CPU环境下,再也不是一个CPU工作,其它CPU打酱油了。哦,对了,为了让多线程Open×××取得最优化性能,我暂时增加了两个配置选 项,一个是线程数量,另一个是是否绑定CPU。当然,以后我还会增加更多的配置选项。

6.多线程Open×××的优化

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

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完成Open×××的两个半双工处理

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

7.总结

现在,我只需要在Linux 3.9+的内核运行以下的命令行,就可以启动一个多线程高性能的Open×××的服务端:
open*** --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是因为我不想签发太多的证书,之所以使用 open***命令而不是我自己写的程序是因为我修改open***,但没有改它的名字。