1. 从单线程到多线程:并发编程的起源与演进
想象你经营着一家小型物流中心。最初,只有你一个人负责所有工作:卸货、登记、分拣、装车。这种单线程工作模式简单直接,但效率低下。当卸货的卡车在门口抛锚时,整个物流中心的工作都会停滞——这就是单线程模型的致命缺陷。
在计算机科学中,这种情况被称为"阻塞"。当一个线程(或进程)因为等待某个资源(如I/O操作、网络请求等)而被挂起时,整个程序的执行就会暂停。这种设计显然无法满足现代应用对性能和响应速度的要求。
1.1 并发与并行的本质区别
并发(Concurrency)和并行(Parallelism)是两个经常被混淆的概念:
- 并发:在宏观上,多个任务看似同时进行。在单核处理器上,这是通过操作系统的时间片轮转实现的。就像物流中心的员工快速切换不同任务,给人同时进行的错觉。
- 并行:在微观上,真正的多个任务在不同的物理核心上同时执行。这相当于物流中心有多个员工,每个人都在独立工作。
C++11引入的std::thread让我们能够轻松创建并发执行的线程。但要注意,真正的并行执行需要硬件支持(多核处理器)。
1.2 线程管理的基本模式
创建一个线程就像雇佣一个新员工:
cpp复制#include <iostream>
#include <thread>
void worker_task() {
std::cout << "新员工开始工作" << std::endl;
}
int main() {
std::thread worker(worker_task); // 雇佣新员工
worker.join(); // 等待员工完成工作
return 0;
}
这里有两个关键决策点:
join():主线程等待工作线程完成detach():主线程不等待,工作线程独立运行
警告:忘记join()或detach()会导致程序终止时std::thread析构函数调用std::terminate(),就像老板提前锁门导致还在工作的员工被困在仓库里。
1.3 RAII:资源管理的黄金法则
为了避免忘记join()导致的问题,我们可以使用RAII(Resource Acquisition Is Initialization)技术:
cpp复制class ThreadGuard {
std::thread& t;
public:
explicit ThreadGuard(std::thread& t_) : t(t_) {}
~ThreadGuard() {
if(t.joinable()) {
t.join();
}
}
ThreadGuard(const ThreadGuard&)=delete;
ThreadGuard& operator=(const ThreadGuard&)=delete;
};
void use_thread_guard() {
std::thread t([]{ /* 工作内容 */ });
ThreadGuard g(t);
// 即使这里抛出异常,t也会被正确join
}
C++20引入了std::jthread,它在析构时会自动join,解决了这个常见问题。
2. 线程间的数据共享与同步
当多个线程需要访问共享数据时,问题变得复杂。回到物流中心的比喻,如果两个员工同时修改库存记录,可能会导致数据不一致。
2.1 竞态条件(Race Condition)的本质
竞态条件是指程序的正确性依赖于线程执行的相对时序。最典型的例子是"检查后行动"(check-then-act)模式:
cpp复制if(!queue.empty()) { // 检查
auto item = queue.front(); // 行动
queue.pop();
}
在这段代码中,另一个线程可能在检查(empty())和行动(pop())之间修改队列状态,导致未定义行为。
2.2 互斥锁:保护共享数据的基础工具
互斥锁(Mutex)是最基本的同步原语,相当于物流中心的门锁:
cpp复制std::mutex mtx;
std::queue<int> shared_queue;
void safe_push(int value) {
std::lock_guard<std::mutex> lock(mtx);
shared_queue.push(value);
}
void safe_pop() {
std::lock_guard<std::mutex> lock(mtx);
if(!shared_queue.empty()) {
shared_queue.pop();
}
}
C++17引入了std::scoped_lock,它可以同时锁定多个互斥量而不会导致死锁:
cpp复制void transfer(Account& from, Account& to, double amount) {
std::scoped_lock lock(from.mtx, to.mtx); // 同时锁定两个账户
from.balance -= amount;
to.balance += amount;
}
2.3 死锁:多线程编程的噩梦
死锁发生在两个或多个线程互相等待对方释放资源时。就像物流中心两个员工各自持有一把钥匙,却需要对方的钥匙才能继续工作。
避免死锁的准则:
- 避免嵌套锁:如果已经持有一个锁,尽量不要获取另一个锁
- 固定加锁顺序:所有线程按照相同的顺序获取锁
- 使用
std::lock()或std::scoped_lock来同时获取多个锁
2.4 读写锁:优化读多写少场景
对于频繁读取但很少修改的数据,可以使用读写锁(std::shared_mutex):
cpp复制std::shared_mutex rw_mutex;
std::map<std::string, int> shared_data;
int read_data(const std::string& key) {
std::shared_lock<std::shared_mutex> lock(rw_mutex);
return shared_data[key];
}
void write_data(const std::string& key, int value) {
std::unique_lock<std::shared_mutex> lock(rw_mutex);
shared_data[key] = value;
}
这种设计允许多个线程同时读取数据,但写入时需要独占访问。
3. 高级线程同步技术
3.1 条件变量:线程间的通知机制
条件变量(std::condition_variable)允许线程等待特定条件成立,而不是忙等待:
cpp复制std::mutex mtx;
std::condition_variable cv;
bool data_ready = false;
void producer() {
std::this_thread::sleep_for(std::chrono::seconds(1));
{
std::lock_guard<std::mutex> lock(mtx);
data_ready = true;
}
cv.notify_one(); // 通知等待的线程
}
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return data_ready; }); // 等待条件成立
std::cout << "数据已准备好" << std::endl;
}
关键点:条件变量总是与互斥锁和谓词条件一起使用。虚假唤醒(spurious wakeup)是可能的,所以谓词检查必不可少。
3.2 Future和Promise:异步结果传递
std::future和std::promise提供了一种更高级的异步编程模型:
cpp复制#include <future>
int compute_heavy_task() {
std::this_thread::sleep_for(std::chrono::seconds(2));
return 42;
}
void use_future() {
std::future<int> result = std::async(std::launch::async, compute_heavy_task);
// 可以做其他工作...
std::cout << "结果是: " << result.get() << std::endl;
}
std::async的启动策略:
std::launch::async:强制在新线程中执行std::launch::deferred:延迟到调用get()或wait()时执行
3.3 原子操作:无锁编程的基础
对于简单的计数器等场景,可以使用原子操作避免锁开销:
cpp复制std::atomic<int> counter{0};
void increment() {
for(int i = 0; i < 1000; ++i) {
++counter; // 原子操作
}
}
原子操作的内存序:
memory_order_relaxed:只保证原子性,不保证顺序memory_order_acquire/memory_order_release:建立线程间的同步关系memory_order_seq_cst:顺序一致性(默认)
4. 并发编程的最佳实践与陷阱
4.1 线程安全的数据结构设计
设计线程安全容器时需要考虑:
- 锁的粒度:保护整个容器还是单个元素
- 异常安全:操作失败时保持一致性
- 死锁避免:避免嵌套调用容器方法
4.2 性能考量
多线程并不总是意味着更快。创建过多线程会导致:
- 上下文切换开销
- 缓存失效
- 锁竞争
可以使用std::thread::hardware_concurrency()获取硬件支持的线程数上限。
4.3 调试技巧
并发程序调试的挑战:
- 使用线程安全的日志系统
- 利用工具如ThreadSanitizer检测数据竞争
- 编写确定性测试用例
5. C++并发编程的未来
C++20引入了更多并发特性:
std::jthread:自动join的线程std::latch和std::barrier:线程同步点std::atomic_ref:对现有变量的原子引用
并发编程是现代C++开发者的必备技能。从简单的线程管理到复杂的无锁数据结构,理解这些概念和工具将帮助你构建高性能、响应迅速的应用程序。就像经营物流中心一样,关键在于找到资源分配和协作的最佳平衡点。
