尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
设备控制程序异步化改造:从同步阻塞到协程并发的实战解析
如果你维护过设备控制类的上位机程序一定见过这种场面一个升压动作调用了老的procedureDoAction然后整个界面就像被按住了一样鼠标转圈、日志不刷、其他设备的轮询全部停摆。我前阵子就栽在这上面——在一个基于Module流程模块框架的控制程序里PowerRise这个负责电源升压的模块一次完整的升压流程要跑将近半分钟期间全是time.sleep等待设备回复而且只能串行执行多台电源要一台一台来。最后我把动作基类里的procedureDoAction重构为异步版本ProcedureDoActionAsync结合PowerRise的流程上下文整个控制链路才真正理顺。这篇东西不谈虚的就把我从同步方法改造成异步方法的完整思路、关键代码、踩坑记录写出来。适合正在做设备控制、流程编排、或准备把老代码从同步迁移到异步的开发者参考尤其是那种带了状态机、需要超时控制和安全回退的工业模块。1. 为什么 procedureDoAction 必须异步化1.1 同步执行在设备控制里的老毛病先说个我实际遇到的场景。现场有一批可编程直流电源需要从当前电压升到目标电压比如 5V 升到 48V。升压不是一步到位得按梯度走比如每步只能加 1V加完等电压稳定再读回真实电压做校验。逻辑上很直白但耗时就出来了假设梯度 1V升 43V 就是 43 次写指令、43 次等待稳定、43 次读回每次哪怕只耗 500ms全程就是 21.5 秒。如果用同步写法伪代码大概是这个样子def procedureDoAction(self, action_data): target action_data[target_voltage] self.device.write(f:VOLT {target}\n) time.sleep(0.5) for step in self._calc_steps(target): self.device.write(f:VOLT {step}\n) time.sleep(1.0) actual float(self.device.read()) if abs(actual - step) 0.01: raise RuntimeError(f电压不稳定: {actual}) return True这段代码有四个非常典型的同步问题第一线程被白占。time.sleep(1.0)期间线程什么事都不能干但 CPU 其实完全空闲它在傻等设备响应。升压这种 I/O 密集型的操作代码却把执行线程绑死了。第二多设备只能串行。程序要管理 4 台电源如果每台的升压流程是 20 秒四台串行就是 80 秒。哪怕设备之间毫无依赖关系也没法并行跑。你要是用多线程去并行又会碰到共享串口、共享日志、共享状态这些锁问题复杂度立刻爆炸。第三流程级超时基本靠写死。socket.settimeout只能管单次网络读写超时但整个升压流程必须在 60 秒内完成这种约束在同步代码里得自己掐秒表、自己写包围逻辑非常别扭。第四取消动作没法安全执行。用户想中途停止升压同步代码里除了强制杀线程几乎没有干净的办法。可电源设备最忌讳的就是说停就停停止前必须把输出关掉、电压回退到安全值。强制杀死线程设备可能还维持在一个危险的输出电压上。1.2 异步化之后收益到底在哪改成异步版本ProcedureDoActionAsync之后直白的收益有这么几点I/O 等待不再占用线程。await设备响应的时候当前协程挂起事件循环立刻去跑其他协程。四台电源可以同时升压逻辑上却还是普通的顺序代码不需要手动开线程。超时和取消变成语言级能力。一个asyncio.timeout(60)就能限定整个流程必须在 60 秒内完成调用方要取消时直接task.cancel()协程内部会收到asyncio.CancelledError我们可以在清理动作做完之后再退出。代码读起来更像是业务说明。升压步骤、等待条件、校验逻辑用async/await写出来比硬凑状态机或者回调嵌套要直白太多。当然异步也不是银弹。如果任务是 CPU 密集型的比如升压过程的电压曲线拟合计算那该用进程池还是得用进程池。异步适合的是在等待外部资源的场景设备 I/O 恰恰是这种场景。2. PowerRise 流程模块设计剖析2.1 Module 基类的职责划分说PowerRise之前得先看它的父类Module。在流程框架里Module是所有流程模块的基类它不关心具体业务只约定四件事模块名字、生命周期状态、动作执行入口、通用的状态切换辅助方法。我项目里的基类大概长这样class Module: 流程模块基类所有流程动作的公共骨架 def __init__(self, name: str): self.name name self.state IDLE # IDLE / RUNNING / SUCCESS / FAILED / CANCELLED async def procedureDoActionAsync(self, action_data: dict) - Any: 动作入口子类必须实现 raise NotImplementedError async def _transition(self, new_state: str): self.state new_state # 这里会向流程引擎广播状态变化便于上位机刷新 await asyncio.sleep(0) async def wait_until_idle(self): while self.state ! IDLE: await asyncio.sleep(0.1)基类里放状态流转很关键。流程引擎、界面显示、其他模块都可以监听某个模块的state判断它是否执行完成。如果没有这个约定每个子类自己搞一套状态变量编排逻辑会乱得没法看。Module基类还应该提供超时和异常处理的默认行为。比如procedureDoActionAsync跑挂了基类把它标记为FAILED同时记录错误信息被取消了则标记为CANCELLED。子类只需要专注业务不用每个都重复写一遍 try/except。2.2 升压流程的业务拆解PowerRise继承Module业务是控制电源升压。升压这个动作看起来简单把目标电压发给电源就行。但实际做设备控制的人都知道要处理的东西远不止一条指令。完整的升压流程我拆成了四个阶段阶段一是预检。读取当前电压确认目标电压在允许范围内确认电源的输出状态是开启而不是急停。这步不做后面改电压时可能踩到硬件保护。阶段二是阶梯升压。按max_step_voltage限制步进比如一次最多加 1V。每次写入目标电压后必须等待电压稳定。稳定怎么判定连续读两次电压偏差小于阈值才算稳。这个等待时间不能用死等要设上限比如单步最长 5 秒。阶段三是最终校验。升到目标电压后保持输出一小段时间再读一次电压误差在容忍带内才算成功。阶段四是异常回退。如果中途失败或者被取消要把输出电压回退到安全值通常是 0V或者回退到流程开始时的电压并关闭输出。这个动作必须保守宁可慢不能跳。这四段逻辑如果有同步版本就是一堆time.sleep嵌在循环里。切到异步之后每个等待点变成await整个流程的时间线就打开了。2.3 异步方法签名与参数设计ProcedureDoActionAsync的签名设计比表面看起来讲究。它不是简单把procedureDoAction加上async关键字还需要考虑数据怎么进、结果怎么出、异常怎么传。我的约定是入参统一是一个字典action_data。对升压模块来说字段包括target_voltage、max_step_voltage可选、timeout可选。返回值我用bool成功返回True。为什么不用float返回最终电压因为返回最终电压可以通过后续的状态上报获得动作方法的职责是执行并告知结果不是查询数据。保持返回值语义单一流程引擎才好统一处理。异常仍然抛。调用方用try/except捕获或者直接让流程引擎统一监听失败。异步代码里抛出异常会沿着await链向上传递这本身就是最自然的错误传播方式比返回错误码强多了。3. 从同步到异步ProcedureDoActionAsync 实操改造3.1 改造前先定位同步堵点我拿到老代码的第一件事不是改代码而是先跑一遍日志把每个阻塞点标出来。方法是粗暴但好用的在可能阻塞的位置前后打时间戳看时间差。实测下来一个 5V 升 48V 的流程日志里明显能看到几十个 1 秒的空白段全是time.sleep(1.0)。除了time.sleep还要排查同步阻塞的底层调用。常见的坑是socket.recv、serial.readline这类阻塞式读取。如果底层通信是同步的上层无论怎么写async def只要一碰到阻塞读取事件循环照样卡死。所以改造顺序必须是先动通信层再动业务逻辑层。通信层的同步方法要么替换成异步库要么用asyncio.to_thread暂时顶着。我这边选择了前者因为电源设备用 SCPI 协议走 TCP 或串口异步通信并不难做。3.2 通信层异步化我封装了一个PowerSupplyTransport类对上提供connect / send / query三个异步方法。TCP 走asyncio.open_connection串口走serial_asyncio.open_serial_connection调用方不需要关心底层是网线还是 USB 转串口。import asyncio import serial_asyncio class PowerSupplyTransport: 可编程电源的异步通信层支持 TCP 与串口 def __init__(self, endpoint: str): # 例如 tcp://192.168.1.10:5025 或 serial:///dev/ttyUSB0:9600 self.endpoint endpoint self.reader None self.writer None async def connect(self): if self.endpoint.startswith(tcp://): host, port_str self.endpoint[len(tcp://):].split(:) self.reader, self.writer await asyncio.open_connection(host, int(port_str)) elif self.endpoint.startswith(serial://): dev, baud_str self.endpoint[len(serial://):].split(:) self.reader, self.writer await serial_asyncio.open_serial_connection( urldev, baudrateint(baud_str) ) else: raise ValueError(f未知设备端点: {self.endpoint}) async def query(self, command: str) - str: 发送 SCPI 命令并等待一行响应 if self.writer is None: raise RuntimeError(设备未连接) self.writer.write((command \n).encode(ascii)) await self.writer.drain() line await asyncio.wait_for(self.reader.readline(), timeout3.0) return line.decode(ascii).strip() async def close(self): if self.writer is not None: self.writer.close() await self.writer.wait_closed()这里有个小细节底层读响应要记得加asyncio.wait_for防止设备不回复时协程永久挂起。设备通信里对方不响应是最常见的问题这层兜底必须做。3.3 核心升压逻辑改写成异步有了异步通信层PowerRise里的procedureDoActionAsync就水到渠成了。下面是我在项目里实际使用的简化版本class PowerRise(Module): 电源升压流程模块 def __init__(self, transport: PowerSupplyTransport, name: str PowerRise): super().__init__(name) self.transport transport self.max_step_voltage 1.0 self._start_voltage 0.0 async def procedureDoActionAsync(self, action_data: dict) - bool: self.state RUNNING target float(action_data[target_voltage]) if max_step_voltage in action_data: self.max_step_voltage float(action_data[max_step_voltage]) try: # 阶段一预检 await self.transport.query(*IDN?) # 确认设备在线 current float(await self.transport.query(:MEAS:VOLT?)) self._start_voltage current if target 60.0: # 模块设定的电压上限 raise ValueError(f目标电压超限: {target}V) # 阶段二阶梯升压 step current while step target - 1e-6: step min(step self.max_step_voltage, target) await self.transport.query(f:VOLT {step:.3f}) await self._wait_voltage_stable(step, timeout5.0) # 阶段三最终校验 await asyncio.sleep(0.5) actual float(await self.transport.query(:MEAS:VOLT?)) if abs(actual - target) 0.05: raise RuntimeError(f升压校验失败: 目标 {target}V实测 {actual}V) await self._transition(SUCCESS) return True except asyncio.CancelledError: await self._safe_rollback() await self._transition(CANCELLED) raise except Exception: await self._safe_rollback() await self._transition(FAILED) raise async def _wait_voltage_stable(self, expected: float, timeout: float): 连续两次读电压偏差小于阈值则视为稳定 async def _sample_twice(): for _ in range(20): v1 float(await self.transport.query(:MEAS:VOLT?)) await asyncio.sleep(0.2) v2 float(await self.transport.query(:MEAS:VOLT?)) if abs(v1 - expected) 0.01 and abs(v2 - expected) 0.01: return True return False if not await asyncio.wait_for(_sample_twice(), timeouttimeout): raise TimeoutError(f电压在 {timeout}s 内未稳定: {expected}V) async def _safe_rollback(self): 异常或取消时回退到流程开始时的电压并关闭输出 try: await asyncio.wait_for( self.transport.query(f:VOLT {self._start_voltage:.3f}), timeout3.0, ) await asyncio.sleep(0.3) await asyncio.wait_for( self.transport.query(:OUTP OFF), timeout3.0, ) except Exception: # 回退动作本身失败时至少保留日志不能再往上抛 pass这个版本有几个设计点值得说明。第一阶梯升压循环里没有time.sleep每个等待点都是await事件循环可以在等待期间处理其他模块的任务。第二_wait_voltage_stable里使用asyncio.wait_for给整个稳定过程设了 5 秒上限。设备如果卡住这里会抛TimeoutError而不是无限等下去。第三异常处理和取消处理都走_safe_rollback。回退动作单独用try/except包住因为回退本身也可能失败。电源控制这种场景安全回退的逻辑要比正常流程更保守。3.4 流程引擎怎么编排异步模块ProcedureDoActionAsync改造完之后调用端也需要跟着调整。如果原来的流程引擎是同步调用的直接调async函数会得到一个coroutine对象什么都不执行。至少要保证流程引擎的事件循环在跑。最简单的编排方式是用asyncio.gather并发执行多个模块async def run_batch(actions): rise1 PowerRise(transport_1) rise2 PowerRise(transport_2) results await asyncio.gather( rise1.procedureDoActionAsync({target_voltage: 48.0}), rise2.procedureDoActionAsync({target_voltage: 24.0}), return_exceptionsTrue, ) for module, result in zip([rise1, rise2], results): if isinstance(result, Exception): print(f{module.name} 失败: {result}) else: print(f{module.name} 完成: {result})如果模块之间有依赖比如必须先升 A 再升 B就用顺序await或者用asyncio.create_task把任务建好之后按依赖关系等待。需要注意asyncio.gather的return_exceptionsTrue参数。设备控制的流程里一个模块失败不应该拖垮整个批次应该让其他模块继续执行最后统一收集结果。4. 常见问题与排查技巧实录4.1 协程 never awaited 的坑改造后最容易遇到的报错是RuntimeWarning: coroutine PowerRise.procedureDoActionAsync was never awaited这个提示的意思是你创建了一个协程对象但没有让它真正执行。最常见的原因有两个。一个是调用处忘了写await。比如流程引擎里写成task rise.procedureDoActionAsync(...)而不是task await rise.procedureDoActionAsync(...)。这种错误很好修但阴险的是它不一定立刻报错只是留下一句 RuntimeWarning任务根本没有跑。另一个原因是把异步函数当普通函数传给线程池或回调。比如老代码里用threading.Thread(targetmodule.procedureDoAction)去调用改造后的方法这也会得到未执行协程。遇到这个情况正确的替代方案是asyncio.run_coroutine_threadsafe或者在事件循环里通过create_task调度。排查方法也不难在procedureDoActionAsync第一行打印日志如果流程跑了但日志没出现说明协程压根没被调度问题在上层调用方不在模块内部。4.2 事件循环被某个阻塞调用卡住这个坑我踩得最深。有一段时间并发的两个电源升压模块一个跑得飞快另一个却迟迟不动。查了半天发现第二个模块的通信层里有一个同步的socket.recv它一执行整个事件循环里的所有协程全部停住。异步事件循环是协作式调度的。任何一个协程里出现阻塞调用比如time.sleep、同步socket.recv、subprocess.run、大列表遍历都会让后续所有协程排队等着。表现就是其他模块全部暂停直到阻塞调用返回。怎么定位这类问题我用的是土办法在关键协程的循环里加带时间戳的日志看哪个时间点之后出现长时间空白。那个空白对应的就是阻塞调用。也可以在模块初始化时把loop.set_debug(True)打开Python 会在检测到阻塞时长超过阈值时打印警告。修正办法就是彻底替换阻塞调用time.sleep(1)改成await asyncio.sleep(1)socket.recv改成异步协议的读写实在换不掉的同步库用asyncio.to_thread丢线程池跑。4.3 取消任务时安全回退的顺序问题异步化之后取消变得非常简单task.cancel()即可。但伴随而来的新问题是取消信号是有延迟的而且可能恰好落在关键指令之间。比如升压流程正在执行:VOLT 45此时用户点击停止事件循环抛入CancelledError。如果模块不处理这个异常直接退出电源就停在 45V 输出上了。这绝对不行。我的处理方式是把CancelledError放在except的第一顺位然后先执行安全回退再重新抛出异常。回退时先写电压、再关输出这个顺序也不能反。先关输出再回退电压的问题在于电源的电容可能还存着电荷直接关输出后电压未必立刻归零先回退电压再关输出设备状态是平稳降下来的。另外要注意_safe_rollback内部如果继续使用await而取消信号还没被完全消化后续的await可能会立刻再次抛出CancelledError。所以回退函数里的每个异步操作都要用asyncio.wait_for单独包住并捕获所有异常确保回退动作能跑完。4.4 并发模块操作同一台设备异步改造让多模块并发变得容易了但并发一多共享资源问题就暴露了。两台电源共用一个通信链路或者两个模块同时向同一台电源发指令指令就会在链路层交错设备端直接报语法错误。最简单的方案是给每台设备建一个asyncio.Lock。升压模块在执行procedureDoActionAsync时先async with lock:确保同一时刻只有一个模块在向这台电源发指令。class PowerRise(Module): def __init__(self, transport, device_lock: asyncio.Lock): super().__init__(PowerRise) self.transport transport self._lock device_lock async def procedureDoActionAsync(self, action_data: dict) - bool: async with self._lock: return await self._do_rise(action_data)锁的粒度建议是整个动作流程而不是单条指令。如果只锁单条指令两个升压模块轮流发指令设备端的运行状态也会乱。这类排查有一个实用技巧在transport.query里加一个发送序号日志里同时记录设备名和序号。出现指令交错时看一眼日志马上能定位是哪个模块在乱插队。4.5 异步改造后的状态上报延迟最后一个容易忽略的细节是状态上报也会被异步化影响。原来的同步代码里模块执行到self.state SUCCESS调用方马上能读到。改成异步之后如果状态修改后立刻被调度出去外部可能要多等一个事件循环周期才能看到状态变化。处理方式很直接我在基类里加了一个_transition方法在修改状态后加一个await asyncio.sleep(0)主动让出事件循环。这一行的作用是让其他读取状态的协程有机会运行保证状态变化能及时被流程引擎感知。5. 改造效果与几条经验沉淀这次改动跑完一轮之后效果非常直观。原来四台电源串行升压需要 80 秒左右改成异步并发之后四台同时跑总耗时就降到最慢的那一台大约 25 秒。流程引擎的存活检测、日志、上位机状态刷新全程没有卡顿。不过我更想说的是经验层面的东西。第一异步化必须从通信层开始而不是从业务层开始。先给通信层换上异步接口业务逻辑的async/await才有意义。反过来做的话你会在业务层到处看到asyncio.to_thread和补丁式的写法。第二电源控制这类涉及硬件安全的模块取消和回退逻辑一定要在改造时一并设计进去。异步带来的取消能力是把双刃剑用得好用户可以随时安全停止操作用不好设备可能停在危险状态没人管。第三日志和超时是异步程序的两条命。每处await都值得想一想如果对方永远不响应这里会怎样没有wait_for兜底一个设备掉线就能拖垮整个事件循环。最后分享一个这次改动里我觉得最值得推广的小设计Module基类的_transition方法。它看起来只是改了个状态变量但配合await asyncio.sleep(0)让出执行权之后流程引擎、界面、其他模块都能及时感知状态变化。这个不起眼的细节让整个编排系统在并发改造完成后依然保持稳定。如果你也要做类似的流程模块异步化升级建议先把基类里的状态流转设计好再动手改业务逻辑顺序对了后面的路会顺很多。
RELATED

