6.2、std::mutex/std::lock_guard(互斥锁)

初识std::mutex:多线程的“门锁”

如果,你家有一台打印机,家里几个人同时想用它打印东西。如果大家一起抢着用,打印内容可能会乱成一团。这时候,你需要一把锁,只有拿到锁的人才能用打印机,其他人得排队等着。这就是std::mutex(互斥锁)的核心作用——在多线程程序中,确保共享资源(如内存中的数据)在同一时刻只被一个线程访问。

在C++11中,std::mutex是一个标准库类,提供了lock()unlock()方法,分别用来“上锁”和“开锁”。当一个线程调用lock()获取锁成功后,其他线程再尝试lock()就会被阻塞,直到锁被释放。这就像是只有一个人能拿到打印机的钥匙,其他人只能干等着。

std::lock_guard:自动化的“锁管理员”

不过,手动调用lock()unlock()有个大坑:如果代码中途抛出异常,或者程序员一不小心忘了调用unlock(),锁就永远不会释放,其他线程就得无限等待,程序直接“卡死”。为了解决这个问题,C++11引入了std::lock_guard,这是一个基于RAII(资源获取即初始化)理念的辅助类。它的原理很简单:构造时自动上锁,析构时自动开锁。这样,不管代码是否异常退出,锁都会被妥善释放。

举个例子,下面这段代码用std::lock_guard保护了一段输出操作:

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

std::mutex mtx;

void print_message(const std::string& msg) {
    std::lock_guard<std::mutex> lock(mtx)// 构造时自动上锁
    std::cout << msg << std::endl;
} // 析构时自动开锁

int main() {
    std::thread t1(print_message, "线程1:你好!");
    std::thread t2(print_message, "线程2:你也好!");
    t1.join();
    t2.join();
    return 0;
}

这段代码中,std::lock_guard确保了std::cout不会被多个线程同时使用,避免输出混乱。关键在于,lock对象的作用域结束时(函数返回或异常抛出),它会自动调用mtx.unlock(),完全不用你操心。

深度案例:线程安全的任务调度器

为了让你更深刻地理解std::mutexstd::lock_guard,我设计了一个线程安全的任务调度器案例。这个调度器允许多个线程添加任务和执行任务,同时保证任务列表不会被并发访问搞乱。代码如下:

#include <iostream>
#include <thread>
#include <mutex>
#include <queue>
#include <string>
#include <chrono>

class TaskScheduler {
public:
    // 添加任务到队列
    void addTask(const std::string& task) {
        std::lock_guard<std::mutex> lock(mtx_)// 保护队列操作
        tasks_.push(task);
        std::cout << "添加任务:" << task << std::endl;
    }

    // 从队列中获取并执行任务
    bool executeTask() {
        std::string task;
        {
            std::lock_guard<std::mutex> lock(mtx_)// 保护队列操作
            if (tasks_.empty()) {
                return false// 队列为空,返回false
            }
            task = tasks_.front();
            tasks_.pop();
        }
        // 模拟任务执行
        std::cout << "执行任务:" << task << " (线程ID: " << std::this_thread::get_id() << ")" << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        return true;
    }

private:
    std::mutex mtx_; // 互斥锁,保护任务队列
    std::queue<std::string> tasks_; // 任务队列
};

void producer(TaskScheduler& scheduler) {
    for (int i = 0; i < 5; ++i) {
        scheduler.addTask("任务" + std::to_string(i));
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }
}

void consumer(TaskScheduler& scheduler) {
    for (int i = 0; i < 5; ++i) {
        while (!scheduler.executeTask()) {
            std::this_thread::sleep_for(std::chrono::milliseconds(10));
        }
    }
}

int main() {
    TaskScheduler scheduler;
    std::thread prod(producer, std::ref(scheduler));
    std::thread cons1(consumer, std::ref(scheduler));
    std::thread cons2(consumer, std::ref(scheduler));

    prod.join();
    cons1.join();
    cons2.join();
    return 0;
}

案例解析与底层细节

