【Lua 入门系列】从执行模型到事件驱动:一文理解 Lua 有栈协程、挂起恢复与协作式调度

🔥 本文专栏:Lua

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录:人生最珍贵的自由,是既有奔向远方的勇气,也有享受当下的从容。


思维导图

text 复制代码
从普通函数的线性执行出发
        │
        ├── 普通函数为何不能随意"暂停后再接着执行"?
        │        ↓
        └── 协程:允许一条执行流在中途挂起、随后恢复
                 ↓
       谁来真正执行 Lua 代码?
                 ↓
       操作系统线程执行 Lua 运行库的机器代码
                 ↓
       Lua 不原生提供操作系统线程创建接口
       可嵌入宿主程序,由宿主负责线程组织
                 ↓
       Lua 自身提供协程这一独立执行流
                 ↓
       有栈协程:保留 Lua 虚拟机栈 + 调用帧 + 执行位置
                 ↓
       create 创建 → resume 启动 → yield 挂起 → resume 恢复
                 ↓
       yield 参数 ↔ resume 返回值
       resume 参数 ↔ yield 返回值
                 ↓
       生命周期:suspended / running / normal / dead
                 ↓
       wrap / close / 错误反馈
                 ↓
       多个协程交替推进:外部调度器决定恢复谁
                 ↓
       异步操作尚未完成 → yield 等待事件
       外部事件就绪 → resume 恢复协程
                 ↓
       在 Lua 协程机制上构建事件驱动调度

引入:为什么不能直接从 coroutine.create() 开始记 API?

在学习协程之前,我们已经学习过进程、线程、普通函数的调用过程,以及 Lua 脚本的编译和解释执行过程。

如果一上来直接学习:

lua 复制代码
coroutine.create(f)
coroutine.resume(co)
coroutine.yield()

我们很容易记住每个函数做什么,但仍然不知道几个更根本的问题:

  • 为什么普通函数通常是一口气往下执行,而协程却能执行一半再回来?
  • 协程究竟运行在什么地方?和操作系统线程是什么关系?
  • 所谓"有栈协程",保存的又是哪一种栈?
  • yield() 之后,控制权究竟返回给谁?
  • 如果协程用于异步网络请求,谁来判断操作完成、什么时候恢复?

因此,本文不以 API 的排列顺序组织内容,而是沿着一条更自然的推导链路展开:先理解执行流,再认识它的运行载体,接着研究保存现场的机制,最后把挂起与恢复扩展为事件驱动。


一、先从普通函数的执行模型出发:协程究竟改变了什么?

1. 普通函数的执行是一条线

先不考虑操作系统因时间片耗尽而进行的线程切换,也暂时不考虑异常、中断等外部因素。

对于一个普通函数来说,我们可以将它的执行过程抽象为一条线:

text 复制代码
函数调用开始
     ↓
   操作 A
     ↓
   操作 B
     ↓
   操作 C
     ↓
函数返回调用方

这里并不是说函数内部绝对不能调用其他函数。实际上,函数 A 可以调用 B,B 又可以调用 C;它们会产生相应的函数调用链。

我们真正要表达的是:普通函数本身通常不具备一种能力------主动暂停整条尚未结束的调用链,先把控制权交还给外部,然后在未来从原先的位置继续。

所以,当我们希望某个任务执行到一半时,让其他任务先执行,再回来完成这个任务,普通函数调用模型就不再那么方便。

2. 协程不是放弃线性执行,而是允许线性执行分段推进

对于协程来说,任务内部的执行顺序依然可以保持线性:

text 复制代码
协程开始
   ↓
 操作 A
   ↓
 操作 B
   ↓
  挂起 ────────────┐
   │              │
   │           未来恢复
   │              │
   └──────────────┘
   ↓
 操作 C
   ↓
协程结束

这里的关键并不是"执行顺序发生了变化",而是:这条执行线在某个位置可以停止推进,同时保留现场;之后再继续向前推进。

因此,我们可以将协程的核心能力先概括为两个动作:

  • 挂起(Yield / Suspend):停止当前协程的推进,并保留执行现场。
  • 恢复(Resume):重新获得执行机会,从之前保存的执行位置继续执行。

那么,假设我们有三个协程,并且目前只有一个操作系统线程,完全可以在不同的时间区间交替执行它们。

例如,t1~t2 执行协程 A,t2~t3 执行协程 B,t3~t4 执行协程 C,t4~t5 再恢复协程 A。

这里要明确:交替执行不是多核并行。 对于上述单线程模型而言,同一时刻只有一个协程在这条操作系统线程上执行;而且协程的切换本身不要求发生操作系统线程切换。

至此,我们先建立了协程的第一个心智模型:协程也是一条执行流,只是允许中途挂起,并在之后继续。


二、Lua 协程运行在哪里?先理清线程、解释器与宿主程序的关系

1. 操作系统只负责调度线程,不直接调度 Lua 协程

我们知道,对于一个进程而言,操作系统线程是 CPU 调度的基本单位。协程则通常是由语言或者上层框架实现的用户态执行抽象。

因此:

text 复制代码
操作系统负责调度线程
          ↓
线程执行 Lua 运行库的原生机器代码
          ↓
Lua 虚拟机解释执行 Lua 字节码
          ↓
协程在 Lua 运行时的控制下挂起与恢复

