C++ 锁

在多线程编程中,当多个线程同时访问共享资源时,可能会导致数据竞争(Data Race),产生不可预期的结果。锁提供了同步机制,确保在同一时间只有一个线程可以访问临界区。

锁的本质是通过互斥机制(Mutual Exclusion)确保:

  • 同一时间只有一个线程能进入访问共享资源的代码段(临界区);
  • 线程对共享资源的修改能被其他线程立即可见(避免 CPU 缓存导致的数据不一致)。

1、标准库锁类型

C++11 及后续标准在<mutex>头文件中提供了多种锁类型,满足不同场景需求。

1.1 std::mutex:基础互斥锁

std::mutex是最基础的互斥锁,提供独占所有权 —— 同一时间仅允许一个线程锁定,其他线程尝试锁定时会阻塞等待,直到锁被释放。

核心操作

  • lock():锁定互斥锁(若已被锁定,当前线程阻塞);
  • unlock():解锁互斥锁(必须由持有锁的线程调用,否则行为未定义);
  • try_lock():尝试锁定(成功返回true,失败返回false,不阻塞)。

基础用法(手动加锁 / 解锁)

#include <iostream>
#include <thread>
#include <mutex>

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

// 线程函数:对共享变量累加
void increment() {
    for (int i = 0; i < 10000; ++i) {
        mtx.lock();       // 手动加锁
        shared_value++;   // 临界区:安全修改共享资源
        mtx.unlock();     // 手动解锁(必须执行,否则死锁)
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "Final value: " << shared_value << std::endl; // 预期20000
    return 0;
}

手动调用lock()unlock()存在风险 —— 若临界区抛出异常,unlock()可能无法执行,导致锁永远被持有(死锁)。因此,实际开发中严禁手动管理锁的生命周期,应使用 RAII 封装。

1.2 std::lock_guard:RAII 自动管理锁(推荐)

std::lock_guardstd::mutex的 RAII(资源获取即初始化)封装类。其构造函数自动调用lock(),析构函数自动调用unlock(),确保锁在作用域结束时必然释放(即使发生异常)。

特点

  • 不可复制、不可移动(避免锁所有权被意外转移);
  • 生命周期与作用域严格绑定,简单高效。

lock_guard避免手动解锁

void safe_increment() {
    for (int i = 0; i < 10000; ++i) {
        // 构造时自动加锁,析构时(离开for循环作用域)自动解锁
        std::lock_guard<std::mutex> lock(mtx); 
        shared_value++; // 临界区安全执行
    }
}

// 主函数同上,最终输出20000(无死锁风险)

优势:即使shared_value++抛出异常(实际不会),lock_guard的析构函数仍会被调用,确保锁释放。

1.3 std::unique_lock:灵活的 RAII 锁

std::unique_locklock_guard更灵活,支持延迟锁定、手动解锁、所有权转移等操作。其内部维护一个 “是否持有锁” 的状态,因此有额外的性能开销(约几个字节的内存和状态判断)。

核心功能

  • 延迟锁定:构造时不立即加锁(需配合std::defer_lock);
  • 手动控制:可通过lock()unlock()手动加解锁;
  • 所有权转移:可通过std::move()转移锁的所有权(适合传递锁);
  • 尝试锁定:支持try_lock()和超时锁定(try_lock_for()try_lock_until())。

延迟锁定与手动控制

void flexible_operation() {
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 延迟锁定(不立即加锁)
    
    // 非临界区操作(无需锁)
    int temp = 100; 
    
    lock.lock(); // 手动加锁(进入临界区)
    shared_value += temp; 
    lock.unlock(); // 提前解锁(退出临界区,让其他线程尽早访问)
    
    // 其他非临界区操作
}

超时锁定(避免永久阻塞)

#include <chrono>

bool try_operation_with_timeout() {
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
    // 尝试锁定,最多等待100毫秒
    if (lock.try_lock_for(std::chrono::milliseconds(100))) {
        shared_value++;
        return true; // 锁定成功并执行操作
    } else {
        return false; // 超时未获取锁,执行备选逻辑
    }
}

1.4 std::recursive_mutex:递归锁(同一线程可重入)

std::mutex不允许同一线程重复锁定(会导致死锁),而std::recursive_mutex允许同一线程多次调用lock(),但需对应相同次数的unlock()才能完全释放(内部维护 “递归计数”)。

递归函数中需要重复锁定同一资源(如二叉树遍历中,递归访问节点时需锁定全局计数器)。

递归函数中的锁重入

#include <recursive_mutex>

std::recursive_mutex rmtx;
int count = 0;

// 递归函数:每次递归都需要锁定
void recursive_count(int depth) {
    if (depth <= 0) return;
    
    std::lock_guard<std::recursive_mutex> lock(rmtx); // 同一线程可多次锁定
    count++; 
    recursive_count(depth - 1); // 递归调用,再次锁定
}

int main() {
    recursive_count(5);
    std::cout << "Count: " << count << std::endl; // 输出5(正确累加)
    return 0;
}

注意:递归锁易掩盖设计缺陷(如过度依赖共享资源),非必要不使用(优先考虑拆分临界区)

1.5 std::shared_mutex(C++17):读写分离锁

std::shared_mutex(或 C++14 的std::shared_timed_mutex)支持两种锁定模式,适用于 “读多写少” 场景:

  • 共享锁(读锁):多个线程可同时获取,用于读取共享资源(不修改);
  • 独占锁(写锁):仅一个线程可获取,用于修改共享资源(此时所有读锁和写锁均阻塞)。

通过分离读写操作,提高读操作的并发性能(读线程间无需互斥)。

读写分离控制

#include <shared_mutex>
#include <vector>

std::shared_mutex smtx;
std::vector<int> data = {1, 2, 3}; // 共享数据

// 读操作:获取共享锁(允许多线程同时读)
int read_data(int index) {
    std::shared_lock<std::shared_mutex> lock(smtx); // 共享锁
    return data[index];
}

// 写操作:获取独占锁(仅允许单线程写)
void write_data(int index, int value) {
    std::unique_lock<std::shared_mutex> lock(smtx); // 独占锁
    data[index] = value;
}

int main() {
    // 多个读线程可并发执行read_data()
    std::thread t1([]{ std::cout << read_data(0) << std::endl; });
    std::thread t2([]{ std::cout << read_data(1) << std::endl; });
    
    // 写线程执行时,读线程需等待
    std::thread t3([]{ write_data(0, 100); });
    
    t1.join(); t2.join(); t3.join();
    return 0;
}

2、死锁

2.1 死锁的产生条件

死锁是指两个或多个线程相互等待对方释放锁,导致永久阻塞的状态,需同时满足:

  • 互斥:锁被线程独占;
  • 持有并等待:线程持有一个锁,同时等待另一个锁;
  • 不可剥夺:线程持有的锁不能被强制释放;
  • 循环等待:线程间形成等待环(如线程 1 等锁 B,线程 2 等锁 A)。

2.2 错误示范

std::mutex mtx_a, mtx_b;

// 线程1:先锁A,再等B
void thread1() {
    std::lock_guard<std::mutex> lock_a(mtx_a);
    // 模拟临界区操作(给线程2抢锁时间)
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    std::lock_guard<std::mutex> lock_b(mtx_b); // 等待线程2释放B → 死锁
}

// 线程2:先锁B,再等A
void thread2() {
    std::lock_guard<std::mutex> lock_b(mtx_b);
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    std::lock_guard<std::mutex> lock_a(mtx_a); // 等待线程1释放A → 死锁
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);
    t1.join(); // 程序永久阻塞(死锁)
    t2.join();
    return 0;
}

