文章目录
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,对一个不可重入的互斥量多次加锁…
简单的互斥管理
既然并发需要锁,但是又要避免死锁的发生,为了方便我们开发总是需要使用一些简单的方法.如果每次使用mutex
还需要我们自己手动lock unlock
属实也不太方便,所以c++还提供了一些进阶的方法
类 | 说明 |
---|---|
lock_guard | 实现严格基于作用域的互斥量所有权 |
unique_lock | 实现可移动的互斥量所有权 |
shared_lock | 实现可移动的共享互斥量所有权 |
scoped_lock | 用于多个互斥量的免死锁 |
锁定策略 | 说明 |
---|---|
defer_lock | 不获得互斥量的所有权 |
try_to_lock | 尝试获得互斥量所有权但是不阻塞 |
adopt_lock | 调用方已拥有互斥量所有权 |
使用这些方法做个小例子
#include<iostream>
#include<thread>
#include<mutex>
using namespace std;
int v=0;
std::mutex v_mutex;
void incre(){
std::lock_guard<std::mutex> lock(v_mutex);
++v;
std::cout<<std::this_thread::get_id()<<":"<<v<<std::endl;
}
int main(){
std::cout<<"origin v:"<<v<<std::endl;
std::thread t1(incre);
std::thread t2(incre);
t1.join();
t2.join();
std::cout<<"done v:"<<v<<std::endl;
}
使用lock_guard自动为互斥量加锁与解锁,在构造的时候就自动加锁,在生命周期结束的时候自动解锁.
对于上面的几种锁,如果要锁住多个互斥量可以这样写
- lock_guard
lock(mutex1,mutex2);
lock_guard lock1(mutex1,adopt_lock);
lock_guard lock2(mutex2,adopt_lock);
- unique_lock
unique_lock lock1(mutex1,defer_lock);
unique_lock lock2(mutex2,defer_lock);
lock(mutex1,mutex2);
- scoped_lock
scoped_lock lockAll(mutex1,mutex2);
条件变量
更常见的情况是当满足某个条件时才继续执行,否则就等待.这个时候就需要使用条件变量condition_variable
,它得配合unique_lock
一起使用.
#include<iostream>
#include<thread>
#include<mutex>
using namespace std;
int v=0;
static mutex mtx;
void change(int change_v){
std::unique_lock<std::mutex> lock(mtx);
v=change_v;
cout<<v<<endl;
lock.unlock();
lock.lock();
v+=1;
cout<<v<<endl;
}
int main(){
std::thread t1(change,2),t2(change,5);
t1.join();
t2.join();
return 0;
}
配合上条件变量我们就可以写一个生产者和消费者的模型
#include<iostream>
#include<queue>
#include<chrono>
#include<mutex>
#include<thread>
#include<condition_variable>
using namespace std;
int main(){
queue<int> produced;
mutex mtx;
condition_variable cond;
bool notified=false;
auto producer=[&](){
for(int i=0;;i++){
std::this_thread::sleep_for(std::chrono::milliseconds(900));
std::unique_lock<std::mutex> lock(mtx);
//生产
cout<<"Producing:"<<i<<endl;
produced.push(i);
notified=true;
cond.notify_all();
}
};
auto consumer=[&](){
while(true){
std::unique_lock<std::mutex> lock(mtx);
while(!notified){
cond.wait(lock);
}
lock.unlock();
//消费
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
lock.lock();
while(!produced.empty()){
cout<<"Consuming..."<<endl;
int xxx=produced.front();
xxx+=100;
cout<<"xxx="<<xxx<<endl;
produced.pop();
}
notified=false;
}
};
std::thread p(producer);
std::thread cs[2];
for(int i=0;i<2;++i){
cs[i]=std::thread(consumer);
}
p.join();
for(int i=0;i<2;++i){
cs[i].join();
}
return 0;
}
当然这里也会发现一些问题,比如我们消费的耗时太久那就会导致生产队列一直增长,内存占用越来越高
原子操作
我们除了锁,还可以使用原子操作来防止并发读写问题.可以用atomic
来实例化一些原子操作对象,并且很多运算符都经过重载过方便使用.
异步
async
异步可以让耗时的操作不影响当前主进程的执行,而是单独的启动一个新的线程来运行任务
#include<iostream>
#include<future>
#include<thread>
#include<mutex>
#include<cmath>
using namespace std;
static const int MAX=10e8;
static double sum=0;
void worker(int min,int max){
cout<<"The Thread ID is:"<<std::this_thread::get_id()<<endl;
for(int i=min;i<max;i++){
sum+=sqrt(i);
}
}
int main(){
sum=0;
cout<<"The Thread ID is:"<<std::this_thread::get_id()<<endl;
auto f1=async(launch::async,worker,0,MAX);
//auto f1=async(worker,0,MAX);
cout<<"Async trigger"<<endl;
f1.wait();
cout<<"Async Finish!!! result="<<sum<<endl;
}
future
上面说异步是我们希望开辟一个线程去执行某操作,但是我们并不想停下来等它执行结束,而是未来某个时间能得到结果.有了future
之后就可以很简单地获得异步任务结果.
#include<iostream>
#include<future>
#include<thread>
using namespace std;
int main(){
std::packaged_task<int()>task([](){return 111;});
std::future<int> result=task.get_future();
std::thread(std::move(task)).detach();
cout<<"Waiting ..."<<endl;
result.wait();
cout<<"Done!!!"<<endl<<"Result is: "<<result.get()<<endl;
return 0;
}
这里用一个线程去得到返回值,然后当我们需要的时候使用get
方法就可以得到结果
当然还有一些更进阶的使用方式,当我们需要某个函数计算结果并且将任务结束和结果返回分离开这个时候还需要使用promise
搭配future
使用
#include<iostream>
#include<future>
#include<thread>
#include<cmath>
#include<vector>
using namespace std;
static int MAX=10e7;
double concurrent_worker(int min, int max) {
double sum = 0;
for (int i = min; i <= max; i++) {
sum += sqrt(i);
}
return sum;
}
void concurrent_task(int min, int max, promise<double>* result) {
vector<future<double>> results;
unsigned concurrent_count = thread::hardware_concurrency();
min = 0;
for (int i = 0; i < concurrent_count; i++) {
packaged_task<double(int, int)> task(concurrent_worker);
results.push_back(task.get_future());
int range = max / concurrent_count * (i + 1);
thread t(std::move(task), min, range);
t.detach();
min = range + 1;
}
cout << "threads create finish" << endl;
double sum = 0;
for (auto& r : results) {
sum += r.get();
}
result->set_value(sum);
cout << "concurrent_task finish" << endl;
}
int main() {
auto start_time = chrono::steady_clock::now();
promise<double> sum;
concurrent_task(0, MAX, &sum);
auto end_time = chrono::steady_clock::now();
auto ms = chrono::duration_cast<chrono::milliseconds>(end_time - start_time).count();
cout << "Concurrent task finish, " << ms << " ms consumed." << endl;
cout << "Result: " << sum.get_future().get() << endl;
return 0;
}