主题
并发模型概述
LPPLC 的 Lua 运行时是单线程事件循环模型:一条 lua 任务运行在 CPU core 0,PLC 梯形图扫描运行在 core 1,两者互不抢占。因此 Lua 侧采集耗时不会影响梯形图的控制周期,但 Lua 内部的各种回调是排队串行执行的。
理解这一点是写对并发代码的前提。
运行时结构
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 显示已连上,就是收不到数据。
验证方法:
- 先跑正确版:D100 每秒 +1,同时 TCP 下发能在日志里看到
收到:。 - 换成错误版:D100 也在加,但下发的回调一条都不打印 —— 这就是「回调失效」的实际样子。
- 在错误版里把整个
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 秒的暂停期间,另挂一个每 200ms 打印的定时器会明显停住(不止是被停的那一个)。
- 三种写法都试一遍(阻塞版 / 单次定时器 / 协程任务)再看本章前面那张对照表,理解会更牢。
注意事项:
- 消息队列深度有限:哪怕只卡 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。
验证方法:
- 跑任一版本:等待差不多 6 秒后 30 个寄存器全部更新一次。
- 轮询进行中发一条云平台下发:能即时响应(一次做完的那种写法做不到)。
- 把写法 A/B 里的
system.sleep(200)改成 0/删掉作为对照:整轮瞬间跑完,但期间对所有外部分发都迟钝。
注意事项:
- 协程/任务里的 CPU 死循环照样卡住全局 —— 它们只在
yield/sleep/wait处让出。 - 别在一个 tick 里注册一大堆定时器企图"并行",它们仍然串行执行。
注意事项
- 事件回调内部建议用
pcall捕获异常。未捕获的错误会让该次回调中断,表现是"某项功能莫名不动了",且不一定有显眼报错。 - 垃圾回收由消息泵在无消息的空拍步进。事件密集期 GC 会滞后,内存峰值升高,因此不建议在回调中频繁创建大表。
- 与梯形图交换数据应统一使用
plc.*接口,它内部是线程安全的;不要用 Lua 全局变量跨任务同步。 - 脚本里不要再写
system.recv(2026-10-22 已删除):需要等待时用任务 +event.wait,需要周期时用timer.start。