本文是 C++ 系列 的第四篇。
写多线程代码时,我最不想看到的是一堆全局变量、几个裸 std::thread,最后靠手写 join() 收尾。
这种代码能演示概念,但不太像工程代码。工程里更重要的是:
- 线程生命周期能不能自动收尾
- 共享状态有没有被对象封装起来
- 锁保护的范围是否明确
- 等待线程能不能被正常唤醒和停止
所以这篇尽量用现代 C++ 的写法:std::jthread 管线程生命周期,类对象管理共享状态,锁对象用 RAII 自动加锁和解锁。
1. 先用 std::jthread,少手写 join
std::thread 最大的问题是:析构前如果还没有 join() 或 detach(),程序会调用 std::terminate()。这不是 bug,是标准库在提醒你线程生命周期没有处理清楚。
C++20 提供了 std::jthread,它的析构函数会自动请求停止并等待线程结束。
#include <iostream>#include <thread>
int main() { std::jthread worker([] { std::cout << "worker thread\n"; });
std::cout << "main thread\n";}这段代码里没有手写 join()。worker 离开作用域时,std::jthread 会自动 join。它符合 RAII:线程资源由对象持有,对象析构时完成清理。
输出顺序仍然不固定。std::jthread 解决的是生命周期,不是执行顺序。
2. 停止线程:用 stop_token 表达退出条件
线程不是启动以后就不管了。后台循环必须知道什么时候退出。
std::jthread 支持把 std::stop_token 传给线程函数:
#include <chrono>#include <iostream>#include <thread>
using namespace std::chrono_literals;
int main() { std::jthread worker([](std::stop_token stopToken) { while (!stopToken.stop_requested()) { std::cout << "working\n"; std::this_thread::sleep_for(100ms); } });
std::this_thread::sleep_for(350ms);}这段代码里,worker 析构时会请求停止。线程函数每轮检查 stop_requested(),收到停止请求后退出循环。
这里的关键不是 sleep_for,而是退出条件被明确写进了线程函数。一个长期运行的线程,必须有可控的退出路径。
3. 共享数据不要散在全局,先封装成对象
多线程访问共享数据时,最基本的做法是:数据和保护它的锁放在同一个类里。
#include <mutex>
class Counter {public: void add(int value) { std::scoped_lock lock(mutex_); value_ += value; }
int value() const { std::scoped_lock lock(mutex_); return value_; }
private: mutable std::mutex mutex_; int value_{0};};这段代码里,value_ 是共享状态,mutex_ 是保护它的锁。它们都在 Counter 内部,外部代码不能绕过锁直接改 value_。
value() 是 const 成员函数,但它仍然需要加锁,所以 mutex_ 标成 mutable。这不是为了修改业务状态,而是为了允许 const 查询时维护同步机制。
调用方式:
#include <iostream>#include <thread>#include <vector>
int main() { Counter counter; std::vector<std::jthread> workers;
for (int i = 0; i < 4; ++i) { workers.emplace_back([&counter] { for (int j = 0; j < 10000; ++j) { counter.add(1); } }); }
std::cout << counter.value() << '\n';}这段代码有一个需要注意的点:std::jthread 的析构发生在 main 结束时,但 std::cout 这一行执行时,线程可能还没跑完。因此这里直接打印可能不是最终值。
如果要在打印前等所有线程结束,可以缩小线程容器的作用域:
Counter counter;
{ std::vector<std::jthread> workers;
for (int i = 0; i < 4; ++i) { workers.emplace_back([&counter] { for (int j = 0; j < 10000; ++j) { counter.add(1); } }); }}
std::cout << counter.value() << '\n';这段代码更符合 RAII 的思路。workers 离开作用域时,里面的 jthread 全部析构并自动 join。之后再读取 counter,结果才是最终值。
4. scoped_lock 和 unique_lock 分工不同
简单临界区可以用 std::scoped_lock。
void add(int value) { std::scoped_lock lock(mutex_); value_ += value;}这段代码从进入函数开始加锁,到函数返回自动解锁。没有手动 lock() 和 unlock(),异常路径也不会漏掉解锁。
如果需要中途释放锁,或者要配合条件变量,就用 std::unique_lock。
void addAndLog(int value) { int current = 0;
{ std::unique_lock lock(mutex_); value_ += value; current = value_; }
std::cout << "counter = " << current << '\n';}这段代码把锁的作用域限制在修改 value_ 的部分。打印不再持锁,避免慢 IO 阻塞其他线程访问计数器。
简单规则:只需要保护一小段代码,用 scoped_lock;需要等待条件、提前解锁或转移锁所有权,用 unique_lock。
5. 条件变量最好也封装进类
生产者消费者模型里,线程不应该一直循环检查队列。没有任务时应该睡眠,有任务或停止时再醒来。
下面是一个最小任务队列。它不是完整线程池,只负责线程安全地放入和取出任务。
#include <condition_variable>#include <mutex>#include <optional>#include <queue>#include <stop_token>
class TaskQueue {public: void push(int task) { { std::scoped_lock lock(mutex_); tasks_.push(task); }
cv_.notify_one(); }
std::optional<int> pop(std::stop_token stopToken) { std::unique_lock lock(mutex_);
cv_.wait(lock, stopToken, [this] { return !tasks_.empty(); });
if (tasks_.empty()) { return std::nullopt; }
int task = tasks_.front(); tasks_.pop(); return task; }
private: std::mutex mutex_; std::condition_variable_any cv_; std::queue<int> tasks_;};这段代码有几个边界:
tasks_只能在持有mutex_时访问push()修改队列后唤醒一个等待线程pop()在队列为空时睡眠,不空时取出任务stopToken被请求停止后,wait可以返回- 返回
std::optional<int>,表示可能取到任务,也可能因为停止而退出
这里用的是 std::condition_variable_any,因为它支持带 std::stop_token 的等待。这样队列不需要自己维护一个额外的 stopped_ 标志。
6. 用对象管理工作线程
有了 TaskQueue,再把工作线程封装成类。
#include <iostream>#include <thread>
class Worker {public: explicit Worker(TaskQueue& queue) : queue_(queue), thread_([this](std::stop_token stopToken) { run(stopToken); }) {}
private: void run(std::stop_token stopToken) { while (!stopToken.stop_requested()) { auto task = queue_.pop(stopToken); if (!task.has_value()) { break; }
std::cout << "handle task: " << *task << '\n'; } }
TaskQueue& queue_; std::jthread thread_;};这段代码里,Worker 持有一个 std::jthread。Worker 析构时,jthread 自动请求停止并 join,不需要外部手动调用 stop() 或 join()。
这里 TaskQueue& queue_ 是引用,说明 Worker 不拥有队列,只使用外部传进来的队列。工程里要保证队列活得比 Worker 久。也可以用 std::shared_ptr<TaskQueue> 表达共享所有权,但这里没必要把所有权关系复杂化。
使用方式:
int main() { TaskQueue queue;
{ Worker worker(queue);
queue.push(1); queue.push(2); queue.push(3); }}这段代码靠作用域管理生命周期。worker 离开作用域时自动请求停止并等待线程结束。queue 在 worker 后析构,所以工作线程不会访问已经销毁的队列。
这个例子已经接近线程池的核心结构:一个任务队列,多个工作线程,线程循环从队列取任务。真正的线程池只是把 int task 换成 std::function<void()>,再管理多个 Worker。
7. atomic 只处理简单状态
如果只是统计处理了多少任务,用 std::atomic 很合适。
#include <atomic>
class Metrics {public: void markDone() { doneCount_.fetch_add(1, std::memory_order_relaxed); }
int doneCount() const { return doneCount_.load(std::memory_order_relaxed); }
private: std::atomic<int> doneCount_{0};};这段代码里,doneCount_ 是一个独立计数器,不参与维护队列内部结构,也不和其他字段组成不变量。用 atomic 很直接。
但不要把 atomic 当成锁的替代品。像下面这种操作仍然需要锁:
if (!tasks_.empty()) { int task = tasks_.front(); tasks_.pop();}这段代码包含三个动作:检查队列、读取队首、弹出队首。它们必须作为一个整体被保护。一个原子变量无法保护 std::queue 的内部结构。
8. 多线程类的设计边界
写多线程类时,我会先看这几个边界。
第一,所有权。谁拥有线程,谁负责停它。用 std::jthread 时,这个答案通常就是持有它的对象。
第二,共享状态。共享数据不要裸露在外面。数据和锁最好放在同一个类里,外部只能通过成员函数访问。
第三,等待条件。线程等待的条件必须明确,退出时也必须能唤醒。stop_token 能减少手写停止标志,但仍然要保证等待函数能响应停止请求。
第四,析构顺序。工作线程引用了什么对象,就要保证这些对象活得比线程久。成员变量析构顺序是声明顺序的反向,这一点在多线程类里尤其重要。
9. 小结
这篇先记住几个现代 C++ 写线程代码的基本判断:
- 优先用
std::jthread管线程生命周期,少手写join - 长时间运行的线程要接收
std::stop_token - 共享状态和保护它的锁应该封装在同一个类里
- 简单临界区用
std::scoped_lock - 需要条件变量或手动控制锁生命周期时用
std::unique_lock - 等待队列这类场景,用条件变量,不要空转轮询
atomic适合独立计数和标志,不适合保护容器
线程库本身不是难点。难点是用对象边界和 RAII 把生命周期、共享状态、等待条件讲清楚。只要这些边界清楚,后面写线程池、异步任务队列或者后台日志线程,结构就不会散。