操作系统并不知道当前运行的是哪一个 Lua 协程。 对操作系统来说,它看到的仍然是某个线程正在执行机器指令。

这里必须注意一点:Lua 脚本本身不等于操作系统线程,更不能把一份 Lua 脚本直接视为一个独立进程。

2. Lua 解释器也不必然等于一个独立的操作系统进程

此前我们已经认识了 Lua 的执行过程:

text 复制代码
Lua 源代码
    ↓
编译成 Lua 字节码
    ↓
Lua 虚拟机取指、分派、执行
    ↓
运行此前已经编译好的解释器机器代码

当我们在终端执行:

bash 复制代码
lua main.lua

确实会运行一个 Lua 解释器程序,并由操作系统创建对应的进程。

但这仅仅是 Lua 的一种运行方式。Lua 本身具有很强的可嵌入性:我们也可以把 Lua 运行库链接到 C/C++ 宿主程序中,通过 Lua C API 加载并执行脚本。此时,执行 Lua 脚本并不意味着额外启动一个独立的解释器进程。

而且,嵌入的并不是某一份特殊的脚本文件,而是 Lua 运行库提供的完整语言执行能力:编译、虚拟机执行、状态管理、内存管理以及标准 API 等。

3. 为什么 Lua 没有像 C++ 那样在标准语言层面提供线程创建接口?

我们知道,C++ 可以通过 std::thread 等机制创建和管理操作系统线程。

那么,Lua 真的没有能力做到吗?

当然不是。 如果通过 C 扩展等方式提供线程创建接口,Lua 完全可以间接使用操作系统线程。

但标准 Lua 选择的是轻量、可嵌入的设计路线,并没有原生提供与 std::thread 对等的线程生命周期管理接口。操作系统线程的创建、分工和管理可以交给宿主程序及相关扩展来完成。

例如,假设一个 C++ 宿主程序创建两个操作系统线程,并分别为它们准备独立的 Lua 状态:

这里有一个特别容易混淆的地方:不同操作系统线程不需要各自重新实现或者复制一份 Lua 虚拟机的机器代码。

Lua 运行库的取指、分派和执行逻辑已经编译为机器代码,各个线程可以执行同一套代码。真正需要分别维护的是各自的执行状态,例如 Lua 虚拟机栈、调用帧、全局环境等。

在这种示例架构中:

  • 操作系统线程 A 使用独立 Lua 状态 A;
  • 操作系统线程 B 使用独立 Lua 状态 B;
  • 两个线程可以并行执行同一套 Lua 运行库中的机器代码。

不过,这只是一种常见设计,操作系统线程与 Lua 状态并不是强制一一对应的关系。共享同一个 Lua 状态时,还需要做好线程同步;不能把一个 Lua 状态当成可以随意并发访问的对象。

顺带认识一下 C API 中的两个名称:

c 复制代码
/* 创建独立 Lua 状态的辅助接口 */
lua_State *L = luaL_newstate();

/* 在现有 Lua 状态内创建一个 Lua 协程 */
lua_State *co = lua_newthread(L);

这里虽然出现了 lua_newthread(),但它创建的是 Lua 的 thread(协程)对象,不是操作系统线程 。并且,lua_newthread() 创建的协程与所属 Lua 状态共享相应的全局环境;它与重新创建一个独立状态不同。

4. 到这里,明确三者各自的职责

层次 核心职责
操作系统线程 执行原生机器代码,由操作系统调度
Lua 运行库 / 虚拟机 编译 Lua 代码,解释执行字节码,管理 Lua 执行状态
Lua 协程 维护一条独立 Lua 执行流,支持挂起、恢复

所以,后续讨论 Lua 协程时,我们主要关注的是一条 Lua 执行流如何暂停与恢复,而不是反复回到操作系统线程的创建问题上。


三、为什么 Lua 可以在多层函数调用中挂起?理解"有栈协程"

1. 先提出问题:如果 A 调用 B,B 调用 C,C 中途挂起怎么办?

考虑下面的调用链:

text 复制代码
协程入口函数 A
       ↓
   调用函数 B
       ↓
   调用函数 C
       ↓
    yield()

假设 C 执行到一半时主动挂起。我们显然不能只保存 C 当前执行到了哪里。

因为将来恢复之后,C 还需要继续执行,然后返回 B,B 执行完再返回 A。也就是说,整条尚未完成的函数调用链都要保留下来。

这也是我们理解有栈协程与无栈协程时,最有价值的切入点。

2. 有栈与无栈,真正的区别是什么?

我们以前接触过 C++20 协程。C++20 的标准协程属于无栈协程:编译器识别协程函数,根据特定协议生成协程帧和状态机,保存该协程函数需要跨挂起点保留的状态。

C++20 协程函数通常具有 co_await、co_yield 或 co_return 等协程关键字,并使用满足协程返回类型协议的类型。

而 Lua 采用的是有栈协程 :它维护独立的 Lua 虚拟机栈及函数调用信息,因此可以在普通 Lua 函数调用链的较深位置执行 coroutine.yield(),让整条协程执行流挂起。

举个例子:

lua 复制代码
local function C()
    local c = 30
    print("进入 C")
    coroutine.yield()
    print("恢复 C", c)
end

local function B()
    local b = 20
    C()
    print("返回 B", b)
end

local function A()
    local a = 10
    B()
    print("返回 A", a)
