多线程编程之——锁

一、前言:

在多线程编程中,是一种核心机制,用于防止多个线程同时访问临界资源(共享数据)而引起的数据竞争问题。根据其底层实现和适用场景,锁机制也分为多种类型。

二、自旋锁(Spin Lock):

自旋锁的实现依赖于底层的原子指令(如 CAS, Compare-and-Swap)。当一个线程尝试获取锁时:

  • 它会持续循环(即“自旋”),通过原子操作不断检查锁是否可用。

  • 一旦检查到锁已释放(获取到锁),线程立即退出循环,进入临界区执行操作。

这样做的优点是:

  • 简单直接,开销小。避免了操作系统切换上下文的开销。

缺点也很明显:

  • CPU 空转。若长时间无法获得锁,线程会一直空转,浪费 CPU 资源

  • 效率限制。临界区操作必须是“快动作”(执行时间极短),否则会严重降低其他线程的效率。

  • 在线程获取到自旋锁的时候在临界区内禁止切换线程,否则会导致死锁或者效率问题

由于用户空间无法可靠地控制线程调度,自旋锁主要用于操作系统内核中,对内核临界数据的加锁。内核可以控制关闭中断和抢占,以避免潜在的死锁或效率问题。

三、互斥锁(Mutex,Mutual Exclusion Lock):

为了解决自旋锁导致的 CPU 空转问题,互斥锁被引入。互斥锁的核心思想是:

  • 如果锁被其他线程占用,尝试获取锁的线程将通过系统调用被置为阻塞 (Blocked) 状态。

  • 阻塞的线程会让出 CPU,不再消耗 CPU 资源。

Futex (Fast Userspace Mutexes):

Futex 机制(Fast Userspace Mutexes)是 Linux 中实现高性能互斥锁的关键。其目标是在保持线程安全的前提下,尽可能避免进入开销很大的内核模式(系统调用)。因此,Futex 采用了一种混合策略

  • 快路径-用户空间操作

    • 线程首先假设锁是空闲的,通过原子指令去修改占用锁(通常是将某个值设置为1)

    • 如果修改成功,线程获得锁,无需进入内核

  • 慢路径-先用户空间,再内核态

    • 短暂自旋:在一个比较短的时间内,不断读取锁的状态,检查锁是否被释放。这段自旋时间是精心选择的。如果一个线程自旋等待的时间小于一次完整的系统调用上下文切换所消耗的时间,那么自旋就是值得的。

    • 系统调用-阻塞:线程执行 Futex 的 wait() 系统调用。

      • 操作系统内核收到请求将线程状态从运行状态切换到阻塞状态

      • 内核将该线程放入与该 Mutex 关联的等待队列 (Wait Queue)

      • 上下文切换: 内核执行上下文切换,将 CPU 分配给其他就绪的线程,此时,该线程不再消耗 CPU 资源

释放锁与唤醒

当持有锁的线程退出临界区,释放锁时:

  • 用户空间解锁: 持有线程先在用户空间原子性地将 Mutex 内存字的值设置为未占用(例如 0)

  • 系统调用: 如果线程发现等待队列中有阻塞的线程,它会执行 Futex 的 wake() 系统调用

  • 内核唤醒: 内核将等待队列中的一个或多个线程状态从阻塞切换回就绪 (Ready),并将它们放入调度器的运行队列中

  • 被唤醒的线程重新竞争 CPU,并在获得 CPU 后,从 Futex 的 wait() 调用返回,重新尝试获取锁

四、条件变量

互斥锁解决了资源访问冲突和 CPU 空转问题。然而,如果临界区需要等待额外的条件才能执行(如缓冲区非空、任务队列有新任务等),仅使用互斥锁仍然效率低下。

线程就会不断地进入 “加锁-检查条件-解锁-等待一小段时间“,这样的循环中,仍然是导致 CPU 空转的浪费。

条件变量的引入就是专门用来解决这种“等待条件”的线程间协作问题:

  • 主动休眠: 线程在持有互斥锁并发现条件不满足时,会调用条件变量的 wait() 方法。

  • wait()自动释放关联的互斥锁,并使线程进入阻塞等待状态,不消耗 CPU。

  • 信号唤醒: 直到其他线程使用 notify_one()notify_all() 信号唤醒它。

  • 重新获取: 被唤醒的线程会被移到就绪队列,并在获得 CPU 后,自动重新获得互斥锁,然后检查条件。

关键细节:虚假唤醒 (Spurious Wakeup) 的处理

虚假唤醒是指条件变量在没有收到明确的 notify 信号的情况下被唤醒。这是一个 POSIX/C++ 标准允许的现象,因此线程被唤醒后,必须重新检查条件是否满足。

使用 if 语句处理虚假唤醒的问题

