C++11多线程编程

线程管理

创建/启动一个新线程

无参线程

hello world开始:

#include <iostream>
#include <thread>
void hello(){
	std::cout<<"Hello Concurrent World!"<<endl;
}

int main(){
	std::thread t(hello);
	t.join();
}

众所周知,主线程的入口函数为main函数。同样的,子线程也需要有一个入口函数hello,并在调用子线程的构造函数的时传给子线程std::thread t(hello);。子线程在创建的那一刻就启动了。

我们希望子线程输出字符串的时候,主线程即使运行完也要等待它,两个线程一起结束,所以调用join方法。

此外,这个函数hello可以通过重载Function call实现成仿函数的形式:

class hello{
public:
	void operator()(){
		std::cout<<"Hello Concurrent World!"<<std::endl;
	}
};

提供给thread实例的函数对象会复制到新线程的存储空间中,函数对象的执行和调用都在线程的内存空间中执行。传递函数对象时要注意语法的格式:

std::thread t(hello());

上面的代码会被c++编译器将整句话整体解析为函数声明,而不是类型对象的定义!!!编译器认为这里声明了一个函数名为t的函数,返回值为std::thread,函数带有一个参数(一个无参数的函数指针,返回类型为hello)。解决方案如下:

std::thread t((hello())); //1
std::thread t{hello()}; //2

另一种方案是使用lambda表达式:

std::thread t([]{
	std::cout<<"Hello Concurrent World!"<<std::endl;
});

此外,可以将类的普通成员函数作为子线程入口函数:

class Hello{
public:
	void hello(){
		std::cout<<"Hello Concurrent World!"<<std::endl;
	}
};
int main(){
	Hello h;
	std::thread t(&Hello::hello,&h);
	t.join();
	return 0;
}

这种方式需要一个类的实例h。子线程将&Hello::hello作为线程函数,h的地址作为指针对象提供给函数。

有参线程

或者叫向线程传递参数:

void shared_print(std::string &msg,int idx){
	std::cout<<msg<<":"<<idx<<std::endl;
}
void func(std::string &msg,int N){
	for(int i=0;i<N;i++){
		shared_print(msg,i);
	}
}
int main(){
	std::thread t(func,"sub thread ",4);
	t.join();
	return 0;
}

注意:
这里thread t的第二个参数传入的是字符串字面值const char *,会隐式转换为string。如果担心隐式转换可能会崩溃,从而导致空悬指针,可以使用显式转换std::thread t(func,std::string("sub thread "),4);
对于如下代码:

void factorial(int &x){
	for(int i=x-1;i>0;i--){
		x *= i;
	}
}
int main(){
	int x=4;
	std::thread t(factorial,x); //error:未能使函数模板“unknown-type std::invoke(_Callable &&,_Types &&...)”专用化
	std::cout<<""<<x<<std::endl;
	return 0;
}

即使默认参数是引用形式,也会拷贝到线程独立内存中(vs2015无法编译通过)。
此时需要std::ref将参数转换为引用的形式:

thread t(factorial,std::ref(t));

可以使用移动语义move将参数的所有权转移:

class Hello {
public:
	void hello(string s) {
		std::cout << "Hello Concurrent World!" <<s<< std::endl;
	}
};
int main() {
	Hello h;
	string s = "\nfun";
	std::thread t(&Hello::hello, &h,move(s));
	cout << s << endl;  //此时s为空
	t.join();
	return 0;
}

等待和分离线程

一旦启动了线程,需要明确指定时等待线程结束(加入式join)还是让他自主运行(分离式detach)。
如果thread对象销毁之前还没有指定,程序就会终止。

如果指定detach,可能面临线程还没结束,函数已经退出的风险。此时就可能访问已销毁
的变量。将数据复制到线程中,使线程函数的功能齐全能减小此风险,但对于指针和引用要谨慎处理。

如果需要等待线程,需要使用joinjoin可以确保局部变量在线程完成之后,才会被销毁。
在上边的例子中,因为原始线程在其生命周期中并没有做什么事,使得多线程看起来和单线程的效率差不多,但是在实际中要么主线程是有额外的工作,要么会有多个子线程同时工作的。
调用join的行为,还清理了线程相关的存储部分,这样thread对象将不再与已经完成的线程有任何关联。这意味着只能调用一次join,能否进行join可以使用joinable来判断:

void shared_print(string& msg,int idx){
	cout<<msg<<":"<<idx<<endl;
}
void func(string &msg,int N){
	for(int i=0;i<N;i++ ){
		shared_print(msg,i);	
	}
}
int main(){
	thread t(func,"sub thread",5);
	try{
		for(int i=0;i<5;i++){
			shared_print("main thread",i);
		}
	}
	catch(...){
		if(t.joinable()) t.join();
		throw;
	}
	if(t.joinable()) t.join();
	return 0;
}

