跳到主要内容C++11 核心特性:可变参数模板、Lambda 与线程库 | 极客日志C++算法
C++11 核心特性:可变参数模板、Lambda 与线程库
综述由AI生成涵盖 C++11 可变参数模板的展开方式(递归与逗号表达式)、Lambda 表达式的语法及捕获列表、Function 包装器与 Bind 适配器的使用,以及线程库的核心组件包括 Thread 类、Mutex 锁、Condition Variable 条件变量和 Atomic 原子操作,通过代码示例展示了多线程同步与并发编程的关键技术点。
嘘25 浏览 九、可变参数模板
下面就是一个基本可变参数的函数模板:
template<class...Args>
void ShowList(Args... args){}
上面的参数 args 前面有省略号,所以它就是一个可变模版参数。我们把带省略号的参数称为'参数包',它里面包含了 0 到 N(N>=0)个模版参数。我们无法直接获取参数包 args 中的每个参数的,只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数。由于语法不支持使用 args[i] 这样方式获取可变参数,所以我们的用一些奇招来一一获取参数包的值。
递归函数方式展开参数包
template<class T>
void ShowList(const T& t) { cout << t << endl; }
template<class T, class...Args>
void ShowList(T value, Args... args) {
cout << value << " ";
ShowList(args...);
}
int main() {
ShowList("string");
ShowList("string", "vector");
ShowList("string", "vector", '6');
return 0;
}

