无锁并发与高性能数据结构工程 - 第1节

1. Qt多线程入门

说实话,我刚接触多线程那会儿,也被进程和线程这两个概念绕晕过。后来我师父跟我说了一句话,我到现在还记得:「进程是资源分配的最小单位,线程是CPU调度的最小单位」。嗯,这句话很官方,我换个说法给你解释。

你想象一下,你的电脑是一个大工厂。每个进程就是一条独立的生产线——比如Word是一条线,浏览器是另一条线。每条生产线都有自己的厂房、设备和原材料(内存、文件句柄等)。而线程呢,就是这条生产线上的工人。一条生产线可以有一个工人在干活,也可以有好几个工人一起干。

我在项目中遇到过一个问题:一个视频处理程序,单线程跑的时候,界面卡得跟幻灯片似的。为什么?因为主线程既要处理用户点击,又要做视频解码,一个人干两份活,不卡才怪。

核心区别一句话总结:

  • 进程之间互相独立,一个崩了不影响另一个
  • 线程之间共享进程的资源,一个崩了可能整个进程都挂
  • 创建进程的开销大,创建线程的开销小得多

二、Qt多线程的应用场景:什么时候该用?

你可能会问:「我写个简单的计算器,需要多线程吗?」我的回答是:别没事找事。多线程不是银弹,用不好反而引入一堆bug。

那什么时候该用呢?我总结了三个典型场景:

场景1:界面不卡顿

这是最常见的需求。你点了个按钮,程序开始做耗时操作——比如下载文件、处理图片、查询数据库。如果这些操作放在主线程,界面就会「假死」。用户会以为程序崩溃了,然后直接关掉窗口。

我个人的习惯是:任何超过100毫秒的操作,都扔到子线程去。100毫秒是什么概念?就是用户能感知到的延迟阈值。

场景2:并发计算

比如你要处理1000张图片,每张图片做滤镜、缩放、水印。单线程一张一张处理,用户得等到天荒地老。这时候开4个线程,每个处理250张,速度直接翻倍(当然,不是线性翻倍,要考虑CPU核心数)。

场景3:I/O密集型任务

读写文件、网络通信、数据库操作。这些操作大部分时间都在等待,CPU其实很闲。多线程可以让CPU在等待的时候干别的活。

我的避坑建议: 我曾经在一个项目里开了20个线程去读数据库,结果数据库连接池不够用,程序直接崩溃。后来我改用线程池,限制最大线程数为CPU核心数+1,问题就解决了。所以,线程不是越多越好。

三、QThread类的核心概念:你必须要懂的

Qt里的多线程,核心就是QThread类。但很多人一开始就理解错了——包括我。我记得我第一次用QThread,直接在子类里重写了run()方法,然后调用start(),以为这就完事了。结果信号槽死活不触发,查了半天才发现问题。

3.1 两种使用方式

QThread有两种用法,我分别说一下:

方式一:继承QThread(不推荐)

class WorkerThread : public QThread
{
    Q_OBJECT
protected:
    void run() override {
        // 耗时操作
        for (int i = 0; i < 100; ++i) {
            emit progress(i);
            QThread::msleep(100);
        }
    }
signals:
    void progress(int value);
};

这种方式的问题在于:你只能重写run(),而且信号槽的连接方式容易搞错。我个人现在基本不用这种方式了。

方式二:使用工作对象(推荐)

class Worker : public QObject
{
    Q_OBJECT
public slots:
    void doWork() {
        for (int i = 0; i < 100; ++i) {
            emit progress(i);
            QThread::msleep(100);
        }
    }
signals:
    void progress(int value);
};

// 使用方式
QThread* thread = new QThread;
Worker* worker = new Worker;
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &Worker::doWork);
thread->start();

这种方式灵活多了。你可以把任意QObject子类移动到子线程,信号槽也能正常工作。说白了,QThread只是线程的管理器,真正的任务逻辑应该放在QObject里。

3.2 线程的生命周期

嗯,这里有个坑。很多人忘记在程序退出时正确清理线程。我见过一个项目,线程退出时直接terminate(),结果资源没释放,内存泄漏严重。

正确的做法是:

// 请求线程退出
thread->quit();
// 等待线程真正结束(超时3秒)
if (!thread->wait(3000)) {
    // 如果超时,强制终止(但尽量别走到这一步)
    thread->terminate();
    thread->wait();
}

警告: 永远不要在主线程里直接调用terminate()。这会导致线程立即停止,不执行任何清理代码。你想想看,如果线程正在写文件,突然被掐断,文件可能就损坏了。

3.3 信号槽与线程的关系

这是Qt多线程最核心也最容易出错的地方。信号槽的连接方式有5种,但常用的就3种:

连接方式 说明 使用场景
AutoConnection(默认) 发送者和接收者在同一线程,直接调用;在不同线程,排队调用 大多数情况用这个就行
DirectConnection 直接调用,不管在哪个线程 需要同步操作时
QueuedConnection 通过事件循环排队调用 跨线程通信时,保证线程安全

我刚开始用Qt时,犯过一个经典错误:在子线程里直接更新UI。比如:

// 错误示例:子线程直接操作UI
void Worker::doWork() {
    ui->label->setText("处理中...");  // 崩溃!
}

为什么崩溃?因为UI控件不是线程安全的。正确的做法是通过信号槽:

// 正确做法:发射信号,让主线程更新UI
connect(worker, &Worker::updateText, this, [](const QString& text) {
    ui->label->setText(text);
});

这里的关键是:信号槽默认是AutoConnection,跨线程时会自动变成QueuedConnection,所以是线程安全的。

四、本章知识体系总览

说了这么多,我画了一张图帮你理清思路:

Qt多线程知识体系 进程 vs 线程 资源分配 vs CPU调度 应用场景 界面不卡顿 / 并发计算 / I/O QThread核心 使用方式 继承QThread(不推荐) 工作对象+moveToThread(推荐) 生命周期管理 quit() → wait() → terminate() 避免直接terminate() 信号槽与线程 Auto / Direct / Queued 跨线程自动QueuedConnection 核心原则:UI操作在主线程,耗时操作在子线程,跨线程通信用信号槽

这张图把本章的核心内容串起来了。从左到右是知识递进,从上到下是层次展开。你把这个图记在脑子里,后面几章学起来会轻松很多。

本章重点回顾:

  • 进程是资源容器,线程是执行单元
  • 多线程主要用于:界面不卡顿、并发计算、I/O操作
  • QThread推荐使用「工作对象+moveToThread」方式
  • 跨线程通信必须用信号槽,不要直接操作UI
  • 线程退出要优雅:先quit(),再wait()

好了,这一章就到这里。记住我上面说的这些,尤其是那个图,后面讲线程同步、线程池、并发容器的时候,都会用到这些基础概念。