c++多线程并发的原理及实现

一、并发、线程、进程的基本概念和综述

线程:线程是操作系统能够进行CPU调度的最小单位,它被包含在进程之中,一个进程可包含单个或者多个线程。可以用多个线程去完成一个任务,也可以用多个进程去完成一个任务,它们的本质都相当于多个人去合伙完成一件事。

多线程并发:多线程是实现并发(双核的真正并行或者单核机器的任务切换都叫并发)的一种手段,多线程并发即多个线程同时执行,一般而言,多线程并发就是把一个任务拆分为多个子任务,然后交由不同线程处理不同子任务,使得这多个子任务同时执行。

C++多线程并发: (简单情况下)实现C++多线程并发程序的思路如下:将任务的不同功能交由多个函数分别实现,创建多个线程,每个线程执行一个函数,一个任务就这样同时分由不同线程执行了。

不要过多纠结多线程与多进程、并发与并行这些概念 (这些概念都是相当于多个人去合伙完成一件事),会用才是王道,理解大致意思即可,想要深入了解可阅读本文“延伸拓展”章节。

我们通常在何时使用并发? 程序使用并发的原因有两种,为了关注点分离(程序中不同的功能,使用不同的线程去执行),或者为了提高性能。当为了分离关注点而使用多线程时,设计线程的数量的依据,不再是依赖于CPU中的可用内核的数量,而是依据概念上的设计(依据功能的划分)。

知道何时不使用并发与知道何时使用它一样重要。 不使用并发的唯一原因就是收益(性能的增幅)比不上成本(代码开发的脑力成本、时间成本,代码维护相关的额外成本)。运行越多的线程,操作系统需要为每个线程分配独立的栈空间,需要越多的上下文切换,这会消耗很多操作系统资源,如果在线程上的任务完成得很快,那么实际执行任务的时间要比启动线程的时间小很多,所以在某些时候,增加一个额外的线程实际上会降低,而非提高应用程序的整体性能,此时收益就比不上成本。

进程:可执行的程序运行起来了,就叫做创建了一个线程,即运行起来的可执行

多线程(并发)

多线程并不是越多越好,每个线程都需要一个独立的堆栈空间,线程保存的越多的中间状态;

多线程之间的切换需要花费大量的时间。

总结

a)线程是用来执行代码的

b)把线程这个东西理解成一个条代码的执行通路,一个新的线程代表一条新的通路。

c)一个进程包含一个主线程,主线程随着进程默默启动并运行,我们可以通过编码来创建多个其他线程(非主线程)。但创建的线程数量不要超过200-300个,具体是每个人情况而定;

d)因为主线程是自动启动的,所以一个进程至少包含一个线程,进程与线程类似父子关系;

e)多线程可以同时干多件事情,所以运行速度快,具体速度具体程序评估。

二、并发的实现方法

实现并行的手段:a)通过多个进程实现并发;b)在单独进程中建立多个线程来实现并发,自己写代码来创建了除主线程之外的其他线程

2.1多进程并发

进程间通信(同一个电脑):管道,文件,消息对列,共享内存;

进程间通信(不同一个电脑:socket通信技术

2.2多线程并发

线程:轻量级进程,每个线程有自己独立运行路径,但是一个进程中所有线程共享地址空间(共享内存);

全局变量,指针,引用都可在线程之间进行传递,所以:使用多线程开销远远小于多进程;

共享内存带来的新问题:数据一致性问题,线程A、线程B同时使用一个数据 。

三、多线程并发的实现

**步骤:**程序运行起来,生成一个进程,该进程所属的主线程开始自动运行;主线程从主函数返回,则整个进程执行完毕。

主线程从main()函数开始执行,那么我们自己创建的线程,也需要从一个函数开始运行,一旦函数运行完毕,回到主线程;

整个进程是都执行完毕的标志是主线程是否执行完毕,如果主线程执行完毕了,就代表了整个进程都执行完毕了。此时,一般情况下,如果其他子线程还没执行完毕,那么这些子线程也会被操作系统强行终止。所以,一般情况下,我们得到一个结论,如果大家向保持子线程的运行状态,那么一定要保证主线程已知运行,不要让主线程运行完毕。
1、实现的具体过程

a)包含一个头文件thread

b)编写一个初始函数

c)main()中开始写代码

