Skynet入门(一)
概念
skynet 是一个为网络游戏服务器设计的轻量框架。但它本身并没有任何为网络游戏业务而特别设计的部分,所以尽可以把它用于其它领域。
设计初衷
如何充分利用它们并行运作数千个相互独立的业务。
模块设计建议
在 skynet 中,用服务 (service) 这个概念来表达某项具体业务,它包括了处理业务的逻辑以及关联的数据状态。使用 skynet 实现游戏服务器时,不建议把业务状态同步到数据库中,而是存放在服务的内存数据结构里。服务、连同服务处理业务的逻辑代码和业务关联的状态数据,都是常驻内存的。
可以把 skynet 理解为一个简单的操作系统,它可以用来调度数千个 lua 虚拟机,让它们并行工作。每个 lua 虚拟机都可以接收处理其它虚拟机发送过来的消息,以及对其它虚拟机发送消息。每个 lua 虚拟机,可以看成 skynet 这个操作系统下的独立进程,你可以在 skynet 工作时启动新的进程、销毁不再使用的进程、还可以通过调试控制台监管它们。skynet 同时掌控了外部的网络数据输入,和定时器的管理;它会把这些转换为一致的(类似进程间的消息)消息输入给这些进程。
网络
我们不建议你在 skynet 的服务中再使用任何直接和系统网络 api 打交道的模块,因为一旦这些模块被网络 IO 阻塞,影响的就不只是该服务本身,而是 skynet 里的工作线程了。skynet 会被配置成固定数量的工作线程,工作线程数通常和系统物理核心数量相关,而 skynet 所管理的服务数量则是动态的、远超过工作线程数量。skynet 内置的网络层可以和它的服务调度器协同工作,使用 skynet 提供的网络 API 就可以在网络 IO 阻塞时,完全释放出 CPU 处理能力。
我们通常建议使用一个网关服务,专门监听端口,接受新连接。在用户身份确定后,再把真正的业务数据转交给特定的服务来处理。同时,网关还会负责按约定好的协议,把 TCP 连接上的数据流切分成一个个的包,而不需要业务处理服务来分割 TCP 数据流。业务处理的服务不必直接面对 socket 句柄,而由 skynet 正常的内部消息驱动即可。
运行原理
服务service运行的三个阶段
- 服务加载阶段,当服务的源文件被加载时,就会按 lua 的运行规则被执行到。这个阶段不可以调用任何有可能阻塞住该服务的 skynet api 。因为,在这个阶段中,和服务配套的 skynet 设置并没有初始化完毕。
- 服务初始化阶段,由 skynet.start 这个 api 注册的初始化函数执行。这个初始化函数理论上可以调用任何 skynet api 了,但启动该服务的 skynet.newservice 这个 api 会一直等待到初始化函数结束才会返回。
- 服务工作阶段,当你在初始化阶段注册了消息处理函数的话,只要有消息输入,就会触发注册的消息处理函数。这些消息都是 skynet 内部消息,外部的网络数据,定时器也会通过内部消息的形式表达出来。
从 skynet 底层框架来看,每个服务就是一个消息处理器。但在应用层看来并非如此。它是利用 lua 的 coroutine 工作的。当你的服务向另一个服务发送一个请求(即一个带 session 的消息)后,可以认为当前的消息已经处理完毕,服务会被 skynet 挂起。待对应服务收到请求并做出回应(发送一个回应类型的消息)后,服务会找到挂起的 coroutine ,把回应信息传入,延续之前未完的业务流程。
同一个 skynet 服务处理很多消息,处理这些消息的流程永远不会真正的并行,它们只是在轮流工作。一段业务会一直运行到下一个 IO 阻塞点,然后切换到下一段逻辑。你可以利用这一点,让多条业务线在处理时共享同一组数据,这些数据在同一个 lua 虚拟机下时,读写起来都比通过消息交换要廉价的多。
两次阻塞 API 调用之间,运行过程是原子的,利用这个特性,会比传统多线程程序更容易编写。如果一条用户线程永远不调用阻塞 API 让出控制权,那么它将永远占据系统工作线程。skynet 并不是一个抢占式调度器,没有时间片的设计,不会因为一个工作线工作时间过长而强制挂起它。
消息
组成
- 消息类型:回应消息、网络消息、调试消息、文本消息、Lua 消息、错误。
- session
- 发起服务地址
- 接收服务地址
- 消息 C 指针
- 消息长度
消息类型
- 回应消息通常不需要特别处理,它由 skynet 基础库管理,用来调度服务内的 coroutine 。
- 网络消息也不必直接处理它,skynet 提供了 socket 封装库,封装管理这类消息,改由一组更友好的 socket api 方便使用。
- 调试消息已经被默认的 skynet 基础库处理了。它使得所有 skynet 服务都提供有一些共同的能力。
- 真正的业务逻辑是由文本类消息和 Lua 类消息驱动的。它们的区别仅在于消息的编码形式不同,文本类消息主要方便一些底层的,直接使用 C 编写的服务处理,它就是简单字节串;而 Lua 类消息则可以序列化 Lua 的复杂数据类型。大多数情况下,我们都只使用 lua 类消息。
- 接管某类消息需要在服务的初始化过程中注册该消息的序列化及反序列化函数,以及消息回调函数。
Actor模型
概念
一种用于实现并发计算的数学模型,核心思想是将计算过程视为一系列的Actor,每个Actor都是一个独立的实体,能够接收消息、创建新的Actor、发送消息,并决定如何响应接下来的消息。
特性
- 服务隔离:每个服务(Actor)运行在独立的 Lua 虚拟机中,内存隔离,数据通过消息传递共享,避免直接内存操作。
- 服务通信:服务间通过异步消息通信(skynet.call 同步调用、skynet.send 异步发送),消息由框架自动路由到目标服务的消息队列。
- 动态服务管理:Actor可以在运行时创建更多的Actor,这种动态性使得Actor模型能够根据需要扩展系统。
优势
-
简化并发编程
-
无需锁机制:天然避免死锁、竞态条件。
-
逻辑清晰:每个 Actor 仅关注自身状态和消息处理。
-
-
高容错性
-
错误隔离:单个 Actor 崩溃不影响整体系统。
-
监督策略:通过层级结构实现优雅恢复。
-
-
弹性扩展
-
分布式友好:Actor 可部署在不同节点,通过消息传递协作。
-
动态负载均衡:根据压力动态创建或迁移 Actor。
-
-
高性能
-
轻量级:Actor 内存占用小(如 Erlang/Elixir 中每个 Actor 仅需 2-3KB)。
-
高效调度:通过轻量级线程(如协程)实现高并发。
-
核心功能
消息调度机制
由于skynet中是用actor模型实现,各个actor模型相互隔离,因此skynet采取了消息的方式来实现各actor的数据共享。
-
消息队列:每个服务拥有私有消息队列(次级队列),全局消息队列管理所有活跃服务的次级队列,工作线程从全局队列中轮询处理消息。
-
协程调度:每个消息对应一个 Lua 协程,通过 skynet.fork 创建新协程,利用 skynet.sleep 挂起逻辑,避免阻塞主线程。
运行流程
-
消息投递:发送方将消息放入接收方 Actor 的队列。
-
消息调度:接收方 Actor 的消息队列按 FIFO 顺序处理消息。
-
行为执行:Actor 根据当前状态和消息内容,执行以下操作之一:
-
修改自身状态。
-
发送新消息。
-
创建子 Actor。
-
停止自身或子 Actor。
-
网络通信
-
多协议支持:内置 TCP/UDP 网络层,通过 socketdriver 模块处理连接、粘包(如包头长度解析)。
-
协议编解码:集成 Sproto 协议库,支持定义结构化消息格式(嵌套类型、数组),优化高频字段顺序以减少编解码开销。
高性能与扩展性
-
线程池模型:启动多个工作线程(数量通常等于 CPU 核心数),线程按权重从全局队列中获取任务,避免线程饥饿。
-
模块化设计:C 模块(如数据库驱动)与 Lua 服务解耦,通过动态库(.so)加载,支持热更新。
集群与分布式支持
-
多节点通信:通过 harbor 配置节点 ID,支持跨进程通信,但主要优化单节点内的高并发,分布式需结合额外逻辑。
-
数据共享:提供 sharedata 模块实现进程内数据共享,跨节点需通过消息或第三方存储(如 Redis)。