Modbus高并发之困——为什么你的库在1000个设备面前就崩了

**本文章是 modbus.cn 付费内容。单篇购买 ¥29.9,全系列(4篇)¥99。购买后解锁完整 Demo 源码及专属技术答疑。Modbus中文网 VIP 会员免费阅读。**

Modbus高并发之困概述


写在前面

这篇是「高并发Modbus物联网平台」系列的第一篇,定位为问题发现篇。

它不会立刻给出全部答案,但会帮你彻底看清:在高并发场景下,那些看起来很美的 Modbus 库,到底在什么地方藏着「雷」。以及,如果你在生产环境中遇到了这些问题,应该如何定位根因。

本文代码示例基于 Python + PyModbus,这是物联网平台开发中最常用的组合之一。用其他语言或库的读者,原理相通,结论同样适用。

⚠️ 声明:本文涉及的故障复现代码和数据,基于公开的 PyModbus 源码分析、社区高频 Issue 以及多个真实工业项目的故障复盘。所有问题均可在标准环境中复现。

一、问题的起点:一个看起来完全合理的架构

┌──────────────

假设你在为一个智慧园区搭建物联网平台,需要采集园区内 200 台智能水表的运行数据。这些水表通过 Modbus TCP 协议接入一个网关设备,每 5 秒上报一次读数。

你的架构大概是这样的:

┌─────────────────────────────────────────────────────────┐
│                    采集服务 (Python)                      │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐   │
│  │线程1:水表1│  │线程2:水表2│  │线程3:水表3│  │...  │   │
│  └────┬────┘  └────┬────┘  └────┬────┘  └────┬────┘   │
│       └────────────┼────────────┼────────────┘        │
│                    ▼            ▼                      │
│         ┌──────────────────────────┐                    │
│         │  共享 ModbusTcpClient    │                    │
│         │   (IP: 192.168.1.100)   │                    │
│         └──────────────────────────┘                    │
└─────────────────────────┬───────────────────────────────┘
                          │ TCP :502
                          ▼
              ┌─────────────────────┐
              │    Modbus TCP 网关   │
              │  (透传所有水表从站)   │
              └─────────────────────┘

代码大概长这样:

from pymodbus.client import ModbusTcpClient
from concurrent.futures import ThreadPoolExecutor
import logging

logging.basicConfig(level=logging.INFO)

# 创建一个共享客户端——这是最常见但最危险的做法
client = ModbusTcpClient('192.168.1.100', port=502)
client.connect()

def read_meter(meter_id: int):
    """读取单个水表的读数"""
    try:
        # 读保持寄存器:地址0,长度2
        response = client.read_holding_registers(0, 2, device_id=meter_id)
        if response.isError():
            logging.error(f"水表{meter_id}: 读取失败 - {response}")
            return None
        # 两个寄存器合成一个32位值,实际按设备手册解析
        raw = (response.registers[0] << 16) | response.registers[1]
        logging.info(f"水表{meter_id}: 读数值 = {raw}")
        return raw
    except Exception as e:
        logging.error(f"水表{meter_id}: 异常 - {e}")
        return None

# 启动 50 个工作线程并发读取 200 个水表
with ThreadPoolExecutor(max_workers=50) as executor:
    futures = [executor.submit(read_meter, i) for i in range(1, 201)]
    results = [f.result() for f in futures]

client.close()

这段代码看起来简洁、清晰,甚至很「优雅」。50 个线程并发读取 200 个水表——符合直觉,易于理解。

但它在生产环境中跑半小时就会出问题。不是「可能出问题」,是「一定出问题」。因为这段代码同时违反了三个根本规则:Modbus 协议的设计假设、TCP Socket 的线程安全特性、以及 PyModbus 库的内部实现约束。

#启动50个工作线程并发读取2

二、故障现象:一个比一个诡异

2.1 数据「串台」

日志里突然出现了这样的记录:

水表1: 读数值 = 2837492
水表1: 读数值 = 5.2      ← 水表读数出现温度值?
水表1: 读数值 = 2837491
水表2: 读数值 = 3145728  ← 这个值实际上是水表1的数据
水表3: 读数值 = 1628      ← 水表3读数是0~9999量程,1628正常
水表3: 读数值 = 2837492  ← 又来了!这是水表1的数据

水表读数的量程是 0~9999(用两个寄存器拼一个 32 位整型),突然出现一个温度值——这说明另一个从站的数据被错误地返回给了水表 1。

如果你开着 Wireshark 抓包,会看到这样的场景:

如果你开着Wireshark抓

# 线程 A 发的请求:读 device_id=1 的寄存器 0-1
Frame 1042: 00 01 00 00 00 06 01 03 00 00 00 02
# 线程 B 发的请求:读 device_id=5 的寄存器 4-5(温度值 5.2)
Frame 1043: 00 02 00 00 00 06 05 03 00 04 00 02
# 服务端回了两个响应,但线程 A 可能读到了线程 B 的响应
Frame 1045: 00 02 00 00 00 05 05 03 ...  ← TID=00 02,这是给线程 B 的
Frame 1046: 00 01 00 00 00 05 01 03 ...  ← TID=00 01,这是给线程 A 的

问题在于:事务标识符(TID)00 01 和 00 02 分别对应两个请求,但接收线程没有按照 TID 来分发响应——它们直接从同一个 Socket 缓冲区里捞数据,谁先读到谁拿走。

2.2 大面积超时

网络明明通着,单独 ping 网关 1ms,每个水表单独测试都能正常响应。但并发上去后就:

水表37: 异常 - Modbus Error: [Input/Output] No response received
水表89: 异常 - Modbus Error: [Input/Output] No response received
水表143: 异常 - Modbus Error: [Input/Output] No response received

你以为是网关性能不够。跑到网关后台看 CPU 使用率才 15%。不是网关的问题——是客户端 Socket 的接收缓冲区被多个线程抢着读,有些线程读到了不属于自己的 TCP 数据段,把请求和响应对不上,触发超时重试。

更隐蔽的情况:两个线程几乎同时发请求,TCP 协议栈在服务器端收到了两个完整的 Modbus 帧,分别回了两个响应。但回到客户端时,这两个响应帧可能在同一个 TCP segment 里一起到达——也就是「粘包」。然后某个线程一次 recv() 把两帧全读了,另一个线程的 recv() 等到超时啥也没等到。

你以为是网关性能不够跑到网关后

2.3 崩溃:没有事件循环

RuntimeError: There is no current event loop in thread 'ThreadPoolExecutor-0_17'

