7.1 协程的概念与基本操作 本节摘要:协程(thread 类型)是一段可暂停恢复的执行状态:create 装配、resume 启动或续跑、yield 暂停交权。它不是操作系统线程——没有并行、没有抢占、没有锁,切换只发生在脚本自己调用 yield 的那一刻。本节讲透四状态流转、五个动词、以及 resume 与 yield 交叉处的"双向传送门"——两边参数互相喂养的语义是协程全部威力的枢纽。 不是线程的线程 先斩断最常见的误解:Lua 协程与多线程、与并行计算毫无关系。它不占独立系统线程、不利用多核、两段协程代码绝不会同时执行。把协程读作"一个会自己按暂停键的函数"就全对了:函数跑到某处说"我先歇会,这是中间结果",执行权立刻回到启动它的人手里;
本节摘要:协程(thread 类型)是一段可暂停恢复的执行状态:create 装配、resume 启动或续跑、yield 暂停交权。它不是操作系统线程——没有并行、没有抢占、没有锁,切换只发生在脚本自己调用 yield 的那一刻。本节讲透四状态流转、五个动词、以及 resume 与 yield 交叉处的"双向传送门"——两边参数互相喂养的语义是协程全部威力的枢纽。
先斩断最常见的误解:Lua 协程与多线程、与并行计算毫无关系。它不占独立系统线程、不利用多核、两段协程代码绝不会同时执行。把协程读作"一个会自己按暂停键的函数"就全对了:函数跑到某处说"我先歇会,这是中间结果",执行权立刻回到启动它的人手里;之后任何时刻再按播放键,函数从暂停处无缝续跑——局部变量、执行位置、调用栈原样保留。
为什么嵌入世界需要这个东西?反着看:宿主若要支持"多段脚本逻辑同时推进",方案一是真线程——调度器、锁、栈内存每条都不便宜,几十 KB 栈乘几百个实体在设备上是灾难;方案二是状态机——每个逻辑写成"记住走到第几步"的代码,人肉维护状态字段,可读性随复杂度崩塌。协程是第三条路:编译器替你维护状态字段——暂停点自动保存一切,续跑点自动恢复一切,代码形态还是普通的顺序逻辑。切换权明确掌握在 yield 一处,宿主对时间片的控制权百分之百——这就是"自愿让出"的寄宿含义:房东不用装门禁,房客自己守规矩。

