skynet笔记
·
文章目录
- API查阅
- 一个独立的 Skynet 服务(Actor)
- 消息头部的类型(ptype)字段。
- 协程
- 主协程
- 协程例子(场景:roomd有3个函数funcA/funcB/funcC
- 常见函数解析
- 常见的字符串处理( Lua 标准字符串库的函数)
- lua内容
- lua的正则模式 /(pattern)
- lua虚拟机(服务
- require "skynet.socket"的理解
- 【yield】
- skynet内容
- 常见问题解析
-
- 关于socket的迷惑点
- skynet.ret(skynet.pack(f(...))) 的意义
- skynet.register 只适合单实例服务吗?多个实例(如房间)怎么找?
- 服务为什么延迟退出?有必要吗?
- 服务延迟退出真的优雅吗?1 秒能保证送达吗?
- skynet.send 是同步还是异步?延迟 1 秒到底在等什么?
- 服务退出会销毁消息队列吗?消息会丢吗?
- “礼节性等待”到底是为了干啥?
- 未来扩展的安全垫体现在哪里
- skynet.timeout 的单位为什么是 10ms?
- 为什么“单线程”还会有竞态?用正常流程和异常流程对比说明。
- 单线程为什么还要“加锁”?穿插到底指什么?
- 我是不是可以理解为,只要是call,我都fork一下?
API查阅
- Skynet Wiki 几乎列出了全部接口,每个都有用法说明
- 源码注释:在 skynet/lualib/skynet.lua 里,大部分核心函数的注释非常清晰。
一个独立的 Skynet 服务(Actor)
- 一个 Lua 文件 + skynet.start + 由 skynet.newservice 加载 = 一个独立的 Skynet 服务(Actor)
- 新文件套上"标准模式"壳就变成了一个服务:
local skynet = require "skynet"
-- ... 引入其他模块
-- 命令处理表
local CMD = {}
function CMD.xxx(...)
-- 处理 lua 消息
end
-- 消息回调处理客户端连接等
-- ...
skynet.start(function()
-- 注册 lua 消息分发
skynet.dispatch("lua", function(session, source, cmd, ...)
local f = CMD[cmd]
if f then
skynet.ret(skynet.pack(f(...)))
end
end)
-- 初始化逻辑(比如启动监听)
-- 一般用 skynet.timeout(0, function() ... end) 延迟执行
end)
消息头部的类型(ptype)字段。
- Skynet 在消息传递时,会在消息头部附加一个类型(ptype)字段。Skynet 设计上将消息类型作为框架级的大类,而“具体干什么”由自定义的命令字段区分。常见的类型有:
- “lua”:Lua 服务之间互相发送的消息(最常用的)
- “client”:来自客户端连接的消息
- “system”:系统内部消息(如退出、错误通知)
- 自定义数字类型:通过 skynet.register_protocol 定义,比如给id为skynet.PTYPE_CLIENT的数字指定一个name
register_protocol的作用
- 把一个数字和一个好记的字符串绑定,之后就可以用字符串来表示消息类型。
- register_protocol 里 id 和 name 的关系就是:
- id:这个类型的底层数字标识(可以是预定义常量,也可以是自定义数字)
- name:你给这个数字起的别名,之后用字符串来指代它
- 例子:
// 例子:注册后 Skynet 内部维护了 "client" → 数字 PTYPE_CLIENT 的映射
skynet.register_protocol {
// 给它起了个叫 "client" 的字符串名字。注册完之后,你就可以用 "client" 来代替数字id。
name = "client",
// skynet.PTYPE_CLIENT 是 Skynet 已经定义好的一个数字常量,表示“客户端消息”这个类型。
// 它是预设的几个标准类型之一(像 PTYPE_LUA、PTYPE_SYSTEM 一样)。
id = skynet.PTYPE_CLIENT,
}
预定义常量
- 常见的预定义常量还有:(冒号后面对应的是大概的用途)
- skynet.PTYPE_LUA:Lua 服务间消息(你用的 “lua” 背后就是这个)
- skynet.PTYPE_CLIENT:来自客户端连接的消息
- skynet.PTYPE_SYSTEM:系统消息(服务退出通知等)
- skynet.PTYPE_RESPONSE:内部用于 skynet.call 的回应
自定义数字(类型)
- 我们都知道Skynet 在消息头部用 1 个字节(0~255)来标记消息类型。所以底层传输时,类型一定是数字。
- 为了让你写代码方便,Skynet 提供了两个东西:
- 预定义的常量:比如 skynet.PTYPE_LUA、skynet.PTYPE_CLIENT、skynet.PTYPE_SYSTEM,它们其实都是数字(分别对应 0、3、1 等值,不同版本可能略有差异,但你不需要记数字)。
- skynet.register_protocol:允许你为某个数字类型起一个人类可读的名字(字符串),并且也可以通过这个名字来引用它。
- 自定义数字(类型)就是你自己创建一个全新的消息类型编号(在 0~255 之间,不能与系统已有类型冲突)。
- 假设你想区分“好友私聊消息”和“世界频道消息”,可以这样:
-- 自己找个没被占用的数字,比如 10 和 11
skynet.register_protocol {
name = "private_chat",
id = 10,
}
skynet.register_protocol {
name = "world_chat",
id = 11,
}
// 之后用 skynet.send(addr, "private_chat", ...) 发消息。
// 接收方也用 skynet.dispatch("private_chat", func) 来处理。
// “自定义数字(类型)”的含义:你自己为某个整数id类型分配一个名字。
协程
- skynet.fork(func, …) - 协程
- 作用:启动一个新的协程去执行一个函数,这样即使函数里阻塞了(比如 skynet.call),也不影响当前业务流程。
- 什么时候要fork?:如果不放在协程里,就会导致出现阻塞卡住,无法处理其他消息或新数据的情况时。(fork 是为“不阻塞主循环”而生的协程启动器)
- 记忆方法:像“开个小线程去做一件可能花时间的事”。
- 挂起是 call 机制的内置行为,那为什么还要用 fork 包一层?
- fork 的作用不是“让挂起发生”,而是保护(主协程/主消息循环)不被阻塞。
- 一个 Skynet 服务只有一个(主协程)主消息处理循环(就是你 skynet.start 里的 dispatch 回调)。如果你在主循环里直接 call,整个服务的消息处理就会被这个协程阻塞住,其他玩家来的消息、超时定时器等都无法被处理,直到 call 返回。
- 所以最佳实践是:
- 主协程只做轻量的事(如分发 CMD、发 send 消息)
- 可能阻塞的 call 用 skynet.fork 扔到新协程里执行,这样主循环可以立刻继续处理下一条消息
- fork 的作用不是“让挂起发生”,而是保护(主协程/主消息循环)不被阻塞。
- 如果没有协程,生活会怎样?
- 在传统线程模型里,如果一个线程阻塞等待 I/O,整个线程就卡死了(你得再开更多线程来弥补,代价高)。
- Skynet 用协程解决了这个问题:你写代码时以为自己在同步等待(舒服),但底层其实是异步非阻塞的(高效),这份“即用即走”的协程调度让你可以在一个服务里同时处理很多逻辑线,而不用手写回调地狱。
出拳消息到达 → on_message 在主协程执行
│
├── 错误写法:直接 call → 主协程挂起 → 服务僵死
│
└── 正确写法:fork 新协程 → 主协程立刻返回 → 继续处理其他消息
│
└── 子协程挂起,等房间回复
- 所以 fork 在这里的作用就是:让 agent 的出拳操作变成“后台任务”,主协程永远保持轻盈,随时准备接收和处理其他服务发来的任何命令。
- 这就是 Skynet 高并发的根基:主协程只做路由分发,耗时等待全 fork 出去。
主协程
- 主协程 就是 主消息处理循环 的具体执行者。
- 它是一个服务里唯一能从消息队列里取任务并执行的“办事员”。
- skynet.call 会挂起当前协程。如果它挂起的是这个唯一的“办事员”,那自然就没有人去处理消息队列里剩下的新消息、定时器和通知了。
- 整个服务因此进入“假死”状态,直到挂起的 call 收到回复,主协程被唤醒。
- 关键结论:
- 服务间不会状态污染。
- 主协程是每个服务私有的,不属于多个服务共享。
- 归属是绝对的:主协程 100% 属于创建它的那个服务,不存在共享。
- 举个例子:roomd 和 agent 的主协程是两个完全独立的执行流,它们之间要通信,必须通过 skynet.send 或 skynet.call 发消息,不能直接调用对方的函数。
- 具体来说:
- 每个服务都有一个主协程:当服务启动,skynet.start 里的那个函数体就是在这个服务的主协程里执行的。这个主协程负责执行消息分发(dispatch)里的回调,也就是你写的那些处理逻辑。
- 它是服务的执行心脏:服务收到的每条消息,最终都是由它的主协程(或由它通过 fork 创建的子孙协程)来处理的。服务销毁时,它所有的协程(包括主协程)都会被清理掉。
- 服务之间协程完全隔离:roomd 的主协程只在 roomd 这个服务里运行,管理的是 roomd 收到的消息。agent 有自己的主协程,只处理 agent 自己的事。(它们虽然可能由同一个底层线程调度,但执行上下文是完全隔离的,内存不共享,协程也不能跨服务跳转。
- 用一个更精确的比喻:
- 每个服务 = 一个独立的办公室,有唯一的地址(服务ID)。
- 主协程 = 这个办公室专属的办事员。他只在办公室内工作,只处理投递到这个办公室的文件。
- skynet.fork = 办事员叫来了一个临时工(子协程)帮忙,但临时工依然在这个办公室里工作。
- 底层工作线程 = 办公楼里的后勤系统,它不关心业务,只负责把所有办公室的**办事员(协程)**安排得井井有条,让他们轮流使用CPU。
- 所以,主协程是服务在运行态的具体化身。我们说“服务A在处理消息”,其实就是“服务A的主协程正在执行消息对应的回调函数”。
协程例子(场景:roomd有3个函数funcA/funcB/funcC
- 演示性质的 roomd,包含三个命令处理函数:
local skynet = require "skynet"
local CMD = {}
-- 假设有一个全局的数据库服务地址
local db_service = nil
function CMD.funcA()
skynet.error("funcA: 快速任务,直接返回")
return "done_A"
end
function CMD.funcB()
skynet.error("funcB: 发起 call 查询数据库(错误示范:会阻塞整个服务)")
-- 这会卡住主消息循环,其他命令只能等 call 返回才能被处理
local result = skynet.call(db_service, "lua", "query", "some_key")
skynet.error("funcB: 数据库返回 " .. result)
return result
end
function CMD.funcC()
skynet.error("funcC: 用 fork 包装 call(正确做法)")
skynet.fork(function()
local result = skynet.call(db_service, "lua", "query", "another_key")
skynet.error("funcC: 数据库返回 " .. result)
-- 如果需要回复调用方,可以在协程里再 skynet.ret,但一般设计上尽量避免
end)
return "funcC 已启动查询,不等结果"
end
skynet.start(function()
-- 假设 db_service 地址已知,这里 mock
db_service = skynet.queryservice("db")
skynet.dispatch("lua", function(session, source, cmd, ...)
local f = CMD[cmd]
if f then
skynet.ret(skynet.pack(f(...)))
end
end)
end)
- 执行效果分析:
| 调用 | 主协程状态 | 服务的其它消息是否能处理 |
|---|---|---|
| funcA | 不阻塞,立刻返回 | 可以 |
| funcB | 阻塞,知道call返回 | 不能 |
| funcC | fork新协程后立刻返回,主协程自由 | 可以 |
- 如果外部服务连续调用 funcB,整个 roomd 会陷入“串行等待”,即使用户超时、玩家出拳等消息全堵在队列里。而 funcC 则不会影响其他消息的处理。
- Skynet 开发中最常见的模式:主线程只发号施令,耗时的活都 fork 出去干。
如果实例的funcB被挂起了,我能再次调用到funcB吗?
- 可不可以取决于你是在主协程里挂起,还是在 fork 出去的协程里挂起。下面分情况说明:
- funcB 在主协程被挂起(错误示范)
function CMD.funcB()
skynet.error("funcB: 发起 call 查询数据库(阻塞主协程)")
local result = skynet.call(db_service, "lua", "query", "some_key")
skynet.error("funcB: 数据库返回 " .. result)
return result
end
- 执行流:某条消息触发 CMD.funcB(),它直接在主协程(也就是处理消息分发的那条协程) 里运行,然后遇到 skynet.call,主协程挂起。
- 后果:整个服务的心跳停了。 所有后续的消息(包括新的 funcB 调用、funcA、funcC、超时回调)全部堵在队列里,没人去取。
- 能再次调用 funcB 吗?
- 不能。 第二条调用 funcB 的消息确实能发过来,但它只能在队列里等着,根本不会被处理,因为主协程正挂着等第一个 call 的回复呢。
- funcB 在子协程被挂起(正确做法)
function CMD.funcB()
skynet.error("funcB: 用 fork 包装 call(不会阻塞服务)")
skynet.fork(function()
local result = skynet.call(db_service, "lua", "query", "some_key")
skynet.error("funcB: 数据库返回 " .. result)
end)
return "funcB 已启动查询,不等结果"
end
- 执行流:某条消息触发 CMD.funcB(),它在主协程里执行,只做了 fork,然后立刻返回。被 fork 出的子协程才去执行 call 并挂起。
- 后果:主协程瞬间空出来,马上去处理下一条消息。
- 能再次调用 funcB 吗?
- 当然能。 再来一条 funcB 消息,主协程再次执行 CMD.funcB(),再 fork 出一个新的子协程去挂起。两个子协程各等各的回复,互不干扰。你会看到日志里先后打印出 “funcB: 用 fork 包装 call…”。
常见函数解析
- pcall(func, …) - 安全调用
- 作用:protected call,安全调用一个函数,即使函数内部出错也不会让整个服务崩溃,而是返回错误信息。
- 为什么有时用 pcall 包住 skynet.call?:skynet.call 可能会报错时,用 pcall 可以兜底,防止 服务 挂掉。
- 记忆方法:“开启保护罩去调用(防自爆的保护罩,里面炸了也影响不了外面的)”。
- skynet.self() - 我是谁
- 作用:返回当前服务自己的地址(一个数字)。
- 用在哪:发给某个服务时告诉对方“我是哪个服务”,便于对方发消息回来。
- skynet.register(“.luaFileName”)
- 把当前服务注册成一个全局名字,带 . 前缀代表是系统级别名。
- 这样就能用 skynet.queryservice(“luaFileName”) 找到它。
- 注意:注册名里的 . 前缀在查询时不需要带,所以 register(“.luaFileName”) 对应 queryservice(“luaFileName”)。
常见的字符串处理( Lua 标准字符串库的函数)
迭代器(match)
for line in string.gmatch(data, "[^\r\n]+") do
// - string.gmatch(s, pattern) 返回一个迭代器,能不断从字符串中找出所有匹配 pattern 的部分
// - 这里的 [^\r\n]+ 是正则模式,意思是:一个或多个(正数个)不是回车(\r)或换行(\n)的字符
// - 所以整句话就是:把接收到的数据按行切割,去掉换行符,得到每行干净的命令
替换函数(sub)
player_name = string.gsub(msg, "%s+", "")
// - string.gsub(s, pattern, replacement) 是替换函数,把所有匹配 pattern 的地方替换成 replacement
// - %s+ 表示一个或多个空白字符(空格、tab、换行等)
// - 替换成 "" 就是删除所有空白
// - 所以这句话就是:把输入的字符串中可能混入的空格和换行全部清掉,只留纯文本
lua内容
ipairs(顺序数组迭代器)【辅助:i->idx; indexed pairs】
- 总结:ipairs 是用来遍历数组部分的,它会严格按照索引顺序:1, 2, 3, …,遇到第一个 nil 就停。
- 它会从索引 1 开始,依次取 table[1], table[2], table[3]…
- 一旦遇到某个索引对应的值为 nil,立即停止。
- 如果改用了 pairs,虽然也能遍历,但不保证顺序,而且可能遍历到其他键。
pairs(kv)【辅助:字典无序迭代】
- 总结:pairs 则是遍历表中所有的键值对,不仅包括数字索引,也包括字符串键,而且顺序不保证。
# 是长度操作符
- 用在表上获取数组部分的元素个数。
- 用在字符串上获取字符数。
… 是字符串拼接符
- 可以把任意基础类型拼成字符串,数字会自动转换。
- Lua 中没有 + 拼接字符串,必须用 …
lua的正则模式 /(pattern)
- Lua 的正则模式(Lua 称为 pattern),和常规正则有一点不同,但逻辑一致。
for line in string.gmatch(data, "[^\r\n]+") do
- […] 叫做字符集,表示匹配括号里的任意一个字符。
- [^…] 表示匹配不在这个集合里的任意一个字符。
- \r 是回车,\n 是换行。
- 所以 [^\r\n] 的意思就是:任意一个不是回车、也不是换行的字符。
- 后面的 + 表示匹配一个或多个这样的字符。
- string.gmatch(data, “[^\r\n]+”) 的效果就是:从接收到的报文里把每一行(去掉了结尾的回车换行)提取出来。
player_name = string.gsub(msg, "%s+", "")
- %s 是 Lua 模式里空白字符的简写(空格、tab、换行等),类似正则里的 \s。
-
- 表示匹配一个或多个。
- 所以 %s+ 就是一连串的空白。
- string.gsub(msg, “%s+”, “”) 就是把玩家输入的名字中可能混入的连续空白(空格、换行、tab)全部清空,只剩干净的名字。
lua虚拟机(服务
- 就用agent.lua来举例:
- agent 有多个实例,但每个实例都是一个独立世界,不会冲突。
- 为什么不会冲突?:因为每次用 skynet.newservice(“agent”) 时,Skynet 都会创建一个全新的独立的 Lua 虚拟机(服务)。
- 也就是说每个新服务都会完整执行一次 agent.lua 文件,也就是每个 agent 都有自己独立的变量/回调/等等。
- 举个更具体的例子:在 agent.lua 里写 socket.on_data = function(data) … end,这个赋值是发生在这个服务自己的环境里,不会影响到其他 agent 服务。
- 就好比每个房间都有一台电视,你把自己房间的电视换成新频道,邻居房间的电视根本不受影响。——每个 agent 只服务自己的那个玩家,互不干扰。
// 整个流程的实例化过程:
// Player1 连接 → logind 创建 agent1 → agent1.lua 全新执行,内部 fd = fd1
// Player2 连接 → logind 创建 agent2 → agent2.lua 全新执行,内部 fd = fd2
require "skynet.socket"的理解
- socket 是你代码里 require “skynet.socket” 得到的模块,它提供网络 I/O 相关的函数。
- 它不是服务,而是一个功能库,类似于 Skynet 给我们的一把网络工具。
- 每个服务中 require “skynet.socket” 得到的都是同一个底层机制的引用,但使用时只影响本服务内部的事件回调,不会干扰其他服务。
- 因此,可以把它看作每个服务有独立的 socket 事件注册,但底层是共享的。
【yield】
- yield 是 Lua 协程的核心机制,Skynet 正是基于它构建了整个异步非阻塞框架。
yield 是 Lua 原生的协程函数
- Lua 提供了 coroutine 标准库,核心就是三个函数:
- yield 就是 “主动让出执行权,暂时挂起自己” 的指令。
| 函数 | 作用 | 通俗理解 |
|---|---|---|
| coroutine.create(f) | 创建一个协程 | 开一个新小夹板 |
| coroutine.resume(co) | 启动/恢复协程 | 办事员拿起这个小夹板继续工作 |
| coroutine.yield(…) | 挂起当前协程 | 办事员放下小夹板,去干别的 |
Skynet 的 call 底层就是 yield
- skynet.call 的“挂起等待”能力,本质上就是 Lua 的 coroutine.yield 提供的。
- 当你写 skynet.call(addr, “lua”, …) 时,Skynet 内部做的事大致是:
-- 伪代码,展示 call 的原理
function skynet.call(addr, type, ...)
-- 1. 发送消息
send_message(addr, type, ...)
-- 2. 挂起当前协程,等待回复
-- 底层用的就是 coroutine.yield
local reply = coroutine.yield()
-- 3. 等回复到了,协程被 resume,继续执行
return reply
end
Skynet 还有自己的 skynet.yield
- Skynet 也封装了一个 skynet.yield(),它和 coroutine.yield 功能类似,但多了一些 Skynet 内部的状态管理。
- 不过在正常写业务代码时,你几乎不需要直接调 yield。Skynet 已经把 yield 包装进了这些高层函数里:
- skynet.call(等回复)
- skynet.sleep(等一段时间)
- skynet.wait(等待唤醒)
平时写代码不需要直接碰 yield
- 作为 Skynet 应用开发者,只需要记住:
- 用 fork 创建协程
- 用 call 同步等待(内部自动 yield)
- 用 send 异步扔消息(不 yield)
- 至于 yield 怎么实现、resume 怎么恢复,这些都是 Skynet 框架处理好的,不用自己写。
何为send异步扔消息?
- 对于“发送方”自己来说:send 是同步的
socket.write(fd, "Hello\n") -- 第一步
skynet.send(room, "lua", "player_move", skynet.self(), "rock") -- 第二步
socket.write(fd, "Move sent\n") -- 第三步
- 代码的执行顺序是:
- 执行 socket.write(“Hello”) → 输出给客户端。
- 执行 skynet.send(…) → 把消息丢到 room 的邮箱 → 立刻返回。
- 执行 socket.write(“Move sent”) → 输出给客户端。
- 当代码执行到 skynet.send 这一行时,它会立刻、马上把消息打包好,塞进目标服务(room)的消息队列里,然后毫不留恋地返回【对于发送方,send是同步的】。
- 整个过程是瞬间完成的(只是把数据指针放进对方队列),没有任何等待对方“开始处理”或“处理完毕”的动作。
- 对于“发送方”和“接收方”之间:执行是分开的
- 这就是 异步 的含义:
- 发送方:扔完消息就继续跑自己的代码,不关心接收方什么时候处理。
- 接收方:在自己的事件循环里,按顺序从邮箱里取出消息,然后由自己的主协程去执行对应的处理函数。
- 它们俩像是两条并行的流水线,互不等待。这就是所谓的 异步解耦。
- 送信比喻
- skynet.send:你把一个包裹扔到了邻居家的信箱里。你做完“投递”这个动作,立刻转身就回家干自己的活了。你完全不关心邻居现在是在睡觉还是出门了,你只知道包裹已经在他信箱里了,他最终一定会看到。这就是“异步扔消息”。
- skynet.call:你不仅把包裹送到邻居家,还按了他家门铃,在他家门口等着他开门、拆包裹、给你一个口头回复,然后你才转身回家。这就是“同步等待”。
- 所以,“异步扔消息”,指的就是 send 只负责把消息快速丢进对方邮箱,然后毫不迟疑地继续执行自己的下一行代码,整个投递过程是立即完成的,但对方对消息的处理是异步发生的。
小结
- yield 属于 Lua 还是 Skynet?
- 属于 Lua 标准库(coroutine.yield),Skynet 也封装了 skynet.yield
- 平时写代码要用吗?
- 几乎不用,Skynet 的 call、sleep 等已经内置了 yield
- 它和 call 的关系?
- call 内部就是通过 yield 挂起协程,等回复到了再 resume
skynet内容
skynet.send()
- 消息扔进对方队列,立刻返回,执行下一行(不用等)
skynet.call()
- skynet.call 是同步阻塞当前协程的,它必须等返回后,才会执行下一行代码。
- call 自身会挂起协程。【但是注意:call 绝对不会“启动”任何子协程。】(call 只负责挂起,不负责创建)。
- Q:是不是调用了 skynet.call 就会挂起协程? A:是的,skynet.call 必然挂起当前协程,等待对方回复。
- 无论你在哪个协程里调用 skynet.call,它都会:
- 把消息发送出去
- 让出执行权,挂起当前协程
- 等对方返回结果后,再从这个协程恢复执行,并返回结果
- 挂起是 call 机制的内置行为,不需要额外的 fork 来触发挂起。
- 无论你在哪个协程里调用 skynet.call,它都会:
- **为什么 call 能“停住”?**skynet.call 的流程:
- 把消息放入目标服务队列
- 挂起当前协程(注意:挂起的是协程,不是整个服务阻塞)
- 等目标服务执行完,并通过 skynet.ret 返回结果
- 唤醒当前协程,把结果作为 call 的返回值
- 继续执行下一行代码
常见问题解析
关于socket的迷惑点
- socket 不是全局单例吗?为什么多个 agent 赋值 on_data 不冲突?
- 核心原因:每个 Skynet 服务是独立的 Lua 虚拟机,拥有完全隔离的全局环境。
- 总结:socket 模块在每个服务里都有一份独立的副本,on_data 回调是服务级别隔离的。
- 具体的:
- require “skynet.socket” 时,虽然加载的是同一段代码,但这段代码会在当前服务的 Lua 状态机里执行,并返回一个模块表。
- 这个表是当前服务私有的,与其他服务中的 socket 表不是同一个对象。
- 即使底层网络实现是进程内共享的,但 socket.on_data 的赋值只绑定到当前服务的回调槽位。
- 底层在收到数据后,会根据 fd 所属的服务 ID,把数据丢给对应服务的 on_data 函数(虽然是socket点出来的,但是是对应服务的,可以这么理解)。
- 所以整个过程就像:
- 你给本服务(agent)自己(实例)的电话本(socket 表/模块副本表)上写了“客户(fd-x)来电请转接给我”的回调。
- 其他服务有自己的电话本,他们怎么写都不会影响你的。
- agent服务实例 ~ socket模块表(on_data) ~ 客户端(fd-x),即1个客户端->1个agentInst.socket.on_data。
skynet.ret(skynet.pack(f(…))) 的意义
- f(…) 返回了值。
- skynet.pack(true) 打包成二进制响应,skynet.ret 把它发回给 skynet.call 的调用方。
- f(…) 没有明确返回值
- 函数最后一句不是 return,或 return 后面无值。
- Lua 中函数返回值会变成 nil。skynet.pack(nil) 打包为携带空值的响应,同样正常发回。
- 发送方用的是 skynet.send(异步),没有人在等待应答。
- skynet.ret 仍然会执行,但 Skynet 底层发现这个消息来自 send(没有请求 session),就会自动丢弃应答包,不会造成阻塞或错误。
- 因此,无论有没有返回值,无论发送方是 send 还是 call,这个标准的 CMD 分发模板都是安全的。这也是为什么几乎每个 Skynet 服务都会雷打不动地写这几行。
skynet.register 只适合单实例服务吗?多个实例(如房间)怎么找?
- skynet.register 注册的名字是全局唯一的,同名会出错,所以只适合那些只有一个实例的服务(比如我们的 matchd)。
- 对于动态创建的多实例服务(如 roomd),绝不会用 register,而是直接把地址传递出去。我们的流程就是:
- matchd 创建房间:room_addr = skynet.newservice(“roomd”)。
- matchd 把 room_addr 发给两个 Agent:skynet.send(p1.agent, “lua”, “battle_start”, room_addr, p2.name)。
- Agent 保存这个 room_addr,后续通信就直接发给这个地址。
- 这种方式不需要全局名字,效率高且天然隔离,是 Skynet 里多实例通信的标准做法。
服务为什么延迟退出?有必要吗?
skynet.timeout(100, function() skynet.exit() end)
- 非常有必要,是保证消息送达的经典做法。
- skynet.send 是异步的,调用后消息只是扔进队列,并不代表接收方立即处理完成。
- 如果紧接着调用 skynet.exit(),当前服务直接销毁,未发出的消息可能被丢弃,导致玩家收不到结果。
- 延迟一小段时间(这里 100 单位 = 1 秒),给底层足够时间将消息推送到目标服务,然后退出,确保玩家一定看到消息。
- 这是一种异步系统中的“优雅退出”手段,真实项目里很常用。
服务延迟退出真的优雅吗?1 秒能保证送达吗?
- “等 1 秒再退出”听起来有点粗暴,但实际上是非常经典且可靠的做法。
- 核心原因:
- skynet.send 是把消息扔进目标服务的消息队列,这个过程本质上只是入队一个内存指针,几乎瞬间完成。1 秒对这个操作而言是绰绰有余的。
- 真正耗时的不是入队,而是事件循环调度的过程。但 Skynet 的事件循环是单线程忙轮询,速度极快。
- 退一万步说,即使系统极度繁忙导致消息还没被对方处理完,消息本身已经在对方队列里了,服务退出不影响它。消息没入队的情况才会丢,而 1 秒足以让系统完成入队操作几万次。
- 所以这个延迟不是“祈祷”,而是基于系统底层的确定性保证。在真实项目中,这种“延迟退出模式”被广泛使用,且时间通常设得非常保守。
skynet.send 是同步还是异步?延迟 1 秒到底在等什么?
-
skynet.send 的“放入队列”是同步的(当你调用 skynet.send 时,Skynet 底层会:
- 把消息打包
- 找到目标服务的消息队列
- 把这个消息立刻挂到那个队列上
- 这一步是同步完成的,函数返回时消息肯定已经在对方队列里了,完全不需要延迟等待。
-
延迟 1 秒等的是“对方处理完”(但!消息虽然入了队,对方服务处理它需要时间。
- roomd 调用 send_to_both(“Result: …”) 后,消息进入了 agent A 和 agent B 的队列。
- agent 的事件循环需要从队列里取出消息,执行回调,再调用 socket.write 把结果发给客户端。
- 如果 roomd 立即退出,它本身没问题(消息已在别人队列里),但我们要确保玩家真正看到结果后(上面提到的socket.write 把结果发给客户端),这个“对局房间”才彻底销毁,便于后续扩展或日志记录。
- 所以延迟 1 秒并不是为了让 send 完成,而是为了让接收方(agent)有充足时间把消息消费掉并转发给客户端。这是一种“等大家都处理完我再走”的礼节性等待。
服务退出会销毁消息队列吗?消息会丢吗?
- “扔进队列”指的是目标服务的消息队列。下面举个具体例子:
- 当 roomd 执行 skynet.send(agent, …) 时,消息会被放入 agent 服务的邮箱队列里。
- 然后 roomd 自己调用 skynet.exit() 退出,销毁的是roomd 自己的资源和自己的邮箱队列。
- agent 的邮箱队列完全不受影响,因为 agent 还活着,消息稳稳地待在它的队列里等着被处理。
- 所以,roomd 退出只销毁自己的东西,不会牵连 agent 的消息。只要消息已经入队,就是安全的。
“礼节性等待”到底是为了干啥?
- 本例子中,如果 roomd 立即退出,玩家依然能看到消息,消息并不会丢。
- 消息传递链路:
- roomd --(skynet.send)–> agent --(socket.write)–> 客户端
- roomd 把结果发给 agent(异步消息)
- agent 收到消息后,调用 socket.write 把文本转发给客户端
- 为什么 roomd 立即退出也不会丢消息?
- 因为 skynet.send 是直接把消息放进目标 agent 的队列里。这个过程在函数返回时就完成了,消息已经属于 agent。
- 之后 roomd 是死是活,完全不影响 agent 已经在自己队列里的消息。agent 的事件循环会按顺序取出并处理它。
- 所以即使 roomd 在 send 之后立刻 exit,玩家依然能收到结果,因为链路中间的消息已经在 agent 手里了。
- 那为什么还要延迟退出?
- 这其实是一个保守的工程习惯,用来防御以下几种更微妙的情况:
- 发送顺序的可见性:虽然消息已经入队,但 agent 处理它还需要一个事件循环 tick。如果 roomd 退出得太快,在某些性能分析或日志跟踪场景下,可能会看到“房间服务已经销毁,而消息还在排队”的奇怪状态,给调试造成迷惑。
- 未来扩展的安全垫:如果后续你给 roomd 增加了“等待玩家确认再退出”或“写入数据库后再退出”的逻辑,这个延迟就是一个天然的框架。现在虽然用不上,但改一行数字远比改架构省力。
- 防止极端并发下的幽灵消息(极少见):在极高端负载下,如果服务退出后立即有新的服务复用了相同地址(虽然 Skynet 地址是单调递增的,很难复用),可能导致某些延迟上报的状态消息错投。延迟退出能降低这种概率。
- 所以严格来说,延迟退出对你当前最简版石头剪刀布不是必须的,但它是一种“更稳健”的写法,能让你在项目逐渐复杂时少踩坑。
- 当前项目可以怎么优化?
- 如果追求极简,完全可以把 roomd 的 judge 和 check_timeout 中发送完消息后直接调用 skynet.exit(),去掉 timeout(100)。经测试客户端照样完美收到结果。
- 建议先带着延迟保留,因为这种“等等再走”的模式会在后来的数据库存盘、玩家断线重连等场景中变得真正重要。等下次看到类似写法时,心里就有底了。
未来扩展的安全垫体现在哪里
- 场景一:将来想加“玩家确认再来一局”
// 这时候 roomd 不能立即退出,因为还要等着收玩家的回答。
// 如果没有延迟退出框架,现在 judge 末尾直接是 skynet.exit(),你得把整个退出逻辑拆掉,改为等待消息。
// 但因为你已经有了延迟退出,修改就极其简单:
-- 现在
skynet.timeout(100, function() skynet.exit() end)
-- 将来改成
skynet.timeout(100, function()
ask_play_again()
end)
// 只是把延迟退出那块替换成“询问逻辑”,整体架构没变,风险极低。
- 将来要写入数据库
// 比如要记录每局战斗日志到数据库(用 db 服务):
// 因为延迟退出已经创造了一个明确的时间窗口,你可以在 roomd 退出前安全地插入任何异步操作,而不必担心服务被提前销毁。
-- 现在
skynet.timeout(100, function() skynet.exit() end)
-- 将来改成
skynet.timeout(100, function()
// 这里是一个协程可暂停的区间
skynet.call(db_service, "lua", "save_battle_result", ...)
skynet.exit()
end)
// 对比:没有延迟退出的旧代码
// 如果你突然想加数据库保存,就得把 exit 移到回调深处,还需要改判断逻辑,极易引入 bug。
// 所以延迟退出就是一个“预留钩子”,成本极低(1 秒超时),却让未来修改变得安全简单。这就是“改一行数字远比改架构省力”的真正含义。
skynet.timeout 的单位为什么是 10ms?
- Skynet 内部时间轮精度选定 10ms,是 CPU 开销与定时精度的平衡。
- 游戏服务端通常不需要 1ms 级超时,10ms 对大多数玩法(如战斗超时、心跳间隔)完全足够,且能减少定时中断频率。
- 所以调用 skynet.timeout(100, fn) 代表 100 * 10ms = 1 秒。
- 记住:想要 N 秒,就写 skynet.timeout(N*100, fn)。
为什么“单线程”还会有竞态?用正常流程和异常流程对比说明。
- 关键点:Skynet 服务是单线程的,但事件循环会交替执行不同的回调。
- 为什么会有这种交错?
- 因为 Skynet 的事件循环会把所有已到达的消息和到期的定时器排进同一个队列,然后依次执行它们的回调。超时回调本身也是一个“消息”。如果超时回调的执行被排在了玩家 2 出拳消息的前面,就会先判超时。
- 所以解决办法就是 result_sent 锁:
- check_timeout 里设置 result_sent = true
- judge 开头检查这个标记,如果已为真则直接返回
- 这样就保证了整个服务生命周期内最多只输出一次结果
- 这个标记就是用来在单线程的事件循环中,防止“超时”和“正常完成”这两个逻辑路径互相穿插带来的重复判罚。
- 一个服务内,所有代码确实都是串行的,不会出现“正在执行 judge,又同时执行 check_timeout”的情况。但问题在于这两个回调的触发时机交错。具体展开如下:
- 正常流程(无竞态):
- 玩家 1 出拳 → player_move 记录
- 玩家 2 出拳 → player_move 记录,发现两人都出了 → 调用 judge()
- judge 发送结果,设置 result_sent = true
- 超时定时器触发(或根本没超时),check_timeout 被调用,看到 result_sent == true,直接返回
- 竞态流程(超时先触发)
- 玩家 1 出拳
- 玩家 2 没出拳,30 秒到了 → 超时定时器回调触发,执行 check_timeout,判玩家 2 负,设置 result_sent = true
- 几乎同时(或在超时回调执行完毕前),玩家 2 的消息刚好到达 → player_move 被调用,这时两人都有拳了,会尝试调用 judge()
- 由于没有 result_sent 保护,judge 就会再次发送结果,出现两次判罚。(所以加个保护标记是有必要的)
单线程为什么还要“加锁”?穿插到底指什么?
- 问题:既然是单线程,函数总是一前一后执行,为什么还会互相穿插?
- 这里的“穿插”不是指代码执行到一半被强行中断,而是指同一个业务逻辑被两个不同的回调各执行了一遍,导致结果重叠。
- 来看看不加锁时的危险流程:
- 具体危险流程时间线:
- 30 秒快到,玩家 2 还没出拳
- 超时定时器触发,进入 check_timeout 函数:
- 判断玩家 2 没出拳
- 发送“Time’s up! 玩家2 输了”给双方
- 设置 result_sent = true
- 调用 skynet.exit(进入退出等待)
- 但是,就在超时触发的同时,玩家 2 的出拳消息刚好到达(网络延迟导致超时边界到达)
- player_move 被调用,此时两个玩家都有拳了,它又去调用 judge,于是又发送一条“玩家2 赢了”的结果。
- 这样两个玩家就收到了两条矛盾的结果消息:第一条说“超时输了”,第二条说“你赢了”。
- 为什么会这样?
- 因为 check_timeout 和 player_move 是两个不同消息的回调,虽然它们不会同时执行(单线程),但它们会被先后调用,各自走了一遍结束逻辑。——这就是加标记锁的意义
- 加上锁后的正确流程:
- check_timeout 执行后 result_sent = true
- 紧接着 player_move 被调用,但 judge 被保护:if result_sent then return end
- 第二条消息的处理结果就不会执行发出
- 所以锁的作用不是防多线程,而是防 “状态已被终结,但后续消息又触发终结逻辑”。这个设计模式叫 一次性状态守卫,是事件驱动模型中的常见手段。
我是不是可以理解为,只要是call,我都fork一下?
- 答案是:绝大多数情况下,是的,但有一个例外。
- 唯一的例外:你自己就在子协程里
- 如果你已经在 fork 出来的子协程里了,那直接 call 就行,不需要再 fork 一层。
-- 已经在子协程里了,直接 call 没问题
skynet.fork(function()
local result1 = skynet.call(db, "lua", "query", "key1") -- 挂起的是子协程
local result2 = skynet.call(db, "lua", "query", "key2") -- 同上
-- 主协程完全不受影响
end)
- 一图记住:
你的代码在哪里?
│
├── 在 skynet.dispatch 的回调里(主协程)
│ └── 必须用 skynet.fork + pcall(skynet.call, ...)
│
└── 已经在 skynet.fork 出来的函数里(子协程)
└── 直接用 skynet.call 就行
- 实战建议:无脑规则
// *刚开始写 Skynet 时,可以先用这个简单规则:
// ***【只要是在 CMD.xxx 这种命令处理函数里调 call,一律 fork】。
// *等你对协程切换完全掌握后,再根据具体情况优化。这样永远不会写出卡死服务的 bug。
- 终极判断法则(覆盖一图记住内容)
// 只要不是100%确定不在主协程,就当作是在主协程————主协程出现耗时阻塞操作必须fork()!!!
当前代码所在的协程是不是主协程?
│
├── 是(在 dispatch 回调、socket.on_data 回调、CMD 函数里)
│ └── 需要等待结果的操作(call)→ 必须 fork
│ └── 不需要等待结果的操作(send、write)→ 直接调
│
└── 不是(已经在 fork 的子协程里)
└── 随便 call,只挂起自己
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)