C++多线程编程深度解析【C++进阶每日一学】
文章目录
- 引言
- 一、 多线程的核心概念
- 1.1 进程与线程 (Process & Thread)
- 1.2 并发与并行 (Concurrency & Parallelism)
- 1.3 竞争条件与临界区 (Race Condition & Critical Section)
- 二、 `<thread>` 库的基础使用
- 2.1 创建与启动线程
- 用法详解: 创建 `std::thread` 对象
- 2.2 线程的生命周期管理
- 2.2.1 线程对象的可结合性 (Joinability)
- 2.2.2 `join()` - 等待线程结束
- 函数用法: `join()`
- 2.2.3 `detach()` - 分离线程
- 函数用法: `detach()`
- 2.2.4 管理分离的线程
- 2.3 向线程传递参数
- 工具用法: `std::ref` 和 `std::cref`
- 三、 线程同步与数据保护
- 3.1 互斥量 `std::mutex`
- 函数用法: `mutex` 成员函数
- 3.2 使用 `std::lock_guard` 自动管理锁
- 用法详解: `std::lock_guard`
- 3.3 `std::unique_lock` - 更灵活的锁管理
- 用法详解: `std::unique_lock`
- 3.4 条件变量 `std::condition_variable`
- 函数用法: `condition_variable`
- 四、 获取线程的执行结果
- 4.1 `std::async`, `std::future` 和 `std::promise`
- 五、 线程中的异常处理
- 六、 总结
如果觉得本文对您有所帮助,点个赞和关注吧,谢谢,你的支持就是我持续更新的最大动力!!!
引言
随着多核处理器的普及,多线程编程已不再是特定领域的专属技术,而是现代软件开发中压榨硬件性能、提升应用响应能力的关键手段。自C++11标准起,C++语言从标准库层面提供了对多线程的全面支持,使得开发者能够以一种可移植、标准化的方式编写并发程序。本文旨在系统性地梳理C++多线程的核心概念、基础用法、关键同步机制,并对关键功能进行详尽的用法解析,补充了线程返回值、异常处理等高级主题。
一、 多线程的核心概念
在深入代码之前,必须厘清几个核心概念。
1.1 进程与线程 (Process & Thread)
- 进程 (Process):是操作系统进行资源分配和调度的基本单位。一个进程拥有独立的内存空间、数据栈以及其他系统资源。进程间的通信(IPC)相对复杂且开销较大。
- 线程 (Thread):是进程内的一个执行单元,是CPU调度的最小单位。一个进程可以包含多个线程,它们共享该进程的内存空间(如代码段、数据段)和资源。因此,线程间的通信更为高效,但也引入了数据同步的挑战。
1.2 并发与并行 (Concurrency & Parallelism)
- 并发 (Concurrency):指在一个时间段内,多个任务都在向前推进。在单核处理器上,这是通过时间片轮转等方式实现的宏观并行。任务之间是交替执行的。
- 并行 (Parallelism):指在同一时刻,多个任务同时执行。这需要多核处理器的物理支持,是真正意义上的同时运行。
C++多线程编程的目标,正是利用硬件实现并行,或在逻辑上管理并发任务。
1.3 竞争条件与临界区 (Race Condition & Critical Section)
- 竞争条件 (Race Condition):当多个线程访问共享数据,并且至少有一个线程会修改数据时,若最终结果取决于线程执行的特定时序,便称之为发生了竞争条件。这通常会导致程序行为不可预测,是并发编程中最常见的错误来源。
- 临界区 (Critical Section):指程序中访问共享资源(如共享数据)的代码片段。为了避免竞争条件,必须确保在任意时刻,只有一个线程能进入临界区执行。
二、 <thread>
库的基础使用
C++多线程功能主要由头文件 <thread>
提供。
2.1 创建与启动线程
要启动一个新线程,需要创建一个 std::thread
类的实例。在创建该实例时,需要指定新线程应该执行哪个函数,以及传递给该函数的参数。
使用格式:
#include <iostream>
#include <thread>
#include <stdexcept> // for std::exception// 线程入口函数
void thread_function(int arg) {std::cout << "Thread function executing with argument: " << arg << std::endl;
}int main() {try {// 创建一个 thread 对象,新线程立即开始执行 thread_function(100)std::thread my_thread(thread_function, 100); // ... 主线程继续执行 ...my_thread.join(); // 等待子线程结束} catch (const std::system_error& e) {std::cerr << "Caught system_error: " << e.what() << '\n';// 例如:当系统资源不足,无法创建新线程时}return 0;
}
用法详解: 创建
std::thread
对象std::thread 对象名( 要执行的函数, 函数的第一个参数, 函数的第二个参数, ... );
- 第一个参数: 一个可调用对象 (Callable Object),例如函数名、函数指针、Lambda 表达式或一个重载了
()
运算符的对象。这指定了新线程的入口点。- 后续参数: 传递给上述函数的实参列表。这些参数会被拷贝或移动到新线程的内部存储中。
- 返回值:
std::thread
的构造函数没有返回值。- 如何判断创建成功: 线程创建是一个可能失败的系统调用。如果操作系统无法创建新线程(例如资源耗尽),
std::thread
的构造函数会抛出std::system_error
异常。因此,将线程创建放在try-catch
块中是健壮的做法。- 作用: 创建一个
thread
对象的同时,会立即启动一个系统级的执行线程。该线程独立于主线程,开始执行你所指定的函数。
2.2 线程的生命周期管理
一个 std::thread
对象在析构时,必须不关联任何正在执行的线程。这意味着,在 thread
对象销毁前,必须明确其关联的后台线程的命运:要么等待它结束 (join
),要么让它彻底独立运行 (detach
)。
2.2.1 线程对象的可结合性 (Joinability)
一个 std::thread
对象是 “joinable” 的,如果它代表一个正在执行的线程。
- 默认构造的
std::thread
对象是 non-joinable。 - 被
join()
或detach()
过的std::thread
对象变为 non-joinable。 - 被移动(
std::move
)过的std::thread
对象变为 non-joinable。
2.2.2 join()
- 等待线程结束
join()
是一个阻塞操作,用于等待子线程执行完毕,并清理与之相关的资源。
#include <iostream>
#include <thread>
#include <chrono>void worker() {std::cout << "Worker thread started." << std::endl;std::this_thread::sleep_for(std::chrono::seconds(2)); // 模拟耗时操作std::cout << "Worker thread finished." << std::endl;
}int main() {std::thread t(worker);std::cout << "Main thread waiting for worker to finish." << std::endl;if (t.joinable()) {t.join(); // 主线程在此处暂停,直到 t 线程执行完毕}std::cout << "Worker thread joined. Main thread finished." << std::endl;return 0;
}
函数用法:
join()
thread对象.join();
- 前置条件:
thread
对象必须是joinable
的。对一个 non-joinable 的thread
对象调用join()
会导致程序终止或抛出std::system_error
异常。- 参数: 无。
- 返回值: 无。
- 作用:
- 阻塞调用
join()
的线程,直到该thread
对象所代表的线程执行完成。- 回收与已完成线程相关的操作系统资源。
- 使
thread
对象本身变为 non-joinable。- 一个
thread
对象只能被join()
一次。
2.2.3 detach()
- 分离线程
detach()
会将子线程与 thread
对象分离,让子线程在后台独立运行,成为“守护线程”。
注意: 分离的线程必须自行管理其生命周期。如果主程序退出,所有分离的线程都将被操作系统强制终止,这可能导致资源未释放、文件未关闭、数据损坏等严重问题。因此,
detach
的使用场景需非常谨慎。
#include <iostream>
#include <thread>
#include <chrono>void background_task() {std::cout << "Background task started." << std::endl;std::this_thread::sleep_for(std::chrono::seconds(5));std::cout << "Background task finished." << std::endl;
}int main() {std::thread t(background_task);t.detach(); // 分离线程,主线程继续执行,无需等待std::cout << "Main thread continues execution and will exit soon." << std::endl;// 主线程可能在 background_task 完成前就退出了std::this_thread::sleep_for(std::chrono::seconds(1));return 0;
}
函数用法:
detach()
thread对象.detach();
- 前置条件:
thread
对象必须是joinable
的。- 参数: 无。
- 返回值: 无。
- 作用: 将执行线程从
thread
对象中分离。分离后,线程继续在后台执行,其生命周期由操作系统管理。thread
对象本身不再与任何执行线程相关联,变为 non-joinable。
2.2.4 管理分离的线程
由于无法 join
分离的线程,我们失去了直接等待它结束的机制。管理它们通常意味着设计一种通信方式,让主线程能够请求它们优雅地退出,并可能等待一个退出的信号。
一个常见的模式是使用原子标志位:
#include <atomic>
#include <thread>std::atomic<bool> stop_flag(false);void detached_worker() {while (!stop_flag) {// ... do work ...}// ... cleanup and exit ...
}int main() {std::thread t(detached_worker);t.detach();// ... 主程序运行 ...stop_flag = true; // 在程序退出前,通知分离的线程停止// 注意:这里没有机制确保线程真的停止了,这只是一个请求。return 0;
}
2.3 向线程传递参数
向线程函数传递参数时,默认是值拷贝或移动。如需按引用传递,必须使用 std::ref()
或 std::cref()
进行包装。
#include <iostream>
#include <thread>
#include <functional> // for std::ref, std::cref
#include <string>void update_data(int& value, const std::string& msg) { // 第一个参数是引用value = 99;std::cout << msg << std::endl;
}int main() {int data = 10;std::string message = "Hello from thread";// 1. 按引用传递: 使用 std::refstd::thread t1(update_data, std::ref(data), message);t1.join();std::cout << "Final data value: " << data << std::endl; // 输出 99// 2. 按移动传递: 使用 std::movestd::thread t2( [](std::string s){ std::cout << s << std::endl; }, std::move(message) );t2.join();// 此时 message 变量的内容已失效return 0;
}
工具用法:
std::ref
和std::cref
std::ref(变量名) // 包装成普通引用 std::cref(变量名) // 包装成 const 引用
- 作用: 生成一个该变量的“引用包装器”。当把这个包装器作为参数传递给
std::thread
时,它能确保目标函数接收到的是变量的引用,而不是变量值的副本。
三、 线程同步与数据保护
3.1 互斥量 std::mutex
互斥量(Mutex)是保护临界区的基本工具,它像一把锁,确保同一时间只有一个线程能进入被保护的代码区域。
#include <mutex>std::mutex mtx;void critical_section_code() {mtx.lock(); // 获取锁,如果锁被占用则等待// ... 访问共享资源 ...mtx.unlock(); // 释放锁,让其他线程可以获取
}
函数用法:
mutex
成员函数
mtx.lock()
: 尝试锁定互斥量。如果mtx
未被锁定,则当前线程获得锁并继续执行。如果mtx
已被其他线程锁定,则当前线程被阻塞,直到获得锁。mtx.unlock()
: 释放由当前线程持有的互斥量mtx
的锁。必须由持有锁的同一个线程调用。mtx.try_lock()
: 尝试锁定互斥量,但不阻塞。如果成功获得锁,返回true
。如果锁被其他线程持有,立即返回false
。
3.2 使用 std::lock_guard
自动管理锁
手动调用 lock()
和 unlock()
是危险的,因为如果临界区发生异常或提前返回,unlock()
可能不会被调用,导致死锁。std::lock_guard
利用RAII(资源获取即初始化)机制解决了这个问题。
#include <iostream>
#include <thread>
#include <vector>
#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); // 创建 lock 对象时,自动锁定 mtxcounter++;} // lock 对象在此处离开作用域,其析构函数会自动调用 mtx.unlock()
}
用法详解:
std::lock_guard
std::lock_guard
是一个模板类,专门用于自动管理互斥锁的生命周期。std::lock_guard<std::mutex> 对象名(互斥量);
- 作用:
- 创建时: 在
lock_guard
对象被创建时,它会自动调用传入的互斥量的lock()
方法。- 销毁时: 当
lock_guard
对象离开其作用域时(例如函数结束或抛出异常),它的析构函数会自动调用互斥量的unlock()
方法。这保证了锁在任何情况下都会被正确释放。
3.3 std::unique_lock
- 更灵活的锁管理
std::unique_lock
同样是一个 RAII 风格的锁管理器,但它比 lock_guard
提供了更强的灵活性。
与 lock_guard
的区别:
- 所有权:
unique_lock
对象拥有它所管理的mutex
的所有权,这个所有权可以转移。 - 灵活性: 允许手动调用
lock()
,unlock()
,try_lock()
,并且支持延迟加锁。
std::mutex mtx;
void flexible_lock_usage() {std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 1. 延迟加锁// ... 做一些不需要锁的操作 ...lock.lock(); // 2. 手动加锁// ... 访问临界区 ...lock.unlock(); // 3. 手动解锁// ... 做一些不需要锁的操作 ...lock.lock(); // 4. 再次加锁// ... 访问临界区 ...
} // 5. 析构时自动解锁(如果此时仍持有锁)
用法详解:
std::unique_lock
std::unique_lock<std::mutex> lock(mtx); // 创建并立即加锁 std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 创建但不加锁
- 主要用途: 当需要对锁进行精细控制时,例如在加锁和解锁之间执行非临界区代码,或者将锁的所有权传递给其他函数。
- 与条件变量的配合:
std::condition_variable
的wait
函数必须使用std::unique_lock
,因为它需要在等待时临时解锁,并在被唤醒后重新加锁。
3.4 条件变量 std::condition_variable
条件变量(Condition Variable)用于线程间的通信,它允许一个线程等待,直到某个条件变为真,而另一个线程则在条件为真时通知等待的线程。
例子 (生产者-消费者模型):
#include <queue>
#include <mutex>
#include <condition_variable>std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;
bool finished = false;void producer() {for (int i = 0; i < 10; ++i) {{std::lock_guard<std::mutex> lock(mtx);data_queue.push(i);}cv.notify_one(); // 通知一个等待的消费者}{std::lock_guard<std::mutex> lock(mtx);finished = true;}cv.notify_all(); // 通知所有消费者生产结束
}void consumer() {while (true) {std::unique_lock<std::mutex> lock(mtx);// 等待,直到 data_queue 不为空或者生产结束cv.wait(lock, []{ return !data_queue.empty() || finished; });if (!data_queue.empty()) {int data = data_queue.front();data_queue.pop();// ... process data ...} else if (finished) {break; // 队列为空且生产结束,退出循环}}
}
函数用法:
condition_variable
// 等待操作 cv.wait(unique_lock<mutex>& lock, Predicate pred);
lock
: 一个std::unique_lock
对象。pred
: 一个返回布尔值的可调用对象(通常是 Lambda 表达式),代表需要满足的条件。这个谓词是必须的,用以防止伪唤醒 (spurious wakeup)。wait
的作用:
- 首先检查
pred
条件。- 若条件为
false
,则原子地释放锁并将当前线程置于阻塞状态。- 当被
notify
唤醒时,线程会重新获取锁,然后再次检查pred
条件。只有当条件为true
时,wait
才会返回,线程继续执行。
cv.notify_one()
: 唤醒一个正在等待cv
的线程。cv.notify_all()
: 唤醒所有正在等待cv
的线程。
四、 获取线程的执行结果
std::thread
本身不提供直接获取线程函数返回值的方法。为此,C++标准库提供了 <future>
头文件中的工具。
4.1 std::async
, std::future
和 std::promise
std::future
: 代表一个异步操作的“未来”结果。你可以通过future
对象获取结果(get()
),或者等待操作完成。std::promise
: 允许你在一个线程中设置值或异常,然后在另一个线程中通过与之关联的future
对象获取。std::async
: 一个高级函数模板,它以异步方式启动一个可调用对象,并返回一个std::future
,该future
会在任务完成时持有其结果。这是获取线程返回值的最简单方式。
使用 std::async
的例子:
#include <iostream>
#include <future> // 包含 async, future
#include <chrono>int calculate_something(int x) {std::this_thread::sleep_for(std::chrono::seconds(2));return x * x;
}int main() {// 异步启动 calculate_something(10)std::future<int> result_future = std::async(std::launch::async, calculate_something, 10);std::cout << "Main thread is doing other work..." << std::endl;// 从 future 获取结果。如果任务未完成,get() 会阻塞直到完成。int result = result_future.get(); std::cout << "The calculated result is: " << result << std::endl; // 输出 100return 0;
}
五、 线程中的异常处理
如果一个线程函数抛出异常而未在线程内部被捕获,程序的默认行为是调用 std::terminate()
,导致整个进程崩溃。
正确的处理方式:
- 在线程函数内部使用
try...catch
:这是最直接的方法。 - 使用
std::promise
和std::future
传递异常:std::promise
可以捕获异常并存储它。当在future
对象上调用get()
时,如果关联的promise
存储了异常,get()
会重新抛出该异常。std::async
内部自动实现了这个机制。
使用 std::async
自动传递异常的例子:
#include <iostream>
#include <future>
#include <stdexcept>void risky_task() {throw std::runtime_error("An error occurred in the thread!");
}int main() {std::future<void> f = std::async(std::launch::async, risky_task);try {f.get(); // get() 会重新抛出在 risky_task 中抛出的异常} catch (const std::runtime_error& e) {std::cerr << "Caught exception from thread: " << e.what() << std::endl;}return 0;
}
六、 总结
C++11及其后续标准为多线程编程提供了强大且类型安全的工具集。掌握这些工具是现代C++开发者的必备技能。
std::thread
是创建和管理线程的基础,但创建可能因系统资源不足而失败(抛出std::system_error
)。- 生命周期管理 (
join
/detach
) 是必须履行的责任。thread
对象在析构前必须是 non-joinable 状态,否则程序会终止。 std::mutex
与RAII锁(std::lock_guard
和std::unique_lock
)是保护共享数据、防止竞争条件的核心武器。优先使用lock_guard
,在需要更灵活控制或与条件变量配合时使用unique_lock
。std::condition_variable
是实现复杂线程同步与协作(如等待-通知模式)的关键,必须配合unique_lock
和谓词使用以避免伪唤醒。std::future
和std::async
是从线程安全地获取返回值和传递异常的最佳实践,极大地简化了异步任务的管理。
多线程编程虽然强大,但也带来了复杂性。正确地识别临界区、审慎地选择同步策略、避免死锁,是每一位并发程序员都需要持续实践和深化的课题。
本文到此结束啦,如果觉得对您有所帮助,点个赞和关注吧,谢谢,你的支持就是我持续更新的最大动力!!!