目录
[多 worker + 串行 service](#多 worker + 串行 service)
[service 内的线程安全](#service 内的线程安全)
[Lua coroutine 伪同步模型](#Lua coroutine 伪同步模型)
[yield 是状态边界](#yield 是状态边界)
介绍
skynet与service
可以将 Skynet 理解为一个运行在单进程内的用户态服务调度框架
- 它负责调度大量独立的service 实例 ,并通过多 worker 线程实现并行执行
service介绍
在 skynet 中,用 服务 (service) 这个概念来表达某项具体业务,它包括了处理业务的逻辑以及关联的数据状态
其中涉及到模块和服务的区别:
- 模块定义了服务的行为,而 service 是模块的运行实例
- 在结构上,模块与服务的关系类似于类与对象,但服务还额外承担了调度与并发隔离的职责
Skynet 以 service(skynet_context) 作为最小调度与隔离单元
Skynet 支持在运行时动态创建与销毁 service 实例,并提供调试控制台用于查看和管理服务状态
- 对于 Lua service,每一次创建都会生成一个新的、彼此隔离的 Lua 虚拟机实例
service创建
豆知识
Skynet 的配置文件中定义了服务模块的搜索路径以及系统启动时的入口服务(start)
框架初始化完成后会首先创建该入口服务,其它业务服务通常由该入口服务通过 newservice 等接口递归创建
过程
把一个符合规范的 C 模块(定义4种接口create,init,signal和release)编译成so库以后,就可以被加载
- 模块被加载后,会注册到一个 modules 表 中
当创建一个新service实例(全新的独立的业务执行单元)时:
在 modules列表 中找到对应模块
调用 create 创建该模块的数据实例
分配 skynet_context 结构(称为context)
将模块接口 + 数据实例绑定到 context
初始化基础字段
注册 handle
创建私有消息队列
把 context 放入全局 context 列
调用 init 初始化模块
handle
服务句柄 / 服务地址 / 服务id
Skynet 分配一个唯一的 32bit 整数,作为该 service 的逻辑地址,用于消息路由
和运行时态相关
即使服务退出,该地址也会尽可能长时间的保留
- 避免当消息发向一个正准备退出的服务后,新启动的服务顶替该地址,而导致消息发向了错误的实体
- 这一点和网络中的端口号的延迟复用机制是一样的
service运行阶段
每个服务分三个运行阶段:
- 服务加载阶段 -- 当服务的源文件被加载时,就会按 lua 的运行规则被执行到。这个阶段不可以 调用任何会yield 当前协程的 skynet api (因为在这个阶段还没有进入完整的消息驱动运行状态,协程挂起后,没有任何机制能把它 resume 回来,就会永久挂起)
- 服务初始化阶段 -- 由 skynet.start 这个 api 注册的初始化函数执行。这个初始化函数理论上可以调用任何 skynet api了,但启动该服务的 skynet.newservice 这个 api 会一直等待到初始化函数结束才会返回 (所以如果调用了会挂起等待的 API,可能造成调用方超时或逻辑卡住)
- 服务工作阶段 -- 当你在初始化阶段注册了消息处理函数的话,只要有消息输入,就会触发注册的消息处理函数。这些消息都是 skynet 内部消息,外部的网络数据,定时器也会通过内部消息的形式表达出来
skynet_context介绍+结构
skynet_context 是 C 层结构体, service是框架概念,两者一一对应
skynet_context 结构体定义:
- 这就是一个service在 C 层的运行时实例,它是是调度系统识别服务的唯一实体
- 而 context就是指struct skynet_context 实例
**lua VM(lua虚拟机)**也是skynet_context的一部分
- 在大多数场景下,一个 service = 一个 Lua VM,两者几乎一一对应,所以会有"Lua 虚拟机" ≈ "service"的说法
严格来说,他们之间的关系是:
但可以简单的看做:
service与lua虚拟机
对于 Lua 类型的 service,其模块实例内部会创建一个独立的 lua_State(Lua 虚拟机) 作为执行环境,两者一一绑定
- Skynet 调度的是 service,而不是 lua_State
因此在结构上:
lua虚拟机
Lua 虚拟机(Lua VM)是 Lua 语言的核心执行引擎
它不是执行源代码的解释器,而是执行 Lua 字节码(bytecode) 的引擎
Lua 源代码在运行前被编译为字节码,然后由 Lua VM 解释执行这些字节码
Lua VM 同时负责管理执行状态、运行栈、函数调用、协程和垃圾回收等机制
Lua VM 的执行需要一个**状态对象 --**lua_State *L
lua_State是 Lua VM 的内核状态,它代表一个独立的 Lua "执行环境"(以上是语言层面的的lua VM)
skynet下的lua VM有一点不同
- "一个 Lua 虚拟机" = 一个
lua_State *L及其关联的完整运行时结构- 它是某个 service 的执行环境,但不是调度单位
Lua 虚拟机是 Lua service 的执行引擎,其生命周期与 service 绑定:
创建 Lua service → 创建新的 lua_State
销毁 Lua service → lua_close
数据与数据库
服务、连同服务处理业务的逻辑代码和业务关联的状态数据\] 要放在**常驻内存**中,表示业务实体 使用 skynet 实现游戏服务器时,不建议把业务状态同步到**数据库**中 * 如果数据库是你架构的一部分,那么大多数它用来持久化备份 -- 数据库IO的性能太低 * 你可以在状态改变时,把数据推到数据库保存,也可以定期写到数据库备份 * 业务处理时直接使用服务内的内存数据结构 -- 游戏服务器本质上是状态机,需要对各种状态高频读写 + 强一致逻辑
架构设计
Skynet 管理的调度单元是 service,对于 Lua 类型的 service,其内部包含一个独立的 lua_State 作为执行环境。每个 service 在同一时刻只会被一个 worker 线程执行,不同 service 之间可以在多个 worker 线程上并行调度
- 因此整体属于单进程 + 多 worker 线程的架构
为什么选择
在 Skynet 的目标场景(游戏服务器、高频内部通信等)下,其他方案都有明显的代价:
- 多进程方案:进程间通信(IPC)成本高,共享数据需要额外的共享内存机制,复杂度高
- 多线程 + 共享内存方案:需要大量锁来保护共享数据,死锁、竞态条件等问题难以排查
而选择 Lua 作为业务开发语言后,架构上的问题迎刃而解:
lua_State天然提供了沙盒隔离,不同 service 的执行环境互不干扰- service 提供调度隔离,保证同一 service 串行执行,业务层无需加锁
- 消息队列提供统一的通信机制,service 之间通过消息传递而非共享内存交换数据
所以,在单进程内,通过 service 实现逻辑隔离,通过多 worker 线程实现并行,兼顾了隔离性、并发性和开发效率
功能
核心解决的问题
一句话总结,就是让服务器充分利用多核 优势,将不同的业务放在独立的执行环境中处理,协同工作
作为核心功能,Skynet 仅解决一个问题 -- 如何在单进程内,让大量独立的业务服务高效并发运行
具体来说:
- 每个业务封装为一个独立的 service,分配唯一的 handle 作为身份标识,其他服务通过 handle 向其发送消息
- 每个 service 向框架注册一个 callback 函数,所有业务都由消息驱动------没有消息时 service 处于挂起状态,对 CPU 资源零消耗
- 如果需要主动触发逻辑,可以利用 Skynet 提供的 timeout 消息定期触发
- 多个 service 由 worker 线程并行调度,充分利用多核 CPU
如何解决
解法一句话总结,基于 Actor 模型的service并发框架 + 多 worker 调度
actor模型
Actor 模型是一种并发编程模型,核心思想:用"Actor"作为并发的基本单元,Actor 之间不共享任何状态,只通过发送消息来通信
每个 Actor 收到消息后,可以做三件事:
- 除此之外,Actor 什么都不能做------它无法直接访问其他 Actor 的状态,也无法被外部直接修改
核心特性:
- 解决了传统多线程编程的核心痛点 -- 共享状态 + 并发访问(每个actor都是私有状态)
这里的actor概念对应skynet中的service概念
多 worker + 串行 service
线程机制
Skynet 共有 4 种线程
- monitor 线程用于检测 worker 线程是否长时间阻塞在某个服务的 callback 中(通过比对 worker 线程在 dispatch 消息前后记录的版本号是否发生变化,本质是防止某个服务长时间霸占 worker 线程)
- timer 线程负责驱动定时器
- socket 线程负责网络数据的收发
- worker 线程负责对消息队列进行调度 -- 数量可通过配置指定,worker线程数通常和系统物理核心数量相关,而 skynet 所管理的服务数量则是动态的、远超过worker线程数量
为什么需要检测长时间阻塞的worker线程?
- 因为 Skynet 并不是抢占式调度器,没有时间片设计,不会因为一个 worker 线程工作时间过长而强制挂起它
- 一旦陷入死循环,它将永远占据该工作线程
skynet 内置的网络层可以和它的服务调度器协同工作
- Skynet 使用非阻塞网络 IO,当一个 Service 的网络请求等待时,工作线程不会被阻塞,而是立即切换到调度其他 Service,从而实现固定线程数下的高效并发处理
多线程的好处
Skynet 采用多线程模型,使得多个 service 可以在不同 worker 线程上并行处理各自的消息,充分利用多核 CPU 的计算能力
充分利用多核的具体方式是:
- 把不同业务拆成不同 service
- 每个 service 可能在不同时刻被调度到不同的 worker 线程上执行
- 多个 service 在多核上并行,充分利用硬件资源
与此同时,Skynet 遵循Actor 模型 的设计哲学,service 之间不共享状态,通过消息传递来交换数据,从而避免 了传统多线程模型中因状态共享带来的锁竞争问题
- 既然每个 service 的数据只有自己能动(service是独立的隔离的),根本不存在"两个线程同时改同一份数据"的情况,自然也就不需要在业务层加锁
多线程模型的诸多弊端,比如复杂的线程锁、线程调度问题等,都可以通过精简底层设计,最终把危害限制在很小的范围内
- Skynet 底层(框架层)依然有锁,但这些锁全部由框架自己管理,设计上非常精简,只保护最小范围的临界区,业务开发者写 service 逻辑时完全感知不到这些锁的存在,所以说复杂度被收敛了
service 内的线程安全
service 内是串行执行的
- 一个 service 在任意时刻只会被一个 worker 线程执行,因此其 callback 调用天然是串行的 -- 上一条消息的 callback 执行完之前,下一条消息不会开始处理
- 不同时刻该 service 可能被调度到不同的 worker 线程上,取决于哪个 worker 从全局队列中抢到了它的次级消息队列,但这不影响串行性的保证
因此:
- service 内部自身状态天然线程安全,不需要加锁
- 但若 service 访问的是跨 service 的共享数据(如 C 层共享结构体、全局变量等),仍需自行保证线程安全
它只保证的是单 service 级别的串行执行安全,多线程的并行发生在 service 之间
- 并发复杂度被限制在 service 间的消息传递上,service 内部完全是串行的单线程模型,开发者只需关心消息的发送和接收,而不需要关心并发
service作为最小并发单元
最小并发单位的意思是:并发发生在这个粒度上,比它更小的粒度内部不存在并发
- 而前面已经介绍了 service内部是串行的+为什么可以串行
- 并且service 的状态对外完全私有,外部无法直接访问(不同
lua_State之间的数据是完全隔离的),保证没有并发问题- 所以service可以作为并发单元
那这个并发单元还能更小吗?
如果把 coroutine 作为并发单位会怎样:
- 本身同一 service 内的多个 coroutine 共享同一个
lua_State,如果多个 coroutine 并发执行,就会同时读写同一块内存 -- 必须加锁,复杂度立刻爆炸所以 coroutine 不能作为并发单位,service 是能保证"内部无并发"的最小粒度
消息驱动
介绍
服务之间不共享状态,只能通过消息传递进行通信
- 每个 service 拥有独立的 skynet_context 与 次级消息队列
- 消息被投递到目标 service 的私有队列中,由 worker 线程驱动执行其回调函数完成处理。
此外,Skynet 统一管理 网络 I/O,定时器系统
- 所有外部事件都会被转换为内部消息,投递到对应 service 的消息队列中,从而以统一的消息驱动模型运行整个系统
队列结构
次级消息队列在底层实现为环形缓冲区(ring buffer),初始容量为 64
- 队列写满时会自动扩容为原来的两倍,因此投递消息不会因队列满而丢失
所有次级消息队列统一挂载在一个全局消息队列中
- 全局队列本身是一个链表,并通过自旋锁保护其 push / pop 操作,确保多个 worker 线程并发访问全局队列时的线程安全
消息调度规则
每条 worker 线程 ,每次从全局消息队列 中 pop 出一个次级消息队列 ,并从次级消息队列中 pop 出一条(或若干条,受权重机制影响)消息 ,找到该次级消息队列的所属服务 ,将消息传给该服务的 callback 函数执行指定业务
当本轮消息处理完毕后:
- 若次级消息队列仍有剩余消息,且全局消息队列非空,则将其重新 push 回全局消息队列,切换处理下一个次级队列
- 若次级消息队列已为空 ,则将该队列的
in_global标志置为 0,表示其已不在全局队列中,不会将其 push 回全局队列。等到下次有新消息 push 进该次级队列时,才会重新将其加入全局消息队列这一设计避免了全局队列中充斥空队列,保证了调度效率
当全局消息队列为空、所有 worker 线程无事可做时:
- worker 线程不会忙等,它会通过条件变量进入休眠,等到有新消息到来时由 socket 线程或 timer 线程将其唤醒,避免空转浪费 CPU
线程安全保证
前面说的"一个 service 在任意时刻只会被一个 worker 线程执行",在这里就可以更详细的解释下:
- 由于每个服务只有一个次级消息队列,每当一条 worker 线程从全局消息队列中 pop 出某个次级消息队列时,其他 worker 线程便无法同时获取同一个次级消息队列并调用其 callback 函数
- 因此,同一个服务在任意时刻只会在一条线程内执行,callback 调用天然串行
socket 线程、timer 线程乃至 worker 线程,都有可能向指定服务的次级消息队列中 push 消息
- 为防止多条线程同时对同一次级消息队列进行读写操作(包括多线程并发 push,以及 push 与 worker 线程的 pop 之间的竞争),push 与 pop 操作内均通过次级队列自身的自旋锁 加以保护,确保对次级消息队列的访问是线程安全的
综合来看,Skynet 的线程安全由两把自旋锁共同保障:
- 全局队列的锁负责保护多 worker 线程对全局队列的并发访问
- 次级队列的锁负责保护对单个服务消息队列的并发读写
- 两者各司其职,共同构成完整的调度安全体系
网络部分
Skynet 的 socket 线程统一管理所有网络 IO,支持 TCP 监听、主动连接、UDP 收发
- 只需要一行代码就可以监听一个端口,接收外部 TCP 连接
当有新的连接建立时,Skynet 会以消息的形式通知 service,通过回调函数可以获得这个新连接的句柄
- 之后和普通的网络应用程序一样,可以读写这个句柄
- 不一样的是,连接的所有权可以在 service 之间转移,不同连接可以交给不同 service 处理,天然并行 -- 这有点像传统 posix 系统中,接收一个新连接后,fork 一个子进程继承这个句柄来处理的模式,不过skynet 的服务没有父子关系
Lua coroutine 伪同步模型
介绍
从框架本质看,Skynet 只有一种通信方式------消息投递(send)+ 回调(dispatch),天然是异步的
- A并没有等待,而是继续处理其他的
但 Skynet 在 Lua 层主动做了一层"欺骗"(即封装),用 coroutine 将异步回调伪装成同步调用风格
为什么要这样做?
- 如果不这样做,就得写成异步回调 -- 业务逻辑被拆散到多个回调函数中,执行路径不直观,状态需要靠闭包或外部变量手动维护
- 以 Node.js 风格的异步回调 和 skynet的伪同步对比:
Lua// Node.js 异步回调 // user 必须靠闭包捕获才能传给下一层 getUser(function(user) { getOrder(user.id, function(order) { // 想同时用 user 和 order,只能靠闭包捕获 user calcPrice(user.level, order, function(price) { reply(user.name, order.id, price) // user、order 全靠闭包 }) }) }) -- Skynet 协程伪同步 -- user、order 就是普通局部变量,天然可以在任何地方使用 local user = skynet.call(B, "get_user") local order = skynet.call(C, "get_order", user.id) local price = skynet.call(D, "calc_price", user.level, order) reply(user.name, order.id, price)
coroutine
coroutine 是一种可以在执行过程中主动挂起、并在之后从挂起点恢复执行的函数
- 它拥有自己独立的执行栈,可以保存自己的局部变量和执行位置
在同一服务内可以有多个用户线程/协程(coroutine)
- 这些线程可以用 skynet.fork 传入一个函数启动,也可以利用 skynet 的定时器的回调函数启动
每收到一条消息,Skynet 会从 coroutine 池中取出/新建一个 coroutine 来执行对应的处理函数
- 每个 请求->响应 的完整流程,对应一个独立 coroutine
- 处理完毕后该 coroutine 会被回收复用,而不是每次都全新创建
同一个 service 内,所有 coroutine 共享同一块内存(同一个 Lua 虚拟机)
- 访问数据时,无需序列化/拷贝/消息往返,比通过消息交换要廉价得多
- 可以利用这一点提高数据共享效率
coroutine 并不是真正操作系统的线程,没有 [并行执行/抢占调度/有真正的调度器/有多个执行流同时运行]:
- 同一服务内的不同 coroutine 永远是轮流获得执行权的 -- 每个 coroutine都是在轮流工作,遇到 yield 点时挂起让出控制权,也会在其他 coroutine 让出控制权后再延续运行
- coroutine 是"可挂起的执行栈",不是调度单元
- 本质上是事件驱动 + 协作式调度
伪同步的实现方式
每次一个 service 向另一个 service 发送请求时,Skynet 会分配一个唯一的整数
session(31位bit) 作为这次请求的 ID,随消息一起发出去
- 对方处理完后,回包里会带上同一个
session,发回来- 这样发送方就能通过
session知道"这个回包是在等哪次请求的结果"- 注意,session在源码里虽然是int类型,但只使用了 32 位 int 的正数范围(相当于有效位是 31 位)
发送请求后:
- 当前处理该消息的coroutine被挂起,而skynet服务仍然可以处理其他消息
- 待对应服务收到请求并做出回应(发送一个回应类型的消息)后,服务会找到挂起的 coroutine ,把回应信息传入,延续之前未完的业务流程
- 这样即使真实的执行过程并不是连续的 -- 中间经历了挂起、其他业务的穿插执行、再恢复,从业务开发者的视角看,整个请求到响应的流程依然是一段完整连续的代码,如同从未被打断过一样,从而把"消息回调"变成"函数返回",使执行逻辑是线性的,即同步的
从使用者角度看,更像是一个独立线程在处理这个业务流程
- 每个业务流程有自己独立的上下文,状态天然在栈上
- 而不像 nodejs 等其它框架中使用的 callback 模式控制流被拆散,状态靠闭包保存
- (当然上面在coroutine中有介绍,并不是真正的独立线程)
yield 是状态边界
互有利弊的是,一旦当前业务处理流挂起,等回应到来继续运行时,内部状态很可能已被同期其他业务处理逻辑所改变:
- call → yield → 其他逻辑运行 → resume
所以 yield 是一个"状态边界"
- 两个 yield 点之间,运行过程是原子的 -- 不会被切换、打断或抢占
- 利用这个特性,把 yield 当做事务边界后,比传统多线程程序更容易编写正确的并发逻辑
无法解决的问题
Skynet 原则上主张所有的服务都在同一个 OS 进程 中协作完成,核心层专注于单节点内(指一个运行中的skynet进程)的 service 调度与消息传递,因此有一些问题不在其解决范围内
- 不内置跨机通信机制(跨机通信由上层的 harbor 机制作为可选扩展提供)
- 也不为单独一个服务的崩溃、重启等提供相应的支持
这些健壮性问题留在更高层去解决
- 比如,使用 Lua 的沙盒的 pcall/xpcall 可以捕获运行时错误,隔绝大多数上层逻辑中的 bug,防止一个 service 内的错误扩散影响整个进程 -- 但这只能隔离 Lua 层的错误
- 如果是 C 层崩溃(如野指针、段错误),整个进程仍然会受到影响,Lua 沙盒对此无能为力
参考:
https://blog.codingnow.com/2012/09/the_design_of_skynet.html
https://github.com/cloudwu/skynet/wiki/GettingStarted
https://manistein.github.io/blog/post/server/skynet/skynet%E6%BA%90%E7%A0%81%E8%B5%8F%E6%9E%90/
















