7.2 协程的应用场景


文档摘要

7.2 协程的应用场景 本节摘要:协程的四个经典落点:生成器与迭代器(把"产出下一个"从闭包状态机变成顺序代码)、生产者消费者管线(两段逻辑交替推进的最小模型)、协作式调度器(游戏 AI 与协议状态机的组织骨架)、非阻塞驱动(把同步写法嫁接到异步 IO 上的 OpenResty 思路)。四个场景一个原理:让出点即切换点,切换权在脚本手里。 生成器起步 第 2 章用闭包写过 range 迭代器,第 3 章手写过无状态版。协程版把"记住走到哪"彻底交还给语言: 泛型 for 拿到 wrap 函数当迭代器,每轮调用即一次 resume。与闭包版的差别在复杂度:闭包版要手工把循环状态拆成 upvalue(多层嵌套循环时状态字段爆炸),协程版循环就写成循环,yield 在哪暂停哪。

7.2 协程的应用场景

本节摘要:协程的四个经典落点:生成器与迭代器(把"产出下一个"从闭包状态机变成顺序代码)、生产者消费者管线(两段逻辑交替推进的最小模型)、协作式调度器(游戏 AI 与协议状态机的组织骨架)、非阻塞驱动(把同步写法嫁接到异步 IO 上的 OpenResty 思路)。四个场景一个原理:让出点即切换点,切换权在脚本手里。

生成器起步

第 2 章用闭包写过 range 迭代器,第 3 章手写过无状态版。协程版把"记住走到哪"彻底交还给语言:

local function range(a, b, step) return coroutine.wrap(function() for i = a, b, step or 1 do coroutine.yield(i) -- 每产出一个值暂停一次 end end) end for v in range(1, 10, 3) do io.write(v, " ") end -- 输出:1 4 7 10

泛型 for 拿到 wrap 函数当迭代器,每轮调用即一次 resume。与闭包版的差别在复杂度:闭包版要手工把循环状态拆成 upvalue(多层嵌套循环时状态字段爆炸),协程版循环就写成循环,yield 在哪暂停哪。嵌套循环的例子最能分出高下——遍历矩阵、跳过零元素:

local function cells(m) return coroutine.wrap(function() for r, row in ipairs(m) do for c, v in ipairs(row) do if v ~= 0 then coroutine.yield(r, c, v) end end end end) end for r, c, v in cells({ {1, 0}, {0, 5} }) do io.write(("%d,%d=%d "):format(r, c, v)) end -- 输出:1,1=1 2,2=5

双层循环、条件过滤、多值产出,全部保持直观形态;闭包版实现同样逻辑要拆出 r、c 两个手工维护的状态变量,还在"过滤后推进"处藏一个易错分支。

递归遍历是协程生成器的杀手级应用——闭包版做树遍历要维护显式栈,协程版直接递归加 yield:

local function tree_nodes(node) -- node = {val, left, right} return coroutine.wrap(function() local function walk(n) if not n then return end walk(n.left) coroutine.yield(n.val) -- 中序位置让出 walk(n.right) end walk(node) end) end local tree = { val = 2, left = { val = 1 }, right = { val = 3 } } for v in tree_nodes(tree) do io.write(v, " ") end -- 1 2 3

生产者与消费者

两段逻辑交替推进的最小模型——生产者造数据、消费者处理数据、中间经 yield/resume 交接:

local function producer(n) return coroutine.create(function() for i = 1, n do coroutine.yield(("pkt-%02d"):format(i)) end return nil -- 结束信号 end) end local function consume(producer_co) while true do local ok, item = coroutine.resume(producer_co) if not ok or item == nil then break end io.write(item, " ") end end consume(producer(5)) -- 输出:pkt-01 pkt-02 pkt-03 pkt-04 pkt-05

真实项目里这两段各有各的复杂度:生产者可能是逐块读文件、逐行解析协议;消费者可能是逐条过滤、聚合、落库。协程让各自保持顺序代码形态,交接点就是 yield。把模型再推一步——过滤器和多级管线

local function stage(input, transform) return coroutine.wrap(function() for v in input do coroutine.yield(transform(v)) end end) end local src = coroutine.wrap(function() for i = 1, 8 do coroutine.yield(i) end end) local doubled = stage(src, function(x) return x * 2 end) local evens = stage(doubled, function(x) return (x % 4 == 0) and x or nil end) for v in evens do if v then io.write(v, " ") end end -- 输出:4 8

stage 是通用管线件:输入迭代器加变换函数,输出新迭代器。数据像水流过各级——每一级内部是普通的 for 循环,级与级之间是协程交接。日志清洗、流式转换、ETL 的每一层都可以这样搭,内存占用恒定(任一时刻只有当前元素活着)。

