跳转至

C++ 锁

Abstract

锁是多线程编程中最常见的同步工具,用来保护共享数据,避免多个线程同时读写同一份资源时出现竞态条件。

  • 锁解决的是共享数据访问中的竞态条件
  • std::mutex 是最基础的互斥锁
  • 日常代码优先使用 RAII 工具,而不是手写 lock() / unlock()
  • 读多写少可以考虑 std::shared_mutex
  • 多锁场景优先考虑 std::scoped_lock
  • 条件变量负责等待条件成立,通常和 std::unique_lock 搭配使用

为什么需要锁

锁要解决的核心问题很直接:多个线程并发访问共享数据时,如果没有同步机制,程序结果就可能不正确,甚至直接进入未定义行为。

典型风险包括:

  • 多线程同时访问共享数据
  • 竞态条件(race condition)
  • 数据不一致
  • 未定义行为

一个例子,展示了为什么需要锁

  • 当不加锁时++counter 不是一个原子操作,它通常会拆成:

    graph LR
        A[读取 counter] --> B[执行加一]
        B --> C[写回结果]
    如果两个线程交错执行,就会导致结果不稳定。

  • 加锁后++counter 虽然本身仍然不是原子操作,但它会在互斥保护下执行,因此不会发生数据竞争,结果稳定为 200000

  • 未加锁: 结果不稳定


    #include <iostream>
    #include <thread>
    
    int counter = 0;
    
    void work() {
        for (int i = 0; i < 100000; ++i) {
            ++counter;
        }
    }
    
    int main() {
        std::thread t1(work);
        std::thread t2(work);
    
        t1.join();
        t2.join();
    
        std::cout << counter << '\n';
    }
    

    如果两个线程交错执行,就会发生“丢失更新”

  • 加锁后: 结果稳定为 200000


    #include <iostream>
    #include <mutex>
    #include <thread>
    
    std::mutex mtx;
    int counter = 0;
    
    void work() {
        for (int i = 0; i < 100000; ++i) {
            std::lock_guard<std::mutex> lock(mtx);
            ++counter;
        }
    }
    
    int main() {
        std::thread t1(work);
        std::thread t2(work);
    
        t1.join();
        t2.join();
    
        std::cout << counter << '\n';
    }
    

基本概念

  • 临界区(critical section): 访问共享资源、且必须被互斥保护的代码区域。

  • 互斥(mutual exclusion): 同一时刻只允许一个线程进入临界区。

  • 同步(synchronization): 协调多个线程执行顺序和数据可见性的机制。锁是同步手段的一种,条件变量、原子变量也属于同步工具。
  • 竞争(contention): 多个线程同时争抢同一把锁或同一资源,导致等待和性能下降。
  • 死锁(deadlock): 两个或多个线程互相等待对方释放资源,导致谁都无法继续执行。
  • 活锁(livelock): 线程没有阻塞,看起来一直在运行,但因为反复让步或重试,始终无法推进实际工作。
  • 饥饿(starvation): 某些线程长期拿不到资源,一直得不到执行机会。
  • 可重入 / 不可重入: 这里通常说的是“同一线程能否重复获取同一把锁”。

    • 可重入: 同一线程可以多次加锁,例如 std::recursive_mutex
    • 不可重入: 同一线程重复加锁会出错或死锁,例如普通 std::mutex
  • 阻塞锁 / 自旋锁:

    • 阻塞锁: 拿不到锁时线程会挂起,等待调度器唤醒
    • 自旋锁: 拿不到锁时线程持续循环重试,不主动睡眠

Note

C++ 标准库主要提供阻塞式互斥量;自旋锁更偏底层实现,通常用于锁持有时间极短的场景。

常见的锁类型


std::mutex

最基础的互斥锁,适合保护共享资源。常见接口:

  • lock(): 加锁
  • unlock(): 解锁
  • try_lock(): 尝试加锁,失败时立即返回

最简单示例

#include <mutex>

std::mutex mtx;
int value = 0;

void update() {
    mtx.lock();
    ++value;
    mtx.unlock();
}

注意事项

  • 逻辑上能工作,但不推荐日常手写 lock() / unlock()
  • 一旦中间有异常、提前 return 或分支跳转,就可能忘记 unlock()
  • 实际开发更推荐 std::lock_guardstd::unique_lock

