• c++多线程并发学习笔记(1)


    共享数据带来的问题:条件竞争

    避免恶性条件竞争的方法:

    1. 对数据结构采用某种保护机制,确保只有进行修改的线程才能看到修改时的中间状态。从其他访问线程的角度来看,修改不是已经完成了,就是还没开始。

    2. 对数据结构的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,这就是所谓的无锁编程。

    3. 使用事务的方式去处理数据结构的更新(这里的"处理"就如同对数据库进行更新一样)。所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交。当数据结构被另一个线程修       改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”(software transactional memory (STM))。

    使用互斥量来保护共享数据

    主要实现方法:当访问共享数据前,将数据锁住,在访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程都能看到共享数据,并而不破坏不变量。

    互斥量不是万能的,在使用时要注意以下问题:

    1. 需要编排代码来保护数据的正确性

    2. 避免接口间的竞争条件

    3. 避免死锁

    4. 对数据的保护太多或太少

    c++中的互斥量

    通过实例化 std::mutex 来创建互斥量实例,需要包含头文件<mutex>

    使用方法:通过成员函数lock() 和unlock()来实现上锁和解锁

    #include <iostream>
    #include <thread>
    #include <mutex>
    
    using namespace std;
    
    class Test
    {
        std::mutex m;
    public:
        void add(int& num)
        {
            m.lock();//上锁
            ++num;
            cout << num << endl;
            m.unlock();//解锁
        }
    };
    
    int main()
    {
        int num = 1;
        Test test;
        thread t1(&Test::add, &test, std::ref(num));
        thread t2(&Test::add, &test, std::ref(num));
        t1.join();
        t2.join();
    }

    但是,实践中不推荐直接去调用成员函数,调用成员函数就意味着,必须在每个函数出口都要去调用unlock(),也包括异常的情况。C++标准库为互斥量提供了一个RAII语法的模板类std::lock_guard,在构造时就能提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁互斥量能被正确解锁。

    对于上个例子而言,可以改造为:

    #include <iostream>
    #include <thread>
    #include <mutex>
    
    using namespace std;
    
    class Test
    {
        std::mutex m;
    public:
        void add(int& num)
        {
            lock_guard<std::mutex> guard(m); //在构造时lock
            ++num;
            cout << num << endl;
        }//在析构时unlock
    };
    
    int main()
    {
        int num = 1;
        Test test;
        thread t1(&Test::add, &test, std::ref(num));
        thread t2(&Test::add, &test, std::ref(num));
        t1.join();
        t2.join();
    }

    另外要注意:在使用互斥量来保护数据时,要注意检查指针和引用。切勿将受保护数据的指针或引用传递到互斥锁作用域之外,无论是函数返回值,还是存储在外部可见内存,亦或是以参数的形式传递到用户提供的函数中去。只要没有成员函数通过返回值或者输出参数的形式,向其调用者返回指向受保护数据的指针或引用,数据就是安全的。

    接口间的竞争

    考虑一个std::stack,它有top(), pop(), empty()等方法。即使我们在每个方法调用内部使用互斥量std::mutex 进行保护,由于接口之间的依赖关系,还是会存在竞争。例如:在调用empty()和调用top()之间,可能有来自另一个线程的pop()调用并删除了最后一个元素。这是一个经典的条件竞争,使用互斥量对栈内部数据进行保护,但依旧不能阻止条件竞争的发生,这就是接口固有的问题。

    死锁

    线程有对锁的竞争:一对线程需要对他们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个解锁。这样没有线程能工作,因为他们都在等待对方释放互斥量。这种情况就是死锁,它的最大问题就是由两个或两个以上的互斥量来锁定一个操作。

    避免死锁的一般方法:就是让两个互斥量总以相同的顺序上锁:总在互斥量B之前锁住互斥量A,就永远不会死锁。

    std::lock——可以一次性锁住多个(两个以上)的互斥量,并且没有副作用(死锁风险)。因为std::lock要么将两个锁都锁住,要不一个都不锁。

    // 这里的std::lock()需要包含<mutex>头文件
    class some_big_object;
    void swap(some_big_object& lhs,some_big_object& rhs);
    class X
    {
    private:
      some_big_object some_detail;
      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::adopt_lock作用是声明互斥量已在本线程锁定,std::lock_guard只是保证互斥量在作用域结束时被释放
        std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock);
        swap(lhs.some_detail,rhs.some_detail);
      }
    };

    避免死锁的一些方法

    1. 避免嵌套锁

    一个线程已获得一个锁时,再别去获取第二个。因为每个线程只持有一个锁,锁上就不会产生死锁。当你需要获取多个锁,使用一个std::lock来做这件事(对获取锁的操作上锁),避免产生死锁。

    2. 避免在持有锁时调用用户提供的代码

    因为代码是用户提供的,你没有办法确定用户要做什么;用户程序可能做任何事情,包括获取锁。

    3. 使用固定顺序获取锁

    当硬性条件要求你获取两个或两个以上的锁,并且不能使用std::lock单独操作来获取它们;那么最好在每个线程上,用固定的顺序获取它们(锁)。

    4. 使用锁的层次结构

    std::unique_lock——灵活的锁

    互斥锁保证了线程间的同步,但是却将并行操作变成了串行操作,这对性能有很大的影响,所以我们要尽可能的减小锁定的区域,也就是使用细粒度锁。这一点lock_guard做的不好,不够灵活,lock_guard只能保证在析构的时候执行解锁操作,lock_guard本身并没有提供加锁和解锁的接口,但是有些时候会有这种需求。

    class LogFile {
        std::mutex _mu;
        ofstream f;
    public:
        LogFile() {
            f.open("log.txt");
        }
        ~LogFile() {
            f.close();
        }
        void shared_print(string msg, int id) {
            {
                std::lock_guard<std::mutex> guard(_mu);
                //do something 1
            }
            //do something 2
            {
                std::lock_guard<std::mutex> guard(_mu);
                // do something 3
                f << msg << id << endl;
                cout << msg << id << endl;
            }
        }
    
    };

    上述代码因为有两段代码需要上锁保护,所以使用lock_guard只能用两个局部变量来上锁和解锁,使用一个也可以,但锁的粒度太大,影响效率,这个时候就可以用unique_lock。

    unique_lock它提供了lock()unlock()接口,能记录现在处于上锁还是没上锁状态,在析构的时候,会根据当前状态来决定是否要进行解锁(lock_guard就一定会解锁)。

    上面的代码使用unique_lock可以修改为:

    class LogFile {
        std::mutex _mu;
        ofstream f;
    public:
        LogFile() {
            f.open("log.txt");
        }
        ~LogFile() {
            f.close();
        }
        void shared_print(string msg, int id) {
    
            std::unique_lock<std::mutex> guard(_mu);
            //do something 1
            guard.unlock(); //临时解锁
    
            //do something 2
    
            guard.lock(); //继续上锁
            // do something 3
            f << msg << id << endl;
            cout << msg << id << endl;
            // 结束时析构guard会临时解锁
            // 这句话可要可不要,不写,析构的时候也会自动执行
            // guard.ulock();
        }
    
    };

    另外,还可以使用std::defer_lock设置初始化的时候不进行默认的上锁操作:

    std::unique_lock<std::mutex> guard(_mu, std::defer_lock);

    unique_lock的灵活是有代价的,因为它内部需要维护锁的状态,所以效率要比lock_guard低一点,在lock_guard能解决问题的时候,就是用lock_guard,反之,使用unique_lock

    另外,unique_locklock_guard都不能复制,lock_guard不能移动,但是unique_lock可以。

    保护共享数据的初始化过程

    某些场景下,我们需要代码只被执行一次,比如单例类的初始化,考虑到多线程安全,需要进行加锁控制。C++11中提供的call_once可以很好的满足这种需求。

    #include<mutex>
    
    template <class Fn, class... Args>
    void call_once (once_flag& flag, Fn&& fn, Args&&...args);

    第一个参数是std::once_flag的对象(once_flag是不允许修改的,其拷贝构造函数和operator=函数都声明为delete),第二个参数可调用实体,即要求只执行一次的代码,后面可变参数是其参数列表。

    call_once保证函数fn只被执行一次,如果有多个线程同时执行函数fn调用,则只有一个活动线程(active call)会执行函数,其他的线程在这个线程执行返回之前会处于”passive execution”(被动执行状态不会直接返回,直到活动线程对fn调用结束才返回。对于所有调用函数fn的并发线程,数据可见性都是同步的(一致的)。还有一个要注意的地方是 once_flag的生命周期,它必须要比使用它的线程的生命周期要长。所以通常定义成全局变量比较好。

    一些其他的互斥锁

    嵌套锁:std::recursive_mutex

    除了可以对同一线程的单个实例上获取多个锁,其他功能与std::mutex相同。互斥量锁住其他线程前,必须释放拥有的所有锁,所以当调用lock()三次后,也必须调用unlock()三次。

    shared_mutex(c++17)/std::shared_timed_mutex(C++ 14)

    shared_mutex的适用场景比较特殊:一个或多个读线程同时读取共享资源,且只有一个写线程来修改这个资源,这种情况下才能从shared_mutex获取性能优势。对于不需要去修改数据结构的线程,

    可以使用std::shared_lock<std::shared_mutex>获取访问权。

    参考资料:

    https://chenxiaowei.gitbook.io/c-concurrency-in-action-second-edition-2019/3.0-chinese/3.2-chinese

    https://www.jianshu.com/p/34d219380d90

    https://blog.csdn.net/xijiacun/article/details/71023777

  • 相关阅读:
    视觉SLAM十四讲课后习题—ch13
    视觉SLAM中涉及的各种坐标系转换总结
    《视觉SLAM十四讲》笔记(ch13)
    《视觉SLAM十四讲》笔记(ch12)
    《视觉SLAM十四讲》课后习题—ch7(更新中……)
    安装opencv_contrib(ubuntu16.0)
    《视觉SLAM十四讲》笔记(ch8)
    如何将“您没有打开此文件的权限”的文件更改为可读写的文件
    《视觉SLAM十四讲》笔记(ch7)
    ubuntu16.04下跑通LSD-SLAM的过程记录
  • 原文地址:https://www.cnblogs.com/duan-shui-liu/p/11435744.html
Copyright © 2020-2023  润新知