转移线程所有权

线程属于resource-owning类型,同一时刻只能有一个对象对其有拥有权,所以线程只能移动,不能拷贝。

void func1();
void func2();
thread t1(func1);
thread t2 = move(t1);
t1 = thread(func2);
thread t3;
t3 = move(t2);
t1 = move(t3); //错误,t1在此之前已经拥有一个线程,此操作会导致程序崩溃

运行时决定线程数量

int thread_num = thread::hardware_concurrency();

识别线程

this_thread::get_id(); //获取当前线程id
t1.get_id();	//获取指定线程id

线程间共享数据/变量

invariants and race condition

了解invariants能够更好的了解race condition。

所谓的不变量是由程序作出的假设,特别是有关变量组间关系的假设。当编写队列包时,你需要为每一个队列指定一个队列头指针,指向队列的第一个元素。每一个数据元素也包含指向下一个元素的指针。重要的并不完全是数据,程序还要依赖于数据之间的关系。例如,队列或者为空,或者包含一个指向队首元素的指针。数据元素包含的指针或者指向下一个队列元素,或者为空(此时该元素为队尾元素)上述关系就是队列包中的不变量。

即使不变量有时是不明显的,也很难遇到一个完全没有不变量的程序。当程序遇到被破坏的不变量时,系统可能会返回错误结果甚至立即失败。例如,当程序试图解除对指向无效数据元素的队列头指针的引用时。—《Posix多线程程序设计》

Invariants are assumptions made by a program, especially assumptions about the relationships between sets of variables. When you build a queue package, for example, you need certain data. Each queue has a queue header, which is a pointer to the first queued data element. Each data element includes a pointer to the next data element. But the data isn’t all that’s important–your queue package relies on relationships between that data. The queue header, for example, must either be NULL or contain a pointer to the first queued data element. Each data element must contain a pointer to the next data element, or NULL if it is the last. Those relationships are the invariants of your queue package.

It is hard to write a program that doesn’t have invariants, though many of them are subtle.
When a program encounters a broken invariant, for example, if it dereferences a queue header containing a pointer to something that is not a valid data element, the program will probably produce incorrect results or fail immediately. —《Programming with Posix Threads》

结合中英文看第一段:
invariants主要描述的是一个变量集合内部的关系。比如队列中的invariants,指的是队列的头指针内容为空或者包含第一个进队的元素,每个结点包含指向下一结点的指针,如果是最后一个结点则指针为空。

众所周知,我们学习的数据结构就是由数据和数据间的关系构成。那么对于数据结构来说,其中的关系就是指invariants!

结合中英文看第二段:
很难写出没有invariants的程序,有些程序不是没有invariants,只是不易察觉。当程序破坏了invariants,程序会产生错误结果或者立即crush掉。比如队列头指针指向无效数据,我们却尝试对这个头指针解引用时。

第二段的例子指出,我们忽视invariants指定的关系,强制获取空队列的数据,破坏了invariants,造成程序崩溃。

个人感觉invariants翻译成不变关系更好,翻译成不变量会给人一种错觉,以为这是一种数据量,而事实却是invariants指的是数据间的关系。

那么什么是条件竞争呢?
我们以双向链表为例距离更好:
在这里插入图片描述
如上图,目的是删除双向链表的某个结点。在删除结点的过程中,b和c不是双向链表,因为其破坏了invariants。删除完结点之后,又恢复了invariants,又成为了新的双向链表。

上面的操作过程中暂时破坏了invariants,但这是可控的。
现在考虑这样一个情景:
线程t1删除上图双向链表的中间的结点时,运行到图b;此时有第二个线程尝试删除右侧的结点。那么右侧结点的下一结点的prev指针可能被修改为指向正在被删除的中间结点,导致整个invariants被破坏且不可恢复。这种情景就是条件竞争race condition

第二个条件竞争的例子,对于栈,一种可能的执行顺序:
在这里插入图片描述
当线程运行时,调用了两次top(),栈没被修改,所以每个线程都能得到同样的值。不仅是这样,调用top()函数的过程中,pop()都没有被调用。这导致第二次pop()的元素直接扔掉了,没有被任何一个线程获取到,造成信息的丢失。此外,在empty()和top()之间,如果另一个线程调用pop()并使线程为空,也会发生条件竞争,导致程序崩溃。

使用互斥量保护共享数据

c++中通过实例化mutex创建互斥量,通过调用成员函数lock进行上锁,unlock进行解锁。
这种方式需要在每个函数的出口或者异常都去调用unlock,实际情况下并不推荐。

c++提供了一种RAII(Resource Acquisition Is Initialization 资源获取即初始化)的方式的模板类lack_guard,其会在构造的时候提供以缩的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。

