线程库和异常

线程

概念
  • 在 C++11 之前,涉及到多线程问题,都是和平台相关的,比如 windows 和 linux 下各有自己的接口,这使得代码的可移植性比较差,C++11 中最重要的特性就是对线程进行支持了,这使得 C++ 在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念,要使用标准库中的线程,必须包含<thread >头文件;
接口
  • thread 线程名称():构造一个空的线程对象,没有关联任何线程函数,即没有启动任何线程;
  • thread 线程名称(fun,args1, args2,...):构造一个线程对象,并关联线程函数 fun,其中 args1,args2,…为线程函数的参数;
  • get_id():获取线程 id;
  • joinable():判断一个线程是否还在执行,joinable 代表的是一个正在执行中的线程;
  • join():在 A 线程中创建了一个线程对象 B 并关联线程函数,在 B 调用join接口后会阻塞住 A 线程,当线程对象 B 结束后,因为前面调用了join接口,所以此时就会回收 B 线程的资源,接着 A 线程继续执行;
  • detach():在 A 线程中创建了一个线程对象 B 并关联线程函数,在 B 创建后马上调用detach接口,可以把 A 线程与 B 线程分离开,被分离的线程 B 变为后台线程,此时 B 线程的"死活"就与 A 线程无关了,B 线程所有权和控制权将会交给 C++ 运行库,同时 C++ 运行库保证,当线程退出时,其相关资源的能够正确的回收,不会造成资源泄露;
  • 如果既不join等待线程 B 结束,也不detach分离线程 B,那么 B 结束后不会释放资源,就会造成资源泄露,因此线程对象销毁前,要么以jion()的方式等待线程结束,要么以detach()的方式将线程进行分离;
要点
  1. 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态;
  2. 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程;
#include <thread>
int main(){
	thread t1;
	cout << t1.get_id() << endl;
	return 0;
}
//输出:0
  1. 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行,线程函数一般情况下可按照以下三种方式提供:
    • 函数指针
    • lambda表达式
    • 函数对象(仿函数)
#include <iostream>
using namespace std;
#include <thread>
void ThreadFunc(int a){
	cout << "Thread1" << a << endl;
}
class TF{
public:
	void operator()(int a){
		cout << "Thread3" << a << endl;
	}
};
int main(){
	// 线程函数为函数指针
	thread t1(ThreadFunc, 10);
	// 线程函数为lambda表达式
	thread t2([]{cout << "Thread2" << endl; });
	// 线程函数为函数对象(仿函数)
	TF tf;
	thread t3(tf, 20);
	
	t1.join();
	t2.join();
	t3.join();
	cout << "Main thread!" << endl;
	return 0;
}
  1. thread类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个线程对象的关联线程的状态转移给其他线程对象,转移期间不影响线程的执行;
  2. 可以通过joinable()函数判断线程是否是有效的,如果是以下任意情况,则线程无效:
    • 采用无参构造函数构造的线程对象;
    • 线程对象的状态已经转移给其他线程对象;
    • 线程已经调用jion或者detach结束;
线程函数参数
  • 线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参;
  • 想要在线程中修改外部的实参,有两种方法:
    • 传指针:地址就算拷贝了也是指向那片实际的空间的,所以不受影响;
    • ref()函数:在传引用时借用ref()函数就可以实现通过形参改变实参;