end

local co = coroutine.create(A)

coroutine.resume(co)
print("控制权返回调用方")
coroutine.resume(co)

输出:

text 复制代码
进入 C
控制权返回调用方
恢复 C    30
返回 B    20
返回 A    10

我们会发现,虽然 yield() 写在最里面的 C 函数中,但是整条 A → B → C 调用链并没有丢失。恢复之后,C 仍然可以拿到局部变量 c,B 和 A 也能继续处理自己的局部变量。

因此,Lua 的有栈协程能够保留多层 Lua 函数调用链,这是它的重要特点。

3. 不过,Lua 的"协程栈"不是原生线程栈

这里需要特别区分两种栈:

  • 原生 C 栈:操作系统线程执行 Lua 解释器的 C 函数时所使用的调用栈。
  • Lua 虚拟机栈:由 Lua 运行时管理,保存 Lua 函数运行所需的栈槽数据,并配合调用帧信息维护 Lua 级别的执行现场。

Lua 虽然是有栈协程,但并不是为每个协程都分配一个完整的原生操作系统线程栈,也不等于将 CPU 的寄存器和硬件栈指针单独保存下来,然后直接切换到那个硬件栈上。

对于我们学习的 Lua 5.4 实现,协程的核心现场由 Lua 虚拟机维护。

在源码中可以重点认识两个数据结构:

lua_State: 表示一个 Lua thread / 协程的运行时状态,包含栈位置、当前 CallInfo、运行状态等信息。

CallInfo: 表示函数调用信息,包含当前函数对应的栈位置、调用链关系,以及 Lua 函数的字节码执行位置 savedpc 等。

这里有一个非常重要的区分:savedpc 记录的是 Lua 字节码执行位置,并不是 CPU 硬件程序计数器(如 RIP)的值。

当协程恢复时,Lua 运行时利用保留的虚拟机栈、函数调用信息等继续执行尚未完成的字节码;不是简单地把 CPU 的 RIP 改回某条旧指令。

Lua 虚拟机维护的调用状态,才是 Lua 协程恢复整条 Lua 调用链的基础。

4. Lua 函数与"协程函数"的称呼

从 C++20 转过来,很容易把含有 yield() 的 Lua 函数也叫作"协程函数"。作为学习中的通俗称呼,这当然可以理解。

不过,在语言机制上我们需要知道:Lua 没有 C++20 那样的特殊协程函数语法分类。

lua 复制代码
local function task()
    print("A")
    coroutine.yield()
    print("B")
end

task 在 Lua 中仍然是一个普通函数值;它既不需要声明特殊返回类型,也不需要由编译器通过协程关键字把函数转换为独立的协程状态机。这里不要理解成 Lua 函数没有返回值:Lua 函数当然可以通过 return 返回一个或多个值,只是不需要像 C++ 那样在函数声明中写固定的返回类型。

这里的 coroutine.yield 是库函数,不是 Lua 语法关键字。函数是否能够通过它成功挂起,取决于它实际运行在哪个执行上下文中。

例如:

lua 复制代码
local co = coroutine.create(task)
coroutine.resume(co)

可以正常挂起;但是如果从 Lua 主执行线程直接调用 task(),执行到 yield() 时就会报错。

另外,task() 即使不包含 yield(),也照样可以被 coroutine.create(task) 用作协程入口,只不过这个协程可能从头到尾都不主动挂起。

所以,Lua 协程更适合理解为一条独立执行流,而不是某一种特殊的函数类型。


认识 coroutine 与协程句柄

在正式使用 Lua 协程之前,我们首先需要明确两个概念:coroutine 和 co 究竟是什么?

首先,对于 coroutine 来说,它本质上就是 Lua 标准库提供的一张 Table 。我们之前已经认识过,Lua 的 Table 本质上就是一个键值对的集合,并且 Table 的值可以是函数。因此,Lua 就利用这种特性,将与协程相关的操作函数统一组织在 coroutine 这张 Table 中,例如 create()、resume()、yield()、status() 等。

这里我们可以将它类比为 C 语言中保存函数指针的集合,只不过 Lua 使用的是以字符串为键、函数为值的 Table,而不是传统的函数指针数组。

因此,当我们执行:

lua 复制代码
coroutine.create(task)

实际上就是访问 coroutine 这张 Table 中名为 create 的字段,取得对应的函数并执行。

那么,co 又是什么呢?

lua 复制代码
local co = coroutine.create(function()
    print("Hello Lua")
    coroutine.yield()
end)

print(type(coroutine))  -- table
print(type(co))         -- thread

可以看到,coroutine.create() 创建协程之后,会返回一个类型为 thread 的协程对象,而变量 co 保存的就是这个协程对象的引用。

这里我们可以将 co 类比为协程句柄,通过这个引用,我们就能够找到对应的协程对象,并对这个协程进行操作。

结合之前学习的 Lua 有栈协程机制,我们知道,每个协程都需要维护独立的 Lua 虚拟机栈以及相关执行状态,包括函数调用帧、字节码执行位置、协程运行状态等信息。

对于 Lua 5.4 的底层实现来说,一个协程对象对应一个 lua_State 结构,其中维护了虚拟机栈的位置、当前函数调用帧等信息,并通过相关数据结构共同保存协程的执行现场。

