3. 进程、线程与并发基础

在 Qt 嵌入式多线程应用工程中,理解进程与线程的区别、并发与并行的差异、上下文切换的代价以及线程生命周期,是写出稳定、高效、可维护多线程程序的前提。很多初学者把“多线程”等同于“更快”,结果在嵌入式设备上遇到界面卡顿、数据错乱、CPU 占用飙升等问题,根源往往是对这些基础概念理解不深。本节从操作系统视角出发,结合 Qt 的实际编程模型,把这些概念讲清楚。

一、进程与线程的本质区别

进程是操作系统进行资源分配的基本单位,它拥有独立的虚拟地址空间、文件描述符表、信号处理表等资源。线程是 CPU 调度的基本单位,同一进程内的多个线程共享该进程的地址空间和大部分资源,但各自拥有独立的栈、寄存器上下文和线程局部存储。

在嵌入式 Linux 上,创建一个进程通常使用 fork() 或 posix_spawn(),代价较高,因为需要复制页表、建立新的地址空间;而创建一个线程使用 pthread_create(),代价小得多。Qt 中对应的是 QProcess 与 QThread:QProcess 用于启动外部程序,进程间通过管道、套接字通信;QThread 用于在同一进程内并发执行任务,线程间可直接共享内存,但必须配合同步机制。

关键结论:进程隔离性强、通信成本高、崩溃影响范围小;线程共享性强、通信成本低、但一个线程崩溃可能拖垮整个进程。嵌入式 GUI 程序通常采用“单进程多线程”结构,把界面、采集、通信、存储分到不同线程。

二、并发与并行:不是一回事

并发(Concurrency)指多个任务在逻辑上同时推进,宏观上“同时”,微观上可能是交替执行;并行(Parallelism)指多个任务在物理上真正同时执行,需要多核 CPU 支持。单核 CPU 上只能并发,不能并行;多核 CPU 上既可以并发也可以并行。

在 Qt 中,QThread、QtConcurrent、QThreadPool 提供的是并发能力,是否真正并行取决于硬件核心数和调度器。嵌入式设备常见的是双核或四核 ARM,如果线程划分合理,采集线程和显示线程可以真正并行,从而避免界面卡顿。

进程 / 线程结构 与 并发 / 并行执行模型 进程 A(独立地址空间) 线程 T1 独立栈 寄存器 线程 T2 独立栈 寄存器 共享资源 堆 / 全局变量 文件 / 信号 进程 B(独立地址空间) 线程 T1 独立栈 寄存器 共享资源 堆 / 全局变量 文件 / 信号 IPC 单核:并发(交替执行) CPU 核心 T1 T2 T1 T2 T1 时间 → 双核:并行(真正同时) 核心 0 T1 持续执行 核心 1 T2 持续执行

三、上下文切换:看不见的性能开销

上下文切换是操作系统在切换执行线程时保存当前线程的寄存器、程序计数器、栈指针等状态,并恢复下一个线程状态的过程。它本身不执行用户任务,却消耗 CPU 时间。在嵌入式设备上,频繁切换会带来明显开销,尤其是当线程数量过多、锁竞争激烈或时间片过短时。

上下文切换的主要来源包括:时间片耗尽、阻塞式系统调用、等待互斥锁、条件变量唤醒、中断处理等。Qt 中常见的误区是“把任务拆得越细越好”,结果创建了几十个线程,每个线程都在等锁,CPU 大量时间花在切换上,吞吐量反而下降。

# 在嵌入式 Linux 上观察上下文切换
$ pidstat -w -p <pid> 1
Average:  UID  PID  cswch/s  nvcswch/s  Command
Average: 1000 812    320.5      45.2   sensor_app

# cswch/s:自愿切换(等待资源)
# nvcswch/s:非自愿切换(时间片耗尽)
经验法则:计算密集型任务线程数接近 CPU 核心数即可;I/O 密集型任务可适当增加,但不宜超过核心数的 2~3 倍。线程越多,上下文切换和同步开销越大。

四、线程生命周期与 Qt 中的状态变化

线程生命周期通常包括:新建(New)、就绪(Ready)、运行(Running)、阻塞(Blocked)、终止(Terminated)。在 Qt 中,QThread 的状态通过 isRunning()、isFinished() 查询,启动用 start(),请求退出用 quit() 或 exit(),等待结束用 wait()。

推荐做法是“工作对象 + moveToThread”模式:把业务逻辑封装到 QObject 子类,通过 moveToThread() 移入线程,用信号槽跨线程通信。这样线程的事件循环负责调度,避免直接继承 QThread 并重写 run() 带来的混乱。

class Worker : public QObject {
    Q_OBJECT
public slots:
    void doWork() {
        while (!m_stop) {
            // 采集、计算、写日志
        }
        emit finished();
    }
    void stop() { m_stop = true; }
private:
    std::atomic<bool> m_stop{false};
};

QThread thread;
Worker* worker = new Worker;
worker->moveToThread(&thread);
connect(&thread, &QThread::started, worker, &Worker::doWork);
connect(worker, &Worker::finished, &thread, &QThread::quit);
thread.start();
// 退出时
worker->stop();
thread.wait(3000);

五、核心概念对照清单

对比项 进程 线程
资源分配单位是否,共享进程资源
调度单位否是
地址空间独立共享
创建/切换开销大小
通信方式管道、共享内存、套接字共享变量、信号槽、互斥锁
崩溃影响通常不影响其他进程可能导致整个进程退出
Qt 对应类QProcessQThread、QtConcurrent

六、案例与常见误区复盘

某嵌入式数据采集项目最初把串口读取、协议解析、界面刷新都放在主线程,界面在数据量大时明显卡顿。改造后:串口读取放入独立线程,解析放入 QThreadPool,界面只负责显示,通过信号槽传递结果。改造后界面流畅,CPU 占用从 85% 降到 45%。

常见误区包括:一是认为线程越多越快,忽视上下文切换成本;二是直接在子线程操作 UI,导致 Qt 报错或崩溃;三是用 terminate() 强制结束线程,造成资源泄漏;四是共享数据不加锁,出现偶发数据错乱。正确做法是:合理划分线程数量,UI 只在主线程操作,线程退出使用标志位加 wait(),共享数据用 QMutex、QReadWriteLock 或原子变量保护。

在嵌入式 Qt 工程中,建议先画出“任务—线程—通信”关系图,再决定哪些任务合并、哪些拆分。线程划分的目标不是数量多,而是让耗时操作不阻塞主线程,同时把同步开销控制在可接受范围内。