1. 背景
笔者出道于爱立信,电信行业,回想这么多年技术或管理上所用的技巧,思想多源于斯,深有感触。在爱立信还是有些狗屎运的,曾于项目青黄不接时独立维护一个AAAServer,熟悉了内存分配器,把该server的内存按得纹丝不动;然后又在一个全球化的网关产品中做PLM,能纵览其异步io+任务式线程组+javascript的设计。但项目中接触到的框架都是现成的,临渊羡鱼不如退而结网,自己也在开始将认识写成代码,即framework-nd。只是没有需求,怎知其可堪一用?
机会还是来了,即我的第二份工作(加薪30%,如果各种福利一起算,实际只加了几百,如果不是因为这是个机会,估计留不下来,从大平台跳出来,请慎重),当时是要做信令和HTTP流量分析平台,一组人负责解析信令,我一人负责HTTP流量解析(当时https用得不多,不然就歇菜了),信令组需要记录的关键字段发给我,我将其和同时段相应的HTTP的请求/响应的关键字段汇总成一条话单,写文件,待后续分析。然后这个框架就用上了,解决了无数的问题后,在惠州移动上线了,日处理数据约1.5TB/日(我们用TAP设备抓了一天的包,1.5TB,内部拿这1.5T文件测试,千兆网卡带宽压到80%~90%之间,cpu和内存的相关数据时间太久就不记得)。第二次上线信令组也用了这套线程框架。
后来这个框架经过适当的改造,应用于我的工具putty-nd中(zmodem和同步Google Drive的本地服务器)。
2. 设计思路
其实大家都知道,要使系统CPU达到最大的利用率,全局活跃线程数应等于逻辑CPU个数,这样CPU没有切换的开销,负载达到CPU个数,刚好吃饱。
这是条基本规则,现实中会有些变化。
- CPU个数有限,如何让有限的线程执行各种不同的任务。
- 纯CPU计算可以让CPU忙起来,但是同步的调用,如Oracle的访问接口,或者需要同步的系统调用(日志)会让线程挂起,导致CPU释放,如果没其他活跃的线程,CPU就闲下来了。
- 锁/同步都会让线程挂起,如何最大限度地减少这种可能性。
结合之前的经验,做了下面的设计。
- 针对第一个问题,用命令模式,将不同的处理函数和数据用boost::bind封装成boost::function<void (*)()>,提交各自线程的处理队列,线程仅需处理这个队列。这样一个线程即可以进行多任务处理。
- 第二个问题,线程分组。纯CPU计算的任务和会让CPU挂起的任务分属不同的组(按需求可以再细分),组内线程地位平等,数量可调。提交处理任务是向线程组提交,提交接口包含一session_id,线程组按此session_id分配到线程处理(取余),这样线程组可以保证同一id的任务会按顺序先后在同一线程处理。
- 第三个问题,创造单线程环境。按逻辑,需要先后处理的各个任务可由逻辑层归为一个session,并分配id,同一session_id的任务按session_id像线程组提交,线程组保证这个会话的任务在同一线程先后处理。会话可能有多个在多个线程触发事件源,但其向线程组提交后在线程的任务队列中串行化,其处理任务不用加锁。
- 对队列加锁,尽量不对一大块逻辑操作加锁。如任务队列需要加锁,session的自增id需要加锁。存储session的集合可以定义和处理线程数内相同的个数,在线程启动前初始化好,分别访问避免加锁。也可以用Thread Local Storage,但这种设计有点侵入性,笔者未采用。
在调试中发现session的任务可能会在错误的线程处理,因此加入了一些防错的校验。
在后来的调优中,发现如果各个线程共享系统的定时器队列,输入在100Mbit/s时达到处理的瓶颈。因为会话需要同步各个输入,用到了大量的定时器,因此改在线程上用最小堆实现了会话的定时器管理。因为同一会话的处理都在同一线程,避免了定时器任务的加锁,顺利跨过这个坎,使输入达到网卡带宽的80%~90%时,仍能稳定处理。
实践发现的一些冗余的设计:对队列设计了池子,但对tcmalloc这种现代分配器,对比发现完全看不出有任何优化。也就是说,无初始化负担仅是内存池子的设计,可以完全交给内存分配器。
3. 实现
有设计的蓝图,实现就不难了,链接。
主要有两个类,Boost的版本,实现还有Pthread的版本,不知道丢哪去了,简单改几个东西, 不麻烦。
- BoostProcessor,线程组,组内线程接口不对外开放,只能用一带session id的接口向线程组提交任务。
- BoostWorker,线程的处理函数,处理任务队列和定时器队列。
- ProcessorSensor,监控线程任务队列数量,如果处理不过来,任务数量会堆积,内存就上去了。
3.1 外部接口
对外提供的接口有三类。
- BoostProcessor(线程组)构造函数和析构函数
- 线程启停
- 提交任务和Timer
3.1.1. BoostProcessor构造函数和析构函数。
1 | BoostProcessor(const unsigned theThreadCount); |
构造函数除了数量,还有一个带名字的。方便知道是哪组线程发生了堆积。其实建议用pthread_setname_np为每个线程都设个名字,这样用Top命令就能看到各个线程的cpu负载情况。
3.1.2. start/stop线程
1 | void start(); |
3.1.3. 提交任务和Timer
1 | struct event* addLocalTimer( |
Timer放在线程上的原因说过了。
看了一下,最新代码的任务还是带了池子的版本,用了大量的模板,导致执行文件过大,性能上没提升,这是一个不太成功的实践。直接用boost::function简化就行了。
3.2. 线程的实现
1 | void BoostWorker::run() |
bufferJobQueueM是读写分离的一个数据结构,因为读取只有一个线程,所以没有锁(改天开篇讲)。
定时器队列用的是libevent的最小堆,小巧,提供了删除的接口。
处理完任务后,如果还有任务要处理,则继续处理,如果还有定时时间,time_wait 500微秒(0.5ms,定时器精度就是这个了),否则直接wait。只要有wait,CPU就被释放了,要么timeout切回来,要么新任务有signal唤醒。有人说线程的切换至少要耗时10ms,笔者有印象这应该是出自某本经典的书,说内核的时间片算法最小单位大约10ms。后来再查了一下,这时间片算法单位应该是和频率有关,10ms应该是书作者写书时的数据,算算下应该是上世纪的事情了。
其他就不贴了。代码都在github上。
4. 后记
在游戏行业的时候,因为帧循环是单线程处理的,曾有想法引入线程组,按场景id分线程去处理。当时最大的问题是32bit程序内存受限,n个线程n个lua vm,配置表得n份,太占内存。因为够用,程序人少事多,晃着晃着就过去了。