因此,需要注意的是,co 变量本身并不是直接保存了整个协程上下文,而是持有对应协程对象的引用;真正的执行现场由 Lua 运行时负责维护。

有了这个引用之后,我们就能够通过协程库提供的函数对指定协程进行操作:

lua 复制代码
coroutine.resume(co)  -- 启动或恢复协程

coroutine.status(co)  -- 查询协程状态

coroutine.close(co)   -- 关闭协程(Lua 5.4)

所以,这里我们最终需要建立的认知就是:

coroutine 是 Lua 提供的一张 Table,用于统一组织协程相关的操作函数;而 co 则是我们自定义的变量,用来引用某个具体的协程对象,可以理解为协程句柄。通过这个句柄,我们就能够利用 coroutine 提供的函数,对相应协程进行创建后的执行控制以及状态管理。

另外,co 只是一个普通的变量名,并不是 Lua 的关键字,我们完全可以根据自己的需求将其命名为其他名称。

四、认识最基础的控制机制:create、resume 与 yield

前面我们已经弄清楚,Lua 为什么能够保存完整调用链。那么接下来就很自然了:如何真正创建、挂起并恢复这条执行流?

1. create():只创建,不执行

lua 复制代码
local function task()
    print("操作 A")
    coroutine.yield()
    print("操作 B")
end

local co = coroutine.create(task)

coroutine.create(task) 会创建一个 Lua 协程对象,并将 task 设置为入口函数。

要注意:此时仅完成创建,入口函数还没有运行。

co 的 Lua 类型是 thread:

lua 复制代码
print(type(co))  -- thread

这里的 thread 是 Lua 自身对协程对象的类型名称,不要误认为已经创建了操作系统线程。

2. resume():第一次启动,之后恢复

lua 复制代码
coroutine.resume(co)

第一次 resume(),意味着从入口函数开始执行;后续 resume(),则意味着从上一次挂起的位置继续执行。

所以在我们的心智模型中,可以把入口函数开始的位置理解为首次启动的起点 ,把后续 yield() 理解为真正的挂起点。

但二者不要混为一谈:入口函数尚未开始执行,不等于它之前调用过一次 yield()。

3. yield():主动挂起,把控制权交还给调用方

我们把刚才的例子补充完整:

lua 复制代码
local function task()
    print("A")
    coroutine.yield()
    print("B")
end

local co = coroutine.create(task)

print("1")
coroutine.resume(co)
print("2")
coroutine.resume(co)
print("3")

输出:

text 复制代码
1
A
2
B
3

这段代码最值得关注的是第一次执行到 coroutine.yield() 的时刻。

它并不是在 yield() 之后立即执行 print("B"),而是直接挂起整条协程执行流,把控制权交还给调用 resume() 的一方。

只有外部再次执行 resume(co) 时,原来的 yield() 调用才继续完成,协程才会往下执行。

4. Lua 的挂起是主动的,不负责自动判断条件是否就绪

我们之前学习过异步编程,知道很多挂起操作来自"当前条件尚未满足、任务尚未完成"。

但对于 Lua 的基础协程接口:

lua 复制代码
coroutine.yield()

这个函数并不会替我们判断网络数据是否到达、定时器是否到期或者数据库请求是否完成。和 C++20 co_await 可以通过 Awaiter 的 await_ready() 协议判断是否需要挂起不同,Lua 的 yield() 本身没有内置的就绪判断协议。

如果业务逻辑需要条件判断,我们可以自己写:

lua 复制代码
if not ready then
    coroutine.yield()
end

只要当前执行上下文允许挂起,执行到 yield() 就会挂起。是否需要挂起,由业务决定;挂起本身,由 Lua 运行时完成。

5. 是否存在不允许挂起的情况?

有,但对于我们现在讨论的普通 Lua 协程,不必把它复杂化。

例如,直接在 Lua 主执行线程调用:

lua 复制代码
coroutine.yield()  -- 报错

就不能成功挂起。必须让代码实际运行在可挂起的协程上下文中;通过 resume() 启动的普通 Lua 协程便符合我们当前学习的基本模型。

注意:不是说 yield() 必须写在协程入口函数的函数体里,它也可以出现在入口函数所调用的其他 Lua 函数中。

Lua 5.4 还提供:

lua 复制代码
print(coroutine.isyieldable())

用于判断当前协程是否允许挂起。少数不支持挂起的 C 函数调用边界也可能限制 yield(),这部分以后学习 Lua 与 C/C++ 交互时再深入。

至此,我们就建立了完整的基础流程:

text 复制代码
create() 创建
    ↓
resume() 首次启动
    ↓
执行 Lua 业务逻辑
    ↓
yield() 主动挂起
    ↓
返回外部调用方
    ↓
resume() 再次恢复
    ↓
继续执行此前的调用链

五、挂起与恢复不仅改变控制流,还能双向传递数据

现在考虑一个问题:协程主动挂起时,能不能顺便告诉外部自己产生了什么结果?外部恢复它时,又能不能顺便传入新的数据?

Lua 对这两件事都提供了支持。

1. yield() 的实参,成为本次 resume() 的返回数据

lua 复制代码
local co = coroutine.create(function()
    coroutine.yield(100)
end)

local ok, value = coroutine.resume(co)
print(ok, value)  -- true    100

我们可以这样理解:

text 复制代码
协程内部:yield(100)
          ↓
