文章目录

C++ 多线程问题:线程安全与互斥锁

发布于 2026-04-08 01:28:43 · 浏览 6 次 · 评论 0 条

C++ 多线程问题:线程安全与互斥锁

在多线程编程中,当多个线程同时访问同一块内存区域(共享资源)时,如果不加控制,最终的数据结果往往是不可预测的。这种现象被称为“数据竞争”。为了解决这个问题,C++ 提供了多种同步机制,其中最基础且最常用的就是互斥锁。

互斥锁的核心逻辑非常简单:它就像一把锁,保证同一时间只有一个线程能持有这把锁并访问受保护的资源,其他线程必须等待锁释放后才能进入。


理解线程安全与数据竞争

首先,我们需要明确什么是线程不安全的代码。

编写 一个简单的计数器程序,如果不使用锁,两个线程同时对一个全局变量 shared_data 进行 10 万次自增操作,预期的最终结果应该是 20 万,但实际运行结果往往小于这个值。

这是因为 ++shared_data 操作在底层汇编层面并非原子操作,它通常包含“读取-修改-写入”三个步骤。线程 A 刚读取了数值,还没来得及写回,线程 B 可能也读取了旧数值并进行修改,导致线程 A 的修改被覆盖。

查看 以下不安全的代码示例:

#include <iostream>
#include <thread>

int shared_data = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        shared_data++; // 危险:非原子操作
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "Final value: " << shared_data << std::endl;
    return 0;
}

为了修复这个问题,引入 互斥锁。


使用 std::mutex 进行手动加锁

C++11 标准库在 <mutex> 头文件中提供了 std::mutex 类。最基本的用法是直接调用 lock()unlock() 成员函数。

修改 上面的代码,加入互斥锁保护:

  1. 包含 <mutex> 头文件。
  2. 定义 一个全局的 std::mutex 对象。
  3. 调用 mtx.lock() 在访问共享数据之前。
  4. 调用 mtx.unlock() 在访问共享数据之后。
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx; // 定义互斥锁
int shared_data = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();    // 加锁
        ++shared_data; // 临界区:安全访问
        mtx.unlock();  // 解锁
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "Final value: " << shared_data << std::endl;
    return 0;
}

注意:手动调用 lock()unlock() 存在极高风险。如果在 lock()unlock() 之间的代码发生了异常(例如抛出错误),或者因为逻辑判断提前 return 了,unlock() 就可能永远执行不到,从而导致“死锁”,其他线程将永久卡住。

为了规避这种风险,C++ 推荐使用 RAII(资源获取即初始化)风格的包装器。


使用 std::lock_guard 自动管理锁

std::lock_guard 是一个 RAII 风格的封装类。它的机制非常聪明:在构造函数中自动调用 lock(),在析构函数(作用域结束)中自动调用 unlock()

无论代码是正常执行完毕,还是因为异常跳出,lock_guard 对象在销毁时都会确保锁被释放。

改造 代码使用 lock_guard

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

std::mutex mtx;
int shared_data = 0;

void safe_increment() {
    for (int i = 0; i < 100000; ++i) {
        // 构造时自动加锁
        std::lock_guard<std::mutex> lock(mtx);
        ++shared_data;
        // 离开当前作用域(循环体末尾)时自动解锁
    }
}

int main() {
    std::thread t1(safe_increment);
    std::thread t2(safe_increment);
    t1.join();
    t2.join();
    std::cout << "Final value: " << shared_data << std::endl;
    return 0;
}

这是最常用、最推荐的标准做法,因为它既简单又异常安全。


使用 std::unique_lock 灵活控制锁

std::unique_lock 也是 RAII 风格的包装器,但比 lock_guard 提供更多的灵活性。它允许你手动加锁、解锁,甚至可以在构造时不立即加锁。

适用场景:当你需要在临界区内部暂时解锁,或者需要配合条件变量(Condition Variable)使用时。

使用 std::defer_lock 参数来延迟加锁:

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

std::mutex mtx;
int shared_data = 0;

