C++ mutex 互斥锁

当多个线程同时读写同一个普通变量时,很容易因为执行步骤互相交错而引发 “数据竞争”,导致算出来的结果出错。为了解决这个问题,C++ 在 <mutex> 头文件中提供了 std::mutex 互斥锁(也叫互斥量),其中 mutex 是 “mutual exclusion(互斥)” 的缩写。

使用 std::mutex 就相当于给共享房间的门装上了一把锁:同一时刻只允许一个线程拿到锁进入房间修改数据,其他线程必须在门外排队等待,直到里面的线程改完数据并把锁交出来为止,如下图所示。

用房间门锁理解 std::mutex 的互斥访问机制

C++ mutex 基本用法

在 C++ 中使用 std::mutex 时,我们需要先定义一个供多个线程共用的互斥锁对象。每次访问共享数据之前先调用 lock() 加锁,访问结束之后立刻调用 unlock() 解锁。

语法:

#include <mutex>

std::mutex 互斥锁名;

互斥锁名.lock();
// 操作共享数据
互斥锁名.unlock();

说明:

被 lock() 和 unlock() 包裹起来、同一时刻只允许一个线程执行的那段代码,通常称为 “临界区(critical section)”。这两个成员函数的作用如下:

  • lock():尝试获取这把锁。如果此时锁是空闲的,当前线程就会拿到锁并继续往下执行。如果锁已经被其他线程拿走了,当前线程就会停在 lock() 这一行排队等待。
  • unlock():操作完共享数据后释放这把锁,让门外正在排队等待的其他线程有机会拿到锁。

示例 1:保护共享计数器

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

int counter = 0;
std::mutex mtx;

void addCount() {
    for (int i = 0; i < 100000; i++) {
        mtx.lock();      // 访问前加锁
        counter++;
        mtx.unlock();    // 访问后解锁
    }
}

int main() {
    std::thread t1(addCount);
    std::thread t2(addCount);

    t1.join();
    t2.join();

    std::cout << "最终计数:" << counter << "\n";
    return 0;
}

运行结果如下。

最终计数:200000

分析:

在这个例子中,我们通过 std::thread 启动了 t1 和 t2 两个子线程,并使用 mtx.lock() 和 mtx.unlock() 将 counter++; 包裹成了受保护的临界区。

当 t1 拿到锁正在修改 counter 时,t2 必须在 mtx.lock(); 处排队等待。直到 t1 调用 mtx.unlock(); 释放锁之后,t2 才能进入临界区执行自增,因此最终结果稳定为 200000。

对于这个例子来说,如果我们把 mtx.lock(); 和 mtx.unlock(); 这两行去掉,每次运行都会算出一个小于 200000 的随机错误结果,比如:

最终计数:143820

可能有些小伙伴会觉得奇怪:为什么把互斥锁去掉之后,简简单单的一行 counter++ 会算错呢?

这是因为 counter++ 表面上看只有一行代码,但在 CPU 底层执行时,实际上分成了 3 步:① 从内存读取 counter 的当前值;② 将读出来的值加 1;③ 把新值写回 counter。如果没有互斥锁保护,假设当前 counter 的值是 0,两个线程的执行步骤很可能会按下面的先后顺序交错:

  • 线程 t1 从内存读取 counter 的值,拿到 0。
  • 线程 t2 也从内存读取 counter 的值,同样拿到 0。
  • 线程 t1 计算 0 + 1,将结果 1 写回 counter。
  • 线程 t2 也计算 0 + 1,同样将结果 1 写回 counter。

从上面的过程可以看出,两个线程明明各自执行了一次 counter++。但由于它们同时读到了同一个旧值 0,最后写回内存的结果却只是 1,白白漏算了一次自增。

提示 加上 std::mutex 之后,程序并不会退化成单线程。互斥锁只作用在 lock() 到 unlock() 之间那一小段修改共享数据的代码上,临界区之外的其他代码依然由多个线程并行执行。

使用 try_lock() 尝试加锁