相关推荐

PMP项目管理认证是智商税吗?值不值得考的深度拆解

PMP项目管理认证是智商税吗?值不值得考的深度拆解

前些天吃饭,一个做运维的朋友问我:“PMP到底是不是交智商税?培训班动不动一万多,考出来真的能涨工资吗?我看知乎上好多人都说没用。”这种问题我一年至少被问十次。每次听到我都挺感慨的:中国可能是世界上P…

📅 2026/9/28 9:01:06
Windows识别CH340失败的底层原因与5种实战修复方案

Windows识别CH340失败的底层原因与5种实战修复方案

1. 这不是驱动问题,是Windows在“假装”不认识CH340你把USB转串口模块插进电脑,设备管理器里却只看到一个带黄色感叹号的“未知设备”,右键更新驱动——“找不到适合此硬件的驱动程序”。你反复下载官网CH340驱动、解压、手动指定.inf文件路径…

📅 2026/9/28 9:01:06
JSP超市管理系统毕设源码:从环境搭建到核心代码实现全解析

JSP超市管理系统毕设源码:从环境搭建到核心代码实现全解析

简介:面向中小型超市信息化建设场景,这套基于JSP与B/S架构的超市管理系统适合计算机相关专业毕业设计、Java Web课程实践及中小超市管理者参考。系统围绕进货、销售、库存与财务等核心环节展开,提供基础的信息化管理方案,能够减少…