std::recursive_mutex

同一线程可以对同一把锁重复加锁,多次加锁后也需要对应次数地解锁。

示例场景

#include <mutex>

std::recursive_mutex mtx;

void inner() {
    std::lock_guard<std::recursive_mutex> lock(mtx);
}

void outer() {
    std::lock_guard<std::recursive_mutex> lock(mtx);
    inner();
}

如果这里换成普通 std::mutexouter() 持锁后调用 inner(),同一线程再次加锁会出问题。

为什么一般不推荐滥用

  • 它容易掩盖调用层次设计不清的问题
  • 看到“能重入”不代表逻辑一定正确
  • 通常更好的做法是拆分职责,避免嵌套加锁

std::timed_mutex

支持带超时的加锁,适合不希望无限阻塞的场景。

常见接口:

  • try_lock_for(): 尝试加锁,失败时等待指定时间
  • try_lock_until(): 尝试加锁,失败时等待直到指定时间点

示例

#include <chrono>
#include <mutex>

std::timed_mutex mtx;
int value = 0;

void work() {
    if (mtx.try_lock_for(std::chrono::milliseconds(100))) {
        ++value;
        mtx.unlock();
    } else {
        // 超时后走降级逻辑
    }
}

适合场景

  • 避免线程长期卡死在等待锁上
  • 某些超时后允许降级或放弃的场景

std::recursive_timed_mutex

它是 recursive_mutextimed_mutex 的组合版本,支持同一线程重复加锁,也支持超时等待。实际开发中不常见,了解即可。


std::shared_mutex

std::shared_mutex 是 C++17 引入的读写锁,适合“读多写少”的场景。

特点

  • 多个线程可以同时持有读锁
  • 写锁必须独占
  • 写入时不能有读者或其他写者

适用场景

  • 配置读取
  • 缓存查询
  • 索引结构访问

示例

#include <mutex>
#include <shared_mutex>
#include <string>

std::shared_mutex rw_mtx;
std::string config = "v1";

void read_config() {
    std::shared_lock lock(rw_mtx);
    // 多个读线程可以同时进入
}

void write_config() {
    std::unique_lock lock(rw_mtx);
    config = "v2";
}

std::shared_timed_mutex

它和 shared_mutex 类似,但支持定时等待。在一些较旧标准或兼容性场景里可能见到,现代代码里通常优先考虑 std::shared_mutex

类型 标准版本 是否支持定时加锁接口
std::shared_timed_mutex C++14 ✅ 支持
std::shared_mutex C++17 ❌ 不支持

RAII 风格的加锁工具

实际开发里,直接操作 lock() / unlock() 容易出错,更推荐 RAII(Resource Acquisition Is Initialization) 风格的加锁工具。


std::lock_guard

最轻量、最常见。

std::lock_guard 的特点

  • 构造时加锁
  • 析构时解锁
  • 不能手动解锁
  • 最适合简单作用域保护

示例

#include <mutex>

std::mutex mtx;
int counter = 0;

void work() {
    std::lock_guard<std::mutex> lock(mtx);
    ++counter;
}

std::unique_lock

lock_guard 更灵活,但也稍重一些。

std::unique_lock 的特点

  • 可以延迟加锁
  • 可以手动 unlock() / lock()
  • 可以转移所有权
  • 可以和条件变量配合使用

示例

#include <mutex>

std::mutex mtx;

void work() {
    std::unique_lock<std::mutex> lock(mtx);

    // 临界区逻辑

    lock.unlock();

    // 后续逻辑不再持锁
}

std::scoped_lock

std::scoped_lock 是 C++17 提供的工具,适合同时锁住多个互斥量。

std::scoped_lock 的特点

  • 一次锁多个 mutex
  • 内部使用死锁规避策略
  • 适合替代手写多重加锁

示例

#include <mutex>

std::mutex m1;
std::mutex m2;

void work() {
    std::scoped_lock lock(m1, m2);
    // 同时安全持有两把锁
}

std::shared_lock

std::shared_lock 一般和 std::shared_mutex 配合使用,表示共享读锁