四状态:挂起(suspended,create 之后、可被 resume)、运行(running,就是当前正在跑的这个)、正常(normal,自己 resume 了别的协程、在旁等待)、死亡(dead,跑完或出错,尸位只能查询不能复活)。
五个动词对着图过一遍代码:
local co = coroutine.create(function(a, b) print("start", a, b) -- 收到第一次 resume 的参数 local got = coroutine.yield(a + b) -- 暂停:a+b 递出去,苏醒时收 got print("wake with", got) return "finish", a * b -- 死亡:终值递给最后一次 resume end) print(coroutine.status(co)) --> suspended print(coroutine.resume(co, 1, 2)) --> start 1 2 / true 3 print(coroutine.status(co)) --> suspended(yield 暂停中) print(coroutine.resume(co, "next")) --> wake with next / true finish 2 print(coroutine.status(co)) --> dead print(coroutine.resume(co)) --> false cannot resume dead coroutine
输出按行核对,语义全部落在"双向传送门"上:
错误处理补两句:协程内抛错,resume 返回 false 加错误值,协程进死亡态——错误不会穿透到 resume 的调用者,宿主驱动循环天然安全。反过来,死亡后再 resume 只得到那句 "cannot resume dead coroutine" 报错。调试协程有个冷知识:栈回溯只显示协程自己的栈,出错协程的现场要趁 resume 返回错误时自己拼。
coroutine.wrap(f) 把"创建加每次 resume"压成一个普通函数:
local gen = coroutine.wrap(function() for i = 1, 3 do coroutine.yield(i) end end) print(gen(), gen(), gen()) --> 1 2 3
wrap 返回的函数每次调用即一次 resume,直接吐 yield 的值(没有 true 前缀);协程死亡后再调用直接抛错(不像 resume 那样温和返回 false)。写迭代器与生成器用 wrap 顺手(下一节大量用),写宿主调度循环用 resume 加状态检查更稳。两者选型一句话:要错误控制选 resume,要简洁流畅选 wrap。
⚠️ 常见坑两枚。其一,对同一协程嵌套 resume:协程 A 里 resume 协程 B 是合法的(A 进正常态),但在 B 里反过来 resume A 会报 "attempt to resume the current coroutine"——自己等自己,死锁在语言层就被拦下。其二,跨协程操作不可重入资源:yield 挂在"持锁段"中间,锁的释放被无限期推迟——协作式调度没有抢占救场,让出点必须选在资源边界之外,这条纪律是下一章 OpenResty 协程模型的生命线。
协程版迭代器能直接挂进泛型 for,因为它满足"可反复调用、返回 nil 即止"的迭代协议。反过来,任何泛型 for 都能改成协程驱动——两边互译是检验理解的试金石:
-- 泛型 for 原生写法 for k, v in pairs(cfg) do handle(k, v) end -- 协程等价:把迭代包进协程,逐个 resume local co = coroutine.create(function() for k, v in pairs(cfg) do coroutine.yield(k, v) end end) while true do local ok, k, v = coroutine.resume(co) if coroutine.status(co) == "dead" then break end if ok then handle(k, v) end end
互译的价值在中间可插入逻辑:第二个版本里,每轮之间可以加节拍控制(第 7.2 节调度器的 wait 语义)、可以中途暂停整个遍历(保存 co,稍后接着跑)、可以把元素分发给多个消费者——这些在 for 循环里都要重构才能做到。for 是遍历的特例,协程是遍历的通例,需要特例的简洁时用 for,需要通例的控制力时上协程。
同一任务两种实现并排,差异更直观——任务:逐步消费一个分页接口(每页拿完再取下一页,模拟宿主回调):
-- 闭包版:手工状态机,state 显式保存在 upvalue local function make_pager(fetch) local page, idx, buf = 1, 0, {} return function() if idx >= #buf then buf, page = fetch(page), page + 1 -- 推进页码 idx = 0 if not buf or #buf == 0 then return nil end end idx = idx + 1 return buf[idx] end end -- 协程版:顺序代码,状态由语言保存 local function pager(fetch) return coroutine.wrap(function() local page = 1 while true do local buf = fetch(page) if not buf or #buf == 0 then return end for i = 1, #buf do coroutine.yield(buf[i]) end page = page + 1 end end) end
闭包版要同时维护三个状态变量并正确处理"恰好取完一页"的边界(idx >= #buf 的判断时序);协程版的循环结构与业务逻辑同构,没有额外的状态簿记。复杂度没有消失,只是从"程序员手动搬运"变成"编译器自动保存"——这正是协程存在的意义。当状态机复杂到闭包版已经要画状态图才能维护时,迁移到协程版的收益最大。
机制光看不练不牢。下面这段"乒乓对练"覆盖了本节全部动词与状态流转,建议逐行敲进解释器(注意交互模式多行输入的续行提示):
local function ping(n) for i = 1, n do local reply = coroutine.yield(("ping %d"):format(i)) -- 让出并等回话 print("ping got:", reply) end return "ping done" end local co = coroutine.create(ping) print(coroutine.status(co)) --> suspended print(coroutine.resume(co, 3)) --> true ping 1(启动,首轮 yield 挂起) print(coroutine.status(co)) --> suspended print(coroutine.resume(co, "pong-a")) --> ping got: pong-a / true ping 2 print(coroutine.resume(co, "pong-b")) --> ping got: pong-b / true ping 3 print(coroutine.resume(co, "pong-c")) --> ping got: pong-c / true ping done print(coroutine.status(co)) --> dead print(coroutine.resume(co)) --> false cannot resume dead coroutine
对照输出核对五件事:首轮 resume 的 3 是函数入口参数(for 循环上限);三次 yield 的 "ping N" 分别作为三次 resume 的第二返回值浮出;回话(pong-a、b、c) 是续跑 resume 的参数、在协程内成为 yield 的返回值;第三轮回合结束后函数自然返回 "ping done",它顶替 yield 成为最后一次 resume 的返回值;死亡后再 resume 得 false 加错误消息。五件事全部对上,双向传送门就算亲手过了关。
再把错误分支补上——协程内抛错的形态:
local bad = coroutine.create(function() error("inner failure", 0) end) print(coroutine.resume(bad)) --> false inner failure print(coroutine.status(bad)) --> dead
error(msg, 0) 的 0 表示"不加位置前缀"(干净地透传给驱动方)。resume 拿到 false 加错误值、协程死亡、驱动方毫发无伤——把这段换成宿主视角(驱动方是你 9.2 节要写的 C 循环),第 6 章的壳与本章的传送门在这里合流。
机制在手,下一节看它四处开花:生成器、管线、调度器、非阻塞模型。