C++多线程编程学习总结

        一、多线程简介

         线程是操作系统提供的一种抽象。它允许在一个进程内,同时执行多个任务。由于现在计算机多是采用多核心或多处理器架构,因此,不同的线程有机会并行的在不同的cpu中执行。这种并行运行方式不同于在单核心上运行多线程的方式。在单核心上实现多线程,实际是依赖于操作系统对线程进行快速切换(这种形式的运行方式也称作并发运行)。而在多个核心上运行的多线程,才是真正同时运行的(线程的这种运行方式也称作并行运行)。

        二、为什么使用多线程

        使用多线程通常是出于两个目的:1、优化性能;2、分离关注点。

        1、分离关注点有点类似于面向对象设计原则中的分离职责原则。它的目的是将实现不同任务的代码分离开,避免将他们杂糅在一起。对于有些应用程序来说,除了执行本身的业务逻辑以外,还需要经常处理用户的交互请求。如果采用单线程方式来实现需求,那么一段代码中除了处理业务逻辑的代码外还要夹杂着处理UI请求的相关代码。这使得代码的复杂度提高,可读性降低。

        采用多线程方式就不同了,处理业务逻辑的代码和处理UI请求的代码可以分开编写。每段代码可以在不同的线程中执行。这种方式编写的代码不仅同样满足了需求,同时还降低了代码的复杂度、提高了代码的可读性、也提高了程序的相应速度。

        2、通过多线程优化程序,主要依赖于线程的并行运行方式。也就是需要多个线程可以同时在不同的cpu上运行。如果仅仅是并发方式运行的多线程是起不到性能优化效果的,甚至会对性能有害。

        三、什么时候不应该使用多线程

        使用多线程是需要付出成本的。这些成本包括编写、测试、维护多线程代码所需要付出的额外精力,复杂度增加带来更多的bug,操作系统提供多线程所花费的额外内存空间,调度线程花费的额外时间开销等等。

        因此,在使用多线程技术前,需要仔细评估使用多线程带来的收益是否可以弥补以上所说的成本开销。并且在使用多线程后,也应该通过测试技术验证收益是否与预期一致。

        四、编写多线程程序需要注意的问题

        1、竞速条件:程序的执行结果与线程间操作的执行顺序相关。(并不是所有竞速条件都是有害的。)可以采用以下方式处理竞速条件:

        1)可以使用互斥锁或者使用原子对象控制线程间操作的顺序。

        2)修改类中有可能引起竞速条件的接口函数

        2、死锁:线程等待一个永远不会满足的条件。例如,两个线程互相等待对方释放互斥锁。可以通过以下方式避免出现死锁现象:

        1)避免嵌套锁:在等待一个互斥锁的时候,不要在尝试获取其他互斥锁。

        2)当等待一个互斥锁的时候,不要调用用户提供的函数。这一条和第一条原理一样

        3)所有线程都采用固定的顺序获取互斥锁

        4)使用层级锁。

        四、C++提供的多线程工具

        1、std::thead类是C++类库提供的一个基础的线程类。通过这个类可以创建一个线程对象,同时创建一个线程。std::thead类对象,在它的生命周期内,必须进行join或者detach操作,否则会产生异常。如果调用了线程对象的join成员函数,那么执行函数调用的线程会等待此线程对象所关联的线程执行结束后才继续运行后续代码。(只能对一个对象调用一次join操作)。调用线程对象的detach成员函数,会使线程对象与关联的线程分离(这个线程会在后台继续运行)。

        2、std::mutex是C++类库提供的一个互斥锁类。通常配合std::lock_guard类以及unique_lock类使用(这些类在析构时会对关联的互斥锁对象进行释放)。其中unique_lock类接口更多,使用也更灵活。

        3、std::condition_variable类提供一种机制:可以让一个线程等待某个条件触发。通过调用类的wait函数使线程等待条件被触发。需要注意的是,等待的线程可能被虚假唤醒(也就是条件并未触发,但是线程从等待状态返回)。可以采用wait的重载形式,显示的传入一个可调用对象,描述所等待的条件,来处理虚假唤醒现象。实现条件的线程可以通过调用类的notify_one或者notify_all成员函数唤醒其他等待中的线程。

        4、std::future类:这个类的对象往往是通过别的函数或者别的类的成员函数返回的。它最终会接收一个值或者一个异常。一个线程可以调用std::future类的对象的wait函数等待值或异常被存储。

        5、std::async函数:可以以多线程方式执行某段代码。可以通过std::launch::async枚举值强制std::async函数以多线程方式执行代码。默认的,std::async自己决定是在当前线程或者一个新线程中执行代码。std::async函数会返回一个std::future类的对象,用于接收代码返回的值。如果执行代码过程中遇到了未捕获的异常,这个异常会存储于返回的std::future类对象中。

        6、std::package_task类:提供了对要执行的任务的一种抽象。通过调用类的get_future()成员函数,可以获取一个std::future类的对象,用于接收任务的返回值。如果执行任务过程中遇到了未捕获的异常,那么这个异常会存储于future对象中。

        7、std::promise类:提供一种机制,让一个关联的std::future类对象读取通过std::promidse类存储的值或异常。如果在std::promise类析构时,还没有存储值或者异常,那么析构函数会在关联的std::future对象中存储一个std::future_error异常。

        8、std::atomic模板:提供了一个生成原子类的模板。这些模板实例提供了一些原子操作。

        五、原子类型

        c++标准库提供了原子类型模板。原子类型除了提供原子操作接口函数外,还有一个很重要的功能,就是提供一种能够控制线程间操作的相对顺序的方法。

       1、 以下操作序列中的首、尾原子读、写操作会形成同步关系。

        1)对变量x采用适当标记的原子写操作,对变量x采用适当标记的读操作。

        2)在一个线程中对一个变量x采用适当标记的连续的原子写操作,对变量x采用适当标记的读操作。

        3)在一个线程中对一个变量x采用适当标记的写操作,在任意线程中对x变量进行不限次数的原子读写操作。对x变量采用适当标记的读操作。

        2、先行关系:是确定操作顺序的基础工具。在一个线程中,操作的执行顺序是由程序中语句的相对顺序以及各种控制语句决定的。在执行的时候,这些操作形成了顺序关系。

        不同线程间操作的相对顺序是不确定的。但是可以借助原子操作间的同步关系来控制线程间操作的相对顺序。它的主要原理是,当一个线程中的A操作与另一个线程中的B操作间形成了同步关系。那么,A操作跨线程先行于B操作。

        先行关系具有传递性。如果A操作先行于B操作,B操作先行于C操作,那么A操作先行于B操作。

        之前提到的std::thread类、std::mutex类、std::promise类,std::future类、std::packged类等,他们底层都是通过原子对象来确定线程间操作的先行关系,从而确定多线程间操作的相对顺序的。

        3、原子操作标记

        1)顺序一致标记:这是一种最强的标记类型,他会在同样使用顺序一致标记的原子操作间形成多个同步关系。在有些架构中,这样的操作对性能会有一定影响

        2)relax标记:用这种标记的原子操作间不会形成同步关系。

        3)acquire-release标记:满足条件的acquire原子写操作与release原子读操作会形成同步关系。

        六、设计基于锁的并发数据结构

        1、实现线程安全的数据结构设计基本原则:

        1)当线程对一个数据结构进行操作时,如果破坏了此数据结构的不变量,那么需要保证在此期间,其他线程不能对这个数据结构进行操作。

        2)需要去掉数据结构接口本身存在的竞速条件(例如:stack的pop()和top()接口就有可能产生竞速条件)

        3)确保当异常出现时,数据结构的不变量不会被打破。

        4)使出现死锁的可能性最小化。可以通过尽量避免出现嵌套锁或者限制互斥锁的作用范围来实现。

        2、实现真正并发访问的建议:

        1)尽量缩小互斥锁的作用范围。尽量将不需要锁定的操作移出互斥锁的作用范围。

        2)是否可以用多个锁分别保护数据结构的不同部分。

        3)确定是否所有操作都需要同样级别的保护

        4)是否可以通过对数据结构底层进行简单修改,增加并行运行机会。      

        六、并发程序设计

        1、如何在线程间分配任务:根据使用多线程技术的目的来确定采用哪种方式对线程所需要负担的任务进行分配。

        1)如果是需要优化程序性能。那么可以选择让每个线程执行同样的操作,将需要处理的数据进行划分。每一个线程并行的处理一部分数据。此外,还可以将处理数据的操作划分为多个步骤,每个线程执行其中的一个步骤。这样的方式类似于流水线。也可以实现同时处理多个数据。

        2)如果需要分离代码职责。那么据需要按照需求来确定所有职责。编写代码,让每段代码实现其中的一个职责。之后让每个线程执行其中的一段代码。

        2、影响多线程程序性能的因素

        1)执行多线程程序的计算机cpu数量:要通过多线程实现性能优化,需要让每个线程在不同的cpu上执行。因此,如果可以让程序实际的线程数量和可用cpu数量一致,才能达到最理想的情况。c++标准库提供了std::thread::hardware_concurrency()接口函数。这个函数可以返回当前计算机所具有的cpu数量。但是这并不等同于当前计算机实际可用的cpu数量。(因为还需要考虑其他正在运行的进程。)但是,通常用这个返回值作为确定线程数量的依据也是可以接受的。

        2)缓存乒乓

        如果一个数据被运行在不同cpu上的线程访问时,如果其中一个线程对这个变量进行了修改,那么另一个线程所在的cpu需要将这个变量的新值更新到缓存中,才能进行后续操作。如果两个线程都对同一个变量进行频繁的修改,那么这个变量的值就会在两个cpu缓存中往复传递。就好像两个cpu在打乒乓球一样。(其中被更新的值就是乒乓球,缓存是球拍)。这种现象会造成cpu等待,影响程序的执行效率。

        3)虚假共享

        缓存兵乓不仅当多个线程同时修改一个变量时会出现。由于cpu缓存是以固定大小为单位对数据进行缓存的。因此,即使运行不同线程的cpu对同一个缓存块中的不同数据进行了修改,也同样会产生缓存乒乓现象。为了避免这个现象出现,不同线程访问的非共享数据应该尽量放在不同的缓存块中。为了提升数据的访问速度,同一个线程访问的相关数据应该尽量放在同一个缓存块中。

        4)线程过饱和

        在确定线程数量时应该参考运行程序的设备的cpu数量。如果,线程数量比设备cpu数量多很多,会出现线程过饱和现象。此时,每个cpu会进行频繁的线程切换操作。这些线程切换操作都是比较耗时的,从而造成程序运行效率降低。因此这种现象是需要尽量避免的。

        5)多线程程序处理异常

        和单线程程序不同,如果一个线程执行的主函数因为异常而退出,那么整个程序将会被中止。因此,对于多线程程序来说,需要用标准库提供的设施来传递异常。例如,std::packaged_task类封装的函数运行时如果出现未捕获的异常,那么对应的std::future对象会接收到这个异常对象。通过std::async函数执行的任务如果出现异常,那么对应的std::future对象也会接收到异常。

        以上是对多线程学习的一些总结,欢迎大家一起交流学习。

  • 10
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值