详解C++中的锁

在多线程编程中,当多个线程访问共享资源时,可能会出现"数据竞争"(data race),导致程序行为不可预测。锁(Lock) 是解决这一问题的核心同步机制,它通过保证"临界区"(critical section,即访问共享资源的代码段)的互斥访问,确保多线程安全。

对于死锁,我脑子中的例子是:两个人A和B,手里都有一个汉堡,现在都需要拿桌子上的番茄酱和芝士,挤压到汉堡上,A拿了番茄,B拿了芝士,此时A等芝士,B等番茄,就发生了死锁。解决死锁的思路可以:同时锁,或者获取锁的顺序一致,或者降低锁的时间等等吧。关于死锁的四个必要条件,下面有详细介绍,记一下。

一、C++中的锁类型

C++标准库(C++11及后续版本)提供了多种锁类型,适用于不同场景,均定义在 <mutex> 头文件中。

1. 基础互斥锁:std::mutex

std::mutex 是最基本的互斥锁,提供独占性访问控制:同一时间只能有一个线程获得锁,其他线程尝试获取锁时会阻塞,直到锁被释放。

核心方法

  • lock():获取锁(若已被其他线程持有,则当前线程阻塞)。
  • unlock():释放锁(必须由持有锁的线程调用,否则行为未定义)。
  • try_lock():尝试获取锁(若成功返回true;若已被持有,立即返回false,不阻塞)。

使用注意
必须手动调用 lock()unlock(),且需保证成对出现(否则可能导致死锁)。实际开发中通常配合RAII工具(如std::lock_guard)使用,避免遗漏解锁。

#include <mutex>
#include <thread>

std::mutex mtx;  // 全局互斥锁
int shared_data = 0;

void increment() {
    mtx.lock();          // 获取锁
    shared_data++;       // 临界区:修改共享资源
    mtx.unlock();        // 释放锁(必须调用,否则其他线程永远阻塞)
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    // shared_data 最终一定是 2(无数据竞争)
    return 0;
}
2. 递归互斥锁:std::recursive_mutex

std::recursive_mutex 允许同一线程多次获取锁(不会阻塞自身),但需要对应次数的解锁(unlock())才能释放。

适用场景
当一个函数递归调用自身,且每次调用都需要获取锁时(若用std::mutex会导致线程自己阻塞自己,造成死锁)。

注意
递归锁的开销比普通std::mutex略大,非必要不使用(过度使用可能掩盖设计问题)。

#include <mutex>

std::recursive_mutex rmtx;
int count = 0;

void recursive_func(int depth) {
    if (depth <= 0) return;
    
    rmtx.lock();
    count++;
    recursive_func(depth - 1);  // 同一线程再次获取锁,不会死锁
    rmtx.unlock();
}

int main() {
    recursive_func(5);  // count 最终为 5
    return 0;
}
3. 定时互斥锁:std::timed_mutexstd::recursive_timed_mutex

这两种锁支持超时获取锁,避免线程无限期阻塞。

  • std::timed_mutex:非递归版本,支持超时。
  • std::recursive_timed_mutex:递归版本,支持超时。

核心方法(新增):

  • try_lock_for(std::chrono::duration):在指定时间段内尝试获取锁(超时返回false)。
  • try_lock_until(std::chrono::time_point):在指定时间点前尝试获取锁(超时返回false)。

适用场景
需要限制等待锁的时间(如避免程序因死锁完全挂起)。

#include <mutex>
#include <chrono>
#include <thread>

std::timed_mutex tmtx;

void try_lock_with_timeout() {
    // 尝试在100毫秒内获取锁
    if (tmtx.try_lock_for(std::chrono::milliseconds(100))) {
        // 成功获取锁,执行临界区
        tmtx.unlock();
    } else {
        // 超时,执行备选逻辑
    }
}
4. 共享互斥锁:std::shared_mutex(C++17引入)

std::shared_mutex 支持两种访问模式:

  • 独占模式(写模式):同一时间只能有一个线程获取(类似std::mutex),用于修改共享资源。
  • 共享模式(读模式):多个线程可同时获取,用于读取共享资源。

核心方法

  • 独占模式:lock(), unlock(), try_lock()(同std::mutex)。
  • 共享模式:lock_shared(), unlock_shared(), try_lock_shared()

适用场景
"读多写少"的场景(如缓存、配置数据),多个读线程可并发访问,提高效率。

#include <shared_mutex>
#include <thread>
#include <vector>

std::shared_mutex smtx;
int shared_value = 0;

// 读操作(共享模式)
int read_value() {
    std::shared_lock<std::shared_mutex> lock(smtx);  // 获取共享锁
    return shared_value;
}

// 写操作(独占模式)
void write_value(int new_val) {
    std::unique_lock<std::shared_mutex> lock(smtx);  // 获取独占锁
    shared_value = new_val;
}

int main() {
    // 多个读线程可同时执行
    std::vector<std::thread> readers;
    for (int i = 0; i < 5; ++i) {
        readers.emplace_back(read_value);
    }
    // 写线程执行时,所有读线程需等待
    std::thread writer(write_value, 10);
    
    for (auto& t : readers) t.join();
    writer.join();
    return 0;
}

二、锁管理工具(RAII)

手动调用lock()unlock()容易出错(如异常导致解锁遗漏)。C++提供了RAII风格的锁管理工具,通过对象生命周期自动管理锁的获取与释放。

1. std::lock_guard(C++11)

std::lock_guard 是最简单的锁管理工具:

  • 构造时自动调用lock()获取锁。
  • 析构时自动调用unlock()释放锁(即使发生异常也会执行)。

