为什么要并发:别让程序干等
很多程序大半时间在"等":等网络返回、等磁盘读完、等一段计算跑完。并发就是让程序别干等——在等这一件事的时候,顺手把另一件事也做了。多核 CPU 普及之后,把一个大任务切成几块分给多个线程同时算,还能直接缩短总耗时。
C++11 之后标准库内置了 <thread> 这一套并发设施,不再依赖操作系统原生 API。你写的线程代码在 Windows、Linux、macOS 上用同一套 std::thread、std::mutex 即可。
并发不是"让代码跑得快一点"这么简单,它首先是"让正确变难"。多线程最大的坑不在启动线程,而在线程之间共享数据时谁先谁后无法预料。
- std::thread代表一个可执行线程对象,必须 join 或 detach。
- 竞态条件多个线程同时读写同一变量,结果取决于执行先后。
- std::mutex互斥锁,用 lock_guard 自动加锁解锁临界区。
- std::atomic对单变量做原子操作,无需加锁即可安全共享。
std::thread:起一个线程并 join
一个 std::thread 对象就代表一个执行中的线程。把要跑的函数(或 lambda)交给它,线程立刻开始执行。关键是:线程对象要么 join(等它跑完),要么 detach(彻底放手),否则程序析构时会直接终止。
#include <iostream>
#include <thread>
void worker(int id) {
std::cout << "线程 " << id << " 正在干活\n";
}
int main() {
std::thread t1(worker, 1); // 线程马上开始跑
std::thread t2(worker, 2);
t1.join(); // 等 t1 跑完
t2.join(); // 等 t2 跑完
std::cout << "全部结束\n";
return 0;
}
join() 是"我等你干完再走"。两个线程同时打印时,输出顺序是不确定的——这正是并发的本质:你无法预设哪个线程先跑完一行。
共享数据与竞态:两个线程改同一变量
起线程不难,难在两个线程同时改一个变量。下面这个计数器,两个线程各加十万次,理论结果应该是二十万——但实际经常不到。
#include <thread>
int counter = 0;
void add100k() {
for (int i = 0; i < 100000; ++i) {
counter++; // 看似一行,其实是 读-改-写 三步
}
}
int main() {
std::thread a(add100k);
std::thread b(add100k);
a.join();
b.join();
// counter 往往不是 200000
return 0;
}
问题在于 counter++ 不是一步完成:它先读出值、加一、再写回。两个线程可能同时读到同一个旧值,各自加一写回,结果只加了一次。这就是竞态条件——结果取决于两个线程在时间上的交错方式。
std::mutex:用锁包住临界区
解法是互斥锁 std::mutex:同一时刻只允许一个线程进入被锁住的代码段。把"改 counter"这一段包起来,就成了串行的临界区。用 std::lock_guard 是最佳实践——它在构造时加锁、析构时自动解锁,哪怕中间 return 也不会忘记释放。
#include <thread>
#include <mutex>
int counter = 0;
std::mutex m;
void add100k() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(m); // 加锁
counter++; // 临界区
} // 出作用域自动解锁
}
int main() {
std::thread a(add100k), b(add100k);
a.join();
b.join(); // 这次 counter 一定是 200000
return 0;
}
锁把并发改成了串行,正确性有了,但代价是性能。所以锁要尽量小——只包住真正必须互斥的那几行,别把整个函数都锁起来。
std::atomic:无需锁的计数器
像计数器这种"只对单个变量做简单操作"的场景,不必动用重锁。std::atomic 让对它的读改写在硬件层面一步完成,编译器保证不会被其他线程中途打断。
#include <atomic>
#include <thread>
std::atomic<int> counter{0};
void add100k() {
for (int i = 0; i < 100000; ++i) {
counter++; // 原子操作,无需 mutex
}
}
int main() {
std::thread a(add100k), b(add100k);
a.join();
b.join(); // counter 一定是 200000
return 0;
}
同样是二十万,这版没有用任何 mutex,速度更快、也更不容易出错。atomic 是应对"单变量共享"的首选,它把互斥的责任交给了硬件指令,而不是让你手动加锁。
锁的代价:什么时候用 atomic
怎么在 mutex 和 atomic 之间选?一条实用的分界线:
- 单个变量、操作简单(计数器、标志位、状态标记):用
std::atomic,轻量高效。 - 多个变量必须作为整体保持一致(如同时改余额和流水):用
std::mutex把这段逻辑整体锁住,atomic 管不住"几个变量一起变"。 - 能用 lock_guard 就别手动 lock/unlock——异常或提前 return 时,RAII 自动解锁,避免死锁。
死锁的另一个来源是两个线程以相反顺序抢两把锁。原则是:所有线程按同一固定顺序加锁,或干脆尽量少用多把锁。
实战:多线程累加任务
串起来:起多个线程各自算一块、把结果用原子累加器汇总,最后 join 等待。这是最常见的并行计算骨架。
#include <atomic>
#include <thread>
#include <vector>
std::atomic<long long> total{0};
void range_add(long long from, long long to) {
long long sum = 0;
for (long long i = from; i <= to; ++i) sum += i;
total += sum; // 每块内部先本地累加,最后原子写一次
}
int main() {
std::vector<std::thread> pool;
for (int k = 0; k < 4; ++k) {
long long a = k * 1000000LL + 1;
long long b = (k + 1) * 1000000LL;
pool.emplace_back(range_add, a, b);
}
for (auto &t : pool) t.join();
// total = 1 到 400 万之和
return 0;
}
这里藏着一个优化经验:每个线程先在自己的局部变量里算大块、最后只原子写一次,而不是循环里反复 atomic 加。共享写入越少,线程互相等待就越少,这正是并发里"减少争用"的核心思路。
并发检查清单
把 C++ 并发记成一条链:起 thread、共享数据判竞态、用 mutex 或 atomic 保护、join 收尸。提交代码前对照自查:
- 每个 std::thread 是否都 join 或 detach 了?没有的线程对象析构会终止程序。
- 有没有多个线程同时读写同一个普通变量?那是竞态,必须保护。
- 加锁是否用了 lock_guard 之类 RAII,而不是手动 lock/unlock?
- 临界区是否尽量小,没把无关代码也锁进去拖慢速度?
- 只是单变量计数/标志时,能否直接换成 std::atomic 去掉锁?
- 多把锁时,所有线程加锁顺序是否一致,会不会死锁?
并发的第一原则:默认串行,确有并行收益时才加线程;一旦共享数据,就必须想清楚谁来保护它。
下一篇我们离开线程,回到 MATLAB:把一段带噪信号滤波、画出频谱,完成一次信号处理与仿真的完整流程。