[C++——lesson39.——「C++11『lambda表达式 ‖ 线程库 ‖ 包装器』」]


🛸个人主页: dragoooon34
🚁所属专栏: C++
🚀操作环境: Visual Studio 2022

目录
🌞前言
自从C++98以来,C++11无疑是一个相当成功的版本更新。它引入了许多重要的语言特性和标准库增强,为C++编程带来了重大的改进和便利。C++11的发布标志着C++语言的现代化和进步,为程序员提供了更多工具和选项来编写高效、可维护和现代的代码
🌜正文
一、😀lambda表达式
lambda表达式 源于数学中的λ演算,λ演算是一种 基于函数的形式化系统,它由数学家 阿隆佐邱奇 提出,用于研究抽象计算和函数定义。对于编程领域来说,可以使用lambda表达式 快速构建函数对象,作为函数中的参数
1-1 🍕仿函数的使用
仿函数 是
C++中的概念,指借助 类+operator()重载 创建的函数对象,仿函数 的使用场景如下创建一个
vector,通过sort函数进行排序,至于结果为升序还是降序,可以通过 仿函数 控制#include <iostream> #include <unordered_map> #include <iostream> #include <vector> #include <algorithm> using namespace std; struct cmpLess { bool operator()(int n1, int n2) { return n1 < n2; } }; struct cmpGreater { bool operator()(int n1, int n2) { return n1 > n2; } }; int main() { vector<int> arr = { 8,5,6,7,3,1,1,3 }; sort(arr.begin(), arr.end(), cmpLess()); // 升序 cout << "升序: "; for (auto e : arr) cout << e << " "; cout << endl; sort(arr.begin(), arr.end(), cmpGreater()); // 降序 cout << "降序: "; for (auto e : arr) cout << e << " "; cout << endl; return 0; }注:
sort如果不传递函数对象,默认排序结果为升序
结果为正确排序,但这种先创建一个仿函数对象,再调用的传统写法有点麻烦了,如果是直接使用
lambda表达式 创建函数对象,整体逻辑会清楚很多使用
lambda表达式 修改后的代码如下,最大的改变就是 可以直接在传参时直接编写函数对象的代码逻辑#include <iostream> #include <unordered_map> #include <iostream> #include <vector> #include <algorithm> using namespace std; int main() { vector<int> arr = { 8,5,6,7,3,1,1,3 }; sort(arr.begin(), arr.end(), [](int n1, int n2) { return n1 < n2; }); // 升序 cout << "升序: "; for (auto e : arr) cout << e << " "; cout << endl; sort(arr.begin(), arr.end(), [](int n1, int n2) { return n1 > n2; }); // 降序 cout << "降序: "; for (auto e : arr) cout << e << " "; cout << endl; return 0; }最终结果也是正常的
有了
lambda表达式 之后,程序员不必再通过 仿函数 构建函数对象,并且可以在一定程度上提高代码的可阅读性,比如一眼就可以看出回调函数是在干什么接下来看看如何理解
lambda表达式 语法
1-2 🍔lambda表达式的语法
lambda表达式 分为以下几部分:
[ ]捕捉列表( )参数列表mutable关键字->returntype返回值类型{ }函数体
[ ]( ) mutable ->returntype { }
其中,
( )参数列表、mutable、->returntype都可以省略
- 省略
( )参数列表 表示当前是一个无参函数对象- 省略
mutable关键字 表示保持捕捉列表中参数的常量属性- 省略
->returntype返回值类型 表示具体的返回值类型由函数体决定,编译器会自动推导出返回值类型注意:
- 捕捉列表 和 函数体 不可省略
- 如果使用了
mutable关键字 或者->returntype返回值,就不能省略( )参数列表,即使为空- 虽然返回值类型编译器可以推导,但最好还是注明返回值类型
也就是说,最基本的
lambda表达式 只需书写[ ]{ }即可表示,比如这样int main() { // 最简单的 lambda表达式 []{ }; return 0; }此时的
lambda表达式 相当于一个 参数为空、返回值为空、函数体为空 的匿名函数对象void func() { }主要区别在于
lambda表达式 构建出来的是一个 匿名函数对象,而func是一个 有名函数对象,可以直接调用
1-3🍟lambda表达式的使用
lambda表达式 构建出的是一个 匿名函数对象,匿名函数对象也可以调用,不过需要在创建后立即调用,否则就会因为越出作用域而被销毁(匿名对象生命周期只有一行)下面通过
lambda表达式 构建一个简单的 两整数相加 函数对象并调用int main() { int ret = [](int x, int y)->int { return x + y; }(1, 2); cout << ret << endl; return 0; }
直接使用
lambda表达式 构建出的 匿名函数对象 比较抽象,一般都是将此 匿名函数对象 作为参数传递(比如sort),如果需要显式调用,最好是将创建出来的 匿名函数对象 赋给一个 有名函数对象,调用时逻辑会清晰很多使用
auto推导 匿名函数对象 的类型,然后创建add函数对象int main() { auto add = [](int x, int y)->int { return x + y; }; int ret = add(1, 2); cout << ret << endl; return 0; }
lambda表达式 还有很多玩法,接下来逐一介绍,顺便学习其他组成部分利用
lambda表达式 构建一个交换两个元素的 函数对象最经典的写法是 函数参数设为引用类型,传入两个元素,在函数体内完成交换
int main() { int x = 1; int y = 2; cout << "交换前" << endl; cout << "\tx: " << x << endl << "\ty: " << y << endl; auto swap = [](int& rx, int& ry)->void { auto tmp = rx; rx = ry; ry = tmp; }; swap(x, y); cout << "交换后" << endl; cout << "\tx: " << x << endl << "\ty: " << y << endl; return 0; }这种经典写法毋庸置疑,肯定能完成两数交换的任务
除此之外,还可以借助
lambda表达式 中的 捕捉列表 捕获外部变量进行交换int main() { int x = 1; int y = 2; cout << "交换前" << endl; cout << "\tx: " << x << endl << "\ty: " << y << endl; auto swap = [x, y]() ->void { auto tmp = x; x = y; y = tmp; }; swap(); cout << "交换后" << endl; cout << "\tx: " << x << endl << "\ty: " << y << endl; return 0; }因为现在 函数对象 是直接捕获外部变量进行操作,调用函数对象时,无需传参
代码写完,编译器立马给出了报错:
x、y不可修改
这是因为 捕捉列表 中的参数是一个值类型(传值捕捉),此时的捕获的是外部变量的内容,然后赋值到 “
x、y” 中,捕捉列表 中的参数默认具有 常量属性,不能直接修改,但可以添加mutable关键字 取消常性int main() { int x = 1; int y = 2; cout << "交换前" << endl; cout << "\tx: " << x << endl << "\ty: " << y << endl; auto swap = [x, y]()mutable ->void { auto tmp = x; x = y; y = tmp; }; swap(); cout << "交换后" << endl; cout << "\tx: " << x << endl << "\ty: " << y << endl; return 0; }但是程序运行结果不尽人意,外部的
x、y并没有被交换,证明此时 捕捉列表 中的参数x、y是独立的值(类似函数中的值传递)
想让外部的
x、y被真正捕获,需要使用 引用捕捉int main() { int x = 1; int y = 2; cout << "交换前" << endl; cout << "\tx: " << x << endl << "\ty: " << y << endl; // 引用捕捉 auto swap = [&x, &y]() ->void { auto tmp = x; x = y; y = tmp; }; swap(); cout << "交换后" << endl; cout << "\tx: " << x << endl << "\ty: " << y << endl; return 0; }现在
x、y被成功交换了
注意: 捕捉列表中的
&x表示引用捕捉外部的x变量,并非取地址(特例)所以说
mutable关键字不常用,因为它取消的是值类型的常性,即使修改了,对外部也没有什么意义,如果想修改,直接使用 引用捕捉 就好了捕捉列表 支持 混合捕捉,同时使用 引用捕捉 + 传值捕捉
int main() { int x = 1; int y = 2; cout << "调用前" << endl; cout << "\tx: " << x << endl << "\ty: " << y << endl; // 混合捕捉 auto func = [&x, y]()mutable ->void { x = 100; y = 200; }; func(); cout << "调用后" << endl; cout << "\tx: " << x << endl << "\ty: " << y << endl; return 0; }
x被修改了,而y没有
除了 混合捕捉 外,捕捉列表 还支持 全部引用捕捉 和 全部传值捕捉
全部引用捕捉
int main() { int x, y, z, a, b, c; x = y = z = 0; a = b = c = 1; string str = "Hello lambda!"; cout << "&str: " << &str << endl << endl; auto func = [&]()->void { cout << x << " " << y << " " << z << " " << endl; cout << a << " " << b << " " << c << " " << endl; cout << str << endl; cout << "&str: " << &str << endl << endl; }; func(); return 0; }无需指定 捕捉列表 中的参数,
&可以一键 引用捕捉 外部所有变量
注:只能捕捉已经定义或声明的变量
全部传值捕捉
int main() { int x, y, z, a, b, c; x = y = z = 0; a = b = c = 1; string str = "Hello lambda!"; cout << "&str: " << &str << endl << endl; auto func = [=]()->void { cout << x << " " << y << " " << z << " " << endl; cout << a << " " << b << " " << c << " " << endl; cout << str << endl; cout << "&str: " << &str << endl << endl; }; func(); return 0; }全部传值捕捉 也能一键捕捉外部变量,不过此时捕获的是外部变量的值,并非变量本身,无法对其进行修改(可以通过
mutable关键字 取消常性)
注意:
[=]表示全部传值捕捉,[]表示不进行捕捉,两者不等价捕捉列表 的使用非常灵活,比如
[&, x]表示x使用 传值捕捉,其他变量使用 引用捕捉;[=, &str]表示str使用 引用捕捉,其他变量使用 传值捕捉捕捉列表 就像一个 “大师球”,可以直接捕捉到外部的变量,在需要大量使用外部变量的场景中很实用,有效避免了繁琐的参数传递与接收
有没有 全部引用捕捉 + 全部传值捕捉 ?
当然没有,这是相互矛盾的,一个变量不可能同时进行 引用传递 和 值传递,即便传递成功了,编译器在使用时也不知道使用哪一个,存在二义性,所以不被允许
注意: 关于 捕获列表 有以下几点注意事项
- 捕捉列表不允许变量重复传递,否则就会导致编译错误
- 在块作用域以外的
lambda函数捕捉列表必须为空- 在块作用域中的
lambda函数不仅能捕捉父作用域中局部变量,也能捕捉到爷爷作用域中的局部变量
lambda表达式 还可以完美用作 线程回调函数,比如接下来使用C++11中的thread线程类,创建一个线程,并使用lambda表达式 创建一个线程回调函数对象int main() { // 创建线程,并打印线程id thread t([] { cout << "thread running... " << this_thread::get_id() << endl; }); t.join(); return 0; }
总之
lambda表达式 在实际开发中非常好用,关于thread类的相关知识放到后面讲解,接下来先看看lambda表达式 的实现原理
1-4 🌭lambda表达式的原理
lambda表达式 生成的函数对象有多大呢?是像 普通的函数对象指针 一样占
4/8字节,还是像 仿函数 一样占1字节,通过sizeof计算大小就可以一探究竟// 普通函数 int add(int x, int y) { return x + y; } // 仿函数 class addFunc { public: int operator()(int x, int y) { return x + y; } }; int main() { auto typeA = add; addFunc typeB; auto typeC = [](int x, int y)->int { return x + y; }; cout << "普通函数: " << sizeof(typeA) << endl; cout << "仿函数: " << sizeof(typeB) << endl; cout << "lambda表达式: " << sizeof(typeC) << endl; return 0; }结果显示,
lambda表达式 生成的函数对象与 仿函数 生成的函数对象大小是一样的,都是1字节
仿函数 生成的函数对象大小为1字节是因为其生成了一个空类,实际调用时是通过operator()重载实现的,比如上面的addFunc类,空类因为没有成员变量,所以大小只为1字节由此可以推断
lambda表达式 本质上也是生成了一个空类,分别查看使用 仿函数 和lambda表达式 时的汇编代码
可以看到,这两段汇编代码的内容是一模一样的,都是先
call一个函数(operator()重载函数),然后再执行主体逻辑(两数相加),只不过使用 仿函数 需要自己编写一个 空类,而 使用lambda表达式 时由编译器生成一个 空类,为了避免这个自动生成的 空类 引发冲突,会将这个 空类 命名为lambda_uuid
uuid是 通用唯一标识码,可以生成一个重复率极低的辨识信息,避免类名冲突,这也意味着即便是两个功能完全一样的lambda表达式,也无法进行赋值,因为lambda_uuid肯定不一样所以在编译器看来,
lambda表达式 本质上就是一个 仿函数
1-4 🍿lambda表达式的优点及适用场景
lambda表达式 作为一种轻量级的匿名函数表示方式,具备以下优点:
- 简洁性: 对于简单的函数操作,无需再手动创建函数、调用,只需要编写一个
lambda表达式生成函数对象- 方便些:
lambda表达式具有 捕捉列表,可以轻松捕获外部的变量,避免繁琐的参数传递与接收- 函数编程支持:
lambda表达式可以作为函数的参数、返回值或存储在数据结构中- 内联定义:
lambda表达式Lambda表达式可以作为函数的参数、返回值或存储在数据结构中- 简化代码: 对于一些简单的操作,使用
lambda表达式可以减少代码的行数,提高代码的可读性总的来说,
lambda表达式 可以替代一些代码量少的函数,使用起来十分方便,如果lambda表达式 编写出来的代码过于复杂时,可以考虑转为普通函数,确保代码的清晰性和可读性
二、😁线程库
关于 线程 相关操作,
Linux选择使用的是POSIX标准,而Windows没有选择POSIX标准,反而是自己搞了一套API和系统调用,称为Win32 API,意味着Linux与Windows存在标准差异,直接导致能在Linux中运行的程序未必能在Windows中运行在
C++11之前,编写多线程相关代码如果保证兼容性,就需要借助 条件编译,分别实现两份代码,根据不同平台编译不同的代码(非常麻烦)// 确保平台兼容性 #ifdef __WIN_32__ CreateThread // Windows 中创建线程的接口 // ... #else pthread_create // Linux 中创建线程的接口 // ... #endif在
C++11中,加入了 线程库 这个标准,其中包含了 线程、互斥锁、条件变量 等常用线程操作,并且无需依赖第三方库,也就意味着使用 线程库 编写的代码既能在Linux中运行,也能在Windows中运行,保障了代码的可移植性,除此之外,线程库 还新加入了 原子相关操作
2-1 🧂thread线程类
thread线程类的概况如下
首先看看
thread类中的 线程id
Linux中的 线程id表示每个轻量级进程TCB的起始地址,用一个unsigned long int表示,理解起来比较费劲;在thread类中,直接创建了一个id类,也就是这里的thread::id,这个类用于标识 线程,同时在类中重载了一系列operator函数,用于两个thread::id对象的比较
线程创建后,系统会为其分配一个类型为
thread::id的标识符,也就是该线程的唯一标识符获取当前线程的
id,并进行比较int main() { thread::id id1 = std::this_thread::get_id(); thread::id id2 = std::this_thread::get_id(); cout << "id1: " << id1 << " " << "id2: " << id2 << endl; if (id1 == id2) cout << "id 相同" << endl; else cout << "id 不同" << endl; return 0; }
注意:
thread::id是一个类,不支持初始化或赋值,用于获取线程id至于
thread::native_handle_type代表一个底层线程的本地(native)句柄或标识符,本地句柄通常是由操作系统提供的,用于标识和管理线程的底层资源
在绝大多数情况下,使用
C++标准库提供的高级线程抽象是足够的,而无需直接访问线程的本地句柄。直接使用底层线程句柄通常是为了执行与平台相关的线程操作,这可能包括与操作系统相关的调度、优先级、特定的线程控制等。这样的操作通常是为了满足对底层线程管理的特殊需求,而不是一般性的C++线程编程。总结就是
thread::native_handle_type一般用不上,现阶段不必关心接下来看看 构造函数 部分
创建 线程类 对象,支持:
- 创建一个参数为空的默认线程对象
- 通过可变参数模板传入回调函数和参数,其中
Fn表示回调函数对象,Args是传给回调函数的参数包(可以为空)- 移动构造,根据线程对象(右值)来构造线程对象
注意:
thread类不支持 拷贝构造,因为线程对象拥有自己的独立栈等线程资源,所以这里的 拷贝构造 使用delete关键字删除了使用
thread类需要包含thread这个头文件#include <iostream> #include <thread> using namespace std; int main() { // 参数为空的默认线程对象 thread t1; // 传入回调函数及参数 thread t2([](int x, int y)->void { while(true) cout << "x + y = " << x + y << endl; }, 1, 2); // 只传入回调函数 thread t3([]()->void { while(true) cout << "thread running..." << endl; }); //t1.join(); // t1 线程状态为空,不能 join 等待 t2.join(); t3.join(); // 无法拷贝构造 //thread t4(t3); return 0; }线程回调函数不止可以使用
lambda表达式,还可以传入 函数指针 或者 函数对象通过调试可以看到
t2、t3线程正在运行中,而t1因为没有指定回调函数,所以也就没有完全创建,自然也就没有在运行其中
17392、3092、5964分别为 主线程、次线程t2和 次线程t3,而8460和26080是ntdll.dll类型的线程,用于为应用程序加载其他动态库,程序运行大概半分钟后,这两个线程就会自动消失,因为当前处于调试状态,并且程序运行时间较短,所以才会看到这个两个系统级线程
注意: 线程如果没有完全创建,是不能
join等待的,并且线程不支持拷贝操作同样的,
thread只支持 移动赋值,不支持 传值赋值
部分构造函数后跟的
noexcept关键字表示当前函数不会抛出 异常,详细知识放到 『异常』 文章中讲解当线程对象生命周期结束时,会调用 析构函数 销毁对象
thread类还提供了一批线程相关接口,比如 获取id、等待、分离、交换
除了
joinable和swap,其他功能在pthread库中都已经使用过了
get_id对应pthread_selfjoin对应pthread_joindetach对应pthread_detach简单使用如下
int main() { // 创建线程 thread t([]()->void { cout << "thread running..." << endl; }); // 获取线程 id thread::id id = t.get_id(); // 线程剥离 // t.detach(); cout << "线程 " << id << " 已经创建了" << endl; // 等待线程退出 t.join(); return 0; }
注意: 分离线程后,主线程运行结束,整个程序也会随着终止,会导致正在运行中的次线程终止
joinable是非阻塞版的线程等待函数,等待成功返回true,否则返回false
swap则是将两个线程的资源进行交换(线程回调函数、线程状态等)
注意:
swap并不会交换thread::id,因为这是线程唯一标识符至于最后两个函数不常用,这里就不介绍了
这些都是线程常见操作,有了
Linux多线程编程的基础,学习起来会轻松很多,接下来编写一个成员:创建一批线程,并分别打印十次自己的idint main() { vector<thread> vts(5); // 5 个次线程(未完全创建) for (int i = 0; i < 5; i++) { // 移动构造 vts[i] = thread([]()->void { for (int i = 0; i < 10; i++) { // 如何获取 id ? cout << "我是线程 " << " 我正在运行..." << endl; } }); } // 等待线程退出 for (auto& t : vts) t.join(); return 0; }此时面临一个尴尬的问题:如何在回调函数中获取线程
id?
- 线程
id目前之前通过线程对象调用get_id函数获取- 传入线程吗?不行,因为此时线程还没有完全创建,线程
id为0- 传入线程对象?不行,线程还没有完全创建,传入的对象也无法使用,也能通过捕获列表进行引用捕捉,不过同样无法使用
如此一来,想要在 线程回调函数 内获取 线程
id还不是一件容易的事,好在C++11中还提供了一个this_thread命名空间,其中提供了获取 线程id等函数,可以自由调用
2-1-1 🐕this_thread 命名空间
this_thread是一个命名空间,其中包含了 获取线程id、线程休眠、线程时间片 相关函数
有了
this_thread命名空间之后,就可以轻松获取 线程idint main() { vector<thread> vts(5); // 5 个次线程(未完全创建) for (int i = 0; i < 5; i++) { // 移动构造 vts[i] = thread([]()->void { for (int i = 0; i < 10; i++) { // 获取 id auto id = this_thread::get_id(); cout << "我是线程 " << id << " 我正在运行..." << endl; } }); } // 等待线程退出 for (auto& t : vts) t.join(); return 0; }可以看到,正常获取到了每个线程的 线程
id
注:这里打印错乱很正常,因为显示器也是临界资源,多线程并发访问时,也是需要加锁保护的
this_thread只是一个命名空间,是如何做到正确调用get_id函数并获取线程id的?this_thread是std中的一个子命名空间,其中包含了一些与线程有关的操作,比如get_id,当线程调用this_thread::get_id时,实际调用的就是该线程的thread::get_id,所以才能做到谁调用,就获取谁的线程id除此之外,
this_thread命名空间中还提供了 线程休眠 的接口:sleep_until、sleep_for
sleep_util表示休眠一个 绝对时间,比如线程运行后,休眠至明天6::00才接着运行;sleep_for则是让线程休眠一个 相对时间,比如休眠3秒后继续运行,休眠 绝对时间 用的比较少,这里来看看如何休眠 相对时间相对时间 有很多种:时、分、秒、毫秒、微秒…,这些单位包含于
chrono类中
比如分别让上面程序中的线程每隔
200毫秒休眠一次,修改代码如下int main() { vector<thread> vts(5); // 5 个次线程(未完全创建) for (int i = 0; i < 5; i++) { // 移动构造 vts[i] = thread([]()->void { for (int i = 0; i < 10; i++) { // 获取 id auto id = this_thread::get_id(); cout << "我是线程 " << id << " 我正在运行..." << endl; // 休眠 200 毫秒 this_thread::sleep_for(chrono::milliseconds(200)); } }); } // 等待线程退出 for (auto& t : vts) t.join(); return 0; }也可以让线程休眠其他单位时间
最后在
this_thread命名空间中还存在一个特殊的函数:yield这里的
yield表示 让步、放弃,带入多线程环境中就表示 主动让出当前的时间片
yield主要用于 无锁编程(尽量减少使用锁),而无锁编程的实现基于 原子操作CAS,关于原子的详细知识放到后面讲解原子操作
CAS是一个不断重复尝试的过程,如果尝试的时间过久,就会影响整体效率,因为此时是在做无用功,而yield可以主动让出当前线程的时间片,避免大量重复,把CPU资源让出去,从而提高整体效率
2-2 🧂mutex 互斥锁类
多线程编程需要确保 线程安全 问题
首先要明白 线程拥有自己独立的栈结构,但对于全局变量等 临界资源,是直接被多个线程共享的
如果想给线程回调函数传递 左值引用 类型的参数,需要使用
ref引用包装器函数进行包装传递比如通过以下代码证明 线程独立栈 的存在
int g_val = 0; void Func(int n) { cout << "&g_val: " << &g_val << " &n: " << &n << endl << endl; } int main() { int n = 10; thread t1(Func, n); thread t2(Func, n); t1.join(); t2.join(); return 0; }可以看到,全局变量
g_val的地址是一样,而局部变量n的地址相差很远,证明这两个局部变量不处于同一个栈区中,而是分别存在线程的 独立栈
如果多个线程同时对同一个 临界资源 进行操作
- 操作次数较少时,近似原子
- 操作次数多时,有线程安全问题
这里同时对
g_val进行n次++操作当
n = 100时,结果还算正常(正确结果为200)int g_val = 0; void Func(int n) { while (n--) g_val++; } int main() { int n = 100; thread t1(Func, n); thread t2(Func, n); t1.join(); t2.join(); cout << "g_val: " << g_val << endl; return 0; }
但如果将
n改为20000,程序就出问题了(正确结果为40000)n = 20000;并且几乎每一次运行结果都不一样,这就是由于 线程安全 问题带来的 不确定性 导致的
关于线程安全的更多知识详见 [Linux——Lesson24.线程:线程同步与互斥]
确保 线程安全 的手段之一就是 加锁 保护,
C++11中就有一个mutex类,其中包含了 互斥锁 的各种常用操作
比如创建一个
mutex互斥锁 对象,当然 互斥锁也是不支持拷贝的,mutex互斥锁 类也没有提供移动语义相关的构造函数,因为锁资源一般是不允许被剥夺的
互斥锁 对象的构造很简单,使用也很简单,常用的操作有:加锁、尝试加锁、解锁
lock对应pthread_mutex_locktry_lock对应pthread_mutex_trylockunlock对应pthread_mutex_unlock
这些操作使用起来十分简单,对上面的程序进行加锁保护
注:使用
mutex类需要包含mutex这个头文件int g_val = 0; // 互斥锁对象 mutex mtx; void Func(int n) { while (n--) { mtx.lock(); g_val++; mtx.unlock(); } } int main() { int n = 20000; thread t1(Func, n); thread t2(Func, n); t1.join(); t2.join(); cout << "g_val: " << g_val << endl; return 0; }此时无论数据量有多大,最终的结果都是符合预期的
注意: 这里的两个线程只需要一把锁,并且要保证两个线程看到的是同一把锁
2-2-1 🐈⬛并行与串行的对比
互斥锁 的加锁、解锁位置也是有讲究的,比如只把
g_val++这个操作加锁,此时程序就是 并行化 运行,线程A与 线程B都可以进入循环,但两者需要在循环中竞争 锁资源,只有抢到 锁资源 的线程才能进行g_val++,两个线程同时竞争,相当于同时进行操作
也可以把整个
while循环加锁,程序就会变成 串行化,线程A或者 线程B抢到 锁资源 后,就会不断进行g_val++,直到循环结束,才会把 锁资源 让出
理论上来说,并行化 要比 串行化 快,实际结果可以通过代码呈现
int main() { int n = 20000; size_t begin = clock(); thread t1(Func, n); thread t2(Func, n); t1.join(); t2.join(); size_t end = clock(); cout << "g_val: " << g_val << endl; cout << "time: " << end - begin << " ms" << endl; return 0; }首先来看看在
n = 20000的情况下,并行化 耗时注:测试性能需要在
release模式下进行
耗时
4ms,似乎还挺快,接下来看看 串行化 耗时
串行化 只花了
2ms,比 并行化 还要快为什么?
因为现在的程序比较简单,while循环内只需要进行g_val++就行了,并行化中频繁加锁、解锁的开销要远大于串行化单纯的进行while循环如果循环中的操作变得复杂,那么 并行化 是要比 串行化 快的,所以加锁时选择 并行化 还是 串行化,需要结合具体的场景进行判断
这里为了让两个线程看到的是同一把锁,将
mutex对象定义成了一个 全局对象,其实也可以定义为 局部对象,配合lambda表达式 的捕捉列表捕获mutex对象int main() { int n = 20000; int val = 0; mutex mtx; // 局部锁对象 size_t begin = clock(); thread t1([&, n]()mutable->void { mtx.lock(); while (n--) val++; mtx.unlock(); }); thread t2([&, n]()mutable->void { mtx.lock(); while (n--) val++; mtx.unlock(); }); t1.join(); t2.join(); size_t end = clock(); cout << "val: " << val << endl; cout << "time: " << end - begin << " ms" << endl; return 0; }
注意:
n是传值捕捉,如果相对其进行修改,需要使用mutable关键字取消常性
2-2-2 🐆其他锁类型
除了最常用的
mutex互斥锁,C++11中还提供了其他几种版本
recursive_mutex递归互斥锁,这把锁主要用来 递归加锁 的场景中,可以看作mutex互斥锁 的递归升级版,专门用在递归加锁的场景中
比如在下面的代码中,使用普通的
mutex互斥锁 会导致 死锁问题,最终程序异常终止// 普通互斥锁 mutex mtx; void func(int n) { if (n == 0) return; mtx.lock(); n--; func(n); mtx.unlock(); } int main() { int n = 1000; thread t1(func, n); thread t2(func, n); t1.join(); t2.join(); return 0; }
为什么会出现 死锁?
因为当前在进入递归函数前,申请了锁资源,进入递归函数后(还没有释放锁资源),再次申请锁资源,此时就会出现 锁在我手里,但我还申请不到 的现象,也就是 死锁解决这个 死锁 问题的关键在于 自己在持有锁资源的情况下,不必再申请,此时就要用到
recursive_mutex递归互斥锁了// 递归互斥锁 recursive_mutex mtx;使用
recursive_mutex递归互斥锁 后,程序正常运行
timed_mutex时间互斥锁,这把锁中新增了 定时解锁 的功能,可以在程序运行指定时间后,自动解锁(如果还没有解锁的话)
其中的
try_lock_for是按照 相对时间 进行自动解锁,而try_lock_until则是按照 绝对时间 进行自动解锁比如在下面的程序中,使用
timed_mutex时间互斥锁,设置为3秒后自动解锁,线程获取锁资源后,睡眠5秒,即便睡眠时间还没有到,其他线程也可以在3秒后获取锁资源,同样进入睡眠// 时间互斥锁 timed_mutex mtx; void func() { // 3秒后自动解锁 mtx.try_lock_for(chrono::seconds(3)); // 睡眠5秒 for (int i = 1; i <= 5; i++) { this_thread::sleep_for(chrono::seconds(1)); cout << "线程 " << this_thread::get_id() << " 已经睡眠了 " << i << " 秒" << endl; } mtx.unlock(); } int main() { thread t1(func); thread t2(func); t1.join(); t2.join(); return 0; }
至于最后一个
recursive_timed_mutex递归时间互斥锁,就是对timed_mutex时间互斥锁 做了 递归 方面的升级,使其在面对 递归 场景时,不会出现 死锁
2-2-3 🫏RAII 风格的锁
手动加锁、解锁可能会面临 死锁 问题,比如在引入 异常处理 后,如果在 临界区 内出现了异常,程序会直接跳转至
catch中捕获异常,这就导致 锁资源 没有被释放,其他线程申请锁资源时,就会出现 死锁 问题// 死锁 mutex mtx; void func() { for (int i = 0; i < 2; i++) { try { mtx.lock(); if (i % 2 == 0) throw exception("抛出异常"); mtx.unlock(); } catch (const std::exception& msg) { cout << msg.what() << endl; } } } int main() { thread t1(func); thread t2(func); t1.join(); t2.join(); return 0; }
这里引发 死锁问题 的关键在于 线程在出现异常后,直接跳转至
catch代码块中,并且没有释放锁资源解决方法有两个:
- 在
catch代码块中手动释放锁资源(不推荐)- 使用
RAII风格的锁(推荐)
RAII风格就是 资源获取就是初始化 ,也就是利用对象出了作用域会自动调用析构函数这个特性,来 自动释放锁资源编写一个
LockGuard类// RAII 风格 template<class locktype> class LockGuard { public: LockGuard(locktype& mtx) :_mtx(mtx) { // 加锁 _mtx.lock(); } ~LockGuard() { // 解锁 _mtx.unlock(); } private: locktype& _mtx; };注意:
- 需要使用模板,因为互斥锁有多个版本
- 成员变量
_mtx需要使用引用类型,因为所有的锁都不支持拷贝使用引用类型作为类中的成员变量时,需要在 初始化列表 中进行初始化,以下三种类型需要在初始化列表进行初始化:
- 引用类型
const修饰- 没有默认构造函数的类型
修改之前的代码,不再手动加锁、解锁
void func() { for (int i = 0; i < 2; i++) { try { LockGuard<mutex> lock(mtx); if (i % 2 == 0) throw exception("抛出异常"); } catch (const std::exception& msg) { cout << msg.what() << endl; } } }此时再次运行,可以发现程序正常运行,证明锁资源被自动释放了
其实库中已经提供了
RAII风格的类了,分别是lock_guard和unique_lock
其中
lock_guard和我们自己实现的LockGuard几乎一样,功能十分简单(构造时加锁,析构时解锁)
而
unique_lock在此基础上增加了一些功能,比如 加锁、解锁、赋值、交换 等,因为在某些场景中,需要在临界区内对锁资源进行操作,此时就比较适合使用unique_lock
在使用 互斥锁 时,推荐使用lock_guard或者unique_lock进行 自动加锁、解锁,避免 死锁问题
2-3 🥓condition_variable 条件变量类
线程安全 不仅需要 互斥锁,还需要 条件变量,条件变量 主要用来同步各线程间的信息(线程同步),同时可以避免 死锁问题,因为如果线程条件不满足,它就会主动将 锁资源 让出,让其他线程先运行
C++11提供了一个condition_variable条件变量类,其中包含了 构造、析构、等待、唤醒 相关接口
条件变量 也是不支持拷贝的,在
wait等待时,有两种方式:
- 传统等待,传入一个
unique_lock对象- 带仿函数的等待,传入一个
unique_lock对象,以及一个返回值为bool的函数对象,可以根据函数对象的返回值判断是否需要等待为什么要在条件变量
wait时传入一个unique_lock对象❓
因为条件变量本身不是线程安全的,同时在条件变量进入等待状态时,需要有释放锁资源的能力,否则无法将锁资源让出;当条件满足时,条件变量要有申请锁资源的能力,以确保后续操作的线程安全,所以把互斥锁传给条件变量合情合理
注:使用条件变量需要包含
condition_variable头文件int main() { mutex mtx; condition_variable cond; // unique_lock 对象 unique_lock<mutex> lock(mtx); // 传统等待 cond.wait(lock); // 带函数对象的等待 cond.wait(lock, []()->bool { return true; }); return 0; }注意: 函数对象返回
true表示条件为真,不需要等待,返回false表示需要等待至于
wait_for和wait_until就是带时间限制的等待,这里不再细谈
notify_one表示随机唤醒一个正在等待中的线程,notify_all表示唤醒所有正在等待中的线程,如果唤醒时,没有线程在等待,那就什么都不会发生条件变量 的使用看似简单,关键在于如何结合具体场景进行设计
2-3-1 🐎交替打印数字
题目要求
给你两个线程T1、T1,要求T1打印奇数,T2打印偶数,数字范围为[1, 10],两个线程必须交替打印两个线程交替打印,并且打印的是同一个值,所以需要使用 互斥锁 保护,由于题目要求
T1打印奇数,T2打印偶数,可以使用 条件变量 来判断条件是否满足,只有满足才能打印,具体实现代码如下int main() { mutex mtx; condition_variable cond; int n = 10; int x = 1; // 从 1 开始 // 创建线程 thread T1([&, n]()->void { while (x <= n) { unique_lock<mutex> lock(mtx); // 避免非法情况 if (x == n && n % 2 == 0) break; // 不为奇数就等待 while (x % 2 != 1) cond.wait(lock); //// 直接这样写也是可以的 //cond.wait(lock, [&]()->bool { return x % 2 == 1; }); cout << "T1: " << x++ << endl; // 唤醒其他线程 cond.notify_one(); } }); thread T2([&, n]()->void { while (x <= n) { unique_lock<mutex> lock(mtx); // 避免非法情况 if (x == n && n % 2 == 1) break; // 不为偶数,就等待 while (x % 2 != 0) cond.wait(lock); //// 这样写也是可以的 //cond.wait(lock, [&]()->bool {return x % 2 == 0; }); cout << "T2: " << x++ << endl; // 唤醒其他线程 cond.notify_one(); } }); T1.join(); T2.join(); return 0; }
如何确保两个线程交替打印❓
某个线程在打印后,条件必定不满足,只能wait等待,在这之前会唤醒另一个线程进行打印,因为数字范围全是正数,即只有奇数和偶数两种状态,所以两个线程可以相互配合、相互唤醒,从而达到交替打印的效果如何确保打印时不会出现非法情况❓
判断待打印的数字是否符合范围,如果不符合就不进行打印,直接break结束循环,因为这里是RAII风格的锁,所以不必担心死锁问题
2-4 🥚atomic 原子操作类
在学习
atomic原子操作类 之前,需要先看看什么是 原子操作原子操作 是一种 “可靠” 的操作,只允许存在 成功 和 失败 两种状态,比如对变量的修改,要么修改成功,要么修改失败,不会存在修改一半被切走的状态(被别人影响)
要想实现 原子操作 就得确保硬件支持
CAS(compare and swap)硬件同步原语,CAS简单来说就是 操作前先保存旧值,准备进行操作时,取操作数的值与旧值进行比较,如果相同就进行操作,否则就更新旧值,准备重新操作
结合具体的场景理解,假设现在有一个单链表
list,线程A 在进行尾插时,线程B 也进行了尾插,并且插入过程比 线程A 快,此时得益于CAS,线程A 发现需要连接的节点变了,也就不再进行插入,而是更新尾节点信息,重新尾插
也就是说,基于
CAS的 原子操作 需要确保待操作数没有发生改变,如果被其他线程更改了,就不能进行之前的操作,而是需要更新信息后重新操作类似的代码实现如下(基于无锁队列实现的链表)
EnQueue(Q, data) //进队列 { //准备新加入的结点数据 n = new node(); n->value = data; n->next = NULL; do { p = Q->tail; //取链表尾指针的快照 } while( CAS(p->next, NULL, n) != TRUE); //while条件注释:如果没有把结点链在尾指针上,再试 CAS(Q->tail, p, n); //置尾结点 tail = n; }如果只是单纯的进行
i++操作,CAS逻辑可以写成这样int i = 0; int old = i; // 保存旧值 // 如果 CAS 函数在对 old 和 i 进行比较时,发现两者不相等 // 就会返回 `false`,进入循环更新 `old` 旧值,准备下一次 CAS 判断 // 直到两者相等,才会进行操作,确保整个过程是原子的 while (!CAS(&i, old, old+1)) { old = i; } // 进行操作 // ...关于
CAS的更多详细信息可以看看 陈皓 大佬的这篇文章:《无锁队列的实现》
CAS操作可以自己手搓,也可以使用库中提供的,比如C++11中的atomic原子操作类,其中提供了一系列 原子操作,比如 加、减、位运算
借助
atomic原子操作 类,就可以在不使用锁的情况下,确保整型变量g_val的线程安全注:使用
atomic原子操作类需要包含atomic这个头文件// 定义为原子变量 atomic<int> g_val = 0; void Func(int n) { while (n--) g_val++; } int main() { int n = 20000; thread t1(Func, n); thread t2(Func, n); t1.join(); t2.join(); cout << "g_val: " << g_val << endl; return 0; }
除了整型
int之外,atomic还支持定义以下类型为 原子变量
atomic定义的原子变量类型与普通变量类型并不匹配,比如使用printf进行打印时,就无法匹配%d这个格式int main() { // 定义为原子变量 atomic<int> val = 0; printf("%d\n", val); return 0; }
此时可以借助
atomic类中的load函数,加载该原子类型的普通类型值
此时可以正常匹配
// ... printf("%d\n", val.load()); // ...
除了
load之外,还可以使用store获取其中的值
// ... int tmp = 0; val.store(tmp); printf("%d\n", tmp); // ...
线程库中还有一个
future类,用于 异步编程和数据共享,并不是很常用,这里就不作介绍,使用细节可以看看这篇文章 《C++11中std::future的使用》
三、😂包装器
包装器 属于 适配器 的一种,正如 栈和队列 可以适配各种符合条件的容器实现一样,包装器 也可以适配各种类型相符的函数对象,有了 包装器 之后,对于相似类型的多个函数的调用会变得十分方便
3-1 🍳function 包装器
现在我们已经学习了多种可调用的函数对象类型
- 普通函数
- 仿函数
lambda表达式假设这三种函数对象类型的返回值、参数均一致,用于实现不同的功能,如何将它们用同一个类型来表示?
// 普通函数 void func(int n) { cout << "void func(int n): " << n << endl; } // 仿函数 struct Func { public: void operator()(int n) { cout << "void operator()(int n): " << n << endl; } }; // lambda 表达式 auto lambda = [](int n)->void { cout << "[](int n)->void: " << n << endl; };如果
C语言中的指针学的还可以的话,可以试试使用 函数指针 来表示这三个函数对象的类型遗憾的是,无法直接使用 函数指针 指向 仿函数对象,也无法指向 类对象
int main() { void(*pf)(int); // 返回值为 void,参数为 int 的函数指针 pf = func; pf(10); //Func f; //pf = f(); // 无法赋值 pf = lambda; pf(20); return 0; }
在
C++11中,增加了function包装器 这个语法,专门用来包装函数对象,function包装器 是基于 可变参数模板 实现的,原型如下template <class Ret, class... Args> class function<Ret(Args...)>;其中
Ret表示函数返回值,Args是上文中提到的可变参数包,表示传给函数的参数,function模板类通过 模板特化 指明了包装的函数对象类型有了
function包装器 后,可以轻松包装之前的三个函数对象注:使用
function包装器需要包含functional头文件int main() { // 包装器 function<void(int)> f; f = func; f(10); f = Func(); f(20); f = lambda; f(30); return 0; }
包装器 可以结合 哈希表 使用,提前准备一批任务,根据用户发出的不同指令来调用不同的任务,比如下面这个程序,完美地在 指令 与 函数 之间建立了映射关系
int main() { // 包装了返回值为 void,参数为 void 的函数类型 unordered_map<string, function<void(void)>> hash; hash["下载请求"] = []()->void { cout << "正在进行下载任务..." << endl; }; hash["SQL查询"] = []()->void { cout << "正在进行SQL查询..." << endl; }; hash["日志记录"] = []()->void { cout << "正在记录日志信息..." << endl; }; string comm; // 指令 while (cin >> comm) { if (!hash.count(comm)) cout << "该指令不存在,请重新输入" << endl; else hash[comm](); // 调用函数 } return 0; }根据给出的指令,调用对应的函数
function包装器 还可以用在刷题中,比如下面这道题目中,就可以使用 包装器 在 运算符 与 具体操作 之间建立映射关系,使用起来十分方便
class Solution { public: int evalRPN(vector<string>& tokens) { // 解题思路:操作数入栈,遇到操作符,取两个数计算后,入栈 // 建立映射关系 unordered_map<string, function<int(int, int)>> hash = { { "+", [](int x, int y)->int { return x + y; } }, { "-", [](int x, int y)->int { return x - y; } }, { "*", [](int x, int y)->int { return x * y; } }, { "/", [](int x, int y)->int { return x / y; } }, }; stack<int> s; for(auto str : tokens) { if(str != "+" && str != "-" && str != "*" && str != "/") s.push(stoi(str)); else { // 注意:先获取 y,再获取 x int y = s.top(); s.pop(); int x = s.top(); s.pop(); s.push(hash[str](x, y)); } } return s.top(); } };
关于这道题的详细题解可以看看这篇文章 《C++题解 | 逆波兰表达式相关》
function包装器 除了可以包装常规函数对象外,还可用于包装 类内成员函数包装 静态成员函数 很简单,指明归属于哪个类就行了
class Test { public: Test(int n = 0) :_n(n) { } static void funcA(int val) { cout << "static void funcA(int val): " << val << endl; } void funcB(int val) { cout << "void funcB(int val): " << val * _n << endl; } private: int _n = 10; }; int main() { // 包装静态函数 function<void(int)> f = Test::funcA; //function<void(int)> f = &Test::funcA; // 这么写也是可以的 f(10); return 0; }
如果包装 非静态成员函数 就有点麻烦了,因为 非静态成员函数 需要借助 对象 或者 对象指针 来进行调用
解决方法是:构建
function包装器时,指定第一个参数为类,并且包装时需要取地址&使用时则需要传入一个 对象,此时传入 匿名对象 或者 普通对象 都行
// 包装非静态函数 function<void(Test, int)> f = &Test::funcB; // 传入匿名对象 f(Test(10), 10); // 传入普通对象 Test t(10); f(t, 10);
关于包装时的参数设置问题
为什么不能设置为 类的指针,这样能减少对象传递时的开销
因为设置如果设置为指针,后续在进行调用时,就需要传地址,如果是普通对象还好说,可以取到地址,但如果是匿名对象(右值)是无法取地址的,也就无法调用函数了
那能否设置成 类的左值引用 呢?
不行,如果是左值还好,但右值无法被左值引用接收
参数设置为
const指针 或者 右值引用 又会导致 左值 无法正常传递,所以这里最理想的方案就是单纯设置为 普通类类型,既能接受 左值,也能接受 右值将参数写成
&&不是会触发引用折叠机制吗,这样不就既能接收左值,也能接收右值了?
不行,引用折叠(万能引用)是指模板推导类型的行为,普通函数是没有这个概念,如果普通函数既想接收左值,又想接收右值,只能重载出两个参数不同的版本了
3-2 🧇bind 绑定
bind绑定 是一个函数模板,它就像一个函数包装器(适配器),接受一个可调用对象,生成一个新的可调用对象来“适应”原对象的参数列表
bind绑定 可以修改参数传递时的位置以及参数个数,生成一个可调用对象,实际调用时根据 修改 规则进行实际的函数调用,具体原型如下template <class Fn, class... Args> bind (Fn&& fn, Args&&... args);
fn是传递的 函数对象,args是传给函数的 可变参数包,这里使用了 万能引用(引用折叠),使其在进行模板类型推导时,既能引用左值,也能引用右值使用
bind绑定 改变参数传递顺序注:
placeholders是一个命名空间,其中的_1、_2、_N称为占位符,分别表示函数中的第1、第2、第N个参数,直接使用就行了void Func(int a, int b) { cout << "void Func(int a, int b): " << a << " " << b << endl; } int main() { // 正常调用 Func(10, 20); // 绑定生成一个可调用对象 auto RFunc = bind(Func, placeholders::_2, placeholders::_1); RFunc(10, 20); return 0; }经过
bind绑定 后,同样的参数传递,出现了不同的调用结果
bind的底层也是仿函数,生成一个对应的类,根据用户指定的规则,去调用函数,比如这里经过绑定后,实际调用时,RFunc中实际在调用Func传递的参数为20 10除了使用
auto自动推导bind生成的可调用对象类型外,还可以使用 包装器 来包装出类型// 使用包装器包装出类型 function<void(int, int)> RFunc = bind(Func, placeholders::_2, placeholders::_1);
bind绑定 改变参数传递顺序很少使用,只需要简单了解即可注意: 在使用
bind绑定改变参数传递顺序时,参与交换的参数类型,至少需要支持隐式类型转换,否则是无法交换传递的
bind绑定 还可以用来指定参数个数,比如对上面的函数Func进行绑定,将参数1始终绑定为100,后续进行调用时,只需要传递一个参数int main() { // 使用包装器包装出类型 auto RFunc = bind(Func, 100, placeholders::_1); RFunc(20); RFunc(10, 20); return 0; }此时如果坚持传递参数,会优先使用绑定的参数,再从函数参数列表中,从左到右选择参数进行传递,直到参数数量符合,比如这里第二次调用虽然传递了
10和20,但实际调用Func时,RFunc会先传递之前绑定的值100作为参数1传递,而10会作为参数2传递,至于20会被丢弃
注意: 无论绑定的是哪一个参数,占位符始终都是从_1开始,并且连续设置绑定普通参数显得没意思,
bind绑定 参数个数用在 类的成员函数 上才舒服,比如对之前function包装器 包装 类的成员函数 代码进行优化,直接把 类对象 这个参数绑定,调用时就不需要手动传递 对象 了class Test { public: Test(int n = 0) :_n(n) { } static void funcA(int val) { cout << "static void funcA(int val): " << val << endl; } void funcB(int val) { cout << "void funcB(int val): " << val * _n << endl; } void funcC() { } private: int _n = 10; }; int main() { function<void(int)> RFuncB = bind(&Test::funcB, Test(10), placeholders::_1); RFuncB(10); return 0; }
除了可以绑定类对象外,也可以直接绑定
val这个参数,亦或是两者都绑定// 绑定对象 function<void(Test, int)> f1 = bind(&Test::funcB, placeholders::_1, 10); f1(Test(), 0); // 两者都绑定 function<void(int)> f2 = bind(&Test::funcB, Test(10), 20); f2(0);注意: 虽然参数已经绑定了,但实际调用时,仍然需要传递对应函数的参数,否则无法进行函数匹配调用,当然实际传入的参数是绑定的值,这里传参只是为了进行匹配;并且如果不对类对象进行绑定,需要更改包装器中的类型,调用时也需要传入参数进行匹配
🔥总结🔥
在这C++11系列的收尾文章中,我们深入研究了lambda表达式,为函数对象提供了快速构建的方法。接着,我们学习了标准线程库,包括线程、互斥锁、条件变量等,为跨平台的多线程编程提供了强大工具。最后,通过包装器和绑定工具,我们获得了统一函数对象类型的新手段,使得代码更灵活、可读性更强,为现代C++编程提供了丰富的工具和技巧
结束语
以上就是我对于【C++】——「C++11『lambda表达式 ‖ 线程库 ‖ 包装器』」的理解
感谢你的三连支持!!!


更多推荐





























































































所有评论(0)