copy自 http://dirlt.com/kylin.html 。
kylin是baidu in-house的异步编程框架,提供CPU,Network以及Disk异步操作接口,并且内置许多常用编程组件包括定时器和内存池等。
相关材料
公共组件代码
最主要实现了atomic add/swap/cas三个操作。
汇编语言可以参考内核汇编语言规则(转)
然后再上面封装了一系列原子操作。封装的一系列原子操作还是比较好理解的。
spinlock直接使用atomic提供的原子操作来实现,理解起来倒不是很麻烦
在spinlock.h下面有一个token实现。token语义非常简单,如果token==0的话那么这个令牌没有被任何人获得, 如果token!=0的话,那么令牌被token标记的对象获取了。token可以是pid,也可以是tid.
提供开销更小的计时器,使用读取CPU的time stamp counter.这个内容表示自计算机启动以来的CPU运行周期。
得到周期之后我们必须转换称为时间(s)。周期转换称为时间就是除CPU的主频。得到CPU主频的话没有什么特别好的办法, 一种简单的方法是通过等待1s然后得到tsc差。对于Linux操作系统的话可以通过读取proc文件系统获得
[[email protected]]$ cat /proc/cpuinfo processor : 0 vendor_id : GenuineIntel cpu family : 6 model : 12 model name : Intel(R) Xeon(R) CPU E5620 @ 2.40GHz stepping : 2 cpu MHz : 2400.186 cache size : 256 KB physical id : 0 siblings : 16 core id : 0 cpu cores : 16 fpu : yes fpu_exception : yes cpuid level : 11 wp : yes flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm syscall nx lm pni monitor ds_cpl est tm2 cx16 xtpr bogomips : 4803.76 clflush size : 64 cache_alignment : 64 address sizes : 40 bits physical, 48 bits virtual power management:
从这里面我们可以学习到如何进行系统调用,阅读一下<asm/unistd.h>可以找到系统调用号,然后使用syscall来发起。
关于futex的话可以看看下面这些链接
尤其是最后一篇文章可以好好看看,讲到了关于如何使用futex.futex使用需要用户态和内核态的配合,用户态处理一些uncontented case, 而对于contented case的话交给内核态处理。在实际应用上发现大部分情况都是uncontented case都可以在用户态解决而不用陷入内核态。 如果想要深入了解的话,看看pthread里面同步组件的实现。
这里我们简单地介绍一下kylin里面使用futex实现的功能,先看看futex结构
可以认为是操作系统里面的PV实现.count就是资源数目,lock始终==0.理解起来并不会很麻烦。
这里cond和pthread_cond是有差别的,这里的cond没有和任何mutex相关。kylin这里认为count==0的时候,那么condition才被满足。
这里的event名字取得也相当的奇怪。这里count实际上有两个状态,>=0以及<0(LARGE_ENOUGH_NEGATIVE).对于count>=0的状态时候, 可以认为当前是没有signaled的需要wait,如果count为<0(LARGE_ENOUGH_NEGATIVE)的时候是有signal的状态的不需要wait。
异步框架代码
kylin对于用户来说首先需要了解的概念就在Async.h文件里面,主要是下面两个类
对于用户来说使用过程大致是这样的:
可以看到在实现时候,最好一个client就绑定几个相关的ctx最方便了。这里有一个地方需要特别关注就是引用计数。因为C++本身没有GC实现,所以我们必须自己来管理内存分配和释放。 因为client可以一次多个调用,而在OnCompletion里面根本不知道谁先完成谁后完成,也就不能够确定释放责任了。通过引用计数可以很好地解决这个问题。 如果我们直接继承CAsyncClient的话,内部是有引用计数实现的,非常方便我们只需要如何适当地使用就OK了。关于如何适当使用,谢谢sunxiao同学在这里的建议。
线程池很简单,取出一个Job出来执行就多了。但是为了更好地理解kylin有必要看看线程池接口/实现。
对于线程池部分的话我们比较关心这么几件事情:
了解线程是怎么工作的,可以看看线程执行的函数是怎么定义的
普通的Job会在每个Man里面单独提到,我们看看控制Job是怎么定义的。在ThreadPool里面就有一个TermianationJob.
通过这种方式来通知线程主动退出。理论上因为shared Queue可能会造成所有永远不会退出但是实际应该不会。
AddWorker非常简单
DelWorker因为有ControlJob的辅助所以可以很好地解决,只需要在每个线程后面增加一个TerminationJob即可
相对来说QueueJob也更加简单一些,直接投递到某个线程对应的WorkerContext里面即可。
而CancelJob则是通过加锁替换这个Job来完成的,还是比较精巧的
TranBuf.h CTranBufPool是一个内存分配器。对于很多系统来说,合理地使用资源是非常必要的。
作者linsd对于内存分配器看法是这样的:
要得到稳定的高吞吐,对内存的合理使用是必要条件。是否用Ring Buffer倒不一定,简单的buffer pool效果也差不多。另外,为了应付极限情况,还需要为buffer请求分级,当资源不足时优先给紧急请求。也可设定高低几条watermark,让各种复杂条件下的资源使用变得平顺。
了解一下真实系统里面定制化的内存分配器是非常有帮助的(相对应地来说 TCMalloc 是通用内存分配器).
首先看看CTranBufPool的数据结构,看看里面每个字段含义和作用.对于TranBuf来说的话内部 本质还是一个sample allocator,也是按照固定的BlockSize来进行分配的。构造函数可以看到水位线三个阈值都是0.
可以看到TranBuf分配方式是每次分配nAlloc个Block(这个过程在后面叫做AllocOnce).每个Block是BlockSize字节. 然后至少分配m_nMin*nAlloc(首先调用m_nMin个AllocOnce过程),最多分配m_nMax*nAlloc个Block.每个内存 不够的话都会调用AllocOnce这个过程。
这里稍微解释一下RealHdl这个字段的意思。对于单个Block分配出来的内存块,RealHdl==this.但是如果是 连续跨越多个Block内存快的话,那么每个Block对应的Handle里面RealHdl对应的是首地址的Handle.这样做的好处就是, 如果希望对这个内存块增加或者是减少引用计数的话,只是指引到一个Handle,对里面字段修改引用计数。否则的话, 需要遍历每个Block对应的Handle修改引用技术。
大部分Create代码都是在设置参数,最后调用m_nMin次AllocOnce来分配初始的内存块。
之前说过AllocOnce是分配一个连续内存块,每个Block大小是m_nBlockSize,而个数是nAlloc. 同时还需要分配nAlloc个Handle.每个Handle管理一个Block.
GetHandle是通过传入buffer首地址来确定管理这个buffer的Handle.但是注意不是RealHdl. 如果需要对这个内存做引用计数的话,应该是对RealHdl做引用计数。可以看看下面的AddRef实现。
对某块内存进行引用计数。并且强大的是这个内存地址不必是分配的首地址,可以是连续内存内部任意地址。
Destroy是将AllocOnce分配的内存和Handle全部回收。因为得到了所有分配内存和Handle的起始地址 保存在map里面所以释放并不麻烦.
分配内存。可以从参数里面看出来语义是说分配多少个Block.nPriority参数是说使用哪个水位线。 如果超过水位线的话,那么会使用相应的策略来处理(打印日志)。
如果说TranBuf是底层内存分配器的话,那BufHandle就是应用层的内存分配器。BufHandle底层是通过 两个TranBuf来进行分配的。BufHandle本质上是chained的形式,主要是为了节省mem copy以及适应 network IO app的。通过全局的BufHandlePool对象来分配内存。
首先我们看看BufHandle结构以及提供的API.
首先我们先看看CBufHandlePool的结构然后在看这个API
然后来看看这些参数是来如何设置的.
这个是底层确保一定分配成功API(如果失败抛异常).来看看实现.使用hang住当前操作等待其他线程归还内存.
底层不一定保证分配成功,可能返回NULL表示失败.只是尝试一次分配.
对于BufHandle的引用技术和TranPool引用计数有点不同,并且平时思考的也不同。BufHandle的引用计数 只是针对头部的BufHandle增加计数而共用其他部分的BufHandle.
(NOTICE)(dirlt):(不过在外部调用可以看到,CloneAndTerminate实际上也还是遍历了所有的Handle做引用计数).
只是释放单个BufHandle对象.
从BigTranBufPool里面分配大块内存.注意对于大块内存而言的话只允许分配一个Block.
从TranBufPool里面分配连续内存出来.
为某个buf分配内存.把buf内容copy进来.并且设置pNext.pppLast表示最后一个节点的next字段指针(三指针比较难理解…)
释放[pHdl,pNext)链上的所有item.
这个API的语义在之前已经解释过了,来看看代码.
这个模块主要负责框架的启动和停止,做了一些琐碎的事情方便用户,主要是下面这两个函数
对于InitKylin里面事情就是启动几个Manager,还做了一件tricky事情就是将SIGPIPE信号忽略了。而StopKylin就是停止这些Manager.我们需要仔细关注的就是这些Manager的启停。
我们首先看看ExecMan的接口
Start逻辑很简单,包括计算1s对应多少cycle数目以及启动线程池。
Stop逻辑的话可能需要仔细理解一下
QueueExec和QueueExecEmergent逻辑非常相似,只不过底层调用线程池的QueueJob和QueueEmergentJob.我们这里只看QueueExec.
我们这里可以看到m_nCurJobs在QueueExec和Stop之间的配合。然后我们稍微看看Proc这个过程,对于CPU任务直接调用OnCompletion然后调用Release.
定时器任务加入是DelayExec,检查触发是RunTimer.如果查看CallGraph的话会发现RunTimer都是在网络部分调用的,我们在网络部分看看触发的时机。 DelayExec里面的逻辑会根据定时时间来判断如何实现,如果定时时间超过g_nTickPrecision,那么会将超时时间加入一个map里面去,然后让RunTimer去触发。 否则会加入线程池里面去。对于加入到map里面的fProc有一个特殊的标记(JOB_PROC)2.在CancelExec时候会认识这个特殊标记,将事件从map中删除。
然后我看看看RunTimer这个部分。这个部分非常简单,就是根据当前时间判断map里面哪些定时器需要进行触发,然后将触发逻辑作为Job丢入CPU线程池。 我们这里不看RunTimer具体代码,反而倒是对外面的一些小细节比较感兴趣。我们不希望RunTimer被多个实例调用,只要有一个实例调用就OK,使用CToken完成。 当然可以使用mutex+try_lock来实现但是开销应该会更大。
我们这里给的例子非常简单,但是希望有启发性.我们从1开始进行打印,每打印1个数字就认为当前任务结束,一直无限打印。 但是我们同时会启动一个定时器,只允许我们做1.2s钟时间的打印。如果我们在1.2s内打印数字个数超过了100个的话,那么我们重启一个定时器1.2s, 而这次打印数字个数阈值为200个之后每次翻倍,直到1.2s内没有打印我们所希望个数的话程序退出。在主线程100ms来检查ExecMan的RunTimer.
#include <cstdio> #include <vector> #include <time.h> #include <span class="org-string">"stdafx.h"</span> #include <span class="org-string">"Kylin.h"</span> static volatile int worker=16; static const int PRINT=0; static const int TIMEOUT=1; static const int TIMEOUT_MS=1200; class XAsyncClient:public CAsyncClient{ public: AsyncContext print_ctx; AsyncContext delay_ctx; int id; int current_number; int threshold; int last_working_number; bool stop; // 一旦stop那么立刻后面内容都不打印了 XAsyncClient(int id_): id(id_), current_number(1), threshold(100), last_working_number(0), stop(false){ InitAsyncContext(&print_ctx); InitAsyncContext(&delay_ctx); print_ctx.pClient=this; delay_ctx.pClient=this; } int Release(){ // Release通常都是这样写的 int n=CAsyncClient::Release(); if(n==0){ delete this; } return n; } void Start(){ // 启动时候我们发起两个Job print_ctx.nAction=PRINT; CAsyncClient::AddRef(); g_pExecMan->QueueExec(&print_ctx,true); CAsyncClient::AddRef(); g_pExecMan->DelayExec(TIMEOUT,this,TIMEOUT_MS,&delay_ctx); } void Print(){ fprintf(stderr,<span class="org-string">"(%d)xref:%d,current:%d\n"</span>,id,CAsyncClient::GetRef(), current_number); } virtual void OnCompletion(AsyncContext* ctx){ switch(ctx->nAction){ // 分别处理这两个类型Job case PRINT: if(stop){ break; } fprintf(stderr,<span class="org-string">"(%d)%d\n"</span>,id,current_number); current_number++; if((current_number-last_working_number)>=threshold){ // update last_working_number=current_number; threshold*=2; // canel timer. fprintf(stderr,<span class="org-string">"(%d)==============================restart timer==============================\n"</span>,id); g_pExecMan->CancelExec(&delay_ctx); g_pExecMan->DelayExec(TIMEOUT,this,TIMEOUT_MS,&delay_ctx); } CAsyncClient::AddRef(); g_pExecMan->QueueExec(&print_ctx,true); break; case TIMEOUT: fprintf(stderr,<span class="org-string">"(%d)********************quit********************\n"</span>,id); atomic_add(&worker,-1); stop=true; break; default: assert(0); } } }; int main(){ // use 4 exec threads. InitKylin(4,0,0); // 100ms const struct timespec spec={0,100*1000000}; const int worker_num=worker; std::vector< XAsyncClient* > vec; for(int i=0;i<worker_num;i++){ XAsyncClient* client=new XAsyncClient(i); vec.push_back(client); client->Start(); } while(1){ nanosleep(&spec,NULL); //Sleep(1); if(AtomicGetValue(worker)==0){ StopKylin(true); break; }else{ // 主线程我们每隔100ms检查一次超时情况 g_pExecMan->RunTimer(); } } for(int i=0;i<worker_num;i++){ XAsyncClient* client=vec[i]; client->Print(); // 退出时候打印一下信息 delete client; } return 0; }
我们首先看看和磁盘相关的两个比较重要的类。因为磁盘操作不像CPU操作一样不需要任何辅助数据结构,磁盘操作需要一些信息比如fd等,磁盘操作需要一个特殊的磁盘Context。 然后每次发起磁盘操作使用另外一个结构Request.这里名字上和原来的CPU事件并不太一样,我们可能需要习惯一下。实际上如果我们需要映射到CPU事件里面的话,这两个Context应该结合在一起。 只不过这里DiskContext不是经常变动的部分,而DiskRequest是经常变动的部分所以分离开了。
然后在看看DiskMan接口
启动停止逻辑非常简单,就是让线程池启动和停止
逻辑非常简单,就是进行一下DiskContext和CAsyncClient初始化的工作。关于DiskContext里面各个字段含义的话,都是在Read/Write时候解释的。 关于这里最重点的绑定内容就是diskno.diskno非常作用类似于CPU事件里面的AsyncId.相同AsyncId可以分摊到同一个CPU线程这件可以免去加锁开销, 而diskno可以让多个DiskContext分摊到同一个Disk线程,不同线程绑定不同的磁盘驱动器,这样可以让同一个磁盘驱动器仅仅为几个文件服务。
文件的Read/Write非常简单,因为本身就是一个阻塞的过程,发起一次就可以保证读取所有内容了,所以不像网络一样需要多次发起。
从上面分析的话,所有重要的工作都分摊在了ReadOp和WriteOp上面。我们需要做的是Dig下去看看两个是怎么工作的。但是很不幸,两个函数里面内容都是使用了宏DiskOp. DiskOp(a,b,c)其中a表示对应的系统调用叫什么名字,b表示这个Job,c表示读写(没有使用).
继续Dig看看DISKOP是怎么工作的
例子非常简单就是我们首先发起一个磁盘操作写文件然后在将去读取出来。
和网络相关的也有两个比较重要的类。同样和DiskMan相同,NetworkMan也提供了NetContext和NetRequest.