目录

C++11 起,标准库正式提供了线程、互斥量、条件变量、原子类型与异步任务支持,分别位于 、、<condition_variable>、、 头文件中。下面按语法特性,把“怎么开线程”和“怎么控并发”捋一遍。


一、为啥总扯上“语法特性”

  • 可移动不可复制:std::thread 禁止拷贝,只能移动,避免好几个对象共管同一条 OS 线程。
  • RAII:std::lock_guard、std::unique_lock 用构造/析构配对加锁解锁,异常路径也不会漏解锁。
  • 模板与完美转发:std::thread 构造函数用变参模板,把可调用对象和参数“原样”转给线程入口;要在线程里改外面的变量,得配合 std::ref。
  • 内存模型与原子:std::atomic 加上 memory_order,把“别的线程啥时候能看见啥”写进语言,不全靠互斥量来猜。
  • C++20:std::jthread 能协作停止;std::barrier、std::latch 等再补一批同步原语。

二、std::thread:创建与生命周期

基本写法

#include <thread>
#include <iostream>

void f(int x) { std::cout << x << "\n"; }

int main() {
    std::thread t(f, 42);   // 新线程执行 f(42)
    t.join();               // 主线程等待 t 结束;必须 join 或 detach,否则 std::terminate
    return 0;
}
  • join():一直等到线程结束;适合“主线程要拿结果或把收尾做干净”的场景。
  • detach():线程和 std::thread 对象脱钩,在后台跑;不能再 join,生命周期得自己兜住(别去引用已经销毁的栈上对象)。

函数、lambda、成员函数都能当入口

#include <thread>
#include <functional>

struct Worker {
    void Run(int n) { (void)n; }
};

int main() {
    auto lam = [] { return; };
    std::thread t1(lam);

    Worker w;
    std::thread t2(&Worker::Run, &w, 1);  // 成员函数要传对象指针/引用

    t1.join();
    t2.join();
    return 0;
}

传参:默认拷贝进线程栈,想改外面用 ref

线程构造里,默认会把参数拷贝或移动到线程自己的栈。要让线程改调用方的变量,用 std::ref / std::cref:

#include <thread>
#include <functional>

void inc(int& x) { ++x; }

int main() {
    int a = 0;
    std::thread t(inc, std::ref(a));
    t.join();
    // a == 1
    return 0;
}

大对象可以 std::move 进线程,少一次多余拷贝。

thread 只能移动,不能拷贝

#include <thread>

void f(int) {}

int main() {
    std::thread t1(f, 1);
    std::thread t2 = std::move(t1);  // t1 不再代表那条线程
    t2.join();
    return 0;
}

三、互斥量:std::mutex 和 RAII 锁

头文件 。

lock_guard:作用域就是临界区

构造时 lock(),析构时 unlock(),出了作用域自动放锁,写法最省事。

#include <mutex>

std::mutex m;
int counter = 0;

void bump() {
    std::lock_guard<std::mutex> lk(m);
    ++counter;
}

unique_lock:能先不加锁、能配合条件变量

可以 晚点再加锁、中间手动 unlock,跟 std::condition_variable 配套;也能 std::adopt_lock 接管你已经拿住的锁。

#include <mutex>

std::mutex m;

void work() {
    std::unique_lock<std::mutex> lk(m);
    // ... 临界区 ...
    lk.unlock();
    // 非临界区干活
    lk.lock();
    // 再进临界区
}

C++17 的 scoped_lock:一次锁住多把互斥量

std::lock 会按固定规则同时抢多把锁,再交给 std::scoped_lock 做 RAII,降低死锁概率。

#include <mutex>

std::mutex m1, m2;

void both() {
    std::scoped_lock lk(m1, m2);
    // 同时持有 m1、m2
}

还有这些互斥量类型

类型干啥用
std::timed_mutextry_lock_for / try_lock_until,限时试一下
std::recursive_mutex同一线程能多次 lock,慎用,容易把设计问题盖住
std::shared_mutex多读单写,后面单独说

四、条件变量:等人、叫人

std::condition_variable 要跟 std::unique_lockstd::mutex 一起用。虚假唤醒免不了,所以 wait 一定要带谓词,用循环把条件钉死。

#include <mutex>
#include <condition_variable>
#include <queue>

std::mutex m;
std::condition_variable cv;
std::queue<int> q;

void producer(int v) {
    {
        std::lock_guard<std::mutex> lk(m);
        q.push(v);
    }
    cv.notify_one();
}

