前言


C++11 把线程正式纳入标准库,std::thread 从此成为跨平台的线程创建方式。但在日常讨论里,"C++ 的线程"经常被说得含糊:有人以为 std::thread 是语言层面实现的"绿色线程",有人以为它和操作系统的线程是两套东西,还有人把 std::async、线程池、协程都笼统地归进"线程"。这些理解都需要澄清。


准确的说法是:C++ 标准定义的是线程的语义模型,std::thread 是一个句柄类,它把一个可调用对象交给底层实现去执行;底层实现通常直接映射到操作系统的原生线程。在 Linux 上一般是 POSIX 线程,在 Windows 上一般是系统线程 API。标准并不规定必须这样映射,只规定可观察的行为:多个线程共享同一地址空间、各自拥有独立的调用栈、并发执行。


本文从"线程到底是什么"讲起,说明 std::thread 的构造、生命周期与汇合方式,再讲共享数据带来的数据竞争以及互斥量、条件变量的正确用法,最后给出一个可读性较好的生产者—消费者示例。所有代码以 C++17 为基准;涉及 C++20 的 std::jthread 时会单独注明。


一、线程的语义模型:共享地址空间,独立调用栈


一个进程拥有独立的虚拟地址空间,里面装着代码段、数据段、堆和各个线程的栈。线程是进程内的执行流:同一进程的所有线程共享代码、全局变量、堆上的对象和打开的文件描述符,但每个线程有自己的栈、寄存器上下文和线程局部存储。正因为共享,线程间传递数据只需要传指针或引用;也正因为共享,任何没有同步保护的并发读写都可能出错。


从调度角度看,操作系统把线程作为调度的基本单位。线程切换需要保存和恢复寄存器、切换栈,可能还涉及内核态与用户态的转换。C++ 标准对这些细节不作规定,标准只保证 std::thread 表示一个"可汇合的执行线程",并提供 join、detach、joinable 等操作。


有几个概念需要区分:




概念由谁提供与 std::thread 的关系



原生线程操作系统std::thread 通常在其上做一层封装

std::threadC++11 标准库线程句柄,构造即启动,析构前必须汇合或分离

std::jthreadC++20 标准库析构时自动 join,并支持协作式停止

线程池第三方库或自行实现复用若干工作线程,减少频繁创建销毁的开销

协程C++20 语言特性用户态协作式切换,不是操作系统线程



需要说明的是,std::thread 构造函数返回时,新线程不一定已经开始执行,标准只保证线程已经启动;具体何时被调度由操作系统决定。因此不能假设"创建顺序等于执行顺序",也不能假设主线程会先跑。


二、std::thread 的构造、汇合与分离


std::thread 的构造函数接受任意可调用对象(函数指针、函数对象、lambda、成员函数指针加对象),后面可以跟参数。参数会被按值复制或移动到线程的内部存储中,再以左值的形式传给可调用对象。这一点很关键:如果参数是引用语义,必须用 std::ref 或 std::cref 显式包裹,否则传给线程的是副本。


// 适用于 C++17
#include <iostream>
#include <string>
#include <thread>

void greet(const std::string& name, int times) {
for (int i = 0; i < times; ++i) {
std::cout << "hello, " << name << '\n';
}
}

int main() {
std::string who = "world";
std::thread t(greet, who, 2); // who 被复制进线程存储
t.join(); // 等待线程结束
return 0;
}

join() 阻塞当前线程,直到目标线程执行完毕,之后该 std::thread 变为不可汇合(joinable() 返回 false)。detach() 则把线程与句柄分离,让它在后台独立运行,句柄不再代表任何线程。一个 std::thread 对象在析构时如果仍然 joinable(),标准规定调用 std::terminate(),程序直接终止。这不是"资源泄漏"那么温和,而是硬性崩溃,所以每个线程都必须有明确的归宿。


join 只能调用一次,对不可汇合的线程调用 join 会抛出 std::system_error。判断是否可以汇合用 joinable()。查看线程标识用 get_id(),返回 std::thread::id,可以比较、可以输出到流;默认构造的 id 表示"无线程"。


std::thread::hardware_concurrency() 返回一个提示性的并发线程数,通常是硬件支持的线程数,但标准允许返回 0,也不保证与实际可用核心数一致。它只能作为初始值,不能当作性能承诺。


C++20 引入了 std::jthread,析构时自动调用 join,还提供 stop_token 做协作式取消。它的构造方式与 std::thread 类似,但需要 C++20 及以上的标准库支持,各编译器实现时间不同,使用前应确认工具链。


三、共享数据与数据竞争:为什么需要同步


多个线程同时访问同一块内存,只要其中至少一个是写操作,且没有同步措施,就构成数据竞争(data race)。C++ 标准明确规定数据竞争是未定义行为(undefined behavior, UB),不是"结果可能不准"这么轻。UB 意味着程序可能看起来正常、可能算出错误结果、也可能崩溃,而且编译器有权假设竞争不存在并据此优化。


因此,"两个线程各加一百万次,最后结果少一点"这种经验描述是不准确的:那不是"丢失更新"这么简单,而是程序本身已经失去了标准赋予的行为保证。


最基础的同步手段是互斥量。std::mutex 提供 lock/unlock,但手工配对容易在异常路径上漏掉解锁,所以应当使用 RAII 包装:



  • std::lock_guard<std::mutex>:构造时加锁,析构时解锁,不能中途解锁,开销最小。

  • std::unique_lock<std::mutex>:同样 RAII,但支持延迟加锁、提前解锁、转移所有权,条件变量必须用它。

  • std::scoped_lock(C++17):可以一次锁多个互斥量,内部用避免死锁的算法,替代 std::lock 加多个 lock_guard 的组合。