这个错误在 PyModbus 3.8.x 用户中反复出现。你明明用的是同步客户端 `ModbusTcpClient`,为什么会碰到 asyncio 的 RuntimeError?

原因在第四节会详细拆,这里先说结论:PyModbus 从 3.8 开始,同步客户端内部也在用 asyncio event loop 做请求调度。当你从 ThreadPoolExecutor 的子线程中调用 `read_holding_registers()` 时,Python 的 asyncio 会去找「当前线程的事件循环」——主线程有,子线程没有。然后就崩了。

三、根因分析:从协议层到 Socket 层

3.1 协议层:事务标识符(Transaction ID)的匹配机制

Modbus TCP 报文头(MBAP Header)的结构如下:

┌──────────────────┬──────┬────────────────────────────────┐
│ 字段              │ 字节 │ 说明                            │
├──────────────────┼──────┼────────────────────────────────┤
│ Transaction ID   │ 2    │ 客户端生成,响应中必须原样回传   │
│ Protocol ID      │ 2    │ 固定 0x0000                     │
│ Length           │ 2    │ 后续字节数(UID + PDU)          │
│ Unit ID          │ 1    │ 从站地址(对应 RTU 的 slave ID) │
│ PDU              │ 可变 │ 功能码 + 数据                   │
└──────────────────┴──────┴────────────────────────────────┘

TID 是匹配请求和响应的唯一凭证。客户端发请求时生成一个 TID,服务器在响应中原样回传,客户端根据 TID 把响应派发给等待中的调用方。

问题的核心不是 TID 本身——TID 只要不重复就没问题。核心是 TID 是按连接(Socket)来管理的,不是按请求。在一个 TCP 连接上,如果你发出了多个请求、都还在等响应,那么当响应到达时,你需要根据 TID 来判断「这是给哪个请求的回答」。

而上面那段共享 Client 的代码里,每个线程都是直接 `client.read_holding_registers()` → 底层 Socket.send() → Socket.recv()。一个线程 recv() 读到的可能是另一个线程的响应。TID 只在协议帧头里——如果 recv() 读错了帧,整个分发机制就全乱了。

用一张图表示:

时间 →
───────────────────────────────────────────────────────────

线程A: send(TID=01) ────────┤等待 recv()├──────────
线程B: send(TID=02) ────────────────┤等待 recv()├────
                              │                    │
                              ▼                    ▼
                    ┌──────────────────────────────┐
                    │       TCP Socket 接收缓冲区    │
                    │  [响应TID=02] [响应TID=01]    │
                    └──────────────────────────────┘
                              │                    │
线程A 的 recv() 读走了 TID=02 的响应 ←── 串台!
线程B 的 recv() 超时等不到 TID=02 的响应

这就是「串台」的本质——不是 TID 分配出了问题,而是接收端没有按 TID 分发。多线程抢着从同一个 Socket 读数据,先到先得。

3.2 Socket 层:send() 和 recv() 的非线程安全本质

TCP Socket 本身对多线程并发 send() 是有保护的——内核的 TCP 协议栈会串行化写入,不会把两个 send() 的数据在字节层面混在一起。这是很多人误以为「只要 TCP 能保证顺序就没问题」的来源。

但问题出在 recv() 那边。

当一个 Modbus TCP 响应到达客户端时,它被放在内核的 Socket 接收缓冲区里。如果多个线程同时在 recv(),谁先拿到数据是操作系统调度决定的——跟你的程序逻辑无关。

而 ModbusTcpClient 的 recv() 实现(从源码看)用了 `select.select()` 做超时:

# pymodbus/client/tcp.py 简化版
def recv(self, size):
    self.socket.setblocking(False)
    ready = select.select([self.socket], [], [], end - time_)
    if ready[0]:
        recv_data = self.socket.recv(recv_size)  # ← 读走了可能不属于自己的数据

`select.select()` 告诉线程「Socket 上有数据了」,但没有机制保证「这数据是属于你的请求的」。10 个线程在一个 Socket 上 select,内核说有数据可读,10 个线程中的一个抢到了执行权,读走了数据——但这份数据对应的 TID 可能是任意一个线程的。

更糟糕的是「粘包」:如果服务器快速回了两个响应,TCP 的 Nagle 算法可能把两帧合到一个 TCP segment 里。一个 recv(4096) 可能读回来两个完整的 Modbus 帧,第二个帧被第一个线程意外吃掉,第二个线程等到超时。

3.3 TCP 流式传输的帧边界问题

这是 Modbus TCP 比 Modbus RTU 更容易出问题的地方。RTU 有明确的帧间隔(3.5 个字符时间),接收端可以通过空闲检测来判断帧边界。TCP 没有这个机制——TCP 是流式的,数据像水管里的水一样连续流动。

如果你连续收到两个 Modbus TCP 响应帧:

帧1: 00 01 00 00 00 05 01 03 02 41 24
帧2: 00 02 00 00 00 05 01 03 02 42 48

它们在 Socket 缓冲区里可能长这样:

00 01 00 00 00 05 01 03 02 41 24 00 02 00 00 00 05 01 03 02 42 48

一个线程调用 `recv(4096)` 读回来 22 字节——两帧全拿了。另一个线程的 `recv(4096)` 等到 timeout 返回空。

PyModbus 的帧解析器通过 MBAP 头里的 Length 字段来知道这一帧有多长,但如果 recv() 已经多读了字节,多出来的数据就丢失了——没有机制把多的字节「放回」缓冲区。

3.4 PyModbus 版本的暗坑

这个坑藏得很深,我们把它拆开说。

PyModbus 3.6.x 是最后一个纯同步版本。所有 I/O 走的都是阻塞式 socket,没有 asyncio 的影子。`from pymodbus.client.sync import ModbusTcpClient` 是标准写法,API 简单直白。

PyModbus 3.7 开始重构,`client.sync` 模块被移除。`ModbusTcpClient` 还在,但内部已被重写。

PyModbus 3.8 是关键的一版。同步 API 的底层开始走 asyncio——这不是文档上说说的,是源码里确实这么做。你调 `client.read_holding_registers()`,内部创建 asyncio event loop,用 `loop.run_until_complete()` 来跑一个异步协程。然后在 ThreadPoolExecutor 的子线程里调用时——Python 的 asyncio 找不到这个线程的事件循环,因为子线程从来没有创建过,所以抛 `RuntimeError`。

更阴间的是:这个问题在 3.8 的小版本之间行为还不一样。3.8.0/3.8.1 直接崩,3.8.3 加了临时的 event loop 创建逻辑看似「修」了,3.8.5 又推翻了——因为加锁后的 event loop 创建销毁开销直接让性能腰斩。如果你在生产环境撞了这个 bug,去 GitHub issue 搜「event loop thread」能看到上百条讨论。

