2229 字
11 分钟
C++ 线程库:用 RAII 管线程,用对象管共享状态

本文是 C++ 系列 的第四篇。

上一篇:C++ 引用:看起来没传地址,为什么能改到原变量?

写多线程代码时,我最不想看到的是一堆全局变量、几个裸 std::thread,最后靠手写 join() 收尾。

这种代码能演示概念,但不太像工程代码。工程里更重要的是:

  1. 线程生命周期能不能自动收尾
  2. 共享状态有没有被对象封装起来
  3. 锁保护的范围是否明确
  4. 等待线程能不能被正常唤醒和停止

所以这篇尽量用现代 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_lockunique_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_;
};

这段代码有几个边界:

  1. tasks_ 只能在持有 mutex_ 时访问
  2. push() 修改队列后唤醒一个等待线程
  3. pop() 在队列为空时睡眠,不空时取出任务
  4. stopToken 被请求停止后,wait 可以返回
  5. 返回 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::jthreadWorker 析构时,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 离开作用域时自动请求停止并等待线程结束。queueworker 后析构,所以工作线程不会访问已经销毁的队列。

这个例子已经接近线程池的核心结构:一个任务队列,多个工作线程,线程循环从队列取任务。真正的线程池只是把 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++ 写线程代码的基本判断:

  1. 优先用 std::jthread 管线程生命周期,少手写 join
  2. 长时间运行的线程要接收 std::stop_token
  3. 共享状态和保护它的锁应该封装在同一个类里
  4. 简单临界区用 std::scoped_lock
  5. 需要条件变量或手动控制锁生命周期时用 std::unique_lock
  6. 等待队列这类场景,用条件变量,不要空转轮询
  7. atomic 适合独立计数和标志,不适合保护容器

线程库本身不是难点。难点是用对象边界和 RAII 把生命周期、共享状态、等待条件讲清楚。只要这些边界清楚,后面写线程池、异步任务队列或者后台日志线程,结构就不会散。