资讯动态

简单聊聊C++中线程的原理与实现

发布时间:2026/10/22 18:10:07 来源:尧图企业网站定制
前言C11 把线程正式纳入标准库std::thread从此成为跨平台的线程创建方式。但在日常讨论里C 的线程经常被说得含糊有人以为std::thread是语言层面实现的绿色线程有人以为它和操作系统的线程是两套东西还有人把std::async、线程池、协程都笼统地归进线程。这些理解都需要澄清。准确的说法是C 标准定义的是线程的语义模型std::thread是一个句柄类它把一个可调用对象交给底层实现去执行底层实现通常直接映射到操作系统的原生线程。在 Linux 上一般是 POSIX 线程在 Windows 上一般是系统线程 API。标准并不规定必须这样映射只规定可观察的行为多个线程共享同一地址空间、各自拥有独立的调用栈、并发执行。本文从线程到底是什么讲起说明std::thread的构造、生命周期与汇合方式再讲共享数据带来的数据竞争以及互斥量、条件变量的正确用法最后给出一个可读性较好的生产者—消费者示例。所有代码以 C17 为基准涉及 C20 的std::jthread时会单独注明。一、线程的语义模型共享地址空间独立调用栈一个进程拥有独立的虚拟地址空间里面装着代码段、数据段、堆和各个线程的栈。线程是进程内的执行流同一进程的所有线程共享代码、全局变量、堆上的对象和打开的文件描述符但每个线程有自己的栈、寄存器上下文和线程局部存储。正因为共享线程间传递数据只需要传指针或引用也正因为共享任何没有同步保护的并发读写都可能出错。从调度角度看操作系统把线程作为调度的基本单位。线程切换需要保存和恢复寄存器、切换栈可能还涉及内核态与用户态的转换。C 标准对这些细节不作规定标准只保证std::thread表示一个可汇合的执行线程并提供join、detach、joinable等操作。有几个概念需要区分概念由谁提供与std::thread的关系原生线程操作系统std::thread通常在其上做一层封装std::threadC11 标准库线程句柄构造即启动析构前必须汇合或分离std::jthreadC20 标准库析构时自动join并支持协作式停止线程池第三方库或自行实现复用若干工作线程减少频繁创建销毁的开销协程C20 语言特性用户态协作式切换不是操作系统线程需要说明的是std::thread构造函数返回时新线程不一定已经开始执行标准只保证线程已经启动具体何时被调度由操作系统决定。因此不能假设创建顺序等于执行顺序也不能假设主线程会先跑。二、std::thread 的构造、汇合与分离std::thread的构造函数接受任意可调用对象函数指针、函数对象、lambda、成员函数指针加对象后面可以跟参数。参数会被按值复制或移动到线程的内部存储中再以左值的形式传给可调用对象。这一点很关键如果参数是引用语义必须用std::ref或std::cref显式包裹否则传给线程的是副本。// 适用于 C17#include iostream#include string#include threadvoid 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也不保证与实际可用核心数一致。它只能作为初始值不能当作性能承诺。C20 引入了std::jthread析构时自动调用join还提供stop_token做协作式取消。它的构造方式与std::thread类似但需要 C20 及以上的标准库支持各编译器实现时间不同使用前应确认工具链。三、共享数据与数据竞争为什么需要同步多个线程同时访问同一块内存只要其中至少一个是写操作且没有同步措施就构成数据竞争data race。C 标准明确规定数据竞争是未定义行为undefined behavior, UB不是结果可能不准这么轻。UB 意味着程序可能看起来正常、可能算出错误结果、也可能崩溃而且编译器有权假设竞争不存在并据此优化。因此两个线程各加一百万次最后结果少一点这种经验描述是不准确的那不是丢失更新这么简单而是程序本身已经失去了标准赋予的行为保证。最基础的同步手段是互斥量。std::mutex提供lock/unlock但手工配对容易在异常路径上漏掉解锁所以应当使用 RAII 包装std::lock_guardstd::mutex构造时加锁析构时解锁不能中途解锁开销最小。std::unique_lockstd::mutex同样 RAII但支持延迟加锁、提前解锁、转移所有权条件变量必须用它。std::scoped_lockC17可以一次锁多个互斥量内部用避免死锁的算法替代std::lock加多个lock_guard的组合。// 适用于 C17#include iostream#include mutex#include threadstd::mutex g_mutex;long long g_total 0;void add(long long n) {for (long long i 0; i n; i) {std::lock_guardstd::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测量。条件变量用于等待某个条件成立必须和互斥量、谓词一起使用// 适用于 C17std::unique_lockstd::mutex lk(m);cv.wait(lk, [] { return ready; });wait在持有锁的情况下检查谓词谓词为假时原子地释放锁并阻塞被唤醒后重新加锁并再次检查。谓词版本能正确处理虚假唤醒spurious wakeup而不带谓词的wait需要自己写循环。通知方应当在持有锁的情况下修改状态再调用notify_one或notify_all持锁通知不是强制的但更容易推理。四、完整示例有界队列的生产者与消费者下面用互斥量加两个条件变量实现一个有界队列队列满时生产者等待队列空时消费者等待关闭后双方都能退出。// 适用于 C17#include condition_variable#include cstddef#include iostream#include mutex#include queue#include threadclass BoundedQueue {public:explicit BoundedQueue(std::size_t cap) : cap_(cap) {}void push(int value) {std::unique_lockstd::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_lockstd::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_guardstd::mutex lk(m_);closed_ true;notFull_.notify_all();notEmpty_.notify_all();}private:std::size_t cap_;std::queueint 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。 ✅ 保证每条路径都汇合或分离C20 起可用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新标准C20 的std::jthread自动汇合支持协作式停止线程的难点不在创建而在生命周期与共享状态的管理。把每个线程都有明确归宿共享数据要么加锁要么原子条件变量必须带谓词这三条守住大部分并发缺陷都能在写代码时就避免。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