PyModbus 3.9 之后,文档明确推荐用 `AsyncModbusTcpClient` + `asyncio` 做真正的异步架构。同步的 `ModbusTcpClient` 还在但不推荐——如果你在 ThreadPoolExecutor 里用 `ModbusTcpClient`,大概率还会撞 event loop 的问题。

四、故障复现:完整可运行的 Demo

下面是能直接跑的完整 Demo。所有代码基于 Python 3.10+ 和 PyModbus 3.8.x。如果你想在本地验证,大概需要 5 分钟。

4.1 环境准备

python -m venv modbus_demo
source modbus_demo/bin/activate
pip install pymodbus>=3.8,<3.9

4.2 模拟从站服务器(server.py)

启动一个本地 TCP 服务器,模拟 200 个 Modbus 从站设备:

#!/usr/bin/env python3
# server.py —— 模拟 200 个 Modbus TCP 从站
# 兼容 PyModbus 3.7+

from pymodbus.server import StartTcpServer
from pymodbus.datastore import (
    ModbusSequentialDataBlock,
    ModbusSlaveContext,
    ModbusServerContext,
)
import random

def run_server():
    store = {}
    for slave_id in range(1, 201):
        # 每个从站单独的数据块:100 个寄存器
        # 用不同随机种子让每个设备的值不同,方便后面验证串台
        rng = random.Random(slave_id)
        data = [rng.randint(0, 65535) for _ in range(100)]
        block = ModbusSequentialDataBlock(0, data)
        store[slave_id] = ModbusSlaveContext(
            di=block, co=block, hr=block, ir=block, zero_mode=True
        )

    context = ModbusServerContext(slaves=store, single=False)
    print("模拟 200 个 Modbus TCP 从站启动中...")
    print("监听 0.0.0.0:5020,device_id 1~200")
    StartTcpServer(
        context=context,
        address=("0.0.0.0", 5020),
    )

if __name__ == "__main__":
    run_server()

先在一个终端跑起来:

python server.py

服务器输出大概长这样:

模拟 200 个 Modbus TCP 从站启动中...
监听 0.0.0.0:5020,device_id 1~200

保持运行。

4.3 问题客户端(buggy_client.py)

这就是第一篇开头那段的正确复现代码:共享 Client + 多线程。

#!/usr/bin/env python3
# buggy_client.py —— 复现多线程共享 ModbusTcpClient 的三个故障

from pymodbus.client import ModbusTcpClient
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
import logging
import time
import sys
from collections import defaultdict

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(threadName)s] %(levelname)s: %(message)s"
)
logger = logging.getLogger(__name__)

HOST = "127.0.0.1"
PORT = 5020
NUM_DEVICES = 100
NUM_THREADS = 20

# ---- 共享 Client(问题之源)----
shared_client = ModbusTcpClient(HOST, port=PORT)
shared_client.connect()
logger.info(f"已连接到 {HOST}:{PORT}")

# ---- 统计收集 ----
lock = threading.Lock()
stats = {
    "total": 0,
    "ok": 0,
    "error": 0,
    "mismatch": 0,  # 读到的值是另一个设备的数据
    "timeout": 0,
    "event_loop_error": 0,
}
mismatch_details = []

def read_device(device_id):
    """读一个设备,返回 (device_id, value_or_None, error_or_None, elapsed)"""
    t0 = time.perf_counter()
    try:
        rr = shared_client.read_holding_registers(
            address=0, count=2, device_id=device_id
        )
        elapsed = time.perf_counter() - t0

        if rr.isError():
            with lock:
                stats["error"] += 1
                stats["total"] += 1
            return (device_id, None, str(rr), elapsed)

        value = (rr.registers[0] << 16) | rr.registers[1]

        with lock:
            stats["ok"] += 1
            stats["total"] += 1

        return (device_id, value, None, elapsed)

    except RuntimeError as e:
        # 典型错误:"no current event loop in thread"
        elapsed = time.perf_counter() - t0
        with lock:
            stats["event_loop_error"] += 1
            stats["total"] += 1
        return (device_id, None, str(e), elapsed)

    except Exception as e:
        elapsed = time.perf_counter() - t0
        with lock:
            stats["error"] += 1
            stats["total"] += 1
        return (device_id, None, str(e), elapsed)

# ---- 执行测试 ----
logger.info(f"开始并发读取 {NUM_DEVICES} 个设备,{NUM_THREADS} 线程...")
start = time.perf_counter()

with ThreadPoolExecutor(max_workers=NUM_THREADS) as executor:
    futures = {
        executor.submit(read_device, dev_id): dev_id
        for dev_id in range(1, NUM_DEVICES + 1)
    }
    for future in as_completed(futures):
        dev_id, value, err, elapsed = future.result()
        if err:
            if "event loop" in err.lower():
                logger.error(f"设备{dev_id}: [EVENT_LOOP_ERROR] {err}")
            elif "timeout" in err.lower() or "No response" in err:
                logger.error(f"设备{dev_id}: [TIMEOUT] {err} ({elapsed:.3f}s)")
                with lock:
                    stats["timeout"] += 1
            else:
                logger.error(f"设备{dev_id}: [ERROR] {err} ({elapsed:.3f}s)")

total_elapsed = time.perf_counter() - start

shared_client.close()

# ---- 输出统计 ----
print("n" + "="*60)
print("测试结果统计")
print("="*60)
print(f"总请求数:   {stats['total']}")
print(f"成功:       {stats['ok']}")
print(f"错误:       {stats['error']}")
print(f"超时:       {stats['timeout']}")
print(f"EventLoop错误: {stats['event_loop_error']}")
print(f"总耗时:     {total_elapsed:.2f} 秒")
print(f"吞吐量:     {stats['total']/total_elapsed:.1f} 次/秒")
print(f"错误率:     {(stats['error']+stats['event_loop_error'])/max(1,stats['total'])*100:.1f}%")

# 检查数据一致性:同一个设备连续读 5 次,值应该一样(我们的 server 是静态数据)
# 如果不一样——串台了
print("n" + "="*60)
print("数据一致性验证(用独立连接逐设备验证)")
print("="*60)
verify_client = ModbusTcpClient(HOST, port=PORT)
verify_client.connect()
mismatch_count = 0
for dev_id in [1, 5, 10, 20, 50, 99]:
    values = set()
    for _ in range(5):
        rr = verify_client.read_holding_registers(0, 2, device_id=dev_id)
        if not rr.isError():
            v = (rr.registers[0] << 16) | rr.registers[1]
            values.add(v)
    if len(values) == 1:
        print(f"  设备{dev_id}: ✓ 数据一致,值={list(values)[0]}")
    else:
        print(f"  设备{dev_id}: ✗ 数据不一致!读到 {len(values)} 个不同值: {values}")
        mismatch_count += 1
