Lua 协程

Lua 5.1 协程完全教程

1. 简介

在 Lua 5.1 中,协程是一种强大的控制流机制。你可以把它理解为一种“可以暂停和恢复的函数”。与操作系统的线程不同,Lua 的协程是协作式多任务的,这意味着它们不会自动抢占执行权,必须由代码显式地交出控制权(挂起)和重新获取控制权(恢复)。

Lua 5.1 的协程常被用来实现状态机迭代器以及异步非阻塞 I/O(如 OpenResty / luarocks 中的某些库)。

2. 协程的四种状态

在了解 API 之前,必须先弄懂协程的四种状态:

3. 核心 API 详解

Lua 5.1 协程相关的函数都在 coroutine 表中。

3.1 coroutine.create(f)

创建一个协程。参数 f 是一个函数。返回一个协程对象(thread 类型)。 注意:刚创建的协程处于 suspended 状态。

3.2 coroutine.resume(co, ...)

启动或恢复协程的执行。

3.3 coroutine.yield(...)

挂起当前正在执行的协程,将控制权交还给调用 resume 的地方。

3.4 coroutine.status(co)

返回协程当前的状态字符串:"running", "suspended", "normal", "dead"

3.5 coroutine.wrap(f)

create 一样创建协程,但返回的不是协程对象,而是一个函数。 每次调用这个函数时,就相当于调用了 resume注意:使用 wrap 时,如果协程内部出错,错误会直接向外抛出,不会像 resume 那样返回 false, err

3.6 coroutine.running()

返回当前正在运行的协程对象。如果在主线程中调用,返回 nil(Lua 5.1 中不返回布尔值,Lua 5.2+ 才有两个返回值)。


4. 基础示例:状态流转

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

5. 数据交互详解

协程最令人困惑的地方在于 resumeyield 之间的数据传递。记住以下规律:

  1. resume 的参数 -> yield 的返回值(除了第一次 resume 的参数 -> 协程函数的参数)。
  2. yield 的参数 -> resume 的返回值(在 true 之后)。
  3. 协程函数的 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

6. 进阶用法

6.1 生成器

协程非常适合用来做生成器,按需生成数据,而不是一次性生成所有数据占用内存。

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

6.2 状态机

协程的挂起特性天然适合实现状态机。

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 ...

6.3 遍历无限序列(使用 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())
end

7. coroutine.create vs coroutine.wrap

特性 coroutine.create coroutine.wrap
返回值 返回一个 thread 对象 返回一个 function
调用方式 需要配合 coroutine.resume(co) 使用 直接调用返回的函数即可
错误处理 捕获错误,返回 false, err_msg 不捕获错误,直接抛出异常(error
适用场景 需要显式控制协程、检查状态、安全处理错误 需要将协程作为普通函数/迭代器传递时

选择建议:如果协程内部可能出错且你不希望程序崩溃,使用 create + resume;如果只是想实现一个简单的生成器或迭代器,使用 wrap 更简洁。


8. 注意事项与陷阱

  1. 协程不是真正的多线程:Lua 5.1 的协程在任何时刻只有一个协程在运行,它不能利用多核 CPU。它只是逻辑上的并发。
  2. C 函数阻塞问题:如果在协程中调用了阻塞的 C 库函数(如原生的 socket:recv()os.execute),整个 Lua 虚拟机都会被阻塞,其他协程无法执行。要实现非阻塞,必须使用支持协程的库(如 LuaJIT 的 FFI + libuv,或 OpenResty 的 cosocket)。
  3. 不能跨越 C 边界 yield:在 Lua 5.1 中,如果通过 C API 调用 Lua 函数,在这个 Lua 函数内部尝试 yield 跨越 C 调用层回到另一个 C 层的 resume 是不被允许的(会报错 attempt to yield across metamethod/C-call boundary)。注:Lua 5.2+ 部分放开了此限制,但 5.1 严格遵守此限制。
  4. 全局变量污染:协程之间共享全局环境。如果协程逻辑复杂,需小心全局变量的覆盖问题。

9. 总结

Lua 5.1 的协程本质上是“可保存上下文并随时挂起/恢复的函数”。 掌握其核心在于理清 resumeyield 之间如同“传球”一样的数据交互路径。通过协程,你可以写出极为优雅的迭代器、状态机,以及在没有 Promise/async 语法糖的 Lua 世界里,优雅地解决异步回调地狱的问题。

Lua5.1 不能跨越 C 边界 yield

这几乎是 Lua 5.1 协程最大的坑。很多游戏服务器(包括 Skynet、OpenResty 早期、各种 Lua 框架)都因为这个限制设计了一整套绕过方案。

先理解为什么会报这个错误。

什么叫「跨越 C 边界 yield」

假设调用栈如下:

C
└── lua_resume()


Lua A


C Function(lua_CFunction)


Lua B


coroutine.yield()

对应代码类似:

// C
lua_resume(L, ...);

Lua:

function A()
    cfunc()        -- C函数
end

C:

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 栈里。

如果突然:

yield

Lua 可以保存:

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()

end

C:

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

就会报错。


为什么报的是 metamethod/C-call boundary?

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 5.2 引入了:

lua_callk()

lua_pcallk()

lua_yieldk()

所谓 Continuation API(延续调用)

例如:

resume

Lua

lua_callk

yield

yield 后:

Lua 会记录:

继续执行哪个 continuation

恢复时:

resume



continuation()



继续执行

不用恢复真正的 C 栈,而是重新进入 continuation。

所以:

Lua 5.2 可以跨很多 C API yield。

但不是全部。

普通 C 函数如果没使用 lua_yieldk / lua_callk,仍然不能安全 yield。


LuaJIT 呢?

LuaJIT 基于 Lua 5.1。

它同样有这个限制。

但是 LuaJIT 做了一些扩展:

例如:

ffi

以及部分 VM 行为可以允许更多场景。

不过:

普通 C API (lua_calllua_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 的规则其实可以归纳成一句话: