ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入理解skynet:Actor模型游戏服务端框架原理与实战

深入理解skynet:Actor模型游戏服务端框架原理与实战

1. 先说清楚:skynet到底是什么、解决什么问题

如果你所在的项目还在用"一个线程跑一个大循环、所有模块共享内存、遇到并发就加把大锁"的方式写游戏服务端,我建议你花点时间认真看看 skynet。它名字听起来像科幻片里的天网,实际上是一个非常轻量的开源游戏服务端框架:C 写底层,Lua 写业务,核心模型就是 Actor/服务模型。我第一次把它的设计思路理清之后,最大的感受是:原来"进程内多服务并发 + 无锁消息"可以做得这么干净。

skynet 解决的问题,概括起来就三件事:第一,让一个服务端进程里可以同时运行大量互相隔离的业务模块;第二,模块之间只通过消息通信,不共享内存、不加业务锁;第三,因为业务跑在 Lua 虚拟机里,所以天然具备很强的热更新能力。简单说,它给你的是"在一个进程里编排一群独立小服务"的能力,而不是一套完整的游戏引擎。

很多人第一反应是拿它和 Web 后端框架比较,其实它们根本不在一个赛道上。Web 后端最常见的模型是"请求进来、开一个 goroutine/协程处理、处理完返回",水平扩展靠多实例部署;skynet 的假设更接近"一个服务器进程就是一个大区",里面各种服务从启动开始就常驻,玩家数据、场景状态、匹配队列都是活的,服务之间通过异步消息互相调用。它不是 RPC 框架,也不是 HTTP 框架,消息类型可以自定义,连"协议"都只是消息里的一个字段。

那什么人适合读这篇?我觉得是这么几类:正在用 skynet 或者考虑引进 skynet 的游戏服务端开发者;对 Actor 模型有兴趣、想看看一个具体实现长什么样的工程师;以及那些写传统 C++ 网络服务、想找一个不那么重的参考方案的同行。自认为它不好的读者也不用着急走,我后面专门用一节讲它的边界。

2. 拆解核心设计:服务、消息、调度、协程

2.1 服务(Service):一个 Actor 就是一条隔离的逻辑线

skynet 里最重要的概念是 Service,中文社区一般叫"服务"。一个服务可以是一个 C 模块,也可以是一个独立的 Lua 虚拟机,后者在业务开发里最常用。每个服务拥有自己的消息队列、自己的状态,服务之间没有共享内存,只能通过消息通信。

这个设计和 Erlang 的 Actor 模型是一个思路。一个服务崩溃了,不会自动把其他服务一起带崩;你甚至可以针对单个服务做重启。对游戏服务端来说,这是非常对味的设计,因为游戏服务天生就是"一堆有状态模块"(玩家在线管理、场景、背包、匹配、排行榜),它们之间自然是相互独立的,共享数据其实可以通过"把数据收拢到某个服务里、其他服务用消息来读写"解决。

用生活化的比喻:每个服务就像一栋楼里的一间独立办公室,办公室之间只有一条内部快递通道。你可以在走廊加一个办公室,也可以让某个办公室整个重装修,而走廊里其他办公室照常干活。这个比喻基本就是 skynet 的运作方式。

2.2 消息的一生:从 send 到 dispatch

理解 skynet,最核心是弄懂一条消息从发出到被处理经过了什么。这里说的消息不是网络包,而是进程内的消息。当某个服务调用skynet.send(target, "lua", "hello")时,底层会构造一个包含这些内容的消息:

来源服务 handle、目标服务 handle、session 会话号、协议类型、数据块

消息会进入目标服务自己的消息队列。skynet 的调度器维护着一个全局待调度链表,哪个服务队列里有待处理消息,就把这个服务挂到链表上。工作线程从链表里取出一个服务,从它的队列中弹出消息,然后调用这个服务注册的 dispatch 回调。整个调用链可以简化成:

skynet.send(target, "lua", "hello") → 生成一个带 source/session/type/payload 的消息 → 进入 target 的消息队列 → worker 线程从全局链表调度到 target → 执行 target 的 dispatch("lua", callback)

这里要特别解释 session 字段。session 是用来匹配"请求-响应"的编号。调用方发请求时会给消息带上一个 session,被调用方想回包时把同样的 session 带回来,调用方就知道这是哪个请求的响应。如果某个消息的 session 为 0,就代表发送方不需要也不期待回包,相当于纯通知。

2.3 调度线程与"无锁"哲学的边界

配置里有一个thread参数,它决定启动多少个工作线程。很多人误以为工作线程多了就能随便并发,其实不是。skynet 的调度是这样的:一个服务同一时刻只会被一个工作线程处理,它的消息是一个一个按顺序执行的。也就是说,同一个服务内部的逻辑是天然串行的,不需要加业务锁。

全局待调度链表本身需要一把锁来保护,但锁只发生在调度层,业务代码层面完全无锁。这个设计非常聪明:把并发控制的复杂度收拢到调度器里,让写业务的同学可以像写单线程程序一样写每个服务。

代价也很明确:一个服务里如果出现死循环或者超长耗时操作,它就会一直占着一个工作线程不放。如果多个服务同时出问题,所有工作线程都被卡住,整个进程就假死了。所以有一条黄金规则:不要在 dispatch 回调里做阻塞操作,不要把耗时计算直接写在主逻辑里。这条规则后面踩坑部分我会细说。

2.4 协程把异步回调掰成了同步调用

游戏服务端写业务最讨厌的就是回调地狱。skynet 用 Lua 协程解决了这个问题,核心 API 是skynet.call。skynet.call(target, "lua", "ping")看起来是同步等待返回,实际上底层是挂起当前协程,让出执行权,等响应消息回来后框架再恢复这个协程,把返回值交回给调用点。

这时候一个服务能同时处理很多消息吗?可以。假设服务 A 同时向 B、C、D 三个服务发起 call,每次 call 挂起的是各自协程,服务 A 的消息循环并没有被堵死,还能继续处理 B 和其他服务发来的新消息。响应回来后,框架自动把对应协程唤起。

理解了这一点,你就能明白skynet.fork的价值。它可以在一个服务里创建新的协程去执行后台任务,但注意:这些协程共享当前服务的状态,而且这个服务还是只有一个线程在跑。协程跟协程之间不是真正的并行,只是通过主动让出(比如等待消息、睡一会儿)来交替执行。要是哪个协程自己写了个while true do end,整个服务照样会被卡死。

3. 从零搭一个可运行的服务并跑通通讯

3.1 编译、启动与配置文件

先做环境准备。把 skynet 源码拉下来后,在源码根目录执行:

make linux

编译会生成一个skynet可执行文件。启动方式很简单:

./skynet examples/config

config 就是它的配置文件,常见字段长这样:

root = "./" thread = 4 logger = "skynet.log" harbor = 0 bootstrap = "snlua bootstrap" start = "main" luaservice = root.."service/?.lua;"..root.."examples/?.lua" lualoader = root.."lualib/loader.lua" lua_path = root.."lualib/?.lua;"..root.."lualib/?/init.lua" lua_cpath = root.."luaclib/?.so"

这里最关键的几个字段:thread是工作线程数,start是 bootstrap 起来后要启动的第一个 Lua 服务名,luaservice指明了去哪里找 Lua 服务文件。不同版本配置字段会有差异,但大致结构就是这样。第一次跑通时不要贪多,先从一个 main 服务开始。

3.2 第一个服务:main + helloworld

在 examples 目录下写一个最简单的 main.lua:

-- examples/main.lua local skynet = require "skynet" skynet.start(function() skynet.newservice("helloworld") skynet.error("main service started") skynet.exit() end)

再写 helloworld.lua:

-- examples/helloworld.lua local skynet = require "skynet" skynet.start(function() skynet.error("hello world from helloworld service") skynet.exit() end)

