面试题:乐观锁和悲观锁——四维图新面试题

旅行   2024-11-23 09:00   广东  

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

分享一套视频课程《C++百万并发服务器开发》,有需要的加我微信获取:fb964919126


面试题:乐观锁和悲观锁

在并发编程中,锁机制是保证线程安全的重要手段。根据锁的实现策略,我们通常将其分为乐观锁和悲观锁。

一、基本概念对比

悲观锁的核心思想

悲观锁采用"先锁后访问"的策略,它假设最坏的情况,认为每次访问共享资源时都会发生冲突。在访问共享资源前,必须先对资源进行加锁,确保在访问期间资源被锁定。这种方式类似于图书馆借书时,一本书被借走后,其他人就无法借阅该书,必须等待书被归还。

乐观锁的核心思想

乐观锁采用"先访问后校验"的策略,它假设最好的情况,认为并发访问共享资源时不会发生冲突。在访问共享资源时不加锁,而是在更新资源时检查是否有其他线程修改了该资源。这种方式类似于在线文档协同编辑,多人可以同时查看和编辑文档,但在保存时会检查是否存在冲突。

二、悲观锁的典型实现:pthread_mutex_t

// pthread_mutex 的核心数据结构struct pthread_mutex_t {    // 互斥锁的类型    int type;    // 递归锁的递归计数    unsigned int count;    // 持有锁的线程ID    pthread_t owner;    // 原子变量,用于自旋    atomic_int lock;    // 条件变量,用于线程阻塞和唤醒    pthread_cond_t cond;};
// mutex 加锁的核心实现int pthread_mutex_lock(pthread_mutex_t *mutex) {    // 快速路径:尝试直接获取锁    if (atomic_exchange(&mutex->lock, 1) == 0) {        mutex->owner = pthread_self();        return 0;    }        // 慢路径:需要等待    while (1) {        // 自旋一段时间        for (int i = 0; i < SPIN_COUNT; ++i) {            if (atomic_load(&mutex->lock) == 0 &&                atomic_exchange(&mutex->lock, 1) == 0) {                mutex->owner = pthread_self();                return 0;            }            // CPU让出时间片            cpu_relax();        }                // 自旋失败,进入睡眠等待        futex_wait(&mutex->lock, 1);    }        return 0;}

从 Linux 2.6.25 开始,内核引入了 "自适应自旋" 机制:在进入完全阻塞前,先进行一定次数的自旋,以处理短期的锁竞争。

悲观锁的实现主要包含以下几个关键点:

原子操作保护:使用atomic_exchange确保锁的获取是原子的,避免竞态条件。
自旋等待:在进入完全阻塞前,先进行一定次数的自旋,以处理短期的锁竞争。
线程阻塞:当自旋等待失败后,通过futex系统调用使线程进入睡眠状态,避免消耗CPU资源

三、乐观锁的典型实现:std::atomic

template<typename T>class atomic {private:    T value;    public:    bool compare_exchange_strong(T& expected, T desired) noexcept {        // 使用CPU的原子指令实现CAS操作        return __atomic_compare_exchange_n(            &value,            // 原子变量的地址            &expected,         // 期望值            desired,           // 新值            false,            // weak版本?            __ATOMIC_SEQ_CST, // 成功时的内存序            __ATOMIC_SEQ_CST  // 失败时的内存序        );    }        bool compare_exchange_weak(T& expected, T desired) noexcept {        return __atomic_compare_exchange_n(            &value,            &expected,            desired,            true,             // weak版本            __ATOMIC_SEQ_CST,            __ATOMIC_SEQ_CST        );    }};
// 使用示例:实现一个乐观锁保护的计数器class OptimisticCounter {private:    std::atomic<int> value{0};    public:    void increment() {        int expected, desired;        do {            expected = value.load();            desired = expected + 1;        } while (!value.compare_exchange_strong(expected, desired));    }};

乐观锁实现的关键点:

CAS操作:Compare-And-Swap是乐观锁的核心,它能够原子地比较并交换值。
无阻塞在竞争失败时不会阻塞线程,而是重试操作。
版本控制通过比较预期值来确保数据一致性。

四、性能对比

悲观锁的主要开销来自:

系统调用:加锁/解锁操作通常需要进入内核态
线程调度:当锁被占用时,线程会被挂起
上下文切换:线程挂起和恢复时的上下文切换

乐观锁的主要开销来自:
CAS操作:虽然是原子的,但频繁失败会导致重试
CPU缓存一致性:CAS操作会触发缓存一致性协议

五、应用场景

适合使用悲观锁的场景

1、写操作频繁的场景

class Database {    std::mutex mtx_;    std::map<int, std::string> data_;public:    void update(int key, const std::string& value) {        std::lock_guard<std::mutex> lock(mtx_);        data_[key] = value;    }};

2、临界区执行时间较长的场景

class ComplexTask {    std::mutex mtx_;    void process_data() {        std::lock_guard<std::mutex> lock(mtx_);        // 复杂的数据处理,可能需要较长时间        std::this_thread::sleep_for(std::chrono::milliseconds(100));    }};

适合使用乐观锁的场景

1、读多写少的场景

class ReadMostlyCache {    std::atomic<uint64_t> version_{0};    std::array<int, 1024> data_;public:    void update(size_t index, int value) {        data_[index] = value;        version_.fetch_add(1, std::memory_order_release);    }        std::pair<int, uint64_t> read(size_t index) {        uint64_t ver = version_.load(std::memory_order_acquire);        return {data_[index], ver};    }};

2、简单的计数器场景

class Statistics {    std::atomic<uint64_t> total_requests_{0};    std::atomic<uint64_t> failed_requests_{0};public:    void record_request(bool success) {        total_requests_.fetch_add(1, std::memory_order_relaxed);        if (!success) {            failed_requests_.fetch_add(1, std::memory_order_relaxed);        }    }};


6. 总结

悲观锁通过操作系统提供的同步原语实现,保证了强一致性,但带来了系统调用和上下文切换的开销。
乐观锁通过硬件提供的原子指令实现,在无竞争情况下性能极高,但在竞争激烈时可能会因为频繁重试而性能下降。

选择合适的锁机制需要考虑:

并发访问的模式(读多写少还是写多)
临界区的执行时间
冲突的概率
性能要求

在实际应用中,往往需要结合这两种锁机制,才能实现最优的并发控制。

end



CppPlayer 



关于AI编程工具:Cursor,现在官方有漏洞可以无限续期。需要方法的加我vx: fb964919126


文章推荐

面试官不讲武德,偷袭我一个大学生:当"我知道答案"遇上"请解释原理"
面试题:什么是用户态和内核态,如何进入内核态?——分叉智能科技一面
面试题:使用 GDB 如何调试 Core 文件?
面试题:poll为什么使用poll_list链表结构而不是数组 - 深入内核源码分析

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