Python串口通信怎么避免阻塞?多线程与异步读取实现方案
在Python开发上位机、工控网关或物联网设备交互时,串口(UART/RS-232/RS-485)读取阻塞是一个极高频出现的问题。核心解决路径非常明确:首先需合理配置底层驱动的超时参数以消除死锁挂起,随后根据系统并发模型,选择“独立接收线程配合线程安全队列”或“基于事件循环的asyncio异步协程”来将串口I/O从主逻辑中完全解耦。本文将直接展示两种主流非阻塞读取方案的具体实现、数据流向设计与适用边界。
一、串口为什么会阻塞:阻塞根源与基础超时参数
在使用 pyserial 库执行 ser.read() 或 ser.readline() 时,默认底层调用处于阻塞模式。若打开串口时未显式指定 timeout 参数(即 timeout=None),一旦对端设备未发送预期的换行符或数据字节不足,Python运行时将无限期等待操作系统内核缓冲区的数据填入,导致整个程序失去响应。
针对最轻量的避免死锁需求,可以通过 Serial(timeout=0.1) 设定读取超时(单位为秒)。当指定时间内未收到足够字节时,调用会返回当前已收到的数据切片或空字节串 b''。但单纯依靠主循环轮询 in_waiting 与 timeout 依然会消耗CPU时间片,难以支撑复杂的应用架构。
二、多线程与队列架构:经典且高兼容的解耦方案
多线程方案适用于绝大多数桌面上位机(如PyQt、Tkinter应用)或多模块同步后台服务。其基本思想是:创建一个专门的后台守护线程(Daemon Thread)负责串行总线的读操作,读出的数据通过 queue.Queue 安全地投递给主工作线程。
多线程读取的标准实现代码
import time
import serial
import threading
import queue
class ThreadedSerialWorker:
def __init__(self, port: str, baudrate: int = 115200):
# 设置合理的超时时间,避免关闭串口时线程永久卡死
self.ser = serial.Serial(port=port, baudrate=baudrate, timeout=0.2)
self.rx_queue = queue.Queue(maxsize=1000)
self.stop_event = threading.Event()
self.worker_thread = threading.Thread(target=self._read_loop, daemon=True)
def start(self):
self.worker_thread.start()
def _read_loop(self):
while not self.stop_event.is_set():
try:
# 检查输入缓冲区是否有可用数据
if self.ser.in_waiting > 0:
raw_data = self.ser.readline()
if raw_data:
# 丢入阻塞队列供主线程消费
try:
self.rx_queue.put(raw_data, timeout=0.1)
except queue.Full:
# 队列满时按需丢弃或告警,防止内存膨胀
pass
else:
# 避免在无数据时造成CPU空转
time.sleep(0.01)
except (serial.SerialException, OSError) as e:
# 捕获物理拔出或链路断开异常
self.rx_queue.put(f"ERROR: {e}".encode())
break
def stop(self):
self.stop_event.set()
if self.worker_thread.is_alive():
self.worker_thread.join(timeout=1.0)
if self.ser.is_open:
self.ser.close()
# 示例:主线程调用演示
if __name__ == "__main__":
worker = ThreadedSerialWorker(port="/dev/ttyUSB0", baudrate=115200)
worker.start()
try:
for _ in range(5):
try:
msg = worker.rx_queue.get(timeout=1.0)
print(f"收到串口消息: {msg.decode(errors='ignore').strip()}")
except queue.Empty:
print("暂无新数据,主线程继续执行其他任务...")
time.sleep(0.5)
finally:
worker.stop()
print("通信已安全关闭。")
三、异步非阻塞方案:基于 asyncio 与 aioserial 的高并发读取
当系统整体基于现代异步网络框架(如 FastAPI、aiohttp 或纯异步守护进程)构建时,混合多线程会带来线程管理与上下文切换的开销。借助 asyncio 与封装好的 aioserial,可以将串口文件描述符直接注册至系统的事件循环(如 Linux epoll 或 Windows IOCP),彻底消除轮询开销。
异步协程读取标准实现代码
import asyncio
import aioserial
async def read_serial_loop(port: str, baudrate: int):
# 初始化异步串口对象
ser = aioserial.AioSerial(port=port, baudrate=baudrate)
print(f"已异步接入串口: {port}")
try:
while True:
# 挂起当前协程,直到读到一整行或超时,底层完全交由事件循环调度
line = await ser.readline_async()
if line:
payload = line.decode(errors="ignore").strip()
print(f"[异步接收] 数据: {payload}")
# 可以在此处直接触发其他异步操作,例如推送到网络WebSocket或数据库
await asyncio.sleep(0) # 礼让执行权
except asyncio.CancelledError:
print("异步读取任务取消...")
finally:
ser.close()
print("串口已关闭。")
async def heartbeat():
# 模拟主应用中并行运行的其他异步任务
while True:
print("[主循环] 心跳正常运行,无阻塞...")
await asyncio.sleep(2.0)
async def main():
# 并发运行串口读取与主业务协程
serial_task = asyncio.create_task(read_serial_loop("/dev/ttyUSB0", 115200))
heartbeat_task = asyncio.create_task(heartbeat())
# 运行5秒作为演示
await asyncio.sleep(5)
serial_task.cancel()
heartbeat_task.cancel()
await asyncio.gather(serial_task, heartbeat_task, return_exceptions=True)
if __name__ == "__main__":
asyncio.run(main())
四、方案对比与技术选型总结
多线程与异步IO各有明确的技术适配边界。对于绝大多数传统工程,多线程方案依靠 pyserial 原生支持,不需要引入额外的第三方依赖;而在面对现代高并发微服务或物联网接入平台时,asyncio 方案具备更好的资源利用率。
| 比对维度 | 基础超时轮询 | 多线程 + Queue | asyncio 异步协程 |
|---|---|---|---|
| 主逻辑解耦 | 未解耦(主循环兼任) | 完全解耦(独立线程) | 完全解耦(事件驱动) |
| CPU占用率 | 容易因空转偏高 | 极低(阻塞/微休眠) | 极低(内核通知唤醒) |
| 扩展复杂度 | 低 | 中等(需维护线程生命周期) | 中等(需纯异步生态协同) |
| 典型应用 | 简易调试脚本 | 桌面客户端、上位机 | 网关服务、并发设备接入 |
- 物理拔出异常处理:在USB转串口拔出时,无论是多线程还是异步协程均会抛出
SerialException。务必在外层做重连保护机制,避免整个进程崩退。 - 分包与黏包处理:串口是流式协议,
read()得到的字节可能是不完整帧。对于固定协议头尾(如0xAA 0x55),建议在入队前或消费端实现专门的状态机进行拼帧。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