示例

#include <shared_mutex>

std::shared_mutex rw_mtx;
int data = 42;

int read_data() {
    std::shared_lock lock(rw_mtx);
    return data;
}

锁的典型用法

保护单个共享变量

这是最基础的用法,适合计数器、状态位、共享标志等简单共享数据。

示例

#include <mutex>

std::mutex mtx;
int counter = 0;

void increment() {
    std::lock_guard<std::mutex> lock(mtx);
    ++counter;
}

保护一个容器

标准容器本身通常不是线程安全的,如果多个线程会读写同一个容器,就需要同步保护。

示例

#include <mutex>
#include <vector>

std::mutex mtx;
std::vector<int> values;

void add_value(int x) {
    std::lock_guard<std::mutex> lock(mtx);
    values.push_back(x);
}

读多写少场景

使用 shared_mutex 可以实现读多写少的场景。

示例

#include <mutex>
#include <shared_mutex>
#include <string>

std::shared_mutex rw_mtx;
std::string cache = "hello";

std::string read_cache() {
    std::shared_lock lock(rw_mtx);
    return cache;
}

void update_cache(const std::string& s) {
    std::unique_lock lock(rw_mtx);
    cache = s;
}

多个锁一起加锁

如果必须同时操作两份共享资源,应尽量一次性加锁,避免锁顺序不一致导致死锁。

#include <mutex>

std::mutex account_mtx1;
std::mutex account_mtx2;
int a = 100;
int b = 200;

void transfer(int amount) {
    std::scoped_lock lock(account_mtx1, account_mtx2);
    a -= amount;
    b += amount;
}

非阻塞尝试

通常 try_lock() 适合“拿不到锁就先做别的”“立即失败也可以接受”的场景。

示例

#include <mutex>

std::mutex mtx;
int value = 0;

void maybe_update() {
    if (mtx.try_lock()) {
        ++value;
        mtx.unlock();
    } else {
        // 这次放弃更新,或者稍后重试
    }
}

条件变量配合

条件变量等待时会临时释放锁,唤醒后再重新获取锁,因此它需要和 std::unique_lock 配合。

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

std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;

void producer() {
    {
        std::lock_guard<std::mutex> lock(mtx);
        q.push(42);
    }
    cv.notify_one();
}

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return !q.empty(); });

    int value = q.front();
    q.pop();
}

条件变量和锁的关系

锁负责“互斥访问共享数据”,条件变量负责“等待某个条件成立”,这两者通常一起使用。


std::condition_variable

std::condition_variable 允许线程在条件不满足时进入等待状态,避免一直忙等浪费 CPU。


wait 配合 std::unique_lock

为什么 wait 要配合 std::unique_lock

因为 wait() 的内部行为不是单纯睡眠,而是:

  1. 检查条件
  2. 释放互斥锁
  3. 挂起当前线程
  4. 被唤醒后重新获取锁
  5. 再次检查条件

这个过程要求锁对象支持“解锁后再重新加锁”,因此使用 std::unique_lock,而不是 std::lock_guard


wait 的正确写法

必须使用谓词,防止虚假唤醒(spurious wakeup)。

错误示例

cv.wait(lock);

正确写法

bool ready = false;

cv.wait(lock, [] { return ready; });

或者显式写成循环:

bool ready = false;

while (!ready) {
    cv.wait(lock);
}

notify_one / notify_all

  • notify_one():唤醒一个等待线程
  • notify_all():唤醒所有等待线程

选择原则:

  • 只有一个线程继续执行就够了,用 notify_one()
  • 多个线程都需要重新竞争条件,用 notify_all()

生产者-消费者示例

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
bool done = false;

void producer() {
    for (int i = 1; i <= 5; ++i) {
        {
            std::lock_guard<std::mutex> lock(mtx);
            q.push(i);
        }
        cv.notify_one();
    }

    {
        std::lock_guard<std::mutex> lock(mtx);
        done = true;
    }
    cv.notify_all();
}

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);

    while (!done || !q.empty()) {
        cv.wait(lock, [] { return done || !q.empty(); });

        while (!q.empty()) {
            int value = q.front();
            q.pop();
            std::cout << value << '\n';
        }
    }
}