前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站
1. 锁的基本概念
1.1 什么是锁?
锁是一种同步原语,用于保证多个线程在访问共享资源时的互斥性。通过加锁机制,可以确保在某一时刻,只有一个线程能够访问共享资源。
1.2 锁的作用
- 互斥性:保证共享资源在同一时刻只被一个线程访问。
- 同步性:协调多个线程的执行顺序,避免数据竞争。
1.3 常见的锁类型
锁类型 | 特点 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
互斥锁 (std::mutex ) | 简单的二进制锁,线程间互斥访问共享资源 | 实现简单、适用广泛 | 阻塞线程,可能导致上下文切换开销 | 共享资源需要严格互斥的场景 |
递归锁 (std::recursive_mutex ) | 同一线程可以多次加锁,无需担心死锁 | 避免递归调用时死锁问题 | 性能略差于普通互斥锁 | 递归函数需要加锁的场景 |
读写锁 (std::shared_mutex ) | 多线程可并发读取,但写操作独占 | 提高读操作多的场景下的并发性能 | 写操作需要独占锁,读多写少时性能最佳 | 数据读多写少的场景 |
自旋锁 (std::atomic_flag ) | 线程忙等待,不阻塞,适合短期锁 | 低延迟,无需上下文切换 | 忙等待消耗CPU资源,不适合长时间锁持有 | 短期锁定操作或实时性高的场景 |
2. 各种锁的实现细节与代码示例
2.1 互斥锁
概念
互斥锁(Mutex)是最基础的锁,通过阻塞线程保证互斥性。C++ 的 std::mutex
提供基础实现。
互斥锁用于保护共享资源的同步机制。当一个线程想要访问一个被互斥锁保护的资源时,它必须首先获取锁。如果锁已经被其他线程持有,那么这个线程就会被阻塞,直到锁被释放。以下是互斥锁的工作流程:
在这个流程图中:
- 线程开始时,它会检查锁的状态。
- 如果锁未被锁定,线程会获取锁,然后访问资源。访问完成后,线程会释放锁,然后结束。
- 如果锁已被锁定,线程会等待锁被释放。当锁被释放后,线程会再次检查锁的状态,然后重复上述过程。
这就是互斥锁的基本工作流程。通过这种方式,互斥锁可以确保任何时候都只有一个线程能够访问被保护的资源,从而避免了数据的不一致性。
代码示例
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
int counter = 0;
void safeIncrement() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx); // 自动加锁和解锁
++counter;
}
}
int main() {
std::thread t1(safeIncrement);
std::thread t2(safeIncrement);
t1.join();
t2.join();
std::cout << "Final counter: " << counter << std::endl;
return 0;
}
2.2 递归锁
概念
递归锁(Recursive Mutex)允许同一线程多次加锁,而不会导致死锁。通常用于递归函数中。
递归锁是一种特殊类型的互斥锁,它允许同一个线程多次获取同一个锁,而不会造成死锁。这在某些需要多次访问同一资源的场景中非常有用,例如递归函数。以下是递归锁的工作流程:
在这个流程图中:
- 线程开始时,它会检查锁的状态。
- 如果锁未被锁定,或者锁已被自己锁定,线程会获取锁,然后访问资源。访问完成后,线程会检查是否需要再次访问资源。如果需要,线程会再次获取锁,然后访问资源。如果不需要,线程会释放锁,然后结束。
- 如果锁已被其他线程锁定,线程会等待锁被释放。当锁被释放后,线程会再次检查锁的状态,然后重复上述过程。
这就是递归锁的基本工作流程。通过这种方式,递归锁可以避免同一个线程因多次获取同一个锁而造成的死锁,从而使得代码更加简洁和易于理解。
代码示例
#include <iostream>
#include <thread>
#include <mutex>
std::recursive_mutex rec_mtx;
void recursiveFunction(int depth) {
if (depth <= 0) return;
rec_mtx.lock();
std::cout << "Lock acquired at depth: " << depth << std::endl;
recursiveFunction(depth - 1);
rec_mtx.unlock();
}
int main() {
std::thread t1(recursiveFunction, 5);
t1.join();
return 0;
}
2.3 读写锁
概念
读写锁(Read-Write Lock)允许多线程并发读取,但写操作需要独占。C++17 提供了 std::shared_mutex
。
读写锁是一种特殊类型的锁,它允许多个读线程同时访问资源,但在写线程访问资源时,所有其他线程(无论是读线程还是写线程)都不能访问资源。以下是读写锁的工作流程:
在这个流程图中:
- 线程开始时,它会检查操作类型(读或写)。
- 如果是读操作,线程会检查是否有写锁。如果没有,线程会获取读锁,然后访问资源。访问完成后,线程会释放读锁,然后结束。如果有写锁,线程会等待写锁被释放,然后再次检查。
- 如果是写操作,线程会检查是否有其他锁(无论是读锁还是写锁)。如果没有,线程会获取写锁,然后访问资源。访问完成后,线程会释放写锁,然后结束。如果有其他锁,线程会等待其他锁被释放,然后再次检查。
这就是读写锁的基本工作流程。通过这种方式,读写锁可以在保证数据一致性的同时,提高读操作的并发性能。
代码示例
#include <iostream>
#include <thread>
#include <shared_mutex>
#include <vector>
std::shared_mutex rw_lock;
std::vector<int> data;
void reader() {
std::shared_lock<std::shared_mutex> lock(rw_lock); // 读锁
for (const auto& val : data) {
std::cout << "Reader sees: " << val << std::endl;
}
}
void writer(int value) {
std::unique_lock<std::shared_mutex> lock(rw_lock); // 写锁
data.push_back(value);
std::cout << "Writer adds: " << value << std::endl;
}
int main() {
std::thread t1(reader);
std::thread t2(writer, 42);
std::thread t3(reader);
t1.join();
t2.join();
t3.join();
return 0;
}
2.4 自旋锁
概念
自旋锁(Spinlock)是一种特殊类型的锁,当线程无法立即获取锁时,它不会立即进入阻塞状态,而是在一个循环中不断地尝试获取锁,直到成功为止。这种锁适用于锁持有时间短且线程不希望在重新调度上花费过多时间的情况。
以下是自旋锁的工作流程:
在这个流程图中:
- 线程开始时,它会检查锁的状态。
- 如果锁未被锁定,线程会获取锁,然后访问资源。访问完成后,线程会释放锁,然后结束。
- 如果锁已被锁定,线程会自旋等待,即在一个循环中不断地尝试获取锁,直到成功为止。
这就是自旋锁的基本工作流程。通过这种方式,自旋锁可以避免线程在等待锁时进入阻塞状态,从而减少了线程调度的开销。
代码示例
#include <atomic>
#include <thread>
#include <iostream>
std::atomic_flag lock = ATOMIC_FLAG_INIT;
void spinlockTask(int id) {
while (lock.test_and_set(std::memory_order_acquire)) {
// 自旋等待,直到获取到锁
}
std::cout << "Thread " << id << " acquired the lock." << std::endl;
lock.clear(std::memory_order_release);
}
int main() {
std::thread t1(spinlockTask, 1);
std::thread t2(spinlockTask, 2);
t1.join();
t2.join();
return 0;
}
在这个代码示例中,我们使用了C++的std::atomic_flag
来实现一个简单的自旋锁。当一个线程无法立即获取锁时,它会在一个循环中不断地尝试获取锁,直到成功为止。
3. 死锁问题及解决方法
3.1 死锁的概念及常见发生现象
死锁概念
**死锁(Deadlock)**是指两个或多个线程或进程因争夺资源而相互等待,导致所有参与者都无法继续执行的状态。
死锁的常见表现
死锁是并发编程中的一种常见问题,它发生在两个或更多的进程或线程互相等待对方释放资源,导致所有的进程或线程都无法继续执行。以下是死锁的一些常见表现:
1. 系统停滞
最明显的死锁表现就是系统停滞,也就是说,系统的一部分或全部都无法继续执行。这可能表现为用户界面无响应,或者后台服务停止工作。
2. 高CPU使用率
在某些情况下,死锁可能会导致CPU使用率异常升高。这是因为,当进程或线程在等待资源时,它们可能会不断地进行无效的轮询,从而消耗大量的CPU时间。
3. 日志停止更新
如果系统有日志记录,那么在死锁发生时,日志可能会停止更新。这是因为,进程或线程在等待资源时,它们无法执行其他的操作,包括写日志。
4. 资源占用不释放
在死锁发生时,相关的资源可能会被永久占用,而无法被释放。这可能表现为内存占用过高,或者文件、数据库连接等资源无法关闭。
3.2 死锁的发生条件
根据 Coffman 在 1971 年提出的理论,死锁的发生需要满足以下四个条件,这些条件同时成立时,系统可能进入死锁状态:
条件 | 描述 |
---|---|
互斥(Mutual Exclusion) | 至少有一个资源是非共享的,某一时刻只能被一个线程或进程占用。 |
请求与保持(Hold and Wait) | 线程已经持有资源,同时又请求新的资源,但未释放已有资源。 |
不可剥夺(No Preemption) | 已被分配的资源不能强制剥夺,只能由持有该资源的线程或进程主动释放。 |
循环等待(Circular Wait) | 存在一个线程或进程的循环等待链,链中的每个线程或进程都在等待下一个线程持有的资源。 |
示例:死锁的实际表现
以下代码展示了一个简单的死锁场景:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1, mtx2;
void thread1() {
std::lock_guard<std::mutex> lock1(mtx1);
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟操作延迟
std::lock_guard<std::mutex> lock2(mtx2); // 等待 mtx2
}
void thread2() {
std::lock_guard<std::mutex> lock2(mtx2);
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟操作延迟
std::lock_guard<std::mutex> lock1(mtx1); // 等待 mtx1
}
int main() {
std::thread t1(thread1);
std::thread t2(thread2);
t1.join();
t2.join();
return 0;
}
- mtx1 和 mtx2:两个互斥资源。
- 死锁场景:线程 1 持有
mtx1
,等待mtx2
;线程 2 持有mtx2
,等待mtx1
。
3.3 如何预防死锁
要避免死锁,可以通过以下策略打破上述四个条件之一:
3.3.1 避免循环等待
策略:为资源分配顺序,所有线程按固定顺序请求资源。
实现:在多锁操作时使用 std::lock
,保证同时锁定多个资源。
std::lock(mtx1, mtx2);
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
3.3.2 避免请求与保持
策略:线程在请求资源前必须释放已持有的资源。
实现:确保资源释放后再进行下一步操作,避免长时间持有锁。
std::unique_lock<std::mutex> lock(mtx);
processData();
lock.unlock(); // 提前释放资源
performOtherTasks();
3.3.3 允许资源剥夺
策略:使用能够超时的锁操作,如 std::unique_lock
的超时版本,避免无限等待。
实现:
std::unique_lock<std::mutex> lock(mtx, std::try_to_lock);
if (lock.owns_lock()) {
processData();
} else {
std::cout << "Resource is busy, retry later." << std::endl;
}
3.3.4 避免互斥
策略:在某些情况下,可以通过无锁编程实现数据共享,避免资源互斥。
实现:使用 std::atomic
或无锁数据结构替代互斥锁。
3.4 如何处理已发生的死锁
-
死锁检测与恢复
- 在程序设计中引入死锁检测机制,当检测到死锁时,采取恢复策略(如强制释放资源或终止某些线程)。
- 示例:自定义资源管理器检测锁持有状态。
-
操作系统级检测
- 某些操作系统(如 Linux)可以通过系统监控工具检测死锁,管理员可以通过强制终止相关进程来解决问题。
-
人工干预
- 在程序卡死后,通过调试器或日志分析线程状态,找到死锁线程,人工终止问题线程。
死锁检测算法
在操作系统中,常用**资源分配图(Resource Allocation Graph,RAG)**来检测死锁:
- 节点表示线程和资源。
- 线程等待资源时,画一条从线程到资源的边。
- 如果资源分配给线程,画一条从资源到线程的边。
- 图中存在循环时,系统可能处于死锁状态。
3.5 代码优化示例:避免死锁的设计
以下代码展示了通过 std::lock
避免死锁的优化:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1, mtx2;
void thread1() {
std::lock(mtx1, mtx2); // 同时锁定多个资源
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
std::cout << "Thread 1 acquired both locks." << std::endl;
}
void thread2() {
std::lock(mtx1, mtx2); // 同时锁定多个资源
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
std::cout << "Thread 2 acquired both locks." << std::endl;
}
int main() {
std::thread t1(thread1);
std::thread t2(thread2);
t1.join();
t2.join();
return 0;
}
通过 std::lock
保证加锁顺序一致,从而避免死锁。
3.6 小结
- 死锁的本质:线程间对资源的相互依赖与等待。
- 死锁预防:通过打破死锁的四个条件(互斥、请求与保持、不可剥夺、循环等待)。
- 死锁处理:通过检测与恢复、系统工具或人工干预解决死锁。
- 良好的设计习惯:使用 C++ 提供的 RAII 机制(如
std::lock_guard
)、加锁策略(如std::lock
)以及无锁编程技术避免死锁。
在实际开发中,预防死锁比处理死锁更为重要,应优先通过良好的设计和编码实践避免死锁的发生。
4. 加锁性能问题探讨
加锁确实会带来性能问题,如上下文切换、锁争用等待、死锁等影响程序并发性能的问题。以下是针对加锁导致的性能问题的分析及优化策略:
4.1 加锁性能问题的来源
-
上下文切换
当线程因锁被阻塞时,操作系统会调度其他线程运行,涉及内核态与用户态的切换,代价较高。 -
锁争用
多线程频繁请求同一资源导致锁竞争激烈,线程阻塞等待锁释放,降低系统吞吐量。 -
长时间持锁
锁持有时间过长可能导致其他线程长时间阻塞,影响系统并发性。 -
锁粒度过大
使用粗粒度的锁(如对整个函数加锁)可能导致无关的操作也被序列化,降低并发性。
4.2 优化策略
4.2.1 减小锁的粒度
问题描述:锁粒度过大(例如整个函数加锁)会降低并发性。
解决方法:将大锁拆分为小锁,尽量缩小锁的作用范围,只保护真正需要保护的关键代码段。
示例
优化前:
std::mutex mtx;
void processData() {
std::lock_guard<std::mutex> lock(mtx);
// 临界区代码
criticalSection();
// 非临界区代码
nonCriticalSection();
}
优化后:
std::mutex mtx;
void processData() {
criticalSection(); // 非加锁部分
{
std::lock_guard<std::mutex> lock(mtx);
criticalSection(); // 临界区代码
}
nonCriticalSection(); // 非加锁部分
}
4.2.2 使用读写锁优化读多写少场景
问题描述:标准互斥锁对读写操作一视同仁,而大多数场景中读操作多于写操作。
解决方法:使用读写锁(如 std::shared_mutex
),允许多个线程同时读,写时加独占锁。
示例
#include <shared_mutex>
#include <vector>
std::shared_mutex rwLock;
std::vector<int> data;
void reader() {
std::shared_lock<std::shared_mutex> lock(rwLock); // 读锁
for (const auto& val : data) {
std::cout << "Read: " << val << std::endl;
}
}
void writer(int value) {
std::unique_lock<std::shared_mutex> lock(rwLock); // 写锁
data.push_back(value);
std::cout << "Wrote: " << value << std::endl;
}
适用场景:数据读多写少的场景(如缓存系统、日志记录等)。
4.2.3 使用无锁数据结构
问题描述:锁的等待和上下文切换增加了开销。
解决方法:在某些场景下,可以使用无锁数据结构(如 std::atomic
或自定义的无锁队列)替代加锁。
示例
无锁计数器:
#include <atomic>
std::atomic<int> counter(0);
void increment() {
for (int i = 0; i < 100000; ++i) {
++counter; // 原子操作,无需加锁
}
}
适用场景:简单的计数、标志位等共享数据操作。
4.2.4 避免长时间持锁
问题描述:锁持有时间过长可能导致其他线程阻塞,系统吞吐量降低。
解决方法:减少临界区的执行时间,将耗时操作移出锁范围。
示例
优化前:
std::mutex mtx;
void process() {
std::lock_guard<std::mutex> lock(mtx);
timeConsumingTask(); // 耗时操作
}
优化后:
std::mutex mtx;
void process() {
prepareData(); // 耗时操作,移出锁范围
std::lock_guard<std::mutex> lock(mtx);
updateCriticalSection(); // 临界区代码
}
4.2.5 使用尝试加锁或超时锁
问题描述:线程可能因等待锁而长时间阻塞。
解决方法:使用 std::try_lock
或 std::unique_lock
的超时功能避免长时间阻塞。
示例
std::mutex mtx;
void process() {
if (mtx.try_lock()) { // 尝试加锁
std::lock_guard<std::mutex> lock(mtx, std::adopt_lock);
// 临界区代码
} else {
std::cout << "Lock is busy, retry later." << std::endl;
}
}
4.2.6 使用分区锁
问题描述:一个大锁可能导致多个无关线程的操作被序列化。
解决方法:将数据划分为多个分区,每个分区使用独立的锁,从而提高并发性能。
示例
#include <mutex>
#include <vector>
std::vector<std::mutex> locks(10); // 每个分区一个锁
std::vector<int> data(10, 0);
void updatePartition(int index, int value) {
std::lock_guard<std::mutex> lock(locks[index]);
data[index] += value;
}
4.2.7 替代同步机制
问题描述:加锁可能并不是唯一的同步方式。
解决方法:使用条件变量、信号量、事件驱动模型等替代锁。
示例:条件变量
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> dataQueue;
void producer() {
std::unique_lock<std::mutex> lock(mtx);
dataQueue.push(42);
cv.notify_one(); // 通知消费者
}
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return !dataQueue.empty(); }); // 等待数据
int value = dataQueue.front();
dataQueue.pop();
std::cout << "Consumed: " << value << std::endl;
}
4.2.8 异步编程(减少锁的需求)
问题描述:大量的锁往往是因为线程竞争共享资源。
解决方法:采用消息队列、任务分解等方式,避免直接共享数据。
示例:任务队列
#include <queue>
#include <mutex>
#include <thread>
#include <iostream>
std::queue<int> taskQueue;
std::mutex mtx;
void worker() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
if (!taskQueue.empty()) {
int task = taskQueue.front();
taskQueue.pop();
lock.unlock(); // 提前释放锁
std::cout << "Processing task: " << task << std::endl;
} else {
lock.unlock(); // 提前释放锁
std::this_thread::yield(); // 暂时让出CPU
}
}
}
int main() {
std::thread t(worker);
{
std::lock_guard<std::mutex> lock(mtx);
taskQueue.push(42); // 添加任务
}
t.join();
return 0;
}
小结
问题 | 解决方案 |
---|---|
锁粒度过大 | 缩小锁粒度,仅对必要代码段加锁 |
锁争用严重 | 使用读写锁或分区锁分散锁竞争 |
长时间持锁 | 将耗时操作移出临界区,减小锁的持有时间 |
频繁上下文切换 | 使用无锁数据结构或自旋锁减少阻塞开销 |
线程长时间等待锁 | 使用尝试加锁(try_lock )或超时锁机制 |
需要高性能并发 | 使用无锁数据结构、分区锁、或异步编程(如任务队列)优化 |
5. 总结
本文介绍了锁的基本概念、不同类型锁的特点及实现方法,并详细阐述了死锁的原理、示例及预防策略。在实际开发中,选择合适的锁类型和避免死锁是并发编程的核心,以下几点需要牢记:
- 理解锁的适用场景:选择合适的锁(如互斥锁、读写锁、自旋锁等)。
- 优化锁的使用:尽量减少锁的持有时间和范围。
- 预防死锁:通过策略设计和工具检测,提前规避死锁问题。
万字长文码字不易,感谢点赞、收藏、关注支持
若将文章用作它处,请一定注明出处,商用请私信联系我!