• C++并发教程-第二节:保护共享数据


    在上一篇文章里,我们知道了如何去创建线程,并让它们并行地执行一些代码。这些代码的执行都是各自独立的。然而在一般来说,我们在编写多线程程序时经常会涉及到线程间共享的数据。当我们这么做的时候,我们就会遇到新的问题:同步。
    我们将在下面的例程中看一看到底是什么问题。

    同步问题

    作为一个例子,我们将会创建一个简单的计数器结构。它有一个数值和方法来增加或减少这个数值。结构体的代码如下:

    struct Counter {
        int value;
        Counter() : value(0){}
    
        void increment(){
            ++value;
        }
    };
    

    这没有什么新鲜的,现在让我们创建一些线程并且执行一些增加操作:

    int main(){
    
        Counter counter;
    
        std::vector<std::thread> threads;
        for(int i = 0; i < 5; ++i){
            threads.push_back(std::thread([&counter](){
                for(int i = 0; i < 100; ++i){
                    counter.increment();
                }
            }));
        }
    
        for(auto& thread : threads){
            thread.join();
        }
    
        std::cout << counter.value << std::endl;
    
        return 0;
    }
    

    没有什么新鲜的,我们创建了五个线程,并且让每一个线程增加计时器100次。在所有的线程运行结束后,我们输出计时器的值。
    我期待这个程序会输出500.然而实际上,没有人能预测这个程序的输出究竟是什么。这儿是一些在我电脑上运行的结果:

    442
    500
    477
    400
    422
    487
    

    以上代码的问题在于执行计时器的增加操作并不是一个原子操作。事实上,这个增加操作分为三个步骤:
    1.读取计时器value的值
    2.给value加一
    3.将新的值存储到value中
    当你使用以上代码运行一个单独的线程,这是没问题的。它会按顺序地执行一个有一个的操作。但是当你拥有多个线程的时候,你就会开始遇到许多问题。想象一下下面的场景:
    1.线程一,读取value为0,增加1,所以value=1
    2.线程二,读取value为0,增加1,所以value=1
    3.线程一,将1存储到value中,value实际为1
    4.线程二,将1存储到value中,value实际为1
    这个场景起因于“交错”(interleaving)。交错描述了多个线程执行一些命令时可能出现的场景。即使对于三个指令和两个线程而言,也有很多可能发生交错的地方。当你有更多的线程和更多的指令时,几乎不可能完全避免交错。这个问题也会出现在当某个线程抢先执行一串指令的情况。
    有很多方法可以来解决这种问题:
    1.信号量
    2.原子引用
    3.监视器
    4.条件代码
    5.比较和交换
    等等
    在这篇博客中我们仅仅学习如何用信号量来解决这种问题。事实上,我们采用的是一种特殊的信号量,被称作互斥量(mutext)。互斥量是一个非常简单的对象。同一时间只有一个线程可以获得互斥量的锁,这种简单(或称之为强大)的特性可以帮助我们克服同步问题。

    使用互斥量保护计时器线程的安全

    在C++线程库中,互斥量定义在mutex.h文件中,并且由类std::mutex表达。一个互斥量有两个非常重要的方法:lock和unlock。就像它们名称所指示的,第一个函数使线程获取锁,而第二个释放锁。lock函数是阻塞的,只有当锁被获得后,线程才能从lock函数中返回并进一步执行。
    为了让我们的计时器结构体更加安全,我们必须为它增加一个std::mutex成员,并且在它的每一个函数中lock和unlock这个成员:

    struct Counter {
        std::mutex mutex;
        int value;
    
        Counter() : value(0) {}
    
        void increment(){
            mutex.lock();
            ++value;
            mutex.unlock();
        }
    };
    

    如果我们现在再次拿前面的代码进行测试,这次我们的程序始终都会输出500。

    异常和锁

    现在,让我们再看看其他例子。假设这个计时器有一个减小操作,允许它的value值减小到0:

    struct Counter {
        int value;
    
        Counter() : value(0) {}
    
        void increment(){
            ++value;
        }
    
        void decrement(){
            if(value == 0){
                throw "Value cannot be less than 0";
            }
    
            --value;
        }
    };
    

    你现在不希望直接访问计数器结构体,那么我们创建一个带互斥量的包装类:

    struct ConcurrentCounter {
        std::mutex mutex;
        Counter counter;
    
        void increment(){
            mutex.lock();
            counter.increment();
            mutex.unlock();
        }
    
        void decrement(){
            mutex.lock();
            counter.decrement();        
            mutex.unlock();
        }
    };
    

    这个包装类在大多数情境下都是可以正常运行的,但是当一个异常出现在减少函数中时,就会遇到一个很大的问题。事实上,当一个异常出现的时候,unlock函数不再被调用,因此整个程序将会始终处于阻塞状态。为了解决这个问题,在抛出异常前,你必须采用try/catch结构来解锁。

    void decrement(){
        mutex.lock();
        try {
            counter.decrement();
        } catch (std::string e){
            mutex.unlock();
            throw e;
        } 
        mutex.unlock();
    }
    

    这段代码不复杂,但是看起来比较丑陋。现在想象一个你有一个包含十个不同退出点的函数,你必须在每一个退出点都要记得调用unlock,那整个函数中在某一处你忘记unlock的概率就会变得很大。甚至当你增加了一个退出点的时候,你可能会忘记调用unlock函数。
    下一个小节给出了一个很优雅的方案去解决这个问题。

    锁的自动管理

    当你想去保护一段代码(在我们的例子中是一个函数,但也可以存在于一段循环或控制结构中)时,有一个很好的解决方案去去避免忘记释放锁:std::lock_guard。
    这个类是一个简单而聪明的锁管理者。当我们创建了std::lock_guard,它自动调用互斥量的lock。当guard销毁的时候,它也会自动释放锁。你可以这样使用它:

    struct ConcurrentSafeCounter {
        std::mutex mutex;
        Counter counter;
    
        void increment(){
            std::lock_guard<std::mutex> guard(mutex);
            counter.increment();
        }
    
        void decrement(){
            std::lock_guard<std::mutex> guard(mutex);
            counter.decrement();
        }
    };
    

    如此简洁,岂不美哉?
    在这种情况下,你不必去惦记着函数中所有的退出点,它都被std::lock_guard对象的析构管理。

    总结

    我们讲解完了信号量。在这篇文章中,你学习了如何使用C++库中的互斥量保护共享变量,
    记住锁是很慢的,实际上,当你使用锁的时候,你的程序实在串行执行。如果你想让你的程序高度并行,有很多其他的解决方法更有效,但是已经超出了这篇文章的范畴了。

    下一话

    在下一篇博客里,我们会讨论一些关于互斥量的进阶的话题,也会涉及到如何利用条件变量来修复当前程序存在的问题。
    这篇文章涉及到的代码在这里

  • 相关阅读:
    C++ char和string的区别
    解读机器学习基础概念:VC维的来龙去脉 | 数盟
    链接集锦
    MSSQL数据库日志满的快速解决办法
    oracle执行update时卡死问题的解决办法
    正则表达式中/i,/g,/ig,/gi,/m的区别和含义
    windows下sqlplus怎么连接远程oracle
    C#中TransactionScope的使用方法和原理
    C#设置Cookies .
    IIS7及以上伪静态报错404
  • 原文地址:https://www.cnblogs.com/wickedpriest/p/14402214.html
Copyright © 2020-2023  润新知