lua 去除小数点有效数字后面的0_skynet之lua服务

其实从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的含义。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值