C++多线程知识点总结

        本文内容是对B站上该视频的知识点总结,主要是为了方便后续对多线程的复习与巩固。

1 并发的基本概念

1.1 并行与并发

        并行:在同一时间下执行多个任务。

        并发:在一定的时间段内执行多个任务。

        显然,对于多任务而言,并行方法比并发的效率更高。比如,我们需要微信、QQ两个程序在后台进行运行,用于接收不同网友的消息,为了实现这一任务,我们可以在这台计算机上配置两个CPU分别处理这两个任务,这样(并行处理)的效率肯定要高一些。然而,“理想很美好,现实很骨感”,一方面CPU价格很高,无法让普通消费者承担一台计算机上拥有多个CPU的豪华配置,另一方面通过这种硬件堆叠的方法是无法解决计算机中多任务同时运行的问题的。因此,我们可以退而求其次,使用并发的方法,让一个CPU在一定的时间段内交替处理多个后台任务,这样就可以充分解决多任务运行的问题。因此,多线程并发处理的方法在计算机中非常基础且较为重要

 

C++多线程知识点总结_第1张图片

1.2 进程与线程

        进程:资源分配的最小单位。

        线程:CPU调度的最小单位

        相信上面两句话是对进程和线程的最常见的解释。然而,由于缺乏实际的举例说明,直接去理解显得非常的空泛。理解下面两个问题,对进程和线程就会有更好的理解:什么是进程?进程其实可以就可以看做是一个可执行程序,比如在电脑中运行的微信与QQ,这些其实就是一个进程,并且计算机都是以进程为单位分配资源的(所以所进程是资源分配的最小单位)。什么是线程?线程其实就是可执行程序中代码运行的通路,微信和QQ的所有代码都是借助线程进行运行的,并且各个线程之间需要CPU进行调度,从而实现了并发(所以说线程是CPU调度的最小单位)。

1.3 并发的实现方法

        为了实现并发,主要有两种方式:

        (1)多进程实现并发:后台多个可执行程序同时运行就实现了并发。

        (2)多线程实现并发:在进程中创建多个子线程,就可实现并发。

2 线程的创建与启动

2.1 通过函数启动线程

        下面是通过函数启动线程的逻辑代码,只需要向线程对象中传递函数名称以及函数对应的参数即可:

#include 
#include 

using namespace std;

void show(int info){
    cout << "info = "<< info<<"; child thread id = " 
    << this_thread::get_id() << endl;
}

int main(){
    // 打印当前主线程的id
    cout << "main thread id = " << this_thread::get_id() << endl;
    // 向线程中传递参数
    thread my_thread(show,1);
    my_thread.join();
    return 0;
}

        通过函数启动线程的主要思想就是在给线程对象传递一个函数名称,目的是告诉子线程去执行传入的函数。下面就是上述代码执行的结果:

          其中join()函数就是为了让主线程在此进行阻塞,让子线程运行结束之后再执行后续的逻辑代码,防止主线程崩掉之后,子线程不受控制,就变成了守护线程,这是我们不希望看到的,因此需要对子线程进行join操作的处理。

          线程中的参数传递与普通函数的参数传递有着较大的不同:

线程具有内部的存储空间,参数会按照默认的方式先复制到该处之后,新创建的执行线程才能够直接访问它们。然后,这些副本被当成临时变量,并以又值的形式传给新线程上的函数或者可调用对象。                                                                          ——《C++并发编程实战》

          为了对info进行引用传递,就必须使用std::ref函数(这种传递参数适合传递类或者结构体变量等占用内存较大的参数):

#include 
#include 

using namespace std;

void show(int& info){
    cout << "info = "<< info<<"; child thread id = " 
    << this_thread::get_id() << endl;
}

int main(){
    // 打印当前主线程的id
    cout << "main thread id = " << this_thread::get_id() << endl;
    // 向线程中传递参数
    int info = 109;
    thread my_thread(show,ref(info));
    my_thread.join();
    return 0;
}

2.2 通过类启动线程

        如果通过类启动线程的话,就需要重写()运算符即可,使得类对象可以向函数那样进行直接进行传参并执行对应的代码。

#include 
#include 

using namespace std;