必须明确一点:有两个线程在跑,相当于整个程序的执行有两条线在同时走,所以可以干两件事。

1.1)thread:是标准库里的类

1.2)join():加入,汇合,说白了就是阻塞主线程,让主线程等待子线程执行完毕,然后子线程与主线程汇合。

1.3)detach():分离,传统多线程程序需要等待子线程执行完毕,然后再退出,detach能够实现主线程运行主线程,子线程运行子线程,互不干扰,主线程也不必等子线程运行结束。

总之,使用C++线程库启动线程,可以归结为构造std::thread对象。当线程启动后,一定要在和线程相关联的std::thread对象销毁前,对线程运用join()或者detach()方法。
join()与detach()都是std::thread类的成员函数,是两种线程阻塞方法,两者的区别是是否等待子线程执行结束。
新手先把join()弄明白就行了,然后就可以去学习后面的章节,等过段时间再回头来学detach()。
等待调用线程运行结束后当前线程再继续运行,例如,主函数中有一条语句th1.join(),那么执行到这里,主函数阻塞,直到线程th1运行结束,主函数再继续运行。

整个过程就相当于:你在处理某件事情(你是主线程),中途你让老王帮你办一个任务(与你同时执行)(创建线程1,该线程取名老王),又叫老李帮你办一件任务(创建线程2,该线程取名老李),现在你的一部分工作做完了,剩下的工作得用到他们的处理结果,那就调用"老王.join()“与"老李.join()”,至此你就需要等待(主线程阻塞),等他们把任务做完(子线程运行结束),你就可以继续你手头的工作了(主线程不再阻塞)。
一提到join,你脑海中就想起两个字,“等待”,而不是"加入",这样就很容易理解join的功能。

#include
#include
using namespace std;
void proc(int &a)
{
    cout << "我是子线程,传入参数为" << a << endl;
    cout << "子线程中显示子线程id为" << this_thread::get_id()<< endl;
}
int main()
{
    cout << "我是主线程" << endl;
    int a = 9;
    thread th2(proc,ref(a));//第一个参数为函数名,第二个参数为该函数的第一个参数,如果该函数接收多个参数就依次写在后面。此时线程开始执行。
    cout << "主线程中显示子线程id为" << th2.get_id() << endl;
    th2.join()//此时主线程被阻塞直至子线程执行结束。
    return 0;
}

调用join()会清理线程相关的存储部分,这代表了join()只能调用一次。使用joinable()来判断join()可否调用。同样,detach()也只能调用一次,一旦detach()后就无法join()了,有趣的是,detach()可否调用也是使用joinable()来判断。

2、互斥量问题
这样比喻:单位上有一台打印机(共享数据a),你要用打印机(线程1要操作数据a),同事老王也要用打印机(线程2也要操作数据a),但是打印机同一时间只能给一个人用,此时,规定不管是谁,在用打印机之前都要向领导申请许可证(lock),用完后再向领导归还许可证(unlock),许可证总共只有一个,没有许可证的人就等着在用打印机的同事用完后才能申请许可证(阻塞,线程1lock互斥量后其他线程就无法lock,只能等线程1unlock后,其他线程才能lock)。那么,打印机就是共享数据,访问打印机的这段代码就是临界区,这个必须互斥使用的许可证就是互斥量(锁)。
2.1 同步与互斥
任务运行时,有些任务片段间存在严格的先后顺序,同步指维护任务片段的先后顺序;

直观的表现就是若A片段执行完才能执行B片段,线程1执行A片段,线程2执行B片段,在B片段执行前申请锁l,在A片段执行结束后解锁l;未申请到锁l即A片段还未执行完,线程1等待线程2执行。A片段解锁了B片段才能申请到锁,保证了A片段执行结束了B片段才能运行,称之为同步;

互斥就是保证资源同一时刻只能被一个进程使用;互斥是为了保证数据的一致性,如果A线程在执行计算式A的时候,某个量被B线程改掉了,这可能会出现问题,于是要求资源互斥,我在用它你就不能用,等我用完了你再用,我们彼此互不干扰。
2.2 互斥锁
互斥量mutex就是互斥锁,加锁的资源支持互斥访问;
2.3 读写锁
shared_mutex读写锁把对共享资源的访问者划分成读者和写者,多个读线程能同时读取共享资源,但只有一个写线程能同时读取共享资源
2.4 lock()与unlock()
shared_mutex通过lock_shared,unlock_shared进行读者的锁定与解锁;通过lock,unlock进行写者的锁定与解锁。
首先需要#include(std::mutex和std::lock_guard都在头文件中声明。)
然后需要实例化std::mutex对象;
需要在进入临界区之前对互斥量加锁,退出临界区时对互斥量解锁;

