欢迎关注本公众号,专注面试题拆解
分享一套视频课程:《C++实现百万并发服务器》 面试需要项目的可以找我获取,免费分享。 欢迎V:fb964919126
互斥锁和自旋锁的区别
互斥锁(Mutex)和自旋锁(Spinlock)都是用于多线程编程中实现同步的机制,它们的主要目的相似,都是为了确保在任何时候只有一个线程能够访问共享资源。但是他们的原理和工作方式有区别。
01
基本概念和实现
互斥锁
class MutexExample {
std::mutex mutex;
void example() {
mutex.lock();
// 如果锁被占用:
// 1. 线程被操作系统挂起
// 2. 放入等待队列
// 3. 让出CPU资源
// 4. 等待被唤醒
// 临界区代码
mutex.unlock();
// 1. 释放锁
// 2. 唤醒等待队列中的线程
}
};
自旋锁
class SpinLockExample {
std::atomic_flag flag = ATOMIC_FLAG_INIT;
void example() {
while(flag.test_and_set()) {
// 如果锁被占用:
// 1. 线程持续运行
// 2. 不断检查锁状态
// 3. 占用CPU资源
// 4. 直到获得锁
}
// 临界区代码
flag.clear();
}
};
互斥锁是sleep_waiting机制,当一个线程无法获取锁时,它会被操作系统挂起,不会占用CPU资源。这有助于节省CPU资源,但会导致上下文切换的开销。
自旋锁是busy-waiting机制,当一个线程无法获取锁时,它会不断循环检查锁的状态,持续占用CPU资源。这有助于减少上下文切换的开销,但可能导致CPU资源浪费。
02
两种锁的详细工作流程
互斥锁
class MutexWorkflow {
std::mutex mutex;
void workflow() {
// 步骤1:尝试获取锁
mutex.lock();
// - 检查锁状态
// - 如果空闲,标记为已锁定
// - 如果被占用,准备进入等待状态
// 步骤2:等待过程
// - 线程状态改为睡眠
// - 加入等待队列
// - 触发上下文切换
// - CPU可以执行其他线程
// 步骤3:被唤醒
// - 收到唤醒信号
// - 重新进入就绪状态
// - 等待CPU调度
// 步骤4:获得锁后执行
// 临界区代码
// 步骤5:释放锁
mutex.unlock();
// - 标记锁为空闲
// - 唤醒等待队列中的线程
}
};
尝试获取锁:
mutex.lock() 尝试获取锁。如果锁是空闲的,当前线程会成功获取锁并继续执行。如果锁被其他线程持有,当前线程会被阻塞并放入等待队列。
等待过程:
被阻塞的线程会被操作系统挂起,状态改为睡眠。
线程加入等待队列,等待锁被释放。
触发上下文切换,CPU可以执行其他线程。
被唤醒:
当持有锁的线程释放锁时,操作系统会选择一个等待队列中的线程进行唤醒。
被唤醒的线程重新进入就绪状态,等待CPU调度。
获得锁后执行:
被唤醒的线程重新获取锁并执行临界区代码。
释放锁:
mutex.unlock() 释放锁,标记锁为空闲。
唤醒等待队列中的一个线程,使其有机会获取锁。
自旋锁
class SpinLockWorkflow {
SpinLock spinlock;
void workflow() {
// 步骤1:尝试获取锁
spinlock.lock();
// - 原子操作检查并标记
// - 如果失败,进入自旋
// 步骤2:自旋等待
// - 持续执行忙等待循环
// - 不断检查锁状态
// - 可能执行CPU让步
// - 不触发上下文切换
// 步骤3:获得锁后执行
// 临界区代码
// 步骤4:释放锁
spinlock.unlock();
// - 原子操作释放锁
// - 其他线程可以立即获取
}
};
尝试获取锁:
spinlock.lock() 尝试获取锁。
使用 flag.test_and_set(std::memory_order_acquire) 原子操作检查并标记锁。
如果锁是空闲的,当前线程会成功获取锁并继续执行。
如果锁被其他线程持有,当前线程会进入自旋等待。
自旋等待:
线程会不断检查锁的状态,持续执行忙等待循环。
使用 _mm_pause() 指令可以减少CPU功耗,提高性能。
自旋等待不会触发上下文切换,线程保持活跃状态。
获得锁后执行:
当锁被释放且当前线程成功获取锁时,执行临界区代码。
释放锁:
spinlock.unlock() 释放锁,使用 flag.clear(std::memory_order_release) 原子操作标记锁为空闲。
其他线程可以立即尝试获取锁。
03
性能以及开销分析
互斥锁开销
1. 上下文切换开销
保存当前线程状态,当一个线程被阻塞时,操作系统需要保存该线程的所有寄存器状态和其他必要的信息,以便在将来恢复执行。
加载新线程状态,操作系统需要加载下一个要执行的线程的状态,包括寄存器值、程序计数器等。
切换页表,如果不同线程运行在不同的地址空间,操作系统需要切换页表,以确保新线程访问正确的虚拟地址。
刷新TLB,转换查找缓冲区(Translation Lookaside Buffer, TLB)需要刷新,以反映新的页表信息。
2. 系统调用开销
用户态切换到内核态
内核执行调度
返回用户态
3. 缓存开销
缓存失效,当一个线程被阻塞并重新调度时,缓存中的数据可能会失效,因为其他线程可能已经修改了这些数据。
缓存重新加载,当线程被唤醒并重新执行时,需要重新加载缓存中的数据,这会导致额外的内存访问开销。
自旋锁开销:
1. CPU消耗
持续执行指令,自旋锁会不断执行忙等待循环,即使没有实际工作可做,也会持续占用CPU资源。
占用CPU时间片
可能影响其他线程
2. 内存总线开销
频繁的原子操作,自旋锁依赖于原子操作(如 test_and_set),这些操作会频繁访问内存总线,增加内存总线的负担。
缓存一致性流量,多核处理器中的缓存一致性协议(如MESI协议)会频繁更新缓存状态,导致额外的缓存一致性流量。
3. 能源消耗
持续的CPU活动
更高的功耗
04
适用场景
互斥锁适用场景
class MutexScenarios {
std::mutex mutex;
// 场景1:IO操作
void ioOperation() {
std::lock_guard<std::mutex> lock(mutex);
// 文件读写
// 网络通信
// 数据库操作
}
// 场景2:复杂计算
void complexCalculation() {
std::lock_guard<std::mutex> lock(mutex);
// 大量数据处理
// 复杂算法执行
}
// 场景3:资源初始化
void resourceInit() {
std::lock_guard<std::mutex> lock(mutex);
// 加载配置
// 初始化资源
}
};
自旋锁适用场景
class SpinLockScenarios {
SpinLock spinlock;
// 场景1:计数器
void counter() {
std::lock_guard<SpinLock> lock(spinlock);
count++;
}
// 场景2:缓存操作
void cacheOperation() {
std::lock_guard<SpinLock> lock(spinlock);
// 快速的缓存读写
}
// 场景3:状态标志
void flagOperation() {
std::lock_guard<SpinLock> lock(spinlock);
// 修改状态标志
}
};
05
总结
关键区别:
等待机制(睡眠 vs 忙等待)
资源消耗(释放CPU vs 占用CPU)
响应时间(上下文切换开销 vs 立即响应)
适用场景(长操作 vs 短操作)
互斥锁:适用于锁的持有时间较长或需要最小化系统开销的情况。虽然有上下文切换和系统调用的开销,但可以有效节省CPU资源,避免不必要的忙等待。
自旋锁:适用于锁的持有时间非常短或需要极低延迟的场景。虽然会占用CPU资源,但可以减少上下文切换的开销,提高响应速度。自旋锁在用户态执行,不会触发内核态切换,但会增加内存总线和缓存一致性的开销。
一般开发的时候,默认选择互斥锁,特定场景才使用自旋锁。
end
CppPlayer
关注,回复【电子书】珍藏CPP电子书资料赠送
精彩文章合集
专题推荐