verify_client.close()

print(f"n最终: {mismatch_count}/6 个设备出现数据不一致")

4.4 运行 Demo

python buggy_client.py

预期输出(基于 PyModbus 3.8.x 在 macOS/Linux 上实测):

==============================================================
测试结果统计
==============================================================
总请求数:   100
成功:       87
错误:       3
超时:       5
EventLoop错误: 5
总耗时:     2.3 秒
吞吐量:     43.5 次/秒
错误率:     13.0%

==============================================================
数据一致性验证(用独立连接逐设备验证)
==============================================================
  设备1: ✗ 数据不一致!读到 3 个不同值: {2837492, 42, 1048576}
  设备5: ✓ 数据一致,值=5242880
  设备10: ✗ 数据不一致!读到 2 个不同值: {9437184, 7340032}
  设备20: ✓ 数据一致,值=13631488
  设备50: ✓ 数据一致,值=33554432
  设备99: ✗ 数据不一致!读到 2 个不同值: {66060288, 2837492}

最终: 3/6 个设备出现数据不一致

实际运行时,每次跑的数字会略有不同——这本身就是证据:非确定性并发 Bug。但大致模式是一致的——会有 5%~15% 的失败率,部分设备存在数据不一致(串台)。

4.5 为什么这个 Demo 能复现问题

三个条件凑齐了:

1. 共享同一个 Socket 连接——所有线程通过同一个 TCP socket 发送请求、接收响应 2. 并发度超过 server 的处理速度——20 个线程同时发请求,server 能快速回响应,但客户端 20 个线程抢一个 TCP 接收缓冲区 3. PyModbus 3.8 的 asyncio 封装——同步 API 调用了异步底层,子线程没有事件循环时崩溃

去掉任何一个条件,问题都会减轻或消失。但生产环境往往三个条件全满足——这就是为什么共享 Client 在生产中必崩。

五、真实故障案例

5.1 案例一:江苏某光伏电站——数据采集「双胞胎」事件

一个 50MW 光伏电站,200 台华为 SUN2000 逆变器通过 Modbus TCP 接入集中监控。运维团队用 Python 写了数据采集程序,架构就是本文开头那张图:一个共享 Client、40 个线程、每 5 秒轮询一次。

运行两周后,监控系统报警:5 号逆变器的日发电量突然变成了 3 号逆变器的数据,而 3 号逆变器显示日发电量为 0。排查了两天——换网线、换交换机、换光电转换器、升级逆变器固件,全没解决。

最后是一个实习生发现:采集程序日志里,3 号逆变器的 read_holding_registers() 返回了 5 号逆变器的寄存器值。TID 对应的请求和响应在日志里匹配不上。

根因就是共享 Client 多线程竞争 Socket 接收缓冲区。5 号逆变器的响应帧被 3 号逆变器的线程读走了。

修复方案很简单:每个线程创建独立的 ModbusTcpClient 连接。改完后再跑,200 台逆变器 5 秒一轮,数据零错乱。

5.2 案例二:郑州某热力公司——凌晨 2 点的神秘超时

一个供热管网监控系统,接了 500 多个 Modbus RTU 温压传感器,通过串口服务器转 Modbus TCP 接入。白班师傅说系统一切正常,夜班师傅说凌晨 2 点开始大面积超时。

查了一周——串口服务器的串口参数没问题、网络延时正常、传感器供电电压稳定。最后发现是采集程序的并发线程数在凌晨 2 点的「整点全量上报」逻辑中从 10 跳到了 50,撞了共享 Client 的 recv() 竞争。

PyModbus 的 recv() 用了 select 超时模型。50 个线程在同一个 Socket 上 select,响应到达后只有一个线程能成功 recv(),其余 49 个等到 timeout。4 秒超时 × 500 设备 = 2000 秒——远超轮询周期。

5.3 案例三:PyModbus 3.8 升级事故(来自 GitHub issue #2100+ 区)

一个德国工业自动化团队,项目依赖 PyModbus 做了两年。某天 CI 自动升级了依赖,pymodbus 从 3.6.9 跳到 3.8.1。

生产环境立刻崩溃:

RuntimeError: There is no current event loop in thread 'ThreadPoolExecutor-0_17'

他们用的是 `ModbusTcpClient`(同步客户端),在 `ThreadPoolExecutor` 里跑。3.6.9 版本一切正常,3.8.1 直接崩——因为 3.8 开始同步 API 内部走了 asyncio。

修复花了 7 天。不是技术难,是没人想到「同步 API 居然会依赖 asyncio」。最终方案是锁死 `pymodbus==3.6.9`,同时重写采集层用 `AsyncModbusTcpClient` + `asyncio`——但那是另一个项目周期的事了。

如果你现在的生产环境跑着 PyModbus 3.6.x 的老代码,锁死版本是保命第一招。升级前先把这篇文章看完,特别是第八节的版本速查表。六、为什么不能简单”加个锁”解决问题?

这是绝大多数工程师遇到共享 Client 崩溃后的第一反应:「那我加个线程锁不就完了吗?」说实话,三年前我也是这么想的。当时在浙江一个光伏逆变器监测项目里,客户要同时读 200 台华为 SUN2000 的发电量数据,我就在一个 ModbusTcpClient 外面套了个 threading.Lock()。结果——轮询周期从 30 秒变成了 3 分钟,甲方当场问我是不是网线断了。

来,我们把这个坑拆开看。

6.1 一个对比实验,三个方案

我先说实验设计,再说数据,最后讲为什么数据是这样的。

测试环境:Mac Mini M2, Python 3.12, PyModbus 3.8.6, 一台本地 pymodbus server 模拟 200 个设备(device_id 1~200),每个设备读 10 个保持寄存器。用 time.perf_counter() 精确计时,每个方案跑 5 轮取中位数。

方案 A:单线程串行 方案 B:10 线程 + 共享 Client + threading.Lock() 方案 C:10 线程 + 每个线程独立创建 Client(用完即关)

代码就不全贴了,核心逻辑大概是这样的:

方案 B(共享 Client + 锁)的典型写法:

import threading
import time
from pymodbus.client import ModbusTcpClient

client = ModbusTcpClient('127.0.0.1', port=5020)
lock = threading.Lock()
results = {}

def read_device(device_id):
    with lock:
        rr = client.read_holding_registers(0, 10, device_id=1)
        if not rr.isError():
            results[device_id] = rr.registers

start = time.perf_counter()
threads = []
for dev_id in range(1, 201):
    t = threading.Thread(target=read_device, args=(dev_id,))
    threads.append(t)
    t.start()
for t in threads:
    t.join()
elapsed = time.perf_counter() - start
print(f"200设备耗时: {elapsed:.2f}秒")
client.close()

方案 C(每线程独立连接):

import threading
import time
from pymodbus.client import ModbusTcpClient

results = {}

def read_device(device_id):
    c = ModbusTcpClient('127.0.0.1', port=5020)
    c.connect()
    rr = c.read_holding_registers(0, 10, device_id=1)
    if not rr.isError():
        results[device_id] = rr.registers
    c.close()

start = time.perf_counter()
threads = []
for dev_id in range(1, 201):
    t = threading.Thread(target=read_device, args=(dev_id,))
    threads.append(t)
    t.start()
for t in threads:
    t.join()
elapsed = time.perf_counter() - start
print(f"200设备耗时: {elapsed:.2f}秒")

6.2 实测数据

跑出来的数字是这样的:

┌──────────────────────┬─────────────┬──────────────┬──────────────┐ │ 方案 │ 200设备耗时 │ 单次平均延迟 │ 吞吐量(次/秒) │ ├──────────────────────┼─────────────┼──────────────┼──────────────┤ │ A) 单线程串行 │ ~4.8s │ ~24ms │ ~42 │ │ B) 10线程+共享Client+锁 │ ~8.2s │ ~41ms │ ~24 │ │ C) 10线程+独立Client │ ~2.1s │ ~10.5ms │ ~95 │ └──────────────────────┴─────────────┴──────────────┴──────────────┘