例,对cout的访问权限加锁,希望一个线程输出完另一个线程才会输出,不会发生同时调用cout的情况:

mutex mu;
//版本1写法
void shared_print_1(string msg, int id) {
	mu.lock(); //如果这句话抛出异常,mu永远会被锁住
	cout << msg << id << endl;  
	mu.unlock();
}

//版本二写法
void shared_print_2(string msg, int id) {
	lock_guard<mutex> guard(mu); //当此句异常,mu对象自动被解锁
	cout << msg << id << endl;
}

由于cout位于std下,是全局变量,没有完全在互斥对象mu的保护下,其它对象仍然可以在不加锁的情况下访问它,为了完全保护资源,互斥对象和资源需要绑定:

//版本三写法
class LogFile {
public:
	LogFile() {
		f.open("log.txt");
	}
	void shared_print(string id, int value) {
		lock_guard<mutex> locker(m_mutex);
		f << "from " << id << ": " << value << endl;
	};
	//!	ofstream& GetStream() { return f; }  //错误写法,不能将f暴露
		//void processf(void fun(ofstream&)) {  //错误写法,fun可以对f为所欲为
		//	fun(f);  
		//}
protected:
private:
	mutex m_mutex;
	ofstream f;
};

大多数情况下,互斥量会和被保护的数据放在同一个类中,而不是定义成全局变量。二者均定义成private成员。
互斥量保护的数据需要对接口的设计相当谨慎,要确保互斥量能锁住任何对保护数据的访问,尤其是需要传递指针和引用时。

还有一种加锁的方式 unique_lock,它和lock_guard都是RAII操作,不同的是他能够更精细的对要保护的数据进行加锁和解锁:

class LogFile {
	ofstream f;
	mutex m1,m2;
public:
	LogFile() {
		f.open("log.txt");
	}
	void shared_print(string msg,int idx) {
		unique_lock<mutex> locker(m1,defer_lock); //第三种加锁方式
		locker.unlock();
		do_something(); //此代码块不想加锁
		locker.lock(); // 解锁之后还可以加锁,增加灵活性。
		f << msg << ":" << idx << endl;
		locker.unlock(); 
		do_some_other_thing();
		locker.lock();
		//此外unique_lock可以被移动,lock_guard则不能被移动
		unique_lock<mutex> locker2 = move(locker);
	}
};

unique_lock提供的这些弹性操作并不是免费的午餐,他需要消耗更多的资源,使用condition_variable的情况下必须使用unique_lock,后边会讲到。

死锁及其解决方案

一个给定操作需要两个或两个以上的互斥量时,另一个潜在的问题将出现:死锁(deadlock)。

class LogFile{
public:
	LogFile() {
		f.open("log.txt");
	}
	void shared_print(string msg,int idx) {  
		lock_guard<mutex> locker1(m1);
		lock_guard<mutex> locker2(m2);
		f << msg << ":" << idx << endl;
	}
	void shared_print2(string msg, int idx) {
		lock_guard<mutex> locker2(m2);
		lock_guard<mutex> locker1(m1);
		f << msg << ":" << idx << endl;
	}
	
protected:
private:
	mutex m1;
	mutex m2;
	ofstream f;
	};
	void func(LogFile& log) {
		for (int i = 0; i > -100; i--) {
			log.shared_print2(string("sub thread:"), i);
			cout << "sub thread:" << i << endl;
		}
	}
}
using namespace multithread_02;
int main() {
	LogFile log;
	thread t1(func,ref(log));
		
	for (int i = 0; i < 100; i++) {
		log.shared_print(string("main thread:"),i);
		cout << "main thread:" << i << endl;
	}
	if (t1.joinable()) t1.join();
	return 0;
}

看上边的程序,当主线程运行完shared_print的lock_guard locker1(m1); m1上锁。此时若t1线程刚运行完shared_print2的lock_guard locker2(m2); m2上锁。这时候主线程会等待m2解锁,t1线程会等待m1解锁,造成死锁。

第一种方案是保证每一个函数locker的顺序相同:

void shared_print(string msg,int idx) {  
		lock_guard<mutex> locker1(m1);
		lock_guard<mutex> locker2(m2);
		f << msg << ":" << idx << endl;
	}
	void shared_print2(string msg, int idx) {
		lock_guard<mutex> locker1(m1);
		lock_guard<mutex> locker2(m2);
		f << msg << ":" << idx << endl;
	}

另一个方案是使用std::lock

void shared_print(string msg,int idx) {  
	lock(m1,m2);
	lock_guard<mutex> locker1(m1,adopt_lock);
	lock_guard<mutex> locker2(m2,adopt_lock);
	f << msg << ":" << idx << endl;
}
void shared_print2(string msg, int idx) {
	lock(m1, m2);
	lock_guard<mutex> locker2(m2, adopt_lock);
	lock_guard<mutex> locker1(m1, adopt_lock);
	f << msg << ":" << idx << endl;
}

