首页 > 后端开发 > C++ > 正文

如何在C++中安全地使用互斥锁_C++多线程同步与互斥锁

下次还敢
发布: 2025-09-20 16:04:01
原创
526人浏览过
安全使用C++互斥锁的关键是遵循RAII原则,优先使用std::lock_guard或std::unique_lock管理std::mutex,避免手动调用lock()和unlock(),以防异常导致的死锁;对于多锁场景,应使用std::scoped_lock或std::lock确保加锁顺序一致,防止死锁;同时可根据读写频率选择std::shared_mutex,或用std::atomic实现无锁原子操作,结合条件变量、异步任务等机制满足不同同步需求。

如何在c++中安全地使用互斥锁_c++多线程同步与互斥锁

在C++多线程编程中,要安全地使用互斥锁,核心在于利用RAII(资源获取即初始化)原则,通过

std::lock_guard
登录后复制
std::unique_lock
登录后复制
来管理
std::mutex
登录后复制
,确保锁的自动获取与释放,从而有效防止数据竞争(Data Race)和死锁(Deadlock)等并发问题,保障共享数据的完整性。

解决方案

安全使用C++互斥锁的关键在于理解并正确运用C++标准库提供的同步原语。最基础的互斥锁是

std::mutex
登录后复制
,但直接调用其
lock()
登录后复制
unlock()
登录后复制
方法风险较高。我个人经验是,几乎所有情况下都应该避免直接调用这两个方法,除非你真的非常清楚自己在做什么,并且有充分的理由。

我们通常会配合

std::lock_guard
登录后复制
std::unique_lock
登录后复制
来使用
std::mutex
登录后复制

1.

std::lock_guard
登录后复制
:简单、安全的首选

立即学习C++免费学习笔记(深入)”;

std::lock_guard
登录后复制
是一个轻量级的RAII封装,它在构造时获取互斥锁,在析构时释放互斥锁。这意味着,无论代码块如何退出(正常结束、异常抛出),锁都能被正确释放。

#include <iostream>
#include <vector>
#include <string>
#include <mutex>
#include <thread>
#include <chrono> // For std::this_thread::sleep_for

std::vector<int> shared_data;
std::mutex mtx; // 全局或成员互斥锁

void add_to_shared_data(int value) {
    // 构造时加锁
    std::lock_guard<std::mutex> lock(mtx); 
    // 临界区开始
    shared_data.push_back(value);
    std::cout << "Thread " << std::this_thread::get_id() << " added: " << value << std::endl;
    // 临界区结束,lock_guard析构时自动解锁
}

// int main() {
//     std::vector<std::thread> threads;
//     for (int i = 0; i < 5; ++i) {
//         threads.emplace_back(add_to_shared_data, i);
//     }
//     for (auto& t : threads) {
//         t.join();
//     }
//     // 验证数据
//     std::cout << "Shared data size: " << shared_data.size() << std::endl;
//     return 0;
// }
登录后复制

2.

std::unique_lock
登录后复制
:更灵活的锁管理

std::unique_lock
登录后复制
提供了比
std::lock_guard
登录后复制
更灵活的锁管理能力。它同样基于RAII,但允许:

  • 延迟加锁(Deferred Locking):构造时不立即加锁,之后手动调用
    lock()
    登录后复制
  • 尝试加锁(Try Locking):使用
    try_lock()
    登录后复制
    尝试获取锁,如果无法获取则立即返回,不会阻塞。
  • 有时限加锁(Timed Locking):使用
    try_lock_for()
    登录后复制
    try_lock_until()
    登录后复制
    在一定时间内尝试获取锁。
  • 锁的转移(Ownership Transfer)
    std::unique_lock
    登录后复制
    是可移动的,可以将锁的所有权从一个
    unique_lock
    登录后复制
    对象转移到另一个。

这些特性在处理复杂并发场景,比如需要条件变量(

std::condition_variable
登录后复制
)或者避免死锁时,会显得非常有用。