特点:轻量、高效,但功能简单(不支持手动解锁、转移所有权等)。

#include <mutex>

std::mutex mtx;
int data = 0;

void safe_increment() {
    std::lock_guard<std::mutex> lock(mtx);  // 构造时锁定
    data++;                                 // 临界区
    // 析构时自动解锁(函数结束时)
}
2. std::unique_lock(C++11)

std::unique_lock 是更灵活的锁管理工具,支持:

  • 延迟锁定(构造时不立即获取锁)。
  • 手动解锁(unlock())和重新锁定(lock())。
  • 超时获取锁(配合timed_mutex)。
  • 所有权转移(通过std::move)。

代价:比std::lock_guard略重(有额外状态开销)。

#include <mutex>
#include <chrono>

std::timed_mutex tmtx;

void flexible_lock() {
    // 延迟锁定(不立即获取锁)
    std::unique_lock<std::timed_mutex> lock(tmtx, std::defer_lock);
    
    // 手动尝试超时获取锁
    if (lock.try_lock_for(std::chrono::milliseconds(50))) {
        // 成功获取锁
        lock.unlock();  // 手动解锁
        // ... 其他操作
        lock.lock();    // 重新锁定
    }
    // 析构时自动解锁(若当前持有锁)
}
3. std::shared_lock(C++17)

std::shared_lock 用于配合std::shared_mutex获取共享锁(读模式),同样是RAII风格:

  • 构造时调用lock_shared()
  • 析构时调用unlock_shared()
// 配合 std::shared_mutex 的读操作
int read_with_shared_lock() {
    std::shared_lock<std::shared_mutex> lock(smtx);  // 获取共享锁
    return shared_value;
}

三、死锁(Deadlock)与避免

死锁是多线程编程中最常见的问题:两个或多个线程互相等待对方释放锁,导致所有线程永久阻塞。

死锁的四个必要条件:
  1. 互斥:资源(锁)只能被一个线程持有。
  2. 持有并等待:线程持有一个锁,同时等待另一个锁。
  3. 不可剥夺:线程持有的锁不能被强制剥夺。
  4. 循环等待:线程A等待线程B的锁,线程B等待线程A的锁。
避免死锁的常用方法:
  1. 按固定顺序加锁
    所有线程获取多个锁时,严格按照相同的顺序(如按锁的地址排序),避免循环等待。

    std::mutex mtx1, mtx2;
    
    // 错误:顺序不一致可能导致死锁
    void thread1() { mtx1.lock(); mtx2.lock(); /* ... */ }
    void thread2() { mtx2.lock(); mtx1.lock(); /* ... */ }  // 危险!
    
    // 正确:固定顺序加锁
    void thread1() { mtx1.lock(); mtx2.lock(); /* ... */ }  // 先1后2
    void thread2() { mtx1.lock(); mtx2.lock(); /* ... */ }  // 先1后2
    
  2. 使用std::lock同时加锁多个锁
    std::lock是标准库提供的函数,可原子地获取多个锁(避免部分获取导致的死锁)。

    std::mutex mtx1, mtx2;
    
    void safe_lock_both() {
        // 原子地获取 mtx1 和 mtx2(无死锁风险)
        std::lock(mtx1, mtx2);
        // 用 std::adopt_lock 告诉 lock_guard 锁已获取,无需再次 lock()
        std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
        std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
        // 临界区
    }
    
  3. 使用超时锁
    try_lock_fortry_lock_until,若超时则释放已获取的锁并重试,避免无限等待。

  4. 减少锁的持有时间
    仅在必要的临界区持有锁,完成后立即释放,降低死锁概率。

四、锁与其他同步机制的配合

锁常与条件变量std::condition_variable)配合,实现线程间的通信(如"生产者-消费者"模型)。

std::condition_variable 用于线程等待某个条件成立,需配合std::unique_lock使用:

#include <mutex>
#include <condition_variable>
#include <queue>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;  // 共享队列(生产者放入,消费者取出)

// 生产者
void producer() {
    for (int i = 0; i < 10; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        q.push(i);
        cv.notify_one();  // 通知一个等待的消费者
    }
}

// 消费者
void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        // 等待队列非空(防止虚假唤醒,用循环判断条件)
        cv.wait(lock, []{ return !q.empty(); });
        
        int val = q.front();
        q.pop();
        lock.unlock();  // 提前解锁,减少阻塞时间
        
        // 处理数据
        if (val == 9) break;  // 结束标志
    }
}

五、锁 vs 原子操作(std::atomic

  • 原子操作:适用于简单的读写操作(如计数器++),由硬件直接支持,开销极小(无上下文切换)。
  • :适用于复杂的临界区(如多步操作、数据结构修改),开销较大(可能导致线程阻塞和上下文切换)。

选择原则:能用水原子操作解决的问题,就不用锁。

六、最佳实践

  1. 最小化临界区:只在必要时持有锁,避免锁的范围过大。
  2. 优先使用RAII工具:用lock_guard/unique_lock管理锁,避免手动解锁遗漏。
  3. 避免嵌套锁:嵌套锁容易导致死锁,若必须使用,严格保证顺序。
  4. 读多写少用shared_mutex:提高并发读的效率。
  5. 警惕锁的粒度:锁太粗(一个锁保护所有资源)会降低并发;锁太细(多个锁保护细分资源)会增加死锁风险,需平衡。

总结:C++的锁机制为多线程同步提供了灵活的工具,理解各种锁的特性和适用场景,结合RAII和死锁避免技巧,才能写出安全高效的并发代码。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值