主题
coroutine — 协程
模块名:coroutine
标准 Lua 协程库(Lua 5.4)。协程可以将「分多步、中间需要等待」的逻辑写成一条顺序执行的函数,用 yield 主动让出、resume 恢复,而不必拆成多个回调。
在 LPPLC 上协程尤其有用:配合 system.sleep,可以在协程里编写「等 1 秒再执行下一步」这类代码而不阻塞事件循环(system.sleep 在协程里是注册定时器后 yield,到时自动恢复)。协程采用协作式调度,并非真正并行,同一时刻只有一个协程在执行。
要跑多个时,改用 task
本页讲的是裸 coroutine 的用法。如果你要同时跑好几段这样的流程, 用 task 更省事 —— 它内部就是协程,但替你管了下面几件事:
| 自己用 coroutine | 用 task |
|---|---|
要自己 coroutine.wrap 创建 | task.start(function() ... end) 一行 |
要自己用 timer.start 起头,否则创建了也不会跑 | 起了就跑 |
| 多个协程要自己排调度 | 调度器统一推进 |
| 想停要自己维护引用 | task.stop(tid) |
| 想等一个事件要自己搭订阅 | event.wait |
| — | 上限 32 个,超出会报错 |
只偶尔用一次,本页的用法就够;要长期跑、要多个、要等事件,用 task。 两者可以同时用,互不冲突。
函数总览
| 函数 | 作用 |
|---|---|
coroutine.create | 用一个函数创建协程,返回协程对象(不启动) |
coroutine.resume(...) | 启动或恢复协程运行 |
coroutine.yield(...) | 在协程内让出,挂起并把值返回给 resume 方 |
coroutine.status | 查询协程状态 |
coroutine.wrap | 创建协程并返回一个恢复它的函数 |
coroutine.isyieldable | 当前是否处于可 yield 的上下文 |
coroutine.running | 返回当前协程及它是否为主线程 |
coroutine.close | 关闭一个挂起或出错的协程,回收资源 |
coroutine.create
lua
coroutine.create(f) --> thread用函数 f 创建一个协程,只创建不运行。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | f | function | 是 | — | 协程主体函数 |
返回值
| 类型 | 说明 |
|---|---|
| thread | 协程对象,交给 coroutine.resume 运行 |
示例
lua
local co = coroutine.create(function(a, b)
print("start", a, b)
coroutine.yield()
print("resumed")
end)
coroutine.resume(co, 1, 2) -- 打印 start 1 2
coroutine.resume(co) -- 打印 resumedcoroutine.resume
lua
coroutine.resume(co, [...]) --> boolean, ...启动或恢复协程。首次 resume 时,额外参数传给协程主体函数;后续 resume 时,额外参数作为 yield 的返回值。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | co | thread | 是 | — | 协程对象 |
| 2… | ... | any | 否 | — | 首次是主体函数的入参;之后是 yield 的返回值 |
返回值
| 类型 | 说明 |
|---|---|
| boolean | 是否成功。协程内报错时为 false |
| ... | 成功时是 yield/return 带出的值;失败时第 2 个返回值是错误信息 |
说明
resume不会把协程里的错误往外抛,而是以false, errmsg的形式返回。要感知错误必须检查第一个返回值。
实用例程
lua
co2=coroutine.create(function()--建立协程2
--数据上传
x=mqtt.creat("192.168.1.100", 1883, "<名称>", "<名称>", "<名称>")--建立 MQTT 对象
x:start()--启动 MQTT 协议栈
dssc=timer.start(60000,
function()--启动定时器 60 秒发布一个数据
if mqtt.is_linked(x) and plc.d(5)==0 then
local sw = plc.d(4) /1000
local jsonstring =[[{"id":123,"dp":{"sw":[{"v":]]..sw..[[}],"err_num":[{"v":]]..plc.d(6)..[[}]}}]]
print(jsonstring)
mqtt.publish(x,"$sys/<设备ID或凭据>/xfsc_sw/dp/post/json",jsonstring,0)--支持 QOS0,QOS1,QOS2
end
end
)
end
)
coroutine.resume(co2)--启动协程(上面只创建了 co2;每个 create 出来的协程都要自己 resume 才会跑)把上面的地址、主题、引脚替换成你项目里的即可。
coroutine.yield
lua
coroutine.yield([...]) --> ...在协程内部让出执行权,挂起协程,把参数作为 resume 的返回值传出去。下次 resume 时从这里继续,yield 的返回值就是那次 resume 传入的额外参数。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1… | ... | any | 否 | — | 传回给 resume 方的值 |
返回值
| 类型 | 说明 |
|---|---|
| ... | 下次 resume 时传入的额外参数 |
说明
- 只能在协程内部调用。在主线程直接调用会报错(见
coroutine.isyieldable)。
coroutine.status
lua
coroutine.status(co) --> string查询协程当前状态。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | co | thread | 是 | — | 协程对象 |
返回值
| 类型 | 说明 |
|---|---|
| string | "suspended" 挂起(可恢复)| "running" 正在运行 | "normal" 恢复了别的协程后自身处于活动但非运行 | "dead" 已结束或出错 |
coroutine.wrap
lua
coroutine.wrap(f) --> function创建协程并返回一个函数;每次调用该函数就相当于 resume 一次,但直接返回 yield 的值(不带成功标志),协程内出错时直接把错误抛出。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | f | function | 是 | — | 协程主体函数 |
返回值
| 类型 | 说明 |
|---|---|
| function | 调用它来驱动协程,返回值即 yield/return 带出的值 |
示例
lua
local gen = coroutine.wrap(function()
for i = 1, 3 do coroutine.yield(i) end
end)
print(gen(), gen(), gen()) -- 1 2 3说明
- 写法比
create+resume简洁,适合做迭代器;缺点是协程内出错会直接抛,需要外层pcall。
coroutine.isyieldable
lua
coroutine.isyieldable() --> boolean判断当前上下文是否可以 yield(即是否在协程里)。
参数
无参数。
返回值
| 类型 | 说明 |
|---|---|
| boolean | 在协程内为 true,在主线程为 false |
coroutine.running
lua
coroutine.running() --> thread, boolean返回当前正在运行的协程,以及它是否为主线程。
参数
无参数。
返回值
| 类型 | 说明 |
|---|---|
| thread | 当前协程对象 |
| boolean | 是否为主线程(在主线程调用时为 true) |
coroutine.close
lua
coroutine.close(co) --> boolean [, string]关闭一个处于 suspended 或 dead 状态的协程,运行其中挂起的 to-be-closed 变量并回收资源,之后该协程变为 dead。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | co | thread | 是 | — | 要关闭的协程 |
返回值
| 类型 | 说明 |
|---|---|
| boolean | 是否成功 |
| string | 失败时的错误信息 |
说明
- 不能关闭正在运行(
running/normal)的协程。
例程
每个例程都是可直接下载运行的完整脚本:注册完定时器后脚本就结束,固件自动进入消息泵。 API 只用本页已文档化的函数;统一模板「场景 / 完整代码 / 要点 / 验证方法 / 注意事项」。
例程 1:把「工序」写成一段顺序代码(协程 + system.sleep)
适用:LP3201 / LP3202 / LP3203 / LP3222 / LP3232 / LP3288 / 软PLC模拟器 | 依赖模块:coroutine system timer plc | 难度:★☆☆
场景:灌装机的一轮动作是"开阀 → 等 2 秒 → 关阀 → 等 1 秒 → 通知完成"。用回调写要拆成三四层嵌套定时器;用协程写就是一段从上到下的顺序代码。关键在于里面的 system.sleep 只在协程里才不阻塞:它注册定时器后 yield,到点由主循环恢复。
完整代码(可直接下载运行):
lua
-- 一轮工序写成顺序代码;等待由协程让出,不卡主循环
local busy = false -- 防止上一轮没跑完又排一轮
local function one_cycle()
busy = true
plc.y(0, 1) -- 开阀
print("开阀")
system.sleep(2000) -- 协程内 yield,期间回调照常
plc.y(0, 0) -- 关阀
print("关阀")
system.sleep(1000)
plc.m(50, 1) -- 通知梯形图:一轮完成
print("一轮动作完成")
busy = false
end
timer.start(1000, function()
if busy or plc.x(0) ~= 1 then return end
plc.m(50, 0)
coroutine.wrap(one_cycle)() -- 每次启动一条新协程跑一轮
end)要点:
system.sleep在协程内才是非阻塞的(注册定时器 →yield→ 到时 resume);主线程里同一个调用会冻结整台设备。coroutine.wrap(f)返回的是一个"调用即 resume"的函数,比create+resume简洁;代价是协程内报错会直接抛出,外层要pcall兜。busy标志必须有:一轮没跑完又排一轮,两条协程会同时动同一个 Y,动作就乱了。- 协程是协作式的:得靠
yield/sleep主动让出才轮到别人;纯 CPU 的死循环照样卡住事件循环。 - 与梯形图的接口用 M / D(这里是 M50),不要用 Lua 全局变量跨任务同步(见并发模型概述)。
验证方法:
- 让 X0 保持 ON:日志按
开阀 →(2 秒)→ 关阀 →(1 秒)→ 一轮动作完成的顺序出现,Y0 随之通断。 - 这 3 秒等待期间,另一个
timer.start(500, …)或 MQTT 下发仍然正常响应(证明没有阻塞主循环)。 - 一轮没结束时再次满足触发条件:因为
busy为 true,不会叠加第二轮(日志不会串味)。 - 把这段搬到主线程直接调用(去掉
coroutine.wrap)作为对照:设备会卡住约 3 秒、期间所有回调失效。
注意事项:
- 忘了
coroutine.wrap直接调用one_cycle():那就在主线程跑了,sleep变成真阻塞。 coroutine.wrap会抛出协程内的错误;用coroutine.create+resume时错误藏在返回值的第二个里(false, err),不检查就"什么都不发生"。- 协程里不要去动别人正在用的输出点,协程之间没有任何互斥机制。
例程 2:轮询多台从站,每台之间让出(不一次做完)
适用:同上 | 依赖模块:coroutine system timer plc | 难度:★★☆
场景:一条 RS485 上挂 20 台从站,逐台查询要花好几秒。串行做完会把整台设备憋死;拆成"查一台让出 200ms",总耗时没变,但期间依然收云平台下发、依然(pump)其它回调。这里用协程保留"看起来顺序"的写法。
完整代码(可直接下载运行):
lua
-- 轮询 1~20 号从站:查一台让出 200ms,整条链路上事件循环全程不卡
local SLAVES = 20
local function read_slave(addr)
-- 现场把这里换成真正的取数逻辑(com_modbus 的 data 对象读回、
-- 或 plc.get 从远端搬回来的值);示意用:读 D(1000+addr)
return plc.d(1000 + addr)
end
local function poll_all()
for addr = 1, SLAVES do
local ok, val = pcall(read_slave, addr)
if ok then
plc.d(2000 + addr, val) -- 结果落到 D2001 起
else
print("从站读取失败:", addr, val)
end
system.sleep(200) -- 协程内:让出一拍
end
plc.m(51, 1) -- 一轮巡检完成
print("巡检完成,共", SLAVES, "台")
end
timer.start(60000, function() -- 每分钟巡检一轮
plc.m(51, 0)
coroutine.wrap(poll_all)()
end)要点:
pcall(read_slave, addr)把"某一台掉线"关在这一台里,不会让整轮巡检中断。- 每台之间
system.sleep(200):既给总线留出应答时间,也给主循环喘息。这就是"长耗时任务"在本设备上的标准处理方式。 - 结果统一落到 D 区(这里从 D2001 起),梯形图和云端都从 D 区取,不读 Lua 变量。
- 完成标志用 M51,梯形图/画面据此刷新"最后更新时间"。
验证方法:
- 下载运行:日志出现
巡检完成,共 20 台,M51 置 ON;D2001 起每 60 秒更新一轮。 - 轮询进行中(约 4 秒内)向设备发一条 MQTT 下发或看另一个定时器的日志:仍然在跑
- 故意让某一台读失败(注释掉 plc.d 写入改成一个会报错的调用):日志只出现一条失败,后面 addr 继续 ——
pcall生效。 - 把
system.sleep(200)删掉作为对照:整轮瞬间跑完,但期间分发被占满,其它回调明显滞后。
注意事项:
- 协程解决的是"等待时不卡",不是加速:总耗时一样。真要更快得靠
system.start_thread(仅固件)。 - 一次
timer.start排一轮,别在被触发的同一个 tick 里排多轮。 - 每台
sleep太短(比如 10ms)意义不大 —— 定时器最小周期是 10ms,太密会挤占主线程处理别的消息。
例程 3:用 create + resume 掌握每一步的返回值(外部推着走)
适用:同上 | 依赖模块:coroutine timer plc | 难度:★★☆
场景:有些流程需要协程告诉外面自己走到哪一步(比如"当前在第 3 步"),而且要由外部决定何时往下走 —— 这时用 coroutine.create + coroutine.resume,从返回值里拿进度,还能检查 false, err 拿到协程里的错误(wrap 则会直接抛)。
完整代码(可直接下载运行):
lua
-- 用 create + resume 拿到协程的每一步返回值,并检查协程是否出错
local steps = coroutine.create(function(total)
for i = 1, total do
plc.d(400, i) -- 把当前步号写进 D400
coroutine.yield("step " .. i) -- 交出进度字符串
end
return "done"
end)
timer.start(1000, function()
if coroutine.status(steps) == "dead" then return end -- 已经跑完就不再 resume
local ok, ret = coroutine.resume(steps, 5)
if not ok then
print("协程出错:", ret) -- resume 不抛错,错误在第 2 个返回值里
return
end
print("推进到:", ret, "状态:", coroutine.status(steps))
end)要点:
coroutine.resume第一个返回值是"有没有错":必须检查,否则协程里出错表现为"悄悄停了"。yield带出的值由resume的返回值带回;反过来,resume后续传入的参数会成为yield的返回值。- 用
coroutine.status(co)判断还能不能 resume("dead"说明已结束或出错,再 resume 只返回false)。 coroutine.close(co)可以关掉一个挂起的协程回收资源;正在运行(running/normal)的不能关。- 这种模式适合"每拍推进一步",由外部主动推进比
wrap更可控。
验证方法:
- 下载运行后每秒打印
推进到: step N 状态: suspended,直到出现done,之后不再推进(status已dead)。 - 上位机监视 D400:每步 +1,最后停在 5。
- 把协程里的
coroutine.yield(...)改成调用一个不存在的函数:日志打印协程出错: …(resume的第二个返回值),不会抛到主循环。 - 去掉
status == "dead"判断作为对照:跑完之后每拍打印false, cannot resume dead coroutine之类信息,逻辑上更容易出错。
注意事项:
coroutine.wrap与create的错误处理完全相反:wrap抛错、create+resume返回false, err,混着写会漏错误。- 协程不是线程:永远优先想到"它什么时候
yield",而不是"它在后台跑"。 - 主线程里直接
coroutine.yield()会报错;判断是否可 yield 用coroutine.isyieldable()。
例程 4:现场常用脚本 协程.LPLUA(六个接口一次跑完,原文)
适用:同上 | 依赖模块:coroutine | 难度:★☆☆
场景:想一次看清 create / resume / status / wrap / yield / running 到底各打印什么。 这份脚本把六个接口全部走一遍(文件 协程.LPLUA),原文收录如下。
完整代码(协程.LPLUA 原文):
lua
-- coroutine_test.lua 文件
co = coroutine.create(
function(i)
print(i);
end
)
coroutine.resume(co, 1) -- 1
print(coroutine.status(co)) -- dead
print("----------")
co = coroutine.wrap(
function(i)
print(i);
end
)
co(1)
print("----------")
co2 = coroutine.create(
function()
for i=1,10 do
print(i)
if i == 3 then
print(coroutine.status(co2)) --running
print(coroutine.running()) --thread:XXXXXX
end
coroutine.yield()
end
end
)
coroutine.resume(co2) --1
coroutine.resume(co2) --2
coroutine.resume(co2) --3
print(coroutine.status(co2)) -- suspended
print(coroutine.running())
print("----------")要点:
- 三段分别对应三种用法:
create+resume(跑完就dead)、wrap(返回的表当函数直接调)、create+ 多次resume(每调一次走一段yield)。 coroutine.status(co2)在外面打印是suspended(挂在yield上),在协程自己内部打印自己才是running—— 原脚本i == 3那两句就是专门演示这个差的。coroutine.running()返回两个值:当前线程对象 + 一个布尔值(是不是主线程)。协程里那句会多印一个true,脚本末尾那句在主线程调、印false;原脚本注释只写了thread:XXXXXX,看到多出来的true/false不是出错。- 末尾
co2停在suspended不再有人resume,这份脚本跑完就结束 —— 它是语法示范,不是常驻任务(常驻写法见例程 5)。
验证方法:
- 日志依次应看到:
1→dead→ 分隔线 →1→ 分隔线 →123runningthread: … true→suspendedthread: … false→ 分隔线。 - 把三个
coroutine.resume(co2)减到两个:最后一行status应变成suspended但只打印到2(确认“一次 resume 只推进一段”)。
注意事项:
- 原脚本里的注释(
-- 1、-- dead)标的是那一行自己产出的内容,不是它旁边的print的返回值;对照日志看容易错位。 wrap出来的函数没有“有没有错”这个返回值,协程里一报错直接抛到调用处(与create+resume完全相反)。
例程 5:现场常用脚本 协程1.LPLUA(两个常驻任务并行,原文)
适用:同上 | 依赖模块:coroutine system | 难度:★★☆
场景:想让两件互不相干的活各自每秒跑一次、又不想被定时器回调把它们串在一起(文件 协程1.LPLUA),原文收录如下。
完整代码(协程1.LPLUA 原文):
lua
co1=coroutine.create(function()--建立协程1
while true do --一直循环执行
print("co1 run")--打印输出
system.sleep(1000)--延时1秒
end
end
)
co2=coroutine.create(function()--建立协程2
while true do --一直循环执行
print("co2 run")--打印输出
system.sleep(1000)--延时1秒
end
end
)
coroutine.resume(co2)--启动协程(上面只创建了 co2;每个 create 出来的协程都要自己 resume 才会跑)要点:
- 这才是现场真正用来跑两个常驻任务的写法:
while true+system.sleep(1000)。 - 关键在
system.sleep在协程里不是睡觉,而是让出:固件先把当前协程登记一个引用防止被回收,挂上一次性定时器,然后lua_yield把协程交出去;定时到点后往 Lua 消息队列投一条唤醒消息,主循环取出来再恢复这个协程。所以两行coroutine.resume都能立刻返回、co1 与 co2 都启动成功,之后每秒各打印一次,互不抢占也不卡主线程。 - ⚠ 这条链依赖主循环在泵消息队列:脚本其他地方只要有主线程里的
system.sleep或不带yield的死循环,两个协程就会停在半路不动,看着像“任务突然不跑了”。 - ⚠ 反过来:在
system.start_thread起的独立线程里,system.sleep走的是真阻塞(不让出),那种上下文里这份脚本的 co2 永远起不来 —— 要并行请用协程配这个写法,而不是在独立线程里套协程。 co1/co2赋值给全局正好挡住了“协程被 GC”这条路(让出期间固件也会临时护住,但醒来后仍需您自己持有)。
验证方法:
- 下载运行后,日志每秒出现
co1 run与co2 run各一行,且两者时间戳差很小 —— 这就是“两个任务并行且没卡主线程”。 - 另加一个
timer.start(500, function() print("alive") end):alive应一直按 500ms 出现,证明协程没有占住主循环。 - 对照实验(只在模拟器或试验设备上做):把 co1 里的
system.sleep(1000)那一行注掉 —— 日志刷不出co2 run、定时器也不动了,因为 co1 再也不会让出。这能直观看到“协作式”三个字的意思。
注意事项:
while true没有退出条件,这两个协程会一直活着;重新下载脚本才会被清掉。需要停时在循环里加条件让协程自己return(而不是从外面“杀”它)。- 只
create不resume:什么都不会发生,且不会报错 —— 这是这份脚本最后两行不能省的原因。 - 两个协程读写同一个 D/M 寄存器时仍会互相踩:协程不是隔离,只是轮流执行,共用数据照样要自己排序。