天天看点

C++多线程之旅实战-带锁的并发数据结构

目录

      • 前言
      • 最小化锁粒度
      • 确保锁的正确性
      • 线程启动时间
      • 总结

前言

前面几部分都是讲的并发编程的一些理论知识,包括线程概念、互斥元的使用、异步线程的使用、原子类型等等。但是这都是纸面上的知识,还没有应用到实战上,而且关于并发还有一些设计技巧,比如

程序段1 // 无需独占
	程序段2 // 访问内存空间
	程序段3 // 打印到屏幕
           

有两种加锁方式

方式一:										方式二:
	lock();										程序段1
	程序段1										lock();
	程序段2										程序段2
	程序段3										unlock();
												程序段3
           

对于单线程程序来说这是无关紧要的,但是对于多线程来说则会差异很大。下面这个例子:

最小化锁粒度

mutex mu;
    int sum = 0;
    void fun_1() {
        for (int i = 0; i < 100000; ++i) {
            mu.lock();
            sum ++;
            mu.unlock();
        }
    }
    void fun_2(){
        mu.lock();
        for (int i = 0; i < 100000; ++i) {
            sum ++;
        }
        mu.unlock();
    }
    void test() {
        auto t1=std::chrono::steady_clock::now();
        //run code
        thread th1(fun_1);
        thread th2(fun_1);
        thread th3(fun_1);
        thread th4(fun_1);
        th1.join();
        th2.join();
        th3.join();
        th4.join();
        auto t2=std::chrono::steady_clock::now();
        double dr_ms=std::chrono::duration<double,std::milli>(t2-t1).count();
        cout << "fun_1 执行时间:" << dr_ms << endl;
        t1=std::chrono::steady_clock::now();
        //run code
        thread th5(fun_1);
        thread th6(fun_1);
        thread th7(fun_1);
        thread th8(fun_1);
        th6.join();
        th7.join();
        th8.join();
        th5.join();
        t2=std::chrono::steady_clock::now();
        dr_ms=std::chrono::duration<double,std::milli>(t2-t1).count();
        cout << "fun_2 执行时间:" << dr_ms;
    }
           

执行结果

C++多线程之旅实战-带锁的并发数据结构

执行结果大概相差一倍左右,而且随着线程和计算机线程数增加这个差值会更大。这是因为在

fun_2()

中循环除了

sum++

以外的部分应该不是独占的,线程在执行这一部分的时候可以把控制权交出去。但是

fun_2()

并没有这样做,导致大量的时间浪费。而且会空闲很多的线程资源。这就是多线程编程的第一个原则:确保锁的时间最小化。

确保锁的正确性

但是如果不加锁会出现什么问题呢? 来看第二个例子:

int sum = 0;
    void fun() {
        for (int i = 0; i < 100000; ++i) {
            sum = sum + 1;
        }
    }
    void test(){
        thread t1(fun);
        thread t2(fun);
        thread t3(fun);
        thread t4(fun);
        t1.join();
        t2.join();
        t3.join();
        t4.join();
        cout << "sum = " << sum;
    }
           

大家猜一下最后的结果是什么?

C++多线程之旅实战-带锁的并发数据结构
C++多线程之旅实战-带锁的并发数据结构

这是一个随机的值,因为:

C++多线程之旅实战-带锁的并发数据结构

同一块区域被多个线程访问,我以双线程为例:假设线程1拿到sum时值为100,在执行

sum++

过程中又被线程2访问,线程2获取的值也是100,此时线程1完成

sum++

,将执行后的结果放回原位置覆盖,此时sum变为了101,此时线程2也完成了操作,也将计算结果放回内存,覆盖之前的结果101。本来应该是两次

++

操作结果应该是102,结果由于多线程为加锁导致结果错误。这有引出了多线程的第二个原则:确保存取数据时要锁住正确的互斥量。

两条原则锁住正确的互斥量优先级更高,因为最小化互斥量只是会提高程序执行效率,而为正确锁住则会导致错误。

线程启动时间

在多线程中需要考虑线程启动的时间,下面先看例子:

mutex mut;
condition_variable cond;
void fun_1(){
    while(true){
        unique_lock<mutex> lk(mut);
        cout << "做包子" << endl;
        this_thread::sleep_for(chrono::seconds(1));
        cout << "做好了" << endl;
        lk.unlock();
        cond.notify_one();
    }
}
void fun_2(){
    while (true){
        unique_lock<mutex> lk(mut);
        cout << "买包子" << endl;
        cond.wait(lk);
        cout << "买到包子" <<endl;
    }
}
int main(){
    thread t1(fun_1);
    thread t2(fun_2);
    t1.join();
    t2.join();
}
           

这是一个典型的生产者-消费者模型,线程

fun_1

获得锁开始做包子,需要1秒时间,做好之后通知消费者前来购买;线程

fun_2

先到店里排队做好了就买到了。执行结果应该是一个做一个包子买一个包子,但是实际情况是这样吗?

C++多线程之旅实战-带锁的并发数据结构

会发现生产者一直在生产,但是没有消费者前来消费。这是因为生产做好之后会唤醒消费者,但是消费者在唤醒过程中,生产者进入了下一轮的生产中,继续占用的互斥量导致最终生产者-消费者模型的失败。

消费者线程从等待状态获得

notify()

转为锁池状态,获得互斥量,然后再进入执行状态;而生产者线程一直处于运行状态,会持续获得互斥量导致消费者线程无法从锁池状态转变为就绪状态,导致模型失效。

C++多线程之旅实战-带锁的并发数据结构

我们在每次生产者完成生产后等待1μs那么这个结果就不一样了:

C++多线程之旅实战-带锁的并发数据结构

这得到了我们所期望了生产者-消费者模型了,所以在并发编程中需要考虑线程唤醒的时间。

总结

其实书上关于这部分的内容并不是这样的,是关于容器的并发编程。这几个例子都是我和我师兄在学习过程中遇到的问题,然后分析讨论过后的结果。这一部分由于多线程会更多的接近底层,所以需要一定的操作系统的知识,至于带锁的方法,一个原则就是先正确再优化。希望能够帮助到大家!