Skip to content

多线程和任务编排 ​

现代程序往往需要同时处理多个工作。服务器需要同时处理多个并发请求,桌面应用需要避免一个应用无响应导致的卡顿,数据处理程序需要同时利用多个 CPU 核心等等。

从 C++11 开始,C++ 标准库逐渐建立了一套跨平台的并发体系。总体来说,C++11 提供了线程、互斥量、条件变量、原子操作和异步,C++17 引入了并行算法,C++20 引入了 jthread、信号量、原子等待和协程,C++23 增强了惰性数据流表达能力。下文将择重进行描述。

并发、并行和异步 ​

并发表示多个任务在同一时间段内交替推进,并不意味着他们真的同时执行。而并行则要求 CPU 确实有多个核心,由多个核心进行同时计算。而异步指的是任务之间的等待方式。发起者启动一个操作之后,不需要等待结果,可以继续执行其他操作,然后再根据通知获取结果。

异步任务不一定并行,比如基于事件的循环程序可以只使用一个线程,但是管理大量的 IO 操作。

现代并发特性 ​

C++11: 线程、同步和任务结果 ​

C++11 是 C++ 并发编程的分水岭。在此之前,开发者通常需要直接使用 Windows Threads、POSIX Threads 或第三方库;C++11 之后,标准库逐步提供了创建线程、保护共享数据、传递任务结果和组织异步操作的工具。

在理解这些工具时,可以抓住一个原则:线程负责执行,任务描述要完成的工作,同步工具负责协调线程访问共享状态。

