Lua 元表、协程调度协议与宿主边界
前置是table与闭包模块。本篇用Lua5.5.1独立解释器,代码中的<close>需要Lua5.4以上,宿主内置的5.1或LuaJIT不能直接复制。目标是设计协作式调度器的消息协议、清理规则和宿主调用边界,而不是把协程当成操作系统线程。
元表如何接管操作
table保存数据,metatable定义某些操作未直接完成时的回退。__index仅在原表没有该键时触发;已有键的读取不会通过它。__newindex也只拦截原表不存在的键,因此给有字段的table加一个报错方法并不能实现通用只读。rawget、rawset跳过对应元方法,适合实现协议底层,不能向不信任脚本开放后再声称只读。
下面是完整程序,可保存proxy.lua并执行lua proxy.lua,输出Ada后false:
local data = {name="Ada"}
local proxy = setmetatable({}, {
__index=data,
__newindex=function() error("read only") end,
__metatable="locked"
})
print(proxy.name)
print((pcall(function() proxy.name="Eve" end)))代理必须为空,真实数据由闭包持有;若data内部含table,返回的嵌套对象仍可修改,所以这只是浅只读。__metatable阻止普通代码替换元表,不是抵御debug库的安全隔离。元方法中再次访问自身缺失键会递归,可用rawget或独立后备表避免。
__close是词法资源清理协议,__gc是垃圾回收终结协议。不能依靠GC时机及时关闭文件。调度器取消挂起协程时主动coroutine.close,使其中<close>变量执行清理;错误结束的协程同样需要close,不能仅看到dead就跳过。清理函数自身报错时应记录并按宿主策略处理;本示例保留首个任务错误,close的返回错误不覆盖它。
resume/yield是双向消息协议
第一次resume(co,args...)把参数传入协程主函数;后续resume参数成为上一个yield的返回值。resume的第一个返回值是执行成功标记;只有它为true时,后续值才是yield或最终返回结果。状态suspended包括尚未启动和yield挂起,dead包括正常返回及出错。
本项目只允许挂起时返回字符串again,表示把执行权让给下一任务;最终返回一个结果。调度器每轮按输入顺序最多resume一次,完成任务不再调度,首次错误停止并关闭其余任务。总resume次数预算防止“不断主动让出却永不完成”;预算不是CPU时间限制,回调若进入不yield的无限循环,整个解释器仍会卡住。
因此输入是受信任的密集函数序列,回调必须遵守主动yield约定。run 在创建任何协程前检查正整数索引、函数类型以及索引数量等于最大索引,拒绝缺洞或附加命名键;否则 ipairs 会在首个 nil 处停止,后面的任务被静默丢弃。外部数据先转换为任务描述,不能把任意缺洞table当队列。若要支持真正不可信代码,应该在独立进程施加内存、CPU和权限限制;单纯删除os不提供计算资源隔离。
完整轮转调度模块与测试
examples/lua/scheduler/只有两个文件,无包依赖。在该目录执行lua test.lua,Windows PowerShell可用& 'lua.exe完整路径' test.lua。预期 scheduler: all contracts passed(实际行首无空格)、退出0。测试明确验证a1,b1,a2顺序、结果、正常及取消时关闭、错误协议、预算耗尽、宿主环境缺能力以及evaluate的输入类型;另有缺洞、非函数值、命名键在启动任务前被拒绝与空任务序列成功的回归断言。本轮仅阅读和补写断言,未执行。
local M = {}
function M.run(functions, budget)
assert(math.type(budget)=="integer" and budget>0, "positive budget")
assert(type(functions)=="table", "dense function sequence required")
local count, maximum = 0, 0
for i,f in pairs(functions) do
assert(math.type(i)=="integer" and i>0 and type(f)=="function",
"dense function sequence required")
count=count+1; maximum=math.max(maximum,i)
end
assert(count==maximum, "dense function sequence required")
local threads, results = {}, {}
for i,f in ipairs(functions) do threads[i]=coroutine.create(f) end
local function abort(message)
for _,co in ipairs(threads) do
coroutine.close(co)
end
return nil,message
end
local remaining=#threads
while remaining>0 do
for i,co in ipairs(threads) do
if coroutine.status(co)~="dead" then
if budget==0 then return abort("budget exhausted") end
budget=budget-1
local ok,value=coroutine.resume(co)
if not ok then return abort("task failed: "..tostring(value)) end
if coroutine.status(co)=="dead" then
results[i]=value; remaining=remaining-1
elseif value~="again" then return abort("protocol: expected again") end
end
end
end
return results
end
function M.evaluate(source, value)
assert(type(source)=="string" and #source<=4096,"source bound")
assert(type(value)=="number","number boundary")
local chunk,err=load(source,"config","t",{value=value})
assert(chunk,err)
local ok,result=pcall(chunk)
assert(ok,result)
assert(type(result)=="number","numeric result required")
return result
end
return Mlocal s = require("scheduler")
local started = false
local function task() started=true end
for _, invalid in ipairs({{[2]=task}, {task, false}, {task, label=task}}) do
assert(not pcall(s.run, invalid, 10))
end
assert(not started)
local empty, empty_error = s.run({}, 10)
assert(type(empty)=="table" and next(empty)==nil and empty_error==nil)
local order, closed = {}, false
local result, err = s.run({function()
local guard <close> = setmetatable({}, {__close=function() closed=true end})
order[#order+1]="a1"; coroutine.yield("again")
order[#order+1]="a2"; return 7
end, function() order[#order+1]="b1"; return 9 end}, 10)
assert(result and not err and result[1]==7 and result[2]==9)
assert(table.concat(order,",")=="a1,b1,a2" and closed)
result,err=s.run({function() coroutine.yield("unknown") end},10)
assert(not result and err:match("protocol"))
closed=false
result,err=s.run({function()
local guard <close> = setmetatable({}, {__close=function() closed=true end})
while true do coroutine.yield("again") end
end},2)
assert(not result and err:match("budget") and closed)
closed=false
result,err=s.run({function()
local guard <close> = setmetatable({}, {__close=function() closed=true end})
coroutine.yield("again"); error("broken")
end},10)
assert(not result and err:match("broken") and closed)
assert(s.evaluate("return value*2", 6)==12)
assert(not pcall(s.evaluate,"return os.execute('echo bad')", 6))
assert(not pcall(s.evaluate,"return value", {}))
print("scheduler: all contracts passed")若module 'scheduler' not found,先确认当前目录和package.path,不要把文件拷入系统全局模块目录来掩盖路径问题。若attempt to yield from outside a coroutine,检查回调是否通过调度器启动。若协议错误,检查yield的值而不只是错误字符串。
load环境与嵌入宿主
evaluate以load(source,"config","t",{value=value})只接受文本chunk并提供新环境,没有os、io、require、debug或_G;仅跨边界传入和返回数字,防止泄漏可变宿主table。源文本上限4096字节,语法失败和执行失败分别检查,数值结果再验证。它用于可信配置的能力收缩演示,不是安全执行任意用户代码的沙箱,不能限制无限循环或内存分配。
嵌入C时需要同一Lua版本的头文件与库、相同架构、兼容编译选项,不能把5.5头文件和5.1动态库混用。基本调用栈契约如下:
| 操作 | 调用者责任 |
|---|---|
lua_gettop | 记录入口栈顶,定义退出时要留下的返回值数量 |
luaL_loadbufferx(...,"t") | 检查编译状态;成功栈顶是函数,失败是错误对象 |
lua_pcall | 用保护调用承接Lua错误,成功检查结果类型再转换 |
lua_tolstring | 长度单独取出;字符串可含NUL,指针不能超过对象有效期保留 |
lua_settop | 在明确归属的栈帧恢复栈,不误删调用者值 |
lua_close | 宿主结束状态生命周期,之后所有关联指针/registry引用失效 |
普通C API调用不能随意跨C++含析构对象的栈帧longjmp;使用宿主项目支持的异常/编译配置并通过保护边界处理。此处是栈协议说明,不伪装成已经编译的C嵌入项目。真实可运行宿主是本机Lua解释器及模块中的load/pcall调用。
GC、闭包与测量
闭包捕获upvalue,table或协程被保留时,关联大对象可能持续存活。弱表适合不强制保留对象的缓存,不适合作为必须可靠执行的任务队列。collectgarbage("count")给Lua管理内存的KiB估计,不等于进程RSS;C宿主的外部分配也可能不在其中。测量前固定任务数及每个任务的yield次数,先检查结果,再记录os.clock与内存估计,说明GC模式及运行轮次。不要把强制GC后下降的估计解释成进程立即归还物理内存。
练习与参考解答
- 一个table已有
name键,加__newindex是否能阻止修改?写反例。 - 任务1在第二次resume抛错,任务2仍挂起且持有
<close>资源;应该发生什么?补一个测试。 - 把协议扩展为
yield("sleep", ticks),说明为什么当前调度器还不支持真实时间。
参考解答
- 不能。
local t=setmetatable({name="a"},{__newindex=error}); t.name="b"直接修改已有字段。用空代理及隐藏存储,并明确只读深度。 - run返回nil及首错,两协程均close,清理标记为true。补任务1先yield再error、任务2每轮yield,测试它的close标记,不把资源回收留给GC。若close自己失败,生产版应另返回清理错误列表。
- 新协议须验证ticks为非负整数、记录唤醒逻辑时钟、无就绪任务时推进时钟。真实时间还需要宿主定时器/事件循环和单调时钟;忙循环检查时间会浪费CPU,不能把轮数当毫秒。
来源与原资料处理
2026-10-17核对Lua5.5官方手册的元表、环境、协程及C API部分;国内核读云风关于to-be-closed特性的开发期分析,以作者对资源清理问题的原始讨论帮助理解动机。该分析写于开发期,最终语法及保证仍以5.5规范和本项目实测为准,不把旧提案当当前规范。南京大学公开的中文Lua PDF本次抓取超时,不算已核读资料。原日志课程保留作为table序列、闭包、require和文件所有权起点;本包baseline没有其他Lua旧文,不虚构逐篇替换。
继续阅读
- 排错
- 开发实践
- 开发实践