前面使用的 lock() 属于 “阻塞等待” 模式:只要锁还在别的线程手里,当前线程就会一直停在原地干等。如果有时我们希望拿到锁就处理数据,拿不到锁也不停下来等待,而是先去做别的事情,就可以使用 try_lock() 成员函数。

语法:

if (互斥锁名.try_lock()) {
    // 操作共享数据
    互斥锁名.unlock();
} else {
    // 去做其他事情
}

说明:

调用 try_lock() 时,当前线程不会停下来排队,而是立刻返回一个布尔值(bool):

  • 返回 true:表示锁原本是空闲的,当前线程已经成功拿到了锁,我们操作完共享数据后必须记得调用 unlock() 解锁。
  • 返回 false:表示锁此时正被其他线程占用,当前线程没有拿到锁。注意此时锁不在我们手里,千万不要调用 unlock()。

示例 2:锁被占用时跳过等待

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

std::mutex mtx;

void tryTask(int id) {
    if (mtx.try_lock()) {
        std::cout << "线程 " << id << ":拿到锁,开始处理数据\n";
        mtx.unlock();
    } else {
        std::cout << "线程 " << id << ":锁被占用,先做其他事\n";
    }
}

int main() {
    mtx.lock();                    // 主线程先占住锁
    std::thread t1(tryTask, 1);
    t1.join();
    mtx.unlock();                  // 主线程释放锁

    std::thread t2(tryTask, 2);    // 锁已空闲,再次尝试
    t2.join();
    return 0;
}

运行结果如下。

线程 1:锁被占用,先做其他事
线程 2:拿到锁,开始处理数据

分析:

mtx.try_lock() 表示在不阻塞当前线程的前提下尝试获取互斥锁。在启动子线程 t1 之前,我们先在主线程里调用 mtx.lock() 把锁占住了。因此当 t1 执行 if (mtx.try_lock()) 时,不会停在原地等待,而是立刻得到 false 并直接进入 else 分支。

等主线程调用 mtx.unlock() 把锁释放之后,子线程 t2 再次调用 try_lock() 就会返回 true。此时 t2 顺利进入 if 分支处理数据,并在用完后调用 mtx.unlock() 归还互斥锁。

使用 mutex 的注意事项

刚接触 std::mutex 的小伙伴,往往容易误以为 “只要在代码里定义了一个 mutex 变量,共享数据就自动安全了”。实际上,std::mutex 本身并不知道我们要保护哪一个变量。要让互斥锁真正发挥作用,我们需要遵守以下 3 条规则:

  • 多个线程必须共用同一把锁:我们应该把 std::mutex 定义为全局变量或类成员变量等所有相关线程都能访问到的对象,千万不要把它定义成线程函数内部的局部变量。
  • 临界区范围应该尽量短小:我们只把真正读写共享数据的那几行代码放进 lock() 和 unlock() 之间。与共享数据无关的耗时计算或等待操作,应该尽量放在锁的外面,避免其他线程长时间排队。
  • 配对解锁且避免同线程重复加锁:调用 lock() 后如果漏写了 unlock(),其他排队的线程就会一直卡住。同时,同一个线程在还没解锁时千万不要对同一把 std::mutex 再次调用 lock()。这在 C++ 中属于未定义行为,最常见的后果就是线程停在原地等自己放锁,把程序彻底卡死(即 “死锁”)。

对于第 1 点,如果我们把 std::mutex 错写成了线程函数内部的局部变量,那么每个线程进入函数时都会各自创建一把新锁。这样一来,大家各锁各的,根本锁不住共享变量,这一点小伙伴们千万不要搞混了。

// 正确方式
std::mutex mtx;
void addSafe() {
    mtx.lock();
    counter++;
    mtx.unlock();
}

// 错误方式
void addBad() {
    std::mutex badMtx;
    badMtx.lock();
    counter++;
    badMtx.unlock();
}

此外,直接手写 lock() 和 unlock() 还隐藏着另一个风险。如果临界区中间遇到了提前 return 或抛出了异常,程序就会直接跳出当前函数,导致后面的 unlock() 根本没有机会执行。为了避免忘记解锁,在实际开发中我们通常会配合 std::lock_guard 来自动管理锁的释放。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号