void flexible_increment() {
    for (int i = 0; i < 100000; ++i) {
        std::unique_lock<std::mutex> lock(mtx, std::defer_lock);

        // 这里可以执行一些不需要锁的耗时操作
        // ...

        lock.lock(); // 需要时手动加锁
        ++shared_data;
        lock.unlock(); // 可以提前解锁,缩小锁的持有时间

        // 这里又可以执行不需要锁的操作
    }
}

int main() {
    std::thread t1(flexible_increment);
    std::thread t2(flexible_increment);
    t1.join();
    t2.join();
    std::cout << "Final value: " << shared_data << std::endl;
    return 0;
}

unique_lock 的缺点是相比 lock_guard 稍微增加了一点点性能开销(通常可以忽略不计),因此如果不需要高级功能,优先使用 lock_guard


C++ 互斥锁类型一览

C++ 标准库提供了四种互斥锁类型,分别适应不同的场景。

互斥锁类型 特点 适用场景
std::mutex 最基础的互斥锁,不可复制,不可移动。 一般的线程同步需求。
std::recursive_mutex 允许同一个线程对同一个互斥锁多次加锁(可重入)。 递归函数中需要加锁的情况。
std::timed_mutex 提供了带超时机制的加锁尝试(try_lock_for, try_lock_until)。 避免死锁,或者只愿意等待特定时间的场景。
std::shared_mutex (C++17) 读写锁。允许多个线程同时读,但写操作独占。 读多写少的场景,能显著提高并发读取性能。

注意std::recursive_mutex 虽然使用方便,但通常被认为是代码设计有问题的信号。如果能通过重构避免递归加锁,应优先使用 std::mutex


最佳实践与常见陷阱

在编写多线程代码时,仅仅知道如何加锁是不够的,还需要遵循一些最佳实践来保证程序的高效与稳定。

1. 减小锁的粒度(临界区范围)

锁的持有时间越长,其他线程阻塞等待的时间就越长,程序并发性能就越差。

对比 以下两种写法:

// 糟糕的写法:锁住了 I/O 操作
std::lock_guard<std::mutex> lock(mtx);
++shared_data;
std::cout << "Current value: " << shared_data << std::endl; // I/O 很慢,不应该被锁住
// 推荐的写法:只锁住共享数据的修改
{
    int temp = 0;
    {
        std::lock_guard<std::mutex> lock(mtx);
        temp = ++shared_data;
    } // 锁在这里就释放了

    // I/O 操作在锁外执行,其他线程可以在这期间获取锁
    std::cout << "Current value: " << temp << std::endl;
}

2. 注意 I/O 流的线程安全

C++ 标准库(如 std::cout)本身并不保证线程安全。如果多个线程同时向 cout 输出,字符可能会穿插在一起。

解决 办法是专门为输出定义一个独立的互斥锁:

std::mutex data_mtx;
std::mutex io_mtx; // 专门用于 I/O 的锁

void worker() {
    int val;
    {
        std::lock_guard<std::mutex> lock(data_mtx);
        val = ++shared_data;
    }
    {
        std::lock_guard<std::mutex> lock(io_mtx);
        std::cout << "Thread " << std::this_thread::get_id() 
                  << " Value: " << val << std::endl;
    }
}

3. 避免死锁

当程序中存在多个锁时,如果线程 A 持有锁 1 并等待锁 2,而线程 B 持有锁 2 并等待锁 1,程序就会永久卡死。

预防 死锁的最简单规则是:所有线程必须以相同的顺序获取锁

或者使用 C++ 标准库提供的 std::lock 函数,它可以同时锁定多个互斥锁,且使用死锁避免算法:

std::mutex mtx1, mtx2;

void safe_operation() {
    // std::lock 会同时锁定两个锁,要么全成功,要么全失败(不阻塞)
    std::lock(mtx1, mtx2);

    // 使用 adopt_lock 参数告诉 lock_guard 锁已经被持有,只需负责释放
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);

    // 执行临界区操作...
}

4. 加锁流程可视化

为了更直观地理解互斥锁的工作流程,可以参考以下逻辑:

graph TD A["线程尝试获取锁"] --> B{锁是否被占用?} B -- 否 --> C["获取锁成功"] C --> D["执行临界区代码
(操作共享资源)"] D --> E["释放锁"] E --> F["线程继续执行其他任务"] B -- 是 --> G["线程进入阻塞状态
(等待)"] G --> B

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文