#include
#include
#include
using namespace std;
mutex m;//实例化m对象,不要理解为定义变量
void proc1(int a)
{
    m.lock();
    cout << "proc1函数正在改写a" << endl;
    cout << "原始a为" << a << endl;
    cout << "现在a为" << a + 2 << endl;
    m.unlock();
}

void proc2(int a)
{
    m.lock();
    cout << "proc2函数正在改写a" << endl;
    cout << "原始a为" << a << endl;
    cout << "现在a为" << a + 1 << endl;
    m.unlock();
}
int main()
{
    int a = 0;
    thread t1(proc1, a);
    thread t2(proc2, a);
    t1.join();
    t2.join();
    return 0;
}

需要在进入临界区之前对互斥量加锁lock,退出临界区时对互斥量解锁unlock;当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。

程序实例化mutex对象m,本线程调用成员函数m.lock()会发生下面 2 种情况: (1)如果该互斥量当前未上锁,则本线程将该互斥量锁住,直到调用unlock()之前,本线程一直拥有该锁。 (2)如果该互斥量当前被其他线程锁住,则本线程被阻塞,直至该互斥量被其他线程解锁,此时本线程将该互斥量锁住,直到调用unlock()之前,本线程一直拥有该锁。

不推荐实直接去调用成员函数lock(),因为如果忘记unlock(),将导致锁无法释放,使用lock_guard或者unique_lock则能避免忘记解锁带来的问题。
2.5 lock_guard:
std::lock_guard()是什么呢?它就像一个保姆,职责就是帮你管理互斥量,就好像小孩要玩玩具时候,保姆就帮忙把玩具找出来,孩子不玩了,保姆就把玩具收纳好。

其原理是:声明一个局部的std::lock_guard对象,在其构造函数中进行加锁,在其析构函数中进行解锁。最终的结果就是:创建即加锁,作用域结束自动解锁。从而使用std::lock_guard()就可以替代lock()与unlock()。

通过设定作用域,使得std::lock_guard在合适的地方被析构(在互斥量锁定到互斥量解锁之间的代码叫做临界区(需要互斥访问共享资源的那段代码称为临界区),临界区范围应该尽可能的小,即lock互斥量后应该尽早unlock),通过使用{}来调整作用域范围,可使得互斥量m在合适的地方被解锁:

#include
#include
#include
using namespace std;
mutex m;//实例化m对象,不要理解为定义变量
void proc1(int a)
{
    lock_guard<mutex> g1(m);//用此语句替换了m.lock();lock_guard传入一个参数时,该参数为互斥量,此时调用了lock_guard的构造函数,申请锁定m
    cout << "proc1函数正在改写a" << endl;
    cout << "原始a为" << a << endl;
    cout << "现在a为" << a + 2 << endl;
}//此时不需要写m.unlock(),g1出了作用域被释放,自动调用析构函数,于是m被解锁

void proc2(int a)
{
    {
        lock_guard<mutex> g2(m);
        cout << "proc2函数正在改写a" << endl;
        cout << "原始a为" << a << endl;
        cout << "现在a为" << a + 1 << endl;
    }//通过使用{}来调整作用域范围,可使得m在合适的地方被解锁
    cout << "作用域外的内容3" << endl;
    cout << "作用域外的内容4" << endl;
    cout << "作用域外的内容5" << endl;
}
int main()
{
    int a = 0;
    thread t1(proc1, a);
    thread t2(proc2, a);
    t1.join();
    t2.join();
    return 0;
}

std::lock_gurad也可以传入两个参数,第一个参数为adopt_lock标识时,表示构造函数中不再进行互斥量锁定,因此此时需要提前手动锁定。

#include
#include
#include
using namespace std;
mutex m;//实例化m对象,不要理解为定义变量
void proc1(int a)
{
    m.lock();//手动锁定
    lock_guard<mutex> g1(m,adopt_lock);
    cout << "proc1函数正在改写a" << endl;
    cout << "原始a为" << a << endl;
    cout << "现在a为" << a + 2 << endl;
}//自动解锁