int consumer() {
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, [] { return !q.empty(); });
    // 等价于 while 循环,自动处理虚假唤醒
    int v = q.front();
    q.pop();
    return v;
}
  • notify_one():叫醒一个正在等的。
  • notify_all():全叫醒,适合状态一变大家都得重看的场景。

C++20 还有 std::condition_variable_any,能跟 std::shared_lock 之类配合。


五、原子与内存序:std::atomic

头文件 。整型、指针等有特化;std::atomic 保证对 T 的读写是原子的(在平台支持的前提下)。

先会写能用

#include <atomic>

std::atomic<int> x{0};

void f() {
    x.fetch_add(1, std::memory_order_relaxed);
}

为啥要管 memory_order

std::mutex 这类互斥量一边把临界区护住,一边帮你搭 happens-before。光用原子、不管顺序的话,编译器和 CPU 可能重排,别的线程会看到“中间态”,调试很头疼。

常用序,从弱到强捋一遍:

  • memory_order_relaxed:只保证原子性,不当线程间同步用;计数、统计之类可以。
  • memory_order_acquire:读侧用;这次读之后的读写,不能跑到这次读前面去。
  • memory_order_release:写侧用;这次写之前的读写,不能跑到这次写后面去。
  • memory_order_acq_rel:读改写一条指令时,读写两边都带上 acquire/release。
  • memory_order_seq_cst:默认就是这个;全局一条总顺序,最强、最省心,代价也略高。

一个典型的 release-acquire 把数据“发布”出去:

#include <atomic>

std::atomic<bool> ready{false};
int data = 0;

void producer() {
    data = 42;
    ready.store(true, std::memory_order_release);
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) { }
    int v = data;  // 能看见 42
}

心里没底的时候,先全程默认 seq_cst,真热点再收紧内存序也不迟。

注意:volatile 不能替代 std::atomic 进行线程同步。volatile 仅防止编译器优化重排,不保证操作的原子性,也不建立线程间的 happens-before 关系。


六、异步:std::async、std::future、std::promise

头文件 。

std::async

#include <future>
#include <iostream>

int slow() { return 1; }

int main() {
    auto fut = std::async(std::launch::async, slow);
    // ... 干别的 ...
    int r = fut.get();  // 阻塞到有结果;只能 get 一次
    return 0;
}
  • std::launch::async:标准只要求在这种策略下,可调用对象如同在一个新的执行线程中异步执行,并未规定底层必须是独立的系统级线程还是由线程池承载。当前主流实现(GCC / Clang 常用运行库、MSVC 等)里,常见做法是直接新建 std::thread。
  • std::launch::deferred:懒,拖到 get() 才在调用线程里跑。
  • 默认策略实现说了算;你强依赖并行,就显式写 async。

packaged_task 和 promise

想把“任务”交给某条线去跑、再把结果塞回 future,就用这套。

#include <future>
#include <thread>

int main() {
    std::promise<int> p;
    std::future<int> f = p.get_future();
    std::thread t([&p] {
        p.set_value(7);
    });
    int v = f.get();
    t.join();
    return v;
}

异常可以 set_exception,会在 future::get() 那边抛出来。


七、只跑一次:std::call_once 与静态局部变量

“整个程序里某段逻辑只执行一次”,常见两条路:call_once 管通用场景;函数内静态局部变量管“懒构造单份对象”,从 C++11 起也是线程安全的。

std::call_once

静态初始化、懒加载做一次初始化,常用 std::once_flag + std::call_once,比手写双重检查锁省心,也不容易写错。

#include <mutex>

std::once_flag flag;

void init() {
    std::call_once(flag, [] {
        // 只执行一次
    });
}

静态局部变量:C++11 起的“魔法静态”

从 C++11 起,块作用域里带初始化的 static 局部变量,在第一次执行到该声明时才会初始化;标准保证初始化只发生一次,且多线程同时首次走到这里时不会数据竞争。这就是常说的 Meyers 单例写法,适合“全局只要一份、且希望用到再建”的对象。

struct Registry {
    Registry() { /* 注册表等一次性开销 */ }
};

Registry& GetRegistry() {
    static Registry reg;
    return reg;
}

和 call_once 怎么选:只要懒构造一个对象、并希望通过函数拿到引用,用 static 局部变量通常更短、更直观;要跑一段任意初始化逻辑、或和 once_flag 绑在类/模块里统一管理,用 call_once 更灵活。若构造函数里抛出异常,实现一般会允许之后再次尝试初始化(与“一次成功即固定”的 call_once 语义细节略有差别,具体以标准与文档为准)。


八、C++17 读写锁:std::shared_mutex

多读一写:读用 std::shared_lock,写用 std::unique_lock。

#include <shared_mutex>
#include <mutex>