协程暂停,并将 100 传给调用方
          ↓
外部 resume(co) 返回 true, 100

这里 true 表示本次 resume() 没有因未处理错误而失败;100 才是 yield() 传出的数据。

2. 后续 resume() 的实参,成为此前 yield() 的返回值

看一个更完整的例子:

lua 复制代码
local function task()
    print("协程开始")

    local result = coroutine.yield(100)

    print("恢复后拿到:", result)
    return 300
end

local co = coroutine.create(task)

print(coroutine.resume(co))       -- 第一次
print(coroutine.resume(co, 200))  -- 第二次

输出:

text 复制代码
协程开始
true    100
恢复后拿到:    200
true    300

首先,第一次 resume(co) 启动协程。执行到 yield(100) 时,100 传出,协程挂起。

接着,第二次 resume(co, 200) 恢复协程。此时 200 成为原来 yield() 调用的返回值,于是赋值给 result。最后入口函数 return 300,所以外部得到 true, 300。

这里有一个特别容易误解的细节:

lua 复制代码
local result = coroutine.yield(100)

这条赋值语句在第一次执行到 yield() 时并没有完成。它要等到协程被再次恢复之后,才能拿到 yield() 的返回值,并完成赋值。

3. 第一次 resume() 传入的数据比较特殊

我们已经认识了第二次 resume() 的参数传给 yield()。那么第一次调用时,协程还没有执行到任何 yield(),参数又传给谁呢?

答案是:传给协程入口函数。

lua 复制代码
local co = coroutine.create(function(a, b)
    print(a, b)
end)

coroutine.resume(co, 10, 20) -- 10    20

所以,完整的数据传递关系如下:

操作 数据流向
第一次 resume(co, ...) 参数传给入口函数
yield(...) 参数传给外部,作为当前 resume() 的后续返回值
后续 resume(co, ...) 参数成为上一次 yield() 的返回值
入口函数 return ... 返回数据成为最后一次 resume() 的后续返回值

这些位置都可以传递多个值,和我们此前学习过的 Lua 多返回值机制是一致的。

到这里我们已经可以发现:yield() 不只是一个挂起点,它同时也是协程与外部调用方交换数据的位置。


六、如何知道一个协程执行到了哪个阶段?生命周期与状态

既然协程可以在多个时间段交替推进,那么外部调度器必然要知道:当前协程是否还活着、是否可以恢复、是否已经结束。

Lua 提供:

lua 复制代码
coroutine.status(co)

查询协程状态。

1. 四种状态及其含义

状态 含义
suspended 尚未启动,或者已经调用 yield() 挂起
running 当前正在执行的协程
normal 仍然活跃,但它通过 resume() 调用了另一个协程,当前控制权在对方手里
dead 协程正常执行结束,或者因未处理错误而终止

这里的名称是 suspended ,不是 suspend。

其中,最常见的一条路径是:

text 复制代码
suspended(刚创建)
      ↓ resume
running
      ↓ yield
suspended(已挂起)
      ↓ resume
running
      ↓ 入口函数结束
    dead

2. 简单查看每个阶段的状态

lua 复制代码
local co = coroutine.create(function()
    print("协程内部:", coroutine.status(coroutine.running()))
    coroutine.yield()
end)

print(coroutine.status(co)) -- suspended

coroutine.resume(co)        -- 协程内部: running
print(coroutine.status(co)) -- suspended

coroutine.resume(co)
print(coroutine.status(co)) -- dead

这里还可以认识另一个函数:

lua 复制代码
local current, isMain = coroutine.running()

它会返回当前正在执行的 Lua thread 对象 ,以及一个布尔值 isMain,用来说明它是否为 Lua 主执行线程。注意,这个 Lua 主执行线程不是操作系统主线程。

3. normal 为什么与 suspended 不同?

normal 最容易让人疑惑,因为它不像 suspended 那样来自主动 yield()。

例如:

lua 复制代码
local co1, co2

co1 = coroutine.create(function()
    coroutine.resume(co2)
end)

co2 = coroutine.create(function()
    print(coroutine.status(co1)) -- normal
    print(coroutine.status(co2)) -- running
end)

coroutine.resume(co1)

此时,co1 本来正在执行,但它主动调用了 resume(co2),把控制权交给 co2。在 co2 正在运行的这段时间内,co1 是 normal,co2 是 running。

normal 表示调用其他协程后暂时不是当前运行者,并不等于自己执行了 yield()。

4. dead 不等于一定正常完成

我们之前说过,协程入口函数正常返回会进入 dead;但如果内部发生未处理的错误,也同样会进入 dead。

所以只看 coroutine.status(co) == "dead",无法判断这个协程是正常结束还是异常终止。要判断成功或失败,还要结合 resume() 的返回值。


七、把生命周期补全:错误反馈、wrap() 和 close()

这一节我们只认识接口行为,不展开 pcall()、xpcall() 等完整错误机制。那些知识可以放在之后单独学习。

1. resume() 的第一个返回值表示执行是否成功

正常情况下:

lua 复制代码
local ok, result = coroutine.resume(co)

第一个返回值 ok 是布尔值。

  • true:这次执行没有遇到未处理错误,可能是成功挂起,也可能已经正常结束。
  • false:恢复失败,后续返回值包含错误对象或错误信息。

例如:

