C++并发编程
前言
因为最近项目涉及到C++的并发相关内容,并且本科时期很少涉及到这些内容(传授过部分思想,但是实战却很少很少)所以这段时间好好的学习了一下C++的并发知识,作一下总结.
为什么需要并发
定义
上图是对于并发与并行之间区别的经典解释
- 并发(Concurrent): 多个队列可以交替使用机器
- 并行(Parallel): 存在多个机器供多个队列交替使用
再简单一点,如果一个系统支持多个任务同时存在,那这就是并法系统;如果还能支持同一时刻多个任务同时执行,那就是并行系统.
再从计算机角度来谈谈.每一台电脑都需要配置一套操作系统,它管理着电脑的资源并且向用户提供服务.其中,进程和线程是操作系统中的基本概念,进程是程序的基本执行实体,线程是操作系统能够进行运算调度的最小单位,包含于进程之中.一个进程最少包含一个线程(主线程),也可以包含多个线程.
比如我们打开QQ聊天,就是启动QQ这个进程.但是如果我们一边下载某个软件,一边和另外的好友聊天,二者之间互不影响这就是运用了多个线程.在早期,电脑只有一个处理器时,所有的进程线程都会分时占用这个处理器;现在电脑都是多核处理器,所以多个任务可以并行运行在不同的处理器中.
最后做个简单的总结,并发和并行都可以是调用了很多线程.如果这些线程能同时被多个处理器执行,那就是并行的;如果是轮流切换执行,那就是并发.
并发的好处
为什么我们现在都在提高并发呢?说到底还是得益于硬件发展,多核cpu已经普及.面对日益增长的大数据,传统的串行弊端逐渐显现;为了追求更高的效率我们就希望极致地发挥cpu性能.
相信大家小学的时候都遇见过这种类型的题目,早上起来刷牙三分钟,烧水五分钟,煎鸡蛋2分钟... 如果是串行,一次只能干一件事,那么前一件事没做完后面的任务全部得等待.但是如果是并发,那我们可以先起来把水烧了,然后去刷牙煎鸡蛋,这样就省了一半的时间,也就是效率提高了一倍.对于电脑来说也是如此,既然我们可以使用多线程,那为什么非要傻乎乎的盯着一个线程一个任务一个任务的执行呢?直接用多个线程一起干.
开始
环境设置
目前我使用的环境是ubuntu20,下面的例子在Win10/Win11应该也没问题.gcc编译器版本是11.1.0,c++标准用17/20都可以.
提示:在ubuntu下使用多线程需要添加-lpthread
参数,不然会报错
gcc安装直接使用sudo apt-get install gcc-11
编辑器vscode/clion/vs… 这个就随意啦
线程
线程创建
在c++11之后创建线程是非常简单的,使用thread
实例化线程对象就可以进行线程创建.
#include<iostream> #include<thread> using namespace std; void hello(){ cout<<"Hello Thread is: "<<std::this_thread::get_id()<<endl; cout<<"Hello Concurrent World\n"<<endl; } int main(){ cout<<"Main Thread is: "<<std::this_thread::get_id()<<endl; thread t(hello); t.join(); return 0; } 复制代码
当然,除了函数我们还可以传入lambda表达式
#include <thread> #include <iostream> int main() { int a = 0; int flag = 0; std::thread t1([&]() { while (flag != 1); int b = a; std::cout << "b = " << b << std::endl; }); std::thread t2([&]() { a = 5; flag = 1; }); t1.join(); t2.join(); return 0; }
果函数需要传入参数,那么直接跟在函数名后面就行.但是参数是以拷贝的形式传递的,如果为了节省拷贝的时间也可以选择传入指针或者引用.但是如果指针或引用参数的生命周期小于线程的生命周期,这个时候就会出错.
join() && detach()
- join() 调用时当前线程会一直阻塞直到目标线程执行完毕;
- detach() 让目标线程成为守护线程,使目标线程独立执行.
线程的管理
在线程内部,我们可以对当前线程进行一些控制
方法 | 说明 |
yield | 让出处理器,重新调度各线程 |
get_id | 得到当前线程的id |
sleep_for | 使当前线程停止指定的一段时间 |
sleep_until | 使当前的线程停止直到指定的时间点 |
如果有些任务我们只需要执行一次,比如初始化加载一些资源的任务,那么还可以用一次调用的方式.即使有多个线程的情况下,相应的函数也仅仅调用一次.
#include<iostream> #include<chrono> #include<thread> #include<memory> #include<mutex> using namespace std; void init(){ cout<<"Initializing..."<<endl; std::this_thread::sleep_for(std::chrono::seconds(5)); cout<<"Finished initializing..."<<endl; } void worker(once_flag* flag){ cout<<"[thread-"<<this_thread::get_id()<<"]: "<<endl; std::call_once(*flag,init); } int main(){ std::once_flag flag; thread t1(worker,&flag); thread t2(worker,&flag); thread t3(worker,&flag); t1.join(); t2.join(); t3.join(); return 0; }
可以看到即使有三个线程,但是init仅仅被执行了一次.
使用多线程
前面说了这么多,但是如何使用多线程来提高程序效率还是没有展现,所以下面的例子就来看看多线程的优势.
假设有这样的任务,需要计算1-10e7内所有数的和,使用串行写法很简单,直接遍历求和
void worker(int min,int max){ for(int i=min;i<max;++i){ sum+=i; } } 复制代码
如果想提高效率,那我们可以把任务分解交给多个线程去计算,但是最后计算汇总结果的时候涉及到临界区的竞争(多个线程同时访问且想修改sum),所以这里提前要用到下面的互斥量和锁来实现
#include<iostream> #include<thread> #include<vector> #include<cmath> #include<mutex> using namespace std; static const int MAX=10e7; static double sum=0; static mutex mtx; void worker(int min,int max){ for(int i=min;i<max;++i){ sum+=i; } } void concurrent_worker(int min,int max){ double tmp=0; for(int i=min;i<max;++i){ tmp+=i; } mtx.lock(); sum+=tmp; mtx.unlock(); } void serial_task(int min,int max){ sum=0; auto start_time=std::chrono::steady_clock::now(); worker(0,MAX); auto end_time=std::chrono::steady_clock::now(); auto cost=std::chrono::duration_cast<std::chrono::milliseconds>(end_time-start_time).count(); cout<<"cost :"<<cost<<"ms Result="<<sum<<endl; } void concurrent_task(int min,int max){ //可使用的线程数 unsigned concurrent_count=std::thread::hardware_concurrency(); cout<<"hardware_concurrency:"<<concurrent_count<<endl; vector<thread> threads; sum=0; min=0; auto start_time=std::chrono::steady_clock::now(); for(int t=0;t<concurrent_count;++t){ int range=MAX/concurrent_count*(t+1); threads.push_back(thread(concurrent_worker,min,range)); min=range+1; } for(auto &t:threads){ t.join(); } auto end_time=std::chrono::steady_clock::now(); auto cost=std::chrono::duration_cast<std::chrono::milliseconds>(end_time-start_time).count(); cout<<"cost :"<<cost<<"ms Result="<<sum<<endl; } int main(){ serial_task(0,MAX); concurrent_task(0,MAX); return 0; } 复制代码
多线程情况下耗时仅为单线程的1/9,为了结果的正确性这里引入了互斥锁保证了某一时刻只能有一个线程访问更改临界区sum的值.
互斥量和锁
并发情况下,最常见的问题就是竞态.所以我们代码设计的时候必须考虑到各种可能发生的情况,并且针对它们添加一定的锁保证程序的正常运行以及结果的正确性.
像上面的代码中使用到的mutex
就是最基本的互斥量,我们可以使用lock
锁住互斥量,对它进行操作;操作结束之后使用unlock
解锁,让其他线程也能访问互斥量.这里要提出一个新的概念—粒度
锁的粒度也就是锁的范围,细粒度就是锁住较小的范围,粗粒度就是锁住较大的范围.往往为了追求性能,我们都希望使用细粒度的锁而不是粗粒度的,如果粒度太大一直等待,这不就退化成串行了吗?失去了并发的优势.所以在锁的范围内,尽量避免执行耗时的操作,想办法将耗时的操作全部移到锁的外面,这样才能发挥更好的性能.
死锁
既然有了锁,那么肯定就会有死锁的情况.什么是死锁呢?举个例子,如果有两个线程A,B,它们都需要获得两个资源才能解锁,但是现在的情况是一人拥有一个资源,而且还都想得到对方的资源.这种情况下你不让我我不让你就发生了死锁.当然实际上还有很多情况,比如两个线程互相join,对一个不可重入的互斥量多次加锁…