IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> C++知识库 -> C++并发 互斥 -> 正文阅读

[C++知识库]C++并发 互斥


1. Mutex

1.1 std::mutex

std::mutex是C++11中最基本的互斥量,std::mutex对象提供了独占所有权的特性——即不支持递归地对 std::mutex 对象上锁,而 std::recursive_lock 则可以递归地对互斥量对象上锁。

成员函数:

  • 构造函数 —— std::mutex不允许拷贝构造,也不允许move拷贝,最初产生的mutex对象是处于unlocked状态的;
  • lock() —— 调用线程将锁住该互斥量。线程调用该函数会发生下面 3 种情况:
    (1) 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁;
    (2) 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住;
    (3) 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
  • unlock() —— 解锁,释放对互斥量的所有权;
  • try_lock() —— 尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面 3 种情况:
    (1) 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量;
    (2) 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉;
    (3) 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
#include <iostream>       // std::cout
#include <thread>         // std::thread
#include <mutex>          // std::mutex

std::mutex mtx;           // mutex for critical section

void print_block(int n, char c) 
{
  mtx.lock();
  for(int i = 0; i < n; ++i) 
  { 
    std::cout << c; 
  }
  std::cout << '\n';
  mtx.unlock();
}

int main ()
{
  std::thread th1(print_block, 50, '*');
  std::thread th2(print_block, 50, '$');

  th1.join();
  th2.join();

  return 0;
}

1.2 std::recursive_mutex

其工作方式与std::mutex相似,不同之处是,其允许同一线程对某互斥的同一实例多次加锁


1.3 std::time_mutex

其工作方式与std::mutex相似,多了两个成员函数,try_lock_for(),try_lock_until()

  • try_lock_for() —— 函数接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与 std::mutex 的 try_lock() 不同,try_lock 如果被调用时没有获得锁则直接返回 false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false;
  • try_lock_until() —— 函数则接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。
#include <iostream>       // std::cout
#include <chrono>         // std::chrono::milliseconds
#include <thread>         // std::thread
#include <mutex>          // std::timed_mutex
 
std::timed_mutex mtx;
 
void fireworks() 
{
  // waiting to get a lock: each thread prints "-" every 200ms:
  while (!mtx.try_lock_for(std::chrono::milliseconds(200))) {
    std::cout << "-";
  }
  // got a lock! - wait for 1s, then this thread prints "*"
  std::this_thread::sleep_for(std::chrono::milliseconds(1000));
  std::cout << "*\n";
  mtx.unlock();
}
 
int main ()
{
  std::thread threads[10];
  // spawn 10 threads:
  for (int i=0; i<10; ++i)
    threads[i] = std::thread(fireworks);
 
  for (auto& th : threads) th.join();
 
  return 0;
}

1.4 std::recursive_timed_mutex

其工作方式与time_mutex相似,不同之处是,其允许同一线程对某互斥的同一实例多次加锁


1.5 std::shared_mutex

std::shared_mutex C++17中引入,用于管理可转移和共享所有权的互斥对象,适用场景比较特殊:一个或多个读线程同时读取共享资源,且只有一个写线程来修改这个资源,这种情况下才能从shared_mutex获取性能优势。

共享锁仅有一个限制,即假设它已被某些线程所持有,若别的线程试图获取排他锁,就会发生阻塞,直到那些线程全都释放该共享锁。反之,如果任一线程持有排他锁,那么其他线程全都无法获取共享锁或排他锁,直到持锁线程将排他锁释放为止。

  • 更新操作可用std::lock_guard<std::shared_mutex>std::unique_lock<std::shared_mutex>锁定;
  • 对于那些无须更新数据结构的线程,可以另行改用共享锁std::shared_lock<std::shared_mutex>实现共享访问
#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>

class dns_entry
{};

class dns_cache
{
    std::map<std::string,dns_entry> entries;
    std::shared_mutex entry_mutex;
public:
    dns_entry find_entry(std::string const& domain)
    {
        std::shared_lock<std::shared_mutex> lk(entry_mutex);
        std::map<std::string,dns_entry>::const_iterator const it=
            entries.find(domain);
        return (it==entries.end())?dns_entry():it->second;
    }
    void update_or_add_entry(std::string const& domain,
                             dns_entry const& dns_details)
    {
        std::lock_guard<std::shared_mutex> lk(entry_mutex);
        entries[domain]=dns_details;
    }
};