这种展开参数包的方式,不需要通过递归终止函数,是直接在 expand 函数体中展开的。printarg 不是一个递归终止函数,只是一个处理参数包中每一个参数的函数。这种就地展开参数包的方式实现的关键是逗号表达式。
我们知道逗号表达式会按顺序执行逗号前面的表达式。expand 函数中的逗号表达式:(printarg(args), 0),也是按照这个执行顺序,先执行 printarg(args),再得到逗号表达式的结果 0。同时还用到了 C++11 的另外一个特性——初始化列表,通过初始化列表来初始化一个变长数组,{(printarg(args), 0)...}将会展开成 ((printarg(arg1),0),(printarg(arg2),0), (printarg(arg3),0), etc...),最终会创建一个元素值都为 0 的数组 int arr[sizeof...(Args)]。由于是逗号表达式,在创建数组的过程中会先执行逗号表达式前面的部分 printarg(args) 打印出参数,也就是说在构造 int 数组的过程中就将参数包展开了,这个数组的目的纯粹是为了在数组构造的过程展开参数包。
template<class T>
void PrintArg(T t) { cout << t << " "; }
template<class...Args>
void ShowList(Args... args) {
int arr[] = {(PrintArg(args), 0)...};
cout << endl;
}
int main() {
ShowList("string");
ShowList("string", "vector");
ShowList("string", "vector", '6');
return 0;
}
template<class T>
int PrintArg(T&& t) { cout << t << " "; return 0; }
template<class...Args>
void ShowList(Args&&... args) {
int arr[] = {PrintArg(args)...};
cout << endl;
}
int main() {
ShowList("string");
ShowList("string", "vector");
ShowList("string", "vector", '6');
return 0;
}
template<class... Args>
void emplace_back(Args&&... args);
首先我们看到的 emplace 系列的接口,支持模板的可变参数,并且万能引用。那么相对 insert 和 emplace 系列接口的优势到底在哪里呢?
int main() {
list<pair<string, string>> l;
l.emplace_back("love", "爱");
l.emplace_back("want", "想要");
l.emplace_back(make_pair("miss", "想念"));
l.push_back(make_pair("I", "我"));
l.push_back({"You", "你"});
for(auto e : l) cout << e.first << ":" << e.second << endl;
return 0;
}
int main() {
list<pair<string, aj::string>> l;
l.emplace_back("love", "爱");
l.emplace_back(make_pair("miss", "想念"));
cout << endl;
l.push_back(make_pair("I", "我"));
l.push_back({"You", "你"});
cout << endl;
return 0;
}
总结:对于深拷贝的类 emplace 系列接口相比于 insert 系列接口效率略微提高一些但是移动构造的成本也足够低。对于浅拷贝的类 emplace 系列接口相比于 insert 系列接口效率提高的更多一些。
十、lambda 表达式
10.1 C++98 中的一个例子
在 C++98 中,如果想要对一个数据集合中的元素进行排序,可以使用 sort 方法。
#include <algorithm>
#include <functional>
int main() {
int array[] = {9, 2, 6, 4, 7, 1, 5, 3, 0};
sort(array, array + sizeof(array)/sizeof(array[0]));
sort(array, array + sizeof(array)/sizeof(array[0]), greater<int>());
return 0;
}
如果待排序元素为自定义类型,需要用户定义排序时的比较规则:
#include <algorithm>
#include <functional>
struct Goods {
string _name;
double _price;
int _evaluate;
Goods(const char* str, double price, int evaluate):_name(str),_price(price),_evaluate(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());
return 0;
}
随着 C++ 语法的发展,人们开始觉得上面的写法太复杂了,每次为了实现一个 algorithm 算法,都要重新去写一个类,如果每次比较的逻辑不一样,还要去实现多个类,特别是相同类的命名,这些都给编程者带来了极大的不便。因此,在 C++11 语法中出现了 Lambda 表达式。
10.2 lambda 表达式
#include <algorithm>
#include <functional>
struct Goods {
string _name;
double _price;
int _evaluate;
Goods(const char* str, double price, int evaluate):_name(str),_price(price),_evaluate(evaluate){}
};
int main() {
vector<Goods> v = {{"苹果", 2.1, 5}, {"香蕉", 3, 4}, {"橙子", 2.2, 3}, {"菠萝", 1.5, 4}};
sort(v.begin(), v.end(), [](const Goods& gl, const Goods& gr){
return gl._price < gr._price;
});
sort(v.begin(), v.end(), [](const Goods& gl, const Goods& gr){
return gl._price > gr._price;
});
return 0;
}
上述代码就是使用 C++11 中的 lambda 表达式来解决,可以看出 lambda 表达式实际是一个匿名函数。
10.3 lambda 表达式语法
lambda 表达式书写格式:[capture-list] (parameters) mutable -> return-type { statement }
10.3.1 lambda 表达式各部分说明
[capture-list] : 捕捉列表,该列表总是出现在 lambda 函数的开始位置,编译器根据 [] 来判断接下来的代码是否为 lambda 函数,捕捉列表能够捕捉上下文中的变量供 lambda 函数使用。
(parameters):参数列表。与普通函数的参数列表一致,如果不需要参数传递,则可以连同 () 一起省略
mutable:默认情况下,lambda 函数总是一个 const 函数,mutable 可以取消其常量性。使用该修饰符时,参数列表不可省略 (即使参数为空)。
->returntype:返回值类型。用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。
{statement}:函数体。在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。
注意:在 lambda 函数定义中,参数列表和返回值类型都是可选部分,而捕捉列表和函数体可以为空。因此 C++11 中最简单的 lambda 函数为:[]{}; 该 lambda 函数不能做任何事情。
int main() {
[]{};
int x = 2, y = 5;
[=]{ return x + 6; };
cout << "改变之前-->" << "x:" << x << " y:" << y << endl;
auto func1 = [&](int z){ x = y + z; };
func1(100);
cout << "改变之后-->" << "x:" << x << " y:" << y << endl << endl;
cout << "改变之前-->" << "x:" << x << " y:" << y << endl;
auto func2 = [=,&y](int z)->int{ return y += x + z; };
func2(520);
cout << "改变之后-->" << "x:" << x << " y:" << y << endl << endl;
auto add_x = [x](int a) mutable{ x *= 2; return a + x; };
cout << add_x(10) << endl;
return 0;
}
通过上述例子可以看出,lambda 表达式实际上可以理解为无名函数,该函数无法直接调用,如果想要直接调用,可借助 auto 将其赋值给一个变量。
10.3.2 捕获列表说明
捕捉列表描述了上下文中那些数据可以被 lambda 使用,以及使用的方式传值还是传引用。
[var]:表示值传递方式捕捉变量 var
[=]:表示值传递方式捕获所有父作用域中的变量 (包括 this)
[&var]:表示引用传递捕捉变量 var
[&]:表示引用传递捕捉所有父作用域中的变量 (包括 this)
[this]:表示值传递方式捕捉当前的 this 指针
- 父作用域指包含 lambda 函数的语句块.
- 语法上捕捉列表可由多个捕捉项组成,并以逗号分割。
比如:
[=, &a, &b]:以引用传递的方式捕捉变量 a 和 b,值传递方式捕捉其他所有变量
[&, a, this]:值传递方式捕捉变量 a 和 this,引用方式捕捉其他变量
- 在块作用域以外的 lambda 函数捕捉列表必须为空。
- 在块作用域中的 lambda 函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者非局部变量都会导致编译报错。
lambda 表达式之间不能相互赋值,即使看起来类型相同
int main() {
auto func1 =[]{cout << "Chinese" << endl;};
auto func2 =[]{cout << "Chinese" << endl;};
cout << typeid(func1).name() << endl;
cout << typeid(func2).name() << endl;
return 0;
}
捕捉列表不允许变量重复传递,否则就会导致编译错误。
比如:[=, a]:=已经以值传递方式捕捉了所有变量,捕捉 a 重复
void(*PF)();
int main() {
auto f1 =[]{cout << "Chinese" << endl;};
auto f2 =[]{cout << "Chinese" << endl;};
auto f3(f2);
f3();
cout << typeid(f2).name() << endl;
cout << typeid(f3).name() << endl;
PF = f2;
PF();
return 0;
}
10.4 函数对象与 lambda 表达式
函数对象,又称为仿函数,即可以想函数一样使用的对象,就是在类中重载了 operator() 运算符的类对象。
struct TotalPrice {
double operator()(int num, double unit_price) {
return num * unit_price;
}
};
int main() {
auto TP = [](int num, double unit_price){ return num * unit_price; };
TotalPrice()(50, 5.2);
TP(50, 5.2);
return 0;
}
从使用方式上来看,函数对象与 lambda 表达式完全一样。
函数对象将 rate 作为其成员变量,在定义对象时给出初始值即可,lambda 表达式通过捕获列表可以直接将该变量捕获到。
实际在底层编译器对于 lambda 表达式的处理方式,完全就是按照函数对象的方式处理的,即:如果定义了一个 lambda 表达式,编译器会自动生成一个类,在该类中重载了 operator()。
十一、包装器
11.1 function 包装器
C++ 中的 function 本质是一个类模板,也是一个包装器。那么我们来看看,我们为什么需要 function 呢?
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() {
cout << useF(f, 11.11) << endl;
cout << useF(Functor(), 11.11) << endl;
cout << useF([](double d)->double{return d / 4;}, 11.11) << endl;
return 0;
}
通过上面的程序验证,我们会发现 useF 函数模板实例化了三份。
包装器可以很好的解决上面的问题
std::function 在头文件<functional>
template<class T> function;
template<class Ret, class... Args>
class function<Ret(Args...)>;
下面使用包装器包装成员函数时需要加取地址并且突破类域取到该函数,若取的是类中的静态成员则可以不加取地址只突破类域即可取得该函数,但是最好养成都加取地址的习惯。
取类的成员函数时需要多加一个参数,可以是类对象的指针或是类对象,但是使用类对象会更加的方便,因为使用类对象的时候传参可以使用匿名对象,但是使用类对象的指针时就必须定义一个类对象然后再取它的地址作为参数。
class ADD {
public:
double addd(double x, double y) { return x + y; }
};
int main() {
function<double(ADD*, double, double)> func1 = &ADD::addd;
ADD a;
cout << "func1(&a, 5, 10)--><" << func1(&a, 5, 10) << endl;
function<double(ADD, double, double)> func2 = &ADD::addd;
cout << "func2(ADD(), 5, 10)--><" << func2(ADD(), 5, 10) << endl;
return 0;
}