lua 复制代码
local co = coroutine.create(function()
    error("发生错误")
end)

local ok, err = coroutine.resume(co)
print(ok)                    -- false
print(coroutine.status(co)) -- dead

如果错误已经在协程内部通过 pcall() 等方式正确处理,那么这个错误本身并不会导致 resume() 返回 false;协程仍然可以继续执行。

这里需要区分:普通 Lua 代码中没有被处理的错误会继续向外传播,但是否导致整个操作系统进程退出,还取决于 Lua 运行方式及宿主程序的保护和错误处理。

2. wrap():把恢复操作包装成一个函数

除了 create(),Lua 还可以这样创建协程:

lua 复制代码
local function task(a)
    print("入口参数:", a)
    local value = coroutine.yield(100)
    print("恢复参数:", value)
    return 300
end

local f = coroutine.wrap(task)

print(f(10))   -- 第一次:输出入口参数,随后返回 100
print(f(200))  -- 第二次:输出恢复参数,随后返回 300

coroutine.wrap(task) 会创建协程,但返回的是一个函数对象 ,不是 thread 类型的协程对象。

这个函数可以理解为内部关联协程的恢复入口。以后每调用一次 f(),就会启动或恢复它对应的协程。

数据传递规律不变:第一次 f(10) 的 10 进入入口函数;第二次 f(200) 的 200 成为前一次 yield() 的返回值。

但它与 create() + resume() 有两项重要区别:

create() + resume() wrap()
创建结果 thread 协程对象 包装函数
恢复方式 resume(co, ...) f(...)
正常返回 true + 数据 直接返回数据,不额外返回 true
遇到未处理错误 返回 false + 错误 向函数调用方传播错误,并关闭协程
直接查询对象状态 有 co 可查询 不直接暴露底层协程对象

因此,wrap() 没有改变协程机制,而是简化了创建与恢复接口。

3. close():不再打算恢复时,主动关闭协程

Lua 5.4 还提供了:

lua 复制代码
coroutine.close(co)

假设一个协程已经挂起,但我们决定不继续执行:

lua 复制代码
local co = coroutine.create(function()
    print("A")
    coroutine.yield()
    print("B")
end)

coroutine.resume(co)        -- 输出 A
print(coroutine.status(co)) -- suspended

local ok = coroutine.close(co)
print(ok)                    -- true
print(coroutine.status(co)) -- dead

这里的 B 不会被执行。因为 close() 不是恢复协程、让它把剩余逻辑执行完,而是关闭这个协程,并处理其中待关闭的变量。

这里有两个边界要注意:

  1. 目标协程必须已经处于 suspended 或 dead 状态,不能直接关闭正在运行的协程。
  2. close() 不等于立即释放协程对象的全部内存;对象本身的内存回收仍涉及垃圾回收机制。

而且 close() 可能因为待关闭变量的关闭操作出现错误而返回 false,所以不能认为调用它一定成功。

版本提醒:coroutine.close() 是 Lua 5.4 的接口;Lua 5.1 并没有这个标准接口。

至此,我们认识的接口基本可以整理为:

接口 作用
coroutine.create(f) 创建协程,但不启动
coroutine.resume(co, ...) 启动或恢复协程
coroutine.yield(...) 挂起当前协程,并传出数据
coroutine.status(co) 查询协程状态
coroutine.running() 获取当前 Lua thread,以及是否为主线程
coroutine.isyieldable() 判断当前执行环境能否挂起
coroutine.wrap(f) 创建协程并返回恢复包装函数
coroutine.close(co) 关闭挂起或已结束的协程

八、从单个协程过渡到多个协程:Lua 的协作式调度

现在考虑一个更有意思的问题:协程 A 主动挂起之后,Lua 会自动选择协程 B 执行吗?

答案是:不会。 标准 Lua 提供了协程挂起、恢复的基础机制,但不会默认按照时间片自动调度我们创建的所有协程。

这意味着:

text 复制代码
外部调度器 resume(A)
          ↓
       A 执行
          ↓
       A yield
          ↓
控制权返回外部调度器
          ↓
外部调度器 resume(B)
          ↓
       B 执行
          ↓
       B yield
          ↓
控制权返回外部调度器

Lua 负责保存和恢复执行现场,外部调度器负责决定下一次恢复谁。

1. 手动实现最简单的轮转调度

lua 复制代码
local function task(name)
    for i = 1, 3 do
        print(name, i)
        coroutine.yield()
    end
end

local co1 = coroutine.create(function()
    task("协程1")
end)

local co2 = coroutine.create(function()
    task("协程2")
end)

while coroutine.status(co1) ~= "dead"
   or coroutine.status(co2) ~= "dead" do

    if coroutine.status(co1) ~= "dead" then
        local ok, err = coroutine.resume(co1)
        if not ok then
            print("协程1失败:", err)
        end
    end

    if coroutine.status(co2) ~= "dead" then
        local ok, err = coroutine.resume(co2)
        if not ok then
            print("协程2失败:", err)
        end
    end
end

输出:

text 复制代码
协程1    1
协程2    1
协程1    2
协程2    2
协程1    3
协程2    3

这里我们其实并没有实现什么复杂的东西。外部代码只是依次恢复不同协程,协程自己在合适的位置主动 yield()。

所以这段 while 循环与 resume() 调用,本质上就是一个简单的调度器。

