☰
Python多线程读写OPC DA:COM套间模型与异步订阅实战
2026/10/15 13:56:18 网站建设 项目流程

简介:这份资源面向工业自动化与物联网方向的 Python 开发者,聚焦 OPC DA 协议的多线程读写实践,帮助读者打通 Python 与 PLC 等工业控制设备之间的数据通道。包内共 6 个文件,以 4 个 py 脚本为主体,辅以 1 个 dll 动态库和 1 个 md 说明文档,压缩包约 90KB,体积轻巧便于快速部署。脚本覆盖 OPC 客户端封装、多线程读写调度以及 MQTT 数据桥接等模块,配合 OPCDAAuto.dll 完成 COM 组件注册与服务器连接,可同时处理多个数据点,提升系统响应速度。目前已有 1542 人学习下载,适合希望掌握 OPC DA 通信、多线程并发与工业数据上云的中级开发者参考,也可作为自动化、工业 4.0 场景下的集成脚本模板。

1. 工业现场的数据采集为什么总卡在 OPC DA 上

做过产线数据采集的工程师大概率都遇到过这个场景:一台老 PLC 或 DCS 系统只暴露 OPC DA 接口,上位机用 Python 写了个脚本去读点位,单线程轮询几十个 Tag 还行,一旦点位数量上到几百甚至上千,采集周期就从 1 秒变成十几秒,画面刷新肉眼可见地卡顿。更麻烦的是写入操作——如果写值和读值挤在同一个线程里,一次写超时会把整轮采集全部拖死。

Python 多线程读写 OPC DA 要解决的核心问题就是这个:在 Windows 平台上通过 COM 组件访问 OPC DA Server,用多线程把读和写拆开,让采集周期稳定、写入不阻塞读取。适合的读者是已经会用 Python 做基础脚本、但对 COM 线程模型和 OPC 异步机制不太熟悉的自动化/工控方向开发者。这篇文章不讲 OPC 协议的历史,只讲怎么把多线程方案跑通、参数怎么调、哪里容易翻车。

2. 选型与线程模型:为什么不能直接开十个线程去读

2.1 Python 操作 OPC DA 的常见库对比

在 Windows 上操作 OPC DA,Python 侧能用的库其实不多,常见的有 OpenOPC、pyOPC、以及基于 win32com 直接调 COM 接口的方案。选型时主要看三点:是否支持多线程、是否支持异步读写、维护状态如何。

方案多线程支持异步读写依赖适用场景
OpenOPC需配合 Pyro不支持OPC Core Components简单单线程采集
win32com 直调需手动管理 COM 套间支持 IOPCAsyncIO2pywin32需要精细控制
封装类自研完全可控可封装pywin32多线程读写场景

我一般会选 win32com 直调加自研封装,原因是 OpenOPC 在多线程下容易出现 COM 套间冲突,而且它对异步回调的支持基本没有。用 win32com 虽然代码量大一些,但线程模型完全在自己手里。

2.2 COM 套间模型对多线程的硬约束

这是最容易翻车的地方。OPC DA Server 是 COM 组件,COM 有套间(Apartment)的概念。Python 主线程默认是 STA(单线程套间),如果你在子线程里直接调用主线程创建的 COM 对象,轻则报CoInitialize has not been called,重则整个进程挂死。

常见做法是每个工作线程自己调用pythoncom.CoInitialize()初始化套间,并且每个线程独立创建 OPC Server 的连接对象,不要跨线程共享同一个 COM 指针。

import threading import pythoncom import win32com.client class OpcWorker(threading.Thread): def __init__(self, prog_id, tag_list): super().__init__() self.prog_id = prog_id self.tag_list = tag_list self.daemon = True def run(self): # 每个线程必须独立初始化 COM 套间 pythoncom.CoInitialize() try: server = win32com.client.Dispatch(self.prog_id) # 后续读写操作都在本线程内完成 self._loop_read(server) finally: # 线程退出前反初始化,避免资源泄漏 pythoncom.CoUninitialize() def _loop_read(self, server): pass # 具体读写逻辑见下一章

