主题
task — 后台任务
模块名:task
让一段代码在后台一直跑,或者按步骤一步一步往下走,同时不影响定时器、网络回调、串口数据、云平台下发的正常响应。
lua
-- 每 200ms 读一次 PLC 的 D100;期间云平台下发照样即时响应
local tid = task.start(function()
while true do
local v = plc.d(100)
if v > 80 then
print("超温 " .. v)
end
system.sleep(200) -- 让出一拍:别的任务和回调照常执行
end
end)task 起的是协作式任务,不是线程。同一时刻只有一个任务在跑,任务之间不会互相打断, 所以不需要加锁(详见并发模型概述)。它靠消息泵推进 —— 任务里必须定期调用 system.sleep 或 event.wait,把执行权交回去。
什么时候用它
| 你的需求 | 用什么 |
|---|---|
| 每隔一段时间做一次 | timer |
| 有事发生时通知我,我不等它 | event.on |
| 一段代码要一直跑,中间还要等 | task.start |
| 停下来等某件事发生再往下写 | event.wait |
一个经验判断:如果你的代码写成 while true do ... end 会更自然,就该用 task。 如果写成"每隔 N 秒做一次"更自然,用 timer 就够了。
函数总览
task 对外只有 3 个函数(与 event 的 6 个合起来是 9 个, task 与 event 两个模块是配套使用的)。
| 函数 | 作用 |
|---|---|
task.start(fn, ...) | 起一个后台任务,返回任务号 |
task.stop(tid) | 停掉一个任务 |
task.list() | 排障:看每个任务还活着吗、卡在等什么 |
event.on / event.wait / event.off / event.emit / event.gather / event.done | 事件的订阅、等待与发布(与 task 配套) |
任务里让出/延时用
system.sleep(ms),没有单独的task.sleep。
task.start
task.start(fn, ...) -> tid| 项 | 说明 |
|---|---|
fn | 任务体,一个函数。写成 while true do ... end 就会一直跑 |
... | 可选,要传给任务体的参数,原样传入 |
返回 tid | 任务号,给 task.stop 用。不用保存也不会停,任务会一直跑到被停掉或程序重启 |
起完下一拍(消息泵推进一拍)任务体就开始跑,不是当场同步执行。
示例:后台轮询
lua
local tid = task.start(function()
while true do
local temp = plc.d(100)
if temp > 80 then
print("超温 " .. temp)
end
system.sleep(500) -- 每 500ms 一次
end
end)
-- 想停掉时:
task.stop(tid)示例:带参数
lua
task.start(function(addr, interval)
while true do
local v = plc.d(addr)
print("addr=" .. addr .. " v=" .. v)
system.sleep(interval)
end
end, 100, 1000)示例:多个任务各自跑
lua
task.start(function()
while true do
print("采集")
system.sleep(1000)
end
end)
task.start(function()
while true do
print("上报")
system.sleep(5000)
end
end)两个任务互不影响,也不会互相打断 —— 它们是排队执行的,不是真的同时跑。
限制
- 同时最多 32 个任务(满了会报
task.start: 任务数已达上限 32)。这不是可以随手调大的参数 —— 任务多了每个分到的执行时间就少,现场表现为"程序反应变慢",不如早一点报错。 别"一件事一个长驻任务":短任务用完会自动回收槽位,重复的活用"一个任务 + 一张表"管起来 (见例程 2。 - 任务里必须让出。写成
while true do end不带system.sleep/event.wait会霸占整个消息泵, 连续执行超过单任务指令上限会被看门狗掐断(ESP32 机型约 50 万条,4G 机型与内置模拟器 200 万条): 该任务结束并报task: 单次执行指令数超过上限,其它任务和主流程照常运行(见例程 4)。 - 任务里报错只影响它自己。该任务结束,同时发一条
task_error事件 (a= 任务号),其余任务和主流程照常运行。
task.stop
task.stop(tid) -> true / false停掉一个任务。返回 true 表示停掉了,false 表示这个任务本来就不存在(或已经自己结束了)。
lua
local tid = task.start(function()
while true do system.sleep(100) end
end)
task.stop(tid) -- true
task.stop(tid) -- false(已经停过了)不知道任务还在不在,可以用返回值判断:
lua
if task.stop(tid) then
print("任务已停止")
else
print("任务早已结束")
end任务自己想结束,直接 return 就行,不必调 task.stop。
注意:协作式没有抢占,stop 是"下一拍不再推进它",不是当场一刀切。 正在跑的任务停不了自己(包括在任务里 task.stop(自己的tid))—— 会返回 false, 要停自己在循环里加退出条件(见例程 6)。
task.list
task.list() -> { {id=, state=, waited=}, ... }排障专用:返回当前每个存活任务的快照。日常产品脚本不需要它,调试"任务怎么不动了"时最好用。
| 字段 | 含义 |
|---|---|
id | 任务号(task.start 返回的那个,只增不减) |
state | sleep 睡在延时里 | wait 在等事件 | gather 在等栅栏集齐 | ready 就绪等下一拍 | run 正在跑 |
waited | 正在等的事件名(没在等就是空字符串);多事件等待只报第一个 |
lua
-- 每隔 5 秒打印一次任务快照,看谁卡在哪
task.start(function()
while true do
for _, t in ipairs(task.list()) do
print(string.format("任务 %d 状态 %s 等 %s",
t.id, t.state, t.waited == "" and "-" or t.waited))
end
system.sleep(5000)
end
end)拿到的是快照:里面的 id 下一秒可能已经结束,别存起来过一会儿再用。
三条硬规则
- 等待类调用只能在任务里用。
event.wait/event.gather在主脚本或回调里调用会直接报错 —— 主线程卡住会把消息泵堵死,而事件正是靠泵递进来的。回调里要"等",就task.start一个新任务 (见事件页的常见问题)。 - 协作式、无抢占:只有
system.sleep/event.wait/event.gather会让出。 长循环里必须让出,否则看门狗会掐断该任务(只死这一个,泵继续跑)。 - 唤醒只发生在消息泵里:
event.emit只是把任务标成就绪,绝不在emit里当场恢复任务。 所以emit返回时订阅回调已经跑完,而等待者要下一拍才醒。
例程
每个例程都是可直接下载运行的完整脚本:注册完任务后脚本就结束,固件自动进入消息泵。 API 只用本页与事件页已文档化的函数;统一模板「场景 / 完整代码 / 要点 / 验证方法 / 注意事项」。
例程 1:后台周期采集,超温写标志位
适用:LP3201 / LP3202 / LP3203 / LP3222 / LP3232 / LP3288 / LP8910 / LP8913 / LP8916 / 软PLC模拟器 | 依赖模块:task plc | 难度:★☆☆
场景:每 500ms 采一次 D100 温度,超过 80 置 M50 报警位、并在日志里留痕。 这就是"一段代码在后台一直跑"最常用的形状。
完整代码(可直接下载运行):
lua
-- 后台采集 + 超温判断;采集节奏用 system.sleep 定,不占主线程
local tid = task.start(function()
while true do
local temp = plc.d(100)
if temp > 80 then
plc.m(50, 1) -- 报警位给梯形图用
print("超温 " .. temp)
else
plc.m(50, 0)
end
system.sleep(500) -- 让出一拍,期间回调照常
end
end)
print("采集任务已启动,任务号 " .. tid)要点:
while true+system.sleep是任务的标准形状;sleep 在任务里不是睡觉,是让出, 下一拍自动回来,期间定时器、网络、串口回调都不受影响。- 与梯形图的接口用 M / D 寄存器(这里是 M50),不要用 Lua 全局变量跨任务同步。
- 任务号
tid只在想停掉它时用得上;不接住也照样跑。
验证方法:
- 下载运行:日志每 500ms 一轮,D100 ≤ 80 时安静,> 80 时打印
超温 …且 M50 置 ON。 - 等待期间另挂一个
timer.start(200, function() print("alive") end):alive一直按 200ms 出现, 证明任务没有占住主循环。 - 把
system.sleep(500)删掉作为对照:任务会被看门狗掐断并报task: 单次执行指令数超过上限,之后alive也停了 —— 这就是"不让出"的后果。
注意事项:
- 周期任务首选其实是
timer;用task的理由是"中间还要等别的事" (等事件、等多步),纯周期场景用 timer 更省。 - 采集值不要走事件传给其它代码(见事件页的提醒),共享数据用 D 寄存器或
data模块。
例程 2:一个任务管一路设备(带参起多个同类任务)
适用:同上 | 依赖模块:task plc | 难度:★★☆
场景:现场 4 台从站要各自轮询,直觉是起 4 个长驻任务。任务上限是 32, "一个设备一个长驻任务"在设备多起来后必然撞墙。正确形状是一个任务 + 一张表: 任务体带参,管一路;要扩展就改表,不改代码。
完整代码(可直接下载运行):
lua
-- 一张表描述所有从站:一个任务管一路,扩容只改这张表
local SLAVES = {
{ addr = 1, d_base = 100, period = 500 },
{ addr = 2, d_base = 200, period = 1000 },
{ addr = 3, d_base = 300, period = 1000 },
{ addr = 4, d_base = 400, period = 2000 },
}
local function poll_one(s)
while true do
-- 现场把下一行换成真正的取数逻辑(com_modbus / pipe 等),示意用 plc.d
local v = plc.d(s.d_base)
print(string.format("从站 %d 读到 %d", s.addr, v))
system.sleep(s.period) -- 每路各自的节奏
end
end
local tids = {} -- 想全停就遍历它
for _, s in ipairs(SLAVES) do
tids[#tids + 1] = task.start(poll_one, s)
end
-- 需要全停时(比如停机工序):
local function stop_all()
for _, tid in ipairs(tids) do task.stop(tid) end
end要点:
task.start(fn, 参数)的实参原样传给任务体;表是整个传进去的,任务里直接s.addr这么用。- 每路任务在自己的
system.sleep上让出,4 路自然错开,不需要额外做"分时"。 - 任务号收集在
tids表里,stop_all一键全停;task.stop对已结束的任务返回false,重复停不会报错。 - 从站数量继续涨(超过 32 路)时,改成"一个任务顺序轮询多路"(见例程 4的分片写法),而不是继续加任务。
验证方法:
- 下载运行:日志按
从站 1…4各自周期出现,互不干扰。 - 调用
stop_all()后再观察:所有轮询日志停止,task.list()里这些任务消失。 - 把
SLAVES改成 40 项再下载:task.start在第 33 个报任务数已达上限 32, 前 32 个照常跑 —— 报错文本里就是本机的上限数字。
注意事项:
- 回调/任务里不要"一事一个任务"地堆:重活用表排队,短任务用完自动回收。
- 每路的
period别小于 10ms:定时器与泵的节奏是 10ms 级,更密没有意义还挤占别人。
例程 3:任务崩了自愈(pcall + task_error)
适用:同上 | 依赖模块:task event | 难度:★★☆
场景:任务里调用现场协议,某一次调用可能抛错(设备掉线、返回格式不对)。 任务一抛错就整个结束,之后再也不干活 —— 现场表现为"跑着跑着某项功能消失了"。 两层防护:任务内 pcall 兜住可恢复的错;task_error 事件兜住任务级的死亡,用来报警和重起。
完整代码(可直接下载运行):
lua
-- 可能抛错的一段业务:现场换成真实的协议调用
local function heavy_job()
local v = plc.d(100) / plc.d(101) -- D101 为 0 时会抛错
plc.d(200, v)
end
local worker
worker = function()
task.start(function()
while true do
local ok, err = pcall(heavy_job) -- 兜住这一轮的错
if not ok then
print("这一轮出错,下一轮再说:" .. tostring(err))
end
system.sleep(1000)
end
end)
end
-- 任务级死亡监控:task_error 的 a = 任务号
event.on("task_error", "*", function(k, tid, code)
print(string.format("任务 %d 异常结束(错误码 %d),重启它", tid, code))
worker() -- 重起一个
end)
worker()要点:
pcall把"某一轮出错"关在这一轮里,任务本身不死;适合可重试的错。task_error是脉冲型系统事件:任务异常结束(含被看门狗掐断)时发一条,a= 任务号、b= 错误码;订阅它等于给所有任务上了死亡保险。- 重起逻辑放在
event.on回调里只做"起头",多步流程放进任务(回调里不能等待,会堵死消息泵)。
验证方法:
- 正常跑:日志每秒一轮,无输出。
- 把 D101 写 0:日志出现
这一轮出错…,但任务不死,D101 恢复后自动继续 ——pcall生效。 - 在
heavy_job里注掉 pcall 并让 D101 保持 0:日志出现任务 N 异常结束…并自动重起 ——task_error生效(重起后又会死,属于预期,现场应报警而非无限重起)。
注意事项:
- 无限重起会刷屏,现场写法是计数限流:同一任务 N 秒内死超过 M 次就只报警不重起。
- 回调里不要做重活(见事件页常见问题),起个头就返回。
例程 4:大批量长任务分片让出
适用:同上 | 依赖模块:task plc | 难度:★★☆
场景:一次要处理 5000 条记录(查表、换算、写库)。一次做完会把消息泵堵住几十秒, 期间云平台下发、串口数据全部积压;而且单次连续执行超过指令上限会被看门狗直接掐断(ESP32 约 50 万条,4G 机型与模拟器约 200 万条)。 标准做法:分批,每批之间让出一拍。
完整代码(可直接下载运行):
lua
-- 5000 条分片处理:每 50 条让出一拍,全程不卡泵
local TOTAL = 5000
task.start(function()
for i = 1, TOTAL do
local v = plc.d(1000 + (i % 100)) -- 现场换成真实处理
if v > 100 then
plc.d(2000 + (i % 100), v)
end
if i % 50 == 0 then
system.sleep(1) -- 让出一拍;1ms 就够
end
end
print("批量处理完成,共 " .. TOTAL .. " 条")
end)要点:
- 看门狗口径:单次 resume 连续执行超过指令上限即被判死循环(ESP32 机型约 50 万条;4G 机型与内置模拟器约 200 万条),该任务被结束。 普通业务循环每 50~100 次迭代
system.sleep(1)就绝对安全。 system.sleep(1)的代价只是一拍(约 10ms 级),5000 条分 100 片总共多花不到 1 秒, 换来的是全程可响应 —— 长任务的标配写法。- 也可以按时间让出(比如每批记录一下
os.clock()),但按条数分片更简单,现场够用。
验证方法:
- 下载运行:约几秒后打印
批量处理完成,共 5000 条;处理期间另挂一个 200ms 的打印定时器,全程不中断。 - 把
if i % 50 == 0 then system.sleep(1) end删掉作为对照:如果整段指令数越过指令上限(ESP32 50 万 / 4G 与模拟器 200 万), 任务被掐断并报task: 单次执行指令数超过上限,完成日志不再出现。 - 把
TOTAL改成 500000 再跑:分批版照常完成,一次做完版必被掐断。
注意事项:
- 分片期间任务仍是"活着但没跑完",
task.list()里它的state在sleep/ready之间跳。 - 别把
sleep(1)写成sleep(0)图省事:0 表示"让出一拍",语义上没问题,但写成 1 更直观。
例程 5:用 task.list 排障(任务卡在哪)
适用:同上 | 依赖模块:task | 难度:★☆☆
场景:现场报"某项功能不动了"。用 task.list() 打一张快照,看每个任务卡在什么状态: wait 说明在等某个事件(看 waited 字段是不是你期望的名字),sleep 说明在延时, run / ready 长驻不动才可疑。
完整代码(可直接下载运行):
lua
-- 常驻排障任务:每 5 秒一份快照;现场用日志/上位机观察
task.start(function()
while true do
local list = task.list()
print("---- 任务快照(共 " .. #list .. " 个)----")
for _, t in ipairs(list) do
print(string.format(" id=%d state=%s waited=%s",
t.id, t.state, t.waited == "" and "-" or t.waited))
end
system.sleep(5000)
end
end)要点:
state五态:sleep(延时中)/wait(等事件)/gather(等栅栏集齐)/ready(就绪等下一拍)/run(正在跑)。waited直接告诉你它在等哪个事件名 —— 事件名写错这类事故,在这里一眼可见 (等一个永远不来的名字,状态会长期停在wait)。- 快照语义:打印出来的是那一刻的情况,任务号下一秒可能就失效了,只用于定位、不要缓存。
验证方法:
- 起两个任务(一个
sleep循环、一个event.wait("my_evt")后不发事件):快照里应看到 一个state=sleep、一个state=wait waited=my_evt。 - 对等待中的任务
event.emit("my_evt"):下一份快照里它回到循环,waited变-。 - 故意把等待名写错(
event.wait("my_evtt", "*")之类拼错的自定义名不会报错、只会永远等): 快照长期停在wait,waited字段就是证据。
注意事项:
task.list是排障工具,别写进产品逻辑(比如用它决定业务走向)—— 它是快照,不是状态机。- 系统事件名拼错会当场报错并列出现有事件名;只有自定义事件名才会静默等待,所以自定义名要照着抄。
例程 6:可停止的常驻任务(退出条件 + task.stop)
适用:同上 | 依赖模块:task plc | 难度:★★☆
场景:任务要能被外面停掉(停机工序、模式切换)。两个手段配合: 循环里的退出条件(任务自己退),和 task.stop(tid)(外面退)。 注意正在跑的任务停不了自己,所以自停必须走退出条件。
完整代码(可直接下载运行):
lua
local running = true -- 退出条件:置 false 任务自己退
local tid = task.start(function()
while running do
print("工作中")
system.sleep(200)
end
print("任务自己退出了") -- 收尾写在这里
end)
-- 手段一:外面停(返回 true = 停掉了;false = 已经不在)
print(task.stop(tid))
-- 手段二:让它自己停(在任务里/回调里改标志位即可)
-- running = false
-- 在任务里停自己是无效的(返回 false)——要退就改退出条件:
task.start(function()
local me = nil
local function self_demo()
me = task.start(function()
task.stop(me) -- ❌ 自己停自己:返回 false,停不掉
end)
end
self_demo()
end)要点:
task.stop停的是"下一拍不再推进它":任务正挂在sleep/wait上时最容易被停; 返回false只说明"它已经没了",不是出错。- 自己停自己返回
false:停的时刻任务正在运行,协作式没有抢占。要自停就用running这样的标志位。 - 收尾(关阀、存数)写在函数体最后一句
return之前,比在外面stop更可靠。
验证方法:
- 下载运行后立刻
task.stop(tid):日志打印true,随后工作中不再出现,但任务自己退出了不会打印(被 stop 的任务不执行收尾句)—— 所以有收尾需求就用标志位自停。 - 改成
running = false:日志出现任务自己退出了,且task.list()里任务消失。 - 对同一个
tid连停两次:第一次true、第二次false。
注意事项:
- 别用"停掉再起一个"来实现模式切换:频繁起停会撞任务上限,也更难排查。一个任务 + 状态判断更稳。
- 任务里
return就结束了;while running的条件在让出之后才会重新求值,所以停止响应有一拍延迟,这是正常的。
常见问题
任务起了却不跑 / 跑着突然停了
按顺序查三件事:
- 循环里有没有让出(
system.sleep/event.wait)?没有 → 被看门狗掐断,日志有task: 单次执行指令数超过上限。 - 是不是报错过?任务里未捕获的错误会让该任务结束并发
task_error事件, 订阅它(见例程 3)就能看到死因。 - 到底还在不在、卡在哪?
task.list()打一份快照(见例程 5)。
task.start: 任务数已达上限 32
同时存活的任务到 32 个了。别扩上限,改结构:重复的活合并成"一个任务 + 一张表" (例程 2),一次性的活用完就 return(槽位自动回收)。
task.stop 返回 false
三种情况都返回 false:任务号不存在(从来没起过或已结束)、任务已经自己退了、 在任务里停自己。用返回值判断即可,不是错误。
任务里能调用 event.emit 吗
能,而且很常见(任务算完把结果广播出去)。但 event.wait / event.gather 只能在任务里用, event.on 的回调在任何地方都能注册 —— 四个函数的场合区别见事件页。
和 timer 怎么分工
| 场景 | 选型 |
|---|---|
| 每隔 N 秒做一次,步骤之间无等待 | timer |
| 做一步、等一下、再做一步(有先后顺序) | task |
| 有事发生时通知我,我不等 | event.on |
| 停下来等某件事再往下写 | event.wait |
多步流程用 task 写出来是平铺的;用 timer 就得拆成多个回调加状态标志。
与 coroutine 的关系
task 内部就是协程,但替你管理了调度 —— 你不用自己 coroutine.wrap、 自己起定时器、自己管恢复时机、自己维护引用。只想偶尔用一次协程,直接用 coroutine 也可以,两者不冲突。
system.start_thread 不要用:那是早期的多线程接口,只在部分机型上存在, 行为与其它机型不一致,已不再维护。要后台跑请用 task.start。
老固件的行为不一样(2026-10-22 之前)
更早的固件上,本页有几个写法不成立,老工程迁移时注意:
- 等待事件的方法叫
task.wait(现在叫event.wait,且第 2 参是编号key); event.on(name, cb, [once])没有编号参数(现在第 2 参要写编号或"*");event.wait可以传一张表等"任意一个"事件(现在只支持单个事件名,多路等待请起多个任务);- 任务上限随机型不同(ESP32 是 16、其它平台 8),现在是全平台 32;
system.recv还存在,脚本末尾常手写while true do system.recv(10) end(现已删除,见并发模型概述)。