
最早听到skynet这个名字还是在一次技术交流会上。当时有位同事在台上讲服务器架构演进PPT里放了几张代码片段底下冒出一句“这不就是erlang那一套嘛”。那时候我只知道skynet大概是个游戏服务器框架但没真正用过。后来自己动手搭项目才慢慢体会到这个框架的设计思路和实际落地的细节。今天这篇文章我不打算把它写成官方文档的复述而是从一个实际用过的开发者角度聊聊skynet到底解决什么问题、怎么上手、有哪些坑。如果你正在选型游戏后端或者对Actor并发模型感兴趣这篇文章应该能给你一些参考。1. 先搞清楚skynet到底是个什么东西1.1 它本质上是“一个进程里的分布式系统”很多人第一次听skynet的定位会被“分布式”这个词吓到其实它解决的问题很朴素你的服务端有很多功能模块比如登录、匹配、战斗、聊天、排行榜这些模块之间要互相通信还要能并发处理大量玩家请求。常规做法是拆成多个进程用HTTP或TCP互相调但这样通信开销大、部署麻烦。skynet的做法是把这些模块变成一个个服务service全部跑在同一个进程里服务之间用消息传递来协作。同一个进程内的消息投递成本远低于跨进程网络调用这是它高性能的基本盘。这里的“服务”并不是传统意义上独立部署的微服务而是一个个被框架调度的执行单元。每个服务有自己的消息队列框架底层的线程池会不断从队列里取出消息交给服务对应的处理函数。你不用自己写锁、写条件变量框架把并发细节都包掉了。关键点在于每个服务默认是单线程执行的。也就是说你在一个服务内写代码不需要担心同一时刻有两个线程同时在跑你这个服务的逻辑。这是Actor模型的核心约定——串行处理消息天然避免了共享内存竞争问题。1.2 消息驱动和回调风格skynet里最核心的抽象就是消息。一个服务给另一个服务发消息消息可以是字符串、Lua表、二进制数据等。接收方注册一个回调函数消息到了以后框架会调用这个回调。这种风格和很多前端开发者的经验很一致——事件驱动写的时候把逻辑拆成一个个回调。不过回调风格也带来一个学习门槛你不能再按“顺序执行”的直觉写代码。比如A服务请求B服务拿数据拿不到立刻返回你的业务逻辑会被截断只能写在回调里。好在skynet提供了skynet.call这样的同步封装看起来像同步调用底层是发消息后挂起当前服务等回应再唤醒。这大大改善了写业务的体验。这里有一个容易踩的误区skynet.call虽然写起来像阻塞调用但它并不真的阻塞线程。框架找到一个合适的时机把当前服务挂起调度其他服务执行。所以即便很多个服务同时做skynet.call系统资源还是能被充分利用。2. 为什么选它对比其他方案时我在想什么2.1 单进程多线程模型带来的部署优势做游戏服务器的人都知道传统多进程部署方案里最麻烦的是模块间的通信和运维管理。模块多了进程数量动辄几十个每个都要配端口、配启动参数、做监控。skynet把所有服务放进一个进程里部署时只需要启动一个可执行文件服务之间的消息走内部队列不走socket。这样不光省了端口资源连日志追踪都简单了一份日志文件里能看到所有服务的运行情况。这带来的直接好处是开发效率高。改完代码重启一个进程就行不用管一堆进程的启停顺序。有个同事调侃说用skynet最大的感受是“不用写进程管理脚本了”。对于十人左右的小团队来说这省出来的时间非常可观。2.2 Lua和C的组合恰好踩在舒适区skynet的核心是用C写的负责消息调度、网络IO、底层的并发处理。业务逻辑主要用Lua写。这是它一个很聪明的设计C部分够快Lua部分够灵活。Lua这个语言虽然小众但和C的亲和力极强嵌入成本极低。写业务逻辑的时候Lua表这种灵活的数据结构很适合描述游戏内的玩法配置、实体属性。热更新也方便改完逻辑只要重新加载脚本不用重新编译整个服务端。这一点在快速迭代的游戏研发期非常加分。当然Lua也有它让人头疼的地方比如没有静态类型检查报错信息有时候不够直观数组起始索引是1而不是0。但这些代价换来的是开发效率和热更新能力权衡下来还是划算的。2.3 和自研框架、无状态网关方案对比我也见过有人不用skynet自己用C或Go写一套服务器框架。自研的好处是贴合自己项目但代价是你要自己处理并发模型、消息序列化、断线重连、连接管理这一大堆工作。这些工作看起来不难但要做到生产级稳定投入的时间和精力远超预期。很多自研框架最终都死在“能用”和“稳定”之间的鸿沟上。而纯无状态网关加HTTP短连接的做法对于棋牌类、弱实时类游戏够用但对MMO这种需要长连接、频繁状态同步的场景就不太合适了。skynet的定位正好卡在中间它给你的不是一堆组件的拼装积木而是一个已经想清楚并发模型和网络方案的完整骨架你只需要往里面填充业务。3. 从零开始环境准备和第一个服务3.1 拉代码、编译、跑起来skynet的编译过程不复杂Linux环境下需要准备编译工具链和autoconf。获取源码后在根目录执行make linux正常情况下就能生成skynet这个可执行文件。编译过程中如果缺了某个依赖一般会提示找不到头文件根据提示补装就行。加载配置文件启动./skynet examples/config.myserver配置文件用的是类似ini的格式框架启动时会读取这些配置决定线程数、日志路径、启动服务等。第一次跑的时候建议直接用自带的示例配置确认能起来再改。注意如果用Mac编译可能要执行make macos而不是make linux这取决于你当前的工作环境。另外编译时不要关闭-O2优化否则对性能影响比较明显。3.2 写一个最小可运行的服务一个最简单Lua服务大概是这样的local skynet require skynet skynet.start(function() skynet.error(Hello skynet) end)保存成hello.lua然后在配置文件的start项里指定入口服务。启动后控制台会打印“Hello skynet”。看起来很简单但这里有个隐藏的细节skynet.start接受的函数并不会立刻执行要等框架初始化完成把当前服务注册进调度系统后才执行。所以你在start之前不能随便调用skynet.send这些API否则会报错。更重要的是这个服务跑起来后不能立刻退出。如果start回调执行完函数返回了服务就进入空闲状态但并不会退出进程。它只是安静地待在调度器里等其他服务给它发消息。3.3 配置文件的几个关键项thread 8 logger logger logpath . start main harbor 0thread框架底层线程池的线程数一般设为CPU核心数或略多。注意并不是越大越好因为每个服务串行执行线程太多反而增加上下文切换开销。start入口服务填的是Lua脚本文件名框架启动时会加载它。harbor是否开启集群模式0表示单节点模式。这个在只做游戏逻辑开发时保持0就行。preload可以指定一个预加载脚本在服务启动前做一些全局初始化比如加载通用库、设置随机种子等。配置项里还有一个容易被忽略的bootstrap。它决定框架启动时的第一个服务是谁默认是snlua启动Lua服务的原生服务。正常情况不需要改但理解它有助于排查启动链路问题。4. 服务间的通信机制send、call、response4.1 区分send和call的适用场景服务间通信最常用的两个API是skynet.send和skynet.call。用send是纯粹单向投递发完就返回不关心对方什么时候处理适合日志、通知类消息。用call会阻塞等待对方响应适合需要得到返回结果的请求比如拉取玩家数据、查询排行榜。有次我写一段逻辑时把一个需要返回结果的操作用了send调试了半天对方服务里迟迟没有结果。后来才发现是API用错了。这里给个判断标准如果你需要依赖响应结果才能继续下一步就一定是call如果只想触发对方做一件事又不用知道做没做完用send。还有一个经验控制消息频率时大量send调用会让接收方消息队列积压。如果业务上能容忍偶尔丢失可以考虑用skynet自带的socket通道自己控制发送节奏如果一定要保证不丢就要监控队列长度及时做削峰。4.2 请求背后发生了什么skynet.call(target, lua, getinfo, uid)这个用法里第二个参数lua表示消息类型。skynet支持不同的消息协议最常用的是lua底层会把Lua函数名和参数打包成一个块传给目标服务的command回调。目标服务注册一个处理函数local CMD {} function CMD.getinfo(uid) return get_player_info(uid) end skynet.dispatch(lua, function(_, _, cmd, ...) local f CMD[cmd] if f then return f(...) end end)dispatch那里第三个参数cmd就是从消息里解出来的函数名第四个...是参数列表。这种dispatch写法是skynet社区最常见的模式相当于给服务定义了一张“API表”。注意dispatch回调是串行执行的也就是说同一个服务不会同时执行两个dispatch回调。但正因如此如果你在回调里做了重量级计算其他消息都会被堵住整个服务的吞吐量就下去了。所以耗时操作要么放到专门的worker服务要么拆成多步异步任务。4.3 服务命名和地址发现机制每次启动一个服务skynet会为它分配一个全局唯一的地址一个32位整数。这个地址类似进程内的端口号服务间通信靠它寻址。但地址是动态分配的不方便记忆。框架提供了skynet.register在一个全局表中注册一个名字其他服务就能用skynet.name xxxx或者skynet.localname来查询名字对应的地址。我自己习惯的做法是关键服务在启动时注册一个常量名比如login_svr、match_svr其他服务通过名字调用这样即使启动顺序变化、服务重启导致地址变化代码里也不用改。有个坑是名字冲突。如果两个服务重复注册同一个名字后注册的会覆盖前一个造成消息发到错误的地方。所以注册的唯一性约束最好由团队规范来保证代码审查时多留意。4.4 超时机制的重要性skynet.call在设计上是有超时概念的。如果目标服务一直不响应调用方会挂起等待。框架里给call配置了超时时间。默认情况下超时后会返回一个错误码你需要在代码里判断返回值。实际线上遇到过A服务call B服务B服务又在等C服务的响应而C服务因为某些原因卡住了导致A等B、B等C整个调用链挂起。这种情况除了代码层面的逻辑梳理还可以依赖框架提供的调试控制台debug console来查看各服务状态。后面我会讲到这个工具。5. 进阶玩法定时器、集群与热更新5.1 定时器和时间轮询游戏服务器里定时任务特别常见比如活动开启、排行榜重置、战斗超时判定。skynet提供了skynet.timeoutskynet.timeout(100, function() -- 100个ticks之后执行 end)这里的100是ticks默认1个tick等于1/100秒也就是10毫秒。如果想做周期性任务就在回调里再注册一次。一种常见的误差陷阱是skynet.timeout不是硬实时定时器它依赖服务消息队列的调度。如果你这个服务同时有很多消息在处理timeout回调可能延迟执行。所以对实时性要求高的定时逻辑不要靠这个API硬撑可以考虑放到专门的实时线程服务里。5.2 跨节点通信cluster的使用场景skynet的harbor 0是单节点模式如果要跑多节点集群需要开启harbor并配置节点名。跨节点通信时消息要经过网络传输不像单节点内部那样零成本。我在项目初期用单节点把所有玩法跑通后来压测发现单进程扛不住在线人数才拆节点。拆的教训是如果预判项目会上一定规模一开始就应该把服务按“可跨节点部署”的边界划分好。比如把登录、网关、战斗分成不同服务并约定好它们之间的接口是跨节点的。否则后面再拆通信API改动量很大。cluster模式下节点之间通过cluster.call和cluster.send通信语义上类似单节点内的call和send。区别在于跨节点要多传一份节点名而且要额外考虑网络分区、重连等问题。这部分文档比单节点少我自己的建议是能单节点就别急着上cluster但代码里尽量通过封装调用来隔离“跨节点成本”给未来扩展留好余地。5.3 热更新的常规操作和潜在风险Lua服务支持热更新这是很多团队选skynet的重要原因。标准做法是改完脚本后用调试控制台或者自定义管理服务对目标服务执行reload。这会重新加载脚本文件但要注意热更新不能保存旧服务内存里的状态。如果你的服务用全局表维护了一堆运行时数据reload后这些数据会丢失。我见过一次事故有人热更了在线玩家数据表结果所有玩家在线状态都掉了客户端被迫重新登录。后来我们定了一个规矩凡是要热更的服务必须把关键状态要么存到独立的数据服务里要么存到数据库服务自身只保存可重建的临时数据。这样reload以后的损失最小。另一个风险是reload时正在处理中的消息可能被打断。skynet的reload机制虽然做了不少保护但极端情况下可能出现消息丢失。所以热更新要避开高峰期并在更新后重点观察日志。6. 实战踩坑实录这些问题你一定也遇到过6.1 在服务里写了阻塞操作整个系统卡住这是新人最容易犯的错。skynet的线程池是共享的如果你在Lua服务里调了一个socket的同步接收、或者执行了一个重量级的文件读写、或者做了一个耗时的数据库同步查询这个服务会占住线程直到操作完成。理论上如果线程池有空闲线程可能还能继续调度但一旦所有线程都被类似操作占满整个进程就“假死”了。我在项目里架过一套数据库访问层刚开始图省事直接用乐高SQL驱动做同步查询结果压测时连接数一高进程响应时间暴涨。后来改成封装一套异步存储服务用消息转发和回调处理数据库结果问题才缓解。记住一条铁律永远不要在服务逻辑里做阻塞IO如果需要就让专门的服务去做或者用框架提供的异步socket API。6.2 服务间的循环依赖A服务启动时要注册自己的名字然后call B服务获取一些配置同时B服务启动时也要call A服务获取一些信息。两个服务同时启动互相等待对方响应结果两边都挂起。这种循环依赖在启动阶段尤其隐蔽因为单服务测试时正常只有一起启动才暴露。解决办法是让启动时的初始化数据变成单向依赖或者把共享配置抽取到一个独立服务。如果一定要双向依赖可以用异步回调方式先启动AA不立刻初始化等B起来后主动向A发消息A收到消息再继续初始化。6.3 用调试控制台排查消息积压skynet自带一个很有用的调试控制台服务配置里加上console 8888运行后用telnet连上去就能查看当前所有服务的地址、名字、消息队列长度等状态。排查线上问题第一步就看哪个服务的消息队列在持续增长。队列积压说明这个服务的处理速度跟不上消息生产速度要么是消息风暴要么是内部有耗时操作。我自己常用的排查步骤是先连控制台看队列Top10找出积压严重的服务再通过list命令看服务地址和状态最后针对可疑服务看最近日志里有没有报错或者慢操作。list控制台命令不多但足够做基础排障。用好这个工具比瞎猜日志高效得多。6.4 不敢改框架源码其实可以慢慢啃很多开发者把skynet当成黑盒遇到问题只看自己业务代码。但框架本身开源的出了问题如果愿意沉下心看C层代码很多疑惑能直接解开。我第一次排查消息丢失问题时折腾了两天业务代码没找出原因后来耐着性子看了skynet_mq.c里的消息队列逻辑才发现是队列容量满了以后做了丢弃策略。从那以后我在定位问题时会两条线并行上层看业务逻辑下层看框架实现。不要一上来就啃全部C源码那样很容易劝退。先从这几个文件入手skynet_server.c服务的生命周期和消息分发、skynet_mq.c消息队列、skynet_socket.c网络事件处理。这三个文件读明白你对skynet的理解会上一个大台阶。6.5 部署和运维方面的注意事项最后说说部署。skynet编译出来是一个单可执行文件但线上运行除了它还需要Lua脚本目录、配置文件、日志目录。我习惯把所有相关文件打个tar包配上systemd服务或supervisor来管理进程。注意配置daemon选项可以让进程以守护方式在后台运行日志轮转也要配好否则日志文件一直膨胀把磁盘撑爆。内存占用方面如果一个人口密集的在线大区几十个服务在一个进程里跑下来RSS能到几个GB。建议给进程设置内存上限并配合监控告警。Lua的GC有些时候不及时存在内存锯齿上升的情况可以在空闲时段主动调用collectgarbage(collect)做一次回收但不要在业务高峰期频繁做否则反而拖垮性能。最后再分享一点自己的体会选型skynet本质上是在选择一个“想清楚了并发模型”的框架而不是选择一堆库自己拼拼凑凑。它把多线程、消息队列、网络事件这些底层问题替你扛住了你只需要专注业务。但这也意味着你必须认同它的设计理念以服务为边界以消息为语言。一旦你逐渐适应了这种写法再回头去看传统多进程架构里那些端口配置、重连机制、加锁同步会觉得skynet真的是用最少的口子把问题解决了。我在实际项目里接触skynet大概三年从最开始照着示例改到后来能自己调试C层定位问题中间踩过的坑几乎都能归结到对“服务串行、消息隔离”这两个原则理解得不够。如果你也想在项目里用skynet我的建议是先别急着上复杂功能试着写一个包含登录、房间、聊天三个服务的小Demo亲手跑通服务间的call、send和定时器调度再慢慢展开架构。这个框架的社区不算大但文档和源码都摆在那里只要肯下功夫摸清它的脉络也就是几个星期的事。