面试题:互斥锁和自旋锁的区别——绿盟科技一面

旅行   2024-11-02 08:08   广东  

欢迎关注本公众号,专注面试题拆解

分享一套视频课程:《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电子书资料赠送

精彩文章合集

专题推荐

【专辑】计算机网络真题拆解
【专辑】大厂最新真题
【专辑】C/C++面试真题拆解

CppPlayer
一个专注面试题拆解的公众号
 最新文章