心智模型:代、租约与零包装透传
这是全书最重要的一章。XServer 的所有行为——热重载为什么安全、旧连接为什么不断、回调里什么不能做——都能从本章的三个概念推导出来。
本章导读
gen 2。
3.1 引入:三个疑问
第一次接触"运行时编译 C"的人,通常会问出三个直击要害的问题:
- 我的代码什么时候变成机器码?——启动时和每次热重载时,由内嵌 TCC 编译为原生代码,之后的执行速度与你预编译的 C 一致(routebench 实测与 Go 同档)。
- 谁拥有连接、监听、定时器这些资源?——宿主拥有。脚本只在回调里"借用"它们。
- 改代码的瞬间,正在处理的请求怎么办?——新旧两代代码并存,老请求由老代码服务到结束,这就是本章的主角。
第 2 章日志里的 script loaded ... gen 1 已经暴露了答案的入口:代(generation)。下面把它讲透。
3.2 三个概念
概念一:generation(代)
每完成一次脚本装载——无论是启动还是热重载——就产生一个新代。一代 = 一份完整的脚本运行时:编译产物、静态变量、ServiceInit 建立的一切。所以重启后看到 gen 2 并不奇怪:代的编号在进程内单调递增,重启只是"没有旧代在场的一次换代"。
概念二:lease(租约)
每个进入处理的请求 / 连接 / 定时器,都会对"它开始工作时所在的那一代"取一个租约(引用)。租约保证了:只要还有工作在旧代上未完成,旧代的代码与数据就不会被释放。这就是"改代码不断线"的机制本质——不是延迟销毁的技巧,而是引用计数的语义保证。
概念三:引用归零回收
旧代的引用归零后,由专职的 GC 线程按固定顺序析构:ServiceUnit(脚本清理)→ TCC 状态 → 驱动运行时 → 配置树。XServer 的测试套件里有专门的三循环 8080↔8081 换代实测:全部旧代回收,零泄漏、零崩溃。你不需要写任何 GC 代码——但你需要知道它存在,才能理解第 28 章的状态交接为什么那样设计。
3.3 分工与透传
宿主管什么,脚本管什么
| 宿主(XServer) | 脚本(你的 C) |
|---|---|
| 读 xs.json、装配服务与监听 | 业务逻辑、路由表、数据处理 |
| 协议解析(HTTP/WS 帧、TLS 记录) | 用解析结果构造响应 |
| 连接与监听的生命周期、GC | 静态状态(按代隔离)、ServiceInit/Swap/Unit |
| 热重载的编译、发布、回滚 | 导出契约符号,提交重载意图 |
零包装透传
多数框架会把连接、请求包装成框架对象,你在框架的抽象里工作。XServer 刻意不包装:回调拿到的 XS_HttpReq 是一组 xrt 原生指针的视图——传输层裸指针、解析后的请求头、分帧状态机。你可以直接调用 xrt 的 3,030 个 API 操作它们,没有隐藏层、没有适配损耗。
| 传统框架包装 | XServer 视图透传 |
|---|---|
| req.headers() 封装取值 | pReq->head 直接是 xrt 解析结构 |
| res.send() 走框架管线 | xrtHttp1ResponseWrite + 流发送,你掌握每一步 |
| 对象生命周期归框架 | 视图仅在回调内有效,所有权规则显式 |
"零包装"是自由也是责任:视图不能存到回调之外(见常见错误①),但也没有框架魔法挡在你和协议之间。
3.4 示例:看见代
代不需要特殊工具观察——启动日志本身就是证据。回顾第 2 章的实机输出:
[xs] engine started, workers=12
[xs] script loaded: .../main.c (2.6 KB gen 1) /* ← 第一代诞生:编译 2.6KB 源码,编号 1 */
[demo] database opened: .../db\main.db /* ← gen1 的 ServiceInit 在跑 */
刚才发生了什么:TCC 把 2.6KB 源码编译为原生代码,创建"第 1 代"脚本运行时;随后 [demo] 前缀的日志证明应用自己的 ServiceInit 已在这代上执行。当你在第 2 章练习里修改 route.h 并重启,日志变成 gen 2——一个新代诞生,旧进程退出,没有旧代需要回收。而热重载(卷四)做的是同一件事,但旧代还在服务:新代发布后,旧代等自己的最后一个租约归还。
3.5 契约
- 线程线:脚本回调在引擎 worker 线程执行(workers=12 意味着回调可能并发);ServiceInit / ServiceSwap 在装配 / reload 控制线程;ServiceUnit 在 GC 线程。跨线程共享状态要自己负责同步。
- 所有权线:回调参数里的视图(XS_HttpReq、头指针、消息视图)只在回调栈内有效;想保存先拷贝。资源(连接、监听、定时器)归宿主,脚本只借用。
- 生命周期线:退役后的代不再接受新请求;静态变量按代隔离——换代即全新的静态状态,跨代传递必须走 ServiceSwap(第 28 章)。
常见错误
- 症状
- 偶发崩溃 / 数据错乱,尤其在高并发或热重载之后。
- 原因
pReq->head等视图只在回调栈内有效;回调返回后缓冲可能被复用,换代后旧代内存会被回收。
static xhttp1head* G_Head;
XS_RequestResult RequestProc(XS_HttpReq* pReq)
{
G_Head = pReq->head; /* 回调外再读 → UAF *//* 在回调内取走需要的值 */
const char* sUA = ...; /* 拷贝字符串 */
G_iCount++; /* 值可以带出,指针不行 */- 症状
- 把不可信来源的代码放进 devfile,或假设脚本的崩溃 / 越界不会影响宿主。
- 原因
- 脚本是同进程的原生代码:与宿主共享地址空间与权限,能调用全部 3,030 个 API 与系统库。没有沙箱是有意的设计。
/* "脚本崩了宿主还能活" —— 不成立
* 野指针一样打穿整个进程 *//* devfile 只放可信运维维护的代码;
* 需要隔离的能力放到独立进程 */- 症状
- 启动慢、热重载期间新请求排队。
- 原因
- ServiceInit 在装配 / reload 控制线程上串行执行;新代必须 Init 完成才对外发布。
void ServiceInit(XS_HostInfo* pHost)
{
BigIndexLoad(); /* 阻塞 8 秒 *//* 轻初始化 + 首请求惰性加载,
* 或后台线程构建、就绪位发布 */编程练习
运行 demo,在日志里标出:哪一行宣告了代的诞生?哪一行证明 ServiceInit 在这代上执行?workers 数是多少、意味着回调的并发度上限是什么量级?
需求:统计当前活跃连接数并在 /stats 输出。写出:计数的静态变量放哪?何时加何时减(提示:WsOpen/WsClose 或 EventOpen/EventClose)?热重载后计数如何不断(提示:ServiceSwap 导出什么)?
不看书画出:10 个并发请求进行中,提交热重载,从"新代编译"到"旧代析构"的完整时序图,标出每个请求分别由哪一代服务、租约何时归还、GC 何时动手。
小结
| 要点 | 结论 |
|---|---|
| 代 generation | 每次脚本装载产生一代;静态变量按代隔离 |
| 租约 lease | 请求 / 连接 / 定时器持有当前代引用,保护其不被释放 |
| 引用归零回收 | GC 线程按序析构:ServiceUnit → TCC → 驱动 → 配置 |
| 分工 | 宿主管装配 / 解析 / 生命周期;脚本管业务与状态 |
| 零包装透传 | 回调收到 xrt 原生视图;值可带出回调,指针不行 |
- xrt 教程 · 内存与所有权 — 租约背后的引用计数基础设施
- xrt 教程 · 并发卷导言 — workers=12 背后的并发模型