Skip to content

并发模型概述 ​

LPPLC 的 Lua 运行时是单线程事件循环模型:一条 lua 任务运行在 CPU core 0,PLC 梯形图扫描运行在 core 1,两者互不抢占。因此 Lua 侧采集耗时不会影响梯形图的控制周期,但 Lua 内部的各种回调是排队串行执行的。

理解这一点是写对并发代码的前提。

双核分工:梯形图独占一个核,脚本侧三种并发机制

运行时结构 ​

Lua 运行时结构:core1 梯形图扫描,core0 事件循环分发回调

main.lua 执行完顶层代码后,固件自动进入消息泵(C 侧的主循环)。所有异步回调(定时器到期、收到 MQTT 报文、串口收到数据、HTTP 请求完成)都是消息,由消息泵每一拍依次取出并调用已注册的 Lua 函数。

结论:

  • 回调之间不会并发,不需要加锁。
  • 任何一个回调阻塞,其它所有回调都会被延迟。
  • 消息队列深度有限,长时间阻塞会导致高频事件(串口/TCP 数据)被丢弃。

2026-10-22 起,脚本侧不再有 system.recv 这个入口(泵已在 C 侧,旧脚本里手写的 while true do system.recv(10) end 会报 attempt to call a nil value)—— 把那一段删掉即可,脚本该干什么干什么,泵由固件接管。

四种并发手段 ​

设备上真实可用的有下面四种,按推荐程度排列:

手段是否真并行适合做什么参考
事件回调否(排队执行)网络收发、串口收发、云平台下发各通信模块章节
timer否(排队执行)周期采集、周期上报、超时判断定时器
task + event否(协作式切换)需要一直跑、中间还要等的多步流程,配合 system.sleep / event.wait后台任务、事件订阅与发布
coroutine否(协作式切换)偶尔用一次的顺序流程,自己管理协程协程

四者都不是真并行 —— 这不是缺陷,而是刻意的设计:回调之间天然串行,就不存在竞态, 也不需要加锁。真正需要"同时"的部分(梯形图扫描)跑在另一个 CPU 核上,不受 Lua 侧影响。

关于锁:固件里没有 thread.createmutex 这类加锁接口,因为不需要 —— 同一时刻只有一段 Lua 代码在执行,不存在两个任务同时改一个变量的情况。 需要跨任务交换数据用 data 模块,或直接用 plc 寄存器。

怎么选 ​

每隔 N 秒做一次 → timer 有事发生时通知我 → event.on 一段代码要一直跑、中间还要等 → task.start 想停下来等某件事再往下写 → event.wait 几件事都办完再继续 → event.gather

system.start_thread 不要用:那是早期的多线程接口,只在部分机型上存在, 已不再维护。要后台跑用 task.start。

首选方案:timer + 事件回调 ​

多数工业场景使用 timer 配合异步回调即可满足需求,代码简短且不存在竞态。

lua
-- 一个定时器承担多个周期任务,比创建多个定时器占用资源更少
local tick = 0
local t = timer.start(1000, function()
    tick = tick + 1
    local temp = plc.d(100)                          -- 每 1s 读一次
    if tick % 5  == 0 then plc.cloud_publish("temp", temp) end  -- 每 5s 上报
    if tick % 60 == 0 then print("alive") end                   -- 每 60s
end)

system.sleep 的三种行为 ​

同一个 system.sleep(ms),在不同上下文里行为完全不同,使用时需要区分:

调用位置实际行为后果
主线程(main.lua 顶层、timer 回调、各种事件回调里)真阻塞,且不推进消息泵整个事件循环停止推进;这期间所有回调都不会被分发,消息积压甚至丢弃
task.start 起的任务里交给任务调度器,让出一拍,到点自动恢复不阻塞,其它任务与回调照常执行
协程内注册定时器后 yield,到时自动恢复不阻塞,其它回调照常执行

因此不要在 timer 回调或任何事件回调里调用 system.sleep。需要延后执行时,应再启动一个单次定时器。

lua
-- 错误:卡住整个事件循环 1 秒
timer.start(2000, function()
    write_output(1)
    system.sleep(1000)      -- 主线程阻塞
    write_output(0)
end)

-- 正确:用单次定时器接续
timer.start(2000, function()
    write_output(1)
    timer.start(1000, function() write_output(0) end, true)
end)

同样地,不要在 main.lua 顶层用 while true do ... system.sleep(n) end 当主循环。 这么写等于把消息泵掐掉(脚本永远不结束,固件根本没机会接管泵),所有 reg() / on_change / timer 回调都成了死代码。周期任务交给 timer.start,脚本 return 后固件会自动进入消息泵。

lua
-- 错误:回调永远不触发
tcpc:reg(tcp_client.RECVDATA, function(data) print(data) end)
while true do
    plc.d(100, plc.d(100) + 1)
    system.sleep(1000)
end

