C++ 多线程入门导读

先建立一个总模型

多线程不是“多写几个 std::thread”。真正要理解的是下面五件事:

任务:要做什么
线程:谁来做
数据:任务之间共享什么
同步:什么时候等、什么时候通知
退出:怎么安全收尾

如果只学 API,很容易变成:

  • 会启动线程,但不知道什么时候 join
  • 会加锁,但不知道锁保护的是哪份数据。
  • 会用条件变量,但不知道为什么必须写谓词。
  • 会用 atomic,但误以为所有并发问题都能靠原子解决。
  • 会写线程池,但不知道任务太小、队列太大、退出不完整会出问题。

所以学习顺序应该从“任务关系”开始,而不是从背函数开始。

一个最小例子:主线程等子线程

#include <iostream>
#include <thread>
 
void work() {
    std::cout << "work in another thread\n";
}
 
int main() {
    std::thread t(work);
    t.join();
 
    std::cout << "main done\n";
}

这里有三个关键点:

  • std::thread t(work):创建一个系统线程去执行 work
  • join():主线程等待子线程结束。
  • join 不是杀死线程,只是等待线程自己执行完。

如果 std::thread 析构时还没有 joindetach,程序会 std::terminate。所以线程生命周期是第一个必须掌握的知识。

继续看:cpp线程生命周期

为什么多线程会出错:共享数据

下面代码看起来只是多个线程一起给计数器加一:

#include <iostream>
#include <thread>
#include <vector>
 
int main() {
    int counter = 0;
    std::vector<std::thread> threads;
 
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back([&] {
            for (int j = 0; j < 10000; ++j) {
                ++counter;
            }
        });
    }
 
    for (auto& t : threads) {
        t.join();
    }
 
    std::cout << counter << '\n';
}

你可能期待输出 40000,但实际不可靠。原因是 ++counter 不是一个不可分割的动作,它通常包含:

读 counter
加 1
写回 counter

多个线程同时做这三步,就可能互相覆盖结果。这叫数据竞争,在 C++ 中属于未定义行为。

解决方式之一是加锁:

std::mutex m;
 
{
    std::lock_guard<std::mutex> lock(m);
    ++counter;
}

继续看:cpp互斥锁与RAII

锁到底保护什么

一个常见误区是:“我给一段代码加锁了,所以安全了。”

更准确的说法是:锁保护的是数据,不是代码。

例如:

std::mutex m;
std::vector<int> values;

应该在脑子里建立规则:

m 保护 values
任何线程读写 values 前,都必须先拿到 m

如果只有一部分代码拿锁,另一部分代码绕过锁直接访问 values,仍然不安全。

什么时候用条件变量

锁只能解决“同一时间只有一个线程改数据”。但很多时候,线程还需要等某个条件成立。

典型例子:队列为空时,消费者不应该一直空转占 CPU,而应该睡眠;生产者放入数据后再唤醒它。

生产者:放数据 -> 通知
消费者:没数据就睡 -> 被通知后再检查

这就是 std::condition_variable 的场景。

最重要的写法:

cv.wait(lock, [&] {
    return !q.empty() || done;
});

必须带谓词,因为:

  • 线程可能虚假唤醒。
  • 通知可能早于等待。
  • 多个消费者醒来后,数据可能被其他线程先取走。

继续看:cpp条件变量

消息传递比到处共享更容易理解

共享内存写法容易让所有线程都能改同一堆对象。项目变大后,谁改了数据很难追。

更容易维护的方式是让线程通过队列传任务或数据:

生产者线程 -> 队列 -> 消费者线程

这样共享状态集中在队列里,队列内部用锁和条件变量保护。其他业务代码只负责 pushpop

这就是 cppChannel模型 的价值。

future 解决什么问题

如果你只想启动一个异步任务,并拿到它的返回值,不一定要自己设计队列。

#include <future>
#include <iostream>
 
int compute() {
    return 42;
}
 
int main() {
    auto fut = std::async(std::launch::async, compute);
    std::cout << fut.get() << '\n';
}

future 表示“未来会有一个结果”。你用 get() 等它完成并取结果。

继续看:cppfuture和promise

原子操作不是万能锁

std::atomic<int> 适合简单计数器、停止标志这类独立状态:

std::atomic<bool> stop = false;

但如果一个操作需要同时维护多个变量的不变量,比如:

队列内容
队列大小
任务状态

通常还是要用 mutex,否则很难保证整体一致。

继续看:cpp原子操作

为什么需要线程池

不要每来一个小任务就创建一个线程:

for (auto task : tasks) {
    std::thread(task).detach();
}

问题:

  • 线程创建成本高。
  • 线程数量可能失控。
  • 退出和异常很难管理。
  • CPU 会花大量时间在线程切换上。

线程池的思路是:

固定数量工作线程
任务进入队列
工作线程循环取任务执行

继续看:cpp线程池

推荐学习顺序

第一阶段:先能写对

  1. cpp并发与多线程
  2. cpp线程生命周期
  3. cpp线程传参与返回值
  4. cpp互斥锁与RAII
  5. cpp条件变量

第二阶段:组织任务

  1. cpp并发核心思维
  2. cppfuture和promise
  3. cppChannel模型
  4. cppWaitGroup模型
  5. cpp协作取消

第三阶段:控制性能

  1. cpp并发性能模型
  2. cpp原子操作
  3. cpp线程池

看代码时先问这 8 个问题

  1. 这个线程负责什么任务?
  2. 线程在哪里启动?
  3. 谁负责等待它结束?
  4. 它访问了哪些共享数据?
  5. 哪个锁保护这些共享数据?
  6. 它等待什么条件?
  7. 退出信号从哪里来?
  8. 异常或提前返回时资源是否能自动释放?

能回答这 8 个问题,多线程代码就不会只是“看起来会跑”,而是开始可维护。

学习路径