文章目录
- [Qt 线程为什么和 Linux pthread 不一样?事件循环、QObject 线程归属与串口上位机正确架构](#Qt 线程为什么和 Linux pthread 不一样?事件循环、QObject 线程归属与串口上位机正确架构)
-
- [1. 先统一概念:Qt 程序的进程、线程与事件循环](#1. 先统一概念:Qt 程序的进程、线程与事件循环)
- [2. "每个线程都有事件循环吗?"------不是!](#2. “每个线程都有事件循环吗?”——不是!)
-
- [2.1 线程本身不自带事件循环](#2.1 线程本身不自带事件循环)
- [2.2 Qt 的 GUI 主线程一定有事件循环](#2.2 Qt 的 GUI 主线程一定有事件循环)
- [2.3 Qt 的工作线程"可以有"事件循环(但不是天生就有)](#2.3 Qt 的工作线程“可以有”事件循环(但不是天生就有))
- [3. Linux pthread 常见"收发分线程",Qt 为什么不这样做?](#3. Linux pthread 常见“收发分线程”,Qt 为什么不这样做?)
-
- [3.1 Linux 收发分线程常见的原因:阻塞 I/O](#3.1 Linux 收发分线程常见的原因:阻塞 I/O)
- [3.2 Qt 更倾向事件驱动 I/O(类似 epoll 模型)](#3.2 Qt 更倾向事件驱动 I/O(类似 epoll 模型))
- [4. 最关键差异:Qt 有 QObject 线程归属(thread affinity)](#4. 最关键差异:Qt 有 QObject 线程归属(thread affinity))
- [5. 那串口是全双工,还需要"一个线程收发"吗?](#5. 那串口是全双工,还需要“一个线程收发”吗?)
- [6. Qt 串口上位机的推荐架构(可落地)](#6. Qt 串口上位机的推荐架构(可落地))
-
- [6.1 分层职责](#6.1 分层职责)
- [6.2 线程间通信方式](#6.2 线程间通信方式)
- [7. 为什么要用 `QTimer::singleShot(0, &w, &MainWindow::monitor)`?](#7. 为什么要用
QTimer::singleShot(0, &w, &MainWindow::monitor)?) - [8. 最小可用示例:I/O 线程内初始化串口(19200/8N1/无流控)](#8. 最小可用示例:I/O 线程内初始化串口(19200/8N1/无流控))
-
- Worker(串口线程内执行)
- [MainWindow 只发请求](#MainWindow 只发请求)
- [9. 总结:一条"最实用"的结论](#9. 总结:一条“最实用”的结论)
Qt 线程为什么和 Linux pthread 不一样?事件循环、QObject 线程归属与串口上位机正确架构
在做电机上位机(Qt Widgets + 串口)时,我一开始的直觉是:
"像 Linux 网络编程那样,一个线程负责发送、一个线程负责接收,收发分离最清晰。"
但在 Qt 里,很多资料都在强调另一套做法:
- 不要让两个线程直接操作同一个
QSerialPort - 更推荐"一个 I/O 线程独占串口对象,通过信号槽与 UI 通信"
- 甚至还要理解
a.exec()、事件循环(event loop)、qApp、QTimer::singleShot(0, ...)
这篇文章把这些概念一次性讲清楚,并给出一个适合"电机串口上位机"的稳定架构。
1. 先统一概念:Qt 程序的进程、线程与事件循环
一个典型的 Qt GUI 程序:
- 启动后是一个 进程
- 进程里至少有一个 GUI 主线程
- GUI 主线程里会运行一个 事件循环(event loop)
事件循环由这句启动:
cpp
return a.exec();
事件循环的作用可以简单理解为:
不断从事件队列取事件 → 分发处理 → 直到退出。
事件包括但不限于:
- 窗口重绘、鼠标键盘事件
- 定时器事件(QTimer)
- 异步 I/O 事件(readyRead、errorOccurred)
- 跨线程信号槽的队列投递(QueuedConnection)
2. "每个线程都有事件循环吗?"------不是!
这是 Qt 线程和你熟悉的 pthread 世界差异最大的点之一。
2.1 线程本身不自带事件循环
线程只是执行流,不自带"消息循环"。
2.2 Qt 的 GUI 主线程一定有事件循环
因为你调用了 a.exec()。
2.3 Qt 的工作线程"可以有"事件循环(但不是天生就有)
工作线程是否有事件循环,取决于你怎么写 QThread:
-
推荐方式:
QObject::moveToThread(thread)+thread->start()这种写法下,
QThread默认run()会调用exec(),因此这个工作线程会有事件循环。 -
不推荐方式:继承
QThread并重写run()写while(true)这种写法通常没有事件循环 (除非你自己在
run()里再手动exec()),会导致:QTimer不触发- readyRead 等事件驱动信号不按预期工作
- queued 信号投递到该线程对象无法执行
结论:
不是每个线程都有事件循环,但 Qt 能让某个线程运行事件循环;很多机制依赖事件循环。
3. Linux pthread 常见"收发分线程",Qt 为什么不这样做?
3.1 Linux 收发分线程常见的原因:阻塞 I/O
在很多 Linux 例程里:
recv()阻塞等待数据send()有时也可能阻塞(缓冲区满、拥塞)
为了避免一个线程被某个系统调用长期卡住,就会把:
- 一个线程专门阻塞在
recv() - 一个线程从队列取数据发送
这是阻塞 I/O 模型下合理的工程选择。
3.2 Qt 更倾向事件驱动 I/O(类似 epoll 模型)
Qt 的 QSerialPort/QTcpSocket 等通常用事件驱动:
- 有数据到来 →
readyRead信号触发 → 你读数据 - 需要发送 →
write()往缓冲写入就返回(一般不会长期阻塞)
这其实更接近 Linux 的:
select/epoll + 非阻塞 read/write
在事件驱动模型下,一个线程就能同时处理收发,不需要用两个线程来"对抗阻塞"。
4. 最关键差异:Qt 有 QObject 线程归属(thread affinity)
这是 Qt 里最容易踩坑、但最重要的规则:
每个 QObject 都属于某个线程(thread affinity),应当主要在它所属线程中使用。
QSerialPort 是 QObject,并且是典型"事件驱动 I/O 对象":
readyRead、errorOccurred等信号由事件循环分发- 它内部维护状态机与缓冲区
如果你把同一个 QSerialPort* 交给两个线程分别 readAll() / write():
- 会出现并发访问(临界资源问题)
- 更严重的是:跨线程直接操作 QObject,会破坏 Qt 的线程归属模型
即使你加锁,也可能出现不稳定的行为(卡死、崩溃、信号不触发、时序错乱)
所以这里不是"端口只有一个没意义",更本质的是:
QSerialPort 这种 QObject 适合被单一线程拥有并操作;跨线程要用信号槽消息传递。
5. 那串口是全双工,还需要"一个线程收发"吗?
全双工意味着硬件/驱动层面可以同时收发。
但这并不要求应用层用两个线程。原因是:
- 驱动内部就有收发缓冲
- 你在同一线程里调用
write()并不会妨碍readyRead的触发 - "并行性"主要由驱动和硬件提供
工程上你要避免的是:让两个线程同时直接操作同一个 QSerialPort 对象,而不是避免全双工。
6. Qt 串口上位机的推荐架构(可落地)
6.1 分层职责
-
MainWindow(GUI 线程):控制层
- 只负责 UI、按钮逻辑、显示数据
- 不直接操作
QSerialPort
-
SerialIoWorker(串口 I/O 线程):执行层
- 在 I/O 线程内创建并初始化
QSerialPort - 统一处理读写(readyRead + write)
- 可用
QTimer在 I/O 线程内做周期发送
- 在 I/O 线程内创建并初始化
-
ParserWorker(解析线程,可选)
- 如果解帧/解析/存盘很重,再单独放到解析线程/线程池
6.2 线程间通信方式
- UI → I/O:通过信号槽发送"打开串口/发送命令/开始轮询"的请求
- I/O → UI:通过信号槽回传"收到的数据/解析结果/错误状态"
- UI 更新必须在 GUI 线程做
7. 为什么要用 QTimer::singleShot(0, &w, &MainWindow::monitor)?
这句常用来确保启动逻辑发生在事件循环开始之后:
cpp
w.show();
QTimer::singleShot(0, &w, &MainWindow::monitor);
return a.exec();
含义是:
0表示不额外等待- 但
singleShot依赖事件循环调度 - 因此它实际效果是:"事件循环启动后尽快执行 monitor()"
这样可以避免在构造函数阶段(事件循环未开始)做一些依赖事件系统的动作。
注意:此时 monitor() 更应该是"启动线程/发打开请求",而不是直接 new 串口并 open。
8. 最小可用示例:I/O 线程内初始化串口(19200/8N1/无流控)
Worker(串口线程内执行)
cpp
void SerialWorker::openPort(const QString& name, int baud)
{
if (!port_) {
port_ = new QSerialPort(this);
connect(port_, &QSerialPort::readyRead, this, &SerialWorker::onReadyRead);
connect(port_, &QSerialPort::errorOccurred, this, &SerialWorker::onError);
}
if (port_->isOpen()) port_->close();
port_->setPortName(name);
port_->setBaudRate(baud);
port_->setDataBits(QSerialPort::Data8);
port_->setParity(QSerialPort::NoParity);
port_->setStopBits(QSerialPort::OneStop);
port_->setFlowControl(QSerialPort::NoFlowControl);
if (!port_->open(QIODevice::ReadWrite)) {
emit opened(false, port_->errorString());
return;
}
emit opened(true, "串口打开成功");
}
MainWindow 只发请求
cpp
emit requestOpen("COM3", 19200);
emit requestSend(cmdBytes);
9. 总结:一条"最实用"的结论
- 不是每个线程天生有事件循环;Qt 的很多机制依赖"对象所在线程的事件循环"。
- Linux 里常见"收发分线程"多出现在阻塞 I/O 场景;Qt 更偏向事件驱动 I/O(类似 epoll)。
- QSerialPort 是 QObject,应当由一个线程独占并在该线程操作;跨线程要用信号槽传递数据。
- 串口全双工不要求两线程;驱动缓冲已经提供并行收发能力。
- 推荐架构:
MainWindow(控制/UI) + SerialIoWorker(I/O 线程独占串口) + 可选 ParserWorker(解析线程)。