class Show{
public:
    void operator()(int info){
        cout << "info = "<< info<<"; child thread id = " 
        << this_thread::get_id() << endl;
    }
};

int main(){
    // 打印当前主线程的id
    cout << "main thread id = " << this_thread::get_id() << endl;
    Show show;
    // show可以看做是一个函数
    thread my_thread(show, 1234);
    my_thread.join();
    return 0;
}

        下面是程序运行的结果:

        上面的方法是直接将整个类作为函数进行调用,功能较为单一。如果我们希望只调用该类中的某个方法呢?代码逻辑是怎样的? 其中thead的第一个参数是类成员方法的地址,第二个参数是类的地址,剩余的参数就是类成员方法所需要的实参了。

#include 
#include 

using namespace std;

class Show{
public:
    void show(int info){
        cout << "info = "<< info<<"; child thread id = " 
        << this_thread::get_id() << endl;
    }
};

int main(){
    // 打印当前主线程的id
    cout << "main thread id = " << this_thread::get_id() << endl;
    Show sobj;
    // 第一个&表示取地址;第二个&表示引用,也可以用ref()函数代替
    thread my_thread(&Show::show, &sobj, 123);
    // thread my_thread(&Show::show, ref(sobj), 123);
    my_thread.join();
    return 0;
}

        下面是程序的运行结果:

 2.3 使用lambda表达式启动线程

        我们同样也可以使用匿名函数的方式启动线程:

#include 
#include 

using namespace std;

int main(){
    // 打印当前主线程的id
    cout << "main thread id = " << this_thread::get_id() << endl;
    // 向线程中传递参数
    // 使用lambda表达式
    auto show = [](int info){
        cout << "info = "<< info<<"; child thread id = " 
        << this_thread::get_id() << endl;
    };
    thread my_thread(show, 1234);
    my_thread.join();
    return 0;
}

        下面是上述程序运行的结果:

        其中,lambda表达式[]中的有着不定的变量,其参数的具体含义如下:

(1) [ ]:空捕获列表,lambda不能使用所在函数中的变量。
(2) [=]:函数体内可以使用Lambda所在作用范围内所有可见的局部变量(包括Lambda所在类的this),并且是值传递方式(相当于编译器自动为我们按值传递了所有局部变量)。
(3) [&]:函数体内可以使用Lambda所在作用范围内所有可见的局部变量(包括Lambda所在类的this),并且是引用传递方式(相当于编译器自动为我们按引用传递了所有局部变量)。
(4) [this]:函数体内可以使用Lambda所在类中的成员变量
(5) [a]:将a按值进行传递。按值进行传递时,函数体内不能修改传递进来的a的拷贝,因为默认情况下函数是const的。要修改传递进来的a的拷贝,可以添加mutable修饰符。
(6) [&a]:将a按引用进行传递。
(7) [=,&a, &b]:除a和b按引用进行传递外,其他参数都按值进行传递。
(8) [&, a, b]:除a和b按值进行传递外,其他参数都按引用进行传递。
————————————————
版权声明:本文为CSDN博主「龚建波」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/gongjianbo1992/article/details/105128849 

3  互斥量与死锁

       在多线程执行的过程中,对于临界资源而言,同一时间段内会有多个线程对其进行访问(包括修改内容),这种多次访问可能会多次修改临界区资源的值,从而造成不可预料的结果。为了解决这个问题,需要保证每一次只有一个线程访问临界资源,这就引出了互斥量的概念。互斥量其实就是一把锁,当线程访问临界区后对临界区进行加锁,访问完临界区之后再将临界区进行解锁。其使用的流程是:上锁 ---》访问资源 ---》解锁。其代码如下:  

mutex my_mutex;
    vector record;
    for(int i = 0; i < 4; ++i){
        my_mutex.lock();
        record.push_back(i);
        my_mutex.unlock();
    }

        死锁是指当两个或两个以上的进程在访问同一个临界资源的时候,由于竞争或者通信等原因造成线程出现了堵塞,无法继续执行后续的代码逻辑。常见的死锁代码如下:

