skynet消息(构成,session,type介绍),数据库与内存管理,消息调用链(发送层,调度层,lua层,总结),外部服务(介绍,skynet内置支持)

目录

[消息 Message](#消息 Message)

消息构成

消息函数的参数列表

session

为什么需要

[session 的所有权](#session 的所有权)

type

为什么需要

介绍

消息类型

[为什么 session 和 type 要单独定义](#为什么 session 和 type 要单独定义)

数据包与内存管理

机制

危险

推荐做法

消息调用链

发送层

调度层

lua层分发

总结

调用链的层级关系

一条消息穿越各层的过程

[外部服务 External Service](#外部服务 External Service)

介绍

skynet提供的内置支持

数据库

HTTP


消息 Message

消息构成

消息函数的参数列表

向服务发送包的函数 skynet_send 和 服务响应函数 skynet_cb 的参数列表 :

cpp 复制代码
int skynet_send(
  struct skynet_context * context, 
  uint32_t source, 
  uint32_t destination,
  int type,
  int session,
  void * msg, 
  size_t sz
);

typedef int (*skynet_cb)(
  struct skynet_context * context,
  void *ud, 
  int type, 
  int session, 
  uint32_t source ,
  const void * msg,
  size_t sz
);

其中:

  • source 32 位整数,表示发送方地址,接收方回复时会用这个地址找到发送方。默认填 0 表示使用自己的 handle,也可以手动填充其他地址来伪造发送源
  • destination 32 位整数,表示目的地址
  • msg/sz 对 标记数据包的指针和长度

每条消息就由除了skynet_context以外的六部分组成

session

为什么需要

在通信模型中,只要不是一对一的直接通信,就需要一个关联标识,让服务知道这个包属于哪次请求

  • session 就是一次"未完成请求"的唯一编号
  • 本质上是一个递增的 request id,用来解决分发与匹配问题,类似于 IP 协议中端口号的作用

session用来回答三个问题:

  • 这条消息属于哪一次请求?

  • 是否有人正在等这条消息?

  • 应该唤醒哪一个协程?

session 的所有权

session 所有权在发送方

如果需要收到这条请求的回应,需要分配一个新的 session:

  • 在 skynet_send() 的 type 参数 上设置 PTYPE_TAG_ALLOCSESSION 标记,框架会忽略传入的 session 参数,自动分配一个当前服务从未使用过的 session 号发送出去
  • 同时约定,接收方在处理完消息后,把这个 session 原样发送回来
  • 编写服务时,只需在 callback 函数里维护一张 session 表,记录所有待返回的 session,收到回包后就能正确找到对应的处理函数

如果一个请求不需要回应(单向推送),则约定 session 置为 0

type

为什么需要

如果只有 session,当存在多个调用方(使用不同协议) 时,接收方无从判断该如何解析数据包

  • type 解决了协议不可扩展的问题 -- 用来回答"这条消息该如何被理解、解码、处理"

框架层需要一个统一的机制来区分"请求"和"回包"

  • 收到一条消息时,框架必须知道这是一个新请求(需要派发给业务逻辑处理), 还是一条回包(需要找到对应的 coroutine 并 resume)
  • session 只能匹配"哪个请求的回包",无法回答"这条消息是不是回包",这个问题由type回答
介绍

虽然是叫消息类型,但更接近网络端口 (port) 这个概念

  • 每个 skynet 服务都支持 256 个不同的 port
  • 服务可以为每种 type 注册独立的处理函数,用来区分和分发不同来源、不同格式的消息

在源码的实际定义中, type 分两层:

第一层:消息类型(低8位,0-255)

cpp 复制代码
#define PTYPE_TEXT        0   // 纯文本,C 服务常用
#define PTYPE_RESPONSE    1   // 回包,框架用来唤醒 coroutine
#define PTYPE_MULTICAST   2   // 组播
#define PTYPE_CLIENT      3   // 来自客户端的消息
#define PTYPE_SYSTEM      4   // 系统消息
#define PTYPE_HARBOR      5   // 跨节点通信
#define PTYPE_SOCKET      6   // 网络层 socket 消息
#define PTYPE_ERROR       7   // 错误通知
#define PTYPE_RESERVED_QUEUE  8
#define PTYPE_RESERVED_DEBUG  9
#define PTYPE_RESERVED_LUA   10   // 即 PTYPE_LUA,Lua 服务间通信最常用
#define PTYPE_RESERVED_SNAX  11

第二层:标志位(高位),控制框架行为

cpp 复制代码
#define PTYPE_TAG_DONTCOPY    0x10000   // 不复制数据包
#define PTYPE_TAG_ALLOCSESSION 0x20000  // 自动分配 session
```
消息类型

skynet 预定义了一组消息类型,需要开发者关心的有:

回应消息 PTYPE_RESPONSE

  • 当你对外发起一个请求后,对方回应的消息类型就是回应消息 -- 是框架独占处理的类型,如果对它注册 dispatch,框架会报错
  • 框架收到一条 PTYPE_RESPONSE 消息时,不会交给业务回调处理,而是直接在框架层根据 session 从内部的 session_id_coroutine 表中查找对应的 coroutine,然后 resume 它,让它从 yield 处继续执行 -- 开发者感受到的只是 skynet.call 返回了结果

网络消息 PTYPE_SOCKET

  • 不必直接处理
  • 开发者调用 socket.read、socket.write 等 API 时,底层收到的 PTYPE_SOCKET 消息已经被转换成了更友好的事件(连接建立、数据到达、连接断开等),开发者完全感知不到 PTYPE_SOCKET 的存在

调试消息 PTYPE_RESERVED_DEBUG

  • 每个服务启动时默认注册了调试消息的处理逻辑
  • 可以通过调试控制台查询的信息包括:虚拟机内存占用、当前挂起的 coroutine 数量、调用栈、以及动态注入一段 Lua 代码执行
  • 调试控制台通过向目标服务发送调试消息来查询其内部状态,目标服务自己执行查询并回应结果 -- 调试能力不依赖任何外部注入或 hook,而是每个服务主动配合的结果

错误消息 PTYPE_ERROR

  • 不需要开发者主动处理,这类消息一般没有实际内容,只有发送源地址和 session 号

  • 有两种情况会产生 PTYPE_ERROR:第一种是服务主动退出时。服务退出前,框架会扫描它还未回应的所有 session,对每个 session 的发起方发送一条 PTYPE_ERROR,告知对方"你等待的回应永远不会来了"。第二种是消息投递失败时。向一个不存在的 handle 发消息,skynet_context_push 返回 -1,框架会回发一条 PTYPE_ERROR 给发送方。

  • 接收到 PTYPE_ERROR 后,skynet 基础库会将其转换成 Lua 的 error ,抛给正在 yield 等待的 coroutine,效果等同于 skynet.call 抛出了一个异常

文本消息 PTYPE_TEXT 与 Lua 消息 PTYPE_RESERVED_LUA

  • 真正驱动业务逻辑的消息类型

  • 两者的本质区别在于注册的 unpack 函数不同:

  • PTYPE_TEXT 的 unpack 是 skynet.tostring,直接把 C 指针和长度转成 Lua string,适合 C 服务生产、Lua 服务消费,或者纯文本协议场景

  • PTYPE_RESERVED_LUA 的 unpack 使用 skynet.unpack,它能还原 Lua 的复杂数据类型(table、number、string 等多个返回值),是 Lua 服务间通信的标准方式

  • 实际开发中 ,Lua 消息有一个约定俗成的惯用法:发送方 传参的第一个参数是字符串,表示操作名称(cmd),后续参数是操作的具体数据。接收方在服务内部维护一张普通的 Lua table(CMD 表),key 是操作名,value 是对应的处理函数。dispatch 回调拿到 cmd 后直接查表分发(这里就可以区分不同的业务逻辑了)

如果内置类型不够用,可以通过 skynet.register_protocol 注册自定义类型,必须提供 pack 和 unpack 两个函数

接管某类消息需要在服务的初始化过程中注册该消息的序列化及反序列化函数,以及消息回调函数

为什么 session 和 type 要单独定义

在早期设计中,Skynet 使用 Google Protocol Buffer 作为统一的通信协议,所有信息包括 session、type 都编码进数据包里,上层约定好协议格式即可

cpp 复制代码
// 定义一个统一的消息格式,所有信息都塞进protobuf数据包
message SkynetMessage {
    int32  session     = 1;  // 请求ID,藏在数据包里
    int32  type        = 2;  // 消息类型,藏在数据包里
    uint32 source      = 3;  // 来源地址,藏在数据包里
    bytes  payload     = 4;  // 实际业务数据
}

// 发送方:每次发消息都要序列化整个结构
SkynetMessage msg;
msg.set_session(123);
msg.set_type(PTYPE_LUA);
msg.set_source(my_handle);
msg.set_payload(business_data);

std::string buf;
msg.SerializeToString(&buf);          // 序列化 → 变成字节流
skynet_send_raw(dest, buf.data(), buf.size());

// 接收方:每次收到消息都要反序列化
SkynetMessage msg;
msg.ParseFromArray(data, sz);         // 反序列化 → 还原结构
int session = msg.session();          // 再取出 session
int type    = msg.type();             // 再取出 type
handle_business(msg.payload());       // 再处理业务数据

但实践中发现这个方案有两个问题:

  • 一是在 Skynet 大量、频繁、极小粒度的服务间消息场景下,每条消息都要经历序列化和反序列化,这个开销在高频通信下不可忽视
  • 二是无论用什么协议,session 和 type 都是绕不开的------session 用来匹配请求和回应,type 用来决定消息该如何解析处理,既然每个开发者都要自己处理它们,不如直接由框架统一提供

因此,Skynet进行了以下优化:

  • 把 session 和 type 从数据包中提取出来,作为框架层的独立字段直接传递,数据包的内容则完全交由开发者自由决定
  • 数据包没有必要被强制序列化成统一的单个数据块,用 C 结构体直接传递 才是进程内通信最高效的方式 -- Skynet 核心解决的是进程内通信,发送方和接收方处于同一进程,共享同一块内存地址空间,可以直接传 C 结构体指针
cpp 复制代码
// 开发者看到的接口:

// 发送:参数平铺,直接传
skynet_send(ctx, source, destination, type, session, data, sz);

// 接收:callback 参数平铺,直接用
static int callback(..., int type, int session, uint32_t source,
                    const void *msg, size_t sz);


//框架内部做的事:

// skynet_send 内部把这些参数打包成结构体
struct skynet_message smsg;
smsg.source  = source;
smsg.session = session;
smsg.data    = data;
smsg.sz      = sz | (type << MESSAGE_TYPE_SHIFT);  // type塞进sz高位

// 然后推进消息队列
skynet_mq_push(context->queue, &smsg);

// 另一端调度时取出来,拆包再作为参数传给callback

以上为模拟代码,不是实际源码

这样做带来了三个好处:

  • 性能上 session/type 直接作为 C 结构体字段访问,无需解析 (接收方和发送方约定好结构体类型后,直接强转指针读取,性能几乎等同于同一线程内的函数调用)
  • 灵活性上不同服务可以用不同的编码格式,不必强制统一
  • 解耦上业务数据和调度元数据各自独立,互不影响

数据包与内存管理

机制

Skynet 并不关心数据包是怎样被打包的仅传递数据包的指针 和声称的长度(并不一定是真实长度)

  • 由于所有服务都在同一进程内,接收方取得指针后可以直接处理其引用的数据
  • 这个机制可以在必要时保证绝对的零拷贝,几乎等价于同一线程内一次函数调用的开销 (在理想情况下)

危险

进程内传指针带来了极高的性能,但也意味着开发者必须清楚这块内存的所有权在什么时候、由谁负责释放

  • skynet 的默认约定是:发送方 malloc,框架在 callback 执行完后统一 free
  • 只要遵守这个约定,开发者不需要关心内存释放的问题

如果设置了 PTYPE_TAG_DONTCOPY ,框架不再复制数据 ,直接把发送方的原始指针传给接收方,内存所有权的规则也随之改变:

  • callback 返回 0:框架 free 这块内存
  • callback 返回 1:框架什么都不做,开发者自己负责 free

一旦偏离这些约定,就会出现以下问题:

  • 发送方提前 free:接收方拿到的是悬空指针,读取时行为未定义,轻则读到脏数据,重则段错误崩溃
  • 开发者忘记 free:内存泄漏,长期运行后进程内存持续增长
  • 开发者 free 了两次:堆结构损坏,崩溃位置往往离实际出错位置很远,极难排查

多个接收方同时读同一块内存

  • 进程内传指针意味着理论上可以把同一个指针发给多个服务。如果多个 worker 线程同时在不同服务的 callback 里读这块内存,而其中有一方在写,就会产生数据竞争
  • skynet 本身不提供任何保护机制,这个问题完全由开发者自己保证

接收方强转类型错误

  • "零拷贝直接传指针"的前提是发送方和接收方对数据结构有一致的约定
  • 如果发送方传的是 struct A,接收方按 struct B 强转读取,不会有任何编译错误,运行时读到的是错误数据,且极难定位

推荐做法

但更为推荐的还是一种更可靠,性能略低的方案:

  • 约定每个服务发送出去的包都复制到 malloc 分配的连续内存中,接收方在 callback 执行完毕后默认调用 free 释放
  • 即 发送方申请内存,接收方释放

消息调用链

发送层

用户层调用lua -> skynet.send(lua层) -> c发送入口skynet_send

  • 如果要等待response,在 type参数 里设上 alloc session 的 tag (PTYPE_TAG_ALLOCSESSION)
  • 之后,type不再是消息类型,而是协议族

->c内核发送,消息进入目标服务的消息队列

  • 如果要等待response,这里需要将当前协程挂起,并记录session表

调度层

worker线程不断循环调用 skynet_context_message_dispatch(),从全局队列中选中一条,并pop一条消息

-> 进入C层的 dispatch_message,把 C 的消息参数转交给 Lua 的 skynet.dispatch_message -> 进入

lua层分发

原样交给服务的统一消息入口callback(每个服务只有一个callback) --消息解包 + 分发器-> 根据type把消息转发给对应的 Lua handler(业务处理函数)

总结

调用链的层级关系

业务层只关心"做什么",不关心消息怎么传、内存怎么管、coroutine 怎么调度

  • 开发者写的代码全部在这一层

Lua 基础库层是业务层和 C 层之间的桥梁

  • 把 C 层的"消息回调"模型 (每次收到消息都是一次独立的回调 ,执行完就返回,如果需要等回包,需要自己记住相关信息) 转换成开发者熟悉的**"函数调用"模型**(底层由 skynet.lua 通过 yield/resume 和 session 表实现)
  • skynet.lua 在框架层面统一处理转换的工作,开发者完全感知不到

C 框架层处理消息的生命周期 -- 从打包、传递、解包,到最后释放内存

  • 它不关心数据内容,只关心指针、长度、type、session 这几个元数据

调度层是整个系统的驱动引擎,决定什么时候、由哪个 worker 来处理哪个服务的消息

  • 服务内串行性的保证也在这一层实现,不依赖任何锁 (由调度机制本身实现)
一条消息穿越各层的过程

外部服务 External Service

介绍

指的是运行在 Skynet 进程之外的独立进程

Skynet 的主要目标是最大化利用单台机器的多核处理能力,在同一进程内可以保持最高的并行能力,远比增加物理机性价比高得多

  • 因此应尽量在同一个 Skynet 进程内完成所有业务逻辑
  • 但有些场景下使用外部进程更合适,比如数据持久化

和 Skynet 内部服务的区别:

  • 一旦需要和外部进程交互,就必须走网络,原来那套消息机制完全不适用,需要另一套接入方式

其中:

  • 不建议把连接管理的网关实现成外部服务 -- 因为 Skynet 在管理大量 TCP 连接方面已经做得很好,放在内部可以减少大量不必要的进程间数据传输
  • 推荐 把不同 Skynet 实例的服务当作外部服务来对待 -- 跨节点通信和进程内通信在性质上有根本差异, 如果开发者忽略这些差异,写出假设"消息一定送达"、"延迟可以忽略"的代码,上线后遇到网络波动就会出问题。"当作外部服务对待"本质上是一种心智模型的要求:时刻意识到跨节点调用是网络调用,需要考虑失败、超时、乱序等问题,就像对待任何外部进程一样。

skynet提供的内置支持

数据库

MySQL、Redis、MongoDB 数据库本身作为独立进程运行在 Skynet 之外。使用外部数据库主要出于两个原因:

  • 数据持久化:游戏数据需要持久化存储,MySQL 负责结构化数据的长期存储,Redis 负责排行榜、会话、高频读写数据的缓存,MongoDB 适合存储日志等非结构化数据
  • 数据安全:数据库作为独立进程运行,即使 Skynet 进程崩溃,数据不会丢失
  • 实际产品中常见的做法是:Redis 作为主存储承接高频读写,再由一个独立进程定期或在数据变更时将 Redis 中的数据以批量事务的方式写入 MySQL 持久化,Skynet 进程本身不直接频繁操作 MySQL

Skynet 发布版内置了 MySQL、Redis、MongoDB 三个驱动模块

  • 这些模块基于 Skynet 自己的 socket API 实现,作为普通内部服务运行在 Skynet 进程内,由 worker 线程调度,完全融入 Skynet 的消息驱动体系,不会阻塞 worker 线程

skyent进程内的驱动模块和外部进程的交互:

若需要对接其他数据库(如 PostgreSQL),需要注意:

  • 大多数第三方驱动直接使用系统 socket API,会阻塞 worker 线程
  • 推荐的做法是额外起一个中间进程,一边使用第三方驱动连接数据库,另一边通过 Skynet 的 socket channel 与 Skynet 交互
HTTP

skynet 自带了 http 模块,让 Skynet 本身能够处理 HTTP 请求

有两个方向的用途:

  • skyent可以作为http服务端
  • 作为http客户端 -- 让 Skynet 内部服务主动去请求外部的 HTTP 接口,比如:
相关推荐
笨鸟先飞的橘猫7 天前
skynet线上问题查证练习记录(更新中)
skynet
笨鸟先飞的橘猫15 天前
skynet——RPC 超时机制优化方案
游戏·rpc·skynet
笨鸟先飞的橘猫15 天前
skynet热更新实现问题与排错记录
学习·lua·skynet
笨鸟先飞的橘猫3 个月前
skynet——上下文管理
skynet
笨鸟先飞的橘猫4 个月前
skynet——网络模块学习
网络·学习·skynet
笨鸟先飞的橘猫4 个月前
skynet——sharetable学习
学习·skynet
笨鸟先飞的橘猫4 个月前
skynet——分布式支持
分布式·skynet
笨鸟先飞的橘猫4 个月前
skynet监控实践
监控·skynet
Huanzhi_Lin6 个月前
skynet笔记
笔记·lua·skynet·actor·actor模型
笨鸟先飞的橘猫6 个月前
MMO游戏中的“跨服团队副本”匹配与状态同步系统
分布式·学习·游戏·lua·skynet