首页 / 资讯中心 / 文章详情

深挖skynet Worker线程调度机制:Actor模型下的并发引擎与调优

深挖skynet Worker线程调度机制:Actor模型下的并发引擎与调优 ★ FEATURED ARTICLE
关于 skynet很多人第一眼接触它时会先被它的 Actor 模型和 Lua 服务所吸引觉得“每个服务一个 Lua 虚拟机”这个设计很优雅。但真正让整个框架稳定跑起来、把成千上万个并发消息调度到不同核上的那个无名英雄其实是底层那群不断循环的 Worker 线程。平时做性能排查时如果只知道 skynet 有地址、有消息、有服务但对 Worker 线程的调度规则一无所知那遇到 CPU 高、消息积压、服务被饿死这类问题往往会无从下手。这篇内容我打算把 skynet 的 Worker 线程机制完整拆开讲一遍从它在整体架构中的位置到核心调度循环、队列竞争、配额策略再到实际观察和调优的手段。无论你是刚把 skynet 跑起来的新手还是已经开始排查线上问题的老手应该都能在这里找到对应的那部分答案。1. Worker 线程在 skynet 并发模型中的角色1.1 从 Actor 模型说起skynet 本质上是一个多线程的 Actor 系统。每个服务service是独立的执行单元服务之间通过消息通信。这个思想和 Erlang 的进程模型很像不同点在于 skynet 的服务既可以跑在 Lua 层也可以是纯 C 层服务而底层承载这些服务执行的就是一组启动时就创建好的 Worker 线程。一个常见的误解是“每个服务一个线程”其实不是。skynet 同进程内所有服务共享一组 Worker 线程任意一个线程在任意时刻可能执行任意一个服务的消息回调。也就是说你的服务代码跑在哪个线程上是不确定的同一个服务不同消息也可能落在不同 Worker 上。这种设计最大的好处是线程数量固定、可预测服务数量可以轻松开到几千上万个而不会把线程资源耗尽代价则是写服务代码时必须遵守框架心智模型——不要在回调里做长时间阻塞操作因为这会直接卡住某个 Worker进而影响其他服务甚至拖慢整个进程。理解了这个再看 skynet 的线程模型主线程负责启动和初始化创建若干 Worker 线程、一个定时器线程、一个 socket 线程以及一个日志线程。Worker 线程数量通过配置文件里的thread参数控制默认是 8。这个参数很关键它决定了并发度但并不是越大越好后面我专门展开说。1.2 Worker 线程的初始化与生命周期程序启动时主线程在做完基础初始化后会调用线程创建函数把 Worker 线程拉起来。每个 Worker 的核心就是一个大循环循环体里不断从全局消息队列取服务、取消息、调用服务回调。这个线程从启动后就会一直存在不会因为一段时间没有消息而退出最多在没有活儿干的时候通过条件变量进入睡眠等待状态等有消息进来再被唤醒。这里有个值得注意的细节Worker 线程并不是平均地在一个循环里“轮流”处理每个服务而是基于一个全局队列谁抢到谁执行。这就带来了竞争框架用原子操作和无锁队列尽量减少锁的开销但本质上多线程依然存在争抢。设计者在调度层面做了一定的概率均匀性处理希望所有 Worker 都能相对均匀地拿到任务避免某个线程空转而另一个线程累死。不过正因为这种抢任务的模式在实际高并发场景下仍然可能出现短暂的任务分配不均现象这是正常的不必一看到线程占用不均衡就紧张到怀疑框架出 bug。2. Worker 线程的调度核心机制2.1 先取服务再排队取消息Worker 线程调度的第一个关键规则是它从全局队列里取的不是一条消息而是一个带着消息的服务。每个服务私有一条消息队列当任意其他服务向它发消息时消息会先推入该服务的私有队列。如果这个服务当前不在全局队列里则会把它挂到全局队列尾部等待某个 Worker 线程来取。理解这个两级队列结构特别重要。全局队列用于高效地把活跃服务分发给多个 Worker避免每个服务一条消息都去敲同一个全局锁服务的私有队列则保证了消息原本的先后顺序。Worker 线程取到一个服务节点后会进入该服务自己的处理循环从私有队列里取出消息调用服务的回调函数处理然后继续取下一条。一直到什么程度才停这就涉及下一个关键点——配额quota。打个比方你就明白了全局队列像是食堂窗口外的队伍每个这个时段想吃饭的人活跃服务都在排队而 Worker 就是打饭阿姨。阿姨一次叫一个人进来这个人可以连续打好几份饭处理多条消息但不允许他一直在窗口前吃完整桌饭得给后面的人留机会。2.2 配额机制如何防止消息饿死如果某个服务消息量极大Worker 取出该服务后无限制地处理它的所有消息会导致其他服务的消息在全局队列里长时间无人问津——也就是所谓的服务饥饿。skynet 的解决办法是为每个服务设置一个消息处理配额默认值是 64。也就是说Worker 从全局队列取出某个服务后最多从该服务的私有队列中取出 64 条消息处理。处理完 64 条哪怕这个服务的队列里还有大量积压也必须把服务重新挂到全局队列尾部换别的服务被调度。这样做保证了高流量服务不会独占某一个 Worker同时其他服务也能在合理时间内获得执行机会。这个配额的实现并不是把消息取出来后才计数而是在取消息前就检查已经处理的数量。一旦达到配额上限当前 Worker 就会退出对当前服务的处理把服务重新推入全局队列。这里还有一个细节服务被重新挂回全局队列之前需要先把当前剩余的消息队列节点和新来的消息合并。因为在处理消息的过程中其他服务可能又向它发了很多新消息如果不做合并第二次调度它时就要处理整个链表节点效率会差很多。skynet 通过一个原子操作比较并交换队列指针来完成这个合并这也是无锁队列设计里比较经典的一个手法。2.3 Worker 之间如何竞争同一个队列所有 Worker 要从同一个全局队列抢服务这个夺权过程必须高效。skynet 在这里没有用互斥锁而是用一个自旋锁spinlock配合原子操作来保护全局队列的头节点。抢到锁的 Worker 可以取出当前队首的服务节点然后释放锁再去处理消息。因为一次取的是一个服务节点而不仅仅是一条消息锁的粒度比较粗实际争抢频率相对较低。不过我还想提醒一点自旋锁在单核机器上其实是比较危险的东西如果当前持锁线程被系统抢占其他 Worker 只能空转等待白白消耗 CPU。这也是为什么 skynet 在配置上通常建议 Worker 数不要超过物理核数单核或者核心数很少的机器上更要小心尽量少配置线程数或者干脆用单 Worker 模式避免自旋锁引发的性能倒挂。那么 Worker 线程没事干的时候在做什么答案是它会在一个条件变量上等待。全局队列为空时Worker 不会傻傻地持续空转而是调用条件变量等待函数进入睡眠状态。当有服务被重新挂回全局队列时会有一个唤醒动作把这个 Worker 从睡眠中拉起来继续干活。多个 Worker 同时睡眠时框架会逐个唤醒让大家都参与到接下来的任务分配中。这个机制对低负载场景特别友好能让 skynet 在空闲时的 CPU 占用降到很低像一个闭关修炼的人平时不出手一旦有事立刻上线。3. Worker 线程的完整调度循环拆解3.1 从全局队列取服务的循环每一个 Worker 线程启动后都会执行这样一个核心循环尝试从全局队列中取出一个服务节点。如果队列为空进入条件变量等待被唤醒后重新执行步骤 1。成功取到服务后初始化一个消息处理的计数上下文然后进入该服务的私有消息处理循环。从服务的私有队列中取出消息调用服务的回调函数。处理完一条消息后递增计数当累计处理达到配额默认 64时或私有队列已空时退出内层循环。在退出前检查服务是否还有新到的消息有则把服务重新挂回全局队列没有则直接结束对当前服务的处理回到步骤 1 继续取下一个服务。这里我特别想展开步骤 6 里的细节。Worker 退出处理时并不是无脑把服务重新入队而是先做一次比对用原子操作查看当前服务队列的头指针是否发生了变化。如果服务队列在处理期间又有新消息加入说明这个服务依然繁忙那就要把它挂回全局队尾如果队列已经空了就不需要再挂回去直接让服务恢复静默状态。这个设计避免了“空转服务”频繁进出全局队列的开销也保证了一个服务只有在确实有消息待处理时才会被调度。3.2 回调异常与消息循环的自我保护服务回调由业务代码实现可能出错、可能崩溃也可能长时间不返回。框架对这块做了处理服务在初始化时通常会设置一个消息处理函数Worker 调用它之前会先切换当前服务的上下文context并在执行过程中允许外部通过信号或控制接口请求关闭服务。这里有个非常实用的知识点如果某个服务的回调里发生了 Lua 报错或 C 层断言失败框架并不会让 Worker 线程直接退出而是捕获错误、记录日志然后继续循环处理下一条消息。这也是为什么很多情况下局部的服务崩溃不会拖垮整个进程。但反过来如果回调里出现了死循环或无限阻塞Worker 线程就会被永久占住这种 bug 靠框架本身是救不回来的只能靠外围监控来发现。所以我在实际项目里常跟团队强调任何服务回调里的循环都必须预留退出条件任何外部请求、数据库访问、网络 IO 都不能以阻塞方式等待。如果真的有这种需求就应该拆出去做成独立服务再通过消息异步回调或者用 skynet 提供的非阻塞 socket 接口和定时器接口来分片轮询。这不是框架设计的问题而是对共享线程池模型的基本尊重。4. Worker 线程与定时器、socket 线程的协同4.1 定时器线程如何向 Worker 注入任务Worker 线程只处理消息但消息的触发方式不只是别的服务发来的。skynet 的定时器消息就是一个特殊来源服务可以注册一个超时回调框架的定时器线程会持续维护一个时间轮timing wheel当某个注册的触发时间到达后定时器线程会向对应服务发送一条超时消息。这个消息同样进入服务的私有队列之后再由 Worker 线程在调度时取出执行。也就是说定时器线程本身不执行业务代码它只负责“到点发消息”最终的执行权还是握在 Worker 手里。这样的好处是定时器线程可以保持极短的阻塞时间即使有成千上万个定时器也不会拖垮整个进程。坏处则是业务方必须接受定时器的实际触发时间不保证精确在设定的那一刻而是“不早于设定时间”具体晚多少取决于 Worker 的忙碌程度。对做游戏服务器这种帧率不敏感的场景来说足够用但你要是拿它做精确到毫秒的金融交易调度那就得另想办法。我遇到过不少把定时器当精准时钟用的情况排查下来发现这类误解的根源都是没有把 Worker 调度模型吃透。牢记一句话定时器线程是闹钟Worker 线程才是干活的人。闹钟响了不一定马上有人来干但如果这个人迟迟不来问题多半出在 Worker 被某些阻塞操作拖住了。4.2 socket 线程的读写分离网络 IO 在 skynet 里也是一个独立线程负责叫 socket 线程。它负责轮询所有 socket 的事件连接建立、可读、可写、关闭把网络事件封装成内部消息投递到对应服务。真正读流量、写流量这些业务处理仍然由 Worker 线程来执行。socket 线程自己基本不做业务逻辑它更像一个高效的“信使”在操作系统网络栈和服务之间传递事件。回顾下进程内的线程全景Worker 线程负责业务消息调度定时器线程负责时间维度的唤醒socket 线程负责网络事件接入日志线程负责把日志异步落盘。这几类线程互不干扰、各司其职形成一个完整的事件循环体系。因此平时看top输出里的线程列表时你会发现 CPU 占用最高的往往就是那组 Worker而 socket 和定时器线程通常 CPU 占用很低。如果有一天 socket 线程的 CPU 异常飙高那你应该优先怀疑是不是有大量空连接扫 poll、恶意连接攻击或者单个 socket 的缓冲区配置不合理而不是把锅甩给业务逻辑。5. 实操观察和调优 Worker 线程5.1 先确认 Worker 线程数量和 CPU 核数的关系配置文件里的thread参数直接决定 Worker 数量。这个值怎么设我踩了不少坑最后的原则是不要超过机器的物理核心数尽量和物理核心数一致或略少。为什么因为 Worker 主要是 CPU 密集型逻辑线程数超过物理核数后额外的线程只会增加上下文切换和锁竞争不会带来吞吐提升。判断物理核心数的常用命令# 查看物理 CPU 核心数Linux grep physical id /proc/cpuinfo | sort -u | wc -l grep core id /proc/cpuinfo | sort -u | wc -l # 更直接的方式查 CPU 核心数 nproc如果你用的是云主机还要注意区分“vCPU”和物理核。超线程开启时nproc返回的是逻辑处理器数量往往等于物理核数的两倍。对 skynet 来说按逻辑核数设 Worker 数虽然也能跑但通常会因为超线程共享执行单元而出现明显性能衰减保险起见按物理核数或物理核数减一配置。另外一个常见误区是全部核心都给了一个 skynet 进程其他服务就没 CPU 可用了。如果你的机器上还跑着数据库、日志采集、监控 agent 等进程建议给 Worker 数量留出一点余量例如 8 核机器配 6 或 7 个。不要卡得太死不然某个瞬时高峰可能把系统整体拖进泥潭。5.2 用系统工具观察 Worker 线程的状态skynet 进程启动后你可以用top -H -p pid或者ps -eLf | grep skynet来观察进程内的线程列表。正常情况下你会看到类似skynet-socket、skynet-timer、skynet-log这类固定名字的线程以及多个以skynet-worker开头的 Worker 线程。观察时重点看两类数据各 Worker 线程的 CPU 占用是否大体均衡。Worker 线程的栈顶是不是停在skynet_context_message_dispatch、cond_wait这类函数附近。如果多个 Worker 的 CPU 占用相差悬殊例如某个 Worker 始终 90% 以上其他 Worker 只有 10%可以先检查是不是有某个服务的消息量特别大把某个 Worker 固定给占住了。虽然调度是概率性的但极端情况下某个 Worker 连续取到同一个高流量服务并非不可能。如果频繁出现就得考虑业务层做分流把高开销服务拆成多个实例或者检查该服务里是否有异常阻塞。当怀疑 Worker 卡死时最直接的排查手段是用gdb附加到进程然后打出所有线程的调用栈gdb -p pid -batch -ex thread apply all bt threads.txt在threads.txt里搜索所有skynet-worker的栈信息。正常的空闲 Worker栈上会停在条件变量等待相关函数上如果某个 Worker 栈上卡在一个业务调用函数里比如数据库客户端 API、加密运算、加锁等待那基本可以断定问题出在那个服务的阻塞调用。这个方法我线上用过多次每次都能快速缩小范围很管用。5.3 借助 skynet 调试控制台看消息积压观察 Worker 线程状态是系统级视角业务级视角则要靠 skynet 自带的调试控制台debug console。调试控制台是一个特殊服务启动时在配置里加一行控制端地址就可以通过网络连接上去执行各种管理命令。连接后输入stats命令能查到某个服务的消息队列当前积压的消息数量。当一个服务的积压数量持续增长、居高不下时原因无外乎两个要么是消息生产速度远超消费速度要么是当前服务的某个回调执行太慢导致同一个服务被 Worker 调度的频率不够。结合前面讲的配额机制服务在处理完 64 条消息后就会被挂回队列尾部如果业务回调本身耗时较长队列积压自然慢慢累积。此时优先看的不是配额数值而是业务回调的耗时分布。用luacov或自研统计在业务入口埋点统计每个消息的平均处理耗时和最大耗时通常很快能找出是哪个接口拖了后腿。比如玩家数据存储时同步调了一个外部 HTTP 接口这个消息可能会堵住服务队列好几百毫秒积压数字自然不好看。还有一类鸡生蛋蛋生鸡的问题服务 A 向服务 B 发大量消息B 处理不过来反过来 B 又把结果大量回发给 A两个服务同时积压。这种循环反馈靠单调靠调整 Worker 数量是解决不了的必须从业务协议或处理算法上做削峰、合并、批量处理。5.4 Worker 线程数的动态尝试与压测验证如果你不确定一个线上业务该配多少 Worker最稳妥的方式不是拍脑袋而是做一次对比压测。我建议的做法是在同一台机器上用不同thread配置分别启动 skynet跑同一套压力脚本观察最大 QPS、P99 延迟和 CPU 使用率。把测试结果放到一张表里对比很容易找到拐点。压测时注意一点客户端压测工具本身也会占用 CPU务必跑在另一台机器上否则测试结果会被压低。如果你只能在同机测试可以把压测客户端的 CPU 亲和性绑定到一部分核心把 skynet 绑定到另一部分核心尽量减少干扰。实测下来一个典型游戏逻辑服在 8 核机器上Worker 数设 7 和设 8 的吞吐差距有限但设 4 时延迟会有明显上升。如果业务消息里包含大量逻辑计算和访问共享内存的操作反而会看到 Worker 数越往上性能越差不升反降。所以压测不是证明线程越多越好的过程而是帮你找到那个“恰好的值”。6. 常见问题与排查心得实录6.1 Worker 线程 CPU 占用不均衡现象top -H里某个 Worker CPU 100%其他 Worker 都只有 20% 左右。这种不均衡常见原因有三个某个服务消息量特别大多次被同一个 Worker 抢到造成了短暂的聚集效应。但注意这个聚集不会持续太长时间因为服务每次最多处理 64 条消息就得回全局队列排队。某个服务回调内部有长耗时操作比如循环 10 万次做字符串拼接虽然消息条数不多但每次处理都会占用 Worker 很长时间。这类问题不会随时间消散CPU 占用会固定在某个 Worker 上。触发了自旋锁的忙等通常是 Worker 数超过核数导致的多线程在抢同一个锁时持续消耗 CPU。排查顺序建议先确认 Worker 数和核数的关系没问题再看具体服务热点用perf top -p pid这类工具能直接看到正在执行的用户态函数非常直观。比如看到某个 Lua 模块的 C 函数占比极高那基本锁定业务热点位置。6.2 服务消息队列持续积压但 CPU 不高如果服务积压很多消息但 Worker 总体 CPU 占用不高反而要警惕。低 CPU 高积压往往意味着 Worker 被阻塞住了而不是正在拼命算。常见的阻塞点有同步调用外部服务。服务回调里直接发起 socket 连接并等待响应返回而这个 socket 不是 skynet 的非阻塞模式导致回调迟迟不返回。文件 IO 或数据库同步操作。虽然这笔操作本身很快但在高负载场景下一次同步写盘可能阻塞几十毫秒积少成多就成了积压根源。锁等待。不同服务之间共享了某个全局锁比如一个跨服务的单例表持锁方回调卡住其他服务消息全堵在锁外。这类问题用 gdb 看线程栈最清晰。栈上会直接显示当前 Worker 卡在哪个函数里例如卡在read、write、pthread_mutex_lock这类系统调用上就能立刻定位到阻塞源。之后针对阻塞源做异步化改造把同步调用改成异步请求回调消息的模式积压问题会明显缓解。6.3 Worker 线程数配置过高导致性能反而下降我见过一个项目16 核机器上把thread配成了 64 个自认为“充分发挥多核优势”。结果压测数据惨不忍睹吞吐量只相当于 8 线程时的 60%CPU 整体占用倒是满了大部分都消耗在锁竞争和上下文切换上。这类问题的典型特征是整体 CPU 使用率高但业务有效处理量不升反降。用vmstat能看到cs上下文切换数值飙到几十万甚至上百万。解决方式就是按物理核数重新配置 Worker 数量在 16 核机器上配 16 或 14压测后当场见效。这里要提个醒如果你在容器里运行 skynet务必确认容器实际分配到的 CPU 配额而不是看宿主机多少核。容器限制了 4 核你配 8 个 Worker一样会触发过载问题。7. 我对 Worker 线程理解的一些实际体会工作这么多年看过的 skynet 项目大大小小也有不少最后说几条自己的体会希望能帮后来的同行少走弯路。第一个体会是真正的性能瓶颈往往不是框架本身而是你在回调里写了什么。框架已经保证了消息调度的公平和高效但如果你在回调里做同步数据库操作、调外部 HTTP、跑大循环再多的 Worker 也救不回来。学会把一切可能阻塞的操作异步化是使用 skynet 最重要的基本功。第二个体会是观察 Worker 线程要形成习惯不要等出问题才去看。我自己的做法是每次变更代码后都会压一遍测顺手看一眼线程分布和消息积压情况。哪怕只是加了一条日志也值得观察一下。因为一个日志接口写多了处理耗时被拉长消息积压也会逐渐变大这类性能退化只有靠持续观察才能尽早发现。第三个体会是关于文档和源码的态度。skynet 的源码并不难读尤其是skynet_server.c和skynet_mq.c这两个文件把 Worker 调度的骨架写得非常清晰。遇到不确定的问题直接翻源码往往比问来问去更快、更准。比如配额为什么是 64、全局队列为什么用自旋锁这些在代码里都有明确答案。读懂源码之后你再回头用框架会感觉明显从容很多。
阅读完成 · 觉得有帮助?
咨询建站