mutex my_mutex1,my_mutex2;
    vector record1,record2;
    // 资源1
    for(int i = 0; i < 4; ++i){
        my_mutex1.lock();
        my_mutex2.lock();
        record1.push_back(i);
        my_mutex2.unlock();
        my_mutex1.unlock();
    }
    
    // 资源2
    for(int i = 0; i < 4; ++i){
        my_mutex2.lock();
        my_mutex1.lock();
        record2.push_back(i);
        my_mutex2.unlock();
        my_mutex1.unlock();
    }

        如上述代码所示,在访问对record1和record2临界资源进行处理的时候,当线程A将my_mutex1上锁之后,此时线程B对资源2的my_mutex2进行了上锁,这样my_mutex1和my_mutex2都被上锁了,对两个临界资源都无法进行访问,两个线程就只会在此进行堵塞为了解决这个问题,必须将两个互斥量上锁的顺序保持一致!!!

mutex my_mutex1,my_mutex2;
    vector record1,record2;
    // 资源1
    for(int i = 0; i < 4; ++i){
        my_mutex1.lock();
        my_mutex2.lock();
        record1.push_back(i);
        my_mutex2.unlock();
        my_mutex1.unlock();
    }
    
    // 资源2
    for(int i = 0; i < 4; ++i){
        my_mutex1.lock();
        my_mutex2.lock();
        record2.push_back(i);
        my_mutex2.unlock();
        my_mutex1.unlock();
    }

        另一个方法是使用lock_guard来帮助我们对锁进行管理。当lock_guard对象创建的时候,就已经上锁了,当该对象销毁的时候,就对其进行解锁,因此我们就不需要进行lock和unlock了

mutex my_mutex1,my_mutex2;
    vector record;
    for(int i = 0; i < 4; ++i){
        lock_guard lg1(my_mutex1),lg2(my_mutex2);
        record.push_back(i);
        // 代码块结束之后就使其进行解锁
    }

    for(int i = 0; i < 4; ++i){
        lock_guard lg1(my_mutex1),lg2(my_mutex2);
        record.push_back(i);
    }

4 unique_lock与条件变量condition_variable

4.1 unique_lock

        上一节中提到可以使用lock_guard来辅助我们对lock进行上锁与解锁,但是在某些时候,我们需要灵活的控制这把锁,因此就需要比lock_gurad更灵活的unique_lock类。它的基本使用方式如下:

// 已经对互斥量mlock进行了上锁
std::unique_lock munique(mlock);

// 对互斥量mlock进行解锁
munique.unlock();

        如上述代码所示,当创建unique_lock对象munique的时候,就已经对mlock互斥量进行了上锁,因此就不需要再执行munique.lock()代码。当执行完临界区的操作之后,需要对munique进行解锁(当该变量被销毁之后,也会把锁进行释放了)。unique_lock之所以灵活,还因为它提供了多个参数,使得创建的unique_lock对象在初始化的时候有着不同的性质。unique_lock在创建对象的时候,第二个参数主要有四个选项:

        (1) 不添加第二个参数:上述的代码就是给munique不添加第二个参数,这表示着我们在创建munique对象的时候就已经给mlock加锁了,后续就不应该再次进行lock()操作。

        (2) defer_lock参数:这是让munique对象在创建的时候不给mlock加锁,使得程序员可以在后续的操作中灵活的控制上锁和解锁的时间(这时候unique_lock就完全退化成了mutext,需要程序员手动lock和unlock)必须保证互斥量没有上锁

// ulock并未对mlock进行上锁
unique_lock munique(mlock,defer_lock);
// 对mlock进行上锁
ulock.lock();
// 对mlock进行解锁
ulock.unlock();

        (3) try_to_lock参数:尝试对互斥量进行上锁,然后针对未成功上锁以及成功上锁两种情况进行处理。必须保证互斥量没有上锁

mutex mlock;
// 互斥量已经提前进行了上锁
unique_lock munique(mlock,try_to_lock);
if(munique.owns_lock()){    // 判断是否拿到mlock的所有权
    // 如果成功上锁了,就进行处理
    
}else{
    // 如果没有成功上锁,再进行处理
}

        其中owns_lock()函数是判断 munique是否获得了锁

        (4) adopt_lock参数:如果互斥量mlock已经上锁了,就可以使用这个作为第二个参数,防止munique在构造函数中再次对mlock进行上锁。使用条件:互斥量已经上锁了

