4、多线程基础:线程的创建与销毁,线程同步的必要性,数据竞争的概念

多线程,说白了就是让程序同时干好几件事。你想想看,一个单线程的程序,就像只有一个工人的流水线。活儿得一件一件干,遇到慢的操作(比如读文件、等网络),整个生产线就卡住了。多线程就是多招几个工人,各干各的,效率自然就上来了。

但多线程不是请客吃饭。我这些年调试过的崩溃、死锁、数据错乱,十有八九都跟多线程有关。嗯,咱们今天就把基础打牢。

4.1 线程的创建与销毁

C++11 标准库提供了 std::thread,用起来非常直接。我个人习惯用 lambda 表达式来封装线程逻辑,代码更紧凑,也更容易看清上下文。

#include <iostream>
#include <thread>

void worker(int id) {
    std::cout << "线程 " << id << " 开始工作\n";
    // 模拟一些计算
    for (int i = 0; i < 1000000; ++i);
    std::cout << "线程 " << id << " 结束\n";
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2([&]() { worker(2); });

    // 等待线程结束
    t1.join();
    t2.join();

    std::cout << "主线程结束\n";
    return 0;
}

这里有两个关键操作:join() 和 detach()。

  • join():主线程阻塞,等待子线程执行完毕。这是最安全的方式。
  • detach():让子线程在后台独立运行,主线程不再管理它。我建议你慎用 detach。为什么?因为一旦 detach,你就失去了对线程的控制权。如果线程还在跑,主线程却退出了,程序可能直接崩溃。
⚠️ 注意: 线程对象销毁前,必须调用 join() 或 detach()。否则 std::thread 的析构函数会调用 std::terminate(),程序直接挂掉。我曾经在代码审查时见过有人忘了这茬,结果线上服务不定期崩溃,查了两天才找到原因。

4.2 线程同步的必要性

多个线程同时访问同一个数据,问题就来了。我给你看个经典例子:

#include <iostream>
#include <thread>

int counter = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        ++counter;  // 这行不是原子操作!
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "counter = " << counter << "\n";
    // 期望值:200000
    // 实际值:可能只有 120000 左右
    return 0;
}

你运行一下试试。期望结果是 200000,但实际往往只有十几万。为什么会这样?

因为 ++counter 在 CPU 层面是三条指令:读内存、加1、写回内存。两个线程同时执行这三步,就可能出现「读到了旧值,各自加1,写回旧值+1」的情况。结果就是明明加了两次,counter 只增加了1。这就是典型的数据竞争。

核心概念: 当多个线程同时访问同一块内存,且至少有一个线程在写,而没有任何同步机制时,就发生了数据竞争。数据竞争会导致未定义行为——程序可能崩溃、结果错误、甚至产生诡异的逻辑跳转。

4.3 数据竞争的概念

数据竞争不是「偶尔出错」,而是「一定会出错」。只是出错的时间和表现方式不同。我在项目中遇到过最诡异的一次:一个多线程程序跑了三天才崩溃,而且每次崩溃的堆栈都不一样。最后用 ThreadSanitizer 一查,发现是一个全局变量在多个线程里无锁读写。

数据竞争有几个典型特征:

  • 结果不可预测:同样的输入,每次运行结果可能不同。
  • 难以复现:调试模式下可能正常,Release 模式下崩溃。
  • 时序敏感:加个打印语句,问题就消失了(这叫 Heisenbug)。

要解决数据竞争,核心思路就是互斥——同一时间只允许一个线程访问共享数据。C++ 提供了 std::mutex 和 std::lock_guard:

#include <iostream>
#include <thread>
#include <mutex>

int counter = 0;
std::mutex mtx;

void safe_increment() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        ++counter;  // 现在安全了
    }
}

int main() {
    std::thread t1(safe_increment);
    std::thread t2(safe_increment);

    t1.join();
    t2.join();

    std::cout << "counter = " << counter << "\n";
    // 稳定输出 200000
    return 0;
}

std::lock_guard 是个好东西。它在构造时自动加锁,析构时自动解锁。就算中间抛出异常,锁也会被正确释放。我建议你养成习惯:能用 lock_guard 就别手动 lock/unlock。

💡 小技巧: 锁的粒度要尽量小。只锁必要的代码行,不要在锁里做耗时的 I/O 操作。否则其他线程会等很久,性能直线下降。

4.4 知识体系总览

下面这张图帮你理清本章的核心逻辑:

多线程基础核心逻辑 线程创建与销毁 std::thread / join / detach 共享数据访问 读 + 写操作 数据竞争 未定义行为 解决方案:线程同步 互斥锁 std::mutex / lock_guard 原子操作 std::atomic 条件变量 std::condition_variable 目标:消除数据竞争,保证线程安全

这张图把本章的逻辑串起来了。你从「线程创建与销毁」出发,多个线程访问「共享数据」,如果没有同步,就会掉进「数据竞争」的坑里。解决方案就是「线程同步」——用互斥锁、原子操作或条件变量来保护共享数据。

一句话总结: 多线程编程的核心就两件事——创建线程和保护数据。创建线程很简单,保护数据才是真正的技术活。

我曾经接手过一个遗留系统,里面全是裸的全局变量,没有任何锁。每次发布新版本,测试团队都要跑一周的压力测试,就为了抓那些随机崩溃。后来我花了两周时间,把所有共享数据都加上了 std::mutex,崩溃率直接降到零。嗯,这就是同步的价值。

下一节我们会深入讲互斥锁的各种用法和陷阱。今天先把基础打牢,多线程的世界才刚刚打开。

可可精品资料库