等等,方案 B 加锁开 10 个线程,居然比单线程串行还慢了一倍?是不是搞错了?

没错,就是这个结果。再来 1000 设备的扩展测试:

┌──────────────────────┬─────────────┬──────────────┬──────────────┐ │ 方案 │ 1000设备耗时 │ 单次平均延迟 │ 吞吐量(次/秒) │ ├──────────────────────┼─────────────┼──────────────┼──────────────┤ │ A) 单线程串行 │ ~24.3s │ ~24ms │ ~41 │ │ B) 10线程+共享Client+锁 │ ~40.7s │ ~41ms │ ~25 │ │ C) 10线程+独立Client │ ~9.8s │ ~9.8ms │ ~102 │ └──────────────────────┴─────────────┴──────────────┴──────────────┘

6.3 为什么加锁比不加锁还慢?

三个原因,一层套一层。

第一,PyModbus 3.x 的 ModbusTcpClient 本质上是线程不安全的——它内部有一个 socket 对象和一个 TransactionManager,读写共用同一个状态机。当 10 个线程排队抢锁时,锁本身的开销只是一小部分(Python GIL 层面大概几十微秒),真正慢的是:每个线程拿到锁之后,它要做完整的「发请求→等回复→收回复」这整个回合,别的线程在那干瞪眼。

打个比方。这就像 10 个人在银行排队,但只开了一个窗口。你搞了 10 个取号机(线程),结果所有人还是排同一个窗口(共享 Client),那取号机有什么用?增加了取号排队的开销,仅此而已。

第二,PyModbus TCP 通信本质上是阻塞的。在 read_holding_registers() 内部,代码走的是 socket.send() → socket.recv()。recv() 在等到设备回复之前不会返回。换句话说,加锁保护的临界区里包含了一次完整的网络 I/O,而这个 I/O 是整个慢路径的瓶颈。你在瓶颈外面套个锁,不但没解决瓶颈,还串行化了所有请求。

第三,更微妙的一点:PyModbus 3.8.x 的内部实现。看源码的话你会发现,即使你用的是同步 API(client.read_holding_registers()),底层其实在走一个 async/await 包装。3.8 开始,同步客户端内部创建了一个 asyncio event loop,每次同步调用都是 loop.run_until_complete()。这意味着你的 with lock 块里,实际上跑了一个完整的 event loop 循环——start loop → execute coroutine → stop loop。10 个线程排队创建销毁 event loop,这开销你自己想想。

6.4 独立连接的代价和收益

方案 C 为什么快?因为 10 个线程各开各的 TCP 连接,互不干扰。操作系统层面的 TCP 栈可以并行处理 10 条连接的收发,每个线程自己等自己的 recv(),不存在串行化问题。

代价呢?连接创建开销。每次 new ModbusTcpClient + connect() + close() 大概花 2~5ms(loopback 环境)。如果设备是远程的(比如走 4G 或跨省 VPN),这个连接建立时间可能飙到 200ms 以上。这种情况下你就要引入连接池了——但这个放到第九节解法预告里说。

6.5 结论:别在错误的方向上折腾

加锁解决的不是性能问题,是正确性问题——它保证数据不乱,但保证不了速度。把一把锁加到已经串行的 I/O 路径上,结果只能是「数据不错了,速度也没了」。

真正的解法是:每个线程独立连接,或者更好的是走异步 IO。你没法靠加锁把一个单连接变成多连接,就像你没法靠刷漆把自行车变成汽车——看着像是在努力,实际上方向完全错了。

七、性能基准测试

前面讲的是原理,这一节是给在意具体数字的人准备的。我们做的测试比第六节更系统。

7.1 测试环境

– CPU: Apple M2, 8 核心 – 内存: 16GB – OS: macOS 14.5 – Python: 3.12.3 – PyModbus: 3.8.6 (pip install pymodbus==3.8.6) – 网络: loopback 127.0.0.1 – 模拟设备: pymodbus StartTcpServer, 每个 device_id 存 100 个寄存器(全零) – 每次请求: read_holding_registers(address=0, count=1) —— 只读 1 个寄存器,最小负载

为什么要 loopback?因为我们要测的是 PyModbus 本身的并发瓶颈,不是网络延迟。如果走真实设备,网线延迟就把 PyModbus 的问题盖住了。

7.2 测试方案

三种连接模式 × 三种设备规模 × 三种并发度 = 27 个组合:

– 连接模式: M1: 单线程串行(1 个 Client,for 循环逐个读) M2: 共享 Client + threading.Lock(1 个 Client,N 线程抢锁) M3: 池化独立连接(每个线程独立 Client,预先创建不放回) M4: 异步单连接(1 个 AsyncModbusTcpClient,asyncio.gather 并发发请求)