2. 协程切换的机制简单,不等于完整调度器就只有这几行

这里需要区分两件事:

  • 协程切换机制 :resume()、yield(),由 Lua 运行时完成。
  • 协程调度策略:谁先执行、谁需要等待、何时恢复、如何处理超时和错误,由上层决定。

如果一个协程一直执行耗时循环,始终没有主动 yield(),外部调度器就没办法仅凭普通的 resume() 调用让同一线程中的另一个协程自动获得执行机会。

因此,我们目前实现的是最基础的协作式调度,而不是操作系统时间片意义上的抢占式调度。


九、继续深入:如何用 yield 与 resume 实现事件驱动异步模型?

前面的轮转调度虽然简单,但很快就会出现一个实际问题。

假设协程 A 发起了一个异步网络请求。请求尚未完成时,协程 A 应该主动挂起,这一点我们已经知道了。

但接下来还存在两个问题:

  1. 协程 A 如何告诉外部调度器:我现在正在等什么事件?
  2. 事件完成之后,外部调度器如何找到需要恢复的那个协程?

一旦把这两个问题解决,我们就能把之前学习的基本协程接口组织成事件驱动机制。

1. 第一个问题:用 yield() 传出等待信息

假设我们约定:

lua 复制代码
coroutine.yield("wait", "request_1001")

这里传递了两个数据:

  • "wait":告诉外部调度器,当前协程想等待事件。
  • "request_1001":告诉外部调度器,具体等待哪个事件。

由于我们已经学习过 yield() 与 resume() 的数据传递机制,所以完全知道外部会收到什么:

lua 复制代码
local ok, action, eventId = coroutine.resume(co)

如果协程执行到上述 yield(),那么外部将得到:

text 复制代码
ok      = true
action  = "wait"
eventId = "request_1001"

现在,外部已经知道这个协程正在等待哪一个事件了。

2. 第二个问题:用 Table 建立"事件 → 协程"的等待关系

我们之前学习过 Lua 的 Table。这里正好可以利用它来建立映射:

lua 复制代码
local waiters = {}

waiters["request_1001"] = co

这样当外部收到 request_1001 事件时,就可以通过:

lua 复制代码
local co = waiters["request_1001"]

找到此前挂起的协程,然后通过:

lua 复制代码
coroutine.resume(co, response)

恢复它,并把异步结果 response 传入协程。

注意,此时传入的 response 会成为协程此前 yield() 调用的返回值。

于是,我们就能够在协程内部写出这样的业务逻辑:

lua 复制代码
local response = coroutine.yield("wait", "request_1001")
print("收到响应:", response)

虽然中间经历了挂起、事件通知和恢复,但从协程内部来看,代码依然按照我们之前熟悉的线性顺序推进。

3. 完整示例:用手动事件通知模拟异步操作完成

下面我们先不实现真正的网络 I/O,而是模拟两个异步请求,并故意让它们的响应按照与请求相反的顺序到达。

重点观察:调度器如何登记等待关系,以及如何根据事件 ID 恢复正确的协程。

lua 复制代码
-- 事件 ID -> 正在等待该事件的协程
local waiters = {}

-- 统一的协程启动 / 恢复入口
local function drive(co, ...)
    local ok, action, eventId = coroutine.resume(co, ...)

    if not ok then
        print("协程执行失败:", action)
        return
    end

    -- 入口函数正常结束,无需再登记等待事件
    if coroutine.status(co) == "dead" then
        return
    end

    -- 协程主动挂起后,向调度器说明自己等待什么
    if action == "wait" then
        if waiters[eventId] ~= nil then
            error("重复的事件 ID: " .. tostring(eventId))
        end
        waiters[eventId] = co
    else
        error("未知的协程挂起请求: " .. tostring(action))
    end
end

-- 模拟外部事件通知:在真实服务器中由事件循环 / I/O 框架触发
local function onEvent(eventId, result)
    local co = waiters[eventId]

    if co == nil then
        print("没有协程等待事件:", eventId)
        return
    end

    waiters[eventId] = nil
    drive(co, result)
end

-- 协程任务:模拟异步请求已经提交,然后等待响应
local function requestTask(name, requestId)
    print(name, "发起请求:", requestId)

    -- 现在请求尚未完成,主动挂起
    local response = coroutine.yield("wait", requestId)

    -- 外部在事件到达后恢复该协程,这里才继续执行
    print(name, "收到响应:", response)
end

local coA = coroutine.create(function()
    requestTask("任务A", "request_A")
end)

local coB = coroutine.create(function()
    requestTask("任务B", "request_B")
end)

-- 启动两个协程;它们各自注册等待事件后挂起
drive(coA)
drive(coB)

print("调度器此时可以处理其他工作")

-- 手动模拟外部事件:B 的响应先到,A 的响应后到
onEvent("request_B", "响应B")
onEvent("request_A", "响应A")

输出:

text 复制代码
任务A    发起请求:    request_A
任务B    发起请求:    request_B
调度器此时可以处理其他工作
任务B    收到响应:    响应B
任务A    收到响应:    响应A

现在我们就按照之前的心智模型,重新分析这个例子。

第一阶段:两个协程发起请求,然后挂起。

这里的 requestTask() 中只是打印"发起请求"作为模拟;真实的异步提交过程并没有实现。随后两个协程分别执行:

lua 复制代码
coroutine.yield("wait", requestId)

把自己正在等待的事件 ID 传给调度器。

第二阶段:调度器登记等待关系。

协程挂起后,drive() 接收到 action 与 eventId,在 waiters 中保存对应关系:

text 复制代码
waiters["request_A"] → coA
waiters["request_B"] → coB

此时两个协程虽然都处于 suspended 状态,但我们知道它们分别在等待不同的事件。

第三阶段:事件 B 先到达。

lua 复制代码
onEvent("request_B", "响应B")

调度器根据 request_B 找到 coB,把它从等待表中移除,再执行:

lua 复制代码
drive(coB, "响应B")

于是,coB 此前的 yield() 返回 "响应B",任务 B 继续处理响应并执行结束。

第四阶段:事件 A 到达。

同样,调度器找到 coA,恢复它,完成任务 A。

所以,事件发生的先后顺序决定了相应协程的恢复时机,而不要求这些协程按照创建顺序执行完毕。

4. 这里最重要的不是写出调度器,而是认识职责划分

到这里,我们可以很清楚地看到三个不同层次:

text 复制代码
协程内部的业务逻辑
    发起操作 → 条件未就绪 → yield → 恢复后处理结果

Lua 协程基础机制
    保存执行现场 → 挂起 → 恢复 → 继续执行

外部调度器 / 事件循环
    登记等待关系 → 接收完成事件 → 定位协程 → resume

这里还需要区分:yield() 本身不是非阻塞 I/O,resume() 本身也不是事件监听器。

真正的异步能力,需要由底层 I/O 机制或宿主框架提供。例如,在游戏服务器中,可以由网络事件循环接收 Socket 就绪或请求完成通知,然后让上层调度器恢复相应的 Lua 协程。

实际项目还需要考虑事件 ID 唯一性、多个协程等待同一个事件、超时、取消、失败以及事件登记与完成通知之间的竞态。本文的示例只用来说明最基本的因果链:先登记等待关系,等事件就绪再恢复对应协程。在真实实现中,还要确保提交异步操作与登记等待关系之间不会丢失提前到达的完成事件。

至此,我们就能理解为什么 Lua 协程很适合组织游戏服务端的异步业务流程:业务代码可以保持直观的线性结构,而复杂的等待和唤醒过程交给外部事件驱动框架。


十、最后,把 Lua 协程的整套心智模型串起来

现在我们回过头来看,整个协程机制其实可以按照四个层次来理解。

第一层:执行模型

普通函数沿着调用路径向下执行,而协程允许在中途暂停,并在之后从保存的位置继续。

因此,协程不是让某一条执行线的逻辑顺序发生变化,而是让这条执行线能够在多个时间段中分段推进。

第二层:执行现场

Lua 采用有栈协程机制。这里的"栈"是 Lua 运行时维护的虚拟机栈,并配合 CallInfo 等结构保存函数调用链、局部执行数据以及字节码执行位置。

因此,协程可以在多层 Lua 函数调用中主动挂起,并在恢复后继续此前尚未完成的调用链。

第三层:执行控制与调度

create() 负责创建,resume() 负责启动或恢复,yield() 负责主动挂起。Lua 不会默认替我们实现多协程的自动轮转调度。

因此,Lua 负责"怎么挂起与恢复",外部调度器负责"什么时候恢复谁"。

第四层:事件驱动异步

在异步网络请求等场景下,我们可以让协程通过 yield() 传递正在等待的事件,外部调度器将事件与协程关联起来。当外部事件完成时,再通过 resume() 恢复对应协程,并把结果传回 yield()。

最终就形成了这样一条完整链路:

text 复制代码
Lua 业务代码发起异步操作
           ↓
      操作尚未完成
           ↓
    yield(事件标识)
           ↓
     协程挂起,保存现场
           ↓
    调度器登记等待关系
           ↓
      外部事件到达
           ↓
  调度器查询对应协程并 resume
           ↓
   yield 返回异步操作结果
           ↓
 Lua 业务代码从旧位置继续执行

这就是我们从普通函数出发,逐步推导出来的 Lua 协程模型:线程承载执行,虚拟机维护现场,协程负责可暂停的控制流,调度器根据外部事件决定恢复时机。


。

相关推荐
椒颜皮皮虾྅1 小时前
一套 C# 接口,解锁四类视觉任务:DeploySharp 分类、分割、关键点与旋转框实战
开发语言·分类·c#
2603_965896622 小时前
JavaScript 原型与原型链
开发语言·前端·javascript
朋友圈自动点赞工具2 小时前
Ruby方法查找:对象不存方法
开发语言·前端·ruby·科技资讯
D e re k ZH2 小时前
C++ 模板与泛型编程进阶(No 2)
开发语言·c++
一级新生2 小时前
LLM _ Agent _ Skill _ 专家 的关系与选型笔记
java·开发语言·人工智能·笔记·大模型·智能体·skill
starzy19902 小时前
Scala String操作:从插值器到正则的完整指南
开发语言·后端·scala
普通人62 小时前
CSS-box-sizing-使用指南
开发语言·前端·css·1024程序员节
她说彩礼65万2 小时前
C# TaskCompletionSource
java·开发语言·c#
eggwyw2 小时前
C++深入浅出探索数据结构的原理
开发语言·c++