void proc2(int a)
{
    lock_guard<mutex> g2(m);//自动锁定
    cout << "proc2函数正在改写a" << endl;
    cout << "原始a为" << a << endl;
    cout << "现在a为" << a + 1 << endl;
}//自动解锁
int main()
{
    int a = 0;
    thread t1(proc1, a);
    thread t2(proc2, a);
    t1.join();
    t2.join();
    return 0;
}

2.6 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;

#include
#include
#include
using namespace std;
mutex m;
void proc1(int a)
{
	unique_lock<mutex> g1(m, defer_lock);//始化了一个没有加锁的mutex
	cout << "xxxxxxxx" << endl;
	g1.lock();//手动加锁,注意,不是m.lock();注意,不是m.lock(),m已经被g1接管了;
	cout << "proc1函数正在改写a" << endl;
	cout << "原始a为" << a << endl;
	cout << "现在a为" << a + 2 << endl;
	g1.unlock();//临时解锁
	cout << "xxxxx" << endl;
	g1.lock();
	cout << "xxxxxx" << endl;
}//自动解锁

void proc2(int a)
{
	unique_lock<mutex> g2(m, try_to_lock);//尝试加锁一次,但如果没有锁定成功,会立即返回,不会阻塞在那里,且不会再次尝试锁操作。
	if (g2.owns_lock()) {//锁成功
		cout << "proc2函数正在改写a" << endl;
		cout << "原始a为" << a << endl;
		cout << "现在a为" << a + 1 << endl;
	}
	else {//锁失败则执行这段语句
		cout << "" << endl;
	}
}//自动解锁

int main()
{
	int a = 0;
	thread t1(proc1, a);
	t1.join();
	//thread t2(proc2, a);
	//t2.join();
	return 0;
}

使用try_to_lock要小心,因为try_to_lock尝试锁失败后不会阻塞线程,而是继续往下执行程序,因此,需要使用if-else语句来判断是否锁成功,只有锁成功后才能去执行互斥代码段。而且需要注意的是,因为try_to_lock尝试锁失败后代码继续往下执行了,因此该语句不会再次去尝试锁。

2.7 unique_lock所有权的转移

注意,这里的转移指的是std::unique_lock对象间的转移;std::mutex对象的所有权不需要手动转移给std::unique_lock , std::unique_lock对象实例化后会直接接管std::mutex。

mutex m;
{  
    unique_lock<mutex> g2(m,defer_lock);
    unique_lock<mutex> g3(move(g2));//所有权转移,此时由g3来管理互斥量m
    g3.lock();
    g3.unlock();
    g3.lock();
}

2.8 condition_variable
需要#include,该头文件中包含了条件变量相关的类,其中包括std::condition_variable类

如何使用?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(): 唤醒所有等待的线程

三、互斥量问题创建线程时的传参问题分析

如“std::thread th1(proc1)”,创建线程时需要传递函数名作为参数,提供的函数对象会复制到新的线程的内存空间中执行与调用。

如果用于创建线程的函数为含参函数,那么在创建线程时,要一并将函数的参数传入。常见的,传入的参数的形式有基本数据类型(int,char,string等)、引用、指针、对象这些,下面总结了传递不同形式的参数时std::thread类的处理机制,以及编写程序时候的注意事项。本章节只给出了部分示例代码,没有必要为了证明处理机制而举例大量简单代码而使得文章冗长,但是推荐新手自行编写程序研究。

总体来说,std::thread的构造函数会拷贝传入的参数:

3.1 当传入参数为基本数据类型(int,char,string等)时,会拷贝一份给创建的线程;
3.2. 当传入参数为指针时,会浅拷贝一份给创建的线程,也就是说,只会拷贝对象的指针,不会拷贝指针指向的对象本身。

3.3 当传入的参数为引用时,实参必须用ref()函数处理后传递给形参,否则编译不通过,此时不存在“拷贝”行为。引用只是变量的别名,在线程中传递对象的引用,那么该对象始终只有一份,只是存在多个别名罢了(注意把引用与指针区别开:指针是一块内存指向另一块内存,指针侧重“指向”二字;引用是只有一块内存,存在多个别名。理解引用时不要想着别名“指向”内存,这是错误的理解,这样的理解会导致分不清指针和引用,别名与其本体侧重于“一体”二字,引用就是本体,本体就是引用,根本没有“指向”关系。);

