7.1 协程的概念与基本操作


文档摘要

7.1 协程的概念与基本操作 本节摘要:协程(thread 类型)是一段可暂停恢复的执行状态:create 装配、resume 启动或续跑、yield 暂停交权。它不是操作系统线程——没有并行、没有抢占、没有锁,切换只发生在脚本自己调用 yield 的那一刻。本节讲透四状态流转、五个动词、以及 resume 与 yield 交叉处的"双向传送门"——两边参数互相喂养的语义是协程全部威力的枢纽。 不是线程的线程 先斩断最常见的误解:Lua 协程与多线程、与并行计算毫无关系。它不占独立系统线程、不利用多核、两段协程代码绝不会同时执行。把协程读作"一个会自己按暂停键的函数"就全对了:函数跑到某处说"我先歇会,这是中间结果",执行权立刻回到启动它的人手里;

7.1 协程的概念与基本操作

本节摘要:协程(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 进:第一次 resume 的参数(1、2)成为函数入口实参;后续 resume 的参数("next")成为上次 yield 的返回值;
  • yield 出:yield 的参数(a+b 即 3)成为那次 resume 的返回值(跟在 true 后面);函数跑完时 return 的值(finish、2)成为最后一次 resume 的返回值;
  • 生死标记:resume 的第一个返回值 true 表示协程还活着(这次成功推进了),false 表示协程已死亡——出错或已结束。这与 pcall 的 ok 加结果完全同构(6.2 节的伏笔),宿主拿同一个壳就能安全驱动协程。

错误处理补两句:协程内抛错,resume 返回 false 加错误值,协程进死亡态——错误不会穿透到 resume 的调用者,宿主驱动循环天然安全。反过来,死亡后再 resume 只得到那句 "cannot resume dead coroutine" 报错。调试协程有个冷知识:栈回溯只显示协程自己的栈,出错协程的现场要趁 resume 返回错误时自己拼。

wrap:语法糖版驱动

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 与协程:一个等价转换

协程版迭代器能直接挂进泛型 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 章的壳与本章的传送门在这里合流。

本节要点回顾

  • 协程是会暂停的函数:thread 类型、无并行无抢占无锁、切换只发生在 yield 一处——宿主零调度成本拿到"多逻辑推进";
  • 四状态:挂起可续、运行即当前、正常是旁站、死亡不可复活;
  • 双向传送门:resume 参数进(首跑当实参、续跑当 yield 返回值)、yield 与 return 的值出(接在 resume 的 true 后);
  • resume 的 false 即死亡或出错,错误不穿透调用者——与 pcall 同构的宿主安全壳;
  • wrap 是糖版驱动:无 true 前缀、死亡即抛错,写生成器顺手,调度循环慎用;
  • for 是遍历特例、协程是通例:中间要插控制(节拍、暂停、分发)时,把 for 译成 resume 循环;
  • 与闭包状态机对照:复杂度不消失,只从手动搬运变自动保存——状态图维护不动时就该换协程;
  • 嵌套自 resume 被语言禁止;让出点必须避开资源持有段——协作式的纪律税。

机制在手,下一节看它四处开花:生成器、管线、调度器、非阻塞模型。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U