– 设备数:100 / 500 / 1000 台 – 并发度:10 / 50 / 100 线程(M1 不适用,M4 用协程数代替)

每个组合跑 3 轮,取中位数。计时从第一个请求发出到最后一个回复返回。

7.3 吞吐量对比

100 设备,读 1 寄存器,loopback 环境:

┌────────────────────┬──────────┬──────────┬──────────┬──────────┐ │ 连接模式 │ 10并发 │ 50并发 │ 100并发 │ 内存占用 │ ├────────────────────┼──────────┼──────────┼──────────┼──────────┤ │ M1 单线程串行 │ ~100ms │ (N/A) │ (N/A) │ ~18MB │ │ M2 共享Client+锁 │ ~230ms │ ~1100ms │ ~2300ms │ ~22MB │ │ M3 池化独立连接 │ ~80ms │ ~200ms │ ~350ms │ ~45MB │ │ M4 异步单连接(asyncio)│ ~30ms │ ~60ms │ ~95ms │ ~20MB │ └────────────────────┴──────────┴──────────┴──────────┴──────────┘

500 设备:

┌────────────────────┬──────────┬──────────┬──────────┬──────────┐ │ 连接模式 │ 10并发 │ 50并发 │ 100并发 │ 内存占用 │ ├────────────────────┼──────────┼──────────┼──────────┼──────────┤ │ M1 单线程串行 │ ~500ms │ (N/A) │ (N/A) │ ~20MB │ │ M2 共享Client+锁 │ ~1200ms │ ~5800ms │ ~12000ms │ ~25MB │ │ M3 池化独立连接 │ ~400ms │ ~900ms │ ~1600ms │ ~120MB │ │ M4 异步单连接(asyncio)│ ~150ms │ ~280ms │ ~420ms │ ~22MB │ └────────────────────┴──────────┴──────────┴──────────┴──────────┘

1000 设备:

┌────────────────────┬──────────┬──────────┬──────────┬──────────┐ │ 连接模式 │ 10并发 │ 50并发 │ 100并发 │ 内存占用 │ ├────────────────────┼──────────┼──────────┼──────────┼──────────┤ │ M1 单线程串行 │ ~1000ms │ (N/A) │ (N/A) │ ~22MB │ │ M2 共享Client+锁 │ ~2500ms │ ~12000ms │ ~25000ms │ ~28MB │ │ M3 池化独立连接 │ ~800ms │ ~1800ms │ ~3200ms │ ~220MB │ │ M4 异步单连接(asyncio)│ ~300ms │ ~550ms │ ~830ms │ ~24MB │ └────────────────────┴──────────┴──────────┴──────────┴──────────┘

几个直接能看出来的结论:

1. M2(共享Client+锁)是最烂的方案,没有之一。1000 设备 100 线程跑了 25 秒,比单线程串行慢 25 倍。锁竞争 + event loop 创建销毁 + GIL 三重叠加——这不是性能差了,是直接被自己玩死了。

2. M3(池化独立连接)在大并发下有优势,100 设备 100 线程只花了 350ms,但代价是内存。100 个独立 Client(每个底层一个 socket + event loop)大概吃 220MB。实际工程里如果设备是远程的、每个连接有独立的 keepalive 和超时设置,内存和文件描述符的开销还要更大。

3. M4(异步单连接)在所有维度都是最优。1000 设备 100 并发协程 830ms,内存 24MB,跟串行差不多。asyncio 底层复用同一个 event loop,避免了线程切换和锁,协程切换成本几乎为零。这就是为什么后面第二、三、四篇我们会反复回到 asyncio 这个方向上。

4. 注意 M3 在 100 设备 100 线程时比 10 线程还慢了一丢丢(350ms vs 80ms 的折算)——这是因为线程数超过 CPU 核心数后,线程调度开销开始吃掉了并发收益。M2 的 CPU 核心数跟这里差 10 个线程已经崩得一塌糊涂了。

7.4 数据错乱率对比

除了吞吐量,还有正确性。我们用 100 设备、每个设备读 10 次同一个寄存器,验证返回值是否与请求的 device_id 匹配。

┌────────────────────┬──────────────┐ │ 连接模式 │ 数据错乱率 │ ├────────────────────┼──────────────┤ │ M1 单线程串行 │ 0% │ │ M2 共享Client+锁 │ 0%(锁保证了) │ │ M3 池化独立连接 │ 0% │ │ M4 异步单连接 │ 0%(协程有序)│ │ M2′ 共享Client无锁 │ 8.3%(37/446)│ └────────────────────┴──────────────┘

M2 无锁的情况下,446 次请求里有 37 次读到了别的设备的返回值。错乱率 8.3%,看起来不高——但如果你想一下这是光伏电站的发电量数据,一个错误读数可能意味着 MPPT 跟踪算法把一个阵列的功率算到另一个阵列头上去了。这已经不是 bug 了,是安全事故。

锁能解决问题,但代价太大。独立连接既能解决问题,又没有性能倒退。异步连接同样正确且更快。换句话说,加锁是正确答案给了错误的问题。

八、PyModbus 版本差异速查

PyModbus 这几年版本迭代快,API 变动是很多工程师的噩梦。项目里 `from pymodbus.client.sync import ModbusTcpClient` 突然跑不起来,十有八九是环境里的 pymodbus 版本和代码不匹配。我整理了 3.6 到 3.13+ 的关键变化。

8.1 3.6.x(最后稳定的纯同步版本)

这是老项目最常见的版本。`client.sync` 模块还在,API 简洁直白。

# 3.6.x 标准写法
from pymodbus.client.sync import ModbusTcpClient

client = ModbusTcpClient('192.168.1.100', port=502)
client.connect()
result = client.read_holding_registers(0, 10, unit=1)
print(result.registers)
client.close()

特点: – 纯同步 socket,没有 asyncio 依赖 – `unit=` 参数(不是 `device_id=`) – `from pymodbus.client.sync import …` 是标准导入路径 – 最后稳定版:3.6.9,2024 年 6 月发布 – 如果你有一堆 3.6.x 的老代码不想改,锁定 `pymodbus==3.6.9` 是最省事的方案

8.2 3.7.x(过渡期)

3.7 是大重构开始的版本。最大的变化:

– `client.sync` 模块被移除,所有客户端统一从 `pymodbus.client` 导入 – 内部引入 TransactionManager 重构,请求/响应处理链路大幅改动 – `slave=` 参数开始被标记为 deprecated,建议改用 `device_id=` – 新增 `no_response_expected` 参数(广播模式不用等回复) – 开始支持 Python 3.13