#include
#include
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;
} 

3.4 当传入的参数为类对象时,会拷贝一份给创建的线程。此时会调用类对象的拷贝构造函数。

四、代码实例- 生产者消费者问题

生产者用于生产数据,生产一个就往共享数据区存一个,如果共享数据区已满的话,生产者就暂停生产,等待消费者的通知后再启动。

消费者用于消费数据,一个一个的从共享数据区取,如果共享数据区为空的话,消费者就暂停取数据,等待生产者的通知后再启动。

生产者与消费者不能直接交互,它们之间所共享的数据使用队列结构来实现;

#include
#include
#include
#include
#include


using namespace std;

//缓冲区存储的数据类型 
struct CacheData
{
	//商品id 
	int id;
	//商品属性 
	string data;
};

queue<CacheData> Q;
//缓冲区最大空间 
const int MAX_CACHEDATA_LENGTH = 10;
//互斥量,生产者之间,消费者之间,生产者和消费者之间,同时都只能一个线程访问缓冲区 
mutex m;
condition_variable condConsumer;
condition_variable condProducer;
//全局商品id 
int ID = 1;

//消费者动作 
void ConsumerActor()
{
	unique_lock<mutex> lockerConsumer(m);
	cout << "[" << this_thread::get_id() << "] 获取了锁" << endl; 
	while (Q.empty())
	{
		cout <<  "因为队列为空,所以消费者Sleep" << endl; 
		cout << "[" << this_thread::get_id() << "] 不再持有锁" << endl;
		//队列空, 消费者停止,等待生产者唤醒 
		condConsumer.wait(lockerConsumer);
		cout << "[" << this_thread::get_id() << "] Weak, 重新获取了锁" << endl; 
	}
	cout << "[" << this_thread::get_id() << "] "; 
	CacheData temp = Q.front();
	cout << "- ID:" << temp.id << " Data:" << temp.data << endl;
	Q.pop(); 
	condProducer.notify_one();
	cout << "[" << this_thread::get_id() << "] 释放了锁" << endl; 
}

//生产者动作 
void ProducerActor()
{
	unique_lock<mutex> lockerProducer(m);
	cout << "[" << this_thread::get_id() << "] 获取了锁" << endl; 
	while (Q.size() > MAX_CACHEDATA_LENGTH)
	{
		cout <<  "因为队列为满,所以生产者Sleep" << endl; 
		cout << "[" << this_thread::get_id() << "] 不再持有锁" << endl; 
		//对列慢,生产者停止,等待消费者唤醒 
		condProducer.wait(lockerProducer);
		cout << "[" << this_thread::get_id() << "] Weak, 重新获取了锁" << endl; 
	}
	cout << "[" << this_thread::get_id() << "] "; 
	CacheData temp;
	temp.id = ID++;
	temp.data = "*****";
	cout << "+ ID:" << temp.id << " Data:" << temp.data << endl; 
	Q.push(temp);
	condConsumer.notify_one();
	cout << "[" << this_thread::get_id() << "] 释放了锁" << endl; 
}

//消费者 
void ConsumerTask()
{
	while(1)
	{
		ConsumerActor();
	}	
}

//生产者 
void ProducerTask()
{
	while(1)
	{
		ProducerActor();
	}	
}

//管理线程的函数 
void Dispatch(int ConsumerNum, int ProducerNum)
{
	vector<thread> thsC;
	for (int i = 0; i < ConsumerNum; ++i)
	{
		thsC.push_back(thread(ConsumerTask));
	}
	
	vector<thread> thsP;
	for (int j = 0; j < ProducerNum; ++j)
	{
		thsP.push_back(thread(ProducerTask));
	}
	
	for (int i = 0; i < ConsumerNum; ++i)
	{
		if (thsC[i].joinable())
		{
			thsC[i].join();
		}
	}
	
	for (int j = 0; j < ProducerNum; ++j)
	{
		if (thsP[j].joinable())
		{
			thsP[j].join();
		}
	}
}

int main()
{
	//一个消费者线程,5个生产者线程,则生产者经常要等待消费者 
	Dispatch(1,5);
	return 0; 
}

你可能感兴趣的:(c++,java,开发语言)