// 适用于 C++17
#include <iostream>
#include <mutex>
#include <thread>

std::mutex g_mutex;
long long g_total = 0;

void add(long long n) {
for (long long i = 0; i < n; ++i) {
std::lock_guard<std::mutex> lock(g_mutex);
g_total += 1;
}
}

int main() {
std::thread a(add, 100000);
std::thread b(add, 100000);
a.join();
b.join();
std::cout << g_total << '\n';
return 0;
}

这段代码把锁放在循环内部,临界区只有一条加法。语义上是正确的,但锁的获取释放发生在每一次迭代,开销不可忽略。更好的做法是让每个线程先在局部变量里累加,最后一次性合并,减少临界区次数。这里不给出具体倍数,因为实际差异取决于平台、竞争程度和优化级别,应当自行用 std::chrono::steady_clock 测量。


条件变量用于"等待某个条件成立",必须和互斥量、谓词一起使用:


// 适用于 C++17
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return ready; });

wait 在持有锁的情况下检查谓词;谓词为假时原子地释放锁并阻塞,被唤醒后重新加锁并再次检查。谓词版本能正确处理虚假唤醒(spurious wakeup),而不带谓词的 wait 需要自己写循环。通知方应当在持有锁的情况下修改状态,再调用 notify_one 或 notify_all;持锁通知不是强制的,但更容易推理。


四、完整示例:有界队列的生产者与消费者


下面用互斥量加两个条件变量实现一个有界队列:队列满时生产者等待,队列空时消费者等待,关闭后双方都能退出。


// 适用于 C++17
#include <condition_variable>
#include <cstddef>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>

class BoundedQueue {
public:
explicit BoundedQueue(std::size_t cap) : cap_(cap) {}

void push(int value) {
std::unique_lock<std::mutex> lk(m_);
notFull_.wait(lk, [this] { return q_.size() < cap_ || closed_; });
if (closed_) {
return;
}
q_.push(value);
notEmpty_.notify_one();
}

bool pop(int& out) {
std::unique_lock<std::mutex> lk(m_);
notEmpty_.wait(lk, [this] { return !q_.empty() || closed_; });
if (q_.empty()) {
return false;
}
out = q_.front();
q_.pop();
notFull_.notify_one();
return true;
}

void close() {
std::lock_guard<std::mutex> lk(m_);
closed_ = true;
notFull_.notify_all();
notEmpty_.notify_all();
}

private:
std::size_t cap_;
std::queue<int> q_;
std::mutex m_;
std::condition_variable notEmpty_;
std::condition_variable notFull_;
bool closed_ = false;
};

int main() {
BoundedQueue queue(8);

std::thread producer([&queue] {
for (int i = 0; i < 20; ++i) {
queue.push(i);
}
queue.close();
});

std::thread consumer([&queue] {
int value = 0;
while (queue.pop(value)) {
std::cout << value << '\n';
}
});

producer.join();
consumer.join();
return 0;
}

几个设计要点。第一,closed_ 的读写都在锁内,close() 之后两个条件变量都 notify_all,保证等待中的线程能醒来看到关闭状态。第二,pop 在队列为空且已关闭时返回 false,消费者据此退出,不会死等。第三,push 在关闭后直接返回,丢弃后续数据,这是示例的简化策略;真实系统里应当返回一个状态值让调用方知道数据未被接收。第四,条件变量的谓词捕获了 this,调用期间对象必须存活,这里对象在 main 的栈上,两个线程都在 main 返回前汇合,生命周期是安全的。


常见坑点


❌ 创建 std::thread 后既不 join 也不 detach,对象析构时触发 std::terminate。 ✅ 保证每条路径都汇合或分离;C++20 起可用 std::jthread 自动汇合。


❌ 向线程函数传递局部变量的引用,线程还没跑完局部变量就销毁了。 ✅ 按值传递,或确保被引用对象的生命周期覆盖线程的整个执行期。


❌ 想在线程里修改外部变量,却忘了用 std::ref,结果改的是副本。 ✅ 用 std::ref(x) 显式传递引用,并自行保证同步与生命周期。


❌ 只用 std::mutex 手工 lock/unlock,异常路径上漏掉解锁导致死锁。 ✅ 用 std::lock_guard、std::unique_lock 或 std::scoped_lock 做 RAII 管理。


❌ 用 volatile 标志位在线程间通信,认为它能让另一个线程看到修改。 ✅ volatile 不是同步手段,用 std::atomic 或互斥量;数据竞争是 UB。


❌ 条件变量用不带谓词的 wait,被虚假唤醒后继续执行。 ✅ 用 wait(lock, pred) 或手写 while (!pred) cv.wait(lock);。


❌ 多个线程同时读写同一个 std::vector,以为 push_back 是"原子"的。 ✅ 容器本身不提供线程安全保证,所有并发访问都要加锁或改用并发数据结构。


❌ 认为 hardware_concurrency() 返回的就是"最佳线程数"。 ✅ 它只是提示值,可能为 0;实际线程数要结合任务类型和测量确定。


总结




主题关键结论



本质std::thread 是标准库句柄,底层通常映射到操作系统原生线程

生命周期构造即启动;析构前必须 join 或 detach,否则 std::terminate

参数传递默认按值复制/移动,需要引用时用 std::ref 并保证生命周期

数据竞争无同步的并发读写是 UB,不是"结果稍有偏差"

同步工具lock_guard/unique_lock/scoped_lock、条件变量配谓词、std::atomic

新标准C++20 的 std::jthread 自动汇合,支持协作式停止



线程的难点不在创建,而在生命周期与共享状态的管理。把"每个线程都有明确归宿""共享数据要么加锁要么原子""条件变量必须带谓词"这三条守住,大部分并发缺陷都能在写代码时就避免。




Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