mutex _mtx;
// 互斥量已经提前进行了上锁
_mtx.lock();
// adopt_lock参数告诉ulock在构造函数中不需要再进行lock
unique_lock ulock(_mtx,adopt_lock);
ulock.unlock();

4.2 条件变量condition_variable

        顾名思义,条件变量就是等待某种条件的发生,如果这个条件不发生,就一直在此等待,如果条件发生了,就继续执行后续的任务。条件变量通常和unique_lock进行联合使用,让子线程一直在等待某种条件的发生,并在此进行堵塞。

        比如在生产者消费者模式中,如果容器为空的时候,消费者线程就需要在此进行等待,直到容器中又有了新的物体。

int main(){
    // (1) 生产者:判断是否已满,如果满了就在进行堵塞
    std::mutex mtx;
    // 必须使用unique_lock,因为需要对lock进行控制
    std::unique_lock my_lock;
    std::condition_variable full;

    cout << "producer is full" << endl;
    full.wait(my_lock,[&]{
        if(idx >= numbers) 
            return false;
        return true; });



    // (2) 消费者:另一个线程需要对full信号量进行notify
    full.notify_all();
    return 0;
}

        在上述wait()函数中,对于传入的第二个参数而言,如果返回值为false就会释放锁,然后让线程在此进行等待,直到有线程对此进行notify操作且lambda表达式返回为true;如果返回值为true锁会保持锁住的状态,线程继续执行后面的代码,此时会。

        notify_one():唤醒在此条件变量下堵塞的当前线程。

        notify_all():唤醒在此条件变量下堵塞的所有线程。

5 异步任务处理-async

        使用std::thread对象开启的函数无法返回数据(当然使用引用也是可以实现得到返回值相同的功能)。但是有时候我们往往需要直接得到函数的返回值(或者让线程开始执行的时间由我们自行控制),这时候就需要使用异步任务处理的函数-async()了。

        下面代码使用async函数开始了一个异步任务,然后将run()函数的结果保存到了future类模板对象中了。当future调用了get()函数之后,这个异步任务才会执行,而主线程就会在此等待,直到子线程的任务执行完毕。

int run(int x){
    std::cout << "run function" << std::endl;
    std::cout << "id = " << std::this_thread::get_id() << std::endl;
    std::chrono::milliseconds dura(1000);
    std::this_thread::sleep_for(dura);
    return 100;
}

int main(){
    std::future rst = std::async(run, 1);
    std::cout << "begin get() " << std::endl;
    std::cout << rst.get() << std::endl;
    return 0;
}

        下面是最终的执行效果:

 5.1 async的第一个参数:

        async函数的第一个参数主要有三个:

1. std::launch::async 传递的可调用对象异步执行;
2. std::launch::deferred 传递的可调用对象同步执行,哪个线程调用get()或者wait()函数,就在哪个线程中执行,并不会开辟新的线程;
3. std::launch::async | std::launch::deferred 可以异步或是同步,取决于操作系统,我们无法控制。

        async函数默认的参数是std::launch::async | std::launch::deferred(也就是第三种),但是这种参数存在着较大的不确定性,在程序执行的过程中,你不直到操作系统到底会不会创建异步任务(创建新的线程),因此我们在使用async函数的时候,最好指定一下使用的是异步参数还是同步参数。

5.2 future对象常用的成员函数

        future主要有以下几种成员函数:

1. get():返回存储在future对象中的返回值。

2. wait():调用函数的线程在此等待,直到任务完成。

3. wait_for():调用函数的线程在此有限时间的等到,直到任务的完成。

        其中wait_for()函数返回状态主要有三种:

1. ready:表示异步任务已经执行完毕。

2. defered:表示异步任务是延迟装填(async函数的第一个参数是std::launch::deferred)。

3. timeout:表示wait_for等待了一段时间,但是异步任务还是没有完成,超时了。

        下面是测试代码,用来测试wait_for()函数返回的状态:

int run(int x){
    std::cout << "run function" << std::endl;
    std::cout << "id = " << std::this_thread::get_id() << std::endl;
    std::chrono::milliseconds dura(1000);
    std::this_thread::sleep_for(dura);
    return 100;
}