总结一下如何解决死锁:

  • 首先评估当前程序是否需要两个锁,如果只要一个就不存在死锁
  • 如果确实需要在同一时刻需要两个以上的锁,可以使用lock函数,配合adopt_lock
  • 然后,避免在上锁的同时去调用其它的你不熟悉的函数,有可能这个函数包含了另外的锁,必须清楚调用的其它函数和类
  • 最后,使用固定顺序获取锁

互斥量所有权传递

对于unique_lock的实例,互斥量可以通过移动语义在不同的实例中传递。
很多时候需要我们显式声明:

mutex mu;
unique_lock<mutex> locker(mu);
unique_lock<mutex> locker2 = move(locker);

此外,一些时候可以通过返回值隐式移动:

std::unique_lock<std::mutex> get_lock(){
	extern std::mutex some_mutex;
	std::unique_lock<std::mutex> locker(some_mutex);
	prepare_data();
	return locker;
}
void process_data(){
	std::unique_lock<std::mutex> lock(get_lock());
	do_something();
}

锁的粒度

锁的粒度用来描述通过一个锁保护着的数据量大小。由于这个被保护的数据量规模不太好定量,所以这是一个hand-waving term。在这个问题上一般粗略的分为两类:细粒度锁(a fine-grained lock)和粗粒度锁(a coarse-grained lock)。一个细粒度锁能够保护较小的数据量,一个粗粒度锁能够保护较多的数据量。

比如lock_guard和unique_lock相比,粒度更粗,他在构造实例时上锁,在作用域(函数)结束时解锁,导致很多和invariants无关的操作也被上锁。与之相反,unique_lock对粒度的控制更精细:

void get_and_process_data(){
	std::unique_lock<std::mutex> my_lock(the_mutex);
	some_class data_to_process = get_next_data_chunk();
	my_lock.unlock();//process操作没有访问共享数据,解锁
	result_type result=process(data_to_process);
	my_lock.lock(); //为了写入数据,对互斥量再次上锁
	write_result(data_to_process,result);
}

同步并发操作

很多时候,我们希望不同线程间按照一定的顺序执行,比如线程A顺序执行任务1和任务3的同时线程B执行任务2,而任务3需要任务2的结果。

如果线程A先执行完任务1,那就需要进行休眠等待线程B的任务2执行完成,线程B的任务2完成后发送一个任务达成信号,然后线程A结束休眠再执行任务3。

c++ 提供了一些工具用于上边描述的同步操作:条件变量condition variables和期望futures。

条件变量

c++标准库对条件变量有两套实现std::condition_variablestd::condition_variable_any。这两个实现都包含在<condition_variable>头文件的声明中。二者都需要一个互斥量,前者只接受std::mutex,后者更加通用,可以和任何满足最低标准的互斥量一起工作,但这增加了开销。

#include <deque>
deque<int> q;
mutex mu;
#include <condition_variable>
//条件变量
condition_variable cond;

void func_1() {
	int cnt = 10;
	while(cnt>0)
	{
		unique_lock<mutex> locker(mu);
		q.push_front(cnt);
		locker.unlock();
		cond.notify_one();  //发送任务达成信号,会激活一个等待这个条件的线程
		//如果存在多个像线程2这样的等待线程,想要同时激活 调用cond.notify_all()
		this_thread::sleep_for(chrono::seconds(1));
		cnt--;
	}
};

void func_2() {
	int data = 0;
	while (data != 1) {
		unique_lock<mutex> locker(mu);
		cond.wait(locker, []() {return !q.empty(); });  
		//这条语句会将线程2休眠,直到线程1调用nodify_one 才会激活
		//条件变量可以避免线程2的很多无用的循环,或者说条件变量可以部分控制线程之间的访问顺序
		//为什么wait函数需要locker作为它的参数呢?
		//因为在wait之前,互斥对象mu已经被线程2锁住,线程休眠的时候线程里不能有东西被锁住
		//wait会先解锁互斥对象mu,然后休眠;激活时对mu重新加锁
		//由于需要重复的加解锁,所以只能使用unique_lock,而不能使用lock_guard
		//此外线程2可能被自己激活,称之为伪激活。如果碰到伪激活不想让线程2运行,需要传入一个检查条件
		//如果q不为空,才会激活,q为空就不会激活
		data = q.back();
		q.pop_back();
		locker.unlock();
		cout << "t2 get a value from t1:" << data << endl;
	}
};

int main() {
	thread t1(func_1);
	thread t2(func_2);
	t1.join();
	t2.join();
	return 0;
}

future和promise

未完待续

  • 34
    点赞
  • 179
    收藏
    觉得还不错? 一键收藏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值