Lua 5.1 协程完全教程
在 Lua 5.1 中,协程是一种强大的控制流机制。你可以把它理解为一种“可以暂停和恢复的函数”。与操作系统的线程不同,Lua 的协程是协作式多任务的,这意味着它们不会自动抢占执行权,必须由代码显式地交出控制权(挂起)和重新获取控制权(恢复)。
Lua 5.1 的协程常被用来实现状态机、迭代器以及异步非阻塞 I/O(如 OpenResty / luarocks 中的某些库)。
在了解 API 之前,必须先弄懂协程的四种状态:
yield 挂起时的状态,随时可以恢复。Lua 5.1 协程相关的函数都在 coroutine 表中。
coroutine.create(f)创建一个协程。参数 f
是一个函数。返回一个协程对象(thread 类型)。 注意:刚创建的协程处于
suspended 状态。
coroutine.resume(co, ...)启动或恢复协程的执行。
f 的开头开始执行,...
作为 f 的参数传入。yield
的地方继续执行,... 作为 yield
的返回值传入。true,后面的返回值是协程内 yield
传出的参数或函数执行结束的返回值;如果协程内部发生错误,第一个返回值是
false,第二个返回值是错误信息。coroutine.yield(...)挂起当前正在执行的协程,将控制权交还给调用 resume
的地方。
... 中的参数会作为对应 resume 的返回值(在
true 之后)传出。resume 时,yield
会返回,返回值是这次 resume 传入的额外参数。coroutine.status(co)返回协程当前的状态字符串:"running",
"suspended", "normal",
"dead"。
coroutine.wrap(f)像 create
一样创建协程,但返回的不是协程对象,而是一个函数。
每次调用这个函数时,就相当于调用了 resume。 注意:使用
wrap 时,如果协程内部出错,错误会直接向外抛出,不会像
resume 那样返回 false, err。
coroutine.running()返回当前正在运行的协程对象。如果在主线程中调用,返回
nil(Lua 5.1 中不返回布尔值,Lua 5.2+
才有两个返回值)。
local co = coroutine.create(function(a)
print("协程启动,参数 a =", a)
local b = coroutine.yield(a + 10) -- 挂起,并传出 a+10
print("协程恢复,收到参数 b =", b)
return "协程执行完毕"
end)
print("1. 状态:", coroutine.status(co)) -- suspended
-- 第一次 resume
print("2. 第一次 resume 返回值:", coroutine.resume(co, 5))
-- 输出: 协程启动,参数 a = 5
-- 返回: true 15 (true表示成功,15是yield传出的)
print("3. 状态:", coroutine.status(co)) -- suspended
-- 第二次 resume
print("4. 第二次 resume 返回值:", coroutine.resume(co, 100))
-- 输出: 协程恢复,收到参数 b = 100
-- 返回: true 协程执行完毕 (true表示成功,后面是return的值)
print("5. 状态:", coroutine.status(co)) -- dead
-- 第三次 resume (尝试唤醒死亡的协程)
print("6. 第三次 resume 返回值:", coroutine.resume(co))
-- 返回: false cannot resume dead coroutine协程最令人困惑的地方在于 resume 和 yield
之间的数据传递。记住以下规律:
resume 的参数 -> yield
的返回值(除了第一次 resume 的参数 ->
协程函数的参数)。yield 的参数 -> resume
的返回值(在 true 之后)。return 值 -> 最后一次
resume 的返回值(在 true
之后)。local function test(x)
local y = coroutine.yield(x * 2) -- yield 传出 x*2,恢复时接收 y
return x + y
end
local co = coroutine.create(test)
-- 第一次:传入 10 给 test(x)
-- yield 传出 20
local ok1, r1 = coroutine.resume(co, 10)
print(r1) -- 20
-- 第二次:传入 5 给 y
-- return 10 + 5 = 15
local ok2, r2 = coroutine.resume(co, 5)
print(r2) -- 15协程非常适合用来做生成器,按需生成数据,而不是一次性生成所有数据占用内存。
local function range(max)
return coroutine.wrap(function()
for i = 1, max do
coroutine.yield(i)
end
end)
end
-- 像迭代器一样使用
for num in range(5) do
print(num) -- 依次打印 1, 2, 3, 4, 5
end协程的挂起特性天然适合实现状态机。
local function traffic_light()
while true do
print("Red Light - Stop")
coroutine.yield()
print("Green Light - Go")
coroutine.yield()
print("Yellow Light - Wait")
coroutine.yield()
end
end
local light = coroutine.wrap(traffic_light)
light() -- Red
light() -- Green
light() -- Yellow
light() -- Red ...wrap 实现)利用 coroutine.wrap
实现斐波那契数列的遍历,无需关心内部状态:
function fib_gen()
local a, b = 0, 1
while true do
coroutine.yield(a)
a, b = b, a + b
end
end
local next_fib = coroutine.wrap(fib_gen)
for i = 1, 10 do
print(next_fib())
endcoroutine.create vs coroutine.wrap| 特性 | coroutine.create |
coroutine.wrap |
|---|---|---|
| 返回值 | 返回一个 thread 对象 |
返回一个 function |
| 调用方式 | 需要配合 coroutine.resume(co)
使用 |
直接调用返回的函数即可 |
| 错误处理 | 捕获错误,返回
false, err_msg |
不捕获错误,直接抛出异常(error) |
| 适用场景 | 需要显式控制协程、检查状态、安全处理错误 | 需要将协程作为普通函数/迭代器传递时 |
选择建议:如果协程内部可能出错且你不希望程序崩溃,使用
create +
resume;如果只是想实现一个简单的生成器或迭代器,使用
wrap 更简洁。
socket:recv() 或
os.execute),整个 Lua
虚拟机都会被阻塞,其他协程无法执行。要实现非阻塞,必须使用支持协程的库(如
LuaJIT 的 FFI + libuv,或 OpenResty 的 cosocket)。yield 跨越 C
调用层回到另一个 C 层的 resume 是不被允许的(会报错
attempt to yield across metamethod/C-call boundary)。注:Lua
5.2+ 部分放开了此限制,但 5.1 严格遵守此限制。Lua 5.1 的协程本质上是“可保存上下文并随时挂起/恢复的函数”。
掌握其核心在于理清 resume 和 yield
之间如同“传球”一样的数据交互路径。通过协程,你可以写出极为优雅的迭代器、状态机,以及在没有
Promise/async 语法糖的 Lua 世界里,优雅地解决异步回调地狱的问题。
这几乎是 Lua 5.1 协程最大的坑。很多游戏服务器(包括 Skynet、OpenResty 早期、各种 Lua 框架)都因为这个限制设计了一整套绕过方案。
先理解为什么会报这个错误。
假设调用栈如下:
C
└── lua_resume()
│
▼
Lua A
│
▼
C Function(lua_CFunction)
│
▼
Lua B
│
▼
coroutine.yield()对应代码类似:
// C
lua_resume(L, ...);Lua:
function A()
cfunc() -- C函数
endC:
int cfunc(lua_State* L)
{
lua_getglobal(L, "B");
lua_call(L, 0, 0); // 调Lua函数B
return 0;
}Lua:
function B()
coroutine.yield()
end此时:
yield
↑
Lua B
↑
lua_call() <-- C还没返回
↑
Lua A
↑
lua_resume()Lua 5.1 会直接报:
attempt to yield across metamethod/C-call boundary原因就是:
yield 试图跳过一个仍然活跃的 C 调用栈(lua_call)返回到 resume。
Lua 5.1 不允许这样做。
Lua 5.1 的 VM 很简单。
它只保存:
Lua CallInfo
Lua Stack不会保存 C 调用栈。
例如:
resume
↓
lua_resume
↓
lua_call
↓
lua_call
↓
lua_call这些 C 函数都在真正的 CPU 栈里。
如果突然:
yieldLua 可以保存:
Lua函数A
Lua函数B
Lua局部变量但是:
C函数正在执行到哪一行?
局部变量?
return地址?CPU 栈根本没法保存。
因此:
Lua 5.1 要求:
yield 时,整个 C 栈必须已经全部退出。
换句话说:
resume
↓
Lua
↓
Lua
↓
yield
可以。
但是:
resume
↓
Lua
↓
C
↓
Lua
↓
yield不可以。
例如:
function foo()
print("A")
coroutine.yield()
print("B")
end直接 resume:
co = coroutine.create(foo)
coroutine.resume(co)没有任何问题。
因为:
resume
↓
foo
↓
yield没有 C 边界。
但是:
function foo()
call_from_c()
endC:
int call_from_c(lua_State *L)
{
lua_getglobal(L,"bar");
lua_call(L,0,0);
return 0;
}Lua:
function bar()
coroutine.yield()
end此时:
resume
↓
foo
↓
call_from_c <-- C
↓
bar
↓
yield就会报错。
Lua 把下面这些都看成 C Boundary:
lua_call()
lua_pcall()
元方法(__index)
__newindex
__add
__gc
__call
CFunction
迭代器(next)
table.sort比较函数
debug hook例如:
mt.__index = function()
coroutine.yield()
end然后:
print(t.a)也会:
attempt to yield across metamethod/C-call boundary因为:
Lua
↓
__index (C触发)
↓
yield也是跨 C。
Lua 5.2 引入了:
lua_callk()
lua_pcallk()
lua_yieldk()所谓 Continuation API(延续调用)。
例如:
resume
↓
Lua
↓
lua_callk
↓
yieldyield 后:
Lua 会记录:
继续执行哪个 continuation恢复时:
resume
↓
continuation()
↓
继续执行不用恢复真正的 C 栈,而是重新进入 continuation。
所以:
Lua 5.2 可以跨很多 C API yield。
但不是全部。
普通 C 函数如果没使用 lua_yieldk /
lua_callk,仍然不能安全 yield。
LuaJIT 基于 Lua 5.1。
它同样有这个限制。
但是 LuaJIT 做了一些扩展:
例如:
ffi以及部分 VM 行为可以允许更多场景。
不过:
普通 C API (lua_call、lua_pcall)
仍然不能跨越 yield。
所以 OpenResty 才会要求:
ngx.sleep()
ngx.socket()
ngx.location.capture()这些全部都是专门修改过的可 yield API。
lua_call?像 Skynet、Luvit、Cloudwu 的很多框架 都遵循一个原则:
不要在 C 中主动调用 Lua,再让 Lua 去 yield。
通常设计成:
C
↓
resume
↓
Lua
↓
Lua
↓
yield或者:
事件到来
↓
C只负责resume
↓
Lua一直执行
↓
yield等待事件
↓
resume继续这样整个协程生命周期中:
resume
↓
Lua
↓
Lua
↓
Lua
↓
yield没有任何中间 C 边界,因此不会触发 Lua 5.1 的限制。
Lua 5.1 的规则其实可以归纳成一句话:
resume → Lua → Lua → yieldresume → Lua → C(lua_call / CFunction / 元方法等) → Lua → yieldyield
时不能跨越仍未返回的 C 调用帧。lua_callk、lua_pcallk、lua_yieldk
引入 Continuation 机制,允许经过适配的 C API 支持协程挂起,但并不是所有
C 调用都自动变成可 yield。