启动后你会看到日志里先出现 hello world,再出现 main service started。这里注意一个细节:main 服务用skynet.newservice("helloworld")启动新服务后,自己就skynet.exit()退出了,但 helloworld 服务会继续运行。skynet 里每个服务的生命周期是独立的,某个服务退出不影响其他服务。

skynet.start是所有 Lua 服务的入口。框架先创建服务、初始化 Lua 虚拟机,然后在一个协程里执行这个函数。启动函数返回后,服务不会自动退出,它会继续停在消息循环里,只有收到退出消息或者自己调用skynet.exit()才会结束。

3.3 服务间通讯:send / call / ret 的完整写法

光会启动服务没有意义,真正核心的是服务之间怎么通讯。实现一个最简单的 ping/pong 服务:

-- examples/ping.lua local skynet = require "skynet" skynet.start(function() skynet.dispatch("lua", function(session, source, cmd) if session ~= 0 then skynet.ret(skynet.pack("pong")) end end) end)

main 服务这样调用它:

-- examples/main.lua local skynet = require "skynet" skynet.start(function() local ping = skynet.newservice("ping") local ok, result = pcall(skynet.call, ping, "lua", "ping") if ok then skynet.error("call result:", result) end skynet.exit() end)

这段代码有几个知识点必须吃透。skynet.dispatch("lua", callback)注册的是对"lua"类型消息的处理函数,回调参数第一个是 session,第二个是来源服务的 handle,后面跟的是消息内容。session 不等于 0 说明这是一个需要回包的请求,所以要调用skynet.ret(skynet.pack(...))把结果发回去;session 等于 0 说明对方只是 send 通知,不需要回包。

skynet.pack是把返回值打包成消息数据,skynet.ret负责回包。调用方用skynet.call(ping, "lua", "ping")发出请求并等待结果,拿到的是目标服务skynet.ret解开后的返回值。怕目标服务崩掉导致 call 抛错,我习惯在外面包一层pcall。

如果只是想发个异步通知,用skynet.send就行了:

skynet.send(ping, "lua", "hello")

发送方不等结果,接收方会照常收到消息并进入 dispatch。

3.4 服务生命周期与 watchdog 管理

服务多了以后,谁来管理它们?答案是 watchdog 模式。用一个常驻的"看门狗"服务负责创建核心业务服务,监控它们的健康状态,异常退出后重新拉起。

skynet 支持给服务起名字,比如skynet.name("ping", ping),其他服务就可以通过skynet.queryservice("ping")拿到它的 handle。每次拿到的是最新的 handle,所以如果 ping 服务重启过、handle 变了,只要名字重新注册,调用方拿到的就是新服务。

当某个服务异常退出时,框架会通知它的启动方(也就是创建它的那个服务)。这类系统级消息在 skynet 里是独立于 "lua" 协议类型的。很多项目会在 watchdog 里专门处理这类通知,实现"某个服务挂了,自动拉一个新的同名服务起来"。这个机制是线上稳定性的基石,但不同版本的具体消息字段有差异,接入前一定要看当前版本源码里的处理逻辑。

4. 热更新、集群与扩展思路

4.1 skynet 热更的本质:服务隔离 + Lua 动态性

游戏服务端基本都有热更需求,毕竟玩家不可能等你把整个服务器重启。skynet 能热更,本质靠的是两层基础:一是服务之间消息隔离,某个服务可以单独重启;二是业务逻辑跑在 Lua 里,Lua 是解释执行、可以动态加载代码的。

但这里必须说实话:skynet 官方并没有给你一个"一键热更"的银弹,热更能力需要你自己在工程层面设计好。常见的做法有两种。

第一种是服务级热更。适合无状态、或者状态可以重建的服务。比如匹配服务、登录校验服务,玩家量少的时候停一下问题不大。做法很简单:把老服务停掉,启动新代码的同名服务,让调用方通过skynet.name / skynet.queryservice拿到新 handle。

第二种是模块级热更。适合那些不能重启的长驻服务。具体做法是:把业务处理函数集中到一张表上,调用点通过表来查函数。需要更新时先清掉package.loaded里对应模块再重新 require:

package.loaded["mylogic"] = nil local mylogic = require "mylogic"

这样新来消息调用mylogic.dispatch(cmd, ...)时,实际拿到的就是新代码。如果业务代码到处直接引用函数、到处存闭包,热更就会失效。所以从第一天写代码起就要约定:热更模块必须通过 require 出来的表来调用,不能把函数引用长期外泄。

4.2 服务重启热更的实操要点

服务重启看着简单,实际操作有几个很实在的注意点。

首先,状态一定要外置。玩家数据、排行榜、商店刷新这类可变状态,尽量放到数据库或专门的存储服务里,逻辑服务保持"无状态化"。这样重启逻辑服务时,你完全不需要担心内存里的进度怎么搬。我见过团队把玩家在线对象全存在一个逻辑服务的内存里,一热更就掉线丢数据,后来才老老实实把关键数据落到存储层。

其次,网络连接要单独放。真正接手客户端连接的 gate/agent 服务,如果重启,所有在线连接都会断。所以一般会把"维持连接"和"业务逻辑"拆分:gate 服务只管收发字节、解析协议,业务服务处理完再通过 gate 把消息发回客户端。这样业务服务可以随时重启,客户端掉线后只要重连重建会话。

最后,命名要抽象。调用方不要到处存对方服务 handle,而是通过名字查询。服务重启后,名字重新注册,调用方自然拿到新 handle。如果某些代码死板地保存了旧 handle,就会出现消息发给一个已经退出服务的尴尬局面。

4.3 跨进程、跨机器:cluster 集群

单机模式总有扛不住的时候。skynet 的跨节点方案是 cluster,它让你可以把消息发给另一台机器上的 skynet 进程里的某个服务。核心 API 有三个:

  • cluster.newservice(node, servicename, ...):在远端节点创建服务
  • cluster.call(node, servicename, ...):跨节点同步请求
  • cluster.send(node, servicename, ...):跨节点异步通知

使用前要在配置里声明本节点名和远端节点列表。启动后每个节点会有一个 cluster 服务专门维护节点间的 TCP 连接,负责消息转发。

我用下来的体会是:cluster 是粗粒度的扩展手段,不适合高频小消息。跨节点一次 call,走网络、序列化、再进入目标服务消息队列,开销比进程内消息大几个数量级。所以跨节点通信尽量"少而大",比如玩家从登录服进入场景服这种低频操作;高频的实时战斗数据走进程内服务,或者干脆同一进程内处理。

4.4 单机多进程与数据落地

很多人以为 skynet 只能一个进程跑到底,其实不是。一台物理机上完全可以跑多个 skynet 进程,进程之间用 cluster 互联,也可以共用数据库。多进程的好处是故障隔离更彻底:一个进程崩溃了,其他进程不受影响;功能模块也能独立灰度、独立重启。

典型的部署形态是:网关进程收连接,登录进程做账号校验,场景进程跑地图逻辑,数据进程统一访问数据库。这四类进程职责不同,升级节奏也不同。比如玩法更新只需要重启场景进程,没必要牵扯网关。

数据侧的建议只有一个:把数据库访问收拢到少数几个专门的 db 服务里,不要让每个业务服务都直连连接池。skynet 本身不管持久化,MySQL、Redis、MongoDB 这类存储都是通过封装服务提供给别人调用的。缓存可以放内存服务里,但要保证崩了能从数据库重建,否则就是给自己埋雷。

5. 实战中踩过的坑与排查手段

5.1 消息积压和调用无响应

skynet.call是一个同步等待 API,但框架并没有内置超时。如果目标服务一直不回包,这个协程就会一直挂在那里。我把几种典型情况列一下,顺便给出排查思路。