std::shared_mutex rw;
int value = 0;

void read_side() {
    std::shared_lock<std::shared_mutex> lk(rw);
    (void)value;
}

void write_side(int v) {
    std::unique_lock<std::shared_mutex> lk(rw);
    value = v;
}

读特别多、写很少的时候划算;否则一把普通 std::mutex 往往更简单。


九、C++20:std::jthread 和停止令牌

std::jthread 析构时如果还能 join,会 自动 join,比裸 std::thread 少踩“写完忘了 join”的坑。可以配合 std::stop_token 做协作式取消:

#include <thread>
#include <stop_token>
#include <chrono>

void worker(std::stop_token st) {
    while (!st.stop_requested()) {
        // 工作一小步
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
}

int main() {
    std::jthread t(worker);
    t.request_stop();
    return 0;
}

另外还有 std::barrier、std::latch、std::atomic::wait 等。


十、实践里容易翻车的点

下面这几条对照一下,能少掉很多坑。

  1. 析构顺序:线程里如果抓着引用或指针,必须保证指向的对象在线程结束前还活着;detach 尤其容易踩雷。
  2. join 或 detach:std::thread 析构前必须二选一,否则直接 std::terminate。
  3. 锁的粒度:临界区越小越好;别扛着锁做 IO、长计算。
  4. 死锁:多把锁就固定顺序加,或者用 std::scoped_lock / std::lock;别随手嵌套乱序加锁。还要避免在持有锁时调用用户自定义回调、虚函数、未知逻辑,防止隐式嵌套锁导致死锁。
  5. 条件变量:永远用带谓词的 wait。
  6. 原子还是互斥量:复杂不变量还是靠锁;原子适合简单标志、计数,或者你专门设计过的无锁结构。
  7. 数据竞争:C++ 里对同一个非原子对象并发读写、又没有同步,是 未定义行为;该上锁上锁,该原子原子。

十一、综合示例:生产者、消费者与工作线程

前面条件变量示例只给了函数片段,这里凑一个能直接编译运行的小程序:多个生产者往共享队列里塞任务,固定数量的工作线程循环取任务、在锁外“干活”,这就是最常见的线程池骨架(真实池子还会复用线程、配任务类型与拒绝策略等,这里只保留核心同步结构)。

流程可以记三步:先 join 所有生产者,再在锁里置位 done 并 notify_all,最后 join 工人线程。工人用带谓词的 wait:有活就干,没活且已收尾就退出。

#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> task_queue;
bool done = false;

void producer(int id, int count) {
    for (int i = 0; i < count; ++i) {
        int item = id * 1000 + i;
        {
            std::lock_guard<std::mutex> lk(mtx);
            task_queue.push(item);
        }
        cv.notify_one();
    }
}

void worker(int id) {
    for (;;) {
        int item = 0;
        {
            std::unique_lock<std::mutex> lk(mtx);
            cv.wait(lk, [] { return !task_queue.empty() || done; });
            if (task_queue.empty() && done) {
                break;
            }
            item = task_queue.front();
            task_queue.pop();
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(5));
        std::cout << "worker " << id << " processed " << item << "\n";
    }
}

int main() {
    const int producer_count = 2;
    const int worker_count = 3;
    const int per_producer = 4;

    std::vector<std::thread> workers;
    for (int w = 0; w < worker_count; ++w) {
        workers.emplace_back(worker, w);
    }

    std::vector<std::thread> producers;
    for (int p = 0; p < producer_count; ++p) {
        producers.emplace_back(producer, p, per_producer);
    }

    for (auto& t : producers) {
        t.join();
    }

    {
        std::lock_guard<std::mutex> lk(mtx);
        done = true;
    }
    cv.notify_all();

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

    return 0;
}

编译运行示例:g++ -std=c++17 -pthread main.cpp -o demo && ./demo(MSVC 下用对应选项链接线程支持即可。)


十二、小结:一张表对照需求

需求常用标准库手段
起线程、等结束std::thread + join;C++20 能上就上 std::jthread
护住共享数据std::mutex + std::lock_guard / std::unique_lock / std::scoped_lock
等条件成立std::condition_variable + 带谓词的循环
计数、标志、无锁底子std::atomic + 合适的 memory_order
异步结果std::async、std::packaged_task、std::promise/std::future
单次初始化std::call_once;C++11 起函数内 static 局部变量(线程安全一次性初始化)
多读单写std::shared_mutex + std::shared_lock / std::unique_lock

把 RAII 锁、线程只能移动、原子内存序 这三块吃透,再按业务选 async 还是裸线程,大部分 C++ 并发代码就能写得又稳又好维护。