逻辑说明:CoInitialize()为当前线程创建 COM 套间,Dispatch在该套间内创建 OPC Server 代理。CoUninitialize()必须和初始化配对,否则线程反复创建销毁会泄漏 COM 资源。参数prog_id是 OPC Server 的 ProgID,比如某模拟项目里用的是"OPC.Simulator.1"这类字符串,实际现场要换成对应 Server 的 ProgID。

2.3 读线程与写线程的职责划分

线程不是越多越好。我的经验是:一个读线程负责批量订阅或轮询,一个写线程负责处理写入队列,再加一个监控线程做心跳和重连。三个线程足够覆盖大多数产线场景。读线程用异步订阅(IOPCAsyncIO2)比同步轮询效率高一个量级,因为 Server 端有变化才回调,不用空转。

import queue write_queue = queue.Queue(maxsize=1000) def write_worker(prog_id): pythoncom.CoInitialize() server = win32com.client.Dispatch(prog_id) while True: tag, value = write_queue.get() if tag is None: break try: # 写入操作单独线程执行,超时不影响读线程 server.Write(tag, value) except Exception as e: print(f"write failed: {tag}, {e}") finally: write_queue.task_done() pythoncom.CoUninitialize()

逻辑说明:写线程从队列取任务,队列满时生产者阻塞,防止写入请求无限堆积。maxsize根据写入频率设,一般 500 到 1000 够用。写入失败只记录不中断循环,避免一个坏点位把整个写线程搞崩。

3. 用 win32com 把多线程读写跑通的最小实现

3.1 连接 OPC Server 与创建组项

OPC DA 的数据组织是 Server → Group → Item 三层。多线程场景下,每个线程可以有自己的 Group,但 Item 的句柄管理要小心。下面是一个最小可运行的连接和建组代码。

import win32com.client import pythoncom def connect_opc(prog_id, host="localhost"): pythoncom.CoInitialize() server = win32com.client.Dispatch(prog_id) # 连接远程或本地 Server server.Connect(prog_id, host) # 创建组,更新周期 500ms group = server.OPCGroups.Add("py_group") group.UpdateRate = 500 group.IsActive = True return server, group def add_items(group, tag_list): items = [] for tag in tag_list: item = group.OPCItems.AddItem(tag, len(items)) items.append(item) return items

逻辑说明:Connect的第一个参数是 ProgID,第二个是主机名。UpdateRate单位毫秒,设太小 Server 压力大,设太大实时性差,500ms 是常见折中。AddItem的第二个参数是客户端句柄,用索引即可,后续通过句柄定位点位。

3.2 异步订阅读取的实现细节

同步Read是阻塞的,多线程下更推荐用IOPCAsyncIO2的订阅模式。win32com 对回调的支持需要通过win32com.server.util注册一个回调对象。

import win32com.server.util class DataCallback: def OnDataChange(self, transaction_id, num_items, client_handles, values, qualities, timestamps): for i in range(num_items): handle = client_handles[i] print(f"handle={handle}, value={values[i]}, quality={qualities[i]}") def subscribe(group, items): # 注册回调对象 callback = win32com.server.util.wrap(DataCallback()) # 建立订阅连接点 group.OPCItems.Connect(callback) # 激活订阅 group.OPCItems.SetEnable(True)

逻辑说明:OnDataChange是 Server 端数据变化时触发的回调,参数里qualities表示数据质量,192 代表 Good,其他值要警惕。SetEnable(True)激活订阅后,Server 按UpdateRate周期推送变化数据。注意回调对象必须保持引用,否则被 GC 回收后回调就断了。

3.3 写入队列与超时控制

写入用队列解耦,但队列本身不能解决超时问题。如果某个点位写入一直失败,写线程会卡在那里。需要给写入操作加超时。

import concurrent.futures def write_with_timeout(server, tag, value, timeout=3): with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(server.Write, tag, value) try: future.result(timeout=timeout) return True except concurrent.futures.TimeoutError: print(f"write timeout: {tag}") return False

