Skip to content

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:

lua
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的输入类型;另有缺洞、非函数值、命名键在启动任务前被拒绝与空任务序列成功的回归断言。本轮仅阅读和补写断言,未执行。

lua
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 M
lua
local 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后下降的估计解释成进程立即归还物理内存。

练习与参考解答 ​

  1. 一个table已有name键,加__newindex是否能阻止修改?写反例。
  2. 任务1在第二次resume抛错,任务2仍挂起且持有<close>资源;应该发生什么?补一个测试。
  3. 把协议扩展为yield("sleep", ticks),说明为什么当前调度器还不支持真实时间。
参考解答
  1. 不能。local t=setmetatable({name="a"},{__newindex=error}); t.name="b"直接修改已有字段。用空代理及隐藏存储,并明确只读深度。
  2. run返回nil及首错,两协程均close,清理标记为true。补任务1先yield再error、任务2每轮yield,测试它的close标记,不把资源回收留给GC。若close自己失败,生产版应另返回清理错误列表。
  3. 新协议须验证ticks为非负整数、记录唤醒逻辑时钟、无就绪任务时推进时钟。真实时间还需要宿主定时器/事件循环和单调时钟;忙循环检查时间会浪费CPU,不能把轮数当毫秒。

来源与原资料处理 ​

2026-10-17核对Lua5.5官方手册的元表、环境、协程及C API部分;国内核读云风关于to-be-closed特性的开发期分析,以作者对资源清理问题的原始讨论帮助理解动机。该分析写于开发期,最终语法及保证仍以5.5规范和本项目实测为准,不把旧提案当当前规范。南京大学公开的中文Lua PDF本次抓取超时,不算已核读资料。原日志课程保留作为table序列、闭包、require和文件所有权起点;本包baseline没有其他Lua旧文,不虚构逐篇替换。

继续阅读