目录
一、C++11的简介
二、万能引用与完美转发
1、万能引用:模板中的 && 引用
2、完美转发:保持万能引用左右值属性的解决方案
三、可变参数模板
1、可变参数模板的基本使用
2、push 系列和 emplace 系列的区别
四、lambda表达式(重点)
1、函数对象(又名仿函数)
2、lambda 表达式
2.1 lambda 表达式的书写格式
1、捕捉列表
2、参数列表
3、mutable
4、->返回值类型
5、函数体
6、小总结
3、各种类型的 lambda 表达式代码示例
4、lambda表达式的一些注意事项
5、lambda表达式的底层原理
五、常用包装器:function
1、代码引入
2、function 包装器的作用
3、function 包装器的使用
3.1 模板原型
3.2 使用举例
六、通用函数适配器:bind
1、模板原型
2、使用举例(重点)
七、C++11标准线程库
0、C++标准线程库常用函数表格
1、标准线程库使用的一些注意事项
2、线程函数的参数
3、线程函数的返回值
4、原子性操作库(atomic)
4.1 C++98 线程安全解决方案:对欲修改的共享数据加锁
4.1.1 互斥锁的类型
4.1.2 加锁解锁的函数
4.1.3 死锁问题
4.1.4 lock_guard VS unique_lock(小重点)
4.2 C++11 提供的另一种线程安全解决方案:原子操作
本章将介绍继C++98以来更新最大的一个标准,也是实际开发中用的最多的重要标准,C++11标准。
由于C++11增加了非常多的语法特性,笔者学识有限,也很难一一介绍,在此主要讲解一些比较实用的语法。
一、C++11的简介
公元2003年,C++标准委员会曾经提交了一份技术勘误表(简称TC1),使得 C++03 这个名字取代了 C++98 称为 C++11 之前的最新C++标准名称。=
不过由于 C++03(TC1) 主要是对 C++98 标准中的漏洞进行修复,并没有改动语言的核心部分,因此人们习惯性的把两个标准合并称为 C++98/03标准。
从 C++0x 到 C++11,C++标准十年磨一剑,第二个真正意义上的标准C++11才终于珊珊来迟。
相较于 C++98/03,C++11带来了数量可观的变化——其中包含了约140个新特性,以及对C++03标准中约600个缺陷的修正;这使得C++11更像是从C++98/03中孕育出的一种新语言。
相比较而言, C++11能更好地用于系统开发和库开发、语法更加泛华和简单化、更加稳定和安全,不仅功能更强大,而且能提升程序员的开发效率,公司实际项目开发中也用得比较多,值得重点去学习。
我们在前文中已经提到了两个C++11标准中的重要内容:
《C/C++(四)类和对象》第五小节第3小点:右值引用和移动语义
《C/C++(七)RAII思想与智能指针》全文
因此关于这两篇文章所提到的有关C++11的知识点,本章在此不做赘述,诸位看官若感兴趣,可直接点击跳转至相应文章观看。
cppreference(C++学习手册)中有关C++11标准的详细介绍
二、万能引用与完美转发
1、万能引用:模板中的 && 引用
我们在 C/C++(四)中介绍了右值引用,即 &&引用,作用是给右值取别名;
但是在模板中,&& 就不是代表右值引用了,编译器会把其处理为万能引用——即既能接收左值引用,又能接收右值引用。(又名引用折叠,即传右值为右值引用,传左值 && 会被折叠成 & 即左值引用)
我们给出一段代码:
#include <iostream> using namespace std; void Fun(int& x) { cout << "左值引用" << endl; } void Fun(const int& x) { cout << "const 左值引用" << endl; } void Fun(int&& x) { cout << "右值引用" << endl; } void Fun(const int&& x) { cout << "const 右值引用" << endl; } template<typename T> void PerfectForward(T&& t) { Fun(t); } int main() { PerfectForward(10); // 右值 int a; PerfectForward(a); // 左值 PerfectForward(std::move(a)); // 右值 const int b = 8; PerfectForward(b); // const 左值 PerfectForward(std::move(b)); // const 右值 return 0; }
运行结果为什么会是这样呢?
这是因为,模板的万能引用只是提供了可以同时接受左值引用和右值引用的能力,但是当把 t 直接传递给
Fun
函数时,t
已经是一个具有名字的变量,因此它总是被视为一个左值。如果我们想在传递参数过程中始终保持其左右值属性怎么办呢?这时候就需要完美转发了。
2、完美转发:保持万能引用左右值属性的解决方案
完美转发的关键字是 std::forward<模板类型名>( 参数名 ),作用是可以保持传参时参数的属性,传左值就是左值,传右值就是右值。
我们修改一下上面的代码:
#include <iostream> using namespace std; void Fun(int& x) { cout << "左值引用" << endl; } void Fun(const int& x) { cout << "const 左值引用" << endl; } void Fun(int&& x) { cout << "右值引用" << endl; } void Fun(const int&& x) { cout << "const 右值引用" << endl; } template<typename T> void PerfectForward(T&& t) { Fun(forward<T>(t)); } int main() { PerfectForward(10); // 右值 int a; PerfectForward(a); // 左值 PerfectForward(std::move(a)); // 右值 const int b = 8; PerfectForward(b); // const 左值 PerfectForward(std::move(b)); // const 右值 return 0; }
三、可变参数模板
1、可变参数模板的基本使用
在C++98/03中,类模板和函数模板中,只能包含固定数量的模板参数;
而C++11引入了新特性可变参数模板,让我们可以接受可变参数的函数模板与类模板。
由于可变参数模板使用比较抽象晦涩,在此点到为止,只介绍一些基础的可变参数模板特性便足够使用了。
下面这个就是一个基本可变参数的函数模板:
// Args是一个模板可变参数包,表示模板可以接受任意数量和类型的参数 template <class ...Args> // args是一个函数可变参数包,表示函数可以接受任意数量和类型的参数。 void Test(Args... args) {}
可以看到,模板可变参数是在模板类型名前面加上 ... ,函数可变参数就是在类型名后面加上 ... 。
我们把带省略号的参数称作“参数包”,里面可以包含任意个参数。
2、push 系列和 emplace 系列的区别
C++使用手册中vector容器的emplace_back函数
C++使用手册中 list 容器的emplace_back函数
template <class... Args> void emplace_back (Args&&... args);
我们可以看到,emplace 系列的接口,相较于push 系列,都是支持万能引用与可变参数模板的。
#include <iostream> #include <list> using namespace std; int main() { // 下面我们试一下带有拷贝构造和移动构造的 string 类型 // 我们会发现差别:emplace_back是直接构造了,push_back是先构造,再移动构造 std::list< std::pair<int, string> > mylist; // 多参数时,可以分开一个个传参(因为 emplace_back 的形参是可变参数包,直接把参数包不断往下传,直接构造到节点上) mylist.emplace_back(10, "sort"); mylist.emplace_back(make_pair(20, "sort")); // 隐式类型转换,先接受参数构造,再移动构造 mylist.push_back(make_pair(30, "sort")); mylist.push_back({ 40, "sort" }); for (auto e : mylist) { cout << e.first << ":" << e.second << endl; } return 0; }
所以:
push
系列接口:适用于已经存在的对象,需要先构造对象再复制或移动到容器中。emplace
系列接口:适用于直接在容器中构造对象,避免了不必要的临时对象创建和销毁,提高了性能。(浅拷贝时以及参数较多时相较于 push 系列性能提升巨大,深拷贝时也会高效一些,但提升幅度没有这么大)
四、lambda表达式(重点)
1、函数对象(又名仿函数)
函数对象,又名仿函数,即可以像函数一样使用的对象,实际上就是在类里面重载了 operator() 运算符的类对象。
在C++98中,如果想要为某些自定义元素排序,就需要利用仿函数来定义排序时的比较规则
#include <iostream> #include <algorithm> #include <vector> using namespace std; struct Goods { Goods(const char* str, double price, int evaluate):_name(str), _price(price), _evaluate(evaluate) {} string _name; // 名字 double _price; // 价格 int _evaluate; // 评价 }; struct ComparePriceLess { // 价格倒序排序 bool operator()(const Goods& gl, const Goods& gr) { return gl._price < gr._price; } }; struct ComparePriceGreater { bool operator()(const Goods& gl, const Goods& gr) { // 价格正序排序 return gl._price > gr._price; } }; int main() { vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2, 3 }, { "菠萝", 1.5, 4 } }; sort(v.begin(), v.end(), ComparePriceLess()); sort(v.begin(), v.end(), ComparePriceGreater()); }
我们可以发现,为了实现自定义类型元素的排序,针对每一种排序规则都要去实现一个类,类里还要重载operator(),显得有些不便。
因此,C++11中出现了lambda表达式用来轻量级代替函数对象
2、lambda 表达式
2.1 lambda 表达式的书写格式
[ 捕捉列表 ](参数列表)mutable ->返回值类型{函数体}
1、捕捉列表
捕捉列表总是出现在 lambda 表达式的开头,编译器根据捕捉列表来判断接下来的代码是否为 lambda 函数。(因此必须要写,不能省略)
作用:可以根据列表捕捉项来捕捉 lambda 表达式所在的函数的作用域中的可用变量,使其直接成为 lambda 函数的成员变量。
列表捕捉项:描述了上下文中哪些数据可以被lambda函数所使用,以及传参的方式是传值还是传引用。
有哪些列表捕捉项呢:
[变量名]:表示传值捕捉某变量,并传值传参,传值捕捉来的变量不可被修改。
[&变量名]:以引用的方式捕捉某变量,并传引用传参。(注意不要与取地址混淆了,只有在捕捉列表里是引用传递捕捉变量)
[=]:表示传值捕捉并传参包含 lambda表达式的代码块中的所有变量。(包括 this)
[&]:表示传引用捕捉并传参包含 lambda表达式的代码块中的所有变量。(包括 this)
[this]:表示值传递方式捕捉当前 this 指针。
2、参数列表
lambda 表达式的参数列表与普通函数的参数列表一致,如果不需要传参,可以连同括号一起省略。
3、mutable
在默认情况下, lambda函数中按值捕获的变量不可被修改。
添加 mutable 关键字,可以取消 lambda 函数中按值捕获的变量的常量性,使之可以被修改不想取消可以省略(注意:如果使用了该修饰符,参数列表将不能省略)
4、->返回值类型
采用追踪返回的形式声明函数返回值类型。如果没有返回值 / 返回值类型可以由编译器明确推导出来,可以省略。
5、函数体
与普通函数的函数体一致,必须要写。(该函数体内除了可以使用参数列表中的参数,还可以使用由捕捉列表捕捉到的变量)
6、小总结
在 lambda 表达式的定义里,(参数列表)、mutable、->返回值类型都是可以省略的部分,而捕捉列表与函数体不能省略。(因此,最简单的 lambda 表达式就是:[]{},但是没有任何意义)
3、各种类型的 lambda 表达式代码示例
#include <iostream> using namespace std; int main() { // 1、最简单的lambda表达式 [] {}; // 2、省略参数列表,mutable和返回值类型,返回值类型由编译器推导出来 int a = 3, b = 4; [=] {return a + b; }; // 3、省略返回值类型(无返回值类型)和mutable auto fun1 = [&](int c) {return a + c; }; // 注意,lambda表达式必须要有变量来接收,才能使用 fun1(10); // 使用也是类似于函数 cout << a << " " << b << endl; // 4、各方面都比较完善的lambda表达式 auto fun2 = [=, &b](int c)->int {return b += (a + c); }; fun2(1); cout << b << endl; // 5、传值捕捉x,添加mutable使之能被修改 int x = 10; auto fun3 = [x](int a)mutable->int { a += 2; x *= 2; return x + a; }; }
值得注意的是,lambda表达式实际上可以被理解为无名函数,不能直接调用,想要直接调用,必须通过 auto 赋值给一个变量。(auto 也是C++11更新的关键字,用于自动推导类型)
4、lambda表达式的一些注意事项
1、捕捉列表可由多个捕捉项组成,并以逗号分割。
(eg:[=, &a, &b]:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量
[&,a, this]:值传递方式捕捉变量a和this,引用方式捕捉其他变量)
2、捕捉列表不允许变量重复传递,否则就会导致编译错误。
(eg:[=, a]:=已经以值传递方式捕捉了所有变量,捕捉a重复)
3、在代码块作用域以外的lambda函数捕捉列表必须为空。
4、在块作用域中的lambda函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者
非局部变量都会导致编译报错。
5、lambda表达式之间不能相互赋值,即使看起来类型相同
5、lambda表达式的底层原理
#include <iostream> using namespace std; class Rate { public: Rate(double rate) : _rate(rate) {} double operator()(double money, int year) { return money * _rate * year; } private: double _rate; }; int main() { // 函数对象 double rate = 0.49; Rate r1(rate); r1(10000, 2); // lambda表达式 auto r2 = [=](double monty, int year)->double {return monty * rate * year; }; r2(10000, 2); return 0; }
之前说过,从使用方式来看,lambda表达式的使用方式与函数对象完全一样。
在这个代码里,函数对象将rate作为其成员变量,在定义对象时给出初始值即可,lambda表达式通过捕获列表则可以直接将该变量捕获到。
其实,底层编译器对 lambda 表达式的处理方式就是完全比照函数对象来的,底层每一个lambda 表达式都会生成一个仿函数类。
五、常用包装器:function
fuction 包装器又称适配器;C++中的function本质是一个类模板,是为了解决模板低效问题而出现的,可以统一类型
1、代码引入
#include <iostream> using namespace std; template <class F, class T> T useF(F f, T x) { static int count = 0; cout << "count: " << count++ << endl; cout << "count: " << &count << endl; return f(x); } double f(double i) { return i / 2; } struct Functor { double operator()(double d) { return d / 3; } }; int main() { // useF的模板参数做函数名 cout << useF(f, 10.27) << endl; // useF的模板参数做仿函数 cout << useF(Functor(), 10.27) << endl; // useF的模板参数做lambda表达式 cout << useF([](double d)->double {return d / 4; }, 10.27) << endl; return 0; }
观察这段程序的运行结果我们可以发现,一套模板可以调用的类型非常丰富:函数指针、仿函数、lambda表达式。
但是他们的运行却是相互独立的,每一种类型都会实例化出一份模板,这会造成模板效率下降。
应该如何解决呢?使用 function 包装器统一类型。
2、function 包装器的作用
函数指针、仿函数、lambda表达式这三大可调用对象各有一定的缺陷:
函数指针用起来比较复杂苦涩而且局限性很大;仿函数比较臃肿笨重而且要全局定义;lambda 表达式又无法判断其类型只能依靠 auto 让编译器自己推导。
因此 function 包装器就出现了,其作用就是把可调用对象给包装起来,对类型进行统一,同时解决模板低效问题。(PS:类成员函数也属于可调用对象,也可以用function 将其包装起来)(但是 function 本身并不是可调用对象,需要包装可调用对象才能使用)
3、function 包装器的使用
前言:使用std::function 需要包含头文件 <functional>
3.1 模板原型
template <class T> function; template <class Ret, class ...Args> class function<Ret(Args...)>; /* 模板参数说明: Ret:被调用对象的返回类型 Args:被调用对象的形参,是个可变参数列表,可以包装各式参数 */
3.2 使用举例
#include <iostream> #include <functional> using namespace std; template <class F, class T> // 函数模板 T useF(F f, T x) { static int count = 0; cout << "count: " << ++count << endl; cout << "count: " << &count << endl; return f(x); } // 函数 double f(double i) { return i / 2; } // 仿函数 struct Functor { double operator()(double d) { return d / 3; } }; class Test { public: double minus(double x, double y) { return x - y; } double add(double x, double y) { return x + y; } }; int main() { // 1、function包装函数名(函数指针) function<double(double)> func1 = f; cout << useF(func1,10.27) << endl; // 2、function包装仿函数 function<double(double)> func2 = Functor(); cout << useF(func2, 10.27) << endl; // 3、function包装lambda表达式 function<double(double)> func3 = [](double x)->double {return x; }; cout << useF(func3, 10.27) << endl; // 4、function包装类成员函数 // 注意:类成员函数的包装比较特殊,可变参数包需要多一个this指针,底层通过这个this指针调用函数;包装的可调用函数也需要加类域 和 & function<double(Test, double, double)> func4 = &Test::add; cout << func4(Test(), 20.15, 10.27) << endl; return 0; }
观察运行结果可以发现,函数模板只实例化了一份,实现了类型统一。
六、通用函数适配器:bind
std::bind 同样定义在 <functional> 头文件中,就像一个函数适配器,接收可调用对象(一般与 function 搭配),生成新的可调用对象来“适应”源对象参数列表,多用来调节可调用对象参数的个数和顺序,让可调用对象的参数使用更加灵活。
一般而言,我们可以用 bind 把一个原本接收n个参数的函数,通过绑定一些参数,返回一个可以只接收其中部分参数的新函数。
1、模板原型
// 模板1:不指定返回类型,通用性较强,编译器通过fn的类型自动推断
template <class Fn, class ...Args>
bind(Fn&& fn, Args&&... args);
// 模板2:指定返回类型Ret,使用的时候需要bind<Ret>显式指明返回类型,防止返回类型出错
template <class Ret, class Fn, class ...Args>
bind(Fn&& fn, Args&&... args);
/*
参数类型说明:
fn:万能引用,传可调用对象
args:可变参数包
*/
其中模板1是最常用的,因此我们调用 bind 的一般形式是:
auto NewCallable = bind(callable, arg_list);
其中,NewCallable 本身也需要是一个可调用对象;arg_list 是一个逗号分隔的参数列表,对应callable 中的参数。(注意:arg_liist 里面大概率会出现 _n (n为正整数)的占位符,用来表示参数在 NewCallable 中的位置,_1为newCallable的第一个参数,_2为第二个参数,以此类推)
当我们调用NewCallable时,NewCallable 会调用 callable,并把arg_list 中的参数传给它。
2、使用举例(重点)
#include <iostream>
#include <functional>
using namespace std;
int add(int x, int y)
{
return x + y;
}
class Test
{
public:
int minus(int x, int y)
{
return x - y;
}
};
int main()
{
// func1 绑定函数 add,且参数由调用 func1, func2 的第一、二个参数指定
function<int(int, int)> func1 = bind(add, placeholders::_1, placeholders:: _2);
cout << func1(2014, 2015) << endl;
// func2 也绑定函数add,但是参数明确给出来(func2 的类型由于两个参数全部写死,类型实际上变成了function<int()>)
function<int()> func2 = bind(add, 2014, 2015);
cout << func2() << endl;
// func3 绑定成员函数(绑定成员函数,需要有对应实例来调用,同时需要取地址,确认是哪个类的哪个成员函数,不取编译器无法识别)
Test t;
function<int(int, int)> func3 = bind(&Test::minus, t, placeholders::_1, placeholders::_2);
cout << func3(2015, 2014) << endl;
// func4 改变参数的位置(即func4的第一个参数由传递的第二个参数决定,反之亦然)
function<int(int, int)> func4 = bind(&Test::minus, t, placeholders::_2, placeholders::_1);
cout << func4(2015, 2014) << endl;
// func5 也可以调整传参的个数
// 可以把每次都要固定传的参数给绑死,在调用的时候简化调用。由于写死了一个形参,所以function的类型也跟着改变为function<int(int)>
function<int(int)> func5 = bind(&Test::minus, t, 2013, placeholders::_1);
cout << func5(2012) << endl;
return 0;
}
七、C++11标准线程库
在C++11之前,涉及到多线程问题,都是和平台相关的,比如在Windows和Linux下各有自己的多线程接口,这使得代码的可移植性较差。
因此C++11中最重要的特性就是提供了对线程的支持,使得C++在并行编程时不需要再依赖第三方库了。(同时在原子操作中还引入了原子类的概念)
使用C++11标准线程库,需要包含头文件 <thread>
C++使用手册中关于 thread 线程类的详细介绍
thread() | 无参构造函数,构造一个没有关联任何线程函数的线程对象,不会启动任何线程 |
thread(fn,args1,args2……) | 构造出一个线程对象,该对象关联了可执行对象 fn,args1、args2、……为线程函数的参数 |
get_id() | 获取 thread 类型的线程ID |
joinable() | 判断线程是否还在执行,如果线程对象关联了一个正在执行的线程,返回 true; 如果线程对象没有关联任何线程,或者线程已经结束已经被回收,返回 false |
join() | 会使当前线程阻塞,直到被调用的线程完全执行完毕,子线程结束后还会自动回收子线程的资源;用来保证线程同步 |
detach() |
在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离
的线程变为后台线程,创建的线程的"死活"就与主线程无关
|
1、标准线程库使用的一些注意事项
1、线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程和获取线程状态。
(也因为线程对象的存在,可以把线程以对象的形式往容器里面放)
2、创建一个线程对象后,如果没有提供线程函数,该对象实际没有对应任何线程。
3、如果创建一个线程对象,并且给该线程对象关联线程函数,程序运行后,该线程就会被启动,与主线程一起并发运行。(线程函数一般关联可调用对象:函数指针、lambda表达式、函数对象)
4、thread线程类是防拷贝的,不允许拷贝构造以及赋值,但是支持移动构造和移动赋值。(即将一个线程对象关联的线程转移给其他线程对象,转移期间不影响线程的执行)
5、可以通过 jionable()函数来判断线程是否有效,如果是以下任意情况,则线程无效:
采用无参构造函数构造的线程对象
线程对象的状态已经转移给其他线程对象
线程已经调用jion或者detach结束
2、线程函数的参数
线程函数在默认传参的时候,是以传值方式传参的,想要保留其引用特性,需要使用 std::ref(参数名)函数。(当然传指针没有这种问题)
(只要线程函数参数是左值引用,传参的时候,必须使用 ref() 函数,把参数包装成引用,才能保证左值引用正确传参——底层是先把参数传给 thread 线程类的构造函数,再传给线程函数,由于线程库底层某些复杂的原因,所有的参数传过去都会变成右值。)
#include <iostream>
#include <thread>
using namespace std;
void ThreadFunc1(int& x)
{
x += 10;
}
void ThreadFunc2(int* x)
{
(*x) += 10;
}
void ThreadFunc3(const string& s)
{
cout << s << endl;
}
int main()
{
int x = 2015;
// 通过 ref 函数保持其引用属性,否则会因为最后结果是右值导致报错没有对应函数
thread t1(ThreadFunc1, ref(x));
t1.join();
cout << x << endl;
// 传指针,不存在此类问题
thread t2(ThreadFunc2, &x);
t2.join();
cout << x << endl;
// 传右值就可以,不会报错
string s = "二零一五";
thread t3(ThreadFunc3, s);
t3.join();
return 0;
}
3、线程函数的返回值
我们一般很难拿到线程函数的返回值,如果想得到某种结果,可以在线程函数中多加一个输出型参数用来承载结果。
4、原子性操作库(atomic)
多线程最主要的问题是共享数据带来的问题(即线程安全问题)。如果共享数据都是只读的,那么没问 题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数 据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的问题。
那么应该如何解决线程安全问题呢?
4.1 C++98 线程安全解决方案:对欲修改的共享数据加锁
4.1.1 互斥锁的类型
C++ 中有许多互斥锁类,他们有各自的作用
C++中互斥锁类的类型 mutex 最常用的普通互斥锁 recursive_mutex 递归互斥锁,在递归的时候使用,因为一般的互斥锁在递归时会死锁(底层简单来说就是在锁资源被占用后,不会第一时间阻塞,而是先判断是不是当前线程,如果不是,就不阻塞,可以继续执行) timed_mutex 时间互斥锁,可以设置超时时间(相对/绝对,一般用相对),到了之后锁才可以加/解锁 recursive_timed_mutex 递归时间互斥锁 4.1.2 加锁解锁的函数
C++加锁解锁的函数也有许多
lock() 最基础且常用的阻塞式加锁,如果锁资源被占用,线程在此阻塞 try_lock() 防阻塞式锁,先试一下能不能加锁,判断一下锁资源有没有被占用,如果没有就上锁,不能字节返回,不会向 lock()一样阻塞线程
try_lock_for(std::chrono::duration<Rep, Period> rel_time)
timed_mutex 时间互斥锁专属阻塞式锁;在设置的相对超时时间(只要写一下秒数即可,因此时间互斥锁加锁常用这个而不是绝对超超时时间)里尝试获取锁,如果锁已被占用,则阻塞当前线程,直到超时时间到达或锁可用 try_lock_until(const std::chrono::time_point<Clock, Duration>& abs_time)
:timed_mutex 时间互斥锁专属阻塞式锁;在设置的绝对超时时间(即具体时间)里尝试获取锁,如果锁已被占用,则阻塞当前线程,直到超时时间到达或锁可用 unlock() 解锁 4.1.3 死锁问题
C++中存在抛异常机制,一旦发生抛异常,会跳转到捕获异常处,后面的代码不再执行,这可能会导致锁资源没有被及时释放,造成死锁:
#include <iostream> #include <thread> #include <mutex> #include <cstdlib> // 包含 srand 和 rand #include <ctime> // 包含 time using namespace std; // 实现一个抛异常函数 void ThrowException() { if (rand() % 6 == 0) { throw "异常"; } } int main() { mutex mtx; int num = 100; // 循环取随机数的次数 // 初始化随机数生成器 srand(static_cast<unsigned int>(time(nullptr))); thread t1([num, &mtx]() { try { for (int i = 0; i < num; i++) { mtx.lock(); ThrowException(); mtx.unlock(); } } catch (const char* s) { printf("这是线程1的异常: %s\n", s); } }); thread t2([num, &mtx]() { try { for (int i = 0; i < num; i++) { mtx.lock(); ThrowException(); mtx.unlock(); } } catch (const char* s) { printf("这是线程2的异常: %s\n", s); } }); // 等待线程结束 t1.join(); t2.join(); return 0; }
那么应该如何解决呢?这就要用到我们 C/C++(七)中所提到的重要思想:RAII思想了,把资源交给一个类对象管理,借助其析构函数进行解锁释放空间等操作。
#include <iostream> #include <thread> #include <mutex> #include <cstdlib> // 包含 srand 和 rand #include <ctime> // 包含 time using namespace std; // 实现一个抛异常函数 void ThrowException() { if (rand() % 6 == 0) { throw "异常"; } } // 利用RAII思想实现一个类用于托管资源 template <class Lock> class LockGuard { public: LockGuard(Lock& mtx):_mtx(mtx) { _mtx.lock(); } ~LockGuard() { _mtx.unlock(); } private: Lock& _mtx; //注意是引用类型,并不创建新锁 }; int main() { mutex mtx; int num = 100; // 循环取随机数的次数 // 初始化随机数生成器 srand(static_cast<unsigned int>(time(nullptr))); thread t1([num, &mtx]() { try { for (int i = 0; i < num; i++) { LockGuard<mutex> lg(mtx); ThrowException(); } } catch (const char* s) { printf("这是线程1的异常: %s\n", s); } }); thread t2([num, &mtx]() { try { for (int i = 0; i < num; i++) { LockGuard<mutex> lg(mtx); ThrowException(); } } catch (const char* s) { printf("这是线程2的异常: %s\n", s); } }); // 等待线程结束 t1.join(); t2.join(); return 0; }
PS:这里的RAII资源托管类不需要手搓,C++11提供了两个模板类:lock_guard 和 unique_lock
4.1.4 lock_guard VS unique_lock(小重点)
lock_guard模板类:只支持构造函数和析构函数,拷贝构造被禁止,且使用方式很单一,只能在构造函数和析构函数的时候进行加锁和解锁。
unique_lock模板类:同样不支持拷贝构造函数,但是支持手动加锁和解锁,使用方式更加灵活,提供了更多的成员函数。
某位前辈大佬整理的完整版 lock_guard 与 unique_lock 的区别
4.2 C++11 提供的另一种线程安全解决方案:原子操作
加锁虽然可以解决线程安全问题,但是很多时候用的都是阻塞锁,会影响程序的运行效率;而且还可能会出现死锁问题。
所以 C++11 就提供了原子操作(即不可被中断的一个/一系列操作)和原子类型,(必须包含头文件 <atomic>)
(PS:不过原子操作一般都是用在临界区较小,操作较短的内置类型操作当中,不需要加锁。线程可以直接对原子类型变量进行互斥地访问;对于较长的操作 / 自定义类型的操作就不建议使用原子操作了,因为原子操作通常会使用自旋锁(spin lock),这意味着线程在请求资源失败后,会不断尝试获取资源,而不是阻塞等待。如果操作时间较长,这种自旋锁会导致 CPU 资源浪费)
#include <iostream> #include <thread> #include <atomic> using namespace std; void fun(atomic<long>& num, size_t count) { for (size_t i = 0; i < count; i++) { num++; // 原子操作,不需要加锁也能实现互斥访问 } } int main() { atomic<long> num = 0; cout << "原子操作前,num = " << num << std::endl; thread t1(fun, ref(num), 1000000); thread t2(fun, ref(num), 1000000); t1.join(); t2.join(); cout << "原子操作后, num = " << num << std::endl; return 0; }