#include <thread>
void ThreadFunc1(int& x){
	x += 10;
}
void ThreadFunc2(int* x){
	*x += 10;
}
int main(){
	int a = 10;
	// 直接传引用,在线程函数中对a修改,不会影响外部实参,因为:线程函数参数虽然是引用方式,但其实际引用的是线程栈中的拷贝副本
	thread t1(ThreadFunc1, a);
	t1.join();
	cout << a << endl;
	// 如果想要通过形参改变外部实参时,必须借助ref()函数
	thread t2(ThreadFunc1, std::ref(a);
	t2.join();
	cout << a << endl;
	// 地址的拷贝
	thread t3(ThreadFunc2, &a);
	t3.join();
	cout << a << endl;
	return 0;
}
join的使用细则
  • join():新线程调用后,会阻塞join所在的线程,当新线程终止时,join()会清理相关的线程资源,然后返回,主线程再继续向下执行,然后销毁线程对象。由于join()清理了线程的相关资源,thread对象与已销毁的线程就没有关系了,因此一个线程对象只能使用一次join(),否则程序会崩溃;
  • join使用时的常见错误:
// jion()的误用一
//说明:如果DoSomething()函数返回false,主线程将会结束,jion()没有调用,线程资源没有回收,造成资源泄漏。
void ThreadFunc() { cout<<"ThreadFunc()"<<endl; }
bool DoSomething() { return false; }
int main(){
	std::thread t(ThreadFunc);
	if(!DoSomething())
	return -1;
	t.join();
return 0;
}


// jion()的误用二
// 下面涉及到了异常的一些知识点,但是出错的原因还是和上面一样,那就是异常触发之后Test2程序提前结束,到时没有运行到join,所以造成资源泄露
void ThreadFunc() { cout<<"ThreadFunc()"<<endl; }
void Test1() { throw 1; }
void Test2(){
	int* p = new int[10];
	thread t(ThreadFunc);
	try{
		Test1();
	}
	catch(...){
		delete[] p;
		throw;
	}
	t.jion();
}
  • 由上面的例子可以知道join的使用时机是非常重要的,一个不小心就有可能造成资源泄露,为了避免这些问题,可以采用 RAII 的方式对线程进行封装(关于 RAII 在智能指针章节会详细讲解),RAII 说白了就是利用普通类对象的生命周期来管理线程对象:
#include <thread>
class mythread{
public:
	//使用构造函数将类对象与线程对象进行绑定
	explicit mythread(thread &t)
		:m_t(t)
	{}
	//在析构函数中进行线程对象的join,这样一来,当该类对象销毁时,就会会回收线程资源
	~mythread(){
		if (m_t.joinable())
			m_t.join();
	}
	//不允许线程对象的拷贝和赋值
	mythread(mythread const&)=delete;
	mythread& operator=(const mythread &)=delete;
private:
	thread &m_t;
};
void ThreadFunc() { cout << "ThreadFunc()" << endl; }
bool DoSomething() { return false; }
int main()
{
	thread t(ThreadFunc);
	mythread q(t);
	if (DoSomething())
		return -1;
	return 0;
}

原子性操作

概念
  • 多线程最主要的问题是共享数据带来的问题 (即线程安全),如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据,但是当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦:
#include <iostream>
using namespace std;
#include <thread>
unsigned long sum = 0;
void fun(size_t num){
	for (size_t i = 0; i < num; ++i)
	sum++;
}
int main(){
	cout << "Before joining,sum = " << sum << std::endl;
	thread t1(fun, 10000000);
	thread t2(fun, 10000000);
	t1.join();
	t2.join();
	cout << "After joining,sum = " << sum << std::endl;
	return 0;
//与其输出20000000,但是实际输出确实一个不定的数
}
原子类型
  • 概念:上面的问题我们可以通过加锁来解决,我们也可以使用一种更简单的操作——将要操作的变量设置为原子属性,这样在操作变量时就不需要进行加解锁的操作,线程能够对原子类型的变量互斥访问;
  • 接口:atomic<变量类型T> 变量名字(初始化数据);,声明一个类型为 T 的原子类型变量;
  • 注意:原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在 C++11 中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了;
atomic<int> sum(0);
void fun(int n){
	for (int i = 0; i < n; ++i){
		sum++;
	}
}
void main(){
	int n; 
	cin >> n;
	thread t1(fun, n);
	thread t2(fun, n);
	t1.join();
	t2.join();
	cout << sum << endl;
}
//输出正常

概念
  • 概念:在多线程环境下,如果想要保证某个变量的安全性,只要将其设置成对应的原子类型即可,即高效又不容易出现死锁问题。但是有些情况下,我们可能需要保证一段代码的安全性,那么就只能通过锁的方式来进行控制,例如:一个线程对变量 num = 0 进行加一 100 次,另外一个减一 100 次,每次操作加一或者减一之后,输出 num 的结果;
  • 方法:如果我们将 num 设置为原子属性,虽然两个线程对 num 进行加一或者减一操作不会出问题,但是却不能保证在加一或者减一 一次之后就进行输出,很有可能会是加一再减一之后才会输出,所以此时就需要使用到锁了,让加减操作和输出操作合并一步完成就可以保证无误;
锁的种类
mutex
  • 概念:这是最基本的锁类,该类的对象之间不能拷贝,也不能进行移动,mutex最常用的三个函数:
    • lock():上锁,锁住互斥量,如果互斥量被其他线程占有,则阻塞等待;
    • unlock():解锁,释放对互斥量的所有权;
    • try_lock():尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞;
  • 线程函数调用lock()时,可能会发生以下三种情况:
    • 如果该互斥量当前没有被锁住,则调用者(线程)将该互斥量锁住,直到调用unlock之前,该线程一直拥有该锁;
    • 如果当前互斥量被其他线程锁住,则当前的调用者(线程)被阻塞;
    • 如果当前互斥量被当前调用者(线程)锁住,则会产生死锁 (deadlock),就相当于拿着手机找手机,永远也找不到;
  • 线程函数调用try_lock()时,可能会发生以下三种情况:
    • 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用unlock释放互斥量;
    • 如果当前互斥量被其他线程锁住,则当前调用线程返回false,而并不会被阻塞掉;
    • 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock);