// 配合条件变量的示例
std::queue<int> q;
std::mutex q_mtx;
std::condition_variable cv;
bool data_ready = false;

void producer() {
    std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟生产时间
    {
        std::unique_lock<std::mutex> lock(q_mtx); // 构造时加锁
        q.push(42);
        data_ready = true;
        std::cout << "Producer produced 42." << std::endl;
    } // lock析构时解锁
    cv.notify_one(); // 通知一个等待线程
}

void consumer() {
    std::unique_lock<std::mutex> lock(q_mtx); // 构造时加锁
    // 等待条件变量,期间会自动解锁,当被唤醒且条件满足时重新加锁
    cv.wait(lock, []{ return data_ready; }); 
    int value = q.front();
    q.pop();
    std::cout << "Consumer consumed: " << value << std::endl;
}

// int main() {
//     std::thread p(producer);
//     std::thread c(consumer);
//     p.join();
//     c.join();
//     return 0;
// }
登录后复制

3.

std::scoped_lock
登录后复制
(C++17):同时锁定多个互斥锁

对于需要同时锁定多个互斥锁以避免死锁的场景,C++17引入了

std::scoped_lock
登录后复制
。它能够以死锁安全的方式一次性锁定多个互斥锁,其内部机制会处理锁的顺序问题。

std::mutex mtx1;
std::mutex mtx2;

void func_with_two_locks() {
    // 自动以死锁安全的方式锁定mtx1和mtx2
    std::scoped_lock lock(mtx1, mtx2); 
    // 临界区
    std::cout << "Thread " << std::this_thread::get_id() << " acquired both locks." << std::endl;
    // ...
}
登录后复制

为什么裸用
std::mutex::lock()
登录后复制
unlock()
登录后复制
是危险的?

直接使用

std::mutex::lock()
登录后复制
std::mutex::unlock()
登录后复制
来手动管理互斥锁,虽然看起来直接,但在实际工程中几乎总是会引入潜在的风险。我个人觉得,这有点像在现代C++中还坚持使用裸指针进行内存管理,虽然能用,但一旦出现异常或复杂的控制流,就很容易出问题。

主要问题出在异常安全和代码维护上:

  1. 异常安全问题: 假设你在

    lock()
    登录后复制
    unlock()
    登录后复制
    之间执行了一些可能抛出异常的代码。如果异常发生,
    unlock()
    登录后复制
    语句将永远不会被执行到,导致互斥锁一直处于锁定状态。其他尝试获取该锁的线程将永远阻塞,造成死锁或程序挂起。

    std::mutex mtx_dangerous;
    void dangerous_function() {
        mtx_dangerous.lock(); // 加锁
        try {
            // 某些操作,可能抛出异常
            if (true) { // 模拟异常条件
                throw std::runtime_error("Something went wrong!");
            }
            // ... 更多操作 ...
        } catch (...) {
            // 如果这里捕获了异常,但忘记了解锁,那么问题就大了
            // mtx_dangerous.unlock(); // 很容易忘记这一行
            throw; // 重新抛出异常
        }
        mtx_dangerous.unlock(); // 如果没有异常,才会执行到这里
    }
    登录后复制

    在上面的例子中,如果

    throw std::runtime_error
    登录后复制
    发生,
    unlock()
    登录后复制
    就不会被调用,锁就泄露了。

  2. 代码维护与可读性: 随着代码量的增加和复杂度的提高,确保每个

    lock()
    登录后复制
    都有对应的
    unlock()
    登录后复制
    变得异常困难。特别是在有多个返回路径、循环或条件分支的代码中,很容易遗漏
    unlock()
    登录后复制
    。这不仅增加了bug的风险,也降低了代码的可读性和可维护性。维护者需要仔细检查每一条路径,确保锁的平衡。

  3. 多返回路径问题: 一个函数可能有多个

    return
    登录后复制
    语句。如果忘记在每个
    return
    登录后复制
    语句之前调用
    unlock()
    登录后复制
    ,同样会导致锁泄露。