协作式调度器

把"几十段逻辑各自推进"组织起来,就是调度器——游戏脚本的心脏:

local Scheduler = {} Scheduler.__index = Scheduler function Scheduler.new(budget) return setmetatable({ tasks = {}, budget = budget or 100 }, Scheduler) end function Scheduler:spawn(fn, name) local co = coroutine.create(fn) self.tasks[#self.tasks + 1] = { co = co, name = name or "?" } end function Scheduler:tick() local remaining = {} for i, task in ipairs(self.tasks) do local ok, wait = coroutine.resume(task.co) if coroutine.status(task.co) ~= "dead" then task.wait = ok and (wait or 1) or 0 -- 出错任务下轮不再等待 remaining[#remaining + 1] = task if not ok then io.write(("[sched] task %s failed: %s\n"):format(task.name, tostring(wait))) end end end self.tasks = remaining return #self.tasks end -- 使用:三个任务轮流各跑一拍 local sched = Scheduler.new() sched.spawn(function() for i = 1, 3 do io.write("A", i, " "); coroutine.yield() end end, "alpha") sched.spawn(function() for i = 1, 3 do io.write("B", i, " "); coroutine.yield() end end, "beta") sched.spawn(function() io.write("C once ") end, "gamma") while sched:tick() > 0 do end -- 输出:A1 B1 C once A2 B2 A3 B3

三个细节值得咀嚼。yield 的返回值当"节拍":任务可以 coroutine.yield(5) 表示"我歇五拍",调度器按 wait 排期(上面简化为忽略,真实实现用时间轮或最小堆)。resume 的 false 分支即错误处理:6.2 节的壳原样适用,失败任务记录日志后清出列表,调度器与其它任务无恙。tick 有预算:每拍最多推进多少任务由 budget 限住——宿主的帧预算意识(1.4 节结尾的"有界循环")在调度层的体现。游戏引擎里的 AI 决策、巡逻路线、技能冷却,OpenResty 之外的网关脚本、路由器固件的事件处理,组织方式与这个三十行的骨架同构。

非阻塞的同步外观

最后一个场景是 OpenResty 的成名绝技,先用纯 Lua 把思路演出来。问题:脚本想"先查缓存、未命中再查上游、再回写"——同步写法天经地义,但宿主的 IO 是异步的(回调式)。协程的解法:

-- 思路演示:宿主提供 async_fetch(url, callback) -- 包装成"看起来同步"的协程友好的 fetch local _FIB = setmetatable({}, { __mode = "k" }) -- 协程到回调的登记表(弱键) local function fetch(url) local co = coroutine.running() -- 我是谁 async_fetch(url, function(body) -- 宿主完成时唤醒我 coroutine.resume(co, body) end) return coroutine.yield() -- 让出等数据,苏醒即拿到 body end -- 用户代码:完全是同步的样子 -- local body = fetch("upstream://pricing") -- local parsed = decode(body)

魔法揭穿只有三步:fetch 记下当前协程、把"唤醒我"注册成回调、yield 让出。宿主在数据到达时 resume,yield 的返回值就是数据。用户代码从头到尾是顺序逻辑,异步细节被压进 fetch 一个函数里——协程之前靠闭包层层嵌套的"回调金字塔",之后是一行行的顺序代码。OpenResty 的 cosocket(每连接一套协程调度)、Lua 世界的许多 async 框架,内核都是这三步。代价也继承自 7.1 节的纪律:yield 的让出点必须避开不可重入的资源段,跨 yield 持有的连接、锁要显式管理——写"同步外观"代码的人必须知道脚下有协程在切换。

调度器的进阶:节拍与优先级

7.2 节骨架里的 wait 被简化忽略了,补上完整版——任务声明"歇几拍",调度器按时间轮排期:

function Scheduler:tick() local now = self.clock or os.clock local t = now() local due, later = {}, {} for _, task in ipairs(self.tasks) do if (task.next_at or 0) <= t then local ok, wait = coroutine.resume(task.co) task.next_at = t + (ok and (tonumber(wait) or 0) or 0) if coroutine.status(task.co) ~= "dead" then later[#later + 1] = task end else later[#later + 1] = task -- 还没到点,顺延 end end self.tasks = later return #later end -- 使用:巡逻任务每三拍动一次,日志任务每拍一次 sched:spawn(function() while true do patrol_step() coroutine.yield(3) -- 歇三拍 end end, "patrol") sched:spawn(function() while true do flush_logs() coroutine.yield(1) -- 每拍 end end, "logger")

每个任务自带节拍,快任务不被慢任务拖累——协作式调度的"优先级"就是频率。再进一步可以加优先级队列(同拍内高优先跑)、加总预算(每 tick 最多推进 N 个任务防过载)、加异常退避(出错任务自动降频而不是被清场)——游戏 AI 与设备事件循环的调度器,成熟形态无非是这三件扩展。

反面清单:协程不适合的三件事

并行计算。协程不利用多核,数学密集任务(图像处理、大矩阵)协程毫无帮助——要么宿主开多状态多线程(9.2 节),要么下沉 C。

跨 yield 的原子语义。两步操作中间 yield 了,别处的逻辑就能观察到中间态——"检查再动作"的窗口比多线程还宽(因为让出点是显式的,人人容易放松警惕)。需要原子就一口气做完再 yield。

当异常机制用。resume 的 false 分支是错误报告不是流程控制,拿协程死亡做"提前退出"的信号会让调度循环充满魔法分支。要提前退,正常 return 让协程自然死亡。

协程版重试与超时:把回调包装推向纵深

回调世界的两大难题——重试与超时——在协程视角下都能写成顺序代码。以"带超时的任务"为例(宿主提供 after 秒级定时器):

local function with_timeout(fn, seconds) local co = coroutine.running() local done, result, timed_out = false local timer = after(seconds, function() -- 超时闹钟 if not done then timed_out = true coroutine.resume(co, nil, "timeout") -- 到点唤醒:注入超时结果 end end) local ok, r, err = pcall(function() local v = fn() -- 假设 fn 内部会在完成时 resume 我们 return v end) done = true cancel(timer) if timed_out then return nil, "timeout" end if not ok then return nil, r end return r end -- 用户视角:一步调用,超时自动返回 -- local body, err = with_timeout(fetch_upstream, 3.0) -- if err == "timeout" then ... end

结构要点:完成与超时是两个唤醒源(谁先到谁 resume),done 标志防止双重唤醒;超时结果经 resume 参数注入(yield 处收到 nil 加 "timeout")——传送门的双向性让"外部事件"变成"函数返回值";定时器要显式取消,否则胜出后闹钟晚些响起会误伤下一次(协程已结束,resume 死协程只是无害的 false,但干净是纪律)。重试同理:外层再套一个 7.2 节的错误码循环,退避用 yield(wait) 交给调度器睡——重试、超时、退避三件事在协程里全部回到顺序代码形态,这正是 OpenResty 的 lua-resty 库群里 timeout 与 keepalive 相关代码的写法内核。

帧内切片:长任务的另一种切法

调度器之外还有一种"切片"思路:单协程主动分片,把大任务摊到多帧——

local function process_all(items, budget_ms, do_one) local t0 = os.clock() for i, item in ipairs(items) do do_one(item) if os.clock() - t0 > budget_ms / 1000 then coroutine.yield(i) -- 预算耗尽:让出,报告进度 t0 = os.clock() end end end -- 驱动方:每帧续跑一拍 local co = coroutine.create(function() process_all(big_list, 2, handle_item) -- 每拍最多花 2 毫秒 end) while coroutine.status(co) ~= "dead" do coroutine.resume(co) render_frame() -- 帧照常出 end

与多协程调度相比,单协程切片天然保序(列表按序处理,无重入问题)、状态最简单(一个协程一个游标),适合"批量但不需要并发"的场景——资源预载、缓存预热、后台扫描。两种切法的选择:任务间独立用多协程(7.2 节调度器),任务本质是一个长流水就用切片——让出是手段,帧预算才是目的

本节要点回顾

  • 生成器:yield 即产出,循环就写成循环;嵌套与递归遍历(树的中序)是协程版对闭包版的碾压局;
  • 管线:stage 模式(输入迭代器加变换函数)组装多级流式处理,任一时刻只有当前元素在内存;
  • 调度器:yield 返回值当节拍、resume 的 false 当错误壳、tick 有预算——游戏 AI 与事件循环的通用骨架;进阶三件是优先级队列、总预算、异常退避;
  • 协作式优先级就是频率:任务自带节拍,快慢互不拖累;
  • 非阻塞同步外观:记下当前协程、注册唤醒回调、yield 等数据——三步把回调式 IO 包装成顺序代码,OpenResty 的内核思路;
  • 不适合三件事:并行计算(不碰多核)、跨 yield 原子(中间态裸奔)、异常式退出(false 不是流程控制);
  • 一个原理四个场景:让出点即切换点,切换权在脚本手里——宿主零调度成本,脚本保顺序形态。

协程讲完,语言的行李全部清点完毕。下一章走进四个真实的家:游戏引擎、Nginx、Redis、OpenWRT。


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