其实从C层的代码看,skynet没有太出彩的地方(也仍然很优秀),有些人草草瞄了几眼C层的代码,就断定skynet很一般:凡是有经验的服务器程序,用个什么东西分分钟就搭出一个skynet之类的话
。其实他们不知道,skynet对Lua的封装才是最好的部分,云风前辈对Lua的理解当属国内最顶尖的那几个。
这一部分非常细节,也非常难懂,不想了解的人估计不会看,了解了的人大概也已经了解,所以就当是自己的备忘录
skynet提供了一个snlua模块,每创建一个snlua类型的服务,snlua就创建一个Lua虚拟机,这使得lua服务之间完全隔离,唯一的通讯方式就是通过skynet的消息机制,每一个消息都在一个lua协程处理,当消息处理完毕,或中间向其他服务发送消息,协程可能会挂起,等其他服务回应这个消息
时,协程才重新唤醒,这种方式使得异步代码像同步一样执行,不用写一大堆回调函数。
有了Lua类型的服务,skynet是不是有点像操作系统的概念,skynet的C层代码像操作系统内核,负责服务的调度,而Lua服务很像进程,有自己独立的空间(虚拟机独立),Lua协程则像系统线程,只不过区别在于线程是真正的并发,协程是协作式的并发。每个Lua服务可以保证,同一时刻,只有一个线程在执行Lua协程,所以我们完全不必担心线程同步的问题,当我们在编写Lua服务时,就把它当成一个单线程一样。
bootstrap
回头看skynet_start.c,在skynet_start函数中,有这样的代码片段:
// 创建logger服务
struct skynet_context *ctx = skynet_context_new(config->logservice, config->logger);
// 创建引导服务
bootstrap(ctx, config->bootstrap);
上面是创建一个Logger服务,config->logger是保存日志的路径,如果为NULL则输出到stdout,logger服务会调用skynet_command(ctx, "REG", ".logger")注册名字(logger),这样就可以方便地用logger找到它的句柄,从而向他发送日志。
bootstrap函数负责创建一个lua服务,config->bootstrap的内容默认是snlua bootstrap
,从C底层来看,它是一个snlua类型的服务,bootstrap是lua服务执行的脚本,从字面上看是一个引导服务。
bootstrap函数如下:
static void
bootstrap(struct skynet_context * logger, const char * cmdline) {
// 启动一个引导服务,默认情况下name为snlua,args为bootstrap.lua这个脚本
int sz = strlen(cmdline);
char name[sz+1];
char args[sz+1];
sscanf(cmdline, "%s %s", name, args);
// 创建服务
struct skynet_context *ctx = skynet_context_new(name, args);
... ...
}
看过skynet总体架构,很清楚的知道这是创建一个snlua的服务,bootstrap为作这个服务的参数传过去。
snlua服务
创建snlua服务后,模块中的snlua_create首先得到调用,它做的事情也非常简单:
struct snlua *
snlua_create(void) {
// 初始化snlua结构
struct snlua * l = skynet_malloc(sizeof(*l));
memset(l,0,sizeof(*l));
l->mem_report = MEMORY_WARNING_REPORT;
l->mem_limit = 0;
// 创建Lua状态机
l->L = lua_newstate(lalloc, l);
return l;
}
就是创建一个snlua结构,创建一个Lua虚拟机,内存分配指定的是lalloc,目的是为了监控Lua分配的内存。MEMORY_WARNING_REPORT为Lua服务的内存阀值,超过该值,会报警。
snlua结构如下:
struct snlua {
lua_State * L; // Lua状态机
struct skynet_context * ctx; // 关联的skynet服务
size_t mem; // Lua使用的内存,在lalloc记录
size_t mem_report; // 内存预警,当达到阀值会打一条日志,然后阀值翻倍
size_t mem_limit; // 内存限制
};
创建snlua实例之后,调用snlua_init:
int
snlua_init(struct snlua *l, struct skynet_context *ctx, const char * args) {
int sz = strlen(args);
char * tmp = skynet_malloc(sz);
memcpy(tmp, args, sz);
// 指定回调函数为launch_cb
skynet_callback(ctx, l , launch_cb);
// 取本服务的句柄
const char * self = skynet_command(ctx, "REG", NULL);
uint32_t handle_id = strtoul(self+1, NULL, 16);
// it must be first message:
// 第一个消息在launch_cb处理,见函数
skynet_send(ctx, 0, handle_id, PTYPE_TAG_DONTCOPY,0, tmp, sz);
return 0;
}
- 首先调用skynet_callback指定消息回调函数,指定为launch_cb。
- 然后取得本服务关联的句柄,调用skynet_command这个API获得。
- 最后向本服务发送第1个消息,打上PTYPE_TAG_DONTCOPY标记,这表示skynet内部不会重新分配内存拷贝tmp。
第1条消息,使得launch_cb被回调,launch_cb调用skynet_callback把回调函数去掉,然后调用init_cb,最后的逻辑都在init_cb里,前面既然把回调去掉了,那么肯定在某个地方会把回调函数加上(后面会看到)。
init_cb做的事情:
- 设置Lua的全局变量:
- LUA_PATH:Lua搜索路径,在config.lua_path指定。
- LUA_CPATH:C模块的搜索路径,在config.lua_cpath指定。
- LUA_SERVICE:Lua服务的搜索路径,在config.luaservice指定。
- LUA_PRELOAD:预加载脚本,这些脚本会在所有服务开始之前执行,可以用它来初始化一些全局的设置。
- 执行loader.lua,把要执行的脚本传进去,由loader去加载执行,skynet初始执行bootstrap.lua。
那么loader.lua做了哪些事情呢,它主要负责执行我们指定的脚本文件,比如skynet启动时,需要执行一个bootstrap.lua去引导整个Lua服务。对于我们来说,这个被执行的脚本,可以认为是Lua服务入口,在这里处理服务消息,向其他服务发送消息等等。
bootstrap服务做的几件事件:
- 调用skynet.launch启动launcher服务,这个launcher服务称之为服务启动器,等会再说说他的作用。
- 如果指定harborid,则说明这是一个主从分布式的skynet结构,不过skynet已经不推荐使用这种构架,这里我们略过它。
- 调用skynet.newservice启动datacenterd服务
- 调用skynet.newservice启动service_mgr服务。
- 最后调用skynet.newservice启动我们在config.start字段指定的脚本服务,这个就是我们的逻辑入口点。
Lua服务的启动过程
skynet.launch是创建服务的通用版本,要在Lua创建某个C写的服务,可以使用它。但如果要创建一个Lua服务,则应该使用skynet.newservice。假设现在在A服务,我们要创建B服务,这个流程是这样的:
- A服务调用skynet.newservice(name, ...),这个函数使A阻塞。
- B被创建出来,name.lua这个脚本被执行,脚本要调用skynet.start(function() ... end),表示服务B启动,可以接受消息。
- 当上面skynet.start的函数返回时,A的skynet.newservice才返回,并且A得到了B的服务句柄。
内部究竟是怎么实现的呢,先从skynet.newservice开始:
function skynet.newservice(name, ...)
return skynet.call(".launcher", "lua" , "LAUNCH", "snlua", name, ...)
end
它向launcher服务调了一条消息,并等待launcher的返回。launcher.lua的command.LAUNCH被调用,里面调用launch_service:
local function launch_service(service, ...)
local param = table.concat({...}, " ")
-- 创建一个服务,返回服务句柄
local inst = skynet.launch(service, param)
-- 取一个resonse闭包,先存起来,等skynet.start返回再调用。
local response = skynet.response()
if inst then
services[inst] = service .. " " .. param
instance[inst] = response
else
response(false)
return
end
return inst
end
上面关键的代码是创建B服务,然后调用skynet.response获得一个闭包,这个response闭包先保存在instance表中,现在我们先不管里面是怎么做的,只要知道调用response, A服务skynet.newservice就可以返回,不过这里只是先存了起来。
B服务创建之后,在脚本里调用skynet.start,这个函数是这样的:
function skynet.start(start_func)
-- 指定消息处理函数
c.callback(skynet.dispatch_message)
-- 隔一帧后,再回调start_func
skynet.timeout(0, function()
skynet.init_service(start_func)
end)
end
前面提到snlua.c处理完第1条消息之后,就把回调函数删除了,在这里才把回调函数设置上,具体的实现代码在lua-skynet.c的lcallback,以后向这个服务发消息,skynet.dispatch_message就会被调用。
接着调用skynet.init_service:
function skynet.init_service(start)
local ok, err = skynet.pcall(start)
if not ok then
skynet.error("init service failed: " .. tostring(err))
skynet.send(".launcher","lua", "ERROR")
skynet.exit()
else
-- 调用成功,向launcher发送一个通知
skynet.send(".launcher","lua", "LAUNCHOK")
end
end
原来B服务在skynet.start的回调函数执行完毕之后,会向launcher发送LAUNCHOK消息:
function command.LAUNCHOK(address)
-- init notice
local response = instance[address]
if response then
response(true, address)
instance[address] = nil
end
return NORET
end
从instance取出response函数,调用它,传入true表示成功,后面跟的address就是skynet.call的返回值,这样A服务终于从skynet.newservice返回,并得到了B的地址(句柄)。
所有经过skynet.newsevice创建的服务,都会记录在launcher服务中,launcher提供了些函数用于查询服务的状态。
B服务完成自己的逻辑之后,可以调用skynet.exit()把自己杀死,skynet.exit会向launcher发一个REMOVE消息:
function command.REMOVE(_, handle, kill)
services[handle] = nil
local response = instance[handle]
if response then
-- instance is dead
response(not kill) -- return nil to caller of newservice, when kill == false
instance[handle] = nil
end
-- don't return (skynet.ret) because the handle may exit
return NORET
end
把这个服务从services移除,同时如果还存在response,说明服务是在skynet.start过程中exit自己的,所以也要回应过去,这样A服务会得到一个nil返回值。
服务的消息处理
一个服务最重要的事情就是收发消息,关于消息处理的实现都在skynet.lua中,里面大量使用了协程,这要求你对协程有足够的了解,否则可能会看晕。我们一步步分解,看看能否说清楚。
发送消息
最简单的是发送消息,使用skynet.send,它向目标服务发送一种类别的消息,上层逻辑中大多数是lua类型:
function skynet.send(addr, typename, ...)
local p = proto[typename]
return c.send(addr, p.id, 0 , p.pack(...))
end
proto是一个记载消息类型的信息表,这个一开始是一个空表,需要使用skynet.register_protocol函数注册消息处理类型,proto的内容是这样的:
proto = {
["lua"] = {
name = "lua", // 名字
id = skynet.PTYPE_LUA, // 类型数值
pack = skynet.pack, // 消息打包函数,发送的时候用
unpack = skynet.unpack, // 消息解包函数,接收的时候用
dispatch = ... // 消息派发函数
},
[skynet.PTYPE_LUA] = ["lua"]的内容
skynet.lua默认注册了几种类型:
do
local REG = skynet.register_protocol
REG {
name = "lua",
id = skynet.PTYPE_LUA,
pack = skynet.pack,
unpack = skynet.unpack,
}
REG {
name = "response",
id = skynet.PTYPE_RESPONSE,
}
REG {
name = "error",
id = skynet.PTYPE_ERROR,
unpack = function(...) return ... end,
dispatch = _error_dispatch,
}
end
- lua类型默认使用skynet.pack和skynet.unpack来打包解包消息数据,dispatch没有指定,需要外部调用skynet.dispatch(typename, func)。所以我们通常在脚本文件中调用这个函数来处理消息,可以看一下launcher.lua。
- response用于处理skynet.call和定时器的返回,就是源服务向我发一个远程调用,我需要用这个类型的消息,外加传过来的session发送回去,使用skynet.ret就可以完成这件事。
- error类型是当call发送错误时,源服务能得到通知。
再看看c.send,这个函数在lua-skynet.c实现,它的参数是:目标地址,消息类型,会话ID,自定义参数列表;其中如果会话ID等于0,表示这是一个不需要回应的消息,也就是用skynet.send发送的消息。
远程调用
skynet.call向目标服务发送消息,并等待这个服务回应,最终返回回应的结果。这个函数很像一般函数调用,且从执行流程看,它调用之后的确是阻塞着,等到远程返回结果才会返回。但是它只是看起来像同步执行的样子,实际上skynet的消息处理都是在协程中,skynet.call会把协程挂起,等到响应消息到达才重新执行协程,协程挂起的时候,服务里的其他协程得到机会执行,这样看起来像同步执行,实际上是并发的。这样方式也有一个坏处,就是状态可能在call的过程当中被修改,比如 :
state = 1
skynet.call(addr, "lua", ...)
-- state == 1 ?
state开始设置为1,skynet.call调用之后,它还是1吗?并不一定,有可能其他协程在执行时把它修改了,这是这种“同步执行”模型容易出错的地方。
skynet.call同样调用c.send向B服务发送消息,不同的是session传入的是nil,看lua-skynet.c得知,如果session为nil,会增加PTYPE_TAG_ALLOCSESSION标识,这样就能返回一个session值,并把这个session值发给B服务。和skynet.sent不同的地方是后面还会调用yield_call函数,把自己(A服务)挂起:
-- call服务,等它返回
local function yield_call(service, session)
watching_session[session] = service
session_id_coroutine[session] = running_thread
local succ, msg, sz = coroutine_yield "SUSPEND"
watching_session[session] = nil
if not succ then
error "call failed"
end
return msg,sz
end
- watching_session是会话和服务的关联
- session_id_coroutine是会话和当前协程的关联,等B服务回应后,通过这个就可以得到协程,并让他唤醒执行。这样,coroutine_yield就返回succ, msg, sz。
- succ指明是否成功回应,失败的情况是B服务处理消息时出错,或B服务停止等等异常情况,那么A服务也报一个错误error "call failed"。
- 如果成功,就返回msg, sz, skynet.call再通过p.unpack解成返回。
现在让我们关注B服务这边,B服务的skynet.dispatch_message被调用,这个函数做了两件事件,一是调用raw_dispatch_message,另一个是处理skynet.fork的回调。raw_dispatch_message的执行框架是这样的:
local function raw_dispatch_message(prototype, msg, sz, session, source)
-- skynet.PTYPE_RESPONSE = 1, read skynet.h
if prototype == 1 then
-- response类型的消息
else
-- 其他类型的消息
end
end
prototype == 1 表示回应消息,这个等下我们回到A服务再看,现在B服务应该是执行下半部分,简化过的代码大概是这样的:
local p = proto[prototype]
local f = p.dispatch
if f then
local co = co_create(f)
session_coroutine_id[co] = session
session_coroutine_address[co] = source
suspend(co, coroutine_resume(co, session,source, p.unpack(msg,sz)))
end
- 根据propotype从proto取出消息处理表,如果存在dispatch函数(这就是我们前面调用skynet.dispatch的原因)。
- 调用co_create创建一个协程,这是一个极其重要的函数,里面的逻辑表明并不是收到一个消息就创建一个协程,那样效率太慢了,协程创建之后就一直存在,等消息处理完就yield住,并把协程放回协程池,等下次有消息,直接从协程池取出来继续resume处理。
- 取到一个协程之后,调用coroutine_resume唤醒协程,并将参数传给协程,这样在协程里就会执行dispatch函数了。
- dispatch函数执行到正常结束会挂起,或者他call了另一个服务也会挂起;协程挂起时,coroutine_resume就返回,将返回的参数传给suspend函数。
我们先按A call B这个流程来分析,现在B的dispatch在处理消息,B想回应A有两种方式,一种是在dispatch里面直接调用skynet.ret,另一种是调用skynet.response得到一个闭包,等到某个适合的时刻,再调用这个闭包。skynet.ret的核心代码如下:
function skynet.ret(msg, sz)
local co_session = session_coroutine_id[running_thread]
session_coroutine_id[running_thread] = nil
local co_address = session_coroutine_address[running_thread]
local ret = c.send(co_address, skynet.PTYPE_RESPONSE, co_session, msg, sz) ~= nil
return ret
end
- 从session_coroutine_id取到session,从session_coroutine_address取到源服务地址。
- 调用c.send发送一个skynet.PTYPE_RESPONSE消息,并把co_session传过去。如果我们想打包一个lua对象,可以用skynet.pack,所以外部调用起来类似这样:skynet.ret(skynet.pack(obj))
再来看看skynet.response,它是如何做到把响应放到后面再执行的呢,答案就是Lua的闭包,闭包可以把外部变量引用住:
function skynet.response(pack)
pack = pack or skynet.pack
local co_session = assert(session_coroutine_id[running_thread], "no session")
session_coroutine_id[running_thread] = nil
local co_address = session_coroutine_address[running_thread]
local function response(ok, ...)
local ret
if ok then
ret = c.send(co_address, skynet.PTYPE_RESPONSE, co_session, pack(...)) ~= nil
else
ret = c.send(co_address, skynet.PTYPE_ERROR, co_session, "") ~= nil
end
return ret
end
return response
end
去掉各种判断代码之后,是否核心流程看起来简单多了,其实就是把co_session和co_address引用着,等合适的时候,调用response闭包,发送出去就可以了。
B服务调用skynet.ret正确回应A,现在再回到A服务这边来:A的raw_dispatch_message被调用,这个函数的上半部分执行,也就是if prototype == 1 then
这部分:
local co = session_id_coroutine[session] -- 这是yield_call那里设进来的
session_id_coroutine[session] = nil
suspend(co, coroutine_resume(co, true, msg, sz))
回头再看看yield_call的代码 local succ, msg, sz = coroutine_yield "SUSPEND"
这一行把协程yield住了,那么上面的coroutine_resume就把协程唤醒,传入的是true, msg, sz,正好就是coroutine_yield返回的值。
yield_call返回msg, sz,然后skynet.call调用p.unpack把数据解压成lua对象返回。至此A call B的正常流程执行完毕。
协程的重复利用
虽然Lua创建一个协程的代价很小,但面对大量消息,如果一个消息就创建一个协程,还是一个不小的开销。skynet使用co_create这个函数,语义上看和创建协程一样,实际里面的协程是重复利用的:
local function co_create(f)
local co = table.remove(coroutine_pool)
if co == nil then
-- 1. 协程池中空,这里创建一个新的协程
else
-- 2. 从协程池中取到协程。
end
return co
end
我们分两部分来看,首先是1 创建新协程:
co = coroutine_create(function(...)
f(...)
while true do
... ...
f = nil
coroutine_pool[#coroutine_pool+1] = co
-- recv new main function f
f = coroutine_yield "SUSPEND"
f(coroutine_yield())
end
end)
- coroutine_create创建完协程,协程默认处于挂起状态,raw_dispatch_message会调用coroutine_resume,此时f(...)开始执行。
- 如果f正常执行完,会进入while循环,这就是协程被重复利用的关键。
- 在while循环里,把自己放进coroutine_pool中,然后yield使自己挂起,这里看有两个yield可能会奇怪,实际上第一个yield是为了得到f函数,第二个yeild为了得到调用函数的参数.
我们分析下第2部分就清楚了:
local running = running_thread
coroutine_resume(co, f)
running_thread = running
- 这部分是得到了协程池中的协程,调用coroutine_resume并传入f,此时第1部分的
f = coroutine_yield "SUSPEND"
返回并得到了函数f。 - 后面raw_dispatch_message调用coroutine_resume把参数传进来,此时
f(coroutine_yield())
正常被调用。 - 这样当f执行完之后,又进入while循环,如此重复,协程就被利用起来了。
消息错误处理
上面只分析了正确的流程,其实在消息派发当中,还有很多意外的情况会导致错误,skynet主要通过PTYPE_ERROR这个消息类型通知错误的。让我们再次分析A call B这个流程,假如B在执行代码过程中,出现了脚本错,并且没有使用pcall等函数,此时coroutine_resume会返回false和一个错误消息:
suspend(co, coroutine_resume(co, true, msg, sz))
suspend处理错误的流程是这样的:
function suspend(co, result, command, param, param2)
if not result then
-- 这里说明协程执行错误
local session = session_coroutine_id[co]
if session then -- coroutine may fork by others (session is nil)
local addr = session_coroutine_address[co]
if session ~= 0 then
c.send(addr, skynet.PTYPE_ERROR, session, "")
end
session_coroutine_id[co] = nil
session_coroutine_address[co] = nil
session_coroutine_tracetag[co] = nil
end
error(debug.traceback(co,tostring(command)))
end
...
end
-
if session ~= 0 then
表明这是一个call,那么调用c.send向A服务发送一个PTYPE_ERROR消息。 - 然后自己也会打印一个error出来。
再回到A这边,PTYPE_ERROR的dispatch函数是_error_dispatch:
local function _error_dispatch(error_session, error_source)
skynet.ignoreret() -- don't return for error
if error_session == 0 then
-- 服务停止的情况
else
-- capture an error for error_session
if watching_session[error_session] then
table.insert(error_queue, error_session)
end
end
end
先把error_session存到error_queue,等这个协程执行完,在suspend函数里面再调用dispatch_error_queue:
local function dispatch_error_queue()
local session = table.remove(error_queue,1)
if session then
local co = session_id_coroutine[session]
session_id_coroutine[session] = nil
return suspend(co, coroutine_resume(co, false))
end
end
这里面做的就是找到那个call的协程,然后resume它,此时yield_call函数里的yield返回,succ为false,最终就抛出一个错误消息。
sleep和和timeout
有了前面的分析,再来看sleep和timeout就比较简单了:
function skynet.timeout(ti, func)
local session = c.intcommand("TIMEOUT",ti)
assert(session)
local co = co_create(func)
assert(session_id_coroutine[session] == nil)
session_id_coroutine[session] = co
end
- 调用c.intcommand("TIMEOUT",ti)向skynet增加一个超时事件,并返回一个session。
- co_create创建一个协程出来,此时协程是挂起状态。
- session_id_coroutine记住会话和协程的关联。
- skynet_timer时间到达时,会发送一个reponse类型的消息回来,那么raw_dispatch_message处理这个消息,把上面co_create的协程唤醒,并开始执行func回调。
skynet.sleep不需要创建协程,它向skynet增加一个超时事件,然后把自己这个协程挂起就行了,等response来后,这个协程就能被唤醒。sleep还提供了第二个参数token,这是用于中断sleep的,其他协程调用skynet.wakeup(token),传入这个token就能中断sleep,此时sleep返回'BREAK',通过这个结果可以判断是正常返回还是中断返回。
skynet.wait(token)和sleep的区别只在于不向skynet增加超时事件,它一直把自己挂起,等待skynet.wakeup来唤醒自己。
fork的处理
skynet.fork(func, ...)可以使func在当前协程处理完之后再执行:
function skynet.fork(func,...)
local args = table.pack(...)
local co = co_create(function()
func(table.unpack(args,1,args.n))
end)
table.insert(fork_queue, co)
return co
end
先通过co_create创建一个协程,然后把这个协程插入fork_queue,在dispatch_message函数可看到,协程执行完,会处理fork_queue,把所有协程一个个唤醒执行。
Lua API
skynet的WIKI列出了多数常用Lua API的使用方法,请阅读这份文档LuaAPI,以理解这些API的含义。