【学习笔记07】C++并发编程入门:线程与互斥锁管理(上)
📝 说明:本文为C++学习笔记,AI辅助系统整理
📅 整理时间:2025年10月
🎯 课时:课时06 - 并发编程基础(上篇)
💡 核心内容:thread线程管理、mutex互斥锁、lock_guard与unique_lock
⭐ 难度等级:⭐⭐⭐⭐☆
🎯 前言
并发编程是C++后端开发的核心技能之一。在多核处理器时代,充分利用多线程能力对于提升程序性能至关重要。本文记录了C++11并发编程基础的学习过程,包括线程创建、互斥锁使用、以及RAII自动锁管理等核心概念。
这是课时06的上半部分,主要学习线程基础和互斥锁管理,下半部分将学习条件变量、线程安全类设计、死锁避免等高级主题。
📚 本文内容
🚀 核心知识点一:std::thread线程基础
什么是线程?
线程是操作系统能够进行运算调度的最小单位。一个进程可以包含多个线程,这些线程共享进程的资源(如内存、文件句柄等),但各自拥有独立的执行路径。
创建线程的基本方法
方法1:使用普通函数
#include <thread>
#include <iostream>
using namespace std;
void myFunction() {
cout << "子线程执行中...\n";
}
int main() {
thread t(myFunction); // 创建线程,立即开始执行
t.join(); // 等待线程结束
cout << "主线程继续执行\n";
return 0;
}
输出:
子线程执行中...
主线程继续执行
方法2:使用lambda表达式
int main() {
thread t([]() {
cout << "lambda线程执行\n";
});
t.join();
return 0;
}
方法3:带参数的线程函数
void printNumber(int n) {
cout << "Number: " << n << "\n";
}
int main() {
thread t(printNumber, 42); // 传递参数42
t.join();
return 0;
}
输出:
Number: 42
join() vs detach():两种线程管理方式
join():等待线程结束
#include <thread>
#include <iostream>
#include <chrono>
using namespace std;
void task() {
cout << "子线程开始\n";
this_thread::sleep_for(chrono::seconds(2));
cout << "子线程结束\n";
}
int main() {
thread t(task);
cout << "主线程等待中...\n";
t.join(); // 阻塞,等待t线程结束
cout << "主线程继续执行\n";
return 0;
}
输出:
子线程开始
主线程等待中...
(等待2秒)
子线程结束
主线程继续执行
join()的特点:
- 主线程会阻塞等待
- 子线程结束后主线程才继续
- 适合需要等待结果的场景
detach():分离线程
void backgroundTask() {
this_thread::sleep_for(chrono::seconds(2));
cout << "后台任务完成\n";
}
int main() {
thread t(backgroundTask);
t.detach(); // 分离线程,不再管它
cout << "主线程不等待,直接继续\n";
this_thread::sleep_for(chrono::seconds(3)); // 主线程延迟,让后台任务有时间执行
return 0;
}
输出:
主线程不等待,直接继续
(等待2秒)
后台任务完成
detach()的特点:
- 主线程不等待
- 子线程独立运行
- 危险:如果主线程先结束,子线程可能被强制终止
detach()的危险示例
void dangerousTask() {
this_thread::sleep_for(chrono::seconds(2));
cout << "这行可能不会输出\n"; // 主线程可能已经结束
}
int main() {
thread t(dangerousTask);
t.detach();
cout << "主线程立即结束\n";
// 主线程结束,整个进程结束,子线程被强制终止
return 0;
}
可能的输出:
主线程立即结束
(没有"这行可能不会输出",因为进程已经结束)
重要提醒:detach后要保证主线程存活时间足够长,或者子线程能快速完成!
join() vs detach() 对比总结
| 特性 | join() | detach() |
|---|---|---|
| 主线程行为 | 阻塞等待子线程结束 | 不等待,继续执行 |
| 子线程结束保证 | 一定会等到子线程结束 | 不保证,主线程可能提前结束 |
| 使用场景 | 需要等待结果 | 后台任务,不关心结果 |
| 安全性 | 安全 | 需要小心管理生命周期 |
| 能否再join | 不能 | 不能 |
🔒 核心知识点二:std::mutex互斥锁与数据竞争
什么是数据竞争?
数据竞争(Data Race):多个线程同时访问同一块共享数据,且至少有一个线程在写操作,就会产生数据竞争。
数据竞争的危险示例
#include <thread>
#include <iostream>
using namespace std;
int counter = 0; // 共享变量
void addCount() {
for (int i = 0; i < 100000; i++) {
counter++; // 危险!多线程同时修改
}
}
int main() {
thread t1(addCount);
thread t2(addCount);
t1.join();
t2.join();
cout << "counter = " << counter << "\n";
return 0;
}
期望输出:counter = 200000
实际输出(可能):counter = 156723(每次运行结果不同!)
为什么会错?
counter++ 实际上是三步操作:
- 读取counter的值到寄存器
- 寄存器的值+1
- 写回counter
数据竞争发生过程:
时刻1:线程1读取counter=0
时刻2:线程2读取counter=0(线程1还没写回)
时刻3:线程1计算0+1=1,写回counter=1
时刻4:线程2计算0+1=1,写回counter=1
结果:两次++,counter只增加了1!
解决方案:std::mutex互斥锁
mutex = mutual exclusion(互斥)
#include <thread>
#include <mutex>
#include <iostream>
using namespace std;
int counter = 0;
mutex mtx; // 创建互斥锁
void addCount() {
for (int i = 0; i < 100000; i++) {
mtx.lock(); // 🔒 上锁
counter++;
mtx.unlock(); // 🔓 解锁
}
}
int main() {
thread t1(addCount);
thread t2(addCount);
t1.join();
t2.join();
cout << "counter = " << counter << "\n";
return 0;
}
输出:
counter = 200000 ← 正确!
mutex的工作原理
临界区(Critical Section):需要互斥访问的代码段
mtx.lock(); // 🚪 进入临界区(关门)
counter++; // 🔒 只有一个线程能执行
mtx.unlock(); // 🚪 离开临界区(开门)
执行过程:
线程1调用lock():成功上锁,进入临界区
线程2调用lock():锁已被占用,阻塞等待
线程1执行counter++
线程1调用unlock():释放锁
线程2的lock()成功:获得锁,进入临界区
线程2执行counter++
线程2调用unlock():释放锁
手动管理mutex的问题
问题1:忘记unlock导致死锁
mutex mtx;
void dangerousFunc() {
mtx.lock();
if (someError) {
return; // ❌ 忘记unlock(),锁永远不释放!
}
counter++;
mtx.unlock(); // 只有正常执行才能解锁
}
结果:其他线程永远等待,程序死锁!
问题2:异常导致unlock不执行
void riskyFunc() {
mtx.lock();
doSomething(); // 如果这里抛异常,unlock不会执行
mtx.unlock();
}
问题:异常发生时,unlock被跳过,死锁!
🛡️ 核心知识点三:std::lock_guard自动锁管理
RAII原理:资源获取即初始化
RAII(Resource Acquisition Is Initialization):
- 构造函数获取资源(上锁)
- 析构函数释放资源(解锁)
- 利用C++对象生命周期自动管理
lock_guard基本用法
#include <mutex>
#include <thread>
#include <iostream>
using namespace std;
int counter = 0;
mutex mtx;
void addCount() {
for (int i = 0; i < 100000; i++) {
lock_guard<mutex> lock(mtx); // 构造时自动上锁
counter++;
// 作用域结束时,lock对象被析构,自动解锁
}
}
int main() {
thread t1(addCount);
thread t2(addCount);
t1.join();
t2.join();
cout << "counter = " << counter << "\n";
return 0;
}
输出:
counter = 200000 ← 正确!
lock_guard的工作原理
// lock_guard的简化实现(帮助理解)
template<typename Mutex>
class lock_guard {
private:
Mutex& m_mutex;
public:
// 构造函数:上锁
explicit lock_guard(Mutex& mtx) : m_mutex(mtx) {
m_mutex.lock(); // 🔒 自动上锁
}
// 析构函数:解锁
~lock_guard() {
m_mutex.unlock(); // 🔓 自动解锁
}
// 禁止拷贝
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
};
关键:无论怎么退出作用域(return、异常、正常结束),析构函数一定会执行!
lock_guard vs 手动lock/unlock对比
❌ 手动管理:容易出错
void transfer(int amount) {
mtx.lock();
if (amount < 0) {
return; // ❌ 忘记unlock()
}
if (account1 < amount) {
return; // ❌ 又忘记unlock()
}
account1 -= amount;
account2 += amount;
mtx.unlock(); // 只有正常执行才能unlock()
}
✅ lock_guard:自动管理
void transfer(int amount) {
lock_guard<mutex> lock(mtx); // 自动上锁
if (amount < 0) {
return; // ✅ lock对象析构,自动解锁
}
if (account1 < amount) {
return; // ✅ lock对象析构,自动解锁
}
account1 -= amount;
account2 += amount;
// ✅ 函数结束,lock对象析构,自动解锁
}
无论怎么退出函数,锁一定会被释放!
异常安全示例
int account1 = 1000;
int account2 = 500;
mutex mtx;
void riskyTransfer(int amount) {
lock_guard<mutex> lock(mtx); // 自动上锁
account1 -= amount;
// 假设这里抛出异常
if (amount > 100) {
throw runtime_error("金额过大");
}
account2 += amount;
// 即使抛异常,lock对象也会被析构,自动解锁!
}
int main() {
try {
riskyTransfer(200); // 会抛异常
} catch (exception& e) {
cout << "异常: " << e.what() << "\n";
}
// 锁已经被释放,其他线程可以正常运行
cout << "程序继续运行\n";
return 0;
}
输出:
异常: 金额过大
程序继续运行
关键:即使抛异常,lock_guard的析构函数也会执行,锁会被释放!
作用域控制
int counter = 0;
mutex mtx;
void func() {
// 临界区1
{
lock_guard<mutex> lock(mtx);
counter++;
cout << "临界区1: " << counter << "\n";
} // lock对象析构,解锁
// 这里不需要锁,可以做其他事情
this_thread::sleep_for(chrono::milliseconds(100));
// 临界区2
{
lock_guard<mutex> lock(mtx);
counter++;
cout << "临界区2: " << counter << "\n";
} // lock对象析构,解锁
}
优点:可以精确控制锁的范围,不需要锁的地方不会持有锁
lock_guard的优缺点
✅ 优点
- 自动管理:不用担心忘记unlock()
- 异常安全:即使抛异常也能正确解锁
- 代码简洁:一行代码搞定上锁解锁
- 作用域清晰:锁的范围一目了然
❌ 缺点
- 不能手动unlock():锁的生命周期绑定对象
- 不能重复lock():只能在构造时上锁
- 功能简单:只支持基本的lock/unlock
🔧 核心知识点四:std::unique_lock灵活锁管理
lock_guard的局限性
问题1:不能手动unlock()
void func() {
lock_guard<mutex> lock(mtx); // 上锁
doSomething();
// ❌ 想提前解锁?做不到!
// lock.unlock(); // lock_guard没有unlock()方法
// 这里不需要锁,但还持有锁
doOtherThings(); // 浪费性能
} // 只能等函数结束才解锁
问题2:不能延迟上锁
void func() {
// ❌ 想先不上锁,后面再上锁?做不到!
lock_guard<mutex> lock(mtx); // 构造时立即上锁,无法延迟
}
unique_lock:更灵活的解决方案
unique_lock能做的事情:
- ✅ 可以手动unlock()和lock()
- ✅ 可以延迟上锁
- ✅ 可以尝试上锁(try_lock)
- ✅ 可以转移锁的所有权(移动语义)
用法1:手动unlock()和lock()
#include <mutex>
#include <thread>
#include <iostream>
using namespace std;
int counter = 0;
mutex mtx;
void func() {
unique_lock<mutex> lock(mtx); // 构造时上锁
counter++;
cout << "临界区1: " << counter << "\n";
lock.unlock(); // ✅ 手动解锁
// 这里不需要锁,可以做耗时操作
this_thread::sleep_for(chrono::milliseconds(100));
cout << "非临界区,不持有锁\n";
lock.lock(); // ✅ 再次上锁
counter++;
cout << "临界区2: " << counter << "\n";
} // 析构时自动解锁(如果还持有锁)
int main() {
thread t1(func);
thread t2(func);
t1.join();
t2.join();
cout << "counter = " << counter << "\n";
return 0;
}
输出示例:
临界区1: 1
非临界区,不持有锁
临界区1: 2
非临界区,不持有锁
临界区2: 3
临界区2: 4
counter = 4
优势:只在需要锁的地方持有锁,提高并发性能!
用法2:延迟上锁(defer_lock)
mutex mtx;
int data = 0;
void func() {
// 构造时不上锁,只是关联mutex
unique_lock<mutex> lock(mtx, defer_lock);
// 做一些不需要锁的准备工作
cout << "准备工作...\n";
this_thread::sleep_for(chrono::milliseconds(100));
// 现在需要锁了,手动上锁
lock.lock();
data++;
cout << "临界区: " << data << "\n";
} // 析构时自动解锁
关键参数:defer_lock = 延迟上锁
用法3:尝试上锁(try_lock)
mutex mtx;
void tryFunc() {
unique_lock<mutex> lock(mtx, defer_lock); // 先不上锁
if (lock.try_lock()) { // 尝试上锁,不阻塞
cout << "获得锁,执行任务\n";
this_thread::sleep_for(chrono::milliseconds(100));
} else {
cout << "获取锁失败,跳过任务\n";
}
} // 自动解锁
int main() {
thread t1(tryFunc);
thread t2(tryFunc);
t1.join();
t2.join();
return 0;
}
可能的输出:
获得锁,执行任务
获取锁失败,跳过任务
用途:非阻塞的锁获取,失败了可以做其他事情
unique_lock vs lock_guard对比
| 特性 | lock_guard | unique_lock |
|---|---|---|
| 构造时上锁 | ✅ | ✅ |
| 析构时解锁 | ✅ | ✅ |
| 手动unlock() | ❌ | ✅ |
| 手动lock() | ❌ | ✅ |
| 延迟上锁 | ❌ | ✅ (defer_lock) |
| 尝试上锁 | ❌ | ✅ (try_lock) |
| 转移所有权 | ❌ | ✅ (移动语义) |
| 性能 | 快(简单) | 慢一点(功能多) |
什么时候用哪个?
// ✅ 简单场景:用lock_guard
void simpleFunc() {
lock_guard<mutex> lock(mtx);
counter++;
}
// ✅ 需要手动控制:用unique_lock
void flexibleFunc() {
unique_lock<mutex> lock(mtx);
doSomething();
lock.unlock(); // 提前解锁
doOtherThings();
lock.lock(); // 再次上锁
doMore();
}
原则:
- 默认用lock_guard(简单、高效)
- 需要灵活控制时用unique_lock
🎯 重要问题解答
学习过程中的关键问题
Q1: join()和detach()的本质区别是什么?
A:
- join():主线程等待子线程结束,确保子线程完成任务
- detach():主线程不等待,子线程独立运行,但要小心生命周期问题
- 关键:detach后如果主线程提前结束,子线程可能被强制终止
Q2: 为什么counter++会有数据竞争?
A:counter++ 不是原子操作,实际是三步:
- 读取counter到寄存器
- 寄存器值+1
- 写回counter
多线程可能在这三步中交错执行,导致结果错误。
Q3: 什么是RAII?为什么用RAII管理锁?
A:
- RAII = Resource Acquisition Is Initialization(资源获取即初始化)
- 构造函数获取资源,析构函数释放资源
- 优势:利用C++对象生命周期自动管理,即使抛异常也能正确释放
- lock_guard和unique_lock都是RAII的典型应用
Q4: lock_guard和unique_lock该如何选择?
A:
- 90%的情况用lock_guard:简单、高效、代码清晰
- 需要灵活控制时用unique_lock:需要提前解锁、延迟上锁、或尝试上锁
- 性能考虑:lock_guard更轻量,unique_lock功能多但略慢
Q5: 为什么说detach()很危险?
A:
detach后线程独立运行,如果主线程先结束,整个进程终止,子线程会被强制终止,可能导致:
- 任务没完成
- 资源没释放
- 数据不一致
建议:能用join就用join,detach要谨慎使用
💡 实战编程建议
多线程编程最佳实践
- 优先使用RAII管理锁:用lock_guard/unique_lock,不要手动lock/unlock
- 最小化临界区:锁的范围越小越好,提高并发性能
- 避免在锁内做耗时操作:减少锁持有时间
- join优先于detach:除非明确知道自己在做什么
- 一个mutex保护一块数据:不要多个mutex保护同一数据
常见错误和避免方法
- 忘记unlock():用lock_guard/unique_lock自动管理
- 锁粒度太大:用作用域{}精确控制锁的范围
- detach后访问栈变量:detach的线程不要访问栈上的数据
- 重复lock导致死锁:一个线程不要对同一个mutex多次lock
🚀 下篇预告
**《C++并发编程入门:线程与互斥锁管理(下)》**将涵盖:
- condition_variable条件变量:线程间通信机制、生产者消费者模型
- 线程安全类设计:如何设计线程安全的类、多变量保护策略
- 死锁避免:死锁产生条件、std::lock同时上锁、避免死锁的策略
- atomic原子操作:原子变量使用、原子操作vs锁的对比
📊 核心概念图解(Mermaid)
图解1:thread生命周期(join vs detach)
关键理解:
- join():主线程阻塞等待,保证子线程完成
- detach():主线程不等待,子线程独立运行但有风险
图解2:数据竞争发生过程
关键理解:
counter++不是原子操作,是读-改-写三步- 两个线程可能在这三步中交错执行
- 结果:两次++,counter只增加1次
图解3:mutex互斥锁工作原理
关键理解:
- 临界区:一次只能一个线程进入
- 其他线程会阻塞等待直到锁释放
- 保证了互斥访问,避免数据竞争
图解4:lock_guard生命周期(RAII原理)
+-------------------+ +-------------------------+ +-----------------------+
| 进入作用域 | --> | 创建lock_guard对象 | --> | 构造函数调用 |
+-------------------+ +-------------------------+ +-----------------------+
|
v
+-----------------+
| mutex.lock [🔒] |
+-----------------+
|
v
+-----------------------+
| 执行临界区代码 |
+-----------------------+
|
v
+-----------------------+
| 退出条件 |
| (正常结束/return/异常) |
+-----------------------+
|
v
+-------------------+ +-------------------------+ +-----------------------+
| 离开作用域 | <-- | 对象析构 | <-- | 析构函数调用 |
+-------------------+ +-------------------------+ +-----------------------+
|
v
+-------------------+
| mutex.unlock [🔓] |
+-------------------+
关键理解:
- 构造时上锁:对象创建时自动lock()
- 析构时解锁:对象销毁时自动unlock()
- 无论如何退出:return、异常、正常结束,析构函数一定执行
- RAII保证:资源一定会被释放
图解5:lock_guard vs unique_lock对比
+-------------------+ +-------------------+
| lock_guard特性 | | unique_lock特性 |
+-------------------+ +-------------------+
| 构造时自动上锁 | | 可延迟上锁 |
| 禁止手动lock/unlock| | 允许手动lock/unlock|
| 析构时自动解锁 | | 支持try_lock |
| 简单高效 ⭐⭐⭐⭐⭐ | | 功能强大 ⭐⭐⭐⭐ |
+-------------------+ +-------------------+
\_______ 选择? ______/
|
+---------+---------+
| |
+---------v---------+ +-------v-------+
| 简单场景(90%推荐) | | 复杂控制(10%) |
+-------------------+ +---------------+
关键理解:
- lock_guard:简单、高效,适合绝大多数场景
- unique_lock:灵活、功能多,适合需要手动控制的场景
- 默认选择:lock_guard优先,有特殊需求再用unique_lock
📝 我的理解
今天学习的收获
1. join和detach终于搞清楚了
之前一直不懂join和detach的本质区别,现在明白了:
- join就是等,主线程傻等子线程干完活
- detach就是不等,但是得小心主线程提前结束把子线程杀了
关键是detach用值捕获还是引用捕获的问题:
- 一开始我以为引用捕获有问题,后来发现如果join的话根本没事
- 但是detach的话,如果主线程生命周期短,引用捕获和值捕获都可能失效
- 所以detach推荐值捕获,但核心是要保证主线程活得够久
2. 函数对象这玩意儿
一开始看到重载()就蒙了,后来理解了:
- 函数对象就是个类,但是可以像函数一样用
- 重载
()之后,对象加括号就能调用 - 好处是可以带状态(成员变量),比普通函数灵活
3. counter++为啥会出错终于理解了
之前完全没想到counter++不是原子操作,觉得这不就一步吗?
- 后来知道是"读-改-写"三步
- 两个线程交错执行,结果就乱了
- 所以必须加锁保护
4. RAII这个思想太牛了
lock_guard的设计真的很巧妙:
- 构造时自动上锁,析构时自动解锁
- 无论咋退出(return、异常、正常结束),析构一定执行
- 再也不用担心忘记unlock了
5. explicit关键字
查了一下,这个是防止隐式类型转换的:
- 构造函数如果不加explicit,可能被编译器自动调用
- 加了之后,必须显式调用,更安全
- lock_guard的构造函数就用了explicit
还不太理解的地方
1. unique_lock和lock_guard的选择
虽然知道unique_lock更灵活,但实际写代码时还是有点纠结:
- 90%场景用lock_guard,但那10%到底是哪些?
- 感觉需要多练习才能形成直觉
2. defer_lock的应用场景
defer_lock这个参数看懂了,但实际啥时候用还不太清楚:
- 延迟上锁的场景到底有哪些?
- 感觉需要实际项目经验才能理解
实际应用的思考
1. 多线程的性能优势
之前写程序都是单线程,现在知道了多线程可以充分利用多核CPU。
- 但是加锁又会影响性能
- 所以要权衡:临界区越小越好
- 能不加锁就不加锁(比如用atomic)
2. 异常安全的重要性
这次学习最大的收获是意识到异常安全的重要性:
- 手动lock/unlock容易出错
- RAII自动管理资源,即使抛异常也没事
- 以后写代码要多用这种思想
3. 线程安全不是想象的那么简单
之前以为只要加个锁就完事了,现在发现:
- 锁的粒度要控制
- 返回引用可能不安全
- 异常要考虑
- 性能要权衡
感觉并发编程比想象的复杂多了,需要多练习
💡 学习建议:并发编程需要大量实践,建议在实际项目中多使用多线程,观察数据竞争和锁的效果。可以写一些小程序测试不同场景下的多线程行为。
🎯 下一步:建议继续学习条件变量,了解线程间如何进行通信和协作,这是实现生产者消费者模型的基础。
📝 本文记录了C++并发编程基础的学习过程,从线程创建到互斥锁管理,掌握了RAII自动锁管理的核心思想。记住:多线程编程的核心是正确管理共享数据的并发访问!
更多推荐
所有评论(0)