如何做游戏渠道网站,网站运营seo,中山专业制作网站,免费的云电脑✨个人主页#xff1a; 北 海 #x1f389;所属专栏#xff1a; C修行之路 #x1f383;操作环境#xff1a; Visual Studio 2022 版本 17.6.5 文章目录 #x1f307;前言#x1f3d9;️正文1.lambda表达式1.1.仿函数的使用1.2.lambda表达式的语法1.3.lambda表达式的使用… ✨个人主页 北 海 所属专栏 C修行之路 操作环境 Visual Studio 2022 版本 17.6.5 文章目录 前言️正文1.lambda表达式1.1.仿函数的使用1.2.lambda表达式的语法1.3.lambda表达式的使用1.4.lambda表达式的原理1.4.lambda表达式的优点及适用场景 2.线程库2.1.thread 线程类2.1.1.this_thread 命名空间 2.2.mutex 互斥锁类2.2.1.并行与串行的对比2.2.2.其他锁类型2.2.3.RAII 风格的锁 2.3.condition_variable 条件变量类2.3.1.交替打印数字 2.4.atomic 原子操作类 3.包装器3.1.function 包装器3.2.bind 绑定 总结 前言
自从C98以来C11无疑是一个相当成功的版本更新。它引入了许多重要的语言特性和标准库增强为C编程带来了重大的改进和便利。C11的发布标志着C语言的现代化和进步为程序员提供了更多工具和选项来编写高效、可维护和现代的代码 ️正文
1.lambda表达式
lambda 表达式 源于数学中的 λ 演算λ 演算是一种 基于函数的形式化系统它由数学家 阿隆佐邱奇 提出用于研究抽象计算和函数定义。对于编程领域来说可以使用 lambda 表达式 快速构建函数对象作为函数中的参数 1.1.仿函数的使用
仿函数 是 C 中的概念指借助 类operator()重载 创建的函数对象仿函数 的使用场景如下
创建一个 vector通过 sort 函数进行排序至于结果为升序还是降序可以通过 仿函数 控制
#include iostream
#include unordered_map
#include iostream
#include vector
#include algorithmusing 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()
{vectorint 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 algorithmusing namespace std;int main()
{vectorint 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表达式 还可以完美用作 线程回调函数比如接下来使用 C11 中的 thread 线程类创建一个线程并使用 lambda 表达式 创建一个线程回调函数对象
int main()
{// 创建线程并打印线程idthread 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 表达式 编写出来的代码过于复杂时可以考虑转为普通函数确保代码的清晰性和可读性 2.线程库
关于 线程 相关操作Linux 选择使用的是 POSIX 标准而 Windows 没有选择 POSIX 标准反而是自己搞了一套 API 和系统调用称为 Win32 API意味着 Linux 与 Windows 存在标准差异直接导致能在 Linux 中运行的程序未必能在 Windows 中运行
在 C11 之前编写多线程相关代码如果保证兼容性就需要借助 条件编译分别实现两份代码根据不同平台编译不同的代码非常麻烦
// 确保平台兼容性
#ifdef __WIN_32__CreateThread // Windows 中创建线程的接口// ...
#elsepthread_create // Linux 中创建线程的接口// ...
#endif在 C11 中加入了 线程库 这个标准其中包含了 线程、互斥锁、条件变量 等常用线程操作并且无需依赖第三方库也就意味着使用 线程库 编写的代码既能在 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;elsecout 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 threadusing 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; });// 获取线程 idthread::id id t.get_id();// 线程剥离// t.detach();cout 线程 id 已经创建了 endl;// 等待线程退出t.join();return 0;
}注意 分离线程后主线程运行结束整个程序也会随着终止会导致正在运行中的次线程终止
joinable 是非阻塞版的线程等待函数等待成功返回 true否则返回 false swap 则是将两个线程的资源进行交换线程回调函数、线程状态等 注意 swap 并不会交换 thread::id因为这是线程唯一标识符 至于最后两个函数不常用这里就不介绍了 这些都是线程常见操作有了 Linux 多线程编程的基础学习起来会轻松很多接下来编写一个成员创建一批线程并分别打印十次自己的 id
int main()
{vectorthread 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 还不是一件容易的事好在 C11 中还提供了一个 this_thread 命名空间其中提供了获取 线程 id 等函数可以自由调用
2.1.1.this_thread 命名空间
this_thread 是一个命名空间其中包含了 获取线程 id、线程休眠、线程时间片 相关函数 有了 this_thread 命名空间之后就可以轻松获取 线程 id
int main()
{vectorthread vts(5); // 5 个次线程未完全创建for (int i 0; i 5; i){// 移动构造vts[i] thread([]()-void{for (int i 0; i 10; i){// 获取 idauto 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()
{vectorthread vts(5); // 5 个次线程未完全创建for (int i 0; i 5; i){// 移动构造vts[i] thread([]()-void{for (int i 0; i 10; i){// 获取 idauto 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多线程【线程互斥与同步】 确保 线程安全 的手段之一就是 加锁 保护C11 中就有一个 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 互斥锁C11 中还提供了其他几种版本 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 风格
templateclass 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{LockGuardmutex 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 条件变量类
线程安全 不仅需要 互斥锁还需要 条件变量条件变量 主要用来同步各线程间的信息线程同步同时可以避免 死锁问题因为如果线程条件不满足它就会主动将 锁资源 让出让其他线程先运行
C11 提供了一个 condition_variable 条件变量类其中包含了 构造、析构、等待、唤醒 相关接口 条件变量 也是不支持拷贝的在 wait 等待时有两种方式
传统等待传入一个 unique_lock 对象带仿函数的等待传入一个 unique_lock 对象以及一个返回值为 bool 的函数对象可以根据函数对象的返回值判断是否需要等待 为什么要在条件变量 wait 时传入一个 unique_lock 对象 因为条件变量本身不是线程安全的同时在条件变量进入等待状态时需要有释放锁资源的能力否则无法将锁资源让出当条件满足时条件变量要有申请锁资源的能力以确保后续操作的线程安全所以把互斥锁传给条件变量合情合理 注使用条件变量需要包含 condition_variable 头文件
int main()
{mutex mtx;condition_variable cond;// unique_lock 对象unique_lockmutex 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_lockmutex 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_lockmutex 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 原子操作类 之前需要先看看什么是 原子操作
原子操作 是一种 “可靠” 的操作只允许存在 成功 和 失败 两种状态比如对变量的修改要么修改成功要么修改失败不会存在修改一半被切走的状态被别人影响
要想实现 原子操作 就得确保硬件支持 CAScompare 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, old1))
{old i;
}// 进行操作
// ...关于 CAS 的更多详细信息可以看看 陈皓 大佬的这篇文章《无锁队列的实现》 CAS 操作可以自己手搓也可以使用库中提供的比如 C11 中的 atomic 原子操作类其中提供了一系列 原子操作比如 加、减、位运算 借助 atomic 原子操作 类就可以在不使用锁的情况下确保整型变量 g_val 的线程安全
注使用 atomic 原子操作类需要包含 atomic 这个头文件
// 定义为原子变量
atomicint 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()
{// 定义为原子变量atomicint 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 类用于 异步编程和数据共享并不是很常用这里就不作介绍使用细节可以看看这篇文章 《C11中std::future的使用》 3.包装器
包装器 属于 适配器 的一种正如 栈和队列 可以适配各种符合条件的容器实现一样包装器 也可以适配各种类型相符的函数对象有了 包装器 之后对于相似类型的多个函数的调用会变得十分方便 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;
}在 C11 中增加了 function 包装器 这个语法专门用来包装函数对象function 包装器 是基于 可变参数模板 实现的原型如下
template class Ret, class... Args
class functionRet(Args...);其中 Ret 表示函数返回值Args 是上文中提到的可变参数包表示传给函数的参数function 模板类通过 模板特化 指明了包装的函数对象类型
有了 function 包装器 后可以轻松包装之前的三个函数对象
注使用 function 包装器需要包含 functional 头文件
int main()
{// 包装器functionvoid(int) f;f func;f(10);f Func();f(20);f lambda;f(30);return 0;
}包装器 可以结合 哈希表 使用提前准备一批任务根据用户发出的不同指令来调用不同的任务比如下面这个程序完美地在 指令 与 函数 之间建立了映射关系
int main()
{// 包装了返回值为 void参数为 void 的函数类型unordered_mapstring, functionvoid(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;elsehash[comm](); // 调用函数}return 0;
}根据给出的指令调用对应的函数 function 包装器 还可以用在刷题中比如下面这道题目中就可以使用 包装器 在 运算符 与 具体操作 之间建立映射关系使用起来十分方便
150. 逆波兰表达式求值 class Solution
{
public:int evalRPN(vectorstring tokens) {// 解题思路操作数入栈遇到操作符取两个数计算后入栈// 建立映射关系unordered_mapstring, functionint(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; } },};stackint s;for(auto str : tokens){if(str ! str ! - str ! * str ! /)s.push(stoi(str));else{// 注意先获取 y再获取 xint 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()
{// 包装静态函数functionvoid(int) f Test::funcA;//functionvoid(int) f Test::funcA; // 这么写也是可以的f(10);return 0;
}如果包装 非静态成员函数 就有点麻烦了因为 非静态成员函数 需要借助 对象 或者 对象指针 来进行调用 解决方法是构建 function 包装器时指定第一个参数为类并且包装时需要取地址
使用时则需要传入一个 对象此时传入 匿名对象 或者 普通对象 都行
// 包装非静态函数
functionvoid(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 生成的可调用对象类型外还可以使用 包装器 来包装出类型
// 使用包装器包装出类型
functionvoid(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()
{functionvoid(int) RFuncB bind(Test::funcB, Test(10), placeholders::_1);RFuncB(10);return 0;
}除了可以绑定类对象外也可以直接绑定 val 这个参数亦或是两者都绑定
// 绑定对象
functionvoid(Test, int) f1 bind(Test::funcB, placeholders::_1, 10);
f1(Test(), 0);// 两者都绑定
functionvoid(int) f2 bind(Test::funcB, Test(10), 20);
f2(0);注意 虽然参数已经绑定了但实际调用时仍然需要传递对应函数的参数否则无法进行函数匹配调用当然实际传入的参数是绑定的值这里传参只是为了进行匹配并且如果不对类对象进行绑定需要更改包装器中的类型调用时也需要传入参数进行匹配 总结
在这C11系列的收尾文章中我们深入研究了lambda表达式为函数对象提供了快速构建的方法。接着我们学习了标准线程库包括线程、互斥锁、条件变量等为跨平台的多线程编程提供了强大工具。最后通过包装器和绑定工具我们获得了统一函数对象类型的新手段使得代码更灵活、可读性更强为现代C编程提供了丰富的工具和技巧 相关文章推荐 C 进阶知识 C11『右值引用与移动语义』 C11『基础新特性』 C 哈希的应用【布隆过滤器】 C 哈希的应用【位图】 C【哈希表的完善及封装】 C【哈希表的模拟实现】 C【初识哈希】 C【一棵红黑树封装 set 和 map】