用 Lua 协程实现可读的异步状态机
Lua 5.4 的协程是协作式的:运行中的协程主动 yield,调度者之后再 resume。它不会自动提供线程、事件循环、网络 I/O 或抢占式调度。示例使用 Lua 5.4.8 和 5.4 系列稳定的 coroutine 接口。恰当地把“等待什么”作为 yield 值,线性的函数就能表达原本散落在回调里的状态机。
让协程产生意图
业务流程不应直接知道定时器或 socket 实现。它只产出一个描述等待条件的表,调度器负责解释:
local function wait_seconds(seconds)
return coroutine.yield({ kind = "sleep", seconds = seconds })
end
local function workflow(order)
order.state = "validating"
wait_seconds(0.1)
if not order.valid then
return nil, "invalid order"
end
order.state = "submitting"
local receipt = coroutine.yield({
kind = "request",
method = "POST",
path = "/orders"
})
order.state = "completed"
return receipt
end
workflow 看起来像顺序代码,但每个 yield 都是明确状态边界。调度器首次 resume 时传入普通函数参数;后续 resume(thread, value) 的值会成为上一次 yield 的返回值。因此网络响应、超时或模拟结果都能从调度器注入,而业务函数无需调用真实网络。
调度器必须检查所有返回值
coroutine.resume 不会把协程内部错误直接抛给调用者,而是返回 false, error。忽略第一个返回值会把错误对象误当成业务结果。一个最小驱动器应区分失败、挂起和结束:
local function step(task, input)
local values = table.pack(coroutine.resume(task.thread, input))
if not values[1] then
task.state = "failed"
task.error = values[2]
return
end
if coroutine.status(task.thread) == "dead" then
task.state = "finished"
task.result = values[2]
else
task.state = "waiting"
task.waiting_for = values[2]
end
end
真实调度器还要保存多个返回值、追加 traceback,并验证 yield 出来的意图。未知 kind 应失败,而不是永久挂起。等待表来自业务代码也不意味着完全可信:插件或配置脚本可能生成巨大延时、未授权路径或错误参数,宿主必须限制它们。
取消需要显式协议
协程没有自动的父子取消语义。把任务从队列删除只会令它再也不被恢复,to-be-closed 变量和清理代码也可能没有机会执行。Lua 5.4 提供 coroutine.close,宿主可以关闭挂起或错误协程并处理关闭结果,但业务仍需要定义何时取消、资源由谁所有以及外部请求能否撤回。
一种简单规则是:任务记录持有取消标记;每次恢复前检查;调度器注销计时器和 I/O 监听;最后关闭协程。已经发生的外部副作用不会随协程关闭而回滚,所以请求要有幂等标识,或在流程层设计补偿动作。不要把协程当作事务。
区分预期结果与程序错误
“订单无效”是普通返回结果,“访问 nil”是程序错误,两者不应塞进同一个字符串通道。流程可以返回 nil, domain_error 表达预期失败;意外错误由 resume 的 false 分支捕获并加上下文。日志至少记录任务标识、当前等待意图、最后恢复位置和错误 traceback,但不能泄露请求中的密钥。
嵌套协程还需决定错误归属。若父任务启动子任务,调度器应记录父子关系,并在父任务取消时明确处理孩子。没有这个模型,“后台任务”会变成无法监控也无法关闭的孤儿。
用虚拟时间做确定性测试
测试中不要真的等待 100 毫秒。调用 step 后断言任务产出 {kind="sleep"},推进虚拟时钟,再用假响应恢复。覆盖正常完成、领域失败、未知意图、恢复 dead 协程、取消和资源清理。协程的优势正是每个 yield 都暴露了可观察检查点;如果测试仍依赖真实网络,说明调度与业务没有真正分离。
结论
Lua 协程能让异步状态机保持线性可读,但完整系统还需要调度、验证、错误、取消和资源所有权。让协程只产出等待意图,让宿主执行受控副作用,并用虚拟时间驱动测试。这样 coroutine 是清楚的流程表达工具,而不是隐藏生命周期问题的魔法异步层。