2.3 避免死锁的核心方法

2.3.1 按固定顺序加锁

所有线程按相同的全局顺序锁定多个锁(如始终先锁mtx_a,再锁mtx_b),打破 “循环等待” 条件。

// 线程1和线程2均按"先A后B"的顺序加锁
void thread1_fixed() {
    std::lock_guard<std::mutex> lock_a(mtx_a);
    std::lock_guard<std::mutex> lock_b(mtx_b); // 不会死锁
}

void thread2_fixed() {
    std::lock_guard<std::mutex> lock_a(mtx_a); // 先等A,再锁B
    std::lock_guard<std::mutex> lock_b(mtx_b); 
}
2.3.2 用std::lock原子锁定多个锁

std::lock原子地同时锁定多个互斥量(内部通过复杂逻辑避免死锁),适合无法固定顺序的场景。

void safe_lock_two() {
    // 原子锁定mtx_a和mtx_b(无顺序依赖)
    std::lock(mtx_a, mtx_b); 
    // 用adopt_lock标记锁已被锁定,避免重复加锁
    std::lock_guard<std::mutex> lock_a(mtx_a, std::adopt_lock);
    std::lock_guard<std::mutex> lock_b(mtx_b, std::adopt_lock);
    // 临界区操作
}
2.3.3 减少锁的持有时间

临界区仅包含必要操作,锁尽早释放(如用unique_lock手动解锁),降低死锁概率。

3、锁的选择与性能考量

锁类型 核心优势 性能开销 适用场景
std::mutex+lock_guard 简单安全,无额外开销 大多数场景,临界区简单且短
std::unique_lock 支持延迟锁定、超时、所有权转移 需要灵活控制锁生命周期的场景
std::recursive_mutex 允许同一线程重入 中高 递归函数需重复锁同一资源(谨慎使用)
std::shared_mutex 读写分离,提高读并发 读多写少,如缓存、配置数据访问

C++ 锁通过互斥机制解决多线程共享资源的竞态条件问题,其核心是确保临界区原子性。实际开发中:

  1. 优先使用 RAII 封装(lock_guard/unique_lock)避免手动管理锁的风险;
  2. 根据场景选择锁类型(如读多写少用shared_mutex);
  3. 通过固定加锁顺序、std::lock等方法严格避免死锁。
posted @ 2025-09-23 16:28  xclic  阅读(66)  评论(0)    收藏  举报