这个任务调度器模拟了一个生产者 - 消费者模型:一个线程负责添加任务(生产者),两个线程负责执行任务(消费者)。std::mutexstd::lock_guard在这里起到了关键作用。

  • • 锁的保护范围:在addTaskexecuteTask方法中,std::lock_guard保护了对tasks_队列的操作。每次只有一个线程能进入被锁保护的代码块,其他线程必须等待。这确保了队列的pushpop操作不会同时发生,避免数据竞争。
  • • 锁的生命周期:注意executeTask方法中,获取任务和执行任务是分开的。锁只在访问队列时持有,执行任务时已经释放锁。这是一个重要的设计理念——锁的持有时间要尽量短,避免不必要的阻塞,提高并发效率。
  • • 底层机制std::lock_guard在构造时调用mtx_.lock(),如果锁已被其他线程持有,当前线程会被操作系统挂起(进入等待队列),直到锁被释放。析构时,std::lock_guard调用mtx_.unlock(),通知操作系统唤醒等待队列中的线程。这背后依赖于操作系统的线程调度和同步原语(如Windows的Critical Section或POSIX的pthread_mutex)。

这个案例的独到之处在于,它不仅展示了std::mutexstd::lock_guard的基本用法,还强调了锁粒度的控制——一个在实际开发中容易被忽视但至关重要的点。过大的锁范围会降低程序性能,而过小的锁范围可能导致数据竞争,找到平衡是多线程编程的艺术。

常见错误使用:别踩这些坑

在实际开发中,std::mutexstd::lock_guard的使用看似简单,但隐藏了不少陷阱。以下是我总结的一些常见错误:

  • • 忘记释放锁:如果你直接用std::mutexlock()unlock(),一旦忘记调用unlock()(比如异常抛出),其他线程将永远无法获取锁。解决办法就是始终使用std::lock_guard,让RAII帮你管理锁。
  • • 重复获取锁:同一个线程试图多次获取同一个std::mutex会导致未定义行为(通常是死锁)。std::mutex不支持递归锁,如果需要这种功能,可以用std::recursive_mutex,但更推荐重新设计代码,避免递归锁需求。
  • • 死锁风险:当多个线程以不同顺序获取多个锁时,可能出现死锁。例如,线程A持有锁1等待锁2,线程B持有锁2等待锁1,双方都无法前进。解决方法是统一锁的获取顺序,或者使用std::lock()函数一次性获取多个锁,避免中间状态。
  • • 锁范围过大:锁保护的代码块如果包含耗时操作(如I/O或复杂计算),会严重影响并发性能。应该尽量缩小锁范围,只保护真正需要同步的部分。

面试热点:你可能会被问到什么

在C++面试中,std::mutexstd::lock_guard是多线程编程的常考点。以下是一些高频问题,供你准备:

  • • std::lock_guardstd::unique_lock的区别是什么?前者简单,只能构造时上锁、析构时开锁;后者更灵活,支持延迟锁、定时锁和手动解锁,适合复杂场景。
  • • 如何避免死锁?统一锁获取顺序、使用std::lock()一次性获取多个锁、尽量减少锁嵌套。
  • • std::mutex的底层实现原理是什么?依赖操作系统提供的同步原语,如Windows的Critical Section或Linux的futex,涉及线程挂起和唤醒机制。
  • • 为什么不直接用std::mutex而要用std::lock_guard?后者基于RAII,自动管理锁生命周期,避免手动管理导致的死锁风险。

总结

std::mutexstd::lock_guard是C++11为多线程编程引入的强大工具。std::mutex提供了互斥访问的原始机制,而std::lock_guard通过RAII理念简化了锁管理,避免了手动操作的常见错误。通过任务调度器的案例,我们不仅看到了它们的基本用法,还深入理解了锁粒度控制的重要性。避开常见误区,掌握面试要点,你就能在多线程编程中游刃有余。希望这篇文章能成为你学习C++11并发特性的起点,未来在实践中不断精进。
本文首发于【讳疾忌医-note】公众号,未经授权,不得转载。
(加入我的知识星球,免费获取账号,解锁所有文章。)

 

阅读剩余
THE END