第 3 章 / 共 51 章
卷一 · 起步与心智模型

心智模型:代、租约与零包装透传

这是全书最重要的一章。XServer 的所有行为——热重载为什么安全、旧连接为什么不断、回调里什么不能做——都能从本章的三个概念推导出来。

本章导读

站位:不写业务代码,只装"概念操作系统"。读完的标准:合上书能向同事讲清"改代码的瞬间,正在处理的请求发生了什么"。这一章解释了第 2 章练习里留下的悬念——重启后为什么会出现 gen 2。

3.1 引入:三个疑问

第一次接触"运行时编译 C"的人,通常会问出三个直击要害的问题:

  • 我的代码什么时候变成机器码?——启动时和每次热重载时,由内嵌 TCC 编译为原生代码,之后的执行速度与你预编译的 C 一致(routebench 实测与 Go 同档)。
  • 谁拥有连接、监听、定时器这些资源?——宿主拥有。脚本只在回调里"借用"它们。
  • 改代码的瞬间,正在处理的请求怎么办?——新旧两代代码并存,老请求由老代码服务到结束,这就是本章的主角。

第 2 章日志里的 script loaded ... gen 1 已经暴露了答案的入口:代(generation)。下面把它讲透。

3.2 三个概念

概念一:generation(代)

每完成一次脚本装载——无论是启动还是热重载——就产生一个新代。一代 = 一份完整的脚本运行时:编译产物、静态变量、ServiceInit 建立的一切。所以重启后看到 gen 2 并不奇怪:代的编号在进程内单调递增,重启只是"没有旧代在场的一次换代"。

监听槽位(ListenerSlot) —— 端口 9080 上的稳定入口 │ slot 的 pData 永远指向"当前代" ▼ ┌──────────────┐ 新连接进入 │ 当前代 gen2 │ ◀───────────────── └──────────────┘ ▲ 切换(原子) ┌──────────────┐ 存量连接继续 │ 旧代 gen1 │ ◀───────────────── └──────────────┘ │ 最后一个连接离开(引用归零) ▼ GC 线程:安全析构脚本 → 驱动 → 配置

概念二:lease(租约)

每个进入处理的请求 / 连接 / 定时器,都会对"它开始工作时所在的那一代"取一个租约(引用)。租约保证了:只要还有工作在旧代上未完成,旧代的代码与数据就不会被释放。这就是"改代码不断线"的机制本质——不是延迟销毁的技巧,而是引用计数的语义保证。

概念三:引用归零回收

旧代的引用归零后,由专职的 GC 线程按固定顺序析构:ServiceUnit(脚本清理)→ TCC 状态 → 驱动运行时 → 配置树。XServer 的测试套件里有专门的三循环 8080↔8081 换代实测:全部旧代回收,零泄漏、零崩溃。你不需要写任何 GC 代码——但你需要知道它存在,才能理解第 28 章的状态交接为什么那样设计。

三个概念合成一句话:代是隔离单位,租约是保护机制,引用归零是回收时机。后面 40 章遇到任何生命周期问题,先回到这句话。

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 里做长阻塞
症状
启动慢、热重载期间新请求排队。
原因
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 延伸阅读