1. 概念
并发与并行的区别:多个进程运行在一个处理器上,这是并发;多个进程运行在不同的处理器上,这是并行。
线程:线程是操作系统能够进行CPU调度的最小单位,它被包含在进程之中,一个进程可包含单个或者多个线程。可以用多个线程去完成一个任务,也可以用多个进程去完成一个任务,它们的本质都相当于多个人去合伙完成一件事。
多线程并发:多线程是实现并发(双核的真正并行或者单核机器的任务切换都叫并发)的一种手段,多线程并发即多个线程同时执行,一般而言,多线程并发就是把一个任务拆分为多个子任务,然后交由不同线程处理不同子任务,使得这多个子任务同时执行。
C++多线程并发: C++98标准中并没有线程库的存在,而在C++11中终于提供了多线程的标准库,提供了管理线程、保护共享数据、线程间同步操作、原子操作等类,。(简单情况下)实现C++多线程并发程序的思路如下:将任务的不同功能交由多个函数分别实现,创建多个线程,每个线程执行一个函数,一个任务就这样同时分由不同线程执行了。
运行越多的线程,操作系统需要为每个线程分配独立的栈空间,需要越多的上下文切换,这会消耗很多操作系统资源,如果在线程上的任务完成得很快,那么实际执行任务的时间要比启动线程的时间小很多,所以在某些时候,增加一个额外的线程实际上会降低,而非提高应用程序的整体性能,此时收益就比不上成本。
2. C++多线程并发基础知识
2.1 创建线程
首先要引入头文件#include
语句std::thread th1(proc1);
创建了一个名为th1的线程,并且线程th1开始执行。
实例化std::thread类对象时,至少需要传递函数名作为参数。如果函数为有参函数,如void proc2(int a,int b)
,那么实例化std::thread类对象时,则需要传递更多参数,参数顺序依次为函数名、该函数的第一个参数、该函数的第二个参数,···,如std::thread th2(proc2,a,b);
。这里的传参,后续章节还会有详解与提升。
只要创建了线程对象(前提是,实例化std::thread对象时传递了“函数名/可调用对象”作为参数),线程就开始执行。
总之,使用C++线程库启动线程,可以归结为构造std::thread对象。
当线程启动后,一定要在和线程相关联的std::thread对象销毁前,对线程运用join()或者detach()方法。
join()与detach()都是std::thread类的成员函数,是两种线程阻塞方法,两者的区别是是否等待子线程执行结束。join()会阻塞主线程,直到子线程结束,detach()不阻塞主线程。
例如,主函数中有一条语句th1.join(),那么执行到这里,主函数阻塞,直到线程th1运行结束,主函数再继续运行。
1 |
|
调用join()会清理线程相关的存储部分,这代表了join()只能调用一次。使用joinable()来判断join()可否调用。同样,detach()也只能调用一次,一旦detach()后就无法join()了,有趣的是,detach()可否调用也是使用joinable()来判断。
如果使用detach(),就必须保证线程结束之前可访问数据的有效性,使用指针和引用需要格外谨慎。
2.2 互斥量使用
什么是互斥量?
这样比喻:单位上有一台打印机(共享数据a),你要用打印机(线程1要操作数据a),同事老王也要用打印机(线程2也要操作数据a),但是打印机同一时间只能给一个人用,此时,规定不管是谁,在用打印机之前都要向领导申请许可证(lock),用完后再向领导归还许可证(unlock),许可证总共只有一个,没有许可证的人就等着在用打印机的同事用完后才能申请许可证(阻塞,线程1lock互斥量后其他线程就无法lock,只能等线程1unlock后,其他线程才能lock)。那么,打印机就是共享数据,访问打印机的这段代码就是临界区,这个必须互斥使用的许可证就是互斥量。
互斥量是为了解决数据共享过程中可能存在的访问冲突的问题。这里的互斥量保证了使用打印机这一过程不被打断。
互斥量怎么使用?
首先需要#include
然后需要实例化std::mutex对象;
最后需要在进入临界区之前对互斥量加锁,退出临界区时对互斥量解锁;
至此,互斥量走完了它的一生。
lock()与unlock():
1 |
|
需要在进入临界区之前对互斥量lock,退出临界区时对互斥量unlock;当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。
程序实例化mutex对象m,本线程调用成员函数m.lock()会发生下面 2 种情况: (1)如果该互斥量当前未上锁,则本线程将该互斥量锁住,直到调用unlock()之前,本线程一直拥有该锁。 (2)如果该互斥量当前被其他线程锁住,则本线程被阻塞,直至该互斥量被其他线程解锁,此时本线程将该互斥量锁住,直到调用unlock()之前,本线程一直拥有该锁。
不推荐实直接去调用成员函数lock(),因为如果忘记unlock(),将导致锁无法释放,使用lock_guard或者unique_lock则能避免忘记解锁带来的问题。
lock_guard:
std::lock_guard()是什么呢?它就像一个保姆,职责就是帮你管理互斥量,就好像小孩要玩玩具时候,保姆就帮忙把玩具找出来,孩子不玩了,保姆就把玩具收纳好。
其原理是:声明一个局部的std::lock_guard对象,在其构造函数中进行加锁,在其析构函数中进行解锁。最终的结果就是:创建即加锁,作用域结束自动解锁。从而使用std::lock_guard()就可以替代lock()与unlock()。
通过设定作用域,使得std::lock_guard在合适的地方被析构(在互斥量锁定到互斥量解锁之间的代码叫做临界区(需要互斥访问共享资源的那段代码称为临界区),临界区范围应该尽可能的小,即lock互斥量后应该尽早unlock),通过使用{}来调整作用域范围,可使得互斥量m在合适的地方被解锁:
1 |
|
std::lock_gurad也可以传入两个参数,第一个参数为adopt_lock标识时,表示构造函数中不再进行互斥量锁定,因此此时需要提前手动锁定。
1 |
|
unique_lock:
std::unique_lock类似于lock_guard,只是std::unique_lock用法更加丰富,同时支持std::lock_guard()的原有功能。 使用std::lock_guard后不能手动lock()与手动unlock();使用std::unique_lock后可以手动lock()与手动unlock(); std::unique_lock的第二个参数,除了可以是adopt_lock,还可以是try_to_lock与defer_lock;
try_to_lock: 尝试去锁定,得保证锁处于unlock的状态,然后尝试现在能不能获得锁;尝试用mutx的lock()去锁定这个mutex,但如果没有锁定成功,会立即返回,不会阻塞在那里,并继续往下执行;
defer_lock: 始化了一个没有加锁的mutex;
1 |
|
使用try_to_lock要小心,因为try_to_lock尝试锁失败后不会阻塞线程,而是继续往下执行程序,因此,需要使用if-else语句来判断是否锁成功,只有锁成功后才能去执行互斥代码段。而且需要注意的是,因为try_to_lock尝试锁失败后代码继续往下执行了,因此该语句不会再次去尝试锁。
std::unique_lock所有权的转移
注意,这里的转移指的是std::unique_lock对象间的转移;std::mutex对象的所有权不需要手动转移给std::unique_lock , std::unique_lock对象实例化后会直接接管std::mutex。
1 | mutex m; |
condition_variable:
需要#include
如何使用?std::condition_variable类搭配std::mutex类来使用,std::condition_variable对象(std::condition_variable cond;)的作用不是用来管理互斥量的,它的作用是用来同步线程,它的用法相当于编程中常见的flag标志(A、B两个人约定flag=true为行动号角,默认flag为false,A不断的检查flag的值,只要B将flag修改为true,A就开始行动)。
类比到std::condition_variable,A、B两个人约定notify_one为行动号角,A就等着(调用wait(),阻塞),只要B一调用notify_one,A就开始行动(不再阻塞)。
std::condition_variable的具体使用代码实例可以参见文章中“生产者与消费者问题”章节。
wait(locker) :
wait函数需要传入一个std::mutex(一般会传入std::unique_lock对象),即上述的locker。wait函数会自动调用 locker.unlock() 释放锁(因为需要释放锁,所以要传入mutex)并阻塞当前线程,本线程释放锁使得其他的线程得以继续竞争锁。一旦当前线程获得notify(通常是另外某个线程调用 notify_* 唤醒了当前线程),wait() 函数此时再自动调用 locker.lock()上锁。
cond.notify_one(): 随机唤醒一个等待的线程
cond.notify_all(): 唤醒所有等待的线程
2.3 异步线程
需要#include
async与future:
std::async是一个函数模板,用来启动一个异步任务,它返回一个std::future类模板对象,future对象起到了占位的作用(记住这点就可以了),占位是什么意思?就是说该变量现在无值,但将来会有值(好比你挤公交瞧见空了个座位,刚准备坐下去就被旁边的小伙给拦住了:“这个座位有人了”,你反驳道:”这不是空着吗?“,小伙:”等会人就来了“),刚实例化的future是没有储存值的,但在调用std::future对象的get()成员函数时,主线程会被阻塞直到异步线程执行结束,并把返回结果传递给std::future,即通过FutureObject.get()获取函数返回值。
相当于你去办政府办业务(主线程),把资料交给了前台,前台安排了人员去给你办理(std::async创建子线程),前台给了你一个单据(std::future对象),说你的业务正在给你办(子线程正在运行),等段时间你再过来凭这个单据取结果。过了段时间,你去前台取结果(调用get()),但是结果还没出来(子线程还没return),你就在前台等着(阻塞),直到你拿到结果(子线程return),你才离开(不再阻塞)。
1 |
|
shared_future
std::future与std::shard_future的用途都是为了占位,但是两者有些许差别。std::future的get()成员函数是转移数据所有权;std::shared_future的get()成员函数是复制数据。 因此: future对象的get()只能调用一次;无法实现多个线程等待同一个异步线程,一旦其中一个线程获取了异步线程的返回值,其他线程就无法再次获取。 std::shared_future对象的get()可以调用多次;可以实现多个线程等待同一个异步线程,每个线程都可以获取异步线程的返回值。
2.4 原子类型atomic<>
原子操作指“不可分割的操作”,也就是说这种操作状态要么是完成的,要么是没完成的,不存在“操作完成了一半”这种状况。互斥量的加锁一般是针对一个代码段,而原子操作针对的一般都是一个变量(操作变量时加锁防止他人干扰)。 std::atomic<>是一个模板类,使用该模板类实例化的对象,提供了一些保证原子性的成员函数来实现共享数据的常用操作。
可以这样理解: 在以前,定义了一个共享的变量(int i=0),多个线程会用到这个变量,那么每次操作这个变量时,都需要lock加锁,操作完毕unlock解锁,以保证线程之间不会冲突;但是这样每次加锁解锁、加锁解锁就显得很麻烦,那怎么办呢? 现在,实例化了一个类对象(std::atomic
提到std::atomic<>,你脑海里就想到一点就可以了:std::atomic<>用来定义一个自动加锁解锁的共享变量(“定义”“变量”用词在这里是不准确的,但是更加贴切它的实际功能),供多个线程访问而不发生冲突。
//原子类型的简单使用
1 | std::atomic<bool> b(true); |
std::atomic<>对象提供了常见的原子操作(通过调用成员函数实现对数据的原子操作): store是原子写操作,load是原子读操作。exchange是于两个数值进行交换的原子操作。 即使使用了std::atomic<>,也要注意执行的操作是否支持原子性,也就是说,你不要觉得用的是具有原子性的变量(准确说是对象)就可以为所欲为了,你对它进行的运算不支持原子性的话,也不能实现其原子效果。一般针对++,–,+=,-=,&=,|=,^=是支持的,这些原子操作是通过在std::atomic<>对象内部进行运算符重载实现的。
3 代码实例
前一章内容为了简单的说明一些函数的用法,所列举的例子有些牵强,因此在本章列举了一些多线程常见的实例
3.1 生产者消费者问题
生产者-消费者模型是经典的多线程并发协作模型。生产者用于生产数据,生产一个就往共享数据区存一个,如果共享数据区已满的话,生产者就暂停生产;消费者用于消费数据,一个一个的从共享数据区取,如果共享数据区为空的话,消费者就暂停取数据,且生产者与消费者不能直接交互。
1 | /* |
4 C++多线程并发高级知识
4.1 线程池
4.1.1 线程池基础知识
不采用线程池时:
创建线程 -> 由该线程执行任务 -> 任务执行完毕后销毁线程。即使需要使用到大量线程,每个线程都要按照这个流程来创建、执行与销毁。
虽然创建与销毁线程消耗的时间 远小于 线程执行的时间,但是对于需要频繁创建大量线程的任务,创建与销毁线程 所占用的时间与CPU资源也会有很大占比。
为了减少创建与销毁线程所带来的时间消耗与资源消耗,因此采用线程池的策略:
程序启动后,预先创建一定数量的线程放入空闲队列中,这些线程都是处于阻塞状态,基本不消耗CPU,只占用较小的内存空间。
接收到任务后,任务被挂在任务队列,线程池选择一个空闲线程来执行此任务。
任务执行完毕后,不销毁线程,线程继续保持在池中等待下一次的任务。
线程池所解决的问题:
(1) 需要频繁创建与销毁大量线程的情况下,由于线程预先就创建好了,接到任务就能马上从线程池中调用线程来处理任务,减少了创建与销毁线程带来的时间开销和CPU资源占用。
(2) 需要并发的任务很多时候,无法为每个任务指定一个线程(线程不够分),使用线程池可以将提交的任务挂在任务队列上,等到池中有空闲线程时就可以为该任务指定线程。
4.1.2 线程池的实现
可以通过阅读 《C++ Concurrency in Action, Second Edition》 9.1章节来学习。线程池确实是难点部分,所以先拖着不更,等把别的章节完善了,再来更新这部分。, 本文的线程池实现的内容将会在《C++11 STL基础入门教程》完善后再来更新。
5 延伸拓展
5.1 线程与进程/并发与并行
这里借用了C++标准委员会成员Anthony Willianms在书籍《C++ Concurrency in Action, Second Edition》中的表述(强推这本书,写的真的很好):
并发的两种方式: 双核及其的真正并行、单核机器的任务切换
并发的两种基本途径:多进程并发、多线程并发
多进程并发: 优点是更容易编写安全的并发代码(操作系统为进程通信提供了一定的保护措施)、可分布式(可以通过远程连接的方式在不同的计算机上独立运行进程);缺点是进程开销大、启动慢,进程之前的通信复杂耗时。
多线程并发: 优点是共享内存的灵活性(进程中的所有线程共享内存地址空间。虽然进程之前也共享内存,但这种共享通常是难以管理的,因为同一数据的内存地址在不同的进程中是不同的),缺点是编写代码时工作量大(需要保证多个线程访问到的共享数据是一致的)
结论是,多个进程(每个进程只包含单一线程)比多个线程(单一进程包含的多个线程)的开销大,若不考虑共享内存所带来的问题,多线程将会成为主流语言更加青睐的并发途径。
并发与并行: 对于多线程来说,两者概念大部分重叠,意思近乎相同,只是侧重点不同,关注于使用当前可用硬件来提高批量数据处理的速度时,我们讨论程序的并行性,关注于任务分离或任务响应时,就会讨论到程序的并发性。(我的理解:并发概念中涵盖了并行)
5.2 创建线程时的传参问题分析
如std::thread th1(proc1)
,创建线程时需要传递函数名作为参数,提供的函数对象会复制到新的线程的内存空间中执行与调用。
如果用于创建线程的函数为含参函数,那么在创建线程时,要一并将函数的参数传入。常见的,传入的参数的形式有基本数据类型(int,char,string等)、引用、指针、对象这些,下面总结了传递不同形式的参数时std::thread类的处理机制,以及编写程序时候的注意事项。本章节只给出了部分示例代码,没有必要为了证明处理机制而举例大量简单代码而使得文章冗长,但是推荐新手自行编写程序研究。
总体来说,std::thread的构造函数会拷贝传入的参数:
当传入参数为基本数据类型(int,char,string等)时,会拷贝一份给创建的线程;
当传入参数为指针时,会浅拷贝一份给创建的线程,也就是说,只会拷贝对象的指针,不会拷贝指针指向的对象本身。
当传入的参数为引用时,实参必须用ref()函数处理后传递给形参,否则编译不通过,此时不存在“拷贝”行为。引用只是变量的别名,在线程中传递对象的引用,那么该对象始终只有一份,只是存在多个别名罢了(注意把引用与指针区别开:指针是一块内存指向另一块内存,指针侧重“指向”二字;引用是只有一块内存,存在多个别名。理解引用时不要想着别名“指向”内存,这是错误的理解,这样的理解会导致分不清指针和引用,别名与其本体侧重于“一体”二字,引用就是本体,本体就是引用,根本没有“指向”关系。);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using namespace std;
void proc(int& x)
{
cout << x <<","<<&x<<endl;
}
int main()
{
int a=10;
cout<< a <<",,"<<&a<<endl;
thread t1(proc,ref(a));
t1.join();
return 0;
}当传入的参数为类对象时,会拷贝一份给创建的线程。此时会调用类对象的拷贝构造函数。
5.3 detach()
使用detach()时,可能存在主线程比子线程先结束的情况,主线程结束后会释放掉自身的内存空间;在创建线程时,如果std::thread类传入的参数含有引用或指针,则子线程中的数据依赖于主线程中的内存,主线程结束后会释放掉自身的内存空间,则子线程会出现错误。