

前面把双供电切换、Wireshark 排障、PoE 功率分配、浪涌防护、EMC 电磁兼容、多协议固件开发全部讲透了。这一篇回到采集端——当你面对的不是几台、几十台,而是数百台以太网温湿度变送器分布在多个库房、多个楼层、多个园区,每台都在跑 Modbus TCP,你该怎么写采集程序?
先说一个很多人踩过的坑:
用同步
pymodbus写了一个for循环,依次轮询 200 台设备,每台超时 3 秒。 跑起来发现:一轮轮询下来要 10 分钟,数据还没入库,下一轮又开始了。 改成多线程,ThreadPoolExecutor(200),结果:200 个线程同时建 TCP 连接,交换机 MAC 表被打爆,一半设备 TCP 握手超时,日志里全是ConnectionResetError。 再改成asyncio,以为万事大吉——结果pymodbus的异步客户端在 3.x 版本里连接管理有坑,并发一高就报ModbusIOException,还不容易复现。
模型 | 并发方式 | 连接数上限 | 上下文切换 | 适用场景 |
|---|---|---|---|---|
同步串行 | 单线程,逐个轮询 | 1 | 无 | 设备 < 10 台 |
多线程 | 每个设备一个线程 | ~200(OS 限制) | 高(内核调度) | 设备 < 100 台,但线程切换开销大 |
多进程 | 每个进程独立 | CPU 核数 × N | 极高 | 不推荐用于 I/O 密集 |
asyncio | 单线程,事件循环,协作式调度 | 数千 | 极低(用户态) | 设备数百~数千台 |
核心优势:asyncio 是单线程的,不存在线程切换开销,不存在 GIL 争抢,不存在锁竞争。所有 I/O 操作(TCP 读写)在等待时让出控制权,事件循环调度其他任务。一台设备等待响应的 50ms 里,可以切换去处理另外几十台设备的请求。
pymodbus 版本 | asyncio 支持 | 问题 |
|---|---|---|
2.x | 有 AsyncModbusTcpClient | 较稳定,但已停止维护 |
3.0 – 3.3 | 重构了 asyncio 实现 | 连接池、重连逻辑有 bug |
3.4+ | 逐步修复 | 需要仔细验证 |
本文基于 pymodbus 3.6+,并给出绕过坑点的写法。
┌─────────────────────────────────────────────────────────────┐
│ 采集服务(单进程 asyncio) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 设备管理器(DeviceManager) │ │
│ │ · 维护设备列表(IP、端口、从站地址、采集周期) │ │
│ │ · 按区域/库房分组 │ │
│ │ · 健康状态跟踪(在线/离线/响应时间) │ │
│ └───────────────────┬─────────────────────────────────┘ │
│ │ │
│ ┌───────────────────▼─────────────────────────────────┐ │
│ │ 连接池(ConnectionPool) │ │
│ │ · 每台设备一个持久 TCP 连接(或按需创建) │ │
│ │ · 连接健康检测(心跳/超时) │ │
│ │ · 自动重连(指数退避) │ │
│ │ · 并发限制(Semaphore 控制同时活跃请求数) │ │
│ └───────────────────┬─────────────────────────────────┘ │
│ │ │
│ ┌───────────────────▼─────────────────────────────────┐ │
│ │ 采集调度器(Scheduler) │ │
│ │ · 按设备采集周期调度(非阻塞) │ │
│ │ · 错峰采集(避免同时发起所有请求) │ │
│ │ · 优先级(关键库房优先) │ │
│ └───────────────────┬─────────────────────────────────┘ │
│ │ │
│ ┌───────────────────▼─────────────────────────────────┐ │
│ │ 数据管道(DataPipeline) │ │
│ │ · 质量位标记(good/bad/timeout) │ │
│ │ · 变化过滤(deadband) │ │
│ │ · 批量写入 InfluxDB / 转发 Kafka │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘决策点 | 选择 | 理由 |
|---|---|---|
连接模型 | 每设备一个长连接 | Modbus TCP 无会话开销,长连接避免握手延迟 |
并发度 | Semaphore 限制 ~100 并发请求 | 避免交换机/设备 TCP 栈过载 |
采集调度 | 错峰 + 随机抖动 | 避免 200 台同时发起请求 |
超时处理 | 单次超时 2s,连续 3 次失败标记离线 | 快速失败,不阻塞其他设备 |
重连策略 | 指数退避 1s/2s/4s/8s,上限 60s | 避免重连风暴 |
数据写入 | 批量异步写入 InfluxDB | 减少 I/O 次数 |
import asyncio
import time
from dataclasses import dataclass, field
from typing import Dict, Optional
from pymodbus.client import AsyncModbusTcpClient
from pymodbus.exceptions import ModbusIOException, ConnectionException
@dataclass
class DeviceConfig:
host: str
port: int = 502
slave_id: int = 1
poll_interval: float = 5.0
timeout: float = 2.0
retry_limit: int = 3
@dataclass
class DeviceState:
client: Optional[AsyncModbusTcpClient] = None
connected: bool = False
last_poll: float = 0.0
last_success: float = 0.0
consecutive_failures: int = 0
response_times: list = field(default_factory=list)
quality: str = "unknown"
class ConnectionPool:
def __init__(self, max_concurrent: int = 100):
self.devices: Dict[str, DeviceState] = {}
self.configs: Dict[str, DeviceConfig] = {}
self.semaphore = asyncio.Semaphore(max_concurrent)
self._lock = asyncio.Lock()
def add_device(self, name: str, config: DeviceConfig):
self.configs[name] = config
self.devices[name] = DeviceState()
async def get_client(self, name: str) -> Optional[AsyncModbusTcpClient]:
"""获取或创建设备的 Modbus TCP 客户端"""
state = self.devices[name]
config = self.configs[name]
if state.client is not None and state.connected:
return state.client
# 需要新建连接
async with self._lock:
# 双重检查
if state.client is not None and state.connected:
return state.client
# 关闭旧连接
if state.client is not None:
try:
state.client.close()
except Exception:
pass
state.client = AsyncModbusTcpClient(
config.host,
port=config.port,
timeout=config.timeout,
retries=1,
retry_on_empty=True,
)
try:
await state.client.connect()
state.connected = state.client.connected
if state.connected:
state.consecutive_failures = 0
return state.client
else:
state.connected = False
return None
except Exception as e:
state.connected = False
return None
async def release(self, name: str):
"""释放信号量(在请求完成后调用)"""
pass # 信号量在 poll_device 中管理
async def close_all(self):
"""关闭所有连接"""
for name, state in self.devices.items():
if state.client is not None:
try:
state.client.close()
except Exception:
pass
state.client = None
state.connected = Falseclass Scheduler:
def __init__(self, pool: ConnectionPool, influx_writer=None):
self.pool = pool
self.influx = influx_writer
self.running = False
self._tasks: Dict[str, asyncio.Task] = {}
async def start(self):
"""启动所有设备的采集任务"""
self.running = True
for name in self.pool.configs.keys():
self._tasks[name] = asyncio.create_task(
self._device_loop(name)
)
# 等待所有任务
await asyncio.gather(*self._tasks.values(), return_exceptions=True)
async def stop(self):
"""停止所有采集任务"""
self.running = False
for task in self._tasks.values():
task.cancel()
await asyncio.gather(*self._tasks.values(), return_exceptions=True)
await self.pool.close_all()
async def _device_loop(self, name: str):
"""单个设备的采集循环"""
config = self.pool.configs[name]
state = self.pool.devices[name]
# 错峰启动:随机抖动 0-5 秒
await asyncio.sleep(hash(name) % 5)
while self.running:
try:
await self._poll_device(name)
except asyncio.CancelledError:
break
except Exception as e:
# 记录异常但不退出循环
pass
# 等待下一个采集周期
await asyncio.sleep(config.poll_interval)
async def _poll_device(self, name: str):
"""执行单次采集"""
config = self.pool.configs[name]
state = self.pool.devices[name]
async with self.pool.semaphore: # 限制并发数
client = await self.pool.get_client(name)
if client is None or not client.connected:
state.quality = "bad"
state.consecutive_failures += 1
return
t0 = time.monotonic()
try:
# 读取保持寄存器 40001-40002(温度、湿度)
resp = await asyncio.wait_for(
client.read_holding_registers(
address=0, count=2, slave=config.slave_id
),
timeout=config.timeout
)
elapsed = (time.monotonic() - t0) * 1000 # ms
if resp is None or resp.isError():
raise ModbusIOException(f"Bad response: {resp}")
# 解析数据
temp = resp.registers[0] * 0.1
humid = resp.registers[1] * 0.1
# 更新状态
state.last_poll = time.time()
state.last_success = time.time()
state.consecutive_failures = 0
state.response_times.append(elapsed)
if len(state.response_times) > 100:
state.response_times.pop(0)
state.quality = "good"
# 写入 InfluxDB
if self.influx:
await self.influx.write_point(
measurement="temperature_humidity",
tags={"device": name, "host": config.host},
fields={
"temperature": temp,
"humidity": humid,
"response_ms": elapsed,
"quality": 0,
}
)
except asyncio.TimeoutError:
state.consecutive_failures += 1
state.quality = "bad"
except (ModbusIOException, ConnectionException, OSError) as e:
state.consecutive_failures += 1
state.quality = "bad"
# 连接可能已断开,标记重连
state.connected = False
try:
client.close()
except Exception:
pass
state.client = Nonefrom influxdb_client.client.influxdb_client_async import InfluxDBClientAsync
class AsyncInfluxWriter:
def __init__(self, url: str, token: str, org: str, bucket: str):
self.client = InfluxDBClientAsync(url=url, token=token, org=org)
self.bucket = bucket
self._queue = asyncio.Queue(maxsize=10000)
self._task = None
async def start(self):
self._task = asyncio.create_task(self._flush_loop())
async def stop(self):
if self._task:
self._task.cancel()
await asyncio.gather(self._task, return_exceptions=True)
await self.client.close()
async def write_point(self, measurement: str, tags: dict, fields: dict):
"""非阻塞写入队列"""
point = {
"measurement": measurement,
"tags": tags,
"fields": fields,
"time": int(time.time() * 1e9),
}
try:
self._queue.put_nowait(point)
except asyncio.QueueFull:
# 队列满,丢弃最旧的数据
try:
self._queue.get_nowait()
except asyncio.QueueEmpty:
pass
self._queue.put_nowait(point)
async def _flush_loop(self):
"""批量写入 InfluxDB"""
batch = []
last_flush = time.monotonic()
while True:
try:
# 等待数据,超时则刷新
point = await asyncio.wait_for(self._queue.get(), timeout=1.0)
batch.append(point)
# 批量条件:达到 500 条或 5 秒
if len(batch) >= 500 or (time.monotonic() - last_flush) >= 5.0:
await self._flush(batch)
batch.clear()
last_flush = time.monotonic()
except asyncio.TimeoutError:
if batch:
await self._flush(batch)
batch.clear()
last_flush = time.monotonic()
except asyncio.CancelledError:
if batch:
await self._flush(batch)
break
async def _flush(self, points: list):
"""批量写入 InfluxDB"""
from influxdb_client import Point
influx_points = []
for p in points:
pt = Point(p["measurement"]).time(p["time"])
for k, v in p["tags"].items():
pt = pt.tag(k, v)
for k, v in p["fields"].items():
pt = pt.field(k, v)
influx_points.append(pt)
try:
await self.client.write_api().write(
bucket=self.bucket, record=influx_points
)
except Exception as e:
# 写入失败,记录日志,不重试(避免阻塞)
passasync def main():
# 从配置文件加载设备列表
devices = load_devices_from_config("devices.yaml")
# 创建连接池
pool = ConnectionPool(max_concurrent=100)
# 添加设备
for name, cfg in devices.items():
pool.add_device(name, DeviceConfig(**cfg))
# 创建 InfluxDB 写入器
influx = AsyncInfluxWriter(
url="http://localhost:8086",
token="your-token",
org="archive",
bucket="env_monitor"
)
await influx.start()
# 创建调度器
scheduler = Scheduler(pool, influx)
try:
await scheduler.start()
except KeyboardInterrupt:
pass
finally:
await scheduler.stop()
await influx.stop()
if __name__ == "__main__":
asyncio.run(main())参数 | 建议值 | 依据 |
|---|---|---|
max_concurrent(Semaphore) | 50–150 | 取决于交换机 MAC 表大小、设备 TCP 栈深度 |
单设备采集周期 | 5–60s | 温湿度变化慢,5s 足够 |
连接超时 | 2s | 现场网络 RTT < 1ms,2s 足够区分故障 |
批量写入大小 | 500 条/批 | InfluxDB 推荐批量写入 |
# 方案 1:随机抖动
await asyncio.sleep(random.uniform(0, 5))
# 方案 2:按设备哈希均匀分布
offset = (hash(name) % 100) / 100 * poll_interval
await asyncio.sleep(offset)
# 方案 3:按区域分批
# 区域 A 设备:第 0-2 秒
# 区域 B 设备:第 2-4 秒
# 区域 C 设备:第 4-6 秒策略 | 优点 | 缺点 |
|---|---|---|
长连接复用 | 无握手延迟,响应快 | 占用交换机端口表,设备重启后连接失效 |
按需创建 | 资源占用少 | 每次握手 ~1ms,高并发时累积延迟 |
混合(推荐) | 长连接 + 健康检查 + 自动重连 | 实现稍复杂 |
问题 | 后果 | 正确做法 |
|---|---|---|
同步 for 循环轮询 | 采集周期过长 | 用 asyncio 并发 |
无限并发(无 Semaphore) | 交换机/设备过载 | 限制并发数 |
不处理连接断开 | 采集静默失败 | 检测断开,标记离线,触发重连 |
不限制队列大小 | 内存暴涨 | 队列满时丢弃旧数据 |
不批量写入 InfluxDB | I/O 瓶颈 | 批量异步写入 |
不记录响应时间 | 无法定位慢设备 | 记录 RTT,用于性能分析 |
不设置超时 | 单设备卡死阻塞全局 | 每次请求设超时 |
不处理 CancelledError | 任务取消时资源泄漏 | 捕获并清理资源 |
数百台设备的并发采集,核心不是"能同时连多少台",而是如何优雅地管理连接生命周期、控制并发度、处理故障、批量写入。 asyncio 提供了正确的并发模型,但 pymodbus 的坑需要你绕过去——连接池、信号量、指数退避、批量写入,这四件事做好了,200 台设备的采集周期可以稳定在 5 秒以内。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。