📅 2026/9/28 9:01:06
MORE NEWS

更多资讯

📰

从CANoe到TSMaster:车载总线测试工具链迁移实战指南

搞车载总线测试的工程师,电脑里大概率都装着一套CANoe。我最早接触CANoe是刚入行那会儿,跟着前辈在项目里做网络测试,从报文发送、DBC解析到UDS诊断,基本全是靠Vector这套工具撑起来的。说实话,CANoe确实是这个行业的标…

📰

从刷榜到用榜:GitHub Trending 的增量逻辑、项目筛选与高效落地

1. 日榜的"热度"到底是怎么算出来的先别急着收藏仓库。每天打开 GitHub 的 Trending 页面,你看到的是过去 24 小时内 Star 增量最高的仓库,周榜和月榜则分别看一周、一个月内的增量。官方没有公开完整排序算法,但用久了会发现&…

📰

【Java开发MCP】SSE模式开发并集成MCP:TaoToken统一Key接入与SpringAI WebFlux配置骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

OpenCompass 高效评测:Partitioner 任务切分与 Runner 执行后端实战指南

模型评测人工智能大模型AI 评测 【免费下载链接】opencompass OpenCompass is an LLM evaluation platform, supporting a wide range of models from OpenAI, Anthropic, Gemini, Qwen, GLM, DeepSeek, etc, across 100 datasets covering knowledge, reasoning, coding, scie…

📰

快速搭建网站的工具怎么选?3个方案省下5万冤枉钱

快速搭建网站的工具怎么选?3个方案省下5万冤枉钱 网站做好了没人访问,这是很多老板最头疼的事。你花大价钱做的官网,设计精美、功能齐全,但打开一看,流量为零,咨询为零。这时候你才意识到,问题不在“做没做”,而在“怎么快速做出来并推向市场”。面…

📰

中文文本分类落地:BERT+CNN+RNN+GCN的生产级链路重构

简介:本资源是一套面向高校计算机与人工智能方向学生的高分课程设计实现方案,聚焦中文文本分类任务,融合CNN、RNN、GCN与BERT四大主流模型,提供端到端可运行的Python工程代码,适用于自然语言处理课程设计、期末大作业及…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