八叶一刀·无仞剑

万物流转,无中生有,有归于无

导航

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

Posted on 2021-02-14 16:59  闪之剑圣  阅读(165)  评论(0编辑  收藏  举报

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

同步问题

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

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++库中的互斥量保护共享变量,
记住锁是很慢的,实际上,当你使用锁的时候,你的程序实在串行执行。如果你想让你的程序高度并行,有很多其他的解决方法更有效,但是已经超出了这篇文章的范畴了。

下一话

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