// 消费者线程 C1 (使用 IF 语句)
void consumer_if() {
    std::unique_lock<std::mutex> lock(mtx);
    
    // 检查条件 (1)
    if (shared_queue.empty()) { // 假设 shared_queue 为空,条件为 TRUE
        cv.wait(lock); // (2) 线程在此处阻塞,释放锁
    }
    
    // 线程 C1 在此行代码内部被唤醒并重新获得锁 (3)
    
    // 检查条件 (4)
    if (!shared_queue.empty()) { // 检查队列是否非空
        int data = shared_queue.front(); 
        shared_queue.pop();
        std::cout << "C1 consumed: " << data << std::endl;
    } else {
        std::cout << "C1 (Error) woke up but queue is empty." << std::endl;
    }
}

假设在步骤 (2) 线程阻塞后,发生了虚假唤醒

  1. 线程 C1 被内核唤醒,并重新获得锁。

  2. cv.wait(lock) 返回,程序流程继续执行到 if (!shared_queue.empty()) 这一行(步骤 4)。

  3. 由于是虚假唤醒,生产者还没有来得及放入数据,所以 shared_queue 仍然是空的。

  4. 检查 (4):!shared_queue.empty() 返回 false

  5. 程序进入 else 块,输出 "C1 (Error) woke up but queue is empty."

结果: 在虚假唤醒的情况下,这段代码虽然没有崩溃,但进入了 else 块,输出了一个错误信息,然后函数结束了线程 C1 并没有重新等待

关键的问题在于: 线程 C1 本来应该继续等待数据,但它被虚假唤醒后,发现队列是空的,就直接退出了(或者函数执行完毕)。它错过了后续生产者真正放入数据时的通知,导致程序逻辑上的死锁(或活锁)—— C1 应该处理的数据永远没人处理了。

使用 while 语句处理虚假唤醒

// 消费者线程 C1 (使用 WHILE 循环)
void consumer_while() {
    std::unique_lock<std::mutex> lock(mtx);
    
    // 检查条件 (第一次:队列为空,满足)
    while (shared_queue.empty()) { // 这里的条件是等待的条件
        cv.wait(lock); // 线程阻塞,释放锁
    }
    
    // 线程 C1 唤醒,重新获得锁。
    // WHILE 循环强制再次检查 shared_queue.empty()!
    
    // 只有当 shared_queue.empty() == false 时,才会退出循环
    int data = shared_queue.front(); 
    shared_queue.pop();
    std::cout << "C1 consumed: " << data << std::endl;
}

假如线程被虚假唤醒:

  1. C1 进入 while 内部,调用 cv.wait(lock),线程阻塞。

  2. C1 被虚假唤醒,重新获得锁。

  3. C1 强制执行 while (shared_queue.empty()) 检查

  4. 发现 shared_queue 仍为空,C1 再次进入循环,调用 cv.wait(lock),重新阻塞。

  5. 结果: 虚假唤醒被安全地处理了,线程没有执行任何错误操作,只是重新等待。

标准库的简化: 在 C++ 标准库中,std::condition_variable::wait 有一个重载版本接受一个谓词(Lambda 表达式或函数对象),它内部实现了 while 循环的检查逻辑,推荐使用。

cv.wait(lock, []{ return !shared_queue.empty(); });

虚假唤醒的原因

  1. 条件变量的唤醒是通过信号来实现的,假如条件变量 wait() 调用系统调用准备进入阻塞状态的间隙,错过了唤醒信号,那么线程就永远无法被唤醒。操作系统需要机制来避免这种情况

  2. 粗粒度唤醒,当内核需要唤醒一个或多个线程时,为了效率,它可能会采取粗粒度操作,一次性唤醒比要求数量更多的线程,或唤醒同一等待队列附近的无关线程

五、互斥锁的阻塞和条件变量的阻塞

  • 发生时机:

    • 互斥锁:是竞争锁失败导致的阻塞

    • 条件变量:是主动休眠,锁获取成功,但业务条件不满足时

  • 核心目的:

    • 互斥锁:解决资源访问冲突(谁能进去)

    • 条件变量:解决线程间协作(何时可以进去)

  • 唤醒:

    • 互斥锁:精确控制所有权转移。内核将一个等待线程状态改为就绪。

    • 条件变量:条件变量的唤醒是信号传递,一个或多个等待线程被唤醒去重新检查条件

六、C++ 标准库提供的各种锁:

std::mutex

  • 非 RAII 锁管理

  • 最基础的排他互斥,保护共享资源,防止数据竞争

  • 保护临界区,确保同一时间只有一个线程访问共享数据(如修改全局变量、写入日志等)。

  • lock() (加锁), unlock() (解锁)。

  • 直接使用 lock() 和 unlock() 是危险的,因为如果在 lock() 和 unlock() 之间发生异常或函数提前 return,unlock() 可能不会被调用,从而导致死锁 (Deadlock)

  • 不推荐直接使用,应配合 RAII 锁管理器。

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