此例中,find_entry()函数只读取数据,采用std::shared_lock<>实例保护共享的、只读的访问;
update_or_add_entry()函数中,需要修改数据,采用 std::lock_guard<>实例进行排他访问



2. lock类

2.1 std::lock_guard

针对互斥类融合实现了RAII手法:在构造时给互斥加锁,在析构时解锁,从而保证互斥总被正确解锁。

在C++中,我们通过构造std::mutex的实例来创建互斥,调用成员函数lock()对其加锁,调用unlock()解锁。但我不推荐直接调用成员函数的做法。原因是,若按此处理,那我们就必须记住,在函数以外的每条代码路径上都要调用unlock(),包括由于异常导致退出的路径。取而代之,C++标准库提供了类模板std::lock_guard

// lock_guard example
#include <iostream>       // std::cout
#include <thread>         // std::thread
#include <mutex>          // std::mutex, std::lock_guard
#include <stdexcept>      // std::logic_error

std::mutex mtx;

void print_even (int x) {
  if (x%2==0) std::cout << x << " is even\n";
  else throw (std::logic_error("not even"));
}

void print_thread_id (int id) {
  try {
    // using a local lock_guard to lock mtx guarantees unlocking on destruction / exception:
    std::lock_guard<std::mutex> lck (mtx);
    print_even(id);
  }
  catch (std::logic_error&) {
    std::cout << "[exception caught]\n";
  }
}

int main ()
{
  std::thread threads[10];
  // spawn 10 threads:
  for (int i=0; i<10; ++i)
    threads[i] = std::thread(print_thread_id,i+1);

  for (auto& th : threads) th.join();

  return 0;
}

指针或引用问题:

利用互斥保护共享数据并不太简单,我们不能把std::lock_guard对象化作“铁拳”,对准每个成员函数施予“重击”。一旦出现游离的指针或引用,这种保护就全部形同虚设。不管成员函数通过什么“形式”——无论是返回值,还是输出参数(out parameter)——向调用者返回指针或引用,指向受保护的共享数据,就会危及共享数据安全。

在这里插入图片描述

上例中,我们遗漏了foo(),调用unprotected->do_something()的代码未被标记。无奈,C++线程库对这个问题无能为力;只有靠我们自己——程序员——正确地锁定互斥,借此保护共享数据。从乐观的角度看,只要遵循下列指引即可应对上述情形:不得向锁所在的作用域之外传递指针和引用,指向受保护的共享数据,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。


多个互斥对象问题:

C++标准库提供了std::lock()函数,专门解决这一问题。它可以同时锁住多个互斥,而没有发生死锁的风险。

class some_big_object
{};

void swap(some_big_object& lhs,some_big_object& rhs)
{}

class X
{
private:
    some_big_object some_detail;
    mutable std::mutex m;
public:
    X(some_big_object const& sd):some_detail(sd){}

    friend void swap(X& lhs, X& rhs)
    {
        if(&lhs==&rhs)
            return;
        std::lock(lhs.m,rhs.m);
        std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock);
        std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock);
        swap(lhs.some_detail,rhs.some_detail);
    }
};
  • std::lock() 锁定两个互斥,并依据它们分别构造std::lock_guard实例;
  • std::adopt_lock 指明互斥锁已经被std::lock锁住,lock_guard构造函数中不需要再次加锁。

C++17还进一步提供了新的RAII类模板std::scoped_lock<>std:: scoped_lock<>std::lock_guard<>完全等价,只不过前者是可变参数模板(variadic template),接收各种互斥型别作为模板参数列表,还以多个互斥对象作为构造函数的参数列表

上例中swap()函数中代码可以改为:

    friend void swap(X& lhs, X& rhs)
    {
        if(&lhs==&rhs)
            return;
        std::scoped_lock guard(lhs.m, rhs.m);
        swap(lhs.some_detail,rhs.some_detail);
    }

