X3 LUA 异步分布式微服务集群框架
Lua 主控,C/C++ 辅助。
进程形态、服务身份、注册发现、协议表、业务逻辑都在 Lua;一个进程 = 一个目录 + 一个
main.lua。C/C++ 补 Lua 做不了或做不快的那几块:I/O 多路复用、线程、网关加解密与压缩、日志、定时器。
Windows(MinGW-w64)与 Linux 共用一份源码、一套 CMake,产物同批落在
bin/。
x3 是一个服务端骨架 :一个宿主进程(x3 / x3.exe)、十几个 C/C++ 模块(dll / so),
加一套用"目录 + main.lua"表达的装配约定。拿它拼服务端时写的是 Lua,管的是线程、连接、
消息与集群;C/C++ 那一层只在需要新原语时才打开。
规模:C/C++ 约 4 万行,Lua 约 5.4 千行。仓库里带一个能跑起来的六进程集群
(注册中心 / 网关 / 登录服 / 两个应用服 / 调度服)和一个客户端剧本。
它面向长连接、多进程、异步消息 的后端:游戏服(MMO / 卡牌 / 实时对战)、实时应用、
内部微服务。这些系统的共同需求是"一条连接要在多台机器之间被转发、被广播、被限速",
x3 把它们做成框架的默认路径,业务侧只写 handler。HTTP 侧提供的是 listen +
path → handler 的派发(见第 14 节)。
x3 是开源框架思想与网文架构思想的集大成。 它没有新造机制:把散落在开源实现与架构
文章里的几条做法挑出来,用同一套 id 语义拼成一个能跑的整体:
- I/O 多路复用取 Linux 的 epoll 与 Windows 的 IOCP,两者之上只留一种事件对象(读一个、写一个);
- 线程归属取 muduo 的 "one loop per thread" 与 Seastar 的 shard-per-core;
- 消息语义取 Erlang / Akka 的位置透明,分阶段流水线取 SEDA;
- 模块形态取 Apache httpd 的 DSO 与模块表,脚本分层取 OpenResty 的 "每个 worker 一个 VM";
- 单写者原则取 LMAX Disruptor 的 single-writer principle;
- "万物皆 ID" 的提法取 ygluu 的《万物皆ID、万事皆事件、万人皆数据》,并用到运行侧
(消息、连接、请求、线程归属)。
名称里的三个词各有所指:
- LUA 主控 ------ 业务逻辑、进程装配、协议表、服务身份、注册发现全在 Lua;C/C++ 只在
Lua 够不到的地方补位(见第 2 节第 2 条)。 - 异步 ------ 没有阻塞式调用栈:
send投出去就返回,call靠reqid配对、回调回到发起线程;
跨线程与跨进程是同一套写法(见第 4 节)。 - 分布式微服务集群 ------ 一个目录一个服务,注册中心只存地址、调度中心只做分配、网关只做接入;
服务之间只按 id 说话,不互相 include、不共享内存(见第 6 节)。
目录
- 三分钟跑起来
- 术语
- 设计思想
- 特点速览
-
- 万物皆 id
-
- 设计取向
-
- I/O 模型
-
- 消息机制
-
- 线程模型
-
- 微服务集群
-
- 模块与插件化
-
- Lua 分层与装配
-
- 目录与模块
-
- 构建
-
- 运行
-
- 实测记录
-
- 开发约定
-
- 已知限制
-
- 参考点:每条机制的参照
- 附:设计文档
- 许可
不同目的各有入口,不必从头读:
| 想做什么 | 从哪读起 |
|---|---|
| 第一次接触 | 上面的简介 → 三分钟跑起来 → 术语 |
| 判断这套结构合不合用 | 特点速览 → 第 2 节 → 15.3(整体形态的七个参考点) |
| 接手写业务 | 第 8 节 → 4.3 → 第 11 节;xa_app/ 是最短的样例 |
| 改框架 | 第 1 节 → 第 3 节 → 第 5 节 → 第 7 节 |
| 评估并发模型 | 第 1 节 → 第 5 节 → 15.5(actor 的寻址与隔离) |
| 排查线上问题 | 第 13 节(开发约定)→ 第 12 节(实测)→ 第 14 节(已知限制) |
三分钟跑起来
sh
./build_linux.sh release # Windows:build_win.bat release
./start_all.sh # registry + gate + login + app×2 + sched
./test-client/start.sh -sched 127.0.0.1:9006 -account alice
./stop_all.sh # 优雅停,与 Ctrl+C 同一条路径
Windows 上把 .sh 换成 .bat,其余参数相同。客户端那一步的末尾是
[cli] 结果:PASS(sched=1 hello=1 login=1 app=1)。test/start.sh 是另一条更短的路径:
不起网络,只压消息分发与定时器。构建细节见第 10 节,运行与预期输出见
第 11 节。
术语
下面这几个词在全文反复出现,先列在一起:
| 词 | 含义 |
|---|---|
appname / app_type |
服务名 = 进程目录名(xa_gate);app_type 是去掉 xa_ 前缀的部分(gate) |
insid |
进程的网络 id,全簇唯一,同时充当雪花 id 的机器号 |
netid |
连接 id。服务之间的连接用对端 insid 当 id;客户端侧用网关生成的雪花 id |
dstid |
目标对象 id,由 bus.enter(objid, obj) 绑定,send_to 按它找线程 |
msgid |
消息 id = crc32(消息名),线上只传这 4 字节 |
consid |
一致性键,非 0 时按 consid % 候选数 在多个实例或多条订阅线程间选一个 |
reqid |
一次 call 的请求 id,应答按它配对 |
tmsg_ / smsg_ / cmsg_ |
三种消息前缀:进程内 / 服务间 / 经网关到客户端 |
| owner | 绑定了 dstid 的对象;"线程归属"也用它表示------一个 owner 只属于一条线程 |
up / down |
xnet 的两个事件对象:一个管读,一个管写,各绑一条线程 |
设计思想
整份代码可以压成三个决定,其余都是它们的推论:
- 万物皆 id。 名字、地址、连接、请求、线程、定时器,在登记那一刻算成一个整数,
之后的路由、分发、超时匹配、日志反查全部只认整数。 - 一个 id 只有一个执行体。 id 能取模、能哈希,所以"这件事该归谁做"是算出来的 :
不需要查表问人,也不需要为它加锁。 - 执行体之间只走消息。 线程之间没有共享容器,于是没有共享内存模型;
跨线程与跨进程在业务侧是同一套写法。
这三个决定各自导出什么:
| 决定 | 导出的结构 | 落地位置 |
|---|---|---|
| 名字 → 整数 | 消息 id = crc32(消息名);字符串 id = crc64(名字);服务 id = insid(16 bit 取值);连接 id = netid |
xid / xbus / xnet |
| 整数 → 执行体 | dstid 定线程、consid % 候选数 在多个实例或多条订阅线程间选一个、insid 定连接 |
xbus / xnet |
| 执行体 → 消息 | send / send_to / send_by / call,本进程与跨进程同一套 |
xbus |
| 整数 → 名字 | id2name 反查,日志与协议展示用 |
xid |
| 名字 → 整数 → 名字 | 双向表,只在登记时写一次 | xid |
这套结构带来的改变是并发模型的位移 :从"多个线程改同一份数据、用锁排好队",
变成"每条线程只改自己的数据、改动以消息通知别人"。锁在结构层不存在,
因此也不会因为某处写漏而退化。
代价是阻塞操作不留在业务调用栈里:数据访问、网络、文件操作全部写成"投递 + 回调",
send 投出去即返回,call 的结果走回调,xhttp 的 handler 由事件驱动。
特点速览
-
万物皆 id。 消息 id =
crc32(消息名);字符串 id =crc64(名字)(双向表可反查);服务端 id 是
insid(16 bit 取值);服务端连接 id 是对端insid(netid);客户端连接 id 是网关发的雪花 id;
call有reqid;listen的对象有dstid;线程有 id(xthread_curr_id),定时器有 id;集群也有名字(
clustername进握手首字段)。字符串只在登记 那一刻参与比较,之后的路由、分发、超时匹配、日志反查全走整数表。
它同时换来三件事:id 能取模/哈希 ⇒ "定线程、选实例、选集群"都是一次运算;
整数比较 ⇒ 热路径便宜;名字仍是唯一事实 ⇒ 日志和协议里都看得懂。
完整清单见第 1 节。
-
I/O:epoll 与 IOCP 之上只有一种事件对象。 两个 event 对象(
up/down)分别绑定一条线程,Linux 上各是一个 epoll 实例,Windows 上各是一个完成端口;一条连接的读和写归
不同的 event,于是收发的系统调用处只有一个分叉点(
inc/xplat.h),状态机只有一套。见第 3 节。
-
线程:托管线程 + 每线程一个
lua_State。 框架线程main/bus、xtpool池线程、自建业务线程全部由
xthread托管:任务队列、周期回调、空闲休眠、超时告警都在框架里。跑业务的那几条各带一个
lua_State,由线程自己建、自己关,跨线程不会碰到同一个 state;net是xnet自建的时间线程,不碰 Lua。见第 5 节。 -
消息:消息名就是协议。 不写
.proto、不跑代码生成,消息名经crc32变成 4 字节 id,线上只传 id;参数是一张 lua 表,msgpack 自动打包解包。广播范围由消息名前缀
(
tmsg_/smsg_/cmsg_)唯一决定,调用方不传范围。见第 4 节。 -
无锁:跨线程只走消息。 线程之间没有共享容器。需要"同一份数据只由一条线程碰",
就把 id 定死到那条线程上:
xtpool_alloc(key)把同一个 key 固定到一条池线程,xa_login按userid % #list选应用服并钉住,send_by(consid)把同一个键固定到一条线程。要串行就定线程,不需要加锁。
-
微服务:三个角色各管一件事。 注册中心只存地址、调度中心只做分配、网关只做接入;
服务位置是算出来的 (
consid % 候选数),不是查出来的,所以没有位置服务这个单点。一条客户端连接钉在一个后端实例上,后端掉线时明确断开,不静默换人。
见第 6 节。
-
插件化:一个模块 = 一个动态库 + 一张 C 表 + 一份语言绑定。
xthread / xbus / xnet / xgate / xlog ...各自编译成dll/so,导出 C ABI 与一张luaL_Reg;模块之间不互相include,也不互相调用,要说话就发消息。宿主
x3只链最少的三个。见第 7 节。 -
装配:一个进程 = 一个目录 + 一个
main.lua+ 一个run。 新增服务:建目录、抄那五行骨架、最后一行换成自己的
run(boot),启动时给-insid/-endpoint。登记、订阅、就绪日志都在这一个
run里,没有配置文件、没有注解,也没有第二处清单要同步。见第 8 节。
-
初始化与退出都有序。 C++ 侧每个编译单元在自己的静态初始化里
post_main<"名字">(&init),宿主登记main线程那一刻按投递顺序补投 ------ 模块不需要一张集中清单,也不需要谁先谁后的约定;Lua 侧
require顺序就是装配顺序。退出走一条固定链条:
tmsg_close广播 →xbus::wait_idle()排空 →xthread::stop_all()→ 最后一条日志 →xlog::flush()→ 各 so 静态析构。见第 7.5 节与第 7.6 节。
-
跨平台与可观测。
xplat.h是全项目唯一出现 socket#ifdef的地方,两个平台共用同一套 CMake 与同一套连接状态机;
xlog异步落盘、按"进程 + 日期"分目录、{}占位、线程可命名(日志里直接看出是谁在说话);
cluster/stat起一条统计线程,各模块用
x*_get_stats供它采集。 -
性能:省在结构上,不靠调参。 同进程消息不进 socket,按消息表逐跳分发(接收者恰好
同线程时就地执行,不排队);运行期只用 4 字节 msgid 查表,不做字符串比较;每线程一个
lua_State,业务侧没有锁。
1. 万物皆 id
ygluu 在《万物皆ID、万事皆事件、万人皆数据》里给出一句判断:"当 ID 统一的时候,你会发现
很多事情都很好做。"(文章与仓库见15.2)
那篇文章讲的是数据侧 的统一:配置文件按模块分配 ID 段位,实例 ID 用 64 位(雪花算法),
名字登记进一个 id-name 系统,条件表达式解析完直接变成 player.datas[9000000] 这样的下标访问。
x3 取的是同一条判断,把范围从数据侧推到运行侧 :一条消息从哪来、到哪去、发给谁、谁在等、
落在哪条线程、归哪台机器,全都收敛成一个整数。
1.1 完整清单
下表的接口名按 C ABI 写,Lua 侧是点号形式(xid_addname → xid.addname,xtimer_add →
timer.add),完整清单见9.5。
| id | 位宽 | 谁给 | 用在哪 |
|---|---|---|---|
| 字符串 id | uint64 |
xid_addname(名字) = crc64(也可业务指定),登记 name2id / id2name 双向表 |
需要"名字 ↔ id"两向都用的场合(配置表、字段名、技能/物品名)。同名不同 id、同 id 不同名都记 error 并保留先来的那个 |
msgid 消息 id |
uint32 |
crc32(消息名),登记时算一次 |
listen 的订阅表、send 的接收者清单、by_msg 路由表 |
insid 服务端 id |
uint64,契约取值 0~65535 |
命令行 -insid |
全簇唯一;同时充当雪花机器号;决定"谁拨谁"(大的拨小的) |
netid 服务端连接 id |
uint64 |
握手后双方各自拿对端 insid 当连接 id |
xnet 的连接表与路由 ------ "发往 7001"就是"发给 insid == 7001 的那条连接",不用查表 |
netid 客户端连接 id |
uint64(雪花) |
网关 accept 之后 xid_gen_id() 生成,机器号 = insid |
网关下行时 dstid 直接就是它;dstid = 1 保留为"该网关下所有客户端" |
dstid 目标 id |
uint64 |
bus.enter(objid, obj) 绑定 |
send_to / call_to 命中哪个 owner;到网关后被解释成客户端连接 |
reqid 请求 id |
uint64 |
xbus_call 内部生成 |
应答匹配(30 秒未回丢弃并告警) |
consid 一致性键 |
uint64 |
业务传(如 userid),或 xid_gen_consid |
0 = 轮询;非 0 = consid % 候选数 选服务实例 |
| 线程 / 回调 id | uint64 |
线程创建时分配;runcb 的名字注册进 xid 得到 id,执行时记进 curr_job_id |
超时告警与卡住告警按它反查"卡在哪个回调";统计表按线程名对齐 |
| 定时器 id | uint64 |
xtimer_add 返回 |
同线程内唯一,用于删除与统计 |
四类 id 各回答一个问题:
- 发给谁 ------
msgid(哪条消息)、dstid(哪个对象)、netid(哪条连接)。 - 谁在等 ------
reqid(这次请求)、consid(这个一致性键固定落哪台)。 - 在哪条线程 ------
dstid绑定的线程、consid映射的订阅线程、线程 id。 - 在哪台机器 ------
insid(哪台)、clustername(哪个集群)。
1.2 两类 netid 共用一个 64 位空间
服务端连接拿对端 insid(≤ 65535),客户端连接是雪花 id(高位是毫秒时间戳,远大于 65535)。
两类连接 id 放在同一个空间里却不冲突,靠的是取值范围天然分开,于是
"客户端 netid 不可能等于 1"这类判断可以直接写进广播语义里 ------ 网关的 dstid = 1
表示"我下面所有客户端",这个保留值不会被真实连接占到。
1.3 跨集群:同一套 id 空间铺到多个集群
clustername 是进程身份的一部分(和 insid / appname 一起进 xnet_init),也是握手
报文的第一个字段 (超长截断到 31 字节)------ 对端拿它判断"是不是同一个集群"。
字符串只在这里比一次,之后同一个 netid 空间里就同时装着本集群与别的集群的连接。
xnet 层的拨号规则三条:
| 情形 | xnet 拨不拨 |
参与按 msgid 路由吗 |
|---|---|---|
| 非本集群 | 拨 ------ 地址只在我手里,对方未必有我 | 不参与(不进 by_msg 表) |
同 appname(同一服务的另一个副本) |
不拨 ------ 两个副本之间没有要交换的业务 | 不适用 |
本集群、不同 appname |
insid 大的拨小的(全集群一致的确定性规则) |
参与 |
clustername 留空 = 不参与任何互连:没有"本",也就算不出"外"。
当前部署走的是更保守的一条:common/cluster/regcli.lua 遇到 cluster 不一样的条目
不拨 ,给 add_ins 填的 clustername 也一律是本集群。所以跨集群链路是 xnet
提供的能力 ,启用方式是业务自己 add_svc 并把 clustername 填成对端的集群名。
注册中心那条路径目前只跑单集群(demo 六台全部 cluster = "dev")。
跨集群是两条明确的窄路,不是"再开一条业务通道":
- 业务进程 → 网关 → 客户端 。
bc = 3(XBUS_BCAST_GATES)按gatename在服务登记表
里找网关,不按集群过滤 ,并自动带上dstids{1};网关收到后dstid = 1表示"我下面
所有客户端",dstid是具体值时就是某一个客户端连接。设计上网关常在另一个集群,所以
这是唯一会常态化跨集群的业务包(demo 里同集群,这条路退化成同集群查找)。 - 服务之间 。跨集群实例只登记、不进
by_msg,业务包不会按msgid流过去;要跨集群
取数,只能由业务显式按netid发(xnet_send(netid, ...)),或用bc = 2发给自己
已经建链的全部服务(跨集群那条也在里面)。
跨集群链路的延迟与可用性不由自己掌握(跨机房 / 跨区),把它留在业务主路径之外,
集群内的 consid % 候选数 就不会被一个远端实例拖慢。跨集群的调用因此都写成显式的。
2. 设计取向
前面三个决定落到工程上,表现为六条取向,一层压一层。
-
万物皆 id(第 1 节)------ 运行期只认整数,字符串只在登记时出现一次。
-
Lua 主控,C/C++ 辅助。 业务逻辑、进程装配(
boot.lua+local_runcfg.lua)、协议表(
common/proto/)、服务身份与注册发现(cluster/)、统计与落库全在 Lua。C/C++ 只做三类事:Lua 碰不到的系统调用(socket / epoll / IOCP / 文件)、Lua 做不快的地方(切包、
id 计算、加解密、压缩)、所有模块共享的底座(线程、日志、定时器)。
判断标准只有一条:能用 Lua 写清楚的就留在 Lua ;进了 C++ 的,都能说出"为什么不能在
Lua 里做"。
-
一个模块一个动态库。
xthread / xbus / xnet / xgate / xlog ...各自编译成dll/so,谁用谁
require。宿主x3只链最少的几个,业务侧按需加载。 -
跨线程只走消息,不共享数据。 线程之间没有共享容器,也就没有业务锁;
需要"同一个东西只由一条线程碰",就把目标 id 定死到那条线程上。
-
消息 id 可反查。 消息名以
tmsg_ / smsg_ / cmsg_开头,线上只传 4 字节 id,日志里反查回名字靠同一张进程级表 ------ id 是索引,名字才是唯一事实。
-
两个平台一套代码。
xplat.h是全项目唯一出现 socket#ifdef的地方;Windows 用 MinGW-w64 + Ninja,Linux 用 GCC + Ninja,输出名带平台后缀的才分开
(如
libxgate_win.a/libxgate_linux.a)。
3. I/O 模型
网络层只做三件事:读、写、按 id 找到那条连接。这一章讲这三件事怎么落在两个事件对象上,
两个平台为什么只有一处分叉,以及"一条连接只有一个写者"这条约束从哪来。
3.1 两个 event 对象,两种后端
xnet 的收发路径不创建线程、不做线程间通信 。内部有两个事件对象,把读和写拆成两半,
驱动函数挂到宿主的空闲等待钩子上:
main 线程 ── xnet_proc_up(ms) ── 驱动 up 事件
bus 线程 ── xnet_proc_down(ms) ── 驱动 down 事件
net 线程 ── xnet 自建 200ms 一圈 ── 空闲超时 / 待释放连接的到期析构 / 重连扫描
哪个事件管哪些读写由 xnet_set_workmode 决定(见 3.3):C 侧默认按侧分离,x3 走 Lua
装配,xnet.init 把模式设成 0(读写分离)------ 读全在 up,写全在 down。
两个事件对象在 Linux 与 Windows 上的落点完全不同,而上层代码完全相同:
| Linux | Windows | |
|---|---|---|
up / down 各是什么 |
各一个 epoll 实例 | 各一个完成端口(每个事件对象自己 CreateIoCompletionPort) |
| 一条连接能否挂两处 | 能 ------ 同一个 fd 同时注册进两个 epoll,各关心自己那一半事件 | 不能 ------ 一个 socket 只能关联一个完成端口 |
| 事件语义 | 读走边沿(EPOLLET,一次事件内读到 EAGAIN);写走水平(有残余就一直提醒,发完立刻摘掉) |
完成语义:0 字节 WSARecv 探测就绪,再循环 recv 到 WSAEWOULDBLOCK |
| 事件里带什么 | epoll_event.data.u64 存 netid ,不存 conn* |
重叠结构的 user 字段存 netid,同样按它回查连接表 |
Linux 上的读写分离是真的:up 等 EPOLLIN、down 等 EPOLLOUT,两条线程各自阻塞在
自己的 epoll 上,一个方向没事件不影响另一个方向。Windows 上做不到这一点:socket 关联到
哪个完成端口,它的读完成和写完成就都投到那个端口、由那条线程收尾。于是写由 down 线程
发起、收尾落在读线程 ------ 这一条写在 inc/xnet.h 里;Windows 在 x3 里的定位是开发环境,运营环境用 Linux。
3.2 事件里存 id,不存指针
epoll_event.data.u64 存的是 netid,不是 conn*。原因是一条连接会在另一条线程
被关闭并释放(读线程收到协议错就关了),裸指针会变成悬空的。用 id 查表多一次哈希,
换来"事件永远只碰活着的对象"这条性质 ------ 一次 use-after-free 会让整个接入进程消失。
关闭路径因此定成四步:标记关闭中 → 从所有关注它的事件里摘掉 → close → 从连接表摘除。
第二步在两个平台上不是同一个动作:Linux 是两个 epoll 各 DEL 一次(apply(c, false, false)
走 EPOLL_CTL_DEL);Windows 的事件层没有注册动作,apply 是空实现,摘除只发生在连接表
与重叠结构上。只关 fd 而漏掉第一步,另一个实例会残留事件,唤醒时 fd 号已被复用,
写进去的就是新连接。
Linux 侧还有一条连带要求:读是边沿触发,连接改名(rekey_conn)换了 id 之后要
io_service::rebind ------ 重新 ADD/MOD 一次,既让 data.u64 跟上新 id,也把内核里
那条已经消费掉的边沿重新装上。漏掉这一步,那条连接不再收到任何事件,而且不报错。
Windows 侧不需要:读是每次处理完再投下一次,投递那一刻才取 id。
3.3 两种读写归属
xnet_set_workmode 只改一件事:一条连接挂到哪两个事件上。收发代码、封包格式、回调时序
与模式无关。
| 模式 | 读 | 写 | 谁在用 |
|---|---|---|---|
读写分离(mode = 0) |
所有连接读 up(main) |
所有连接写 down(bus) |
x3:xnet_lua.cpp 的 init 传入 0 |
按侧分离(mode != 0) |
listen 侧读 up、connect 侧读 down |
listen 侧写 down、connect 侧写 up |
C 侧默认;不经 Lua 装配时维持的行为 |
C 侧默认是按侧分离,Lua 装配把它改成 0,原因是一条硬约束:出网动作全部由总线投给 bus
线程执行(见 3.4),所以 bus 必须是所有连接的写 owner。按侧分离下 connect 侧的写 owner
是 main,总线投过去的每一包都会被判 bad_owner 丢掉 ------ 现象是"连上了、握手也过了,
业务包一条都发不出去"。这个值由 xnet_lua.cpp 的 init 设定,调用方不需要传。
归属只在连接建立那一刻决定(attach),运行期改模式不影响已挂好的连接。
3.4 一条连接只有一个写者
发送只能由该连接的写 owner 线程 调用:conn::send_raw 第一件事是比对
send_svc_->is_current(),不匹配就丢弃、bad_owner 加一、记一条 warn ------ 不排队、不加锁、
不做跨线程写。理由是两条线程同时往一个 socket 写,字节流会被撕成半包与半包交错,
而这种错误在低并发下不复现。
这条约束不靠调用方自觉:xbus 是出网的唯一出口,它把封包动作投给 bus 线程,
3.3 的模式又让 bus 成为所有连接的写 owner。于是业务代码无论在 main 还是池线程上,
都碰不到 socket,写法上也没有"先拿到连接再写"这条路。
3.5 管理动作单独一条线程
net 线程是本模块唯一自己创建的线程,只做三件时间类的事:空闲(心跳)超时判定、
待释放连接的到期析构、主动连接的重连扫描。它不发任何业务数据,所以不会撕开字节流。
把这三件事从 up / down 里拆出来的理由:它们是纯时间动作 ,与"哪个方向有事件"
无关。挂在事件的 tick 上就变成"这条线程没被驱动 → 永不断线、永不释放",
宿主只驱动一侧时(单线程测试、纯转发进程)尤其明显。拆出来之后 up / down 只管收发,
net 只管时间,两边都不必知道对方在做什么。
握手补发与心跳这两个发送 动作留在写 owner 上,没有搬进 net ------ 它们要写 socket。
4. 消息机制
消息层回答四个问题:名字怎么变成 id、谁来收、投给谁、走多远。四个答案都在登记那一刻定下,
运行期只剩整数查表。
4.1 消息名 → msgid
消息名(cmsg_chat / smsg_handshake / tmsg_stat_req)
│ crc32,登记时算一次
▼
uint32 msgid ── 线上只传这 4 个字节 ──▶ 订阅表 / by_msg 路由表 / 日志反查
三条约束构成这套协议:
- 名字以
tmsg_ / smsg_ / cmsg_开头,前缀同时决定广播范围(见 4.4)。 - id 由
crc32算,不手写;算出来是 0 的名字要换一个(0 在总线上表示"非法 msgid")。 - 重名登记幂等:同一个名字算两遍得同一个 id,所以任何进程自己算一遍就能对齐,
不需要把注册表同步到每台机器。
common/proto/init.lua 把各接收者的协议文件聚合起来,消息名→id 是进程级 一张表,
日志、路由、add_svc 都查它。加一条消息 = 登记名字 + 写 handler,两端立刻能用,
没有 IDL、没有代码生成步骤。
4.2 订阅与归属
xbus 的 listen 只对当前线程 生效:handler 挂在登记它的那条线程上,之后的消息投递
只往那条线程投。由此得到"登记线程就是归属线程",不存在跨线程代登记。
enter(objid, obj) 把目标和当前线程绑死:之后 send_to(dstid) 命中时,handler 的第一个
参数就是那个对象。一个线程可以绑多个对象,一个对象只能绑一条线程。
4.3 登记与投递
登记只有三件事:
| 接口 | 语义 |
|---|---|
add_msgname(name) |
登记消息名,返回 crc32(name);重名幂等 |
listen(msgid, fn) / listen{...} |
订阅;只对当前线程生效 |
enter(obj) |
把"目标对象"绑到当前线程,send_to(dstid) 命中时 handler 第一个参数就是它 |
投递有四种,区别只在"选谁":
| 接口 | 选谁 | 说明 |
|---|---|---|
send(msgid, ...) |
订阅者(多线程时按轮询或 consid) |
不知道接收者是谁,只知道消息名 |
send_to(dstid, msgid, ...) |
按对象找线程 | 只投给绑定了 dstid 的那条线程 |
send_by(consid, msgid, ...) |
按一致性键找线程 | 同一个 consid 恒落同一条线程 |
call(..., cb) / call_to(dstid, ..., cb) |
按对象找线程,并且等它回话 | 应答回到发起线程的回调 |
broadcast(msgid, ...) 是 send 的广播版本,范围由消息名前缀 唯一决定,调用方不传范围。
本地模块与远端服务用的是同一批函数,业务代码不需要知道对方在哪台机器上。
4.4 广播范围由前缀决定
| 前缀 | 范围 | 说明 |
|---|---|---|
tmsg_ |
LOCAL |
本进程所有订阅线程,xbus 自己扇出,不出网 |
smsg_ |
SVCS |
所有服务连接(服务间广播) |
cmsg_ |
GATES |
所有网关,网关再下发给自己下面的客户端 |
xbus_send 的 bc 参数只是一个开关(要不要广播),范围它决定不了 ------ 范围写死在名字里。
这样"这条消息能走多远"在读协议表的时候就能看出来,不需要追到调用点。
4.5 call 与 reqid
call 立刻拿到 reqid,对方回包按 reqid 找回等待者,回调在发起调用的那条线程 上
执行(不跨线程碰 lua_State);30 秒未回自动丢弃并告警。C++ 侧另有带返回值的 call_args,
类型由编译期推导。
reqid 在线上封装里位于 msgsize 段内部 ,和消息体一起被加密压缩,所以任何
"不解密就读 reqid"的路子都不成立 ------ 网关按 reqid 配对、转发,都发生在解密之后。
4.6 序列化
消息体用 msgpack (bus.pack / unpack),根是实参数组 ------ 服务端 handler 解出来的
就是客户端发的那几个实参,test-client 就是靠这个跟集群对话的。msgpack 的类型是自描述的,
lua 那边不用声明结构;代价是没有字段编号,schema 变化只能靠双方约定,
这一点在第 14 节里列为已知限制。
C ABI 那层 handler 的形参位置是固定的:(netid, owner, msgid, args, result) ------ netid 是
消息从哪条连接来(进程内是 0),owner 是 enter 进来的对象,位置固定,不会少一格。
Lua 侧是"能省就省"的版本:enter 过才有第一个位置;smsg 且 netid != 0 才多塞一个
netid;cmsg 未绑定时第一个实参是客户端在网关侧的连接 id(回包要用)。
4.7 一条 cmsg 走过的六跳
把前面几节串起来,一条业务消息从客户端到应用服再回来,只有两次真正跨线程:
① 客户端 xgate.cli_connect{endpoint, ticket, master}
→ 32 字节首包:ticket(8) + 8 + 16 字节密文,AAD = "x3-hello"
② 网关外网口 验票(票一次性,用完即删)→ HKDF-SHA256 派生上行 / 下行两把密钥
③ 客户端发 flag | [dstsize|dstarr] | msgsize | [reqid] | msgid | msgbody
msgsize 段整体压缩 + 加密,flag、msgsize、idx 恒明文
④ 网关 main 线程读到 → 解密 → 解压 → 切出 msgid / reqid / body
→ 选目标(钉住的应用服优先,否则按 msgid 路由)
→ 投到 bus 线程写出(写 owner,见 3.4) ← 跨线程 ①
⑤ 应用服 main 线程读到 → xbus 按 msgid 投给订阅线程
handler 就在订阅线程上就地执行(通常就是 main)
⑥ 回程 handler 里 send_to(来源 netid, cmsg_xxx_ret)
→ 总线投到 bus 线程 → 写回网关 ← 跨线程 ②
→ 网关把 dstid 当客户端连接 id,加密下发
每一次跨线程都是同一种动作:把包连同目标线程投过去,收件线程自己去写。没有锁,也没有
"等着对方写完"这件事 ------ 所以这条路径上的任何一环变慢,都不会把上游卡住,只会让队列变长。
5. 线程模型
执行体是线程。这一章讲线程怎么被托管、lua_State 怎么绑上去、一个周期怎么用掉,
以及为什么"没有调度器"在这里是一个可以承受的选择。
5.1 三种线程
┌─ main 线程 ─────────────────────────────────────────────────────
│ main.lua、登记在 main 上的 handler、定时器
│ 驱动 up 事件:所有连接的读
└──┬──────────────────────────────────────────────────────────────
│ post_bus:发送、投递
┌──▼──────────────────────────────────────────────────────────────
│ bus 线程
│ 总线队列 → 按 msgid 扇出 → 封包头 → 驱动 down 事件:所有连接的写
└──┬──────────────────────────────────────────────────────────────
│ post:扇出到订阅线程、把应答投回发起线程
┌──▼──────────────────────────────────────────────────────────────
│ 池线程 tpool1..N、自建业务线程(每条自带 lua_State)
└─────────────────────────────────────────────────────────────────
net 线程(xnet 自建,200ms 一圈):心跳超时 / 待释放连接析构 / 重连扫描,不发业务数据
- 固定线程 :
main(跑main.lua与业务回调、驱动up事件)、bus(消费总线队列、
扇出、驱动down事件)、net(xnet自建的纯时间线程)。 - 池线程 :
xtpool建的 N 条,xtpool_alloc(key)对同一个 key 恒返回同一条线程。 - 业务线程 :由
xthread_create_lua起的任意条,每条自带lua_State,跑自己的入口脚本。
所有线程都由 xthread 托管,统一具备:一个任务队列(post)、一组周期回调(runcb)、
一个空闲休眠周期、一个超时告警阈值。xthread_stops() 一次置位,全部线程跳出循环。
5.2 一线程一 lua_State
xthread_create_lua 建 state、配搜索路径、loadfile + pcall;线程退出时 lua_close
也归它。所以跨线程永远不会碰同一个 state ------ 这是"业务侧没有锁"的另一半原因:
Lua 的 lua_State 本身就不是线程安全的,把它绑死在线程上,比给它加锁便宜得多。
代价是没有协程级的并发:同一线程上的多个 owner 共享一份 lua_State 和同一个 tick,
一个长任务会拖住同线程的其它模块。这一点靠 xtimer 的每轮预算(k_budget /
k_catchup)缓解,而不是靠调度器把它切走。
5.3 runcb:周期怎么用掉
xthread 的周期回调返回值是"本回调自己休眠了多少毫秒",不是"要不要再来一轮":
- 返回 0:只做了一轮非阻塞的活,没睡。
- 返回 > 0:自己睡了这么多(典型是
epoll_wait/select/wait_for把周期用掉了)。
一轮里只要有一个回调报 > 0,线程就不再额外休眠;全都报 0,线程睡"周期 − 本轮已耗时",
周期从轮次开始算,不往后拖。超时告警会扣掉自报的睡眠,等待本身不计入超时。
这套约定让"事件驱动的线程"和"纯周期线程"用同一个抽象:xnet 的两个 proc_* 报自己
在 epoll_wait 里花掉的时间,业务侧的周期任务报 0,两者都不需要知道自己跑在哪种模式里。
5.4 xtimer 与 xtpool
xtimer的定时器挂在当前线程 :注册、删除、触发都在本线程,不走跨线程队列,
也没有锁。名字在同一 owner 下唯一;owner的作用是查重与回调透传。
单次回调默认 15ms 预算,超了打 WARN ------ 这是"长任务拖住同线程"的第一道观测手段。xtpool是线程池:xtpool_alloc(key)在 key 非 0 时按key % 线程数选,
key 为 0 时按rr_轮询;同一个 key 恒得同一条。需要"某个玩家的活固定一条线程"时用它,
比自己维护 id→线程映射省事。
5.5 线程收尾
xthread_stops() 一次置位,全部线程跳出循环;xthread::stop_all 不 join 线程对象
(对象与进程同寿,真正的 join 在静态析构期),但会等每条线程真的跳出循环,再留 500ms
让它们收尾。此后任何创建入口都返回失败 ------ 退出途中冒出来的新线程活不到被 stop。
进程级的退出链条、以及每一步为什么不能换序,见第 7.6 节。
6. 微服务集群
集群由三个角色组成,每个角色只做一件事。这一章讲它们怎么分工、位置怎么算出来、
一次登录怎么走完,以及网关那一层为什么刻意不做判断。
6.1 三个角色,各管一件事
| 进程 | insid | 内网地址 | 额外端口 | 角色 |
|---|---|---|---|---|
xa_registry |
11 | 127.0.0.1:9000 |
--- | 注册中心:登记 / 全量下发 / 新服务广播 / 保活 |
xa_gate |
301 | 127.0.0.1:9001 |
客户端 9101 |
网关:验票、加解密、转发、钉住后端 |
xa_login |
201 | 127.0.0.1:9002 |
--- | 登录:按 userid 取模选应用服并钉住会话 |
xa_app ×2 |
101 / 102 | :9003 / :9004 |
--- | 业务服(多开 = 同目录 + 不同 insid/endpoint) |
xa_sched |
401 | 127.0.0.1:9005 |
HTTP 9006 |
调度:挑网关、发票据、下发客户端口 |
分工的原则是谁也别替别人记状态 :注册中心只存"谁在哪个地址",不做决策;
调度中心只做"这次给谁",不存目录;网关只做"这条连接转发给谁",不管业务。
三个角色各自可以单独重启,重启后从注册中心重新对齐。
一次接入的方向是这样走的:
┌──────────────────────────────────────┐
客户端 ── 9101 ───▶│ xa_gate 301(外网 9101 / 内网 9001) │
└───────┬──────────────────┬───────────┘
│ 内网 │ 内网
▼ ▼
xa_login 201 xa_app 101 / 102
└─── 选出落服 ─────▶
客户端 ── HTTP 9006 ──▶ xa_sched 401 挑网关、发票据、给客户端口
xa_registry 11(:9000)◀── 以上全部注册、保活、收全量列表
6.2 位置是算出来的
选应用服是三行:regcli.of_app("app") 拿在线列表(按 insid 排序),
list[(userid % #list) + 1] 取一台。同一个 userid 每次都落同一台,
所以不需要一个"位置服务"记住"这个玩家在哪台机器上"。
xnet 的按 msgid 路由用的是同一条算式:候选实例按 insid 排序后
consid % 候选数 选一台,consid == 0 时走轮询(pick_index,src/xnet/xnet_router.cpp);
xbus 在同进程有多个订阅线程时也是 consid % 订阅线程数。
这是"算出来的"与"查出来的"的分界:后者需要一个有状态的目录节点,任何一次寻址都要问它,
它挂了整个集群失忆;前者任何一台机器都能独立算出同一个答案,没有单点,
也没有"位置表与实际不符"这类不一致。
代价是实体不能随意迁徙:要换实例就得换 userid 的落点,而 userid 是玩家身份的一部分。
换来的是位置查询零成本、没有单点。
6.3 注册与发现
启动 ──▶ 向 xa_registry 注册(自报 appname / insid / endpoint / 消息清单)
◀── 收全量列表(smsg_reg_welcome)
◀── 收新服务广播(smsg_reg_new)
──▶ 按 insid 规则建链:大的拨小的、同 appname 副本互不拨
──▶ 保活:定时 smsg_reg_ping
消息清单从 proto.mid 取,协议只写一处。注册中心的保活超时是静默剔除 (不广播下线),
客户端侧靠连接断开感知;同集群实例断开满 3 分钟从本地列表删除,这期间重连由 xnet
按拨号规则自动完成。
网关拨后端服务走 xgate.add_svc 而不是普通的 net.add_ins ------ 网关要顺带登记转发路由的
认领清单(哪些 appname 的目标它接得住),这是它区别于普通服务的一处。
6.4 一次完整链路
① 调度 http.get(sched, "/sched?account=xxx")
→ {"code":0,"gate":"ip:port","ticket":N,"key":"<64hex>","ttl":ms}
② 接入 xgate.cli_connect{endpoint=网关客户端口, ticket, master} ← 首包验票,派生两把方向密钥
③ 登录 cmsg_login [account, prefer] → cmsg_login_ret [ok, code, msg, userid]
④ 业务 cmsg_chat ×2 → cmsg_chat_ret [ok, where, echo] ← 两次落在同一台应用服
insid 是 16 bit,全簇唯一,同时也是雪花算法的机器号。网关在 ① 之后拿到 ticket 与
32 字节 master,首包校验票据后由 HKDF-SHA256 派生上行 / 下行两把密钥,之后
chacha20-poly1305 加密、LZ4 按块压缩。
网关的消息转发不做业务判断,只做三件事:验票、解密、按 dstid 下发。dstid 到了网关
直接当客户端连接 id ,不查表;dstid = 1 是保留值,表示"本网关下所有客户端"。
xgate 内部按单元拆开,每个单元只认一件事:
| 单元 | 做什么 |
|---|---|
xgate_ticket |
一次性接入票据(调度服推过来),首包校验后作废 |
xgate_crypt |
AEAD 形态加解密,由 xgate_pack 直接调,不经 xnet |
xgate_zip |
LZ4 形态压缩 / 解压 |
xgate_pack |
三个入口:外网首包验票 / 外网业务包解密转发 / 内网包双向 |
xgate_handshake |
握手钩子:记对端服务类型(xnet 只认实例号,不认角色) |
客户端口 = 网关内网口 + client_off(demo 里 100,9001 → 9101)。
这个偏移在 xa_gate/local_runcfg.lua 与 xa_sched/local_runcfg.lua 各写了一遍,
两处是同一个数字的两份拷贝。
6.5 网关的限速设计
doc/03-网关CD算法设计.txt 记录的是一套消息级限速(CD,cool down)的算法设计:
以毫秒为单位的信用桶,credit 按真实时间懒补充、封顶 span,判定门槛是 0.9 × cd_ms,
消费的是完整的 cd_ms。
三条性质是这套设计的全部理由:
- 可累积 ------ 空闲 300ms 就有 300ms 信用,允许 TCP 挤压后的连发。
- 有上限 ------ 补充封顶
span,挂机一小时也只能攒到span。 - 容差只偶发生效 ------ 门槛 0.9T、消费 T,偶发提前 10% 靠下一包补回;持续提前 10%
每包净亏 10ms,信用耗尽后被拦,长期速率恒为 1/T。
它与 GCRA 的差别正在第三点:GCRA 的 τ 会让持续提前的客户端永久提速 11%,
而这里容差是"可以小额透支",透支由后续间隔偿还。判定为加速的包直接丢弃,不排队、不延迟 ------
排队会把队头阻塞引入转发路径,而合法包恰恰最需要低延迟。
7. 模块与插件化
7.1 一个模块 = 一个动态库 + 一张 C 表 + 一份语言绑定
inc/x*.h C ABI 声明(C++ 糖写在同一文件)
src/x*/x*.cpp 实现
src/x*/x*_lua.cpp luaopen_*:一张 luaL_Reg 交给 xlua::open_module
bin/x*.dll / .so 产物
每个模块同时导出两套入口:一组 X*_API 的 C 函数,和一张 lua 表。lua 侧看不到 C 结构体,
只看到函数与 userdata 句柄;C 侧看不到 lua 表,只看到函数指针。两边通过
xlua_module.h 的 open_module + set_field_* 对齐,注册表就是一张 luaL_Reg 数组。
7.2 宿主只链三样
x3.exe 只链 xthread / xbus / xlog,其余模块按需加载:
- 不用的模块不加载,进程启动快,内存占用只跟用到的功能有关。
- 模块的依赖关系是单向的:
xid → xlog、xbus → xthread + xid、xnet → xbus + xthread,
没有环。 - 模块私有单例叫
M,用按值静态static M md_;+ 自定义~M()------ so 卸载时才析构,
加载期初始化、卸载期释放都由模块自己负责,不依赖任何一方替它收尾。
7.3 模块之间不互相 include,也不互相调用
httpd 允许模块调 ap_*,nginx 允许调 ngx_*;这里只允许发消息。耦合因此更松,
代价是跳数更多 ------ 一次跨模块动作要过一次总线的分发,而不是一次函数调用。
这条规则有一个例外,是"公共基础设施只有一个实现":xplat.h(socket / 地址 / 超时)、
xendian.h(字节序读写)、xlua_module.h(模块注册)、xbuf.h。这四个是头文件形式的
共享件,不是模块,也不导出符号。做成一件事而不是各写一份,是因为平台相关的错误
(字节序、socket 关闭顺序、超时精度)一旦有第二份实现,就会有一份是错的。
7.4 平台层只有一个分叉点
xplat.h 是全项目唯一允许出现 socket #ifdef 的地方。它提供 socket 类型、地址解析、
非阻塞设置、errno 归一、线程号、单调时钟这些原语;xnet 内部几百处 plat::xxx
通过 xnet_platform.hpp 的两行别名引用它,所以加一个平台只需要改 xplat.h 一处。
Windows 与 Linux 的连接状态机是同一套:accept / connect / 收 / 发 / 关五个动作,
只有底下的系统调用分叉。这样 Windows 上调通的逻辑在 Linux 上不会因为跨线程而失效,
反之亦然。
7.5 有序初始化
C++ 模块的初始化分两段,中间隔着一个"main 线程还不存在"的窗口:
① 静态初始化期 各 cpp 单元自己注册
xthread::post_main<"xgate 消息登记">(®ister_msgs)
(此时不能直接 xbus_listen ------ 它只认当前线程,而进程里还没有托管线程)
│
▼ 挂进 pending 队列(FIFO)
② main 登记后 xthread_set_main(t) → 按投递顺序补投 → 在 main 线程上执行
三条性质:
- 没有集中清单。 新增一个单元,只写自己的
init()与
namespace { auto _ = init(); },宿主和别的单元都不用改。这是"一个模块一个动态库"
在源文件一级的对应做法:模块的初始化代码跟着模块走,不跟着宿主走。 - 顺序可复现,但不承载依赖。 队列是 FIFO,投递顺序就是静态初始化的完成顺序
(同一个编译单元内按定义顺序)。模块之间不靠这个顺序建立依赖 ------ 每个模块只订阅自己
那几条消息,谁先跑结果一样。所以这个顺序是给诊断用的(日志里能看出谁先初始化),
不是正确性的一部分。 - 早投不丢。 在
xthread_set_main之前投的任务挂起,登记那一刻补投;
登记之后投的直接进线程队列。模块在静态期就能"预约"一条 main 线程上的初始化,
不必关心宿主什么时候把 main 线程起来。
Lua 侧不用队列,把顺序直接写进入口:main.lua 依次做 算目录 → boot → 起 stat 线程
→ run(boot),业务的登记全在 run 里。顺序只有一处,读那五行就知道谁先谁后,
不需要在别的地方再维护一份初始化清单。
7.6 有序退出
tmsg_close 广播 → xbus::wait_idle() → xthread::stop_all()
→ 最后一条日志 → xlog::flush() → 各 so 静态析构
每一步都在前一步收尾之后才开始,顺序不能换:
| 步骤 | 做什么 | 放在这一步的理由 |
|---|---|---|
tmsg_close 广播 |
通知所有模块"要退了" | 广播本身要过总线,要趁线程还在 |
xbus::wait_idle() |
排空还在飞的消息 | 先停线程再排空,消息会投给已经跳出的线程 |
xthread::stop_all() |
置停止位、等每条线程跳出循环、留 500ms 收尾 | 线程里可能还有半条业务链,前面两步给它们让出了时间 |
| 最后一条日志 | 只走 fprintf(stderr) |
日志队列的落盘线程此时已经停了,再入队没人写 |
xlog::flush() |
把队列里剩下的写完 | 在最后一条日志之后、析构之前 |
| 各 so 静态析构 | ~M() 收自己的资源 |
此时已无线程在用这些模块,最后一步 |
退出入口只有两个:Ctrl+C(SIGINT)与 x3 -stop,两者执行同一条函数。-stop 从命令行
找到同目录的实例、投递退出请求,等价于按一次 Ctrl+C。
taskkill /F(TerminateProcess)是另一个方向:内核直接收走进程,上面六步一步都跑不到,
tmsg_close 不广播、总线不排空、线程不跳循环、日志队列不落盘。所以停止统一走 -stop,
它和 Ctrl+C 是同一条路径 ------ 这也是"有序退出"能成立的前提:退出由进程自己执行。
8. Lua 分层与装配
xa_<app>/main.lua 进程入口:一副固定骨架
xa_<app>/local_runcfg.lua 本进程独有的配置(M.app 进程形态 / M.biz 业务参数)
xa_<app>/<业务>.lua 业务实现,对外只有 run(boot)
xi_<app_type>/ 同类型进程的公共实现(app_type = 目录名去掉 xa_ 前缀)
common/boot.lua 进程怎么起来(路径 / 身份 / 日志 / xnet)
common/cluster/ 起来之后怎么说话:svc、regcli、runcfg、stat
common/proto/ 消息协议(名字→id、接收者清单)
五个服务进程的 main.lua 是同一副骨架,差别只在最后一行(test-client 少一条 stat 线程、
多一行 require("proto"),末尾用 os.exit(退出码) 把结果交给启动脚本):
lua
local main_dir = <本文件所在目录> -- 从 debug.getinfo(1,"S").source 截出来
package.path = main_dir .. "/../common/?.lua;" .. package.path
local boot = require("boot")(main_dir) -- 网关与客户端多传一个 { net = false }
require("xthread").create("stat", boot.comm_root .. "/cluster/stat.lua", 5, 20)
return require("app").run(boot) -- ← 唯一不同的那一行
- 目录由入口算 :
boot(main_dir)拿它推出proj_root/comm_root/xi_app_root/
log_root/bin_root;不传时退回script_dir()(沿调用栈取宿主直接跑的那个 chunk)。
目录一律绝对路径,宿主拒绝相对路径。 - 身份来自目录 + 命令行 :
appname=main.lua所在目录名(命令行不传,避免两处不一致),
app_type= 目录名去掉xa_前缀;insid/endpoint/peers来自命令行。 - 配置分两层 :
common/cluster/runcfg.lua放全集群一致的值,local_runcfg.lua只写
本进程不一样的那几项,boot深合并上去,同名 key 以本文件为准。M.app是进程形态
(框架读),M.biz是业务参数(业务读)。 - 没有部署表 :本进程只知道自己的身份,以及注册中心现场告诉它的"集群里有谁"。
common/proto/init.lua把各接收者的协议文件聚合起来 ------ 消息名→id 是进程级 一张表,
日志、路由、add_svc都查它。
8.1 一个 run,一处装配
业务的入口只有一个:run(boot)。它里面做三件事 ------ 向注册中心登记并发现对端
(cluster.regcli.start(boot))、订阅消息(bus.listen(H))、打一行就绪日志。
main.lua 里除了这行调用只有路径与 stat 线程,所以"这个进程装了哪些模块"看 H 表,
"什么时候开始工作"只有一个答案:run 返回之前。
网关与客户端是在这副骨架上换个填法:{ net = false } 关掉 boot 自带的网络初始化,
网关的 run 自己按顺序做 register_routes → start_gate → start_registry,
客户端的 run 自己拨号。入口形状不变,变的是里面那几步。
8.2 进程 = 目录
加一台同类型进程 = 同一份目录 + 不同 -insid / -endpoint,不复制目录。
xa_app 起两台就是这样做的:main.lua 一份,101 和 102 只是命令行不同。
同类型的公共实现放 xi_<app_type>/,业务差异留在 xa_<app>/ 里。
appname 从目录名取,这一点因此不靠纪律:复制目录就等于换了一个服务名,
"两处写得不一致"的机会在结构上就没有。
8.3 boot 与 cluster 的分工
boot.lua 只负责"怎么起来":路径、配置、身份、日志、xnet.init / xnet.listen。
它是一个工厂函数,返回一张表(appname / insid / endpoint / cluster / runcfg /
comm_root ...),业务要什么从这里取。
cluster/ 负责"起来之后怎么说话":svc(身份与对端清单,纯数据)、regcli(注册与发现,
由 run 调 .start(boot))、runcfg(公共配置,被 boot 读走)、stat(统计线程的入口
脚本,由 xthread.create 拉起)。四个文件里只有 regcli 带启动动作,其余两个是表、
一个是线程脚本。
网关与客户端给 boot 传第二个参数 { net = false } ------ 这两个进程的网络栈不由 boot 起。
网关把注册发现放在 xgate.start 之后 ,并覆盖 regcli.dial 走 xgate.add_svc。
两个例外的原因相同:网关的两个 listener 要先于注册发现就位,否则第一批向它注册的连接
没有地方落。
8.4 一个业务模块
xa_app/app.lua 是最短的完整例子,去掉日志与注释后只剩这些:
lua
local bus = require("xbus")
local where -- "xa_app@127.0.0.1:9003",run 里赋值
local H = {} -- handler 表:键是消息名,值是函数
function H.smsg_user_login(netid, client_netid, account, userid, gate_insid)
bus.send_net(netid, "smsg_user_login_ret", client_netid, true, "ok")
end
function H.cmsg_chat(client, netid, text)
bus.send_via(netid, client, "cmsg_chat_ret", true, where, text)
end
local M = {}
function M.run(boot)
where = boot.appname .. "@" .. boot.endpoint
require("cluster.regcli").start(boot) -- 向注册中心登记、拿对端列表
bus.listen(H) -- 把 H 里每条消息登记到当前线程
return M
end
return M
H的键就是消息名,bus.listen(H)一次把整张表登记完,登记在哪条线程就归哪条线程。- handler 的形参由消息名决定:
smsg首参是netid,cmsg首参是client(客户端在网关
侧的连接 id),后面才是消息体。每个common/proto/*.lua都在名字下面注了形参顺序。 send_net沿一条连接发;send_via额外带目标段 ------ 服务回包给客户端要用它,
否则网关不知道下发给哪个连接。
加一条消息是三步:
- 在接收方的
common/proto/<receiver>.lua里加一行M.cmsg_xxx = common.def("cmsg_xxx"),
并在下面注释形参; - 接收方写
H.cmsg_xxx = function(...) ... end; - 发送方
bus.send("cmsg_xxx", ...),或按场景用send_to / send_via / call。
名字一登记,两端同时可用:proto.mid 是进程级的一张表,日志、路由、add_svc 都查它,
没有中间文件要生成。
9. 目录与模块
9.1 目录树
x3/
├── src/ C/C++ 辅助层(每个子目录 = 一个模块 = 一个 dll/so)
│ ├── x3/ 宿主:建 main 线程跑 main.lua、信号、退出流程、-stop
│ ├── xthread/ xtime/ xlog/ xfile/ xflag/ xid/ xtpool/
│ ├── xbus/ xnet/ xtimer/ xgate/ xhttp/
│ └── CMakeLists.txt 顶层构建(产物统一输出到 ../bin)
├── inc/ 对外头文件(C ABI + C++ 糖);xgate 的头在 src/xgate/ 下
├── bin/ 所有产物:*.dll / *.so / *.dll.a / x3(.exe) / lua 运行库
├── 3rd/ 第三方:lua、msgpack-c、lz4、mbedtls
├── common/ Lua 公共层
│ ├── boot.lua 进程启动引导(工厂):路径 / 身份 / 日志 / 网络
│ ├── cluster/ 运行期公共件:svc 身份、regcli 注册发现、runcfg、stat 统计
│ └── proto/ 协议:消息名→id、接收者清单、字段说明(唯一一份)
├── xa_registry/ 注册中心(insid 11,:9000)
├── xa_gate/ 网关(insid 301,:9001 → 客户端口 :9101)
├── xa_login/ 登录服(insid 201,:9002)
├── xa_app/ 应用服,可多开(insid 101 :9003 / 102 :9004)
├── xa_sched/ 调度服(insid 401,:9005;HTTP 分配口 :9006)
├── xi_app/ 同类型进程的公共实现(impl 层)
├── test-client/ 客户端剧本(走完整链路:调度→接入→登录→业务)
├── test/ 线程调度 demo(xbus / xthread / xtimer 的用例集)
├── doc/ 设计文档:协议、网络组件、网关限速算法、C/C++ 编码规范
├── log/ run/ 运行期日志 / 整簇的 stdout 与 pid
├── handlers.txt xbus 生成的 listen 签名清单(debug 首次登记时才出现)
├── build_win.bat Windows 构建(MinGW + Ninja → build/win)
├── build_linux.sh Linux 构建(→ build/linux),build_linux.bat 是 WSL 包装
└── start_all.bat / .sh 一键起整簇;stop_all.bat / .sh 优雅停
9.2 模块一览
| 模块 | 产物 | 职责 | 头文件 |
|---|---|---|---|
x3 |
x3 / x3.exe |
宿主:建 main 托管线程跑 main.lua,装信号,跑退出流程,-stop 优雅停 |
--- |
xthread |
xthread.dll/.so |
托管线程:任务队列 + 周期回调 + 超时告警;每线程一个 lua_State |
inc/xthread.h |
xtime |
xtime |
单调时钟 / 墙上时钟(ns、ms) | inc/xtime.h |
xlog |
xlog |
异步日志:多线程入队、单线程落盘、按进程+日期分目录、{} 占位 |
inc/xlog.h |
slog |
slog |
第二个 logger(同实现,换个名字给 Lua 用) | --- |
xfile |
xfile |
路径规范化 / 读文件 / 目录操作 | inc/xfile.h |
xflag |
xflag |
命令行参数注册(-insid、-endpoint、-peers ...) |
inc/xflag.h |
xid |
xid |
crc32/crc64、名字→id 表、雪花 gen_id、一致性哈希 consid |
inc/xid.h |
xtpool |
xtpool |
线程池;xtpool_alloc(key) 把同一个 key 固定到一条池线程 |
inc/xtpool.h |
xbus |
xbus |
消息总线:消息名→id、listen/enter、同进程扇出、出网封包(msgpack) |
inc/xbus.h |
xnet |
xnet |
TCP:连接(拨号/监听/心跳/重连)、编解(封包/切包/校验)、路由(服务表) | inc/xnet.h |
xtimer |
xtimer |
每线程时间轮;回调在本线程同步执行;单次回调超时告警 | inc/xtimer.h |
xgate |
xgate + 静态库 |
网关:验票 / AEAD 加解密 / LZ4 压缩 / 转发 / 钉住某个后端实例 | src/xgate/xgate.h |
xhttp |
xhttp |
HTTP 基础服务:listen + path → handler,业务在 Lua |
inc/xhttp.h |
xbuf / xendian / xplat / xlua / xlua_module |
头文件 | 缓冲区、字节序、平台层、Lua 绑定基础设施 | inc/*.h |
9.3 依赖方向
xid ──▶ xlog
xthread ──▶ xid, xlog
xbus ──▶ xthread, xid, xlog
xnet ──▶ xbus, xthread, xid, xlog
xgate ──▶ xnet, xbus(静态库,显式 start)
xnet 与 xgate 之间是一次显式的 xgate::start(cfg):它按固定顺序挂通道回调、
连接钩子、总线订阅,最后才 xnet_init 与 xnet_listen_ex。这个顺序错了的表现是
"第一批注册上来的服务连不上",所以顺序写在 xgate.h 的注释里。
9.4 配置速查
配置只有两层,boot 深合并后挂在 boot.runcfg:common/cluster/runcfg.lua 放全集群一致的
值,xa_<app>/local_runcfg.lua 只写不一样的那几项(同名 key 以它为准)。下表首列为空的行
表示沿用上一行的文件。
| 文件 | 段 | 键 | 含义与缺省 |
|---|---|---|---|
common/cluster/runcfg.lua |
cluster |
--- | 集群名,"dev";进握手首字段 |
env |
--- | 环境名,"dev";只用于日志与展示 |
|
registry |
insid / appname / endpoint |
注册中心身份:11 / "xa_registry" / 127.0.0.1:9000 |
|
net |
workmode |
读写归属:0 = 读全在 up、写全在 down |
|
heartbeat_ms / idle_timeout_ms |
心跳 3000 / 空闲断链 30000 |
||
max_uppack / max_downpack |
上行 / 下行单包上限,各 64K |
||
send_limit |
单连接发送队列上限(字节),10M |
||
log |
debug / monitor_ms |
debug 开关 / 线程卡住告警周期 30000 |
|
xa_gate/local_runcfg.lua |
gate |
client_off |
客户端口 = 内网口 + 它,100 |
hello_timeout_ms |
首包验票超时 5000 |
||
client_max_recv / client_send_limit |
客户端收包上限 4096 / 队列 0 = 沿用公共值 |
||
crypt |
require / ticket_ttl_ms |
客户端包必须带加密位 / 票据有效期 5000 |
|
zip |
min / max |
LZ4 压缩区间 256 ~ 64K |
|
forward |
pin_fallback |
钉住的后端断线后是否回落按 msgid 路由,false |
|
xa_sched/local_runcfg.lua |
sched |
http_port / ticket_ttl_ms / client_off |
调度口 9006 / 票据 5000 / 偏移 100(与网关同一个数) |
xa_app/local_runcfg.lua |
app |
listen / impl |
是否监听内网口 true / 用哪个 xi_ 实现目录(nil = 目录名去掉 xa_) |
biz |
业务自定义 | 示例是 tick_ms = 50 |
四条分界线决定了什么该写在哪:
- 身份不进配置。
appname来自目录名,insid/endpoint来自命令行。配置里再写一份,
就有两个真相。 - 协议不进配置。
msgid在common/proto/,改它等于换协议;配置改的只是运行参数。 - 全集群一致的值放 common。 心跳、单包上限、发送队列不一致时,症状是"一边断一边好"。
- 端口用偏移。 客户端口写
client_off而不是写死9101,换机器只改-endpoint,
外网口跟着走。
9.5 Lua API 速查
4.3 讲投递语义,这里只列名字,便于查接口。require 进来就能用,模块名与 dll/so 同名:
| 模块 | 常用函数 |
|---|---|
xbus |
add_msgname / listen / enter / leave / list / send / send_to / send_net / send_via / send_by / broadcast / call / call_to / pack / unpack / wait_idle |
xthread |
create(name, entry, sleep_ms=5, timeout_ms=20) / curr / curr_name / curr_id / stat |
xtimer |
add(name, ms, [owner,] fn [, timeout_ms]) / add_one(name, delay_ms, ...) / add_count(name, ms, count, ...) / del / stat |
xnet |
init / listen(port) / connect(endpoint) / add_ins{...} / del_ins / close / endpoint / alive / peer_insid / insid_netid / app_netids / is_internal_msg / stat |
xgate |
start{...} / add_svc(insid, appname, endpoint) / add_msg(msgid, kind, appname) / stop / cli_connect / cli_send / cli_recv / cli_close |
xhttp |
listen(port) / on(path, fn) / get(url, cb) / stop |
xlog |
info / debug / warn / error(各有 _fmt 版)/ init / set_debug / is_debug / set_thread_name / stop |
xid |
crc32 / crc64 / gen_id / gen_consid / addname / name2id / id2name |
xtpool |
create / count |
三条与调用位置有关的约定:
xnet的workmode/heartbeat/ 包上限要在init之前设,init之后返回< 0。listen/enter/timer.add都只作用于当前线程,在哪条线程调就归哪条线程。call的回调在发起调用的那条线程上执行;跨线程与跨进程的写法一致。
函数签名与参数细节写在 inc/*.h 和 src/*/*_lua.cpp 的注释里,每个 lua 绑定函数上方都有一段
用法示例。
10. 构建
依赖:CMake ≥ 3.20、Ninja;Windows 用 MinGW-w64 (C:\mingw64\bin 在 PATH),
Linux 用 g++(WSL 可直接编)。第三方都在 3rd/(lua、msgpack-c、lz4、mbedtls),
不需要额外装。
工程按 C++20 与 64 位 写死:CMAKE_CXX_STANDARD 20,32 位工具链在 configure 阶段
直接 FATAL_ERROR,免得 32 位产物混进 bin/。
bat
:: Windows(默认 Debug;第一个参数可给 release)
build_win.bat release
sh
# Linux
./build_linux.sh release
# 在 Windows 的 cmd 里编 Linux(走 WSL):
build_linux.bat release
- 产物统一落在
bin/:xthread/xtimer/xtpool/xbus/xtime/xlog/slog/xfile/xflag/xid/xnet/xhttp的
dll/so+ 导入库*.dll.a,静态网关库libxgate_{win,linux}.a,宿主x3(.exe)。 - 构建目录:
build/win、build/linux,互不干扰。 - 脚本末尾有产物自检 :缺哪个会打印
[MISSING]并以非 0 退出。 - 仓库里的
.bat是 CRLF ,用 LF 覆盖会解析错乱(报'st' 不是内部或外部命令之类)。
11. 运行
11.1 线程调度 demo(test/)
15 个用例:send_to / 自投递 / 池轮询 / send_by 一致性 / call / call_to / 自己 call 自己 / 广播 / 广播含自己 / send_net 无钩子 / 64KB 大包 / 5000 条高频 / 复杂载荷(单向+往返) / 统计上报 / 定时器(30 秒持续运行)。
bat
:: Windows
test\start.bat
sh
# Linux
./test/start.sh
跑完打印 [FINAL] 通信 + 定时器 15/15 通过,然后停在"等 tmsg_close 广播"。这个停顿是
正常状态:demo 不自己退,退出走 Ctrl+C 或 x3 -stop,两者都是
tmsg_close → bus 排空 → 线程收尾 → 日志落盘这条链。
判定口径都写在
test/test_main.lua里:每个用例先打[CASE]说明预期,末尾
[PASS]/[FAIL]+[FINAL] 汇总,子线程计数靠tmsg_stat_req上报回来核对。
11.2 整簇 + 客户端
sh
./start_all.sh # 起 registry + gate + login + app×2 + sched
./test-client/start.sh -sched 127.0.0.1:9006 -account alice
./stop_all.sh # 优雅停(走 x3 -stop,与 Ctrl+C 同一路径)
bat
:: Windows:每台一个控制台窗口
start_all.bat
test-client\start.bat -sched 127.0.0.1:9006 -account bob
stop_all.bat
期望输出:
[cli] ① 调度 OK(127.0.0.1:9006 → gate=127.0.0.1:9101 ticket=...)
[cli] ② 接入 OK(客户端口 127.0.0.1:9101)
[cli] ③ 登录 OK(msg=ok userid=...)
[cli] chat#1 OK where=xa_app@127.0.0.1:9003 echo=alice#...:hello-1
[cli] chat#2 OK where=xa_app@127.0.0.1:9003 echo=alice#...:hello-2
[cli] ④ 业务 OK(落服 xa_app@127.0.0.1:9003)
[cli] 结果:PASS(sched=1 hello=1 login=1 app=1)
-account 可以是任意字符串;-prefer <insid> 可点名应用服(不给就按 userid 取模)。
退出码:0 = PASS,2 = 超时 / 断言失败。
11.3 单进程
sh
cd bin
./x3 /abs/path/to/xa_app/main.lua -insid 103 -endpoint 127.0.0.1:9007
./x3 -stop # 停本目录所有实例;x3 -stop <pid...> 只停指定的
-stop 打的是"退出请求",和 Ctrl+C 完全同一条路径。
12. 实测记录
| 项目 | Windows(MinGW-w64, Release) | Linux(WSL, Release) |
|---|---|---|
| 构建 | build_win.bat release 通过,产物自检全 OK |
build_linux.sh release 通过,产物自检全 OK |
| 线程调度 demo | 通信类 14 项全过 | 15 项全过 |
| 整簇 + 客户端 | 6 进程起齐,test-client PASS (落服 xa_app@9004) |
6 进程起齐,test-client PASS (落服 xa_app@9003) |
| 优雅停 | x3 -stop → 所有实例已退出,0 残留 |
stop_all.sh → 6 台逐个退出 |
13. 开发约定
这一节记的是代码里反复出现的形状,每一处都对应一个具体的失败模式。
- 编码规范 (
doc/C-C++编码规范.txt):tab 缩进、花括号独占一行(含单行if)、
初始化列表花括号独立成行、行尾无空格、lambda 显式捕获(不用[=])。
显式捕获这一条在so里尤其重要:隐式捕获this的 lambda 存进线程队列后,
模块卸载时会指向已经析构的对象。 - 日志 :C++ 侧只有
xlog::info/debug/warn/error(xlog::here(), "fmt {}", ...),
here()作显式实参(宏里塞位置信息会让调用点的行号错位);Lua 侧只有xlog.info,
只认裸{}。退出期走fprintf(stderr)(见 7.6)。 - 消息名 以
tmsg_ / smsg_ / cmsg_开头,id 由crc32计算,不手写;
算出的 id 为 0 时换一个名字。 - 模块私有单例叫
M,用按值静态static M md_;(so 卸载才析构)+ 自定义~M();
没有资源要收的 cpp 不硬造M。每个导出 API 第一句先确认初始化 ------
dll可以被任意顺序加载,第一句检查是唯一可靠的位置。 - 公共基础设施只有一个实现 :
xplat.h(socket / 地址 / 超时)、xendian.h
(字节序读写)、xlua_module.h(模块注册)、xbuf.h。这四件事各写两份,
一定有一份在某个平台上出错,而且错得很安静。 - 进程 = 目录 :加一台同类型进程 = 同一份目录 + 不同
-insid/-endpoint,
不复制目录。两份目录意味着两份要同步的代码,同步一定会漏。
14. 已知限制
下面这些是当前实现的边界。每一条都写在对应模块的注释里,这里集中列出。
- 调度下发的
master由math.random拼成,不是密码学随机。换随机源(/dev/urandom或
BCryptGenRandom)的改动范围限于gen_master()一处。 - 注册中心的保活超时是静默剔除(不广播下线),客户端侧靠连接断开感知。
- demo 进程跑完不会自己退,停在"等
tmsg_close广播"------这是设计的正常状态,
Ctrl+C 或x3 -stop才是退出方式。 xhttp只做基础解析 +path → handler派发(方法名原样给出,不区分 REST 语义),
没有 TLS / 路由分组 / keep-alive 调优;客户端侧只有一个极简 GET,
够"进程当客户端问调度要网关"这种场景。- 消息体是 msgpack,没有字段编号。schema 变化靠双方约定,没有兼容性检查。
- Windows 上受完成端口约束,一条 socket 的读写完成落在同一条线程,第 3.1 节的两方向
分离只在 Linux 上成立。 doc/03的毫秒信用桶限速目前是算法设计,网关代码里没有对应的判定函数。- 没有健康检查端点,也没有 metrics 文本导出;
cluster/stat把统计写进日志。
15. 参考点:每条机制的参照
这一章按设计层面 分组:每一节先写 x3 在这一点上定了什么,再列定这条机制时参照过的
地方,最后写 x3 走到哪一步为止。
参考点分三类:能读到实现代码的仓库与库,规范文档(epoll(7)、I/O 完成端口、Lua 手册),
以及 SEDA、actor、single-writer 这类有成熟实现、需要一个名字来指代 的经典模型。
一条机制可以有几个参考点,彼此不必相关;同一个参考点也会出现在不同层面。
15.1 到 15.3 是整条思路上的三个大参考点(iogo / amc 两个仓库、名字系统、服务端框架的
整体形态),15.4 到 15.12 按九个层面逐一列出(身份、执行体、通信、I/O、模块、装配、脚本、
部署、观测),15.13 是真正编进产物的第三方库,15.14 收成三条。
15.1 代码仓库:iogo 与 amc
x3 的分层方式(进程=目录、模块=动态库、消息总线、集群分工)参照的是这两个仓库,
左边是仓库,右边是 x3 里对应的位置。
| 仓库 | 链接 | 对应的 x3 技术点 |
|---|---|---|
| iogo ------ GRPC/HTTP 微服务框架 | https://github.com/ygluu/iogo | 集群拓扑:注册中心只存地址、调度只做分配、网关只做接入 ↦ xa_registry / xa_sched / xa_gate;代码组织:iogo.go(入口装配)↦ common/boot.lua,server.go / client.go ↦ xnet 的 listen / connect 两侧,config.go ↦ local_runcfg.lua,unit + plugin ↦ "一个目录一个进程 + 一个动态库一个模块" |
| amc ------ 异步微服务集群 | https://github.com/ygluu/amc | svc / module / method 三层拆分 ↦ common/cluster/svc.lua + common/proto/;msg_queue.go / msg_id.go ↦ xthread 的每线程 FIFO 队列 + xbus 的 crc32(name) 消息 id;gate_*.go ↦ xgate |
amc的文件分工:svc.go / svc_module.go / svc_method.go是服务与模块的拆分,
thread.go / msg_queue.go / msg_id.go是"线程 + 消息队列 + 消息 id"的最小实现,
net_*.go / gate_*.go是网关的搬运路径。网络插件尚未完工,消息与模块部分是完整的,与
xbus + xnet + xgate逐层对应。
15.2 名字系统:ygluu 的《万物皆ID》与 data-e 的 idname
| 资料 | 链接 | 对应的 x3 技术点 |
|---|---|---|
| 《【图解】AIGC时代的游戏抽象设计:万物皆ID、万事皆事件、万人皆数据》 | https://www.cnblogs.com/ygluu/p/19117836 | "万物皆 id"这个提法的出处。文章讲数据侧 的统一:配置文件按模块分配 ID 段位、实例 ID 用 64 位雪花、名字登记进 id-name 系统、条件表达式解析完变成 player.datas[9000000] 这样的下标访问。x3 的 msgid / insid / netid / dstid / reqid 是同一判断在运行侧的版本:消息、连接、请求、线程归属、广播范围全部整数化 |
data-e 的 go/names.go |
https://github.com/ygluu/data-e/blob/master/go/names.go | 名字系统的完整实现:RegisterName / GetIdByName / GetNameById / GetCfgById,两张 map 反查,id 分段(高 8 位类型号 + 低 24 位序号)。同一仓库在 2024 年补了 Lua 版,与 x3 的"Lua 主控"是同一个方向 |
ygluu 那篇文章里三条判断,x3 对应如下:
| 文章的判断 | 文章的做法 | x3 的做法 |
|---|---|---|
| 万物皆 ID | 配置表按模块分段拿 ID,实例用雪花;名字系统把名字换成 id | 消息名 crc32、字符串名 crc64、连接 id 与请求 id 全部整数化;整数同时承担寻址与路由 |
| 万事皆事件 | 动作执行、数据改变都触发事件,事件驱动成长线 | xbus 的 listen + 定时器构成事件入口;业务模块在 listen 里等消息,不在循环里轮询 |
| 万人皆数据 | 普通怪 / 人形怪 / 地上的物品 / 随从 / 玩家收敛成"地图实体",差异只在配置 | 进程形态收敛成"目录 + main.lua + local_runcfg.lua",差异只在配置与最后那一行 run |
文章给的是数据建模 的抽象;x3 用到的是第一条,并把它变成运行期的寻址规则:
if player.datas[9000000] = 200001 这种"名字换成下标"的写法,和
listen(msgid, handler) 的"名字换成查表下标"是同一个动作,只是一个作用在数据上,
一个作用在消息上。
data-e 的 go/names.go 是这条思路最完整的一个实现:
| 规则 | 实现 |
|---|---|
| id 分段 | 高 8 位是类型号,低 24 位是该类型内的序号(typeIdBit = 8、maxNameOfType = 0x00FFFFFF) |
| 注册 | RegisterName(type, name, rawId):rawId == 0 时自增分配;非 0 用业务指定值,同类型内查重 |
| 反查 | GetIdByName / GetNameById / GetCfgById,走 nameCfgOfName 与 nameCfgOfId 两张 map |
| 名字自带元信息 | 每个名字登记重置周期 rsc、init / min / max、setFunc / getFunc ------ 名字就是一条变量声明 |
| 有序 id | 低 24 位范围(<= 0x00FFFFFF)属 OrderIDName 类型,不挂 set / get |
x3 在这条思路上做了三处选择:
- 消息 id 用
crc32(名字),不用中心分配。 任何进程自己算一遍就得到同一个值,
不需要把注册表同步到每个进程,也不存在"注册顺序不同、id 就不同";代价是不可反查 ------
所以xbus另外维护一张进程级 name 表,只在登记时写一次,日志与协议展示都查它。 - 字符串 id 走
xid_addname,双向表但仍是算出来的。id == 0时取crc64(名字),
非 0 时用业务指定的值;登记name2id与id2name两张表,同名不同 id、同 id 不同名
都记 error 并保留先来的那个。这样"名字 ↔ id"两向都能查,却不需要一个中心分配者。 - 分段 id 只用在能分段的地方。
insid是 16 bit(全簇唯一,同时当雪花机器号);
服务实例按名字排序后用consid % 候选数选;消息 id 本身不分段,因为 crc32 已经
均匀分布,取模就够。
这条思路落到 x3 里是一句话:名字是唯一事实,id 只是索引 。名字进日志、进协议文档;
id 进热路径、进路由表。
15.3 整体形态:ET 与其它服务端框架
ET( https://github.com/egametang/ET )是 Unity 客户端 + .NET 服务端的双端框架,也是 x3
在整体形态上最完整的一个参考点:按功能切进程、实体按 id 寻址、一套消息双端共用、
数据与逻辑分离,这四件事在 ET 里都已经成型。x3 取的是这四件事的分工,换掉的是它们
定下来的时机 ------ ET 把变化留在运行期(挂组件、查位置、重载 dll),x3 把变化挪到启动期
(选入口、算哈希、重启进程)。下面按"ET 的机制 → x3 里对应的东西 → 差别"列,
某一层的细节在对应的分层小节里展开。
| ET 的机制 | x3 的对应物 | 差别 |
|---|---|---|
| 组件化装配,1 变 N / N 变 1:每个服务端功能做成组件,按进程类型挂载;开发期全部挂在一个进程里单步调试,发布时换一份多进程配置就拆成 Login / Gate / Map... | x3.exe 是唯一宿主,xa_*/main.lua 是一副五行骨架、最后一行调哪个 run 就是挂了哪些模块;local_runcfg.lua 写本进程独有的配置(端口偏移、上限、超时);boot 的第二个参数 { net = false } 关掉它自带的网络初始化 |
ET 是同一进程里动态挂组件,所以"开发态一进程 = 发布态 N 进程";x3 是"一个目录一个进程",拆进程 = 建目录 + 抄那五行骨架 + 给不同命令行 |
Actor + MailBox + 位置服务器 :实体挂上 MailBoxComponent 即成为 Actor,谁拿到 entity id 都能发消息;不知道位置先问位置服务器(id → 进程) |
bus.enter(objid, obj) 绑定的对象拿到 dstid,send_to(dstid, ...) / call_to 位置透明 |
ET 的位置是查出来的 (位置服务有状态,要注册、要同步);x3 的位置是算出来的 (list[userid % #list]、consid % 候选数)。代价是实体不能随意迁徙,换来位置查询零成本、没有单点 |
| Fiber(纤程):ET8 把执行体细化到纤程,调度可选主线程 / 线程池 / 独占线程;纤程之间只走 Actor 消息 | xthread 托管的线程就是最小执行体,一个线程一个 lua_State;需要"同一个键固定一条线程"时用 xtpool_alloc(key) / send_by(consid) |
没有比线程更细的粒度:同一线程上的多个 owner 共享一份 lua_State 和同一份 tick;ET 一个线程可以跑很多纤程 |
数据与方法分离 :Entity 只有字段,逻辑写成 System 的扩展方法([EntitySystemOf] + Awake/Update),换 dll 就换逻辑 |
业务逻辑全在 Lua,xa_* 下没有一行 C++;模块只有"订阅 + 回调"两种形态 |
ET 靠 dll 热重载换逻辑;x3 靠 Lua 换逻辑,但运行期不 reload,改代码走重启进程(见 15.10) |
| 一套消息定义双端共用:Message 定义编进 Model 包,客户端与服务端引同一份,加一条消息只改一处 | common/proto/ 一份,服务端与 test-client 都 require;test-client 还直接复用 xgate 的打包 / 解包 |
ET 是 opcode 数字 + 序列化结构体(带代码生成);x3 是 crc32(消息名) + 一张 lua 表,没有生成步骤 |
| 按功能切进程 + 服务发现:Realm(选服)/ Gate / Map / Location / Router / Watcher 各是一个进程角色;ET9 起服务发现单独成包(注册、注销、查询、订阅、心跳、租约、变更通知) | xa_registry(只存地址)+ xa_sched(只做分配)+ xa_gate(只做接入)+ xa_app(业务),谁也不替别人记状态 |
x3 的注册中心是单实例(insid = 11),没有主节点选举,也不做变更订阅 |
| 机器人复用客户端逻辑:压测机器人直接跑客户端的逻辑层代码 | test-client 是独立 lua 程序,但走同一份 common/proto/ 与同一套 xgate_pack |
两边都让机器人走真实客户端路径;x3 这边的机器人只验证链路,不共享业务逻辑 |
| 配置表导表 / 行为树 / 预测回滚帧同步 / KCP + 软路由 | --- | x3 定位是服务端骨架:配置只到 local_runcfg.lua 这一层,传输是 TCP,同步模型是请求---回调 |
取的是分工,换的是可塑性的位置。 ET 让运行期可塑(挂组件、查位置、重载 dll),
x3 让启动期可塑(读清单、算哈希、重启进程)。x3 的运行期因此没有反射、没有位置服务,
也没有重载时的状态迁移;改结构要重启,实体不做迁徙。
整体形态上还有六个参考点。它们与 ET 面对同一组问题 ------ 连接怎么接、逻辑跑在哪台、
模块怎么装 ------ 各有各的答案,x3 分别取了一段:
| 框架 | 语言 | x3 参照到的做法 | x3 走到哪一步 |
|---|---|---|---|
| KBEngine | C++ + Python | Mailbox 只存实体 id、目的地地址、实体类型,脚本层 other.entity.method() 不用知道对方在哪个进程 ↦ send_to(dstid);LoginApp 只验号、回一个 BaseApp 地址让客户端自己连 ↦ xa_sched 发票据并给客户端口;一个账号登录后固定在一个 BaseApp ↦ 网关把会话钉在一个后端实例 |
实体分 base / cell 两部分,另有 Space、AOI 与实体迁移;x3 的业务进程不切空间,一台上跑全部场景逻辑 |
| Pomelo | Node.js | 前端(gate + connector)/ 后端分离:connector 只维护连接与 session,按路由把请求转给后端,后端处理完经 connector 回客户端 ↦ xa_gate + xa_app;master 负责拉起与监控全部进程 ↦ xa_registry + start_all |
前端 session 与后端 session 是两份,后端改 session 要 RPC 回写前端;x3 的会话就是那条连接加上钉住的实例 |
| Pitaya | Go | 前端 / 后端两类节点,路由串 serverType.component.method 决定投给哪台 ↦ add_msg(msgid, kind, appname);会话绑在前端,后端推消息要 PushToFront ↦ 回包必须带客户端在网关侧的连接 id |
服务发现依赖 etcd、RPC 依赖 NATS / gRPC;x3 的注册中心是自己起的第 11 号进程,RPC 走自己的总线与 xnet |
| NoahGameFrame | C++ | 插件是动态库,导出 DllStartPlugin 交给插件管理器装载 ↦ luaopen_* 交给 xlua::open_module;NFIModule 的 Init / AfterInit / Execute / BeforeShut / Shut 五段生命周期 ↦ post_main 预约初始化 + 退出链条;LuaScriptModule 允许用 Lua 写模块 ↦ 业务全在 Lua |
插件清单一本 Plugin.xml,模块之间用 FindModule<T>() 直接调接口;x3 的清单是 main.lua 五行骨架,模块之间只发消息。NF 支持 Lua 脚本热更,x3 改 Lua 走重启进程 |
| leaf | Go | 每个模块一条 goroutine,模块之间只走 ChanRPC,不直接调函数 ↦ 模块之间只发消息;OnInit 按注册顺序调用、OnDestroy 逆序 ↦ 有序初始化与有序退出;gate 只管连接与路由,业务在别的模块 ↦ xa_gate 只管接入 |
ChanRPC 有同步 / 异步 / Go 三种调用并支持返回值,x3 只有投递与 call 回调;leaf 的模块数量按"少而粗"设计,x3 按进程目录切 |
| breeze | C++ | "一切皆 service":toService() 把消息发给另一个 service,不关心对方在本机还是另一个 docker,slotting() 把消息类型绑到处理函数 ↦ bus.send_to + listen;服务之间的依赖写进配置,装载与卸载按依赖拓扑做 ↦ 模块清单在 main.lua、退出按固定链条;部署由 Lua 配置(bin/config.lua)指定哪个服务装在哪个 docker ↦ local_runcfg.lua + -insid / -endpoint |
service 在运行期按需创建与销毁(玩家下线就卸载对应的 service),x3 的进程与模块在启动期定形,运行期不再装卸 |
七个参考点拼起来是同一个形状:连接与逻辑分开、实体按 id 寻址、功能按模块装配 ,
x3 把这三件事都放到启动期定下来 ------ 进程形态来自目录名,消息 id 来自名字哈希,
线程归属来自 key。
15.4 身份与寻址:名字登记一次,运行期只剩 id
x3 的做法:消息名 crc32 成 4 字节 msgid;字符串名 crc64 成 64 位字符串 id 并登记
双向表;insid(16 bit 取值)是服务端 id,握手后对端的 insid 直接当连接 id(netid);
客户端连接另给一个雪花 id(同一个 64 位空间,靠取值范围与服务端 id 不重叠);bus.enter
绑定的对象给 dstid;call 给 reqid;线程有 id、定时器有 id;集群名进握手报文第一位,
于是"是不是一个集群的"也只在建链时比一次字符串,之后全按 id 走。
| 参考点 | 参照到的做法 |
|---|---|
| Erlang/OTP | atom 在加载期进全局 atom 表换成一个整数索引,此后比较与匹配都是整数;进程身份是 pid,跨节点也不变 |
| Erlang 的 pid / port / reference | 三种"身份"共处同一个 term 空间,可传递、可比较,但语义不同、不互相冒充 ------ x3 服务端连接(对端 insid)与客户端连接(雪花 id)也是同一 64 位空间里的两类 |
| Protocol Buffers / Thrift | 字段号在 IDL 里定死,线上只有编号与线型,名字只活在人读的 .proto 里 |
Unreal Engine FName |
字符串进全局 name pool 换一个 32 位索引,FName 之间的比较就是整数比较 |
| Redis Cluster | key → CRC16 slot(16384)→ node,三段全是整数,客户端只认 slot 不认地址 |
| Kafka | topic-partition 是整数,生产与消费都按分区号定位,顺序性也定义在分区上 |
| Snowflake ID | 一个 64 位整数里放"时间 + 机器 + 序号"三段,机器号保证多台机器各自生成的 id 不撞;x3 的客户端连接 id 是这一套,机器号放在最低 16 位、直接等于 insid(src/xid/xsnowflake.hpp:36 位 10ms 时间戳 + 11 位序号 + 16 位机器号) |
ygluu《万物皆ID》/ data-e idname |
名字注册得 uint32(高 8 位类型 + 低 24 位序号),并保留双向映射(见 15.2) |
差别:Erlang 的 atom 表、protobuf 的字段号、UE 的 name pool,id 都来自一次集中的登记
或者编译期生成。x3 用 crc32(名字),任何进程自己算一遍就得同一个值,不需要同步注册表,
也没有代码生成;代价是不可反查,所以 xbus 另存一张进程级名表,只在登记时写一次,
日志和协议展示查它。
15.5 执行体:actor 的寻址与隔离
寻址与隔离这一层,x3 参照的是 actor 模型给出的三条性质:
| actor 的性质 | x3 的对应 | 落地位置 |
|---|---|---|
| 实体只靠消息通信,不共享状态 | 模块之间不 include、不互相调用,只有 bus.send / send_to / send_by / call |
7.3;src/xbus/xbus.cpp 的分发路径 |
| 同一个实体的消息串行处理 | dstid 绑定到一条线程,同线程内按到达顺序执行,业务侧不加锁 |
xbus.enter + 线程队列(src/xthread/xthread.cpp) |
| 位置透明:拿到 id 就能发,不必知道对端在哪 | send_to(dstid) 不区分对端在本进程还是别的进程;跨进程由 xnet 按 netid 转 |
4.3;src/xnet/xnet_router.cpp 的 by_msg 表 |
执行体这一侧是线程:一条线程一个执行流,多个对象共享这条线程,按 key 定归属。这一层的
做法参照 Seastar 的 shard-per-core 与 muduo 的 one-loop-per-thread。
| 参考点 | 参照到的做法 |
|---|---|
| Actor 模型(Hewitt, 1973) | 消息通信、单实体串行、位置透明 |
| Seastar / ScyllaDB | shard-per-core:一核一个分片,按 key 哈希定分片,分片内无锁;跨分片只能发消息 |
| Microsoft Orleans | grain 激活是单线程的,同一个 grain 的调用天然串行;按 grain id 定位到 silo |
| LMAX Disruptor | single-writer principle:一个状态只有一个写者,读者按序号追赶,锁被设计掉而不是被优化掉 |
| Redis | 命令单线程执行,所有数据结构因此都不需要锁 |
| Node.js | 一个 event loop 跑完所有回调,业务代码里不出现锁 |
| skynet | 一个 lua 服务 = 一个执行体,服务内串行、服务间消息 |
| muduo | one loop per thread:一条连接固定由一条线程读写 |
15.6 通信:位置透明、一次请求一个 id、回调回到原线程
x3 的做法:bus.send / send_to / send_by / call 四个入口,本地模块与远端服务同一套
写法;call 生成 reqid,应答按 reqid 找回等待者,回调在执行 call 的那条线程上跑 ;
30 秒未回丢弃并告警。
| 参考点 | 参照到的做法 |
|---|---|
| Erlang | Pid ! Msg 本地与跨节点同一语法,收件人就是 pid,调用方不知道对端在哪台机器 |
| Akka | ActorRef 位置透明,tell / ask 对本机 actor 与远端 actor 完全一致 |
| ZeroMQ | 同一套 socket API,inproc:// / ipc:// / tcp:// 只换地址字符串 |
| gRPC / Thrift | 请求与响应靠 id(HTTP/2 stream id)配对,与 reqid 是同一个手法 |
| .NET SynchronizationContext | Post 把回调排回原线程的队列 ------ 与"结果回到发起线程的 lua_State"是同一手法 |
| Boost.Asio 的 strand / executor | 完成处理器按绑定的 executor 投递,保证同一 strand 内不并发执行 |
差别:没有 IDL、没有流式、没有背压;call 只有一问一答,超时固定 30 秒(不按调用配置),
也没有"取消"语义。
15.7 I/O 与多路复用:两个 event,两套后端
x3 的做法:读写分离模式下一个连接的读在 main、写在 bus,任何时刻只有一条线程碰它;
另有 net 线程做时间类的事(超时 / 释放 / 重连)。Linux 上 up / down 各是一个 epoll
实例,Windows 上各是一个完成端口;两个平台只在系统调用处分叉,状态机只有一套。
| 参考点 | 参照到的做法 |
|---|---|
| libuv | 在 epoll / kqueue / event ports / IOCP 之上统一事件循环;loop 绑定一条线程,loop 不是线程安全的 |
| Boost.Asio(Proactor) | 在 reactor(epoll / kqueue / select)之上模拟完成语义,Windows 用原生 IOCP、每个 io_context 一个完成端口 |
| nginx | 多 worker + epoll ET,一个 worker 一个事件循环;accept 的惊群处理与连接生命周期回收 |
| muduo / 《Linux 多线程服务端编程》 | "one loop per thread" 的线程归属:一条连接固定归属一条线程,不跨线程漂移 |
| Half-Sync/Half-Async(Schmidt,POSA 卷二) | 上层同步(lua 回调直接写业务)、下层异步(socket 事件驱动),中间是线程队列 ------ 正是 main(同步 lua)↔ bus(异步发送)↔ xnet(事件)三层的关系 |
| SEDA(Staged Event-Driven Architecture,分阶段事件驱动) | 阶段之间用队列解耦 + 每阶段有界预算:bus 与 net 各自推进,xtimer 用 k_budget / k_catchup 限制单 tick 的工作量 |
| epoll(7) | Linux 侧读用 ET、写用水平触发,data.u64 存 netid;rekey_conn 换了 id 之后要 rebind,否则该连接静默不再触发 |
| I/O Completion Ports | Windows 侧的完成端口与 ConnectEx / AcceptEx 路径;一个 socket 只关联一个端口,所以两方向的分离退化为同线程收尾 |
| 线程池轮询分发 | xtpool 的 rr_ 取模选线程;需要"同一个 key 固定同一条线程"时按 id 哈希,而不是轮询 |
差别:SEDA 的阶段可以动态加线程、有显式排队策略;x3 每个阶段的线程数是固定的,
队列只有"进 / 出"两种背压(满了丢包并计数),不做自适应。libuv 是"一条线程一个 loop"、
Asio 把"完成"抽象成 handler 并由 executor 投递,x3 是"一进程两个 event、分属两条
线程",把"就绪"直接暴露给业务回调。前两者的目标是"把平台差异藏起来",x3 的目标是
"让读和写各有一个 owner,且归属可算" ------ 一个是兼容性抽象,一个是性能抽象。
muduo 的一连接一线程在这里被再切一刀:读与写分属两个 owner,代价是跨线程那一步,
换来读侧不会被写阻塞。
15.8 模块:动态库 + 一张 C 表 + 一份语言绑定
x3 的做法:inc/x*.h 里一组 X*_API 的 C 函数(C++ 糖写在同一文件)+ src/x*/x*_lua.cpp
的 luaopen_*(一张 luaL_Reg 交给 xlua::open_module)。模块之间不互相 include。
| 参考点 | 参照到的做法 |
|---|---|
| Apache httpd | 模块就是一个 module 结构体加 register_hooks 钩子表,LoadModule 动态装载 |
| nginx | ngx_module_t 数组,模块自带 ctx 与命令表,编进数组即生效 |
| COM | 纯虚接口 + vtable,跨语言只认 ABI;用 IID 定位接口而不是名字 |
| Lua / Node.js 的 C 扩展 | 宿主提供稳定 C ABI,插件导出固定入口(luaopen_*、napi_register_module_v1) |
| 微内核(Mach / L4) | 内核只做 IPC 与调度,其余能力全在用户态服务里;对应宿主 x3 只链 xthread / xbus / xlog 三样 |
差别:一个模块同时导出两套入口(C API 与 lua 表),lua 侧看不到 C 结构体;而且模块之间没有
直接调用 ------ httpd 允许模块调 ap_*、nginx 允许调 ngx_*,这里只能发消息,
耦合比它们更松、跳数比它们更多。
15.9 装配与生命周期:约定优于配置,初始化与退出都有序
x3 的做法:进程 = 目录 + main.lua + run(boot);登记与订阅集中在 run 里
(regcli.start → bus.listen(H));C++ 侧每个编译单元在静态初始化里 post_main
预约一次 main 线程上的初始化;退出按固定链条走(7.5 / 7.6)。
| 参考点 | 参照到的做法 |
|---|---|
Go 的 init() 与 sql.Register |
包被 import 就完成注册,没有显式启动步骤 ------ 与"boot 之后一个 run 就把自己装好"同构 |
| Rails 的 Convention over Configuration | 目录名与文件名即语义,不写 XML 清单 |
| Spring Boot 自动装配 | classpath 扫描 + 条件装配,把显式声明压到最少 |
systemd 的 *.service 目录 |
文件放进去就生效,没有中心注册表要改;ExecStop 按依赖逆序执行 |
OpenResty 的 init_worker_by_lua |
每个 worker 起来时按脚本把自己那一套装好,不依赖外部编排 |
| Erlang application | 按启动的逆序停,先停入口再停依赖 |
| UE 的模块管理器 | 模块按 LoadingPhase 分批 StartupModule,关闭时按各模块启动完成的逆序 ShutdownModule,依赖方先起后停;x3 的初始化由各编译单元静态期 post_main 预约、main 线程按登记顺序补投,退出走固定链条 |
| nginx graceful shutdown | 先停止 accept,再 drain 存量连接,最后退出 |
差别:没有 DI 容器、没有注解、没有条件装配 ------ 装配顺序就是 main.lua 的那五行(Lua)
与静态初始化的投递顺序(C++),而且两者都不依赖这个顺序 。退出靠进程内信号而不是外部编排,
所以"进程被 kill -9"这条路径上什么都拿不到 ------ 这也是 stop_all 不用 taskkill /F
的原因。
15.10 脚本分层:Lua 主控、C/C++ 补位,一线程一个 VM
x3 的做法:每个被托管的线程一个 lua_State,业务回调只在自己线程的 VM 里跑,
lua_State 不跨线程;xa_* 下没有一行 C++,改业务不需要重新编译。
| 参考点 | 参照到的做法 |
|---|---|
| OpenResty | nginx(C)+ Lua 业务,一个 worker 一个 lua VM,VM 之间不共享 |
| skynet | C 底座 + lua 服务,服务间靠消息,业务不碰线程 |
| skynet 的 lualib | 服务启动时按脚本把自己那一套装好,对应 x3 的 run(boot) 里 bus.listen(H) |
| Redis Lua scripting | 单线程 VM 内跑脚本,C 侧提供 API,脚本不得阻塞 |
| Neovim / WoW UI | 宿主提供 C API,插件全用 lua 写,改插件不用重编宿主 |
| Lua 5.4 参考手册(C API) | 每线程 luaL_newstate + 独立 GC;luaL_ref / luaL_unref 存回调;lua_pcall 隔离业务错误(xlua::pcall);userdata + metatable 做句柄与 luaL_testudata 校验 |
| Programming in Lua(第 4 版) | lua 侧模块组织:require + 模块表 + package.path;boot.lua 的 add_path 挂 ?.lua 与 ?/init.lua 就是这里的约定 |
Lua 5.4 源码(3rd/lua) |
编成 bin/liblua 静态链接进每个 x*_lua 模块;lua_State 不跨线程共享,所以不需要给 lua 加锁 |
差别:RPC 一律写成回调 ------ 结果从另一条线程回来,而 lua_State 不能跨线程碰;skynet 用
coroutine 把 RPC 写成像同步调用。lua 代码的更新走重启进程。
15.11 部署:目录 / 调度 / 接入三个角色,会话钉住
x3 的做法:registry 只存地址、sched 只做分配、gate 只做接入并把一条客户端连接
钉在一个后端实例上;分配时 sched 生成 ticket 与 32 字节 master 推给网关,首包校验
票据后由 HKDF-SHA256 派生上行 / 下行两把密钥,之后 chacha20-poly1305 加密、LZ4 按块压缩。
| 参考点 | 参照到的做法 |
|---|---|
| Consul + Nomad / Kubernetes | 服务目录(存地址与健康)与调度器(做决策)分离,谁也不替对方记状态 |
Agones 的 GameServerAllocation |
一次分配原子地挑一台 Ready 实例、标成 Allocated,把地址与端口回给分配方,之后客户端直连那个地址;分出去的实例不被缩容或滚动更新回收,直到进程自己结束 ↦ xa_sched 挑网关、发票据、把网关端口给客户端,会话由网关钉住 |
| Envoy / HAProxy / nginx | 一致性哈希、stick-table、ip_hash 做会话亲和;upstream 掉线按策略处理,不静默改写目标 |
| Kerberos / JWT / 各家 session ticket | 一次票据换会话凭据,之后每包不再带账号密码 |
| TLS 1.3 / WireGuard | 握手后按方向派生密钥,两个方向各自独立的 nonce 序列 |
差别:票据是自定义 msgpack 包,不是标准 token;没有证书链、没有 mTLS,master 目前是
math.random 拼的(见第 14 节),换随机源的改动范围限于 gen_master()。doc/03 那套
限速在设计上只做丢弃、不做排队;断线权限留在应用服。
15.12 观测:线程名、统计接口、按序收尾
x3 的做法:xlog 异步落盘,线程可命名(xlog_set_thread_name),日志行头直接看出是
谁在说话;cluster/stat 起一条统计线程,各模块实现 x*_get_stats(T*, int max) 供它采集;
模块退出前把最后一条日志写完再 flush。
| 参考点 | 参照到的做法 |
|---|---|
| Prometheus 的 collector | 每个子系统实现一个 collect 接口、采集器统一拉取 ------ 对应各模块的 x*_get_stats |
| log4j MDC | 每线程一份上下文(这里是线程名),日志自动带上 |
| Erlang application / systemd | 按依赖逆序停止,先通知再收尾 |
| epoll(7) 的调试手段 | top -H 看到的线程号与日志行头的线程号是同一个数字(plat::thread_id),排障时不需要换算 |
差别:没有健康检查端点,也没有 metrics 文本格式导出,cluster/stat 只把统计写进日志;
停止靠进程内信号而不是外部编排。
15.13 x3 实际使用的第三方库
3rd/ 下只有这四个,都编进了产物,不是备选清单。
| 库 | 链接 | 对应的 x3 技术点 |
|---|---|---|
| Lua 5.4 | https://www.lua.org/ ;源码见 3rd/lua |
编成 bin/liblua,静态链接进每个 x*_lua 模块;线程与 API 细节见 15.10 |
| msgpack-c | https://github.com/msgpack/msgpack-c | xbus 的业务消息体编码;xgate 握手包的 array(5) 与 [{msgid,msgname}] 服务表;xendian.h 里大端的 msgpack 头读写 |
| LZ4 | https://github.com/lz4/lz4 | xgate 只编 lib/lz4.c(块格式)做包体压缩,不用 lz4frame / lz4hc |
| Mbed TLS | https://github.com/Mbed-TLS/mbedtls | xgate 只编 6 个 .c:chacha20 / poly1305 / chachapoly 做包加密,sha256 做 HKDF,platform_util / constant_time 做支撑;不编 TLS、X.509、PSA |
15.14 收敛点
上面十三节可以压成三条,每一条都能指回一个具体的参考点:
- 身份定死 ------ pid、grain id、slot、
FName、crc32(名字)是同一件事在不同参考点里的
名字:"名字算一次,之后只认整数"。整数才可比较、可取模、可入路由表。 - 写者唯一 ------ shard、grain activation、single-writer、按 key 定线程,说的是同一句:
"同一份状态只有一条执行流能碰",实现里因此没有锁。 - 只走消息 ------
!、tell、send、bus.send_to,都是跨执行体不共享内存。
x3 把这三条压在同一层:线程 。一条线程一个执行流,多个对象按 key 共享同一条;
长任务会拖住同线程的其它模块,单进程承载量由线程数决定。这条边界贯穿全文,
也是第 14 节"已知限制"的来源。
附:设计文档
doc/ 下四份是这套代码的设计记录:
| 文档 | 内容 |
|---|---|
doc/01-协议设计.txt |
消息编号、包头、目标数组、兼容策略 |
doc/02-网络组件设计.txt |
xnet 三层职责、up / down 两个 event 的读写归属、连接生命周期 |
doc/03-网关CD算法设计.txt |
网关消息级限速(CD)的毫秒信用桶算法:可累积、有上限、容差只偶发生效 |
doc/C-C++编码规范.txt |
命名、缩进、日志、错误处理 |
许可
Apache License 2.0,见仓库根目录的 LICENSE。