timed_mutex
  • mutex多了两个成员函数,try_lock_for()try_lock_until()
    • try_lock_for():接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与mutextry_lock()不同,try_lock如果被调用时没有获得锁则直接返回 false),如果在此期间其他线程释放了锁,则该线程可以获得锁,如果超时(即在指定时间内还是没有获得锁),则返回 false;
    • try_lock_until():接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得锁,如果超时(即在指定时间内还是没有获得锁),则返回 false;
两个重要的锁
问题
  • 虽然使用锁可以解决一些问题,但是那种普通的锁使用起来很容易出错,很可能会导致死锁的情况,最常见的比如在锁范围内执行返回,或者在锁的范围内抛异常,这些都会导致死锁,因此 C++11 采用 RAII 的方式对锁进行了封装,这就产生了lock_guardunique_lock
lock_guard
template<class _Mutex>
class lock_guard{
public:
	// 在构造lock_gard时,_Mtx还没有被上锁
	explicit lock_guard(_Mutex& _Mtx)
		: _MyMutex(_Mtx)
	{
		_MyMutex.lock();
	}
	// 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁,传入内容为:std::adopt_lock
	lock_guard(_Mutex& _Mtx, adopt_lock_t tag)
		: _MyMutex(_Mtx)
	{}
	~lock_guard() _NOEXCEPT
	{
		_MyMutex.unlock();
	}
	lock_guard(const lock_guard&) = delete;
	lock_guard& operator=(const lock_guard&) = delete;
private:
	_Mutex& _MyMutex;
};
  • 优点:通过上述代码可以看到,lock_guard类模板主要是通过 RAII 的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要传入一个Mutex对象来实例化一个lock_guard,而在实例化对象时会调用构造函数成功上锁,出作用域前lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题;
  • 缺陷:太单一,用户没有办法对该锁进行控制,因此 C++11 又提供了unique_lock
unique_lock
  • unique_locklock_guard在原理上基本没有什么区别,但是unique_lock更加的灵活,它提供了更多的成员函数;
  • 上锁/解锁操作:locktry_locktry_lock_fortry_lock_untilunlock
  • 修改操作:
    • 移动赋值;
    • 交换(swap):与另一个unique_lock对象互换所管理的互斥量所有权;
    • 释放(release):返回它所管理的互斥量对象的指针,并释放所有权;
  • 获取属性:
    • owns_lock:返回当前对象是否上了锁;
    • operator bool():与owns_lock()的功能相同;
    • mutex():返回当前unique_lock所管理的互斥量的指针;
  • 给大家一个链接,这里面有对unique_locklock_guard的详细介绍,感兴趣的小伙伴可以去了解下:点此跳转

异常