2.2 std::scoped_lock

C++17进一步提供了新的RAII类模板std::scoped_lock<>std:: scoped_lock<>std::lock_guard<>完全等价,只不过前者是可变参数模板(variadic template),接收各种互斥型别作为模板参数列表,还以多个互斥对象作为构造函数的参数列表


2.3 std::unique_lock

  • 与lock_guard类似,RAII机制;
  • 与lock_guard不同,可以随时加锁解锁;
  • std::unique_lock实例不占有与之关联的互斥,所以随着其实例的转移,互斥的归属权可以在多个std::unique_lock实例之间转移。转移会在某些情况下自动发生,譬如从函数返回实例时,但我们须针对别的情形显式调用std::move()。

可以随时加锁解锁的示例:

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

int  v = 1;

void critical_section(int change_v){
    static std::mutex mtx;
    
    std::unique_lock<std::mutex> lock(mtx);
    // 执行竞争操作
    v = change_v;
    printf("%d\n", v);
    
    // 释放锁
    lock.unlock();

    // 在此期间,任何人都可以抢夺v的持有权
    
    // 开始另一组竞争操作,再次加锁
    lock.lock();
    v += 1;
    printf("%d\n", v);
}
int main() {
    std::thread t1(critical_section, 2), t2(critical_section, 3);
    t1.join();
    t2.join();
}

unique_lock的第二个参数:

  • std::adopt_lock,同lock_guard的第二个参数,表示互斥量已经被lock了(程序员必须把互斥量先lock,否则会报异常);
  • std::try_to_lock,尝试用mutex中的lock()去锁定这个mutex,但如果没有锁定成功,也会立刻返回,并不会阻塞;
  • std::defer_lock,延后锁,不能先lock(),否则汇报异常
    void swap(X& lhs, X& rhs)
    {
        if(&lhs==&rhs)
            return;
        std::unique_lock<std::mutex> lock_a(lhs.m,std::defer_lock);
        std::unique_lock<std::mutex> lock_b(rhs.m,std::defer_lock);
        std::lock(lock_a,lock_b);
        swap(lhs.some_detail,rhs.some_detail);
    }

std::unique_lock占用更多的空间,也比std::lock_guard略慢。但std::unique_lock对象可以不占有关联的互斥,具备这份灵活性需要付出代价:需要存储并且更新互斥信息。
所以,如果std::lock_guard已经能满足所需,我建议优先采用。话说回来,有一些情形需要更灵活的处理方式,则std::unique_lock类更为合适。延时加锁即属此例。另一种情形是,需要从某作用域转移锁的归属权到其他作用域。



3. std::once_flag类

以线程安全的懒汉式单例模式为例

// singleton.cpp
#include "singleton.h"

// 单例 - 懒汉式(双检锁 DCL 机制)
Singleton *Singleton::m_pSingleton = nullptr;
mutex Singleton::m_mutex;

Singleton *Singleton::GetInstance()
{
     if (m_pSingleton == nullptr) {     // 1
         std::lock_guard<std::mutex> lock(m_mutex);
         if (m_pSingleton == nullptr) {   // 2
             m_pSingleton = new Singleton();   // 3
         } 
      } 
      return m_pSingleton;
}

加锁影响效率。也有可能诱发恶性条件竞争。
当前线程在锁保护范围外读取指针①,而对方线程却可能先获取锁,顺利进入锁保护范围内执行写操作③,因此读写操作没有同步,产生了条件竞争,既涉及指针本身,还涉及其指向的对象。尽管当前线程能够看见其他线程写入指针,却有可能无视新实例Singleton的创建。

C++标准委员会相当重视以上情况,在C++标准库中提供了std::once_flag类和std:: call_once()函数,以专门处理该情况。上述代码先锁住互斥,再显式检查指针,导致问题出现。我们对症下药,令所有线程共同调用std::call_once()函数,从而确保在该调用返回时, 指针初始化由其中某线程安全且唯一地完成(通过适合的同步机制)。必要的同步数据则由std::once_flag实例存储,每个std::once_flag实例对应一次不同的初始化。相比显式使用互斥,std::call_once()函数的额外开销往往更低,特别是在初始化已经完成的情况下,所以如果功能符合需求就应优先使用。