-- 正确:周期任务用定时器,消息泵交给固件
tcpc:reg(tcp_client.RECVDATA, function(data) print(data) end)
timer.start(1000, function()
    plc.d(100, plc.d(100) + 1)
end)

长耗时任务怎么处理 ​

遇到"一次要做很久"的活(逐条轮询几十台慢速串口设备、大文件分块校验),不要试图把它一次做完, 而要拆成小步,每步交还控制权。三种写法:

用协程写,逻辑看起来还是顺序的:

lua
-- 轮询 30 台从站,每台之间让出 200ms,事件循环全程不卡
local function poll_all()
    for addr = 1, 30 do
        local ok, val = pcall(read_slave, addr)
        if ok then plc.d(1000 + addr, val) end
        system.sleep(200)      -- 协程内的 sleep 不阻塞,会 yield
    end
end
timer.start(60000, function() coroutine.wrap(poll_all)() end)

用任务写,连定时器起头都省了(任务里可以一直跑,自带让出):

lua
-- 同一个需求的任务版:起一个常驻任务,循环里让出
task.start(function()
    while true do
        for addr = 1, 30 do
            local ok, val = pcall(read_slave, addr)
            if ok then plc.d(1000 + addr, val) end
            system.sleep(200)  -- 任务内:让出一拍
        end
        system.sleep(40000)   -- 一轮完事歇着,到点自动下一轮
    end
end)

或者用定时器自己记进度,适合步骤之间没有共享状态的场合:

lua
local addr = 0
timer.start(200, function()
    addr = addr % 30 + 1
    local ok, val = pcall(read_slave, addr)
    if ok then plc.d(1000 + addr, val) end
end)

三种写法的耗时总量和阻塞式写法一样,区别在于期间设备仍然响应云平台下发、仍然收串口数据。

例程 ​

这一节三个例程全是「错误写法 vs 正确写法」对照 —— 正是 AI 和新手最常撞的那堵墙。 每段都可以单独下载验证:错误版会看到"设备活着但回调全死",正确版没有任何等待语句却一样按周期跑。

例程 1:自己补的主循环 vs 定时器驱动 ​

适用:LP3201 / LP3202 / LP3203 / LP3222 / LP3232 / LP3288 / 软PLC模拟器 | 依赖模块:timer tcp_client plc | 难度:★☆☆

场景:想每 1 秒把 D100 加 1,同时收 TCP 下发。直觉写法是在脚本末尾补一个 while 主循环 + sleep,这是本平台上最致命的错误:脚本永远不结束,固件没机会进入消息泵,注册过的回调一行都跑不到。

错误写法(❌ 不要这么写):

lua
-- ❌ 错误:自补主循环,消息泵永远不会启动,所有回调永远不触发
tcpc:reg(tcp_client.RECVDATA, function(data) print(data) end)
while true do
    plc.d(100, plc.d(100) + 1)
    system.sleep(1000)      -- 真阻塞,且不推进消息泵
end

正确写法(可直接下载运行):

lua
-- 正确:周期任务交给定时器,脚本结束由固件进入消息泵
tcpc:reg(tcp_client.RECVDATA, function(data) print("收到:", data) end)
tcpc:start()

timer.start(1000, function()
    plc.d(100, plc.d(100) + 1)
end)

要点:

  • 用户脚本执行完后,固件自动进入消息泵接手分发;脚本里再写一个 while true 主循环,等于把这条路堵死。
  • 回调 "看起来注册成功"不代表会触发:只要泵没在转,它们就是死代码。
  • 报错表现最有迷惑性:设备在线、网络正常、TCP 显示已连上,就是收不到数据。

验证方法:

  1. 先跑正确版:D100 每秒 +1,同时 TCP 下发能在日志里看到 收到:。
  2. 换成错误版:D100 也在加,但下发的回调一条都不打印 —— 这就是「回调失效」的实际样子。
  3. 在错误版里把整个 while true ... end 删掉、让脚本直接结束:回调恢复(固件自动接管泵)。

注意事项:

  • sleep 只在任务内或协程内才不阻塞,主线程的任何地方都不要用。
  • 多线程里常见的"主循环 + 延时"写法在这里不存在对应物,别移植其它平台的习惯。

例程 2:回调里想延时 → 用单次定时器接续 ​

适用:同上 | 依赖模块:timer plc | 难度:★☆☆

场景:"先开、隔 1 秒再关" 这类需求在回调里最容易写成阻塞版。回调本来就在主线程里跑,里面的 sleep 会把所有回调一起拖住,包括正在等待超时判断的那些。

错误写法(❌ 不要这么写):

lua
-- ❌ 错误:回调里 sleep,整个事件循环停 1 秒
timer.start(2000, function()
    write_output(1)
    system.sleep(1000)      -- 主线程阻塞,期间定时器/MQTT/串口全停
    write_output(0)
end)