现象可能原因排查思路
call 报错目标服务不存在、handle 已过期检查服务是否已退出,重新用名字查询 handle
call 永久不返回目标服务 dispatch 里死循环,或内部逻辑卡住看进程 CPU,用调试控制台查服务状态
call 偶发超时目标服务队列积压,处理不过来加日志看目标服务每消息处理耗时,必要时拆服务
两个服务互相 call形成循环等待,协程全部挂起理清服务依赖方向,避免 A 等 B、B 等 A

我在项目里自己封装过一个带超时的 call:用skynet.fork起一个协程去发请求,同时用skynet.timeout设一个兜底定时器,如果定时器先触发就返回一个超时错误,并清理掉等待状态。这个封装几乎是线上必备的,因为调用链深了以后,任何一个中间服务出问题都会导致上游协程堆积。

5.2 一个死循环把整个进程拖垮

这是新手最容易踩的大坑,而且踩得无声无息。某个服务的 callback 里写了个while true do end,或者遍历一张超大表时不小心进入死循环,这个服务就会一直占着一个工作线程不放。

如果只是占一个还好,如果配置了 4 个工作线程,刚好有 4 个服务同时出问题,整个进程 CPU 打满、所有消息全部卡死。更隐蔽的是"看起来很慢"型:不是死循环,而是每次回调都做大量计算,导致目标服务消息处理速率远低于消息到达速率,队列越积越长,响应越来越慢。

我自己定的规矩是:dispatch 回调只做轻量逻辑,所有耗时操作要么拆出去、要么分帧处理。如果某个计算确实重,比如地图寻路、大量排序,最好放到专门的 C 服务或者独立进程里,不要跟主业务服务混在一起。

5.3 Lua 内存问题与服务 GC

skynet 里每个 Lua 服务是独立虚拟机,所以内存天然按服务隔离。好处是可以精确定位谁吃内存,坏处是服务一多,每个虚拟机的固定开销加起来也不小。

线上最常见的 bug 是"内存只涨不跌"。原因十有八九是:某个服务往全局表里塞数据不清理;消息回调里skynet.fork出来的协程被挂起后引用一直没人释放;或者skynet.call永远不返回,那个等待中的协程和它的参数一直被持有。排查时用调试控制台看mem <handle>,先定位是哪个服务在涨,再去看那个服务里有没有只加不删的缓存表。

Lua 的 GC 也要注意。一个服务里突然生成大量垃圾,GC 会拖慢这个服务本身,但不会直接影响别的服务,因为 GC 是按 Lua 虚拟机独立跑的。如果发现某个服务响应变慢,可以临时在服务里手动触发collectgarbage("collect")看看是否有明显改善。

5.4 消息序列化的性能与大小

skynet.pack/unpack对常规 Lua 类型很稳,但要记住几个限制。function 和 userdata 不能跨 Lua 虚拟机传递,一个服务里的闭包打包到另一个服务根本没法还原。大型 table 打包很消耗 CPU,尤其是高频推送的场景数据。嵌套太深的表在解开时也容易出现性能问题。

我的建议是:进程内高频消息尽量传 ID、索引这类轻量数据,把重的对象留在对方服务里,需要的时候让对方服务自己查;跨进程、跨节点才考虑完整序列化。数据量实在大就上 protobuf/cbor 这类二进制协议,别指望 skynet 的默认打包方案扛住一切。

5.5 调试控制台与日志定位

skynet 自带一个调试控制台,在配置里开启端口后,可以用 telnet 连上去敲命令。常用的就是list列出当前节点所有服务,stat看服务消息统计,mem <handle>看内存,gc触发回收。线上出问题的时候,第一时间连上去看"哪些服务还活着、哪个服务内存异常、哪个服务消息积压",比瞎猜高效得多。

日志也值得多说两句。多服务并发处理消息,如果不带服务名和 session 号,排错时根本对不上号。我习惯在关键入口和出口打印统一格式日志,比如[服务名][handle][session] 收到/发出 消息内容。刚开始写很啰嗦,但线上问题和这个日志格式帮了大忙。