// singleton.cpp
#include "singleton.h"

Singleton *Singleton::m_pSingleton = nullptr;

std::once_flag flag;

Singleton *Singleton::GetInstance()
{
     std::call_once(flag, []() {
     	m_pSingleton = new Singleton();
     });
     return m_pSingleton;
}

如果把局部变量声明成静态数据,那样便有可能让初始化过程出现条件竞争。根据C++标准规定,只要控制流程第一次遇到静态数据的声明语句,变量即进行初始化。若多个线程同时调用同一函数,而它含有静态数据,则任意线程均可能首先到达其声明处,这就形成了条件竞争的隐患。C++11标准发布之前,许多编译器都未能在实践中正确处理该条件竞争。其原因有可能是众多线程均认定自己是第一个,都试图初始化变量;也有可能是某线程上正在进行变量的初始化,但尚未完成,而别的线程却试图使用它。
C++11解决了这个问题,规定初始化只会在某一线程上单独发生,在初始化完成之前,其他线程不会越过静态数据的声明而继续运行。
于是,这使得条件竞争原来导致的问题变为,初始化应当由哪个线程具体执行。

class Singleton 
{
protected:
    //如果允许该类被继承,protected修饰 
    Singleton(){};
        
public:
    static Singleton& getInstance() {
        static Singleton instance;
        return instance;
    } 
};


4. 预防死锁

防范死锁的准则最终可归纳成一个思想:只要另一线程有可能正在等待当前线程,那么当前线程千万不能反过来等待它。

具体内容参见《C++并发编程实战(第2版)》3.2.5小节。

4.1 避免嵌套锁

第一条准则最简单:假如已经持有锁,就不要试图获取第二个锁。万一确有需要获取多个锁,我们应采用std::lock()函数,借单独的调用动作一次获取全部锁来避免死锁。

4.2 一旦持锁,就须避免调用由用户提供的程序接口

这是上一条准则的延伸。若程序接口由用户自行实现,则我们无从得知它到底会做什么,它可能会随意操作,包括试图获取锁。一旦我们已经持锁,若再调用由用户提供的程序接口,而它恰好也要获取锁,那便违反了避免嵌套锁的准则,可能发生死锁。

4.3 依从固定顺序获取锁

如果多个锁是绝对必要的,却无法通过std::lock()在一步操作中全部获取,我们只能退而求其次,在每个线程内部都依从固定顺序获取这些锁。

4.4 按层级加锁

我们把应用程序分层,并且明确每个互斥位于哪个层级。若某线程已对低层级互斥加锁,则不准它再对高层级互斥加锁。具体做法是将层级的编号赋予对应层级应用程序上的互斥,并记录各线程分别锁定了哪些互斥。

4.5 将准则推广到锁操作以外

死锁现象并不单单因加锁操作而发生,任何同步机制导致的循环等待都会导致死锁出现。因此也值得为那些情况推广上述准则。譬如,我们应尽可能避免获取嵌套锁;若当前线程持有某个锁,却又同时等待别的线程,这便是坏的情况,因为万一后者恰好也需获取锁,反而只能等该锁被释放才能继续运行。类似地,如果要等待线程,那就值得针对线程规定层级,使得每个线程仅等待层级更低的线程。有一种简单方法可实现这种机制:让同一个函数启动全部线程,且汇合工作也由之负责。



5. 参考资料

《C++并发编程实战(第2版)》



  C++知识库 最新文章
【C++】友元、嵌套类、异常、RTTI、类型转换
通讯录的思路与实现(C语言)
C++PrimerPlus 第七章 函数-C++的编程模块(
Problem C: 算法9-9~9-12:平衡二叉树的基本
MSVC C++ UTF-8编程
C++进阶 多态原理
简单string类c++实现
我的年度总结
【C语言】以深厚地基筑伟岸高楼-基础篇(六
c语言常见错误合集
上一篇文章      下一篇文章      查看所有文章
加:2022-05-14 09:47:23  更:2022-05-14 09:48:06 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年5日历 -2024/5/10 17:51:43-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码