相比之下,

std::lock_guard
登录后复制
std::unique_lock
登录后复制
等RAII(Resource Acquisition Is Initialization)风格的锁管理对象,在它们的生命周期结束时(无论是正常退出作用域,还是因为异常导致展开),都会自动调用析构函数来释放互斥锁。这从根本上解决了上述问题,使得锁的管理变得异常安全和简洁。这正是C++社区推荐的现代并发编程实践。

如何避免多线程编程中常见的死锁问题?

死锁是多线程编程中最令人头疼的问题之一,它通常发生在两个或更多线程互相等待对方释放资源时,导致所有线程都无法继续执行。避免死锁,我觉得更多是一种设计哲学和习惯,而不是单纯的技术手段。

死锁发生的四个必要条件(Coffman条件):

如知AI笔记
如知AI笔记

如知笔记——支持markdown的在线笔记,支持ai智能写作、AI搜索,支持DeepseekR1满血大模型

如知AI笔记 27
查看详情 如知AI笔记
  1. 互斥(Mutual Exclusion):资源不能共享,一次只能被一个线程使用。
  2. 占有并等待(Hold and Wait):线程已经持有一些资源,又去申请其他资源,但申请不到,于是阻塞等待。
  3. 不可剥夺(No Preemption):已经分配给一个线程的资源不能强制性地被剥夺,只能由持有它的线程显式释放。
  4. 循环等待(Circular Wait):存在一个线程链,每个线程都在等待链中下一个线程所持有的资源。

要避免死锁,我们通常会尝试破坏其中一个或多个条件。

实践中避免死锁的策略:

  1. 保持一致的加锁顺序(Consistent Lock Ordering): 这是最常用也最有效的策略。如果你的线程需要同时获取多个互斥锁,那么所有线程都应该以相同的顺序来获取这些锁。

    std::mutex mtxA, mtxB;
    
    void func1() {
        std::lock_guard<std::mutex> lockA(mtxA); // 先锁A
        std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 模拟工作
        std::lock_guard<std::mutex> lockB(mtxB); // 再锁B
        std::cout << "Func1 acquired A then B." << std::endl;
    }
    
    void func2() {
        // 如果这里颠倒顺序,就可能死锁
        // std::lock_guard<std::mutex> lockB(mtxB); 
        // std::lock_guard<std::mutex> lockA(mtxA); 
    
        // 正确做法:保持与func1相同的顺序
        std::lock_guard<std::mutex> lockA(mtxA); // 先锁A
        std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 模拟工作
        std::lock_guard<std::mutex> lockB(mtxB); // 再锁B
        std::cout << "Func2 acquired A then B." << std::endl;
    }
    登录后复制

    如果

    func2
    登录后复制
    先锁
    mtxB
    登录后复制
    再锁
    mtxA
    登录后复制
    ,而
    func1
    登录后复制
    先锁
    mtxA
    登录后复制
    再锁
    mtxB
    登录后复制
    ,就可能形成循环等待。

  2. 使用

    std::lock()
    登录后复制
    函数同时锁定多个互斥锁: C++标准库提供了
    std::lock(m1, m2, ...)
    登录后复制
    函数,它能够以死锁安全的方式原子性地尝试锁定多个互斥锁。如果所有锁都能成功获取,它就返回;否则,它会释放所有已获取的锁并重试,直到所有锁都被获取。这正是为了避免“占有并等待”条件。通常与
    std::unique_lock
    登录后复制
    std::defer_lock
    登录后复制
    标签配合使用。

    std::mutex mtx_x, mtx_y;
    
    void swap_data(int& data_x, int& data_y) {
        // std::lock 会原子性地锁定所有提供的互斥锁,避免死锁
        std::unique_lock<std::mutex> lock_x(mtx_x, std::defer_lock);
        std::unique_lock<std::mutex> lock_y(mtx_y, std::defer_lock);
        std::lock(lock_x, lock_y); // 同时锁定,避免死锁
    
        // 此时两个锁都被持有
        std::swap(data_x, data_y);
        std::cout << "Data swapped by thread " << std::this_thread::get_id() << std::endl;
        // lock_x和lock_y在析构时会自动释放
    }
    登录后复制

    C++17的

    std::scoped_lock
    登录后复制
    提供了更简洁的语法来实现相同的功能,如前面解决方案中所示。

  3. 避免在持有锁时进行耗时操作或I/O操作: 锁的粒度应该尽可能小。在持有锁的临界区内,只进行必要的操作,尽快释放锁。长时间持有锁会增加其他线程等待的时间,也增加了死锁的可能性。

  4. 使用

    std::try_lock()
    登录后复制
    std::timed_mutex
    登录后复制
    如果无法立即获取所有必需的锁,线程可以尝试获取,如果失败则放弃当前操作,或者等待一段时间后重试。这打破了“占有并等待”条件。

    std::mutex mtx_a, mtx_b;
    
    void try_to_do_something() {
        if (mtx_a.try_lock()) { // 尝试获取锁A
            std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 模拟一些工作
            if (mtx_b.try_lock()) { // 尝试获取锁B
                std::cout << "Acquired both A and B." << std::endl;
                mtx_b.unlock();
            } else {
                std::cout << "Could not acquire B, releasing A." << std::endl;
            }
            mtx_a.unlock();
        } else {
            std::cout << "Could not acquire A." << std::endl;
        }
    }
    登录后复制

    这种方式虽然可以避免死锁,但代码会变得复杂,且可能导致活锁(livelock,线程反复尝试失败)。

  5. 避免不必要的嵌套锁: 尽量减少在一个锁的临界区内再尝试获取另一个锁的情况。如果确实需要,请确保遵循一致的加锁顺序。

  6. 资源分层: 为资源定义一个层次结构。线程总是按照从高到低的顺序获取资源(锁)。