6. 它的边界:什么场景不太适合

6.1 不要拿它当 Web 后端

skynet 不是 HTTP 框架,没有现成的路由、中间件、鉴权生态。你要自己做协议解析、限流、参数校验,这些在成熟 Web 框架里都是标配。拿 skynet 写 REST 接口属于拿错了工具,开发效率和稳定性都不如直接用成熟的 Web 技术栈。

它也不适合纯 CPU 密集的离线批处理任务。Actor 模型的强项是"有状态模块之间的消息协作",不是并行计算。你要跑大规模数据清洗、机器学习推理,老老实实去用专门的并行计算框架。

6.2 对团队和工程化的隐性要求

skynet 很灵活,灵活到"给你一堆服务和一个消息系统,其余自己定"。这在项目早期是优势,到后期就是压力。服务怎么拆、协议类型怎么定义、消息格式要不要带版本号、服务命名规则是什么,这些约定必须在团队里统一。

我见过一个项目,业务代码三百多个服务文件,协议类型五花八门,同一个"玩家进入场景"的消息在不同服务里格式都不一样,最后排查线上问题全靠搜日志。这不是 skynet 的锅,是工程化没跟上。如果团队没有强烈的约定意识和代码审查机制,建议先想清楚再引入。

6.3 与其他方案的取舍

拿 skynet 和业内几个替代方案简单对比。Erlang/OTP 是更重型的 Actor 方案,自带监督树、容错、OTP 标准库,但学习曲线陡,团队要能适应函数式写法。Go 的 goroutine + channel 也能模拟 Actor,但 goroutine 是并行实体、channel 可以跨 goroutine 任意传递,语义上跟"一个服务串行处理消息"不完全一样。自研 C++ 服务器自由度最高,但消息队列、定时器、热更、网络封装都要自己造,周期和风险都摆在那。

skynet 的定位是"轻量、贴合游戏、用 Lua 快速迭代"。选它的人大多是冲着效率和可控性去的,代价是要接受它的并发模型约束和相对有限的社区生态。

7. 最后分享几点个人实践经验

写了这么多,最后聊点实在的个人体会。第一件是服务拆分的粒度。有人会觉得一个玩家一个服务很酷,但每个 Lua 服务都是一个独立虚拟机,上万个玩家就是上万个虚拟机,内存先爆炸。正确做法是按功能模块拆服务,而不是按玩家实例拆。玩家对象在某个场景服务或 agent 服务里就是一张表,服务内部继续用协程或者对象表来细分,不要滥用"一个对象一个 Actor"。

第二件是尽早读源码。skynet 文档一直不算丰富,社区讨论也散。真正吃透它靠的是看核心的 C 源码,比如服务调度、消息队列、snlua 那几个文件,配合 Lua 侧的lualib/skynet.lua一起看。我就是在项目出过一次诡异消息丢失后,耐着性子读完调度器代码才彻底理解消息投递顺序和全局链表的关系。读完之后,很多之前靠猜的 API 边界一下就清晰了。

第三件小技巧:刚接触时别急着设计自己的架构,先把官方 examples 里的服务跑一遍,改参数、加日志、堵 bug。等你能在几分钟内写一个"启动两个服务、互相 call 几个来回"的 Demo,skynet 的门槛就已经跨过去一大半了。后面遇到问题,也尽量先怪自己没理解模型,而不是急着怪框架,因为绝大多数诡异现象,最后都能在"服务隔离、消息串行、协程让出"这三个关键词上找到解释。

skynet 不是一个花哨的框架,甚至看起来有点"简陋"。但越用你越会发现,它的每一个核心选择都踩在游戏服务端实际痛点上。如果你决定用它,请记住:架构就是你定义的协议,规范就是团队的底线。这两件事做到了,skynet 能陪你走得非常远。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进