#include <functional>
using namespace std;
int add(int x, int y) { return x + y; }
struct Add {
int operator()(int x, int y) { return x + y; }
};
class ADD {
public:
static int addi(int x, int y) { return x + y; }
double addd(double x, double y) { return x + y; }
};
int main() {
function<int(int, int)> func1 = add;
cout << func1(5, 10) << endl;
function<int(int, int)> func2 = Add();
cout << func2(5, 10) << endl;
function<int(int, int)> func3 = [](int x, int y)->int{return x + y;};
cout << func3(5, 10) << endl;
function<int(int, int)> func4 = &ADD::addi;
cout << func4(5, 10) << endl;
function<double(ADD, double, double)> func5 = &ADD::addd;
cout << func5(ADD(), 5, 10) << endl;
return 0;
}
有了包装器,如何解决模板的效率低下,实例化多份的问题呢?
#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; }
};
int main() {
function<double(double)> func1 = f;
cout << useF(func1, 11.11) << endl;
function<double(double)> func2 = Functor();
cout << useF(func2, 11.11) << endl;
function<double(double)> func3 = [](double d)->double{return d / 4;};
cout << useF(func3, 11.11) << endl;
return 0;
}
11.2 bind
bind 函数定义在头文件中,是一个函数模板,它就像一个函数包装器 (适配器),接受一个可调用对象(callable object),生成一个新的可调用对象来'适应'原对象的参数列表。一般而言,我们用它可以把一个原本接收 N 个参数的函数 fn,通过绑定一些参数,返回一个接收 M 个(M 可以大于 N,但这么做没什么意义)参数的新函数。同时,使用 bind 函数还可以实现参数顺序调整等操作。
template<class Fn, class... Args>
bind(Fn&& fn, Args&&... args);
template<class Ret, class Fn, class... Args>
bind(Fn&& fn, Args&&... args);
可以将 bind 函数看作是一个通用的函数适配器,它接受一个可调用对象,生成一个新的可调用对象来'适应'原对象的参数列表。
调用 bind 的一般形式:auto newCallable = bind(callable,arg_list);
其中,newCallable 本身是一个可调用对象,arg_list 是一个逗号分隔的参数列表,对应给定的 callable 的参数。当我们调用 newCallable 时,newCallable 会调用 callable,并传给它 arg_list 中的参数。
arg_list 中的参数可能包含形如_n 的名字,其中 n 是一个整数,这些参数是'占位符',表示 newCallable 的参数,它们占据了传递给 newCallable 的参数的'位置'。数值 n 表示生成的可调用对象中参数的位置:_1 为 newCallable 的第一个参数,_2 为第二个参数,以此类推。
#include <functional>
using namespace std;
int add(int x, int y) { return x + y; }
int sub(int x, int y) { return x - y; }
struct Add {
int operator()(int x, int y) { return x + y; }
};
class ADD {
public:
static int addi(int x, int y) { return x + y; }
double addd(double x, double y) { return x + y; }
};
int main() {
function<int(int, int)> func1 = bind(add, placeholders::_1, placeholders::_2);
cout << func1(5, 10) << endl;
function<int(int, int)> func2 = bind(sub, placeholders::_1, placeholders::_2);
cout << func2(5, 10) << endl;
function<int(int, int)> func3 = bind(sub, placeholders::_2, placeholders::_1);
cout << func3(5, 10) << endl;
function<int(int, int)> func4 = bind(ADD::addi, placeholders::_2, placeholders::_1);
cout << func4(5, 10) << endl;
function<double(double, double)> func5 = bind(&ADD::addd, ADD(), placeholders::_2, placeholders::_1);
cout << func5(5, 10) << endl;
return 0;
}
十二、线程库
12.1 线程
有关于线程的内容,例如线程、锁、条件变量等内容,在 Linux 中的多线程这篇文章有更详细的讲解,有兴趣的可以去看一下那篇问题。
12.1.1 thread 类的简单介绍
在 C++11 之前,涉及到多线程问题,都是和平台相关的,比如windows 和 linux 下各有自己的接口,这使得代码的可移植性比较差。C++11 中最重要的特性就是对线程进行支持了,使得 C++ 在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含< thread >头文件。
12.1.2 thread 类中常用函数
12.1.2.1 构造函数
这个构造函数创建了一个没有关联的线程对象。也就是说,这个 thread 对象不表示任何正在执行的线程。这种类型的对象通常被称为'joinable'的否定,即它不能被 join 或 detach。
template<class Fn, class... Args>
explicit thread(Fn&& fn, Args&&... args);
这个模板构造函数用于创建一个新的线程,该线程将执行指定的函数 fn,该函数可以接受任意数量的参数 args。这里 Fn 和 Args 是模板参数,分别代表函数和参数的类型。Fn&&和 Args&&…表示函数和参数都被完美转发,允许传递左值或右值。参数 fn 为可执行对象,是线程需要执行的方法,可以为函数指针、仿函数、lambda 表达式和包装器。
使用有参构造函数创建线程时,通常使用 lambda 表达式作为可执行对象,下面这段代码中就使用 lambda 表达式作为参数创建了两个线程,但是下面这段代码中有由于多线程同时对 x 进行++操作,所以存在线程安全问题,下面的锁和原子操作就会讲到如何解决这个问题。
thread(thread&& x) noexcept;
这个构造函数允许将一个 thread 对象 x 的资源(即它所代表的线程)移动到新创建的 thread 对象中。这是通过右值引用 thread&& x 实现的,表示 x 是一个将要被移动的对象。移动后,x 将不再拥有线程(即它变为非 joinable 状态),而新创建的 thread 对象将拥有该线程。
下面演示一下如何让一个线程将资源移动到新线程中,首先创建一个线程 th1,然后使用移动构造将另一个线程 th2,将 th1 使用 move 函数从左值转变为右值作为 th2 的参数,就可以将 th1 的资源移动给 th2 了。
12.1.2.2 移动拷贝
thread& operator=(thread&& rhs) noexcept;
thread 类的移动赋值运算符允许将一个 hread 对象(rhs)的线程所有权转移到另一个 thread 对象中。这个操作是'移动'而不是'拷贝',意味着资源(在这里是线程的执行)从一个对象转移到另一个对象,而不是被复制。
如果是我们创建的线程都是使用无参构造的,而 C++ 线程库中又没有 start 函数能够让线程启动,那么应该如何让这些线程启动呢?
在下面的代码中,我们使用无参构造在 vector 中创建了三个线程,我们可以使用移动赋值将一个右值的线程拷贝给这些线程,就可以让这三个线程启动起来 了,移动赋值的参数需要右值的线程,我们可以创建临时对象作为右值,也可以使用 move 将一个左值线程转换为右值线程。
12.1.2.3 thread::get_id 函数 与 this_thread::get_id 函数
id get_id() const noexcept;
thread 类中的成员函数 get_id 用于获取与该线程对象关联的线程的唯一标识符。这个标识符在线程的整个生命周期内是唯一的,并且即使线程已经终止,标识符仍然有效且唯一。
我们可以通过线程对象获取到线程的 id,但是进入到了线程的内部,这个方法就行不通了,在线程内部可以使用 this_thread::get_id 函数来获取到线程的 id。
12.1.2.4 joinable 函数
bool joinable() const noexcept;
thread 类中的成员函数 joinable 用于检查线程对象是否关联了一个可加入的(joinable)线程。一个线程对象是 joinable 的,意味着它代表了一个正在执行或尚未终止的线程,并且这个线程还没有被 join(等待完成)或 detach(分离,使其独立执行)。
12.1.2.5 join 函数
thread 类中成员函数 join() 用于等待与其关联的线程完成其执行。调用 join 的线程(通常是主线程)会被阻塞,直到被 join 的线程执行完毕。
12.1.2.6 detach 函数
thread 类中成员函数 detach 设计用来将线程从其关联的 thread 对象中分离出来,使其成为一个在后台独立运行的线程。一旦线程被分离,程序就不再拥有对该线程的直接控制权,也无法再与之进行同步(如使用 join)。
12.1.2.7 swap 函数
void swap(thread& x) noexcept;
thread 类中成员函数 swap 用于交换两个 thread 对象的状态。
上面我们使用了移动构造和移动拷贝来转移线程的资源,实际上还可以使用 swap 函数来转移线程对象的状态。
12.2 锁
12.2.1 锁的简单介绍
在 C++ 中,mutex(互斥量)是用于多线程编程中的一种同步机制,用于保护共享数据,防止多个线程同时访问同一资源而导致数据竞争或条件竞争。mutex 提供了一种简单而有效的方式来确保在同一时间只有一个线程可以访问某个特定的代码段或资源。
C++ 中除了互斥锁以外,还有递归锁(recursive_mutex)、时间锁(timed_mutex)和递归时间锁(recursive_timed_mutex),但是最常用的还是互斥锁,大家如果感兴趣的话,可以去了解一下上述的其他锁。
12.2.2 mutex 中常用的接口及应用
12.2.2.1 lock 函数
当你调用 mutex 对象的 lock() 成员函数时,系统首先检查互斥锁是否当前被任何线程持有。如果锁是空闲的,则当前线程会成功获取锁,并继续执行后续代码。如果锁已经被其他线程持有,则当前线程会被阻塞,直到持有锁的线程释放锁为止。一旦锁被释放,系统会尝试再次获取锁,如果成功,则当前线程继续执行。一旦线程成功获取锁,它就'拥有'了这个锁,直到它显式地调用 unlock() 成员函数释放锁为止。
12.2.2.2 try_lock 函数
try_lock 函数用于尝试获取互斥锁,而不会阻塞调用线程去等待锁的释放。如果锁当前未被其他线程持有,则 try_lock 会成功获取锁并返回 true;如果锁已被其他线程持有,则 try_lock 会立即返回 false,表示未能获取锁。
12.2.2.3 unlock 函数
当你调用一个 mutex 对象的 unlock() 成员函数时,如果当前线程持有该 mutex,则 unlock() 会释放这个锁,使得其他被阻塞的、尝试获取该锁的线程可以继续执行。一旦锁被释放,它就不再由当前线程持有。此时,任何其他线程都可以尝试获取这个锁。
注意:只有持有锁的线程才能调用 unlock()。如果未持有锁的线程尝试调用 unlock(),则行为是未定义的(通常是未指定的行为,可能导致程序崩溃或产生不可预测的结果)。
为了避免忘记解锁或异常导致的死锁,通常建议使用 RAII 技术来管理锁的生命周期。这可以通过使用 std::lock_guard 或 std::unique_lock 等类来实现,它们会在对象销毁时自动调用 unlock()。
12.2.2.4 接口的使用
互斥锁 mutex 是不支持拷贝的,所以在下面的代码中,我们使用引用的方式传递 mutex,但是这样会报错,因为创建线程的参数实际上是先传给构造函数的,又因为底层的原因,构造函数中会创建一个对象,实际上引用接收的并不是 mtx,而是 mtx 的拷贝,所以需要使用 ref 将 mtx 包裹起来,这里的 x 也是同样的道理。这里的底层非常的复杂,我也只是了解到了一点皮毛,不建议大家去了解 C++11 的底层,大家只要记住,给线程传递左值引用参数需要用 ref 包裹起来即可。实际上这里还可以通过传递指针的方式解决传递的问题。
下面的代码中,我们使用了 ref 包裹了 mtx 和 x,然而实际上的代码还存在线程安全的问题。两个线程分别对 x 进行 10000 次++操作,但是 x 的值最后却不是 20000,这是因为两个线程可能同时对 x 进行++,最终导致了数据不一致的问题,所以需要对公共区域进行加锁。
在下面的代码中,我们对线程访问的公共区域进行加锁后,无论两个线程进行多少次++操作,最终的结果都会是正确的。
上面的代码中,当给线程传递左值引用时,需要使用 ref 包裹,但是如果我们使用 lambda 表达式就可以完全避免这个问题了,因为 lambda 表达式不需要传参,而是通过捕获的方式获取到变量。
12.2.3 lock_guard 类
lock_guard 是一个模板类,用于管理互斥锁的锁定和解锁操作。它是 C++11 标准库 < mutex > 头文件的一部分,并遵循 RAII 原则,即在构造时获取资源(在这里是锁定互斥锁),在析构时释放资源(在这里是解锁互斥锁)。
当我们给临界区中加锁后,当线程在访问临界区中发生异常,这时线程的锁就得不到释放,会出现死锁的情况,lock_guard 的存在就可以简化互斥锁的使用,并确保锁在不再需要时能够被正确释放,从而避免死锁和资源泄露等问题。
在下面的代码中,我们就让线程在访问共享区域的时候,可能发生异常,多运行几遍发现,线程确实因为异常的缘故出现了死锁的情况。
下面的代码我们使用 lock_guard 来保护公共区域,并且我们增大调用函数的次数,我们发现虽然线程会因为异常被中断,但是并不是出现死锁的情况。
12.2.4 unique_lock 类
unique_lock 是一个功能更为强大的互斥锁管理器,相较于 lock_guard,它提供了更多的灵活性和控制力。unique_lock 同样位于 C++11 标准库的 < mutex > 头文件中,并且也遵循 RAII 原则,即在构造时获取资源(在这里是锁定互斥锁),在析构时释放资源(在这里是解锁互斥锁)。
unique_lock 允许在对象构造时不立即锁定互斥锁,而是可以在稍后的某个时间点进行锁定。unique_lock 对象可以被移动(但不可被复制),这意味着锁的所有权可以在不同的 unique_lock 对象之间转移。unique_lock 常与条件变量一起使用,以实现线程间的同步。除了自动管理锁的生命周期外,unique_lock 还允许程序员显式地锁定和解锁互斥锁。
12.3 条件变量
12.3.1 条件变量的简单介绍
condition_variable 是一个用于多线程同步的重要类,它通常与锁进行配合使用,来实现线程间基于条件的等待与通知机制。
12.3.2 condition_variable 的中常用的接口
12.3.2.1 wait 函数
void wait(unique_lock<mutex>& lck);
condition_variable 中的 wait 函数主要用于让线程等待特定条件的满足,当线程调用 wait 函数时,它会自动释放当前线程已经获取到的与之关联的互斥锁(通常是 mutex 通过 unique_lock 管理的锁)。
大家可以思考一下,为什么这里要用 unique_lock 来管理锁,而不用 lock_guard 来管理锁呢?
因为 lock_guard 只支持析构的时候释放锁,而 unique_lock 支持手动释放锁,当线程调用 wait 时,它需要释放锁让其他线程申请锁资源。
12.3.2.2 notify_one 函数
void notify_one() noexcept;
condition_variable 的 notify_one 函数起着唤醒等待线程的重要作用,当有多个线程通过 condition_variable 的 wait 在等待某个条件满足时,notify_one 函数会选择其中一个线程进行唤醒。
12.4 综合应用(支持两个线程交替打印,一个打印奇数,一个打印偶数)
#include <iostream>
#include <vector>
#include <string>
#include <time.h>
using namespace std;
#include <thread>
#include <mutex>
int main() {
mutex mtx;
int x = 0;
condition_variable cv;
bool flag = false;
thread th1([&](){
for(int i = 0; i < 10; i++){
unique_lock<mutex> lock(mtx);
if(flag) cv.wait(lock);
flag = true;
cout << this_thread::get_id() << " : " << x++ << endl;
cv.notify_one();
}
});
thread th2([&](){
for(int i = 0; i < 10; i++){
unique_lock<mutex> lock(mtx);
if(!flag) cv.wait(lock);
flag = false;
cout << this_thread::get_id() << " : " << x++ << endl;
cv.notify_one();
}
});
th1.join();
th2.join();
return 0;
}
上面的代码保证了 th1 先运行,并且 th1 和 th2 交替运行,下面我将分情况详细的讲解一下。
情况 1:线程 th1 先运行,线程 th2 待定
线程 th1 先运行,则线程 th1 申请锁成功,又 flag 为 false,则不进行 wait,将 flag 变为为 true,进行打印,唤醒在当前条件变量下的等待的线程,若没有则什么都不做,最后出作用域再解锁。
- 线程 th2 没有启动或没有分到时间片
- 线程 th2 启动运行了,但是没有申请到锁资源,阻塞在锁上等待
- 在线程 th2 的第一种情况下,线程 th1 又分到了时间片,再次申请到锁,但是由于此时 flag 为 true,所以线程 th1 会在条件变量下进行等待
- 在线程 th2 的第二种情况下,线程 th1 访问完临界区后,会先唤醒 th2,再释放锁,这时 th2 就可以竞争锁了。
- 在线程 th1 的第一种情况下,线程 th2 总会分到时间片,此时 th2 申请锁成功,flag 为 true,th2 不会进行 wait,进行打印,唤醒在当前条件变量下的等待的线程,若没有则什么都不做,最后出作用域再解锁。
- 在线程 th1 的第二种情况下,假设 th2 申请到锁了,此时 flag 为 true,线程 th2 不会进行 wait,再将 flag 改为 false,进行打印,唤醒在当前条件变量下的等待的线程,若没有则什么都不做,最后出作用域再解锁。
情况 2:线程 th2 先运行,线程 th1 待定
线程 th2 先运行,则先获取到锁,此时 flag 为 false,所以线程 th2 需要在条件变量下进行等待。
在线程 th2 申请锁成功到 th2 还未进行 wait 的区间中,线程 th1 可以分为两种情况。
- 线程 th1 没有启动或是没有分到时间片
- 线程 th1 启动运行了,但是没有申请到锁资源,阻塞在锁上等待
- 在上面的第一种情况下,线程 th1 总会分到时间片,此时 th1 会申请到锁资源,flag 为 false,th1 不会进行 wait,将 flag 变为为 true,进行打印,唤醒在当前条件变量下的等待的线程,若没有则什么都不做,最后出作用域再解锁。
- 在上面的第二种情况下,线程 th2 进行在条件变量下进行等待时,会释放锁资源,th1 就可以竞争锁资源了,假设 th1 申请到了锁资源,flag 为 false,th1 不会进行 wait,将 flag 变为为 true,进行打印,唤醒在当前条件变量下的等待的线程,若没有则什么都不做,最后出作用域再解锁。
由于线程 th2 一直在条件变量下进行等待,需要 th1 对 th2 进行唤醒,唤醒以后,后序就进行交替打印了。
12.5 原子操作
12.5.1 atomic 类 的简单介绍
atomic 类型提供了一种线程安全的操作方式,以避免在多线程环境下使用共享数据时发生数据竞争。atomic 类型和函数定义在头文件 < atomic > 中。atomic 模板类允许你创建原子类型的变量,这些变量可以确保在多线程环境中的读写操作是原子的,即不可被中断的。
atomic 的底层实现依赖于 CAS 操作,这是大佬们关于 CAS 的一些文章,有兴趣的可以去学习一下。
12.5.2 atomic 类 的简单使用
我们之前说过,多个线程同时对共享资源进行操作,可能会导致数据不一致,我们可以在共享区加锁以保护共享区,使同一个共享资源在同一时间内只能有一个线程进行操作。
互斥锁确实能解决多个线程同时对共享资源进行操作导致数据不一致的问题,但是对于这种共享区中只有++、- -、true 改为 false 的这种操作,会占用很多 CPU 资源,所以这种内置类型的简单操作使用 atomic 会更加的高效。
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online