逻辑说明:用线程池包一层超时控制,timeout根据现场网络质量设,本地 Server 1 到 2 秒够,远程 3 到 5 秒。超时后线程池会尝试取消,但 COM 调用不一定能真正中断,所以更稳妥的做法是写线程本身有看门狗,超时次数超过阈值就重建连接。

4. 多线程读写 OPC DA 的避坑与排查

4.1 坑一:子线程报 CoInitialize 未调用

现象:主线程能正常读写,子线程一调用就抛pythoncom.com_error: CoInitialize has not been called。

原因:Python 主线程启动时自动初始化了 COM,但子线程不会继承这个状态。

解决:每个子线程入口第一行调pythoncom.CoInitialize(),退出前调CoUninitialize()。如果用了线程池,注意线程复用场景下初始化一次即可,不要重复初始化。

4.2 坑二:跨线程共享 COM 对象导致随机崩溃

现象:程序跑几分钟到几小时不等,突然无响应或直接退出,日志里没有任何异常。

原因:COM 对象有套间亲和性,STA 套间创建的对象只能在创建它的线程使用。跨线程调用是未定义行为,可能不报错但会随机崩。

解决:每个线程独立Dispatch创建自己的 Server 连接,不要用全局变量传 COM 指针。如果必须共享,改用 MTA 套间模式,但 OPC DA Server 对 MTA 的支持参差不齐,不建议。

4.3 坑三:UpdateRate 设太小导致 Server 端 CPU 飙升

现象:采集程序跑起来后,OPC Server 所在机器 CPU 占用从 5% 涨到 40% 以上。

原因:UpdateRate设成了 50ms 甚至更小,Server 端要频繁扫描所有点位并回调。

解决:根据实际工艺需求设,一般 200ms 到 1000ms。如果确实需要高频采集,改用同步Read按需读取,而不是全量订阅。

4.4 坑四:回调对象被 GC 回收后订阅静默失效

现象:程序启动后能收到几次回调,之后再也没有数据更新,但进程还活着。

原因:回调对象只被局部变量引用,函数返回后被垃圾回收,Server 端回调指针变成野指针。

解决:把回调对象存到实例属性或全局容器里,保持强引用。同时加心跳检测,超过 N 个周期没收到回调就重建订阅。

4.5 坑五:写入失败后队列积压导致内存暴涨

现象:某个点位写入持续失败,write_queue的qsize一直涨,最终内存耗尽。

原因:写线程处理速度跟不上生产速度,或者写入阻塞导致队列消费停滞。

解决:队列设maxsize,生产者用put_nowait或带超时的put,满了就丢弃并记录。同时写线程要有失败计数,连续失败超过阈值就告警并重建连接。

5. 进阶:把采集周期压到 200ms 以内的几个技巧

先说结论:单靠加线程压不下去,关键在减少 COM 调用次数和优化数据搬运路径。

第一个技巧是批量读写。OPC DA 支持Read和Write传数组,一次调用处理多个点位,比逐个调用快 5 到 10 倍。下面是一个批量读的示例。

def batch_read(group, items, count): # 一次读取多个点位,减少 COM 往返 values = group.SyncRead(1, count, [item.ServerHandle for item in items]) return values

逻辑说明:SyncRead的第一个参数是数据源(1 表示缓存,2 表示设备),缓存读取快但可能不是最新值,设备读取慢但实时。count是点位数量,ServerHandle列表从 Item 对象取。批量读的返回顺序和传入顺序一致。

第二个技巧是把数据落地和业务处理从采集线程剥离。采集线程只负责把数据塞进环形缓冲区,另起线程做解析、入库、告警。这样采集线程的循环体足够短,周期才稳。

第三个技巧是连接保活。OPC Server 长时间无操作可能断开,加一个心跳线程每 30 秒读一个固定点位,断了就重连。重连逻辑要幂等,避免多个线程同时重连把 Server 打挂。

最后一个习惯:所有 COM 调用都包在 try/except 里,异常只记录不抛出。工业现场的网络抖动、Server 重启都是常态,程序要能自己扛过去。我早期写采集程序不喜欢加异常处理,觉得影响性能,后来在现场被教做人——一个未捕获的 COM 异常能让整个采集进程静默退出,排查半天才发现是网线松了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询