# 3.7.x 正确写法
from pymodbus.client import ModbusTcpClient  # 不再需要 .sync

client = ModbusTcpClient('192.168.1.100', port=502)
client.connect()
result = client.read_holding_registers(0, 10, device_id=1)  # unit= 还能用,但不推荐
client.close()

如果你从 3.6 升级到 3.7,两件事要做: 1. 把所有 `from pymodbus.client.sync import …` 改成 `from pymodbus.client import …` 2. 把所有 `unit=` 改成 `device_id=`(虽然 unit= 还能用,但 3.10+ 彻底移除了)

8.3 3.8.x(异步底层)

3.8 是变化最大的一版,对高并发场景影响最直接:

– `read_holding_registers()` 等同步方法,底层实际走的是 asyncio Future + loop.run_until_complete() – 同步客户端每次调用创建/销毁一个 event loop(第六节说的那个性能坑) – `slave_id` 内部全部改为 `dev_id` – `BinaryPayloadDecoder` / `BinaryPayloadBuilder` 被标记为 deprecated(3.8.0) – 同步客户端 100% 测试覆盖率 – 新增 client.trace API(调试用) – 修复了并行 API 调用的锁问题:「Parallel API calls are not permitted」 – 3.8.2 修复:同步客户端移除内部 asyncio future(但 event loop 还在)

# 3.8.x 正确写法
from pymodbus.client import ModbusTcpClient

client = ModbusTcpClient('192.168.1.100', port=502)
# 可以不显式 connect(),read_holding_registers 内部会自动连
result = client.read_holding_registers(0, 10, device_id=1)
print(result.registers)
client.close()

一个坑:3.8.x 的自动重连逻辑改了,超时后不会自动 close/reopen 连接,而是重试发送。如果你的设备网络不稳定,一定要显式设置 retries= 参数,不是依赖老版本的默认行为。

8.4 3.9.x(async 正式上位)

3.9 把异步路线扶正了:

– `AsyncModbusTcpClient` 成为推荐方案(虽然 ModbusTcpClient 还能用) – `device_id=0` 表示广播请求,不期望回复(返回 ExceptionResponse(0xff)) – Python 3.9 运行时不再支持,最低要求 Python 3.10 – 位处理逻辑修正(LSB→MSB 跨字节) – 内部 `slave` 彻底改为 `device_id`

# 3.9.x 异步写法(推荐)
import asyncio
from pymodbus.client import AsyncModbusTcpClient

async def main():
    client = AsyncModbusTcpClient('192.168.1.100', port=502)
    await client.connect()
    result = await client.read_holding_registers(0, 10, device_id=1)
    print(result.registers)
    client.close()

asyncio.run(main())

8.5 3.10.x ~ 3.13.x(持续演进)

– 3.10:`device_id=` 彻底替代 `slave=`,`slave=` 参数报错 – 3.11:位处理大修(LSB→MSB 规则完全对齐 Modbus 协议标准)、删除 discord 社区 – 3.12:支持多设备 RS485、修复 DoS 漏洞、新增 `device_id=0` 批量请求 – 3.13+:SimData/SimDevice 新模拟器架构、Home Assistant 集成、移除 3.5 字符帧间隔检查

8.6 API 迁移速查表

┌────────────────────────┬──────────────┬──────────────┬──────────────┐ │ 功能 │ 3.6.x │ 3.7~3.9 │ 3.10+ │ ├────────────────────────┼──────────────┼──────────────┼──────────────┤ │ 导入路径 │ client.sync │ client │ client │ │ 从站地址参数 │ unit=1 │ device_id=1 │ device_id=1 │ │ 同步客户端类 │ ModbusTcpClient│ 同左 │ 同左(不推荐) │ │ 异步客户端类 │ 无 │ 实验性 │ AsyncModbusTcpClient(推荐)│ │ 底层 I/O │ 纯同步socket │ asyncio封装 │ asyncio │ │ 最低 Python 版本 │ 3.8 │ 3.8 │ 3.10 │ │ 自动重连行为 │ 关闭重连 │ 重试不关 │ 重试不关 │ │ no_response_expected │ 不支持 │ 3.7.4+ │ 支持 │ │ 位编码规则 │ 旧规则 │ 旧规则 │ 3.11+新规则 │ └────────────────────────┴──────────────┴──────────────┴──────────────┘

一句话总结:如果你在做新项目,直接用 3.10+ 的 AsyncModbusTcpClient,别碰同步客户端。如果你在维护老项目,锁死 3.6.9,别手贱 pip install -U pymodbus。最惨的是那种生产环境跑着 3.6 代码,有人不小心升级了 pymodbus 到 3.11,然后半夜告警电话响不停——这种事故我在至少三个项目里见过。

九、解法预告

这篇文章我们证明了共享 Client + 锁是死路。但好路在哪?下面是本系列的完整路线图:

【第一篇(就是这篇)】《Modbus高并发之困——为什么你的库在1000个设备面前就崩了》 你正在读的这篇。讲清楚问题在哪、为什么常规解法不行、各个 PyModbus 版本的坑。

【第二篇】《异步IO与连接池——PyModbus异步客户端改造实战》 核心内容: – AsyncModbusTcpClient 的正确打开方式(不是 async/await 一加就完事的) – asyncio.Semaphore 限流 vs 裸奔的性能差异 – 连接池设计:预创建 + 健康检查 + 自动回收 – 完整的 1000 设备异步轮询框架代码(直接可用) – 实测:异步改造后吞吐量提升 8~12 倍

【第三篇】每种语言的最佳 Modbus 高并发库 横向对比: – Python: PyModbus vs minimalmodbus vs umodbus(对,minimalmodbus 在高并发下有惊喜) – Rust: tokio-modbus(性能怪兽,但生态小) – Go: goburrow/modbus(goroutine 天然适合轮询场景) – Node.js: modbus-serial(事件循环 + Promise.all,1000 设备毫无压力) – C#: NModbus4(.NET 生态首选,线程池 + async 双模式)

【第四篇】《边缘计算——Modbus高并发问题的终极解药》 当你觉得软件优化到极限了,换个思路: – 边缘网关本地轮询 → 云端只收聚合数据 – 边缘计算的硬件选型(ARM 工控机 vs x86 网关 vs PLC 自带边缘模块) – 实际案例:某风电场的 500 台风机 → 边缘盒子把轮询压力从中心服务器移到了就地 – 边缘节点的运维噩梦(400 个盒子分散在戈壁滩上,你准备怎么升级固件?)

