当前位置 博文首页 > 让代码改变世界:C++中的线程同步

    让代码改变世界:C++中的线程同步

    作者:[db:作者] 时间:2021-06-24 21:14

    1. 背景知识
    线程是计算机科学中一个常见的概念,很多开发人员也在开发多线程程序。很多文章也都在讲如何创建线程,以及如何使用线程。但是,对线程背景的介绍还是比较少的,新人非常容易"陷入局优解",产生只见树木不见森林的现象,下面就我了解到的多线程背景给大家做一个介绍,希望可以起到抛砖引玉的作用。
    首先多线程是必须的,这一点应该不用质疑,就不多啰嗦了。当计算机先驱们意识到这一点的时候,做了两件事:

    1)硬件上的支持,这个大家还是了解一下的好,也就是说并不是所有硬件系统都支持多线程,一个计算机系统要支持多线程,硬件首先要支持才可以。
    2)软件上的支持,这个对我们就比较重要了,重点在于要搞清几个领域中的多线程概念,一是协议中的多线程,二是Linux系统中的多线程(Windows当然也有,但都懂得),三是各种语言中的多线程。下面重点介绍一下这一部分。

    协议中的多线程:协议一般指POSIX、LSB等等,这些协议之间相互借鉴引用,又有所差别,这是由于历史原因导致的,合合分分在计算机发展史也非常常见,一般人员也不用太关心到底有哪些差别(有需要的可以找专门的博客去看),只要明白有这么个东西就可以了,不要别人一说POSIX中的多线程如何如何,你还不知道怎么回事儿就可以了。

    Linux中的多线程:Linux可以看做是LSB协议的实现,当然又由于协议间的关系比较模糊,LSB又很大程度上采用了POSIX标准,所以POSIX标准和Linux中的具体实现也基本差不多,但是要搞明白,这是两个东西,协议是写到纸上的,Linux是真实的代码,二者只是"碰巧差不多"罢了。接下来,由于Linux是由C语言实现的,所以就导致C语言可以直接调用Linux中的多线程相关接口(就是pthread类接口),但其他语言由于"并非亲生",是没办法直接调用的,这就要提到第三个方面了。

    各种语言中的多线程:这应该是大家最熟悉的部分了,很多文章都在介绍这一部分。C++、Java、Python、Lua...总之你听说过的所有支持多线程的语言,既然不能直接调用,那就只能间接调用了,方法也很简单,语言设计者直接给你一个接口,你去调用就可以了,底层怎么实现的你不用管,当然,你不说咱也知道,肯定是调用了Linux中用C写的接口。这么做的好处非常多,因为各种语言特点不同,谁都可以根据自己特点来提供各种创建线程方法,这里面还有一些第三方库,基本思想一样,都是为了提供方便快捷的多线程开发接口。
    说了这么多,大都是了解内容,只要以后遇到多线程的文章或者接口,知道它们是属于哪部分的就可以了,千万不要以为各种语言或者第三方库提供的接口就是多线程本身。他们背后都是调用了Linux系统的C语言接口,而Linux的接口又基本是根据POSIX协议来写的,就是这么个关系。

    2. C++语言中的线程同步

    背景知识说了不少,关键还是要落地的,下面就拿C++为例,来说一说这种语言提供了哪些多线程接口。

    按理来说,C++是完全兼容C的,所以C++是可以直接利用C语言中的线程相关接口的,实际上也是这样,在C++11标准出来之前,C++一直是利用的C风格的接口。但C语言的特点体现在“程序员的精准控制”上,这就导致了接口相对比较难用,C++11为了解决这个问题,于是就在C++11标准中引入了一套新的线程接口。

    2.1 先让多线程跑起来

    /*
        注意:
        1.以下代码中的thread对象为C++11中引入的,编译时要添加编译选项-std=gnu++11
        2.多线程库默认是不链接的,要添加链接选项-pthread
        所以编译命令为:g++ thread.cpp -o thread -std=gnu++11 -pthread
    */
    #include <thread> //多线程库
    #include <iostream>
    #include <unistd.h> //sleep()接口
    
    using namespace std;
    
    int g_num = 0;
    
    void consumer()  //多线程之一
    {  
        for(int i = 0; i < 10; i++){
            g_num++;
            cout << "consumer 1: " << g_num << endl;
            sleep(2);
            cout << "consumer 2: " << g_num << endl;
        }
    }  
    
    void producer() //多线程之二
    {  
        for(int i = 0; i < 10; i++){
            g_num++;
            sleep(1);
        }
    } 
    
    int main()  
    {  	
        std::thread t1(consumer); //创建一个多线程对象,入口为consumer
        std::thread t2(producer); //创建一个多线程对象,入口为producer
    	
        t1.join(); //先用着,以后会解释
        t2.join();
    	
        return 0;  
    }  

    这段程序很简单,就是构造了两个thread对象,利用两个函数指针对其进行了初始化,当对象被定义出来后,其对应的函数就会以一个新的线程开始运行,后面的join函数之后会解释,现在先来看一下这段代码的输出。

    consumer 1: 1
    consumer 2: 3
    consumer 1: 4
    consumer 2: 6
    consumer 1: 7
    consumer 2: 9
    consumer 1: 10
    consumer 2: 12
    consumer 1: 13
    consumer 2: 15
    consumer 1: 16
    consumer 2: 16
    consumer 1: 17
    consumer 2: 17
    consumer 1: 18
    consumer 2: 18
    consumer 1: 19
    consumer 2: 19
    consumer 1: 20
    consumer 2: 20

    consumer函数的输出可能会让你大跌眼镜,如果只看consumer内部的函数,这输出是绝对不可能出现的,连续两次输出同一个变量,值怎么会不同呢?当然,这里假设大家对多线程都有了一些了解,所以也就不故弄玄虚了,正是因为另一个线程producer中的代码也对全局变量进行了修改,所以才会导致consumer中两次连续的输出不一致。这揭示了多线程一个重要特点,即多个线程间的执行顺序是随机的,不固定的。这种情况下,如果线程间没有任何关系,自己跑自己的,那也没问题,但情况往往不是这样,不同线程之间很有可能会访问同一个资源(目前理解成全局变量就好),所以接下来的内容才是重点,那就是锁机制。

    锁机制是和多线程息息相关的一个概念,简单来说,锁的出现就是为了解决多个线程访问同一个变量产生的,通过一个锁可以将线程的某部分代码锁起来,直到该段代码执行完,否则是不会被中断的。锁的作用常常会被拿来和上厕所比较,厕所的位置可以看做是一个全局变量,每个上厕所的人都是一个单独的线程,正如我们上厕所要一个一个上一样,对全局变量的访问也要一个一个来,所以和厕所一样,要给全局变量加一个锁,否则就会出现两个人蹲一个坑位的尴尬情况。

    说了这么多,来看看锁是怎么用的吧。

    #include <thread> //多线程库
    #include <iostream>
    #include <unistd.h> //sleep()接口
    #include <mutex> //锁库
    
    using namespace std;
    
    std::mutex g_mutex; //锁
    
    int g_num = 0;
    
    void consumer()  
    {  
    	
        for(int i = 0; i < 10; i++){
            sleep(1);
            std::unique_lock<std::mutex> lock( g_mutex ); //加锁
            g_num++;
            cout << "consumer 1: " << g_num << endl;
            sleep(2);
            cout << "consumer 2: " << g_num << endl;
    	}
    }  
    
    void producer()  
    {  
        for(int i = 0; i < 10; i++){
            sleep(1);
            std::unique_lock<std::mutex> lock( g_mutex ); //加锁
            g_num++;
        }
    } 
    
    int main()  
    {  	
    
        std::thread t1(consumer);
        std::thread t2(producer);
    	
        t1.join();
        t2.join();
    	
        return 0;  
    } 

    程序就是在原来的基础上,给每个线程的代码都加了一个锁(注意是同一个),这个时候我们再来看一下输出。

    consumer: 1
    consumer: 1
    consumer: 3
    consumer: 3
    consumer: 5
    consumer: 5
    consumer: 7
    consumer: 7
    consumer: 9
    consumer: 9
    consumer: 11
    consumer: 11
    consumer: 13
    consumer: 13
    consumer: 15
    consumer: 15
    consumer: 17
    consumer: 17
    consumer: 19
    consumer: 19

    从结果可以看出,显然锁已经起作用了,consumer前后两次的打印相同了,中间没有其他进程来访问全局变量了,它可以安心上厕所了。

    这里说一下锁变量(lock)定义的位置,由于C++崇尚RAII机制,即变量定义的时候,就是获取资源的时候,而变量被析构的时候,就是释放资源的时候,所以C++不要求像C那样有明显的“加锁”、“解锁”操作了,而是隐含在lock变量的构造和析构中了,也就是说lock变量构造的时候(也就是定义的时候),我们其实是执行了“加锁”操作,而当lock对象析构的时候(离开其作用域时),我们其实是执行了“解锁”操作,所以如果线程是一个循环体,那要注意不要在循环体外定义锁对象,因为这样会导致之后该线程一直占有该锁,其他线程无法继续执行的情况。而且,一般我们会在“析构”与“构造”之间加一段延时,来保证其他线程可以正常获取锁,总之,锁的定义规则是范围越小越好,不要你刚睡醒还没起床就先把厕所给占上。

    锁的问题就讲到这里,有需求的可以搜索相关文章继续深造,我们继续我们的话题。

    事情到这里仿佛已经很令人满意了,我们引入锁解决了多个线程同时访问一个全局变量的问题,使得每个线程都可以安心上厕所了。但实际情况往往更复杂,即线程间可能不仅仅是要排队上厕所,他们很可能还要同心协力来完成一个整体的功能。这就是典型的生产者-消费者模型了,生产者生产资源,消费者消费资源,显然两个线程不能各自为政,否则就会出现生产者还没生产出来,消费者已经消费了,或者反之的情况。也就是说,我们要引入一种机制,使两个线程之间可以进行某种“通讯”,消费者要消费的时候,先看看有没有资源,没有就等着,等生产者生产出来后,再通知消费者去消费。这一功能C++是利用条件变量实现的。

    #include <thread> //多线程库
    #include <iostream>
    #include <mutex> //锁库
    #include <condition_variable> //条件变量
    
    using namespace std;
    
    std::mutex g_mutex; //控制队列的锁
    std::condition_variable g_cond; //条件变量
    
    int g_num = 0;
    
    void consumer()  
    {  
        while(true){
            std::this_thread::sleep_for(std::chrono::milliseconds(500));
            std::unique_lock<std::mutex> lock( g_mutex ); //加锁
            g_cond.wait( lock, [](){ return g_num > 0; } ); //等待,并释放锁
            cout << "consumer: " << g_num << endl;
            g_num--;
            cout << "consumer: " << g_num << endl;
        }
    }  
    
    void producer()  
    {  
        
        while(true){
            std::this_thread::sleep_for(std::chrono::milliseconds(500));
            std::unique_lock<std::mutex> lock( g_mutex ); //加锁
            g_num++;
            cout << "producer: " << g_num << endl;
            g_cond.notify_all();//唤醒一个等待线程,释放锁
        }
    } 
    
    int main()  
    {   
    
        std::thread t1(consumer);
        std::thread t2(producer);
        
        t1.join();
        t2.join();
        
        return 0;  
    }  

    我们将一个全局变量当做某种资源,消费者会消费这种资源(变量-1)而生产者会生产这种资源(变量+1),当消费者发现资源数小于0时,便会进入等待。而当生产者生产出一个资源时,则会唤醒对应的消费者去消费。所以上述代码的执行效果如下:

    producer: 1
    consumer: 1
    consumer: 0
    producer: 1
    consumer: 1
    consumer: 0
    producer: 1
    consumer: 1
    consumer: 0
    producer: 1
    consumer: 1
    consumer: 0
    ...
    

    ?

    下一篇:没有了