Skynet是一个开源的轻量级游戏服务器框架,用C语言实现了actor模型和相关的脚手架,提供了一套完善的微服务调度框架。Skynet同时集成了Lua虚拟机,作为actor模型的具体实现。具体介绍可以参考 skynet github官网
openUBMC中仅使用了skynet的部分功能,本篇文章主要介绍了openUBMC相关的功能介绍,用于指导开发。
组件与服务
在Skynet中,服务是一个最小的独立运行单元,每个服务都拥有一个独立的Lua虚拟机,同一时间只会在一个线程运行,服务与服务之间需要通过异步消息进行交互。
在openUBMC中,基于组件需要业务功能,可以创建一个或多个Skynet服务。大部分组件都仅需创建一个服务即可完成业务操作。因此在组件编写时,无需考虑数据并行操作的风险。组件内部的业务呈原子化。
创建服务
Skynet中,通过skynet.unique_service
即可创建一个服务,或者通过skynet.register
注册服务。openUBMC组件中,src/service/main.lua
文件用于控制组件的服务创建。
local skynet = require 'skynet'
local logging = require 'mc.logging'
local my_app_app = require 'my_app_app'
skynet.register('my_app')skynet.start(function()
skynet.unique_service("sd_bus")
skynet.register("my_app")
local ok, err = pcall(my_app_app.new)
if not ok then
logging:error('my_app start failed, err: %s', err)
end
end)
在上面的例子中,创建了一个sd_bus
服务,以及注册当前服务名为my_app
,并调用创建了my_app_app
对象,也就是基于微组件框架创建的组件对象。
skynet_unique_service
的入参为服务的唯一名称,如果同名的服务在同一进程已经创建了,skynet不会再拉起服务。因此虽然每个组件中需要创建sd_bus
服务,但进程中也只会有一个。
协程(Coroutine)
从多线程(multithreading)的角度看,协程(coroutine)与线程(thread)类似:协程是一系列的可执行语句,拥有自己的栈、局部变量和指令指针,同时协程又与其他协程共享了全局变量和其他几乎一切资源。线程和协程的主要区别在于,一个多线程程序可以并行运行多个线程,而协程却需要彼此协作地运行,即在任意指定的时刻只能有一个协程运行,且只有当正在运行的协程显式地被挂起(suspend)时其执行才会暂停。
摘抄自《Lua程序设计第四版》
在openUBMC中引入了一些微服务的概念:协程与并发。
微组件架构中,服务的范围是相对较小的,同时服务的数量是随着业务逐步增长的。如果每个微服务都配置一个进程/线程的话,不但会造成系统资源成为瓶颈,同时也是对系统资源的极大浪费。BMC的大部分业务都可以拆解成一个个小的独立单元,当设备、网管等外部因素触发时才进行工作。因此我们通过协程的方式,对业务进行重新编排,通过并发的方式解决服务数量持续上涨的未来场景。
Lua本身提供了良好的协程支持。协程的创建、切换在大部分场景下消耗较低。当然,如果协程设计不当导致频繁的协程切换会导致大量的性能浪费在协程和上下文的切换。
注意:
仅Lua部分支持协程切换,C语言部分不支持协程的操作。
代码片段:
local skynet = require 'skynet'
print("before skynet fork")
skynet.fork(function()
print("inside coroutine")
end)
print("after skynet fork")
运行结果:
> before skynet fork
> after skynet fork
> inside coroutine
注意:
skynet.fork
具有一定的迷惑性,此处的fork
并非创建一个线程,而是创建一个协程。
Skynet的协程创建接口为skynet.fork
,入参为一个函数。在openUBMC,协程的运行范围是运行一个完整的函数,因此必须在**当前函数运行结束后,才会运行协程函数。**在上述例子中,inside coroutine
打印会在after skynet fork
之后,而不是之前。
常驻协程
在openUBMC中,经常需要创建一些无限循环的代码,周期性地从硬件读取数据。在这种场景下,协程可以极大幅度的简化代码开发。
local skynet = require 'skynet'
function main()
some_logic()
skynet.fork(function()
while true do
some_function_to_get_hardware_reading()
skynet.sleep(100)
end
end)
other_logic()
end
注意:
skynet.sleep
的单位为10毫秒,因此skynet.sleep(100)
实际上是100 * 10
毫秒,也就是1秒。
在上面例子中,创建了一个协程,这个协程的函数中有一个死循环,因此这个协程永远无法执行完毕。
每次执行完some_function_to_get_hardware_reading()
后,都会将协程挂起 1秒,然后再次执行。
在挂起的1秒内,组件仍然可以去执行其他业务逻辑,等到1秒后再次执行some_function_to_get_hardware_reading()
协程退出
当函数执行结束后,协程才会退出,因此协程的退出取决于函数是否可以执行完毕,不管是正常执行完毕,还是异常退出。
skynet.fork(function()
local retry = 0
while retry < 3 do
if hardware_not_exist() then
error("hardware missing")
end
local ok = set_value_to_hardware()
if ok then
print("set success!")
return
end
retry = retry + 1
skynet.sleep(100)
end
print("set fail after 3 times!")
end)
上述例子是一个常见的会重试3次的硬件数值设置案例。
无论再哪种场景下,函数本身的设计是会执行结束的,因此协程一定会执行完成,不会一直存在。
这种退出方式叫做协程主动退出,退出调用方是协程本身。
但某些场景下,协程内部无法退出时,也可以通过skynet.killthread
来结束
local co = skynet.fork(function()
while true do
print("long live coroutine!")
skynet.sleep(100)
end
end)
function stop_coroutine()
skynet.killthread(co)
end
Skynet在创建协程的时候会返回一个协程标记,在需要强行结束的时候可以使用。
这种退出方式叫做协程被动退出,退出调用方并非协程自身,而是其他协程。如果调用stop_coroutine
的协程一直无法触发、或者尚未轮到时,常驻协程仍然会执行。
Lua SDK Task机制
Skynet的协程创建虽然方便,但是依赖开发者学习Skynet相关的知识。openUBMC为了简化开发者对Skynet的依赖,对协程进行了一层封装,提供了mc.tasks
机制,简化上手难度。
local tasks = require 'mc.tasks'
local t = tasks.get_instance():new_task("unique task id")
t:loop(function(task)
if self.TemperatureCelsius > 120 then
task:stop() -- task内部提供停止操作
end
self.TemperatureCelsius = self.TemperatureCelsius + 1
end)
t:set_timeout_ms(5000) -- 设置常驻协程轮询周期
function stop_task()
tasks.get_instance():new_task():once(function()
tasks:sleep_ms(1000) -- 挂起协程1s
t:stop()
end
end
在创建task
时,需要传入一个task的唯一标识。如果标识重复则不会再创建,避免异常场景下创建大量重复的协程任务。如果不设置唯一标识,则不会检查。
task
提供轮询或者一次性机制。轮询机制则需要设置轮询周期,单位为毫秒。
tasks
本身也提供挂起当前协程的能力,tasks:sleep_ms
的单位也是毫秒。
task
协程内部也可以获取到自己的句柄,可以根据业务诉求结束协程。
协程调度和编排
在操作系统中,线程是操作系统调度的最小单元。协程的颗粒度比线程还要小,因此协程的调度无法依赖操作系统完成,而是由代码开发人员进行调度。因此代码开发人员可以基于自身的业务场景和诉求,在每次操作系统提供的运行时间片中,尽可能的将需要运行的代码排满,最大化的利用CPU。
协程调度最早的概念来源于IO场景。因为IO场景存在大量的等待时间,不管是调用方的处理延时、网络传输的延时,还是操作系统处理的延时,都可以拿来利用。
在openUBMC中,所有的RPC、数据库读写、网络读写都是异步操作,意味着这些的调用在等待响应时,当前的协程会被挂起,其他的协程会被运行。
skynet.fork(function()
print("function 1 rpc start!")
rpc_takes_10ms_to_return()
print("function 1 rpc end!")
end)
skynet.fork(function()
print("function 2 rpc start!")
rpc_taks_1ms_to_return()
print("function 2 rpc end!")
end)
function 1 rpc start!
function 2 rpc start!
function 2 rpc end!
function 1 rpc end!
上述例子中,两个协程均调用了rpc
,因此在function 1
调用rpc
时,function 1
协程被挂起,开始执行function 2
协程,然后在function 2
调用rpc
时,function 2
协程被挂起。后续两个协程的唤醒顺序,取决于rpc的返回顺序。这个场景中,function 2
协程依赖的rpc
仅需1ms,先返回,所以function 2 rpc end!
先打印。
注意:
在正常场景下,上述执行顺序一定是固定的,也不存在两个协程并行运行的场景。
但实际环境下,干扰因素很多,因此开发者在代码编写时,不应该默认顺序是固定的。
如果对顺序有强诉求,使用一个协程进行RPC调用,而不是拆成多个协程。
休眠与等待
在线程开发中,在正常的CPU状态下,sleep()
休眠等待的时间由操作系统保证可靠性,操作系统靠严酷的切换机制尽可能的保证公平。因此大部分场景下都会很好的按照开发者的设置唤醒。然而在协程开发中,由于调度者是程序本身,因此很难做到精准地调度。
同时创建了两个协程,协程1中的业务1先执行。在协程1的业务逻辑设计中,业务1和业务2中间的等待时间应该是等待1,然而仅为等待导致协程被切出后,业务3开始运行。
由于业务3所需的CPU时间片较长,长于等待1的时间,因此当协程1被唤醒继续执行业务2时,已经比设计的等待时间要晚。业务2实际完成时间是要比代码中设计的完成时间要晚。
然而对于协程2来说,因为等待2时间较长,且业务2执行时间较短,因此仍然可以在规划的时间点被唤醒,按照预计的计划执行业务4。
总结:
在协程设计的时候,为了避免协程1遇到的场景,尽可能地避免协程中任务地不均匀性,同时也不要在一个协程中做长时间阻塞操作。
进程、工作线程
在一个Skynet进程中,会创建一个主线程,一个健康监控线程,一个定时器线程,一个IO线程,然后根据配置创建多个工作线程,服务是运行在工作线程中。同一个服务同一时间只会在一个工作线程中运行,同一时间可以由多个服务同时运行。因此工作线程数 <= 服务数,工作线程数 == 同一时间可运行的组件数
由此可以得知,如果一个Skynet进程只有一个服务的话,系统实际开销是非常浪费的。但如果系统只有一个进程,从操作系统资源来看也是一种浪费。同样,如果进程中的工作线程至配一个,那么进程中同一时间也只有一个组件可以运行。因此进程以及进程中组件的数量配比是需要精心设计的。
在openUBMC中,推荐将多个组件合并至一个进程中。openUBMC针对进程内的组件RPC、数据访问进行了进一步优化,组件与组件之间的通讯开销非常小。针对一些颗粒度较大的组件,或者对安全性、稳定性要求较高的组件也会单独开启一个Skynet进程。具体的配置可以参考《hica》。
在进程的Config文件中,可以配置工作线程的数量。
thread = 10
这样可以配置10个工作线程,可以支持10+组件的运行。
协程队列
在某些场景下,协程创建的数量由外部控制,但这些协程同一时间只能串行访问,这种场景下可以使用skynet.queue
将协程串起来运行。
Worker机制
在某些场景下,需要临时创建线程进行业务操作,如调用可能存在阻塞性的C函数。openUBMC Lua SDK提供了Worker机制,方便开发者在Lua侧创建线程,和C/C++编程场景下类似的能力。