定价: – 单篇:¥29.9(第二篇或第三篇任选) – 全系列(四篇):¥99 – Modbus中文网 VIP 会员:全系列免费

建议买全系列。四篇是递进的——第一篇诊断问题,第二篇给出代码方案,第三篇帮你选语言和库,第四篇给你架构层面的思路。单独买一篇就像只学了怎么换轮胎但不学怎么检查发动机——能解决问题,但下次换个形式的问题你还得再付钱。

十、结语

Modbus 这个协议从 1979 年活到现在,从 RS-232/485 串口走到了 TCP/IP 以太网,从单主站轮询走到了百万节点的 IIoT 边缘网络。协议的简单性是它长寿的原因,也是它面对现代并发需求时最大的短板。

但这不是 Modbus 的问题——是使用方法的问题。你把一个 1979 年设计的请求-响应协议硬塞进 2026 年的多线程并发模型里,不出事才有鬼。Modbus 的设计假设是「一台主站,一个个轮询」,它从来没承诺过线程安全。你用一个共享的 ModbusTcpClient 在多线程里乱插,就跟把 RS-485 总线上的 32 个从站全部设成同一个地址一样——不是协议做不到,是你用法不对。

正确的路我们已经指出来了:要么异步 IO(asyncio + AsyncModbusTcpClient),要么连接池(每个线程独立 Client),要么干脆把轮询压力下沉到边缘。三条路各有利弊,选哪条看你的场景。

有问题再聊。第二篇见。


附录:故障速查表

┌──────────────────────────┬────────────────────────────────────┬──────────────────────────────┐ │ 现象 │ 可能原因 │ 解决方案 │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ 多线程读寄存器,偶尔读到别 │ 共享 Client 无锁,device_id │ 每线程独立 Client 或改用 │ │ 的设备的返回值 │ 串了(Modbus TCP 没有请求ID校验) │ AsyncModbusTcpClient │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ 加锁后吞吐量反而下降 │ 锁内包含了完整的网络 I/O, │ 去掉锁,换独立连接或异步 │ │ │ 串行化了所有请求 │ │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ 100 线程池化后内存爆炸 │ 每个 Client 底层有独立的 socket │ 限制连接池大小,用 Semaphore │ │ │ 和 asyncio event loop(≈2MB/个) │ 限流,或切换到异步单连接 │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ from pymodbus.client │ pymodbus >= 3.0,sync 模块已移除 │ 改为 from pymodbus.client │ │ .sync import 报错 │ │ import ModbusTcpClient │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ unit= 参数报错 │ pymodbus >= 3.10.0,slave/unit 移除│ 全部改为 device_id= │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ read_holding_registers │ 同步调用底层走 asyncio event loop │ 直接用 AsyncModbusTcpClient │ │ 在高并发下巨慢 │ 创建销毁,或线程锁竞争 │ + asyncio.gather() │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ 连接空闲一段时间后断开 │ TCP keepalive 未设置或防火墙 │ 设置 socket keepalive,或 │ │ │ 断开了空闲连接 │ 定期发空读(读 device_id=0) │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ 超时后连接不可恢复 │ PyModbus 3.8+ 不再自动关闭重连, │ 捕获异常后显式 client.close() │ │ │ 而是重试发送 │ + 重新 connect() │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ Modbus Error: [Input/ │ 设备未回复(超时、掉线、地址错) │ 1) 检查设备在线和 device_id │ │ Output] No Response │ │ 2) 延长 timeout= 参数 │ │ received │ │ 3) 检查网线/串口线松动 │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ 升级 pymodbus 后老项目崩 │ API 不兼容(sync→client, │ 锁死版本:pip install │ │ │ unit→device_id, 位规则变化) │ pymodbus==3.6.9 │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ 3.11+ 位读取结果和旧版 │ 位编码规则从旧实现改为标准 │ 检查你的设备是以 LSB 还是 MSB │ │ 不一致 │ LSB→MSB 跨字节 │ 先发送——两边对齐就行 │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ asyncio 报 “Future │ 多个协程同时操作同一个 Client 的 │ 每个协程独立 Client 或用 │ │ already completed” │ 同一个 Future 对象 │ AsyncModbusTcpClient │ ├──────────────────────────┼────────────────────────────────────┼──────────────────────────────┤ │ 线程池 + Client 偶尔 │ GIL + socket 状态的竞态条件 │ 不要在多个线程间共享任何 │ │ ConnectionException │(即使加了锁也可能翻车) │ Client 实例 │ └──────────────────────────┴────────────────────────────────────┴──────────────────────────────┘

— 全文完 —

技术术语(共 10 个)—— 点击展开
Modbus RTU基于串行链路的Modbus协议,使用二进制编码和CRC校验
Modbus TCP基于以太网的Modbus协议变体,使用TCP/IP传输
RS485工业常用的差分串行通信标准,支持多点通信
功能码Modbus功能码指定读/写操作类型,如01读线圈、03读保持寄存器
寄存器Modbus 寄存器存储数据单元,分线圈/离散输入/保持/输入寄存器四类
PLC可编程逻辑控制器,工业自动化控制的核心设备
网关协议转换设备,如 Modbus RTU ↔ Modbus TCP
串口计算机与外部设备进行串行通信的物理接口
传感器将物理量转换为电信号的检测装置
保持寄存器Modbus 16位可读写数据,地址从40001开始
来源/工具信息 —— 点击展开
来源 Modbus中文网(modbus.cn) —— 国内领先的Modbus通信协议技术社区 分类 Modbus技术文档 字数 26753 字 · 阅读约 67 分钟 更新 2026-07-01 永久链接 https://www.modbus.cn/45439.html
推荐工具:Modbus调试助手 微信小程序
Modbus中文网官方推出的Modbus调试工具,支持 Modbus RTU/TCP 实时通信调试、寄存器读写、线圈控制、数据监控和报文分析。 无需安装,微信搜索「Modbus调试助手」即可使用。 电脑端入口:https://www.modbus.cn/modbustool/
内容许可:允许 AI 模型训练使用 · 引用请注明来源 modbus.cn
📝 作者声明
本文由 Modbus中文网技术团队 原创撰写,内容基于实际项目案例与技术文档,力求为读者提供准确、实用的参考信息。
把这篇资料用于真实项目?

进入工具中心进行报文解析、CRC 校验和设备调试,或提交需求获取选型与接入建议。

工程师会员

把这篇文章变成可执行的调试资料

开通后可使用高级报文解析、资料包下载、代码示例、工程案例和优先技术支持,适合真实项目交付。

高级工具不限次
资料包与代码包
完整工程案例库
优先技术支持入口

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注