• 2022-08-04 C++并发编程(七)



    前言

    除了锁,C++还有其他方式保护共享数据,比如一些生成后就只读的数据,生成后极少更改的数据。

    此外,还有一种可递归的锁,虽然极少使用,甚至不推荐使用。


    一、只需在初始化中保护的共享数据

    我们设计程序时,可能用到延迟初始化,如下代码:

    #include 
    #include 
    #include 
    #include 
    
    struct someResource
    {
        void doSomething() const
        {
            std::cout << a << std::endl;
        }
    
        int a = 0;
    };
    
    std::shared_ptr<someResource> resourcePtr;
    
    void foo()
    {
        if (!resourcePtr)
        {
            resourcePtr = std::make_shared<someResource>();
        }
        resourcePtr->doSomething();
    }
    
    auto main() -> int
    {}
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    为了将以上代码用于多线程,一般需要在判断语句前加锁:

    std::shared_ptr<someResource> resourcePtr;
    
    void foo()
    {
        std::unique_lock<std::mutex> lk(resourceMutex);
    
        if (!resourcePtr)
        {
            resourcePtr = std::make_shared<someResource>();
        }
    
        lk.unlock();
    
        resourcePtr->doSomething();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    然而,其后果是由于所有线程都需要竞争锁,导致了多线程逻辑又回到单线程逻辑,除了徒添烦恼,没有什么卵用。

    于是为了改进以上代码,实施了会导致 UB 的双检验:

    std::shared_ptr<someResource> resourcePtr;
    
    void undefinedBehaviourWithDoubleCheckedLocking()
    {
        if (!resourcePtr)
        {
            std::lock_guard<std::mutex> lk(resourceMutex);
            if (!resourcePtr)
            {
                resourcePtr = std::make_shared<someResource>();
            }
        }
        resourcePtr->doSomething();
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    如果上述代码中,智能指针指向对象的初始化是原子性的,问题不大,但如果分成两步,初始化指针指向的对象,操作指针指向对象,以达到最终可用状态,那么无论过程是否上锁,都会出现指针非空,但对象并未可用状态,然而其他线程已经开始使用此指针指向的对象数据了,这时一种隐蔽很深的逻辑错误。

    为了改良设计,我们可以用 std::call_once() 函数进行初始化:

    std::once_flag resourceFlag;
    std::shared_ptr<someResource> resourcePtr;
    
    void initResource()
    {
        resourcePtr = std::make_shared<someResource>();
    }
    
    void fooInitOnce()
    {
        std::call_once(resourceFlag, initResource);
        resourcePtr->doSomething();
    }
    
    auto main() -> int
    {
        fooInitOnce();
        return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    同样,可以利用 std::call_once() 做线程安全的类成员数据延迟初始化。

    例如取得连接很耗时,也只需一次,则可以在操作类函数,进行收或发数据时进行。

    struct X
    {
        explicit X(const connectionInfo &connectionDetails_)
            : connectionDetails(connectionDetails_)
        {}
    
        void sendData(const dataPacket &data)
        {
            std::call_once(connectionInitFlag, &X::openConnection, this);
            connection.sendData(data);
        }
    
        auto receiveData() -> dataPacket
        {
            std::call_once(connectionInitFlag, &X::openConnection, this);
            return connection.receiveData();
        }
    
      private:
        void openConnection()
        {
            connection = connectionManager.open(connectionDetails);
        }
    
        connectionInfo connectionDetails;
        connectionHandle connection;
        std::once_flag connectionInitFlag;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    对于全局只需唯一单例的设计,C++11后一般用局部静态返回函数完成,不必用 std::call_once()

    struct myClass
    {
        int a = 0;
    };
    
    auto getMyClassInstance() -> myClass &
    {
        static myClass instance;
        return instance;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    局部静态变量,只初始化一次,且一直存在直至程序结束。

    二、对于读多,写极少的共享数据

    有些数据,一旦写入则极少更改,而且基本是只需要读,C++是否有能够锁写不锁读的锁呢?

    有但只在至少C++14以上支持,读写锁或称共享锁 std::shared_mutex,通过 std::shared_lock 模板加锁,其他线程可共享持有 std::shared_lock,但一旦有线程要持有普通锁 std::lock_guard 则会阻塞,直到所有持有共享锁的线程释放。

    反之只要一个线程持有普通锁,所有线程不可持有共享锁,直到普通锁释放。

    注意,一般互斥变量要用mutable修饰,因为用锁的函数很可能是 const 成员函数。

    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    struct dnsEntry
    {
        int a = 0;
    };
    
    struct dnsCache
    {
        auto findEntry(const std::string &domain) const -> dnsEntry
        {
            std::shared_lock<std::shared_mutex> lk(entryMutex);
            const auto it = entries.find(domain);
            return (it == entries.end()) ? dnsEntry() : it->second;
        }
    
        void updateOrAddEntry(const std::string &domain, const dnsEntry &dnsDetails)
        {
            std::lock_guard<std::shared_mutex> lk(entryMutex);
            entries[domain] = dnsDetails;
        }
    
      private:
        std::map<std::string, dnsEntry> entries;
        mutable std::shared_mutex entryMutex;
    };
    
    auto main() -> int
    {
        dnsEntry a;
        dnsCache b;
        b.updateOrAddEntry("test: ", a);
        b.updateOrAddEntry("test2: ", a);
        auto c = b.findEntry("test: ");
    
        return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44

    三、递归加锁

    首先,一般互斥变量 std::mutex 不可以重复加锁,但递归互斥变量 std::recursive_mutex 除外。

    #include 
    
    std::recursive_mutex m;
    
    auto fn(int b) -> int
    {
        std::lock_guard<std::recursive_mutex> lk(m);
        if (b == 0)
        {
            return 0;
        }
        return b + fn(b - 1);
    }
    
    auto main() -> int
    {
        int c = fn(5);
        return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    其次,一般来讲,用到递归锁,说明程序设计可能出了问题,比如一个类中的两个公有函数,两个函数都上锁,其中一个函数调用另一个函数,那就必然重复上锁,一般互斥锁会报错,而暴力的使用递归互斥就可以解决,但这明显不是递归锁的使用场景。

    以上情况需要尽量避免。


    总结

    对于只需初始化一次,后续不变的公有变量,我们 可以通过一次初始化而非一直加锁解决,对于读多写少的数据,可以使用读写锁解决。

    对于可能出现的递归加锁,我们有相应的工具,但通常是设计出了问题,需要理清逻辑更改设计,而非简单粗暴的递归加锁。

  • 相关阅读:
    数据挖掘技术-掌握ufunc函数
    多功能手持VH501TC采集仪如何处理监测数据
    python-docx 使用xml为docx不同的章节段落设置不同字体
    【记一次el-select undefined】
    【2022CSPJ普及组】 T3.逻辑表达式(expr)
    React项目实战之租房app项目(五)顶部导航组件封装&CSS Modules解决样式覆盖&地图找房模块封装
    数据结构——树型结构二叉树与堆的代码功能讲解(1)
    Blazor和Vue对比学习(基础1.3):属性和父子传值
    go中读写锁(rwmutex)源码解读实现原理
    Day696.Jetty如何实现具有上下文信息的责任链 -深入拆解 Tomcat & Jetty
  • 原文地址:https://blog.csdn.net/m0_54206076/article/details/126153312