skynet任务调度分析

 云风同学开源的skynet,当前规模是8K+ C代码和2K+ lua代码,实现了一个多线程高并发的在线游戏后台服务框架,提供定时器、并发调度、服务扩展框架、异步消息队列、命名服务等基础能力,支持lua脚本。单服务器支持10K+客户端接入和处理。

 

    我个人比较关注高性能和并发调度这块,这两天分析了一下skynet的代码,简单总结一下。

 

1. 总体架构

 

    一图胜千言,去掉监控、服务扩展、定时器等功能,skynet服务处理的简化框架如下图所示:



 
    每个在线客户的客户端,在skynet server上都对应有一个socket与其连接。一个socket在skynet内部对应一个lua虚拟机和一个”客户特定消息队列“(per client mq)。当客户特定消息队列中有消息时,该队列就会挂载到全局队列(global message queue)上,供工作线程(worker threads)进行调度处理。

 

     skynet的服务处理主流程比较简单:一个socket线程轮询所有的socket,收到客户端请求后将请求打包成一个消息,发送到该socket对应的客户特定消息队列中,然后将该消息队列挂到全局队列队尾;N个工作线程从全局队列头部获取client特定的消息队列,从客户特定消息队列中取出一个消息进行处理,处理完后将该消息队列重新挂到全局队列队尾。

 

    实际代码要更复杂一些:定时器线程会周期性检查一下设置的定时器,将到期的定时器消息发送到client消息队列中;每个lua vm在运行过程中也会向其他lua vm(或自己)的客户特定消息队列发送消息;monitor线程监控各个客户端状态,看是否有死循环的消息等等。本文重点关注消息的调度,因为消息调度的任何细微调整都可能对服务端性能产生很大影响。

 

    另外可以看出,每个客户处理消息时,都是按照消息到达的顺序进行处理。同一时刻,一个客户的消息只会被一个工作线程调度,因此客户处理逻辑无需考虑多线程并发,基本不需要加锁。

 

2. 并发任务调度方式

 

    lua支持non-preemptive的coroutine,一个lua虚拟机中可以支持海量并发的协作任务,coroutine主要的问题是不支持多核,无法充分利用当前服务器普遍提供的多核能力。所以目前有很多项目为lua添加OS thread支持,比如Lua LanesLuaProc等,这些项目都要解决的一个问题就是并发任务的组成以及调度问题。

 

    并发任务可以使用coroutine表示:每个OS线程上创建一个lua虚拟机(lua_State),虚拟机上可以创建海量的coroutine,这种调度如下图所示:




 
 

这种OS线程与lua vm 1:1的调度方式有很多优点:

  • 每个OS线程都有私有的消息队列,该队列有多个写入者,但只有一个读取者,可以实现读端免锁设计。
  • OS线程可以与lua vm绑定,也可以不绑定。由于现代OS都会尽量将CPU core和OS线程绑定,所以如果OS线程与lua vm绑定的话,可以大大减少cpu cache的刷新,提高cache命中率
  • lua vm与OS线程个数相当,与任务数无关。大量任务可以共用同一个lua vm,共享其lua bytecode,字符串常量等信息,极大减少每个任务的内存占用。
但该方式也有比较严重的缺陷:
  • 不支持任务跨lua vm迁移。每个任务是一个coroutine,而coroutine是lua vm内部的数据结构,执行中其stack引用了lua vm内部的大量共享数据,无法迁移到另一个lua vm上执行。当一个lua vm上的多个任务都比较繁忙时,只能由一个OS线程串行执行,无法通过work stealing等方式交给其他OS线程并行处理。我以前参与的一个电信项目中就是这种业务和线程绑定的处理方式,对于业务逻辑比较固定的电信业务,各个客户请求的处理工作量类似,因为绑定后CPU使用比较均衡。由于cache locality比较好的原因,这种处理方式性能极高。但对于一些工作量不固定甚至经常变动的客户请求,这种方式很容易造成某些线程很忙,另外一些线程很闲,无法有效利用多核能力。
  • 在同一个lua vm内的多个任务,共享lua vm的内存空间。一个任务出现问题时,很容易影响到其他任务。简单说就是任务间的隔离性不好。
    另一种处理方式是每个lua vm表示一个任务,系统中的海量并发任务由海量lua vm处理。skynet就是采用的这种方式。这种方式有效解决了上一种处理方式的两个缺陷,每个任务完全独立,可以交给任意一个OS线程处理;同时任务不会共享lua vm内存空间,隔离性非常好,一个任务的问题不会影响其他任务的执行。这种方式的主要问题就是存在大量内存浪费。每个lua vm都要加载大量相同的lua字节码和常量,对内存需求量非常高。这也造成每个任务执行时无法重用cpu cache,导致cache命中率降低很多。云风对这一问题的解决方案是修改lua vm的代码加载机制,同一进程内部的多个lua vm共享字节码。具体实现上,有一个独立的lua vm专门负责加载字节码,并负责字节码的垃圾回收。同一进程中的其他lua vm共享该独立lua vm加载的字节码。这种方式无法解决字符串常量共享的问题,仅仅解决了字节码共享的问题。不过即使这样,每个在线用户也节省了 1M的内存。
 
3.  全局消息队列
 
   skynet中有一个”全局消息队列“和在线用户特定的”任务特定消息队列“。与go语言1.1版本之前runtime对goroutine的调度类似,所有工作线程使用了一个公共的全局队列。skynet全局队列是一个循环队列,使用数组实现。使用全局队列不是一种很高效的消息调度方式,每个消息的出队入队一般都需要加全局锁。go1.1中,优化了调度器算法, 每个线程使用局部队列,性能提高很多(据称有些应用性能提升近40%)。
    skynet全局队列有多个并发生产者和并发消费者,通常情况下访问该全局队列时是需要加锁的。但skynet居然采用了一种wait-free的队列实现方式,看如下代码:
消息入队列:
C代码   收藏代码
  1. <span style="font-size: 14px;">static void  
  2. skynet_globalmq_push(struct message_queue * queue) {  
  3.     struct global_queue *q= Q;  
  4.   
  5.     uint32_t tail = GP(__sync_fetch_and_add(&q->tail,1));  
  6.     q->queue[tail] = queue;  
  7.     __sync_synchronize();  
  8.     q->flag[tail] = true;  
  9. }  
  10. </span>  
 
 消息出队列:
C代码   收藏代码
  1. <span style="font-size: 14px;">struct message_queue *  
  2. skynet_globalmq_pop() {  
  3.     struct global_queue *q = Q;  
  4.     uint32_t head =  q->head;  
  5.     uint32_t head_ptr = GP(head);  
  6.     if (head_ptr == GP(q->tail)) {  
  7.         return NULL;  
  8.     }  
  9.   
  10.     if(!q->flag[head_ptr]) {  
  11.         return NULL;  
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值