• 2022-08-01 C++并发编程(四)



    前言

    在C++多线程并发编程中,各个线程可以共享主线程全局变量,这既是优点,也是缺点。

    我们不用特殊机制即可在线程间共享数据,同样意味着,每个线程修改共享数据都会影响所有线程,引起数据竞争,导致计算结果不确定。

    因此我们需要了解互斥锁,条件变量等,用以梳理计算顺序,辅助程序逻辑,防止程序错误。


    一、互斥锁

    通过C++的互斥库,可以方便的引入互斥变量,进而在需要防止数据竞争的地方加以保护。

    可以用 lock() 函数进行加锁,使得数据只能由持有锁的线程进行读写改动,用 unlock() 函数进行解锁,释放数据的读写权限。

    当然,在C++中,更常见的则是利用 std::lock_guard guard(some_mutex) 对象将互斥对象封装,利用 RAII 机制,自动进行锁释放,防止不恰当的 unlock() 导致的错误。

    通过简单的示例,我们可以感受一下互斥锁的基本运用。

    标准库的 list 是非线程安全的,我们不能保证添加数据的安全,也不能保证检查是否含有数据的安全。

    因为这两个操作不能保证原子性,很可能在添加数据时,多个线程同时找到链表末尾 end ,并分别在 end 后添加数据,导致数据添加混乱,以及内存泄漏。

    而检查是否含有数据 A,则可能在一个线程检查时,另一个线程删除 A 或增加数据 A,导致结果的错误。

    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    std::list<int> someList;
    std::mutex someMutex;
    
    void addToList(int newValue)
    {
        std::lock_guard<std::mutex> guard(someMutex);
        // c++17  std::scoped_lock guard(someMutex);
    
        someList.push_back(newValue);
    }
    
    auto listContains(int valueToFind) -> bool
    {
        std::lock_guard<std::mutex> guard(someMutex);
    
        return std::find(someList.begin(), someList.end(), valueToFind) !=
               someList.end();
    }
    
    void addSome(const std::vector<int> &someValue)
    {
        for (const auto &i : someValue)
        {
            addToList(i);
            fprintf(stdout, "Yes %d\n", i);
        }
    }
    
    void checkSome(const std::vector<int> &someValue)
    {
        for (const auto &i : someValue)
        {
            if (!listContains(i))
            {
                fprintf(stdout, "No %d\n", i);
            }
        }
    }
    
    auto main() -> int
    {
        std::vector<int> data;
        data.reserve(1000);
    
        std::vector<int> chkdata;
        chkdata.reserve(10);
    
        for (int i = 0; i != 1000; ++i)
        {
            data.push_back(i);
        }
    
        for (int i = 0; i < 1000; i += 100)
        {
            chkdata.push_back(i);
        }
    
        std::thread thrd(addSome, data);
        std::thread thrd2(checkSome, chkdata);
    
        thrd.join();
        thrd2.join();
    
        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
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72

    二、可能引起互斥锁失效的程序设计

    互斥锁的设计,只能保证持有锁的函数入口的调用,能够线程安全,但是如果其他线程通过访问没有锁的函数入口读写数据,则意味着数据保护的失效。

    这本质上是程序设计的逻辑出现问题,会导致程序顺利执行,但结果和预期完全不同,一旦涉及较大的程序,则极难排查。

    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    std::list<int> someList;
    std::mutex someMutex;
    
    void addToList(int newValue)
    {
        std::lock_guard<std::mutex> guard(someMutex);
        // c++17  std::scoped_lock guard(someMutex);
    
        someList.push_back(newValue);
    }
    
    auto listContains(int valueToFind) -> bool
    {
        std::lock_guard<std::mutex> guard(someMutex);
    
        return std::find(someList.begin(), someList.end(), valueToFind) !=
               someList.end();
    }
    
    void addSome(const std::vector<int> &someValue)
    {
        for (const auto &i : someValue)
        {
            addToList(i);
            fprintf(stdout, "Yes %d\n", i);
        }
    }
    
    void addSomeUnsafe(const std::vector<int> &someValue)
    {
        for (const auto &i : someValue)
        {
            someList.push_back(i);
            fprintf(stdout, "Yes %d\n", i);
        }
    }
    
    void checkSome(const std::vector<int> &someValue)
    {
        for (const auto &i : someValue)
        {
            if (!listContains(i))
            {
                fprintf(stdout, "No %d\n", i);
            }
        }
    }
    
    auto main() -> int
    {
        std::vector<int> data;
        data.reserve(1000);
    
        std::vector<int> chkdata;
        chkdata.reserve(10);
    
        for (int i = 0; i != 1000; ++i)
        {
            data.push_back(i);
        }
    
        for (int i = 0; i < 1000; i += 100)
        {
            chkdata.push_back(i);
        }
    
        std::thread thrd(addSome, data);
        std::thread thrd2(checkSome, chkdata);
        std::thread thrd3(addSomeUnsafe, data);
    
        thrd.join();
        thrd2.join();
        thrd3.join();
    
        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
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83

    三、并发逻辑要排除固有条件竞争

    对于某些容器操作,比如栈容器,C++标准库提供的接口是天然存在条件竞争的。

    比如出栈,第一步是判断栈非空,第二步是出栈操作,逻辑上应该是原子化的,但现实则并不是,如果用于多线程,需要进行相应的改造。

    而改造也是需要动些脑筋的,虽然是一步原子操作,如何传出两个讯息,其一,是否为空,其二弹出一个数据。

    我们可以用引用类型作为形参,取得弹出的数据,函数返回bool值,确定栈在弹出数据时是否为空,确定引用对象的赋值是否是有意义的赋值。

    还有一种思路是函数返回指针,指针为空意味着弹出时栈为空,指针不空意味着栈弹出时不空,结果是有意义的。

    #include 
    #include 
    #include 
    #include 
    #include 
    
    struct empty_stack : std::exception
    {
        auto what() const noexcept -> const char * override;
    };
    
    template <typename T>
    struct threadsafe_stack
    {
        threadsafe_stack();
        threadsafe_stack(const threadsafe_stack &);
        auto operator=(const threadsafe_stack &) -> threadsafe_stack & = delete;
        void push(T new_value);
        auto pop() -> std::shared_ptr<T>;
        auto pop(T &value) -> bool;
        auto empty() const -> bool;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    总结

    多线程访问读写数据共享数据很容易,但做对,得到想要的结果很难。

    通过最简单的互斥锁,可以确保单一入口的函数对共享数据访问线程安全,然而一旦混合无锁入口,则线程就不安全了。

    同时,设计线程安全的程序还需考虑排除固有的数据竞争,将需要原子化的部分合在一起,要记住,每一步都是线程安全的,组合起来则不一定是线程安全的,需要进行重新的设计。

  • 相关阅读:
    开关电源环路稳定性分析(03)-开环电源
    案例分析-金融业网络安全攻防
    并查集(数据结构)
    GBase 8c V3.0.0数据类型——EXTRACT
    电脑技巧:推荐一款桌面增强工具AquaSnap(附下载)
    iptables防火墙 (SNAT、DNAT)
    常见安全设备介绍
    ranger的只读(read)权限引起的
    uniapp 悬浮窗插件(在其他应用上层显示) Ba-FloatWindow
    9.13校招 实习 内推 面经
  • 原文地址:https://blog.csdn.net/m0_54206076/article/details/126094964