1. 从“单打独斗”到“团队协作”:为什么我们需要并发
如果你写过一些C++程序,尤其是那些需要处理用户界面、网络请求或者大量数据计算的程序,你很可能遇到过这样的场景:程序在执行一个耗时操作(比如从网络下载一个大文件)时,整个界面“卡死”了,鼠标转圈,点击无响应,直到这个操作完成。这背后的根本原因,就是你的程序在“单打独斗”。
在传统的单线程程序里,代码是一条线顺序执行的。当它遇到一个需要等待的操作时(比如I/O等待、复杂计算),它就只能“傻等”,占着CPU资源却什么也不干,或者干不了别的。这就好比一个厨师在厨房里,他必须等水烧开才能下面,等面煮好才能炒菜,整个过程是线性的,效率低下。
并发(Concurrency)就是为了解决这个问题而生的。它的核心思想是让程序能够“同时”处理多个任务。注意,这里的“同时”在单核CPU时代更多是一种错觉,是通过快速地在多个任务间切换来实现的,就像那个厨师学会了“一心多用”:在烧水的同时去切菜,在煮面的同时去调酱汁。虽然某一瞬间他只在做一件事,但从宏观上看,多项任务在并行推进,整体效率大大提升。到了多核CPU时代,这种“同时”变成了物理上的真实并行,不同的任务可以真正分配到不同的核心上同时执行。
对于C++开发者而言,在C++11标准之前,编写跨平台的多线程程序是一件相当痛苦的事情,需要依赖操作系统特定的API(如Windows的CreateThread, Linux的pthread_create)。C++11将线程、互斥量、条件变量、异步操作等并发支持直接纳入了标准库,这标志着C++真正成为了一个对现代多核硬件友好的系统级编程语言。我们不再需要为不同平台写不同的线程代码,标准库为我们提供了一套统一、可移植的并发编程工具。
那么,并发主要解决哪些问题呢?
- 提升性能与响应能力 :这是最直接的动机。通过将计算密集型任务分解到多个线程或进程,充分利用多核CPU的计算能力,缩短任务总耗时。对于GUI程序,将耗时操作放到后台线程,可以保持界面的流畅响应。
- 简化建模 :有些问题天然适合并发模型。例如,一个网络服务器需要同时处理成千上万个客户端连接。为每个连接创建一个独立的线程或进程来处理,比用一个单线程循环处理所有连接要直观和简单得多。
- 处理阻塞操作 :当程序需要等待磁盘I/O、网络响应或用户输入时,阻塞会浪费大量CPU时间。使用并发,可以让一个线程专门等待,而其他线程继续执行有用的工作。
然而,并发并非银弹。它引入了复杂性,最主要的就是 数据竞争 和 死锁 。当多个执行流(线程)在没有正确同步的情况下访问同一块内存数据,且至少有一个是写操作时,程序的行为将变得不可预测,这是并发编程中最常见也最危险的Bug。而死锁,就像两个哲学家在餐桌上都拿着自己右边的叉子,等着对方左边的叉子,结果谁也吃不上饭。这些都是我们在后续笔记中需要深入探讨和防范的核心问题。
2. 进程与线程:操作系统中的执行单元
在深入C++的多线程之前,我们必须先理清两个最基础也最容易混淆的概念:进程和线程。你可以把它们理解为操作系统进行任务调度的两个不同层级的“容器”或“执行流”。
2.1 进程:独立的“王国”
想象一下,你电脑上同时运行着浏览器、音乐播放器和代码编辑器。它们就是三个不同的进程。 进程是操作系统进行资源分配和保护的基本单位。
每个进程都拥有自己独立的“王国”:
-
独立的地址空间
:这是进程间最核心的隔离。一个进程无法直接访问另一个进程的内存数据。这种隔离性带来了稳定性,一个进程的崩溃通常不会直接影响其他进程。在Linux中,你可以通过
ps aux命令查看所有进程;在Windows中,则是任务管理器。 - 独立的系统资源 :包括文件描述符(打开的文件、网络套接字)、信号处理器、用户ID、工作目录等。
- 独立的执行状态 :每个进程都有自己独立的程序计数器(PC)、寄存器集合和堆栈。
进程间的通信(IPC, Inter-Process Communication)需要特殊的机制,因为它们的地址空间是隔离的。常见的IPC方式包括:
- 管道 :单向数据流,常用于父子进程。
- 消息队列 :存放在内核中的消息链表。
- 共享内存 :映射同一块物理内存到不同进程的地址空间,速度最快,但需要自行处理同步。
- 信号量 :用于进程间同步。
- 套接字 :最通用的方式,可以跨网络。
创建一个新进程(例如使用
fork()
系统调用)开销是相对较大的,因为操作系统需要为其分配独立的资源,建立完整的地址空间映射。
2.2 线程:王国里的“工人”
现在,把目光聚焦到一个进程内部,比如你的浏览器。它可能在同时做很多事情:下载文件、渲染网页、响应用户点击、播放视频。这些任务如果只用一个执行流(单线程)来完成,又会回到响应迟缓的老路。于是,线程登场了。
线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。 一个进程可以包含多个线程,它们共享进程的“王国”资源。
- 共享资源 :同一个进程内的所有线程共享进程的地址空间、全局变量、堆内存、文件描述符等。这使得线程间共享数据非常高效,直接通过内存指针访问即可。
- 独立资源 :每个线程有自己独立的线程ID、程序计数器、寄存器集合和堆栈。堆栈用于保存局部变量和函数调用链,这是线程“独立执行”的基础。
- 轻量级 :创建和销毁一个线程的代价远小于进程,因为不需要分配新的地址空间和大量系统资源。线程间的切换(上下文切换)开销也通常小于进程间切换。
由于共享内存,线程间的通信变得极其简单和快速,但也正是这种共享,带来了 数据竞争 的噩梦。如果多个线程同时读写一个全局变量,而没有正确的同步机制(如互斥锁),结果将是未定义的。这也是多线程编程复杂性的主要来源。
2.3 核心区别与选择
为了更清晰地对比,我们可以用下面的表格来总结:
| 特性 | 进程 | 线程 |
|---|---|---|
| 根本区别 | 资源分配的基本单位 | CPU调度的基本单位 |
| 地址空间 | 独立,互不干扰 | 共享所属进程的地址空间 |
| 通信方式 | 复杂,需要IPC机制 | 简单,直接读写共享内存 |
| 创建/销毁开销 | 大 | 小 |
| 稳定性 | 一个进程崩溃不影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 |
| 数据同步 | 不需要(天然隔离) | 必须显式进行(共享内存) |
| 类比 | 独立的公司 | 公司内部的各个部门 |
如何选择进程还是线程?
- 选择进程 :当你需要高度的 隔离性和稳定性 时。例如,Chrome浏览器为每个标签页使用独立的进程,这样某个网页崩溃不会导致整个浏览器关闭。或者,开发需要高安全性的服务,不同模块间需要严格的权限隔离。
- 选择线程 :当你需要 极高的性能和数据共享效率 ,且任务之间耦合紧密时。例如,一个图像处理程序,将一张大图分块,用多个线程同时处理不同的块,最后合并结果。或者,一个游戏引擎,用不同线程处理渲染、物理计算和音频。
在C++11的语境下,我们主要关注的是
线程
,因为标准库
std::thread
提供的就是线程级别的并发支持。理解进程的概念,是为了更好地理解线程运行的上下文环境及其资源边界。
3. C++11并发编程的基石:
std::thread
C++11通过
<thread>
头文件引入了
std::thread
类,这是你进行多线程编程的起点。一个
std::thread
对象代表一个独立的执行线程。
3.1 启动线程:几种常见姿势
启动一个线程,本质上就是告诉这个新线程去执行一段代码。这段代码可以是一个函数、一个函数对象(仿函数)、一个Lambda表达式,甚至是某个类的成员函数。
1. 使用普通函数 这是最直接的方式。函数签名可以是任意形式,只要可调用即可。
#include <iostream>
#include <thread>
void hello() {
std::cout << "Hello from thread! Thread ID: " << std::this_thread::get_id() << std::endl;
}
int main() {
std::thread t(hello); // 创建线程t,并立即执行hello函数
// ... 主线程可以同时做其他事情
t.join(); // 等待线程t执行完毕
return 0;
}
注意 :创建线程对象
t后,新的线程就开始执行了(具体时机由操作系统调度)。join()是必须的,它会让主线程阻塞,直到t线程结束。如果不join,也不detach(分离),在线程对象t析构时,程序会调用std::terminate()强制终止,这通常不是你想要的。
2. 使用Lambda表达式 Lambda是现代C++中启动线程非常简洁和常用的方式,尤其适合简单的任务。
#include <thread>
#include <iostream>
int main() {
int local_var = 42;
std::thread t([&local_var]() { // 以引用方式捕获局部变量
std::cout << "Captured value: " << local_var << std::endl;
local_var = 100; // 修改主线程的局部变量!
});
t.join();
std::cout << "Main thread, local_var is now: " << local_var << std::endl; // 输出 100
return 0;
}
这里有一个
极其重要的坑
:Lambda通过引用
[&]
捕获了主线程栈上的局部变量
local_var
。在线程
t
中修改它,会直接影响主线程。如果主线程在
t
访问
local_var
之前就结束了(比如快速执行完
main
函数),那么
t
访问的就是一个已经被销毁的栈内存,导致
未定义行为
(通常是崩溃)。因此,通过引用传递数据到线程时,必须确保数据的生命周期覆盖线程的执行期。更安全的做法是传值
[=]
或明确传递参数。
3. 使用带参数的函数 线程函数可以接受参数,参数会拷贝到线程的内部存储中。
#include <thread>
#include <string>
#include <iostream>
void print_message(const std::string& msg, int times) {
for(int i = 0; i < times; ++i) {
std::cout << msg << std::endl;
}
}
int main() {
// 参数直接跟在函数名后面
std::thread t(print_message, "Hello Concurrent World!", 3);
t.join();
return 0;
}
参数传递遵循普通的函数传参规则。但要注意,即使你的函数参数是引用类型,
std::thread
的构造函数也会
先进行拷贝
。如果你真的希望在新线程中修改原对象,需要使用
std::ref
进行包装。
void modify_int(int& val) {
val = 999;
}
int main() {
int value = 0;
// std::thread t(modify_int, value); // 错误:value被拷贝,原值不会被修改
std::thread t(modify_int, std::ref(value)); // 正确:传递引用
t.join();
std::cout << value << std::endl; // 输出 999
return 0;
}
4. 使用成员函数 需要提供两个参数:成员函数指针,以及该成员函数所属的对象实例。
class Worker {
public:
void do_work(int id) {
std::cout << "Worker " << id << " is working in thread " << std::this_thread::get_id() << std::endl;
}
};
int main() {
Worker w;
// 第一个参数是成员函数指针 &Worker::do_work
// 第二个参数是对象实例(这里传引用,确保操作的是w)
std::thread t(&Worker::do_work, &w, 1);
t.join();
return 0;
}
3.2 等待与分离:管理线程的生命周期
线程启动后,你需要决定如何管理它。
-
join():阻塞当前线程(通常是主线程),直到被join的线程执行结束。这确保了线程资源的正确清理。一个线程对象在其生命周期内,join只能调用一次。调用join后,该thread对象就不再关联任何执行线程(joinable() == false)。 -
detach():将线程与thread对象分离。分离后的线程变为“后台线程”或“守护线程”,其生命周期与主线程无关,它会独立运行直到其任务完成。一旦detach,你就失去了对这个线程的直接控制权,无法再对其调用join。分离线程必须确保其访问的数据在整个运行期间都是有效的,这需要格外小心。
std::thread t([](){
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "Detached thread finished.\n";
});
t.detach(); // 主线程继续,不等待t
// 此时,t对象不再关联任何线程
// 主线程可能先于t结束,但t会继续在后台运行直到完成
重要经验 :在资源管理上,遵循RAII思想是个好习惯。你可以创建一个
ThreadGuard类,在析构函数中自动调用join,防止因异常或提前返回导致线程未被join。
class ThreadGuard {
std::thread& t;
public:
explicit ThreadGuard(std::thread& t_) : t(t_) {}
~ThreadGuard() {
if(t.joinable()) { // 必须检查,不能join两次
t.join();
}
}
// 禁止拷贝
ThreadGuard(const ThreadGuard&)=delete;
ThreadGuard& operator=(const ThreadGuard&)=delete;
};
void some_function() {
std::thread t(do_something);
ThreadGuard g(t);
// ... 即使这里抛出异常,g的析构也会确保t被join
}
3.3 线程标识与硬件并发数
-
std::this_thread::get_id():这是一个命名空间函数,返回当前调用线程的唯一ID。这个ID主要用于调试和日志记录,比较两个线程是否相同。 -
std::thread::hardware_concurrency():这是一个静态成员函数,返回当前硬件支持的并发线程数。通常它返回CPU的核心数量(如果支持超线程,则是逻辑核心数)。这个值在规划线程池大小时非常有用,可以作为默认线程数的参考。但要注意,它可能返回0,表示信息不可用。
4. 共享数据的噩梦:数据竞争与互斥锁
现在,我们来到了多线程编程最核心、也最令人头疼的部分。当多个线程共享数据时,如果不加控制,就会发生数据竞争,导致程序行为诡异且难以调试。
4.1 数据竞争:一个简单的例子
看下面这个程序,我们创建10个线程,每个线程都对一个全局计数器
counter
进行10000次加1操作。理论上,最终结果应该是100000。
#include <iostream>
#include <vector>
#include <thread>
int counter = 0;
void increment() {
for (int i = 0; i < 10000; ++i) {
++counter; // 这就是危险区!
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter value: " << counter << std::endl;
return 0;
}
多次运行这个程序,你几乎不可能得到100000这个正确结果,每次的结果都不同,且都小于100000。为什么?
++counter
这行看起来是原子的操作,在底层实际上分为三步:
-
从内存读取
counter的当前值到CPU寄存器。 - 在寄存器中将值加1。
-
将新值写回
counter所在的内存。
假设两个线程A和B几乎同时执行
++counter
,
counter
初始为0。
-
线程A读取
counter=0到寄存器。 -
线程B也读取
counter=0到自己的寄存器。 -
线程A在寄存器中计算
0+1=1,并写回内存。此时counter=1。 -
线程B在寄存器中计算
0+1=1,并写回内存。此时counter再次被写为1。
两次递增操作,最终
counter
只增加了1!这就是数据竞争。在多个线程交织执行的复杂时序下,这种错误会被放大,导致结果完全不可预测。
4.2 互斥锁:
std::mutex
为了解决这个问题,我们需要一种机制来确保同一时间只有一个线程可以执行“读取-修改-写回”这个关键代码段。这就是互斥锁。C++11提供了
std::mutex
。
#include <iostream>
#include <vector>
#include <thread>
#include <mutex>
int counter = 0;
std::mutex counter_mutex; // 定义一个互斥锁
void safe_increment() {
for (int i = 0; i < 10000; ++i) {
counter_mutex.lock(); // 进入临界区前加锁
++counter; // 临界区代码
counter_mutex.unlock(); // 离开临界区后解锁
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(safe_increment);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter value: " << counter << std::endl; // 稳定输出 100000
return 0;
}
现在,当一个线程调用
counter_mutex.lock()
时,如果锁未被其他线程持有,它就获得锁并继续执行。如果锁已被其他线程持有,这个线程就会被阻塞,直到锁被释放(
unlock
)。这样就保证了
++counter
这个操作是串行执行的,消除了数据竞争。
4.3 锁的守卫:
std::lock_guard
与
std::unique_lock
直接使用
lock()
和
unlock()
是非常危险的。如果在加锁和解锁之间的代码抛出了异常,或者程序员忘记调用
unlock()
,锁将永远不会被释放,导致其他所有等待该锁的线程永久阻塞,这就是
死锁
的一种。
C++标准库提供了RAII风格的锁管理类来解决这个问题。
std::lock_guard
这是最简单、最常用的守卫。它在构造时自动加锁,在析构时(离开作用域时)自动解锁。
void safer_increment() {
for (int i = 0; i < 10000; ++i) {
std::lock_guard<std::mutex> lock(counter_mutex); // 构造时加锁
++counter;
} // lock 析构时自动解锁,即使发生异常也会解锁
}
std::lock_guard
非常轻量,但不灵活(不能在作用域中间释放锁)。
std::unique_lock
它比
lock_guard
更灵活,但开销稍大。除了RAII特性,它还允许:
- 延迟加锁(构造时不立即加锁)。
- 手动加锁和解锁。
- 转移锁的所有权。
- 与条件变量配合使用(这是必须的)。
void flexible_increment() {
std::unique_lock<std::mutex> lock(counter_mutex, std::defer_lock); // 延迟加锁
// ... 这里可以执行一些不需要锁保护的准备工作 ...
lock.lock(); // 手动加锁
++counter;
lock.unlock(); // 可以手动提前解锁
// ... 执行其他不需要锁的操作 ...
// 离开作用域时,如果锁仍被持有,会自动解锁
}
对于大多数简单的临界区保护,
std::lock_guard
是首选。当需要更精细的控制时,才使用
std::unique_lock
。
4.4 死锁:当锁相互等待
互斥锁引入了新的问题:死锁。最常见的死锁场景是 锁顺序不一致 。
假设有两把锁
mutex_a
和
mutex_b
,以及两个线程:
-
线程1:先锁
mutex_a,再锁mutex_b。 -
线程2:先锁
mutex_b,再锁mutex_a。
如果执行时序如下:
-
线程1锁定了
mutex_a。 -
线程2锁定了
mutex_b。 -
线程1尝试锁定
mutex_b,发现已被线程2锁定,于是阻塞等待。 -
线程2尝试锁定
mutex_a,发现已被线程1锁定,于是阻塞等待。
两个线程都在等待对方释放锁,程序永远无法继续。这就是死锁。
避免死锁的黄金法则:对所有需要获取多个锁的代码,强制规定一个全局的加锁顺序,所有线程都必须按照这个顺序来获取锁。
在上面的例子中,如果规定必须先锁
mutex_a
再锁
mutex_b
,那么线程2也必须遵守,死锁就不会发生。
C++标准库提供了
std::lock
函数来帮助一次性锁定多个互斥量,且能避免死锁。它采用特定的算法(如 try-and-back-off)来保证即使以不同顺序请求锁,也能安全地获取。
std::mutex mutex_a, mutex_b;
void process_with_two_locks() {
// 使用 std::lock 一次性锁定两个锁,避免死锁
std::lock(mutex_a, mutex_b);
// 接下来,必须使用 std::adopt_lock 策略来接管已锁定的互斥量
std::lock_guard<std::mutex> lock_a(mutex_a, std::adopt_lock);
std::lock_guard<std::mutex> lock_b(mutex_b, std::adopt_lock);
// 临界区操作...
} // 自动解锁
std::lock
会阻塞直到所有指定的锁都成功获取,并且在这个过程中如果无法获取所有锁,它会释放已持有的锁,然后重试,从而避免死锁。这是一种非常实用的技术。
5. 实战中的陷阱与经验分享
理论讲完了,我们来聊聊实际编码中会遇到的坑。这些经验往往比语法本身更重要。
5.1 警惕“接口非线程安全”
即使一个类在单线程下工作完美,在多线程环境下也可能崩溃。一个经典的例子是
std::cout
。
std::cout
本身不是线程安全的,多个线程同时向标准输出写入,会导致输出内容交错混乱。
std::thread t1([](){ std::cout << "Hello from " << "Thread 1\n"; });
std::thread t2([](){ std::cout << "Hello from " << "Thread 2\n"; });
t1.join(); t2.join();
// 输出可能是:Hello from Hello from Thread 1\nThread 2\n
虽然有时看起来没问题,但这属于未定义行为。解决方案是为输出操作加锁,或者使用每个线程独立的输出流(如字符串流),最后再集中输出。
很多其他标准库容器,如
std::vector
、
std::map
等,在同时进行读和写,或者同时进行多个写操作时,也不是线程安全的。你需要用互斥锁来保护对它们的访问。
5.2 锁的粒度:不是越大越好
锁的粒度指的是锁保护的数据范围大小。锁的粒度太粗(比如用一个全局锁保护所有共享数据),会严重限制并发性,导致线程大部分时间在等待锁,失去了并发的意义。锁的粒度太细,又会增加锁管理的复杂性,容易出错。
一个好的设计原则是: 用不同的锁保护不同的数据 (锁分解)。只将真正需要共享访问的数据放入临界区。例如,一个类有多个成员变量,如果它们可以被独立访问,就应该用不同的互斥量来保护。
class ComplexData {
private:
std::mutex mtx_a;
int data_a;
std::mutex mtx_b;
std::string data_b;
public:
void set_a(int val) {
std::lock_guard<std::mutex> lock(mtx_a);
data_a = val;
}
void set_b(const std::string& val) {
std::lock_guard<std::mutex> lock(mtx_b);
data_b = val;
}
// 如果需要同时访问a和b,则需要小心处理死锁问题
std::pair<int, std::string> get_both() {
std::lock(mtx_a, mtx_b); // 使用std::lock避免死锁
std::lock_guard<std::mutex> lock_a(mtx_a, std::adopt_lock);
std::lock_guard<std::mutex> lock_b(mtx_b, std::adopt_lock);
return {data_a, data_b};
}
};
5.3 线程与异常安全
异常和多线程结合时,需要格外小心。确保在持有锁时,代码是异常安全的。如果临界区内的代码可能抛出异常,必须使用RAII锁(如
lock_guard
),以保证异常发生时锁能被正确释放,避免死锁。
另外,如果线程函数本身抛出了异常,且未被该线程内部捕获,这个异常会传播到线程外,导致
std::terminate
被调用,整个程序终止。因此,在线程入口函数的顶层进行
try-catch
是一个好习惯。
void thread_task() {
try {
// 可能抛出异常的代码
do_something_risky();
} catch (const std::exception& e) {
// 记录日志,或者将异常信息存储到共享位置供主线程检查
std::cerr << "Thread failed: " << e.what() << std::endl;
} catch (...) {
std::cerr << "Thread failed with unknown exception." << std::endl;
}
}
5.4 性能考量:锁的代价
加锁和解锁操作本身是有开销的,它涉及到操作系统内核态的调用和可能的线程上下文切换。频繁地竞争锁会成为性能瓶颈。在设计高性能并发程序时,可以考虑以下思路:
-
无锁编程
:使用原子操作(
std::atomic,我们将在后续笔记中详述)来实现简单的同步,避免锁的开销。但无锁数据结构设计极其复杂,容易出错,非必要不轻易尝试。 - 减少锁的持有时间 :只在对共享数据操作的最小必要时间内持有锁。在锁外完成所有可能的计算和准备工作。
-
使用读写锁
:C++14引入了
std::shared_timed_mutex,C++17引入了std::shared_mutex。它们允许多个线程同时读,但写是独占的。这对于“读多写少”的场景性能提升显著。 -
使用线程局部存储
:如果数据不需要在线程间共享,使用
thread_local关键字声明,每个线程都会有自己的副本,完全避免了同步。
从我个人的经验来看,并发编程入门的第一步是理解并正确使用
std::thread
和
std::mutex
。先写出正确的、有同步的代码,再去考虑优化性能。在项目初期,清晰的逻辑和正确的同步远比极致的性能更重要。很多诡异的Bug都源于对数据竞争和锁的生命周期理解不深。多写,多调试,使用
ThreadSanitizer
这样的工具来检测数据竞争,是提升并发编程能力的不二法门。

2379

被折叠的 条评论
为什么被折叠?