std::mutex mtx_basic;
int counter_m = 0;

void increment_mutex() {
    mtx_basic.lock(); // 手动加锁
    try {
        counter_m++;
        // 临界区操作...
    } catch (...) {
        mtx_basic.unlock(); // 异常时确保解锁
        throw;
    }
    mtx_basic.unlock(); // 必须手动解锁
}

std::lock_guard(c++11)

  • RAII 锁管理

  • 引入了RAII 机制,解决直接调用 lock()/unlock() 带来的死锁和异常安全问题

  • 最常用、最简单的作用域锁。用于保护简单、固定的临界区。

  • RAII 机制:构造时锁定 mutex,析构时自动释放 mutex。

  • 不可移动、不可复制

#include <iostream>
#include <mutex>

std::mutex mtx_guard;
int counter_lg = 0;

void increment_lock_guard() {
    // 构造时加锁,离开作用域时自动解锁
    std::lock_guard<std::mutex> lock(mtx_guard);
    
    counter_lg++;
    // 临界区代码...
}

std::unique_lock (c++11)

  • RAII 锁管理

  • 提供比 lock_guard 更灵活的锁控制和支持条件变量

  • 需要灵活控制锁的生命周期(如手动 lock()/unlock())。

  • 配合 std::condition_variable 使用

  • 灵活的 RAII 机制:支持延迟加锁 (std::defer_lock)、所有权移动、支持手动 lock()/unlock()

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

std::mutex mtx_unique;
std::condition_variable cv_unique;
bool ready = false;

void consumer_unique() {
    // 构造时加锁
    std::unique_lock<std::mutex> lock(mtx_unique); 

    // cv.wait 需要 unique_lock,且它会自动释放锁并阻塞
    cv_unique.wait(lock, []{ return ready; }); 
    
    // 线程被唤醒后自动重新获得锁
    std::cout << "Data consumed." << std::endl;
}

void producer_unique() {
    std::unique_lock<std::mutex> lock(mtx_unique);
    // 生产数据...
    ready = true;
    lock.unlock(); // 手动提前解锁(可选)
    cv_unique.notify_one(); // 发出信号
}

std::shared_mutex (c++14)

  • 非 RAII 锁管理

  • 解决读多写少场景下,std::mutex 过于严格(读操作也不能并发)导致的性能瓶颈

  • 实现读写锁,允许多个线程同时读取数据,但在写入时独占

  • lock() (获取独占锁/写锁), unlock() (释放独占锁)

  • lock_shared() (获取共享锁/读锁), unlock_shared() (释放共享锁)

#include <iostream>
#include <shared_mutex>
#include <thread>

std::shared_mutex rw_mtx;
int shared_resource = 100;

void reader() {
    // 获取共享锁(读锁)
    std::shared_lock<std::shared_mutex> lock(rw_mtx); 
    
    // 多个读线程可以同时执行这段代码
    std::cout << "Reading: " << shared_resource << std::endl;
}

void writer(int new_val) {
    // 获取独占锁(写锁)
    // std::unique_lock 可以用于 shared_mutex 的独占模式
    std::unique_lock<std::shared_mutex> lock(rw_mtx); 
    
    // 只有当前线程可以执行这段代码
    shared_resource = new_val;
    std::cout << "Wrote: " << new_val << std::endl;
}

std::shared_lock (c++14)

  • RAII 锁管理

  • 配合 std::shared_mutex,以 RAII 方式获取共享锁

  • 当执行只读操作时,使用它来获取共享锁,允许与其他读线程并行。

  • RAII 机制:构造时调用 lock_shared(),析构时调用 unlock_shared()

  • 支持延迟加锁、所有权移动等。

std::scoped_lock (c++17)

  • RAII 锁管理

  • 安全地同时锁定多个互斥量,解决多锁场景下的死锁问题

  • 需要同时访问和修改多个共享资源,并且每个资源都有自己的互斥量

  • 死锁避免算法:构造时自动尝试锁定所有传入的互斥量,并保证无死锁。支持传入一个或多个 mutex

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

std::mutex mtx_a;
std::mutex mtx_b;
int resource_a = 10;
int resource_b = 20;

void swap_resources() {
    // 自动以无死锁的方式同时锁定 mtx_a 和 mtx_b
    std::scoped_lock lock(mtx_a, mtx_b); 
    
    // 临界区:操作多个资源
    int temp = resource_a;
    resource_a = resource_b;
    resource_b = temp;
    
    std::cout << "Resources swapped." << std::endl;
    // 离开作用域,自动释放 mtx_a 和 mtx_b
}
posted @ 2026-01-06 17:48  只取一瓢饮  阅读(9)  评论(0)    收藏  举报