Qt 线程为什么和 Linux pthread 不一样?事件循环、QObject 线程归属与串口上位机正确架构

文章目录

  • [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/无流控))
    • [9. 总结:一条"最实用"的结论](#9. 总结:一条“最实用”的结论)

Qt 线程为什么和 Linux pthread 不一样?事件循环、QObject 线程归属与串口上位机正确架构

在做电机上位机(Qt Widgets + 串口)时,我一开始的直觉是:

"像 Linux 网络编程那样,一个线程负责发送、一个线程负责接收,收发分离最清晰。"

但在 Qt 里,很多资料都在强调另一套做法:

  • 不要让两个线程直接操作同一个 QSerialPort
  • 更推荐"一个 I/O 线程独占串口对象,通过信号槽与 UI 通信"
  • 甚至还要理解 a.exec()、事件循环(event loop)、qAppQTimer::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),应当主要在它所属线程中使用。

QSerialPortQObject,并且是典型"事件驱动 I/O 对象":

  • readyReaderrorOccurred 等信号由事件循环分发
  • 它内部维护状态机与缓冲区

如果你把同一个 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 线程内做周期发送
  • 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(解析线程)
相关推荐
不做无法实现的梦~2 小时前
PX4怎么使用使用PlotJuggler分析PX4日志
linux·嵌入式硬件·机器人·自动驾驶
_leoatliang2 小时前
基于Python的深度学习以及常用环境测试案例
linux·开发语言·人工智能·python·深度学习·算法·ubuntu
少控科技2 小时前
QT新手日记025 - W002程序代码
开发语言·qt
mqiqe2 小时前
K8S 算力架构
容器·架构·kubernetes
网宿安全演武实验室2 小时前
Linux Rootkit 手法解析(上):用户态的“隐身术”与检测思路
linux·网络·安全·apt·攻防对抗
C++ 老炮儿的技术栈2 小时前
Qt中自定义 QmyBattery 电池组件开发
c语言·开发语言·c++·windows·qt·idea·visual studio
dump linux2 小时前
Linux DRM GPU 驱动框架详解
linux·驱动开发·嵌入式硬件
Howrun7772 小时前
Linux_C++_日志实例
linux·运维·c++
头发还没掉光光2 小时前
C语言贪吃蛇:基于Linux中ncurses库实的贪吃蛇小游戏
linux·c语言·开发语言