int main(){
    std::future rst = std::async(std::launch::async, run, 1);
    std::cout << "begin get()!" << std::endl;
    std::future_status status = rst.wait_for(std::chrono::milliseconds(0));
    if(std::future_status::timeout == status){
        std::cout << "[status]: time out" << std::endl;
    }else if(std::future_status::deferred == status){
        std::cout << "[status]: deferred!" << std::endl;
    }else{
        std::cout << "[status]: ready!" << std::endl;
    }
    return 0;
}

        下面是程序的输出结果:

C++多线程知识点总结_第2张图片

         可以发现上面的程序的打印结果已经出现了乱序了,这主要是因为主线程在等待一段时间后,就直接执行后续的任务了,而子任务也独自在运行,因此打印结果就不是很正常。

        在std::future对象中,我们只能够调用一次get()函数,这主要是因为该函数是使用move移动语义得到最终结果的。如果我们想要future对象在多个线程中不断的获取值得话,就需要使用shared_future对象,它对象支持多次调用get()函数。

5.3 用package_task包装可调用对象

        为了得到子线程的返回值,上面是让async直接返回一个future对象。还有一个方法是使用package_task包装被调用的函数,然后从package_task中得到future对象,最后再得到异步任务的执行结果。

        下面是使用package_task对象获取子线程结果的范例:

int run(int x){
    std::cout << "run function" << std::endl;
    std::cout << "id = " << std::this_thread::get_id() << std::endl;
    std::chrono::milliseconds dura(1000);
    std::this_thread::sleep_for(dura);
    return 100;
}

int main(){
    std::packaged_task my_task(run);
    std::thread my_thead(std::ref(my_task), 1);
    my_thead.join();
    std::cout << "get future" << std::endl;
    std::future rst = my_task.get_future();
    std::cout << "get rst " << std::endl;
    std::cout << "rst = " << rst.get() << std::endl;
    return 0;
}

        其中,package_task是一个类模板,它的模板参数是函数签名。比如int(int)就表示函数的参数是int类型,函数的返回值也是int类型的;void()就表示函数没有传入参数,也没有返回值。

        下面是范例程序执行的结果,不难发现当子线程执行完毕之后(执行join函数), 才能够获得future对象及其结果:

C++多线程知识点总结_第3张图片

         补充一点:package_task类模板不仅仅可以用于获取子线程的返回值,也可以用于线程池任务中,作为线程池的构建单元,用于其他任务的管理工作。

比如,为各个任务分别创建专属的独立运行的线程,或者在某个特定的后台线程上依次执行全部的任务。如果一项庞大、繁杂的操作可以分解成若干个子任务的话,就可以将这些任务都包装到多个std::package_task<>实例之中,然后再传递给任务调度器或者线程池中。这就隐藏了细节,将任务抽象化,当任务调度器专注于std::package_task<>实例,无须纠结形形色色的函数了。                                                                              ——《C++并发编程实战》

5.4 使用promise获取子线程的结果

        我们也可以向被调用函数中传递一个promise对象,将程序运行的结果保存到这个对象中。然后后续从promise对象中获取future及其结果。

void run(std::promise &my_prom){
    std::cout << "run function" << std::endl;
    std::cout << "id = " << std::this_thread::get_id() << std::endl;
    std::chrono::milliseconds dura(1000);
    std::this_thread::sleep_for(dura);
    my_prom.set_value(100);
}

int main(){
    std::promise my_prom;
    std::thread my_thead(run, std::ref(my_prom));
    my_thead.join();
    std::cout << "get future" << std::endl;
    std::future rst = my_prom.get_future();
    std::cout << "rst = " << rst.get() << std::endl;
    return 0;
}

         下面就是上述代码运行的结果:

C++多线程知识点总结_第4张图片

        小结一下,在这一小节中,我们主要介绍了可以开辟一个异步任务的async函数以及future对象,future可以获取异步任务的返回结果。future除了可以从async函数的返回值获取外,还可以使用package_task<>对象对被调用函数进行封装、promise<>对象对结果进行封装,这两个方法都需要使用get_future()函数获取future对象,然后再从future对象中获取异步任务的处理结果!!!

你可能感兴趣的:(c++)