传统处理
  1. 终止程序,如assert,缺陷:用户体验非常不好,如发生内存错误,除 0 错误时就会终止程序;
  2. 返回错误码,缺陷:需要程序员自己去查找对应的错误,如系统的很多库的接口函数都是通过把错误码放到errno中表示错误;
异常概念
  • 异常是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者来处理这个错误;
  • 异常的关键字有三个,这三个组合在一起形成了一个完整的异常结构:
    • throw:当问题出现时,程序会抛出一个异常,这是通过使用throw关键字来完成的;
    • trytry块中所放置的就是需要执行的代码,可以理解为尝试执行这段代码,如果出错就会被抛出异常,它后面通常跟着一个或多个catch块;
    • catch:在您想要处理问题的地方,通过异常处理程序捕获异常,catch关键字用于捕获异常,可以有多个catch进行捕获不同的异常并做出相应处理;
  • 如果有一个块使用throw抛出一个异常,捕获异常的方法会使用trycatch关键字,try块中放置可能抛出异常的代码,try块中的代码被称为保护代码,使用try / catch语句的语法如下所示:
{
	//throw抛出异常
}
...
try
{
	// 保护的标识代码
}catch( ExceptionName e1 )
{
	// catch 块,处理一
}catch( ExceptionName e2 )
{
	// catch 块,处理二
}catch( ExceptionName eN )
{
	// catch 块,处理三
}
异常使用
  • 异常的抛出和匹配原则:
    1. 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码;
    2. 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个;
    3. 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch捕捉以后销毁(这里的处理类似于函数的传值返回);
    4. catch(...)被称为万能捕获,可以捕获任意类型的异常,但是不能确定异常的错误是什么;
    5. 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,我们可以抛出派生类对象,使用基类引用来捕获(多态),这个在实际中非常实用;
  • 链式异常:在函数调用链中异常栈的展开匹配原则;
    1. 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句,如果有匹配的,则调到catch的地方进行处理,处理完之后,会继续沿着catch子句后面继续执行,不会返回去之前的代码进行执行;
    2. 没有匹配的catch则退出当前函数栈,继续在调用当前函数的栈中进行查找匹配的catch
    3. 如果到达main函数的栈,依旧没有匹配的,则终止程序;
    4. 上述这个沿着调用链查找匹配的catch子句的过程称为栈展开,所以实际中我们最后都要加一个catch(...)捕获任意类型的异常,否则当有异常没捕获,程序就会直接终止;
      在这里插入图片描述
void fun1(){
	int a, b;
	cin >> a >> b;
	if (b == 0)
		//抛异常
		throw "string error";
	cout << a / b << "fun1()" << endl;
}
void fun2(){
	int* arr = new int[100];
	try{
		fun1();
	}
	catch (int err)
	{}
	catch (double d)
	{}
	cout << "fun2()" << endl;
}

void fun3(){
	fun2();
	cout << "fun3()" << endl;
}
void main(){
	try{
		fun3();
	}
	catch (int i)
	{}
	catch (char* str){
		cout << str << endl;
	}
	catch (...){
		cout << "..." << endl;
	}
	cout << "main()" << endl;
}
异常的重新抛出与安全
  • catch块捕捉到异常后,但是不能完全解决问题,那么在进行完该catch块的操作后,希望再交给更外层的调用链函数来处理,catch则可以通过重新抛出将异常传递给更上层的函数进行处理;
  • 但是重新抛出异常可能会导致本函数中的一些操作没有完成,从而造成一些不安全的问题,例如:
double Division(int a, int b){
	// 当b == 0时抛出异常
	if (b == 0){
		throw "Division by zero condition!";
	}
	return (double)a / (double)b;
}
void Func(){
	int* array = new int[10];
	try {
		int len, time;
		cin >> len >> time;
		cout << Division(len, time) << endl;
	}
	//当捕获到异常后,因为没有能力处理,所以需要重新抛出,但是如果直接重新抛出那么array这片空间就不会被释放了,所以就需要在重新抛出异常之前解决掉当前函数中的问题
	catch (...){
		cout << "delete []" << array << endl;
		delete[] array;
		throw;
	}
	// ...
	//这里虽然也有释放array操作,但是如果上面的catch块中重新抛出异常,那么就不会执行到这里,所以需要在重新抛出前处理掉这个问题
	cout << "delete []" << array << endl;
	delete[] array;
}
int main(){
	try{
		Func();
	}
	catch (const char* errmsg){
		cout << errmsg << endl;
	}
	return 0;
}
  • 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化;
  • 析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等);
  • C++ 中异常经常会导致资源泄漏的问题,比如在newdelete中间抛出了异常,导致内存泄漏、在lockunlock之间抛出了异常导致死锁;
异常规范
  1. 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些,可以在函数的后面接throw(类型列表),列出这个函数可能抛掷的所有异常类型;
  2. 函数的后面接throw(),表示函数不抛异常;
  3. 若无异常接口声明,则此函数可以抛掷任何类型的异常;
// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
void fun() throw(A,B,C,D);
// 这里表示这个函数只会抛出bad_alloc的异常
void* operator new (size_t size) throw (bad_alloc);
// 这里表示这个函数不会抛出异常
void* operator new (size_t size, void* ptr) throw();
异常体系
  • 如果在一个项目中,程序员随意的抛出异常那么用户使用起来就会很烦,所以实际中都会定义一套继承的规范体系,这样大家抛出的都是继承的派生类对象,然后使用基类引用或指针进行捕捉就可以了,这是利用了多态的原理;
// 服务器开发中通常使用的异常继承体系,这是异常体系中的基类
class Exception{
protected:
	string _errmsg;
	int _id;
	//list<StackInfo> _traceStack;
	// ...
};
//这些是处理不同异常的派生类
class SqlException : public Exception
{};
class CacheException : public Exception
{};
class HttpServerException : public Exception
{};
int main(){
	try{
		// 产生异常后,抛出对象都是派生类对象
		server.Start();
	}
	 // 这里使用基类引用或者指针捕捉就可以
	catch (const Exception& e){
		//这是基类和所有派生类中都有的接口,但是实现却不相同,用来处理不同的异常
		e.what();
	}
	catch (...){
		cout << "Unkown Exception" << endl;
	}
	return 0;
}
异常的优缺点
优点
  1. 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的 bug;
  2. 返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层次的函数返回了错误,那么我们得做很多操作,这样最外层才能准确拿到错误,例如:
int ConnnectSql(){
	//在这个函数中可能会产生两个错误,如果发生错误就会返回相应的错误码
	if (...)
	return 1;
	if (...)
	return 2;
}
int ServerStart() {
	//这里调用了ConnnectSql函数,但是并不在该函数中处理错误,所以我们要将错误码再返回给上一层函数,不过我们需要判断ConnnectSql函数到底返回了什么错误,而这些操作若使用异常的话,那就是多余的,因为异常有catch块可以捕捉异常
	if (int ret = ConnnectSql() < 0)
	return ret;
	int fd = socket()
	if(fd < 0return errno;
}
int main(){
	if(ServerStart()<0)
	...
	return 0;
}
  1. 很多的第三方库都包含异常,比如 boost、gtest、gmock 等等常用的库,那么我们使用它们也需要使用异常;
  2. 很多测试框架都使用异常,这样能更好的使用单元测试来进行白盒的测试;
  3. 部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理、比如T& operator[]()这样的函数,如果 pos 越界了只能使用异常或者终止程序处理,没办法通过返回错误码表示错误;
缺点
  1. 异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛异常就会乱跳。这会导致我们跟踪调试时以及分析程序时,比较困难;
  2. 异常会有一些性能的开销,当然在现代硬件速度很快的情况下,这个影响基本忽略不计;
  3. C++ 没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题,这个需要使用 RAII 来处理资源的管理问题;
  4. C++ 标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,非常的混乱;
  5. 异常尽量规范使用,否则后果不堪设想,随意抛异常,外层捕获的用户苦不堪言,所以异常规范有两点:一、抛出异常类型都继承自一个基类,二、函数是否抛异常、抛什么异常,都使用统一接口的方式规范化;
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值