正确写法(可直接下载运行):

lua
-- 正确:延时用单次定时器接续(true = 只触发一次)
timer.start(2000, function()
    write_output(1)
    timer.start(1000, function()
        write_output(0)
    end, true)
end)

要点:

  • 第三参 true 让定时器只跑一次,跑完自动释放,不需要 stop()(见 timer)。
  • 多步工序就继续"接力",每步一个单次定时器;可读性确实不如协程,但它对主线程最友好。
  • 想写成"顺序代码"就用协程或任务(那里的 system.sleep 是让出),见 coroutine 例程 1 与 task。

验证方法:

  1. 正确版:每次都会看到「开→(1 秒)→关」,且这 1 秒期间其它定时器回调照常打印。
  2. 错误版:1 秒的暂停期间,另挂一个每 200ms 打印的定时器会明显停住(不止是被停的那一个)。
  3. 三种写法都试一遍(阻塞版 / 单次定时器 / 协程任务)再看本章前面那张对照表,理解会更牢。

注意事项:

  • 消息队列深度有限:哪怕只卡 1 秒,串口/TCP 高频数据也可能被丢弃且无法找回。
  • 回调里建议一律 pcall 包住,未捕获错误会让该回调中断、后续消息继续派发(表现是"某项功能莫名其妙不动了")。

例程 3:长耗时任务 —— 一次做完 vs 拆成小步 ​

适用:同上 | 依赖模块:timer task plc | 难度:★★☆

场景:一次轮询 30 台从站要几秒。选一次做完还是拆成小步,是明确的取舍:一次做完代码最直白,但这几秒里设备对外失联;拆成小步总耗时一样,期间仍然响应云平台下发和串口数据。下面两种写法对应本章「长耗时任务怎么处理」的那几段。

写法 A:协程保留顺序写法(推荐,可直接下载运行)

lua
-- 拆步版 A:协程里的 sleep 是 yield,事件循环全程不卡
local function poll_all()
    for addr = 1, 30 do
        local ok, val = pcall(read_slave, addr)
        if ok then plc.d(1000 + addr, val) end
        system.sleep(200)                 -- 协程内:让出一拍
    end
end
timer.start(60000, function() coroutine.wrap(poll_all)() end)

写法 B:一个常驻任务(可直接下载运行)

lua
-- 拆步版 B:任务里循环 + 让出,不需要定时器起头
task.start(function()
    while true do
        for addr = 1, 30 do
            local ok, val = pcall(read_slave, addr)
            if ok then plc.d(1000 + addr, val) end
            system.sleep(200)             -- 任务内:让出一拍
        end
        system.sleep(40000)               -- 歇 40 秒再来一轮
    end
end)

写法 C:定时器自己记进度

lua
-- 拆步版 C:没有协程/任务参与,靠每个 tick 推进一台
local addr = 0
timer.start(200, function()
    addr = addr % 30 + 1
    local ok, val = pcall(read_slave, addr)
    if ok then plc.d(1000 + addr, val) end
end)

要点:

  • 三种写法的总耗时一样,区别是"期间设备还在不在"。
  • 写法 A 适合步骤之间有共享状态、逻辑要写顺的场合;写法 B 适合"这一轮完还有下一轮"的常驻场景; 写法 C 适合步骤彼此独立、又不想引入协程/任务的场合。
  • read_slave 换成真实逻辑后别忘了 pcall:某一台掉线不该毁掉整轮。
  • 任务里连续执行超过指令上限会被看门狗掐断(只死这一个任务):ESP32 机型约 50 万条,4G 机型与内置模拟器约 200 万条 —— 大批量循环记得分批让出,见 task 例程 4。

验证方法:

  1. 跑任一版本:等待差不多 6 秒后 30 个寄存器全部更新一次。
  2. 轮询进行中发一条云平台下发:能即时响应(一次做完的那种写法做不到)。
  3. 把写法 A/B 里的 system.sleep(200) 改成 0/删掉作为对照:整轮瞬间跑完,但期间对所有外部分发都迟钝。

注意事项:

  • 协程/任务里的 CPU 死循环照样卡住全局 —— 它们只在 yield / sleep / wait 处让出。
  • 别在一个 tick 里注册一大堆定时器企图"并行",它们仍然串行执行。

注意事项 ​

  • 事件回调内部建议用 pcall 捕获异常。未捕获的错误会让该次回调中断,表现是"某项功能莫名不动了",且不一定有显眼报错。
  • 垃圾回收由消息泵在无消息的空拍步进。事件密集期 GC 会滞后,内存峰值升高,因此不建议在回调中频繁创建大表。
  • 与梯形图交换数据应统一使用 plc.* 接口,它内部是线程安全的;不要用 Lua 全局变量跨任务同步。
  • 脚本里不要再写 system.recv(2026-10-22 已删除):需要等待时用任务 + event.wait,需要周期时用 timer.start。

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