Skip to content

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 返回的那个,只增不减)
statesleep 睡在延时里 | 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 下一秒可能已经结束,别存起来过一会儿再用。


三条硬规则 ​

  1. 等待类调用只能在任务里用。event.wait / event.gather 在主脚本或回调里调用会直接报错 —— 主线程卡住会把消息泵堵死,而事件正是靠泵递进来的。回调里要"等",就 task.start 一个新任务 (见事件页的常见问题)。
  2. 协作式、无抢占:只有 system.sleep / event.wait / event.gather 会让出。 长循环里必须让出,否则看门狗会掐断该任务(只死这一个,泵继续跑)。
  3. 唤醒只发生在消息泵里: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 只在想停掉它时用得上;不接住也照样跑。

验证方法:

  1. 下载运行:日志每 500ms 一轮,D100 ≤ 80 时安静,> 80 时打印 超温 … 且 M50 置 ON。
  2. 等待期间另挂一个 timer.start(200, function() print("alive") end):alive 一直按 200ms 出现, 证明任务没有占住主循环。
  3. 把 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. 下载运行:日志按 从站 1…4 各自周期出现,互不干扰。
  2. 调用 stop_all() 后再观察:所有轮询日志停止,task.list() 里这些任务消失。
  3. 把 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 回调里只做"起头",多步流程放进任务(回调里不能等待,会堵死消息泵)。

验证方法:

  1. 正常跑:日志每秒一轮,无输出。
  2. 把 D101 写 0:日志出现 这一轮出错…,但任务不死,D101 恢复后自动继续 —— pcall 生效。
  3. 在 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()),但按条数分片更简单,现场够用。

验证方法:

  1. 下载运行:约几秒后打印 批量处理完成,共 5000 条;处理期间另挂一个 200ms 的打印定时器,全程不中断。
  2. 把 if i % 50 == 0 then system.sleep(1) end 删掉作为对照:如果整段指令数越过指令上限(ESP32 50 万 / 4G 与模拟器 200 万), 任务被掐断并报 task: 单次执行指令数超过上限,完成日志不再出现。
  3. 把 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)。
  • 快照语义:打印出来的是那一刻的情况,任务号下一秒可能就失效了,只用于定位、不要缓存。

验证方法:

  1. 起两个任务(一个 sleep 循环、一个 event.wait("my_evt") 后不发事件):快照里应看到 一个 state=sleep、一个 state=wait waited=my_evt。
  2. 对等待中的任务 event.emit("my_evt"):下一份快照里它回到循环,waited 变 -。
  3. 故意把等待名写错(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 更可靠。

验证方法:

  1. 下载运行后立刻 task.stop(tid):日志打印 true,随后 工作中 不再出现,但 任务自己退出了 不会打印(被 stop 的任务不执行收尾句)—— 所以有收尾需求就用标志位自停。
  2. 改成 running = false:日志出现 任务自己退出了,且 task.list() 里任务消失。
  3. 对同一个 tid 连停两次:第一次 true、第二次 false。

注意事项:

  • 别用"停掉再起一个"来实现模式切换:频繁起停会撞任务上限,也更难排查。一个任务 + 状态判断更稳。
  • 任务里 return 就结束了;while running 的条件在让出之后才会重新求值,所以停止响应有一拍延迟,这是正常的。

常见问题 ​

任务起了却不跑 / 跑着突然停了

按顺序查三件事:

  1. 循环里有没有让出(system.sleep / event.wait)?没有 → 被看门狗掐断,日志有 task: 单次执行指令数超过上限。
  2. 是不是报错过?任务里未捕获的错误会让该任务结束并发 task_error 事件, 订阅它(见例程 3)就能看到死因。
  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(现已删除,见并发模型概述)。

LPPLC 工业物联网云平台 · 软硬件与云平台全栈自研