C++11 标准化了跨平台的线程模型。开发者可以通过 std::thread 启动一个线程,利用 join() 等待线程完成。线程访问同一份可变数据时,需要通过互斥量或者原子类型来避免数据竞争(参考 原子操作与内存序。

下面的程序让工作线程等待主线程发出可以开始的信号:

cpp
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>

int main() {
    std::mutex m;
    std::condition_variable cv;
    bool ready = false;

    std::thread worker([&] {
        std::unique_lock<std::mutex> lock(m);

        // 等待 ready 变成 true
        // wait 会在等待时暂时解锁;被唤醒后重新加锁并检查条件。
        cv.wait(lock, [&] { return ready; });

        std::cout << "工作线程开始工作\n";
    });

    {
        std::lock_guard<std::mutex> lock(m);
        ready = true;
    }
    cv.notify_one();

    worker.join();
}

在这段代码中,m 为互斥量,用来保护 ready 共享变量,cv 为条件变量,工作线程通过检测这个值等待。ready 表示工作线程是否可以开始。主线程可以设置 ready 值,工作线程等到 ready 变为 true 后才会继续。

std::lock_guard 是简单的自动加锁工具。它创建时锁住互斥量,所在作用域结束时自动解锁,包括遇到 return 或异常退出的情况。它不能手动提前解锁,也不能在之后重新加锁。比如:

cpp
{
    std::lock_guard<std::mutex> lock(m);
    // 这里持有 m,可以安全访问受保护的数据
}
// 离开作用域时,lock 自动解锁 m

std::unique_lock 是更为灵活的自动加锁工具。他可以通过 unlock() 和 lock() 灵活的管理锁的释放。比如:

cpp
std::unique_lock<std::mutex> lock(m);
// 持有 m

lock.unlock(); // 手动解锁
lock.lock();   // 之后可以重新加锁

wait 用于在条件不满足时释放并等待,最基本的用法是 cv.wait(lock);,他会暂时释放 lock 持有的互斥量,让其他线程可以修改共享状态,然后当前线程等待。当收到通知后,会重新锁住互斥量。此时 wait 返回,程序继续执行。但是线程可能在没有收到通知时醒来,所以需要传入条件的 lambda 来检查函数:

cpp
cv.wait(lock, [&] { return ready; });

这相当于反复检查 ready。只有当 ready 为 true 时候才会返回,继续执行当前的线程,否则就继续等待。这就实现了和 unique_lock 一起实现条件释放。

notify_one 可以通知一个等待线程重新检查条件。它会唤醒一个正在这个条件变量上等待的线程。被唤醒的线程要重新取得互斥量,并检查等待条件。一个典型的顺序是,先在锁的保护下修改共享状态,然后解锁,然后通知等待线程:

cpp
{
    std::lock_guard<std::mutex> lock(m);
    ready = true;
}
cv.notify_one();

notify_one() 通常放在解锁之后,避免被唤醒的线程马上又因为拿不到锁而等待。技术上通知也可以在持锁时调用,但这里解锁后再通知更清楚。

将上面的代码一起看,首先创建线程 worker,并在其中锁住 m,然后等待 ready 变量为 true。在主线程中,ready 被设置为 true,之后 worker 中才会打印日志。

因为每次读写 ready,都需要锁住同一个互斥量 m,所以 m 可以保护 ready 不被数据竞态影响。

对于简单的独立计数,可以使用原子类型:

cpp
#include <atomic>
#include <thread>
#include <vector>

int main() {
    std::atomic<int> count{0};
    std::vector<std::thread> workers;

    for (int i = 0; i < 4; ++i) {
        workers.emplace_back([&] {
            for (int n = 0; n < 1000; ++n) {
                ++count;
            }
        });
    }

    for (auto& worker : workers) {
        worker.join();
    }

    // 此处 count 的值为 4000。
}

在这段代码中,如果把 count 换成普通 int,多个线程同时执行 ++count 就会发生数据竞争。原子操作保证单次更新不可被其他线程观察为“只做了一半”。但原子类型并不能自动保护由多个变量组成的不变关系;这类复合状态通常仍需要互斥量。

C++11 还提供 std::promise、std::future 和 std::async,用来传递任务结果。下面的例子把计算放到异步任务中,再通过 get() 取回结果:

cpp
#include <future>
#include <iostream>

int main() {
    std::future<int> result = std::async(
        std::launch::async,
        [] {
            return 20 + 22;
        }
    );

    std::cout << result.get() << '\n'; // 输出 42
}

std::future 代表一个稍后才能取得的结果。调用 get() 会等待任务完成,并取回返回值;若任务抛出异常,异常也会通过 get() 传回。示例指定了 std::launch::async,要求异步启动;若使用默认策略,标准允许实现延后任务,直到调用 wait() 或 get() 时再执行。因此,std::async 提供了方便的任务结果接口,但不是对线程池或特定调度方式的承诺。

C++14: 共享锁 ​

普通互斥量在同一时刻只允许一个线程持有锁。对于读多写少的数据,这意味着多个只读操作也会互相排队。C++14 增加了 std::shared_timed_mutex 和 std::shared_lock,允许多个读线程同时持有共享锁,而写线程仍须独占访问:

cpp
#include <mutex>
#include <shared_mutex>

std::shared_timed_mutex data_mutex;
int value = 0;

int read_value() {
    std::shared_lock<std::shared_timed_mutex> lock(data_mutex);
    return value;
}

void write_value(int new_value) {
    std::unique_lock<std::shared_timed_mutex> lock(data_mutex);
    value = new_value;
}

读函数使用共享锁,多个读者可以同时进入;写函数使用独占锁,写入时不会有其他读者或写者同时访问受保护的数据。共享锁并非总能提高性能:如果写操作很多,或者锁竞争本身很重,维护读写状态也会产生开销。

C++17: 并行 ​

C++17 为部分标准算法增加了执行策略。下面使用 std::transform 对数组中的元素逐个求平方,并允许实现并行处理:

cpp
#include <algorithm>
#include <execution>
#include <vector>

int main() {
    std::vector<int> input{1, 2, 3, 4};
    std::vector<int> output(input.size());

    std::transform(
        std::execution::par,
        input.begin(), input.end(),
        output.begin(),
        [](int x) { return x * x; }
    );
}

std::execution::par 表示算法允许并行执行。这个例子适合并行化,因为每个输出元素只依赖对应的输入元素,没有多个任务同时写同一个位置。若算法内部有未同步的共享状态,例如多个调用同时修改同一个普通计数器,就可能造成数据竞争。

执行策略表达的是意图和约束,并不保证每次运行都一定创建多个线程。对于数据量很小的任务,并行调度的开销还可能超过并行带来的收益。

C++17 还增加了 std::shared_mutex,提供不带超时操作的共享互斥量;std::scoped_lock 则可以一次管理多个互斥量,降低多个线程按不同顺序加锁而产生死锁的风险。

C++20: 协作式停止、阶段同步与原子等待 ​

C++20 引入了 std::jthread。它在对象销毁时会请求线程停止,并等待线程结束。它还可以把 std::stop_token 传给线程函数,让工作线程检查是否收到了停止请求:

cpp
#include <chrono>
#include <iostream>
#include <thread>

int main() {
    {
        std::jthread worker([](std::stop_token stop) {
            while (!stop.stop_requested()) {
                std::cout << "处理一批工作\n";
                std::this_thread::sleep_for(
                    std::chrono::milliseconds(100)
                );
            }

            std::cout << "工作线程准备退出\n";
        });

        std::this_thread::sleep_for(std::chrono::milliseconds(350));
    } // 析构时请求停止,并等待 worker 结束
}

停止是协作式的:request_stop() 不会强制中断线程,也不会自动打断任意函数里的阻塞操作。工作线程必须检查停止状态,并在合适的时机退出。上例每处理一批工作就检查一次,因此对象离开作用域后,线程最多还会完成当前一轮处理和短暂等待。

C++26: sender 和 receiver 组合操作 ​

C++26 纳入的 std::execution 执行控制库提供了 sender/receiver 模型。sender 表示一个异步操作及其可能的完成结果;receiver 约定如何处理操作成功、失败或停止;scheduler 则表示任务所使用的执行环境。通过适配器,程序可以把多个操作连接起来,例如在一个操作后继续处理结果,或等待多个操作都完成。该库由 WG21 的 P2300 提案推进并纳入 C++26 工作草案。

C++26 还纳入了危险指针(hazard pointers)和读-复制-更新(RCU)相关设施,帮助并发数据结构在读者仍可能访问对象时安全地延后回收它们。这类机制适合对延迟或锁竞争有明确要求的低锁、读多写少场景;初学者通常先掌握互斥量、原子操作和生命周期管理,再考虑使用它们。因此,我们不在这里进行赘述。

总结 ​

与很多语言相比,C++ 的多线程管理显得想当繁杂。所以,在实际工程中,开发者们常常采用很多不同的方式来实现类似的目的。比如,采用 ROS(Robotic Operation System)来分模块实现功能,然后用他们的通信机制来进行数据传输;采用 Linux/POSIX 系统层面的 API 来实现,利用 Qt 中的 QThreadPool 实现,CUDA 等等。当然 jthread 之类的相对方便的 API 也被广泛使用。

Released under MIT License