死锁问题没有一劳永逸的解决方案,它需要开发者在设计并发系统时就进行周密的考虑。我的经验是,保持简单、一致的加锁顺序,并优先使用

std::scoped_lock
登录后复制
std::lock
登录后复制
来管理多个互斥锁,是避免大多数死锁问题的有效途径。

除了互斥锁,C++还有哪些多线程同步机制?何时选择它们?

C++标准库提供了多种多线程同步机制,它们各有侧重,适用于不同的并发场景。了解它们的特点和适用范围,能帮助我们更高效、安全地构建并发程序。

  1. std::condition_variable
    登录后复制
    (条件变量):

    • 作用: 允许线程等待某个条件变为真,或者在某个条件变为真时通知其他等待的线程。它通常与
      std::mutex
      登录后复制
      std::unique_lock
      登录后复制
      配合使用。
    • 何时选择: 经典的生产者-消费者模型、任务队列、线程池等场景。当一个线程需要等待另一个线程完成某个操作或满足某个条件才能继续执行时,条件变量是理想的选择。例如,消费者线程等待队列中有数据可取,生产者线程在放入数据后通知消费者。
    • 技术深度:
      wait()
      登录后复制
      函数在等待时会自动释放持有的
      unique_lock
      登录后复制
      ,并在被唤醒时重新获取锁。这避免了在等待期间阻塞其他线程对共享资源的访问。
  2. std::atomic
    登录后复制
    (原子操作):

    • 作用: 提供对基本数据类型(如
      int
      登录后复制
      ,
      bool
      登录后复制
      , 指针等)的原子操作。原子操作是不可中断的,要么完全执行,要么不执行,从而避免了数据竞争,而不需要使用互斥锁。
    • 何时选择: 当你只需要对单个、简单的共享变量进行读写操作,且这些操作本身就可以原子化时。例如,计数器、标志位、简单的状态更新。使用
      std::atomic
      登录后复制
      通常比使用
      std::mutex
      登录后复制
      更高效,因为它避免了锁的开销。
    • 技术深度:
      std::atomic
      登录后复制
      提供了
      load()
      登录后复制
      ,
      store()
      登录后复制
      ,
      exchange()
      登录后复制
      ,
      compare_exchange_weak()
      登录后复制
      ,
      compare_exchange_strong()
      登录后复制
      等操作,以及各种原子算术操作。其底层实现可能依赖于CPU指令(如CAS,Compare-And-Swap)。
  3. std::promise
    登录后复制
    std::future
    登录后复制
    (异步结果):

    • 作用:
      std::promise
      登录后复制
      用于在一个线程中设置一个值或异常,而
      std::future
      登录后复制
      则用于在另一个线程中获取这个值或异常。它们提供了一种机制来传递异步操作的结果。
    • 何时选择: 当你需要在一个线程中启动一个任务,并在稍后从另一个线程获取该任务的结果时。例如,异步计算、并行任务的协调。
      std::async
      登录后复制
      函数是使用
      std::promise
      登录后复制
      std::future
      登录后复制
      的便捷方式。
    • 技术深度:
      std::future
      登录后复制
      get()
      登录后复制
      方法会阻塞直到结果可用。
      std::shared_future
      登录后复制
      允许多个
      future
      登录后复制
      对象引用同一个结果。
  4. std::shared_mutex
    登录后复制
    (C++17) /
    std::shared_timed_mutex
    登录后复制
    (共享互斥锁/读写锁):

    • 作用: 允许多个线程同时拥有共享(读)锁,但只允许一个线程拥有排他(写)锁。
    • 何时选择: 当你的数据结构读操作远多于写操作时。读锁之间不互斥,可以提高并发度;写锁会阻塞所有读写操作,保证数据一致性。
    • 技术深度:
      std::shared_lock
      登录后复制
      用于获取共享锁,
      std::unique_lock
      登录后复制
      或直接的
      lock()
      登录后复制
      /
      unlock()
      登录后复制
      用于获取排他锁。
  5. std::latch
    登录后复制
    std::barrier
    登录后复制
    (C++20) (同步点):

    • 作用:
      std::latch
      登录后复制
      是一个一次性的计数器,允许一组线程等待直到计数器达到零。
      std::barrier
      登录后复制
      则是一个可重用的同步点,允许多个线程在达到某个点时同步,然后继续执行。
    • 何时选择:
      std::latch
      登录后复制
      适用于“一次性事件”同步,例如,等待所有子任务完成才能进行下一步。
      std::barrier
      登录后复制
      适用于“循环同步”或“阶段性同步”,例如,在并行算法的每个迭代中,所有线程都必须完成当前阶段才能进入下一阶段。
    • 技术深度:
      latch
      登录后复制
      wait()
      登录后复制
      方法会阻塞直到
      count_down()
      登录后复制
      被调用足够次数。
      barrier
      登录后复制
      则更复杂,可以在所有线程到达后执行一个完成函数,然后重置。

这些机制各有千秋,选择哪种取决于具体的同步需求。通常,我会先考虑

std::atomic
登录后复制
能否解决问题,如果不行,再考虑
std::mutex
登录后复制
配合RAII锁,如果涉及复杂的等待通知模式,就会用到
std::condition_variable
登录后复制
。对于读多写少的数据,
std::shared_mutex
登录后复制
能显著提升性能。C++20的
latch
登录后复制
barrier
登录后复制
则为更高级的并行模式提供了简洁的解决方案。

以上就是如何在C++中安全地使用互斥锁_C++多线程同步与互斥锁的详细内容,更多请关注php中文网其它相关文章!

最佳 Windows 性能的顶级免费优化软件
最佳 Windows 性能的顶级免费优化软件

每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。

下载
来源:php中文网
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
最新问题
开源免费商场系统广告
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板
关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新 English
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送
PHP中文网APP
随时随地碎片化学习

Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号