404
页面不存在
这里什么也没有
diff --git a/404.html b/404.html new file mode 100644 index 00000000..ca339bdb --- /dev/null +++ b/404.html @@ -0,0 +1,40 @@ + + +
+ + + + + + +private:
+ _Thrd_t _Thr;
+
using _Thrd_id_t = unsigned int;
+struct _Thrd_t { // thread identifier for Win32
+ void* _Hnd; // Win32 HANDLE
+ _Thrd_id_t _Id;
+};
+
结构很明确,这个结构体的 _Hnd
成员是指向线程的句柄,_Id
成员就是保有线程的 ID。
在64 位操作系统,因为内存对齐,指针 8 ,无符号 int 4,这个结构体 _Thrd_t
就是占据 16 个字节。也就是说 sizeof(std::thread)
的结果应该为 16。
std::thread
的构造函数默认构造函数,构造不关联线程的新 std::thread 对象。
thread() noexcept : _Thr{} {}
+
移动构造函数,转移线程的所有权,构造 other 关联的执行线程的 std::thread
对象。此调用后 other 不再表示执行线程失去了线程的所有权。
thread(thread&& _Other) noexcept : _Thr(_STD exchange(_Other._Thr, {})) {}
+
复制构造函数被定义为弃置的,std::thread 不可复制。两个 std::thread 不可表示一个线程,std::thread 对线程资源是独占所有权。
thread(const thread&) = delete;
+
构造新的 std::thread
对象并将它与执行线程关联。表示新的执行线程开始执行。
template <class _Fn, class... _Args, enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0>
+ _NODISCARD_CTOR_THREAD explicit thread(_Fn&& _Fx, _Args&&... _Ax) {
+ _Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
+ }
+
template <class _Fn, class... _Args>
+void _Start(_Fn&& _Fx, _Args&&... _Ax) {
+ using _Tuple = tuple<decay_t<_Fn>, decay_t<_Args>...>;
+ auto _Decay_copied = _STD make_unique<_Tuple>(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
+ constexpr auto _Invoker_proc = _Get_invoke<_Tuple>(make_index_sequence<1 + sizeof...(_Args)>{});
+
+ _Thr._Hnd =
+ reinterpret_cast<void*>(_CSTD _beginthreadex(nullptr, 0, _Invoker_proc, _Decay_copied.get(), 0, &_Thr._Id));
+
+ if (_Thr._Hnd) { // ownership transferred to the thread
+ (void) _Decay_copied.release();
+ } else { // failed to start thread
+ _Thr._Id = 0;
+ _Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);
+ }
+}
+
template <class _Tuple, size_t... _Indices>
+ _NODISCARD static constexpr auto _Get_invoke(index_sequence<_Indices...>) noexcept {
+ return &_Invoke<_Tuple, _Indices...>;
+ }
+
+ template <class _Tuple, size_t... _Indices>
+ static unsigned int __stdcall _Invoke(void* _RawVals) noexcept /* terminates */ {
+ // adapt invoke of user's callable object to _beginthreadex's thread procedure
+ const unique_ptr<_Tuple> _FnVals(static_cast<_Tuple*>(_RawVals));
+ _Tuple& _Tup = *_FnVals.get(); // avoid ADL, handle incomplete types
+ _STD invoke(_STD move(_STD get<_Indices>(_Tup))...);
+ _Cnd_do_broadcast_at_thread_exit(); // TRANSITION, ABI
+ return 0;
+ }
+
_Get_invoke 函数很简单,就是接受一个元组类型,和形参包的索引,传递给 _Invoke 静态成员函数模板,实例化,获取它的函数指针。
`,2),bn={href:"https://en.cppreference.com/w/cpp/utility/integer_sequence",target:"_blank",rel:"noopener noreferrer"},fn=n("code",null,"index_sequence",-1),Tn=n("code",null,"make_index_sequence",-1),yn={href:"https://godbolt.org/z/dv88aPGac",target:"_blank",rel:"noopener noreferrer"},wn=n("p",null,[n("strong",null,"_Invoke 是重中之重,它是线程实际执行的函数"),s(",如你所见它的形参类型是 "),n("code",null,"void*"),s(" ,这是必须的,要符合 "),n("code",null,"_beginthreadex"),s(" 执行函数的类型要求。虽然是 "),n("code",null,"void*"),s(",但是我可以将它转换为 "),n("code",null,"_Tuple*"),s(" 类型,构造一个独占智能指针,然后调用 get() 成员函数获取底层指针,解引用指针,得到元组的引用初始化"),n("code",null,"_Tup"),s(" 。")],-1),xn={href:"https://zh.cppreference.com/w/cpp/utility/functional/invoke",target:"_blank",rel:"noopener noreferrer"},An=n("code",null,"std::invoke",-1),En=n("code",null,"std::move",-1),Sn=n("code",null,"_STD get<_Indices>(_Tup))...",-1),Cn=n("code",null,"std::get<>",-1),In=n("code",null,"_Indices",-1),Dn=n("p",null,[n("code",null,"_Thr._Hnd = reinterpret_castif (_Thr._Hnd) {
_Thr._Hnd
不为空,则表示线程已成功启动,将独占指针的所有权转移给线程。(void) _Decay_copied.release()
} else { // failed to start thread
_Thr._Id = 0;
_Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);
class thread + { + public: +#ifdef _GLIBCXX_HAS_GTHREADS + using native_handle_type = __gthread_t; +#else + using native_handle_type = int; +#endif +
__gthread_t
即void*
。
我们这里的源码解析涉及到的 C++ 技术很多,我们也没办法每一个都单独讲,那会显得文章很冗长,而且也不是重点。
`,2),jn=n("strong",null,"不会模板,你阅读标准库源码,是无稽之谈",-1),Kn={href:"https://github.com/Mq-b/Modern-Cpp-templates-tutorial",target:"_blank",rel:"noopener noreferrer"},Qn=n("strong",null,"现代C++模板教程",-1);function Zn($n,ns){const a=p("ExternalLinkIcon");return c(),l("div",null,[i,u,n("p",null,[s("我们以 "),d,s(" 实现的 "),n("a",_,[k,e(a)]),s(" 代码进行讲解,MSVC STL 很早之前就不支持 C++11 了,它的实现完全基于 "),h,s(",出于某些原因 "),m,s(" 的一些库(如 "),n("a",v,[g,e(a)]),s(", _v 变量模板)被向后移植到了 "),b,s(" 模式,所以即使是 C++11 标准库设施,实现中可能也是使用到了 C++14、17 的东西。")]),f,T,n("p",null,[y,s(" 只保有一个私有数据成员 "),n("a",w,[x,e(a)]),s(":")]),A,n("p",null,[n("a",E,[S,e(a)]),s(" 是一个结构体,它保有两个数据成员:")]),C,n("p",null,[I,s(" 有四个"),n("a",D,[s("构造函数"),e(a)]),s(",分别是:")]),n("ol",null,[n("li",null,[L,n("p",null,[n("a",B,[s("值初始化"),e(a)]),s("了数据成员 _Thr ,这里的效果相当于给其成员 "),z,s(" 和 "),F,s(" 都进行"),n("a",q,[s("零初始化"),e(a)]),s("。")])]),n("li",null,[R,n("p",null,[n("a",N,[s("_STD"),e(a)]),s(" 是一个宏,展开就是 "),H,s(",也就是 "),n("a",V,[G,e(a)]),s(",将 "),O,s(" 赋为 "),M,s(" (也就是置空),返回 "),X,s(" 的旧值用以初始化当前对象的数据成员 "),U,s("。")])]),P]),J,W,n("p",null,[s("如你所见,这个构造函数本身并没有做什么,它只是一个可变参数成员函数模板,增加了一些 "),n("a",Y,[s("SFINAE"),e(a)]),s(" 进行约束我们传入的"),n("a",j,[s("可调用"),e(a)]),s("对象的类型不能是 "),K,s("。函数体中调用了一个函数 "),n("a",Q,[Z,e(a)]),s(",将我们构造函数的参数全部完美转发,去调用它,这个函数才是我们的重点,如下:")]),$,n("ol",null,[nn,n("li",null,[sn,n("ul",null,[n("li",null,[s("定义了一个"),n("a",an,[s("元组"),e(a)]),s("类型 "),en,s(" ,它包含了可调用对象和参数的类型,这里使用了 "),n("a",tn,[on,e(a)]),s(" 来去除了类型的引用和 cv 限定。")])])]),n("li",null,[pn,n("ul",null,[n("li",null,[s("使用 "),n("a",cn,[ln,e(a)]),s(" 创建了一个独占指针,指向的是 "),rn,s(" 类型的对象,"),un,s("。")])])]),n("li",null,[dn,n("ul",null,[n("li",null,[s("调用 "),n("a",_n,[kn,e(a)]),s(" 函数,传入 "),hn,s(" 类型和一个参数序列的索引序列(为了遍历形参包)。这个函数用于获取一个函数指针,指向了一个静态成员函数 "),n("a",mn,[vn,e(a)]),s(",用来实际执行线程。这两个函数都非常的简单,我们来看看:")])]),gn,n("blockquote",null,[n("p",null,[s("它的形参类型我们不再过多介绍,你只需要知道 "),n("a",bn,[fn,e(a)]),s(" 这个东西可以用来接收一个由 "),Tn,s(" 创建的索引形参包,帮助我们进行遍历元组即可。"),n("a",yn,[s("示例代码"),e(a)]),s("。")])]),wn,n("p",null,[s("此时,我们就可以进行调用了,使用 "),n("a",xn,[An,e(a)]),s(" + "),En,s("(默认移动) ,这里有一个形参包展开,"),Sn,s(",_Tup 就是 std::tuple 的引用,我们使用 "),Cn,s(" 获取元组存储的数据,需要传入一个索引,这里就用到了 "),In,s("。展开之后,就等于 invoke 就接受了我们构造 std::thread 传入的可调用对象,调用可调用对象的参数,invoke 就可以执行了。")])]),n("li",null,[Dn,n("ul",null,[n("li",null,[s("调用 "),n("a",Ln,[Bn,e(a)]),s(" 函数来启动一个线程,并将线程句柄存储到 "),zn,s(" 中。传递给线程的参数为 "),Fn,s("(一个静态函数指针,就是我们前面讲的 "),qn,s(")和 "),Rn,s("(存储了函数对象和参数的副本的指针)。")])])]),Nn]),Hn,n("p",null,[s("需要注意,libstdc++ 和 libc++ 可能不同,就比如它们 64 位环境下 "),Vn,s(" 的结果就可能是 "),Gn,s("。libstdc++ 的实现只"),n("a",On,[s("保有一个 "),Mn,e(a)]),s("。"),n("a",Xn,[s("参见"),e(a)]),s("。不过实测 gcc 不管是 "),Un,s(" 还是 "),Pn,s(" 线程模型,线程对象的大小都是 8,宏 "),Jn,s(" 的值都为 1("),n("a",Wn,[s("GThread"),e(a)]),s(")。")]),Yn,n("p",null,[s("相信你也感受到了,"),jn,s(",市面上很多教程教学,教导一些实现容器,过度简化了,真要去出错了去看标准库的代码,那是不现实的。不需要模板的水平有多高,也不需要会什么元编程,但是基本的需求得能做到,得会,这里推荐一下:"),n("a",Kn,[Qn,e(a)]),s("。")])])}const es=o(r,[["render",Zn],["__file","01thread的构造与源码解析.html.vue"]]),ts=JSON.parse('{"path":"/md/%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/01thread%E7%9A%84%E6%9E%84%E9%80%A0%E4%B8%8E%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.html","title":"std::thread 的构造-源码解析","lang":"zh-CN","frontmatter":{},"headers":[{"level":2,"title":"std::thread 的数据成员","slug":"std-thread-的数据成员","link":"#std-thread-的数据成员","children":[]},{"level":2,"title":"std::thread 的构造函数","slug":"std-thread-的构造函数","link":"#std-thread-的构造函数","children":[]},{"level":2,"title":"总结","slug":"总结","link":"#总结","children":[]}],"git":{"createdTime":1710214174000,"updatedTime":1711681670000,"contributors":[{"name":"归故里","email":"3326284481@qq.com","commits":8},{"name":"A. Jiang","email":"de34@live.cn","commits":1}]},"readingTime":{"minutes":7.05,"words":2114},"filePathRelative":"md/详细分析/01thread的构造与源码解析.md","localizedDate":"2024年3月12日","excerpt":"\\n我们这单章是为了专门解释一下 C++11 引入的 std::thread
是如何构造的,是如何创建线程传递参数的,让你彻底了解这个类。
我们以 MSVC 实现的 std::thread
代码进行讲解,MSVC STL 很早之前就不支持 C++11 了,它的实现完全基于 C++14,出于某些原因 C++17 的一些库(如 invoke
, _v 变量模板)被向后移植到了 C++14 模式,所以即使是 C++11 标准库设施,实现中可能也是使用到了 C++14、17 的东西。
在我们谈起“并发编程”,其实可以直接简单理解为“多线程编程”,我知道你或许有疑问:“那多进程呢?” C++ 语言层面没有进程的概念,并发支持库也不涉及多进程,所以在本教程中,不用在意。
我们完全使用标准 C++ 进行教学。
并发,指两个或两个以上的独立活动同时发生。
并发在生活中随处可见,我们可以一边走路一边说话,也可以两只手同时做不同的动作,又或者一边看电视一边吃零食。
计算机中的并发有两种方式:
多核机器的真正并行。
单核机器的任务切换。
在早期,一些单核机器,它要想并发,执行多个任务,那就只能是任务切换,任务切换会给你一种“好像这些任务都在同时执行”的假象。只有硬件上是多核的,才能进行真正的并行,也就是真正的”同时执行任务“。
在现在,我们日常使用的机器,基本上是二者都有。我们现在的 CPU 基本都是多核,而操作系统调度基本也一样有任务切换,因为要执行的任务非常之多,CPU 是很快的,但是核心却没有那么多,不可能每一个任务都单独给一个核心。大家可以打开自己电脑的任务管理器看一眼,进程至少上百个,线程更是上千。这基本不可能每一个任务分配一个核心,都并行,而且也没必要。正是任务切换使得这些后台任务可以运行,这样系统使用者就可以同时运行文字处理器、编译器、编辑器和 Web 浏览器。
事实上,对于这两个术语,并没有非常公认的说法。
有些人认为二者毫无关系,指代的东西完全不同。
有些人认为二者大多数时候是相同的,只是用于描述一些东西的时候关注点不同。
我喜欢第二种,那我们就讲第二种。
对多线程来说,这两个概念大部分是重叠的。对于很多人来说,它们没有什么区别。 这两个词是用来描述硬件同时执行多个任务的方式:
“并行”更加注重性能。使用硬件提高数据处理速度时,会讨论程序的并行性。
当关注重点在于任务分离或任务响应时,会讨论程序的并发性。
这两个术语存在的目的,就是为了区别多线程中不同的关注点。
概念从来不是我们的重点,尤其是某些说法准确性也一般,假设开发者对操作系统等知识有基本了解。
我们也不打算特别介绍什么 C++ 并发库的历史发展、什么时候你该使用多线程、什么时候不该使用多线程... 类似问题应该是看你自己的,而我们回到代码上即可。
',22),s=[t];function i(r,h){return n(),a("div",null,s)}const d=e(p,[["render",i],["__file","01基本概念.html.vue"]]),m=JSON.parse('{"path":"/md/01%E5%9F%BA%E6%9C%AC%E6%A6%82%E5%BF%B5.html","title":"基本概念","lang":"zh-CN","frontmatter":{},"headers":[{"level":2,"title":"前言","slug":"前言","link":"#前言","children":[]},{"level":2,"title":"并发","slug":"并发","link":"#并发","children":[]},{"level":2,"title":"在计算机中的并发","slug":"在计算机中的并发","link":"#在计算机中的并发","children":[]},{"level":2,"title":"并发与并行","slug":"并发与并行","link":"#并发与并行","children":[]},{"level":2,"title":"总结","slug":"总结","link":"#总结","children":[]}],"git":{"createdTime":1709618654000,"updatedTime":1709886872000,"contributors":[{"name":"归故里","email":"3326284481@qq.com","commits":4}]},"readingTime":{"minutes":2.79,"words":838},"filePathRelative":"md/01基本概念.md","localizedDate":"2024年3月5日","excerpt":"\\n在我们谈起“并发编程”,其实可以直接简单理解为“多线程编程”,我知道你或许有疑问:“那多进程呢?” C++ 语言层面没有进程的概念,并发支持库也不涉及多进程,所以在本教程中,不用在意。
\\n我们完全使用标准 C++ 进行教学。
\\n并发,指两个或两个以上的独立活动同时发生。
\\n并发在生活中随处可见,我们可以一边走路一边说话,也可以两只手同时做不同的动作,又或者一边看电视一边吃零食。
\\n计算机中的并发有两种方式:
"}');export{d as comp,m as data}; diff --git "a/assets/02scoped_lock\346\272\220\347\240\201\350\247\243\346\236\220.html-14rPQ6Fa.js" "b/assets/02scoped_lock\346\272\220\347\240\201\350\247\243\346\236\220.html-14rPQ6Fa.js" new file mode 100644 index 00000000..1c557f6e --- /dev/null +++ "b/assets/02scoped_lock\346\272\220\347\240\201\350\247\243\346\236\220.html-14rPQ6Fa.js" @@ -0,0 +1,88 @@ +import{_ as c}from"./plugin-vue_export-helper-DlAUqK2U.js";import{r as o,o as l,c as u,a as n,b as s,d as a,w as i,e as p}from"./app-Oub5ASTw.js";const k={},d=n("h1",{id:"std-scoped-lock-的源码实现与解析",tabindex:"-1"},[n("a",{class:"header-anchor",href:"#std-scoped-lock-的源码实现与解析"},[n("span",null,[n("code",null,"std::scoped_lock"),s(" 的源码实现与解析")])])],-1),r=n("p",null,[s("本单章专门介绍标准库在 C++17 引入的类模板 "),n("code",null,"std::scoped_lock"),s(" 的实现,让你对它再无疑问。")],-1),m=n("code",null,"std::thread",-1),_=n("strong",null,"不会模板,你阅读标准库源码,是无稽之谈",-1),v={href:"https://mq-b.github.io/Modern-Cpp-templates-tutorial/",target:"_blank",rel:"noopener noreferrer"},b={href:"https://github.com/microsoft/STL/blob/8e2d724cc1072b4052b14d8c5f81a830b8f1d8cb/stl/inc/mutex#L476-L528",target:"_blank",rel:"noopener noreferrer"},h=n("code",null,"std::scoped_lock",-1),g={href:"https://github.com/gcc-mirror/gcc/blob/7a01cc711f33530436712a5bfd18f8457a68ea1f/libstdc%2B%2B-v3/include/std/mutex#L743-L802",target:"_blank",rel:"noopener noreferrer"},f=n("code",null,"libstdc++",-1),x={href:"https://github.com/llvm/llvm-project/blob/7ac7d418ac2b16fd44789dcf48e2b5d73de3e715/libcxx/include/mutex#L424-L488",target:"_blank",rel:"noopener noreferrer"},y=n("code",null,"libc++",-1),w=p(`std::scoped_lock
的数据成员std::scoped_lock
是一个类模板,它有两个特化,也就是有三个版本,其中的数据成员也是不同的。并且它们都不可移动不可拷贝,“管理类”应该如此。
主模板,是一个可变参数类模板,声明了一个类型形参包 _Mutexes
,存储了一个 std::tuple
,具体类型根据类型形参包决定。
_EXPORT_STD template <class... _Mutexes>
+class _NODISCARD_LOCK scoped_lock { // class with destructor that unlocks mutexes
+public:
+ explicit scoped_lock(_Mutexes&... _Mtxes) : _MyMutexes(_Mtxes...) { // construct and lock
+ _STD lock(_Mtxes...);
+ }
+
+ explicit scoped_lock(adopt_lock_t, _Mutexes&... _Mtxes) noexcept // strengthened
+ : _MyMutexes(_Mtxes...) {} // construct but don't lock
+
+ ~scoped_lock() noexcept {
+ _STD apply([](_Mutexes&... _Mtxes) { (..., (void) _Mtxes.unlock()); }, _MyMutexes);
+ }
+
+ scoped_lock(const scoped_lock&) = delete;
+ scoped_lock& operator=(const scoped_lock&) = delete;
+
+private:
+ tuple<_Mutexes&...> _MyMutexes;
+};
+
对模板类型形参包只有一个类型情况的偏特化,是不是很熟悉,和 lock_guard
几乎没有任何区别,保有一个互斥量的引用,构造上锁,析构解锁,提供一个额外的构造函数让构造的时候不上锁。所以用 scoped_lock
替代 lock_guard
不会造成任何额外开销。
template <class _Mutex>
+class _NODISCARD_LOCK scoped_lock<_Mutex> {
+public:
+ using mutex_type = _Mutex;
+
+ explicit scoped_lock(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock
+ _MyMutex.lock();
+ }
+
+ explicit scoped_lock(adopt_lock_t, _Mutex& _Mtx) noexcept // strengthened
+ : _MyMutex(_Mtx) {} // construct but don't lock
+
+ ~scoped_lock() noexcept {
+ _MyMutex.unlock();
+ }
+
+ scoped_lock(const scoped_lock&) = delete;
+ scoped_lock& operator=(const scoped_lock&) = delete;
+
+private:
+ _Mutex& _MyMutex;
+};
+
对类型形参包为空的情况的全特化,没有数据成员。
template <>
+class scoped_lock<> {
+public:
+ explicit scoped_lock() = default;
+ explicit scoped_lock(adopt_lock_t) noexcept /* strengthened */ {}
+
+ scoped_lock(const scoped_lock&) = delete;
+ scoped_lock& operator=(const scoped_lock&) = delete;
+};
+
std::mutex m1,m2;
+
+std::scoped_lock<std::mutex>lc{ m1 }; // 匹配到偏特化版本 保有一个 std::mutex&
+std::scoped_lock<std::mutex, std::mutex>lc2{ m1,m2 }; // 匹配到主模板 保有一个 std::tuple<std::mutex&,std::mutex&>
+std::scoped_lock<> lc3; // 匹配到全特化版本 空
+
std::scoped_lock
的构造与析构在上一节讲 scoped_lock
的数据成员的时候已经把这个模板类的全部源码,三个版本的代码都展示了,就不再重复。
这三个版本中,只有两个版本需要介绍,也就是
那这两个的共同点是什么呢?构造上锁,析构解锁。这很明显,明确这一点我们就开始讲吧。
std::mutex m;
+void f(){
+ m.lock();
+ std::lock_guard<std::mutex> lc{ m, std::adopt_lock };
+}
+void f2(){
+ m.lock();
+ std::scoped_lock<std::mutex>sp{ std::adopt_lock,m };
+}
+
这段代码为你展示了 std::lock_guard
和 std::scoped_lock
形参包元素数量为一的偏特化的唯一区别:调用不会上锁的构造函数的参数顺序不同。那么到此也就够了。
接下来我们进入 std::scoped_lock
主模板的讲解:
explicit scoped_lock(_Mutexes&... _Mtxes) : _MyMutexes(_Mtxes...) { // construct and lock
+ _STD lock(_Mtxes...);
+ }
+
explicit scoped_lock(adopt_lock_t, _Mutexes&... _Mtxes) noexcept // strengthened
+ : _MyMutexes(_Mtxes...) {} // construct but don't lock
+
这个构造函数不上锁,只是初始化数据成员 _MyMutexes
让它保有这些互斥量的引用。
~scoped_lock() noexcept {
+ _STD apply([](_Mutexes&... _Mtxes) { (..., (void) _Mtxes.unlock()); }, _MyMutexes);
+}
+
template< class F, class Tuple >
+constexpr decltype(auto) apply( F&& f, Tuple&& t );
+
template<class Callable, class Tuple, std::size_t...index>
+constexpr decltype(auto) Apply_impl(Callable&& obj,Tuple&& tuple,std::index_sequence<index...>){
+ return std::invoke(std::forward<Callable>(obj), std::get<index>(std::forward<Tuple>(tuple))...);
+}
+
+template<class Callable, class Tuple>
+constexpr decltype(auto) apply(Callable&& obj, Tuple&& tuple){
+ return Apply_impl(std::forward<Callable>(obj), std::forward<Tuple>(tuple),
+ std::make_index_sequence<std::tuple_size_v<std::remove_reference_t<Tuple>>>{});
+}
+
其实就是把元组给解包了,利用了 std::index_sequence
+ std::make_index_sequence
然后就用 std::get
形参包展开用 std::invoke
调用可调用对象即可,非常经典的处理可变参数做法,这个非常重要,一定要会使用。
举一个简单的调用例子:
std::tuple<int, std::string, char>tuple{66,"😅",'c'};
+::apply([](const auto&... t) { ((std::cout << t << ' '), ...); }, tuple);
+
如你所见,其实这很简单。至少使用与了解其设计原理是很简单的。唯一的难度或许只有那点源码,处理可变参数,这会涉及不少模板技术,既常见也通用。还是那句话:“不会模板,你阅读标准库源码,是无稽之谈”。
相对于 std::thread
的源码解析,std::scoped_lock
还是简单的多。
本单章专门介绍标准库在 C++17 引入的类模板 std::scoped_lock
的实现,让你对它再无疑问。
这会涉及到不少的模板技术,这没办法,就如同我们先前聊 std::thread
的构造与源码分析最后说的:“不会模板,你阅读标准库源码,是无稽之谈”。建议学习现代C++模板教程。
在我们初学 C++ 的时候应该都写过这样一段代码:
#include <iostream>
+
+int main(){
+ std::cout << "Hello World!" << std::endl;
+}
+
我们可以启动一个线程来做这件事情:
#include <iostream>
+#include <thread> // 引入线程支持头文件
+
+void hello(){ // 定义一个函数用作打印任务
+ std::cout << "Hello World" << std::endl;
+}
+
+int main(){
+ std::thread t{ hello };
+ t.join();
+}
+
#include <iostream>
+#include <thread>
+
+int main(){
+ unsigned int n = std::thread::hardware_concurrency();
+ std::cout << "支持 " << n << " 个并发线程。\\n";
+}
+
英特尔® 超线程技术是一项硬件创新,允许在每个内核上运行多个线程。更多的线程意味着可以并行完成更多的工作。
AMD 超线程技术被称为 SMT(Simultaneous Multi-Threading),它与英特尔的技术实现有所不同,不过使用类似。
举个例子:一款 4 核心 8 线程的 CPU,这里的 8 线程其实是指所谓的逻辑处理器,也意味着这颗 CPU 最多可并行执行 8 个任务。
我们的 hardware_concurrency()
获取的值自然也会是 8。
当然了,都 2024 年了,我们还得考虑一个问题:“ 英特尔从 12 代酷睿开始,为其处理器引入了全新的“大小核”混合设计架构”。
比如我的 CPU i7 13700H
它是 14 核心,20 线程,有 6 个能效核,6 个性能核。不过我们说了,物理核心这个通常不看重,hardware_concurrency()
输出的值会为 20。
我们可以举个简单的例子运用这个值:
template<typename ForwardIt>
+auto sum(ForwardIt first, ForwardIt last){
+ using value_type = std::iter_value_t<ForwardIt>;
+ std::size_t num_threads = std::thread::hardware_concurrency();
+ std::ptrdiff_t distance = std::distance(first, last);
+
+ if(distance > 1024000){
+ // 计算每个线程处理的元素数量
+ std::size_t chunk_size = distance / num_threads;
+ std::size_t remainder = distance % num_threads;
+
+ // 存储每个线程的结果
+ std::vector<value_type>results(num_threads);
+
+ // 存储关联线程的线程对象
+ std::vector<std::thread> threads;
+
+ // 创建并启动线程
+ auto start = first;
+ for (std::size_t i = 0; i < num_threads; ++i) {
+ auto end = std::next(start, chunk_size + (i < remainder ? 1 : 0));
+ threads.emplace_back([start, end, &results, i] {
+ results[i] = std::accumulate(start, end, value_type{});
+ });
+ start = end; // 开始迭代器不断向前
+ }
+
+ // 等待所有线程执行完毕
+ for (auto& thread : threads)
+ thread.join();
+
+ // 汇总线程的计算结果
+ value_type total_sum = std::accumulate(results.begin(), results.end(), value_type{});
+ return total_sum;
+ }
+
+ value_type total_sum = std::accumulate(first, last, value_type{});
+ return total_sum;
+}
+
template<typename ForwardIt>
+typename std::iterator_traits<ForwardIt>::value_type sum(ForwardIt first, ForwardIt last);
+
多线程求和只需要介绍三个地方
chunk_size
是每个线程分配的任务,但是这是可能有余数的,比如 10 个任务分配三个线程,必然余 1。但是我们也需要执行这个任务,所以还定义了一个对象 remainder
,它存储的就是余数。
auto end = std::next(start, chunk_size + (i < remainder ? 1 : 0));
这行代码是获取当前线程的执行范围,其实也就是要 chunk_size
再加上我们的余数 remainder
。这里写了一个三目运算符是为了进行分配任务,比如:
假设有 3 个线程执行,并且余数是 2。那么,每个线程的处理情况如下:
i = 0
时,由于 0 < 2
,所以这个线程会多分配一个元素。i = 1
时,同样因为 1 < 2
,这个线程也会多分配一个元素。i = 2
时,由于 2 >= 2
,所以这个线程只处理平均数量的元素。这确保了剩余的 2 个元素被分配给了前两个线程,而第三个线程只处理了平均数量的元素。这样就确保了所有的元素都被正确地分配给了各个线程进行处理。
auto start = first;
在创建线程执行之前先定义了一个开始迭代器。在传递给线程执行的lambda表达式中,最后一行是:start = end;
这是为了让迭代器一直向前。
由于求和不涉及数据竞争之类的问题,所以我们甚至可以在刚讲完 Hello World
就手搓了一个“并行求和”的简单的模板函数。主要的难度其实在于对 C++ 的熟悉程度,而非对线程类 std::thread
的使用了,这里反而是最简单的,无非是用容器存储线程对象管理,最后进行 join()
罢了。
在 C++ 标准库中,只能管理与 std::thread
关联的线程,类 std::thread
的对象就是指代线程的对象,我们说“线程管理”,其实也就是管理 std::thread
对象。
使用 C++ 线程库启动线程,就是构造 std::thread 对象。
`,5),vn=n("code",null,"std::thread",-1),mn=n("code",null,"std::thread",-1),bn={href:"https://zh.cppreference.com/w/cpp/named_req/Callable",target:"_blank",rel:"noopener noreferrer"},hn=n("code",null,"operator()",-1),_n=p(`当然了,如果是默认构造之类的,那么
std::thread
线程对象没有关联线程的,自然也不会启动线程执行任务。std::thread t; // 构造不表示线程的新 std::thread 对象 +
class Task{
+public:
+ void operator()()const {
+ std::cout << "operator()()const\\n";
+ }
+};
+
我们显然没办法直接像函数使用函数名一样,使用“类名”,函数名可以隐式转换到指向它的函数指针,而类名可不会直接变成对象,我们想使用 Task
自然就得构造对象了
std::thread t{ Task{} };
+t.join();
+
直接创建临时对象即可,可以简化代码并避免引入不必要的局部对象。
不过有件事情需要注意,当我们使用函数对象用于构造 std::thread
的时候,如果你传入的是一个临时对象,且使用的都是 “()
”小括号初始化,那么编译器会将此语法解析为函数声明。
std::thread t( Task() ); // 函数声明
+
这被编译器解析为函数声明,是一个返回类型为 std::thread
,函数名为 t
,接受一个返回 Task
的空参的函数指针类型,也就是 Task(*)()
。
之所以我们看着抽象是因为这里的形参是无名的,且写了个函数类型。
我们用一个简单的示例为你展示:
void h(int(int)); //#1 声明
+void h(int (*p)(int)){} //#2 定义
+
即使我还没有为你讲述概念,我相信你也发现了,#1 和 #2 的区别无非是,#1 省略了形参的名称,还有它的形参是函数类型而不是函数指针类型,没有 *
。
显然,int(int)
是一个函数类型,它被调整为了一个指向这个函数类型的指针类型。
那么回到我们最初的:
std::thread t( Task() ); // #1 函数声明
+std::thread t( Task (*p)() ){ return {}; } // #2 函数定义
+
#2
我们写出了函数形参名称 p
,再将函数类型写成函数指针类型,事实上完全等价。我相信,这样,也就足够了。
所以总而言之,建议使用 {}
进行初始化,这是好习惯,大多数时候它是合适的。
#include <iostream>
+#include <thread>
+
+int main(){
+ std::thread thread{ [] {std::cout << "Hello World!\\n"; } };
+ thread.join();
+}
+
比如函数结束,那么函数局部对象的生存期都已经结束了,都被销毁了,此时线程函数还持有函数局部对象的指针或引用。
#include <iostream>
+#include <thread>
+
+struct func {
+ int& m_i;
+ func(int& i) :m_i{ i } {}
+ void operator()(int n)const {
+ for (int i = 0; i <= n; ++i) {
+ m_i += i; // 可能悬空引用
+ }
+ }
+};
+
+int main(){
+ int n = 0;
+ std::thread my_thread{ func{n},100 };
+ my_thread.detach(); // 分离,不等待线程结束
+} // 分离的线程可能还在运行
+
主线程(main)创建局部对象 n、创建线程对象 my_thread 启动线程,执行任务 func{n}
,局部对象 n 的引用被子线程持有。传入 100 用于调用 func 的 operator(int)。
my_thread.detach();
,joinable() 为 false
。线程分离,线程对象不再持有线程资源,线程独立的运行。
主线程不等待,此时分离的子线程可能没有执行完毕,但是主线程(main)已经结束,局部对象 n
生存期结束,被销毁,而此时子线程还持有它的引用,访问悬空引用,造成未定义行为。my_thread
已经没有关联线程资源,正常析构,没有问题。
解决方法很简单,将 detach() 替换为 join()。
通常非常不推荐使用 detach(),因为程序员必须确保所有创建的线程正常退出,释放所有获取的资源并执行其它必要的清理操作。这意味着通过调用 detach() 放弃线程的所有权不是一种选择,因此 join 应该在所有场景中使用。 一些老式特殊情况不聊。
另外提示一下,也不要想着 detach() 之后,再次调用 join()
my_thread.detach();
+// todo..
+my_thread.join();
+// 函数结束
+
认为这样可以确保被分离的线程在这里阻塞执行完?
我们前面聊的很清楚了,detach() 是线程分离,线程对象放弃了线程资源的所有权,此时我们的 my_thread 它现在根本没有关联任何线程。调用 join() 是:“阻塞当前线程直至 *this 所标识的线程结束其执行”,我们的线程对象都没有线程,堵塞什么?执行什么呢?
简单点说,必须是 std::thread 的 joinable() 为 true 即线程对象有活跃线程,才能调用 join() 和 detach()。
顺带的,我们还得处理线程运行后的异常问题,举个例子:你在一个函数中构造了一个 std::thread 对象,线程开始执行,函数继续执行下面别的代码,但是如果抛出了异常呢?下面我的 join() 就会被跳过。
std::thread my_thread{func{n},10};
+//todo.. 抛出异常的代码
+my_thread.join();
+
避免程序被抛出的异常所终止,在异常处理过程中调用 join(),从而避免线程对象析构产生问题。
struct func; // 复用之前
+void f(){
+ int n = 0;
+ std::thread t{ func{n},10 };
+ try{
+ // todo.. 一些当前线程可能抛出异常的代码
+ f2();
+ }
+ catch (...){
+ t.join(); // 1
+ throw;
+ }
+ t.join(); // 2
+}
+
我知道你可能有很多疑问,我们既然 catch 接住了异常,为什么还要 throw?以及为什么我们要两个 join()?
这两个问题其实也算一个问题,如果代码里抛出了异常,就会跳转到 catch 的代码中,执行 join() 确保线程正常执行完成,线程对象可以正常析构。然而此时我们必须再次 throw 抛出异常,因为你要是不抛出,那么你不是还得执行一个 t.join()
?显然逻辑不对,自然抛出。
我知道你可能会想到:“我在 try 块中最后一行写一个 t.join()
,这样如果前面的代码没有抛出异常,就能正常的调用 join(),如果抛出了异常,那就调用 catch 中的 t.join()
根本不需要最外部 2 那里的 join(),也不需要再次 throw
抛出异常”
void f(){
+ int n = 0;
+ std::thread t{ func{n},10 };
+ try{
+ // todo.. 一些当前线程可能抛出异常的代码
+ f2();
+ t.join(); // try 最后一行调用 join()
+ }
+ catch (...){
+ t.join(); // 如果抛出异常,就在 这里调用 join()
+ }
+}
+
简单的说是:构造函数申请资源,析构函数释放资源,让对象的生命周期和资源绑定。当异常抛出时,C++ 会自动调用栈上所有对象的析构函数。
我们可以提供一个类,在析构函数中使用 join() 确保线程执行完成,线程对象正常析构。
class thread_guard{
+ std::thread& m_t;
+public:
+ explicit thread_guard(std::thread& t) :m_t{ t } {}
+ ~thread_guard(){
+ std::puts("析构"); // 打印 不用在乎
+ if (m_t.joinable()) { // 没有关联活跃线程
+ m_t.join();
+ }
+ }
+ thread_guard(const thread_guard&) = delete;
+ thread_guard& operator=(const thread_guard&) = delete;
+};
+void f(){
+ int n = 0;
+ std::thread t{ func{n},10 };
+ thread_guard g(t);
+ f2(); // 可能抛出异常
+}
+
不允许这些操作主要在于:这是个管理类,而且顾名思义,它就应该只是单纯的管理线程对象仅此而已,只保有一个引用,单纯的做好 RAII 的事情就行,允许其他操作没有价值。
严格来说其实这里倒也不算 RAII,因为 thread_guard 的构造函数其实并没有申请资源,只是保有了线程对象的引用,在析构的时候进行了 join() 。
向可调用对象或函数传递参数很简单,我们前面也都写了,只需要将这些参数作为 std::thread
的构造参数即可。需要注意的是,这些参数会拷贝到新线程的内存空间中,即使函数中的参数是引用,依然实际是拷贝。
void f(int, const int& a);
+
+int n = 1;
+std::thread t(f, 3, n);
+
线程对象 t 的构造没有问题,可以通过编译,但是这个 n 实际上并没有按引用传递,而是拷贝了,我们可以打印地址来验证我们的猜想。
void f(int, const int& a) { // a 并非引用了局部对象 n
+ std::cout << &a << '\\n';
+}
+
+int main() {
+ int n = 1;
+ std::cout << &n << '\\n';
+ std::thread t(f, 3, n);
+ t.join();
+}
+
void f(int, int& a) {
+ std::cout << &a << '\\n';
+}
+
+int main() {
+ int n = 1;
+ std::cout << &n << '\\n';
+ std::thread t(f, 3, std::ref(n));
+ t.join();
+}
+
“cref”呢?,这个“c”就是“const”,就是返回了 std::reference_wrapper<const T>
。我们不详细介绍他们的实现,你简单认为reference_wrapper
可以隐式转换为被包装对象的引用即可,
int n = 0;
+std::reference_wrapper<int> r = std::ref(n);
+int& p = r; // r 隐式转换为 n 的引用 此时 p 引用的就是 n
+
int n = 0;
+std::reference_wrapper<const int> r = std::cref(n);
+const int& p = r; // r 隐式转换为 n 的 const 的引用 此时 p 引用的就是 n
+
struct move_only {
+ move_only() { std::puts("默认构造"); }
+ move_only(const move_only&) = delete;
+ move_only(move_only&&)noexcept {
+ std::puts("移动构造");
+ }
+};
+
+void f(move_only){}
+
+int main(){
+ move_only obj;
+ std::thread t{ f,std::move(obj) };
+ t.join();
+}
+
struct X{
+ void task_run(int)const;
+};
+
+ X x;
+ int n = 0;
+ std::thread t{ &X::task_run,&x,n };
+ t.join();
+
传入成员函数指针、与其配合使用的对象、调用成员函数的参数,构造线程对象 t
,启动线程。
如果你是第一次见到成员指针,那么我们稍微聊一下,&X::task_run
是一个整体,它们构成了成员指针,&类名::非静态成员。
成员指针必须和对象一起使用,这是唯一标准用法,成员指针不可以转换到函数指针单独使用,即使是非静态成员函数没有使用任何数据成员。
`,4),Hs={href:"https://zh.cppreference.com/w/cpp/utility/functional/bind",target:"_blank",rel:"noopener noreferrer"},Ms=n("code",null,"std::bind",-1),Ls=p(`std::thread t{ std::bind(&X::task_run, &x ,n) };
+
struct X {
+ void task_run(int& a)const{
+ std::cout << &a << '\\n';
+ }
+};
+
+X x;
+int n = 0;
+std::cout << &n << '\\n';
+std::thread t{ std::bind(&X::task_run,&x,n) };
+t.join();
+
std::thread t{ std::bind(&X::task_run,&x,std::ref(n)) };
+
void f(const std::string&);
+std::thread t{ f,"hello" };
+
代码创建了一个调用 f("hello")
的线程。注意,函数 f
实际需要的是一个 std::string
类型的对象作为参数,但这里使用的是字符串字面量,我们要明白“A的引用只能引用A,或者以任何形式转换到A”,字符串字面量的类型是 const char[N]
,它会退化成指向它的const char*
指针,被线程对象保存。在调用 f
的时候,这个指针可以通过 std::string
的转换构造函数,构造出一个临时的 std::string
对象,就能成功调用。
void f(const std::string&);
+void test(){
+ char buffer[1024]{};
+ //todo.. code
+ std::thread t{ f,buffer };
+ t.detach();
+}
+
将 detach()
替换为 join()
。
void f(const std::string&);
+void test(){
+ char buffer[1024]{};
+ //todo.. code
+ std::thread t{ f,buffer };
+ t.join();
+}
+
显式将 buffer
转换为 std::string
。
void f(const std::string&);
+void test(){
+ char buffer[1024]{};
+ //todo.. code
+ std::thread t{ f,std::string(buffer) };
+ t.detach();
+}
+
std::this_thread
这个命名空间包含了管理当前线程的函数。
`,3),ka={href:"https://zh.cppreference.com/w/cpp/thread/yield",target:"_blank",rel:"noopener noreferrer"},va=n("code",null,"yield",-1),ma={href:"https://zh.cppreference.com/w/cpp/thread/get_id",target:"_blank",rel:"noopener noreferrer"},ba=n("code",null,"get_id",-1),ha={href:"https://zh.cppreference.com/w/cpp/thread/sleep_for",target:"_blank",rel:"noopener noreferrer"},_a=n("code",null,"sleep_for",-1),ga={href:"https://zh.cppreference.com/w/cpp/thread/sleep_until",target:"_blank",rel:"noopener noreferrer"},fa=n("code",null,"sleep_until",-1),wa=n("strong",null,"停止到",-1),ya=n("p",null,[s("它们之中最常用的是 "),n("code",null,"get_id"),s(",其次是 "),n("code",null,"sleep_for"),s(",再然后 "),n("code",null,"yield"),s(","),n("code",null,"sleep_until"),s(" 较少。")],-1),Ea=n("code",null,"get_id",-1),ja={href:"https://godbolt.org/z/fPcaj7xTv",target:"_blank",rel:"noopener noreferrer"},xa=p(`int main() {
+ std::cout << std::this_thread::get_id() << '\\n';
+
+ std::thread t{ [] {
+ std::cout << std::this_thread::get_id() << '\\n';
+ } };
+ t.join();
+}
+
使用 sleep_for
延时。当 Sleep
之类的就行,但是它需要接受的参数不同,是 std::chrono
命名空间中的时间对象。
int main() {
+ std::this_thread::sleep_for(std::chrono::seconds(3));
+}
+
using namespace std::chrono_literals;
+
+int main() {
+ std::this_thread::sleep_for(3s);
+}
+
简单直观。
`,2),Wa=p(`yield
减少 CPU 的占用。
while (!isDone()){
+ std::this_thread::yield();
+}
+
线程需要等待某个操作完成,如果你直接用一个循环不断判断这个操作是否完成就会使得这个线程占满 CPU 时间,这会造成资源浪费。此时可以判断操作是否完成,如果还没完成就调用 yield 交出 CPU 时间片让其他线程执行,过一会儿再来判断是否完成,这样这个线程占用 CPU 时间会大大减少。
使用 sleep_until
让当前线程延迟到具体的时间。我们延时 5 秒就是。
int main() {
+ // 获取当前时间点
+ auto now = std::chrono::system_clock::now();
+
+ // 设置要等待的时间点为当前时间点之后的5秒
+ auto wakeup_time = now + 5s;
+
+ // 输出当前时间
+ auto now_time = std::chrono::system_clock::to_time_t(now);
+ std::cout << "Current time:\\t\\t" << std::put_time(std::localtime(&now_time), "%H:%M:%S") << std::endl;
+
+ // 输出等待的时间点
+ auto wakeup_time_time = std::chrono::system_clock::to_time_t(wakeup_time);
+ std::cout << "Waiting until:\\t\\t" << std::put_time(std::localtime(&wakeup_time_time), "%H:%M:%S") << std::endl;
+
+ // 等待到指定的时间点
+ std::this_thread::sleep_until(wakeup_time);
+
+ // 输出等待结束后的时间
+ now = std::chrono::system_clock::now();
+ now_time = std::chrono::system_clock::to_time_t(now);
+ std::cout << "Time after waiting:\\t" << std::put_time(std::localtime(&now_time), "%H:%M:%S") << std::endl;
+}
+
介绍了一下 std::this_thread
命名空间中的四个函数的基本用法,我们后续会经常看到这些函数的使用,不用着急。
std::thread
转移所有权传入可调用对象以及参数,构造 std::thread
对象,启动线程,而线程对象拥有了线程的所有权,线程是一种系统资源,所以可称作“线程资源”。
std::thread 不可复制。两个 std::thread 不可表示一个线程,std::thread 对线程资源是独占所有权。移动就是转移它的线程资源的所有权给别的 std::thread
对象。
int main() {
+ std::thread t{ [] {
+ std::cout << std::this_thread::get_id() << '\\n';
+ } };
+ std::cout << t.joinable() << '\\n'; // 线程对象 t 当前关联了活跃线程 打印 1
+ std::thread t2{ std::move(t) }; // 将 t 的线程资源的所有权移交给 t2
+ std::cout << t.joinable() << '\\n'; // 线程对象 t 当前没有关联活跃线程 打印 0
+ //t.join(); // Error! t 没有线程资源
+ t2.join(); // t2 当前持有线程资源
+}
+
这段代码通过移动构造转移了线程对象 t
的线程资源所有权到 t2
,这里虽然有两个 std::thread
对象,但是从始至终只有一个线程资源,让持有线程资源的 t2
对象最后调用 join()
堵塞让其线程执行完毕。t
与 t2
都能正常析构。
我们还可以使用移动赋值来转移线程资源的所有权:
int main() {
+ std::thread t; // 默认构造,没有关联活跃线程
+ std::cout << t.joinable() << '\\n'; // 0
+ std::thread t2{ [] {} };
+ t = std::move(t2); // 转移线程资源的所有权到 t
+ std::cout << t.joinable() << '\\n'; // 1
+ t.join();
+
+ t2 = std::thread([] {});
+ t2.join();
+}
+
我们只需要介绍 t2 = std::thread([] {})
,临时对象是右值表达式,不用调用 std::move
,这里相当于是将临时的 std::thread
对象所持有的线程资源转移给 t2
,t2
再调用 join()
正常析构。
函数返回 std::thread
对象:
std::thread f(){
+ std::thread t{ [] {} };
+ return t;
+}
+
+int main(){
+ std::thread rt = f();
+ rt.join();
+}
+
这里的 return t
重载决议[1]选择到了移动构造,将 t
线程资源的所有权转移给函数调用 f()
返回的临时 std::thread
对象中,然后这个临时对象再用来初始化 rt
,临时对象是右值表达式,这里一样选择到移动构造,将临时对象的线程资源所有权移交给 rt
。此时 rt
具有线程资源的所有权,由它调用 join()
正常析构。
如果标准达到 C++17,RVO 保证这里少一次移动构造的开销(临时对象初始化
rt
的这次)。
所有权也可以在函数内部传递:
void f(std::thread t){
+ t.join();
+}
+
+int main(){
+ std::thread t{ [] {} };
+ f(std::move(t));
+ f(std::thread{ [] {} });
+}
+
std::move
将 t 转换为了一个右值表达式,初始化函数f
形参 t
,选择到了移动构造转移线程资源的所有权,在函数中调用 t.join()
后正常析构。std::thread{ [] {} }
构造了一个临时对象,本身就是右值表达式,初始化函数f
形参 t
,移动构造转移线程资源的所有权到 t
,t.join()
后正常析构。
本节内容总体来说是很简单的,如果你有些地方无法理解,那只有一种可能,“对移动语义不了解”,不过这也不是问题,在后续我们详细介绍 std::thread
构造函数的源码即可,不用着急。
我们上一个大节讲解了线程管理,也就是 std::thread
的管理,其中的重中之重就是它的构造,传递参数。我们用源码实现为各位从头讲解。
了解其实现,才能更好的使用它。
joining_thread
这个类和 std::thread
的区别就是析构函数会自动 join
。如果您好好的学习了上一节的内容,阅读了 std::thread
的源码,以下内容不会对您构成任何的难度。
我们存储一个 std::thread
作为底层数据成员,稍微注意一下构造函数和赋值运算符的实现即可。
class joining_thread {
+ std::thread t;
+public:
+ joining_thread()noexcept = default;
+ template<typename Callable, typename... Args>
+ explicit joining_thread(Callable&& func, Args&&...args) :
+ t{ std::forward<Callable>(func), std::forward<Args>(args)... } {}
+ explicit joining_thread(std::thread t_)noexcept : t{ std::move(t_) } {}
+ joining_thread(joining_thread&& other)noexcept : t{ std::move(other.t) } {}
+
+ joining_thread& operator=(std::thread&& other)noexcept {
+ if (joinable()) { // 如果当前有活跃线程,那就先执行完
+ join();
+ }
+ t = std::move(other);
+ return *this;
+ }
+ ~joining_thread() {
+ if (joinable()) {
+ join();
+ }
+ }
+ void swap(joining_thread& other)noexcept {
+ t.swap(other.t);
+ }
+ std::thread::id get_id()const noexcept {
+ return t.get_id();
+ }
+ bool joinable()const noexcept {
+ return t.joinable();
+ }
+ void join() {
+ t.join();
+ }
+ void detach() {
+ t.detach();
+ }
+ std::thread& data()noexcept {
+ return t;
+ }
+ const std::thread& data()const noexcept {
+ return t;
+ }
+};
+
int main(){
+ std::cout << std::this_thread::get_id() << '\\n';
+ joining_thread thread{[]{
+ std::cout << std::this_thread::get_id() << '\\n';
+ } };
+ joining_thread thread2{ std::move(thread) };
+}
+
使用容器管理线程对象,等待线程执行结束:
void do_work(std::size_t id){
+ std::cout << id << '\\n';
+}
+
+int main(){
+ std::vector<std::thread>threads;
+ for (std::size_t i = 0; i < 10; ++i){
+ threads.emplace_back(do_work, i); // 产生线程
+ }
+ for(auto& thread:threads){
+ thread.join(); // 对每个线程对象调用 join()
+ }
+}
+
线程对象代表了线程,管理线程对象也就是管理线程,这个 vector
对象管理 10 个线程,保证他们的执行、退出。
使用我们这节实现的 joining_thread
则不需要最后的循环 join()
:
int main(){
+ std::vector<joining_thread>threads;
+ for (std::size_t i = 0; i < 10; ++i){
+ threads.emplace_back(do_work, i);
+ }
+}
+
如果你自己编译了这些代码,相信你注意到了,打印的是乱序的,没什么规律,而且重复运行的结果还不一样,这是正常现象。多线程执行就是如此,无序且操作可能被打断。使用互斥量可以解决这些问题,这也就是下一章节的内容了。
本章节的内容围绕着:“使用线程”,也就是"使用 std::thread
"展开, std::thread
是我们学习 C++ 并发支持库的重中之重,本章的内容并不少见,但是却是少有的准确与完善。即使你早已学习过乃至使用 C++ 标准库进行多线程编程已经很久,我相信本章也一定可以让你收获良多。
如果是第一次学习,有还不够理解的地方,则一定要多思考,或记住,以后多看。
我尽量讲的简单与通俗易懂。学完本章,你大概率还无法在实际环境使用多线程提升程序效率,至少也要学习到使用互斥量,保护共享数据,才可实际使用。
在标准 C++ 中,std::thread
可以指代线程,使用线程也就是使用 std::thread
类。
在我们初学 C++ 的时候应该都写过这样一段代码:
\\n#include <iostream>\\n\\nint main(){\\n std::cout << \\"Hello World!\\" << std::endl;\\n}\\n
本章节主要内容:
多线程共享数据的问题
使用互斥量保护共享数据
保护共享数据的其它方案
在上一章内容,我们对于线程的基本使用和管理,可以说已经比较了解了,甚至深入阅读了部分的 std::thread
源码。所以如果你好好学习了上一章,本章也完全不用担心。
我们本章,就要开始聊共享数据的那些事。
在多线程的情况下,每个线程都抢着完成自己的任务。在大多数情况下,即使会改变执行顺序,也是良性竞争,这是无所谓的。比如两个线程都要往标准输出输出一段字符,谁先谁后并不会有什么太大影响。
void f() { std::cout << "❤️\\n"; }
+void f2() { std::cout << "😢\\n"; }
+
+int main(){
+ std::thread t{ f };
+ std::thread t2{ f2 };
+ t.join();
+ t2.join();
+}
+
只有在涉及多线程修改相同共享数据的时候,才会导致“恶性的条件竞争”。
std::vector<int>v;
+
+void f() { v.emplace_back(1); }
+void f2() { v.erase(v.begin()); }
+
+int main() {
+ std::thread t{ f };
+ std::thread t2{ f2 };
+ t.join();
+ t2.join();
+ std::cout << v.size() << '\\n';
+}
+
比如这段代码就是典型的恶性条件竞争,两个线程共享一个 vector
,并对它进行修改。可能导致许多问题,比如 f2
先执行,此时 vector
还没有元素,导致抛出异常。又或者 f
执行了一半,调用了 f2()
,等等。
当然了,也有可能先执行 f,然后执行 f2,最后打印了 0,程序老老实实执行完毕。
但是我们显然不能寄希望于这种操作系统的调度。
`,5),_=n("code",null,"emplace_back",-1),h={href:"https://zh.cppreference.com/w/cpp/language/memory_model#.E7.BA.BF.E7.A8.8B.E4.B8.8E.E6.95.B0.E6.8D.AE.E7.AB.9E.E4.BA.89",target:"_blank",rel:"noopener noreferrer"},g=n("em",null,"未定义的行为",-1),f=n("code",null,"emplace_back",-1),w=n("em",null,"未定义",-1),x=n("blockquote",null,[n("p",null,[s("当某个表达式的求值写入某个内存位置,而另一求值读或修改同一内存位置时,称这些"),n("strong",null,"表达式冲突"),s("。"),n("strong",null,"拥有两个冲突的求值的程序就有数据竞争"),s(",除非")]),n("ul",null,[n("li",null,"两个求值都在同一线程上,或者在同一信号处理函数中执行,或"),n("li",null,"两个冲突的求值都是原子操作(见 std::atomic),或"),n("li",null,"一个冲突的求值发生早于 另一个(见 std::memory_order)")]),n("p",null,[n("strong",null,"如果出现数据竞争,那么程序的行为未定义。")])],-1),y=n("em",null,"数据竞争",-1),E={href:"https://zh.cppreference.com/w/cpp/language/memory_model#.E7.BA.BF.E7.A8.8B.E4.B8.8E.E6.95.B0.E6.8D.AE.E7.AB.9E.E4.BA.89",target:"_blank",rel:"noopener noreferrer"},q=n("em",null,"未定义行为",-1),B=o(`int cnt = 0;
+auto f = [&]{cnt++;};
+std::thread t1{f}, t2{f}, t3{f}; // 未定义行为
+
互斥量(Mutex),又称为互斥锁,是一种用来保护临界区[1]的特殊对象,它可以处于锁定(locked)状态,也可以处于解锁(unlocked)状态:
如果互斥锁是锁定的, 通常说某个特定的线程正持有这个互斥锁
如果没有线程持有这个互斥量,那么这个互斥量就处于解锁状态
概念从来不是我们的重点,用一段对比代码为你直观的展示互斥量的作用:
void f() {
+ std::cout << std::this_thread::get_id() << '\\n';
+}
+
+int main() {
+ std::vector<std::thread>threads;
+ for (std::size_t i = 0; i < 10; ++i)
+ threads.emplace_back(f);
+
+ for (auto& thread : threads)
+ thread.join();
+}
+
#include <mutex> // 必要标头
+std::mutex m;
+
+void f() {
+ m.lock();
+ std::cout << std::this_thread::get_id() << '\\n';
+ m.unlock();
+}
+
+int main() {
+ std::vector<std::thread>threads;
+ for (std::size_t i = 0; i < 10; ++i)
+ threads.emplace_back(f);
+
+ for (auto& thread : threads)
+ thread.join();
+}
+
当多个线程执行函数 f
的时候,只有一个线程能成功调用 lock()
给互斥量上锁,其他所有的线程 lock()
的调用将阻塞执行,直至获得锁。第一个调用 lock()
的线程得以继续往下执行,执行我们的 std::cout
输出语句,不会有任何其他的线程打断这个操作。直到线程执行 unlock()
,就解锁了互斥量。
那么其他线程此时也就能再有一个成功调用 lock
...
至于到底哪个线程才会成功调用,这个是由操作系统调度决定的。
看一遍描述就可以了,简而言之,被 lock()
和 unlock()
包含在其中的代码是线程安全的,同一时间只有一个线程执行,不会被其它线程的执行所打断。
std::lock_guard
void f() {
+ std::lock_guard<std::mutex>lc{ m };
+ std::cout << std::this_thread::get_id() << '\\n';
+}
+
_EXPORT_STD template <class _Mutex>
+class _NODISCARD_LOCK lock_guard { // class with destructor that unlocks a mutex
+public:
+ using mutex_type = _Mutex;
+
+ explicit lock_guard(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock
+ _MyMutex.lock();
+ }
+
+ lock_guard(_Mutex& _Mtx, adopt_lock_t) noexcept // strengthened
+ : _MyMutex(_Mtx) {} // construct but don't lock
+
+ ~lock_guard() noexcept {
+ _MyMutex.unlock();
+ }
+
+ lock_guard(const lock_guard&) = delete;
+ lock_guard& operator=(const lock_guard&) = delete;
+
+private:
+ _Mutex& _MyMutex;
+};
+
所以有的时候你可能会看到一些这样的代码:
void f(){
+ //code..
+ {
+ std::lock_guard<std::mutex>lc{ m };
+ // 涉及共享资源的修改的代码...
+ }
+ //code..
+}
+
使用 {}
创建了一个块作用域,限制了对象 lc
的生存期,进入作用域构造 lock_guard
的时候上锁(lock),离开作用域析构的时候解锁(unlock)。
“粒度”通常用于描述锁定的范围大小,较小的粒度意味着锁定的范围更小,因此有更好的性能和更少的竞争。
我们举一个例子:
std::mutex m;
+
+void add_to_list(int n, std::list<int>& list) {
+ std::vector<int> numbers(n + 1);
+ std::iota(numbers.begin(), numbers.end(), 0);
+ int sum = std::accumulate(numbers.begin(), numbers.end(), 0);
+
+ {
+ std::lock_guard<std::mutex>lc{ m };
+ list.push_back(sum);
+ }
+}
+void print_list(const std::list<int>& list){
+ std::lock_guard<std::mutex>lc{ m };
+ for(const auto& i : list){
+ std::cout << i << ' ';
+ }
+ std::cout << '\\n';
+}
+
std::list<int> list;
+std::thread t1{ add_to_list,i,std::ref(list) };
+std::thread t2{ add_to_list,i,std::ref(list) };
+std::thread t3{ print_list,std::cref(list) };
+std::thread t4{ print_list,std::cref(list) };
+t1.join();
+t2.join();
+t3.join();
+t4.join();
+
先看 add_to_list
,只有 list.push_back(sum)
涉及到了对共享数据的修改,需要进行保护,我们用 {}
包起来了。
假设有线程 A、B执行函数 add_to_list()
:线程 A 中的 numbers、sum 与线程 B 中的,不是同一个,希望大家分清楚,自然不存在数据竞争,也不需要上锁。线程 A、B执行完了前面求 0-n
的计算,只有一个线程能在 lock_guard
的构造函数中成功调用 lock() 给互斥量上锁。假设线程 A 成功调用 lock(),那么线程 B 的 lock() 调用就阻塞了,必须等待线程 A 执行完里面的代码,然后作用域结束,调用 lock_guard
的析构函数,解锁 unlock(),此时线程 B 就可以进去执行了,避免了数据竞争,不存在一个对象同时被多个线程修改。
函数 print_list()
就更简单了,打印 list
,给整个函数上锁,同一时刻只能有一个线程执行。
我们的使用代码是多个线程执行这两个函数,两个函数共享了一个锁,这样确保了当执行函数 print_list()
打印的时候,list 的状态是确定的。打印函数 print_list
和 add_to_list
函数的修改操作同一时间只能有一个线程在执行。print_list()
不可能看到正在被add_to_list()
修改的 list。
至于到底哪个函数哪个线程会先执行,执行多少次,这些都由操作系统调度决定,也完全有可能连续 4 次都是执行函数 print_list
的线程成功调用 lock
,会打印出了一样的值,这都很正常。
std::mutex m;
+std::lock_guard lc{ m }; // std::lock_guard<std::mutex>
+
std::mutex m;
+std::scoped_lock lc{ m }; // std::scoped_lock<std::mutex>
+
我们在后续管理多个互斥量,会详细了解这个类。
try_lock
try_lock
是互斥量中的一种尝试上锁的方式。与常规的 lock
不同,try_lock
会尝试上锁,但如果锁已经被其他线程占用,则不会阻塞当前线程,而是立即返回。
它的返回类型是 bool
,如果上锁成功就返回 true
,失败就返回 false
。
这种方法在多线程编程中很有用,特别是在需要保护临界区的同时,又不想线程因为等待锁而阻塞的情况下。
std::mutex mtx;
+
+void threadFunction(int id) {
+ // 尝试加锁
+ if (mtx.try_lock()) {
+ std::cout << "线程:" << id << " 获得锁" << std::endl;
+ // 临界区代码
+ std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟临界区操作
+ mtx.unlock(); // 解锁
+ std::cout << "线程:" << id << " 释放锁" << std::endl;
+ } else {
+ std::cout << "线程:" << id << " 获取锁失败 处理步骤" << std::endl;
+ }
+}
+
如果有两个线程运行这段代码,必然有一个线程无法成功上锁,要走 else 的分支。
std::thread t1(threadFunction, 1);
+std::thread t2(threadFunction, 2);
+
+t1.join();
+t2.join();
+
互斥量主要也就是为了保护共享数据,上一节的使用互斥量也已经为各位展示了一些。
然而使用互斥量来保护共享数据也并不是在函数中加上一个 std::lock_guard
就万事大吉了。有的时候只需要一个指针或者引用,就能让这种保护形同虚设。
class Data{
+ int a{};
+ std::string b{};
+public:
+ void do_something(){
+ // 修改数据成员等...
+ }
+};
+
+class Data_wrapper{
+ Data data;
+ std::mutex m;
+public:
+ template<class Func>
+ void process_data(Func func){
+ std::lock_guard<std::mutex>lc{m};
+ func(data); // 受保护数据传递给函数
+ }
+};
+
+Data* p = nullptr;
+
+void malicious_function(Data& protected_data){
+ p = &protected_data; // 受保护的数据被传递
+}
+
+Data_wrapper d;
+
+void foo(){
+ d.process_data(malicious_function); // 传递了一个恶意的函数
+ p->do_something(); // 在无保护的情况下访问保护数据
+}
+
成员函数模板 process_data
看起来一点问题也没有,使用 std::lock_guard
对数据做了保护,但是调用方传递了 malicious_function
这样一个恶意的函数,使受保护数据传递给外部,可以在没有被互斥量保护的情况下调用 do_something()
。
我们传递的函数就不该是涉及外部副作用的,就应该是单纯的在受互斥量保护的情况下老老实实调用 do_something()
操作受保护的数据。
process_data
的确算是没问题,用户非要做这些事情也是防不住的,我们只是告诉各位可能的情况。
试想一下,有一个玩具,这个玩具有两个部分,必须同时拿到两部分才能玩。比如一个遥控汽车,需要遥控器和玩具车才能玩。有两个小孩,他们都想玩这个玩具。当其中一个小孩拿到了遥控器和玩具车时,就可以尽情玩耍。当另一个小孩也想玩,他就得等待另一个小孩玩完才行。再试想,遥控器和玩具车被放在两个不同的地方,并且两个小孩都想要玩,并且一个拿到了遥控器,另一个拿到了玩具车。问题就出现了,除非其中一个孩子决定让另一个先玩,他把自己的那个部分给另一个小孩。但如果他们都不愿意,那么这个遥控汽车就谁都没有办法玩。
我们当然不在乎小孩抢玩具,我们要聊的是线程对锁的竞争:两个线程需要对它们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个线程的互斥量解锁。因为它们都在等待对方释放互斥量,没有线程工作。 这种情况就是死锁。
避免死锁的一般建议是让两个互斥量以相同的顺序上锁,总在互斥量 B 之前锁住互斥量 A,就通常不会死锁。反面示例
std::mutex m1,m2;
+std::size_t n{};
+
+void f(){
+ std::lock_guard<std::mutex>lc1{ m1 };
+ std::lock_guard<std::mutex>lc2{ m2 };;
+ ++n;
+}
+void f2() {
+ std::lock_guard<std::mutex>lc1{ m2 };
+ std::lock_guard<std::mutex>lc2{ m1 };
+ ++n;
+}
+
f
与 f2
因为互斥量上锁顺序不同,就有死锁风险。函数 f
先锁定 m1
,然后再尝试锁定 m2
,而函数 f2
先锁定 m2
再锁定 m1
。如果两个线程同时运行,它们就可能会彼此等待对方释放其所需的锁,从而造成死锁。
但是有的时候即使固定锁顺序,依旧会产生问题。当有多个互斥量保护同一个类的对象时,对于相同类型的两个不同对象进行数据的交换操作,为了保证数据交换的正确性,就要避免其它线程修改,确保每个对象的互斥量都锁住自己要保护的区域。如果按照前面的的选择一个固定的顺序上锁解锁,则毫无意义,比如:
struct X{
+ X(const std::string& str) :object{ str } {}
+
+ friend void swap(X& lhs, X& rhs);
+private:
+ std::string object;
+ std::mutex m;
+};
+
+void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::lock_guard<std::mutex>lock1{ lhs.m };
+ std::lock_guard<std::mutex>lock2{ rhs.m };
+ swap(lhs.object, rhs.object);
+}
+
考虑用户调用的时候将参数交换,就会产生死锁:
X a{ "🤣" }, b{ "😅" };
+std::thread t{ [&] {swap(a, b); } }; // 1
+std::thread t2{ [&] {swap(b, a); } }; // 2
+
1
执行的时候,先上锁 a 的互斥量,再上锁 b 的互斥量。
2
执行的时候,先上锁 b 的互斥量,再上锁 a 的互斥量。
void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::lock(lhs.m, rhs.m); // 给两个互斥量上锁
+ std::lock_guard<std::mutex>lock1{ lhs.m,std::adopt_lock };
+ std::lock_guard<std::mutex>lock2{ rhs.m,std::adopt_lock };
+ swap(lhs.object, rhs.object);
+}
+
因为前面已经使用了 std::lock
上锁,所以后的 std::lock_guard
构造都额外传递了一个 std::adopt_lock
参数,让其选择到不上锁的构造函数。函数退出也能正常解锁。
std::lock
给 lhs.m
或 rhs.m
上锁时若抛出异常,则在重抛前对任何已锁的对象调用 unlock()
解锁,也就是 std::lock
要么将互斥量都上锁,要么一个都不锁。
所以我们前面的代码可以改写为:
void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::scoped_lock guard{ lhs.m,rhs.m };
+ swap(lhs.object, rhs.object);
+}
+
使用 std::scoped_lock
可以将所有 std::lock
替换掉,减少错误发生。
然而它们的帮助都是有限的,一切最终都是依靠开发者使用与管理。
死锁是多线程编程中令人相当头疼的问题,并且死锁经常是不可预见,甚至难以复现,因为在大部分时间里,程序都能正常完成工作。我们可以通过一些简单的规则,约束开发者的行为,帮助写出“无死锁”的代码。
避免嵌套锁
线程获取一个锁时,就别再获取第二个锁。每个线程只持有一个锁,自然不会产生死锁。如果必须要获取多个锁,使用 std::lock
。
避免在持有锁时调用外部代码
这个建议是很简单的:因为代码是外部提供的,所以没办法确定外部要做什么。外部程序可能做任何事情,包括获取锁。在持有锁的情况下,如果用外部代码要获取一个锁,就会违反第一个指导意见,并造成死锁(有时这是无法避免的)。当写通用代码时(比如保护共享数据中的 Date
类)。这不是接口设计者可以处理的,只能寄希望于调用方传递的代码是能正常执行的。
使用固定顺序获取锁
如同第一个示例那样,固定的顺序上锁就不存在问题。
std::unique_lock
灵活的锁void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::unique_lock<std::mutex>lock1{ lhs.m, std::defer_lock };
+ std::unique_lock<std::mutex>lock2{ rhs.m, std::defer_lock };
+ std::lock(lock1, lock2);
+ swap(lhs.object, rhs.object);
+ ++n;
+}
+
private:
+ _Mutex* _Pmtx = nullptr;
+ bool _Owns = false;
+
如你所见很简单,一个互斥量的指针,还有一个就是表示对象是否拥有互斥量所有权的 bool 类型的对象 _Owns
了。我们前面代码会调用构造函数:
unique_lock(_Mutex& _Mtx, defer_lock_t) noexcept
+ : _Pmtx(_STD addressof(_Mtx)), _Owns(false) {} // construct but don't lock
+
void lock() { // lock the mutex
+ _Validate();
+ _Pmtx->lock();
+ _Owns = true;
+}
+
如你所见,正常上锁,并且把 _Owns
设置为 true
,即表示当前对象拥有互斥量的所有权。那么接下来看析构函数:
~unique_lock() noexcept {
+ if (_Owns) {
+ _Pmtx->unlock();
+ }
+}
+
必须得是当前对象拥有互斥量的所有权析构函数才会调用 unlock() 解锁互斥量。我们的代码因为调用了 lock
,所以 _Owns
设置为 true
,函数结束的时候会解锁互斥量。
设计挺奇怪的对吧,这个所有权语义。其实上面的代码还不够简单直接,我们再举个例子:
std::mutex m;
+
+int main() {
+ std::unique_lock<std::mutex>lock{ m,std::adopt_lock };
+ lock.lock();
+}
+
void _Validate() const { // check if the mutex can be locked
+ if (!_Pmtx) {
+ _Throw_system_error(errc::operation_not_permitted);
+ }
+
+ if (_Owns) {
+ _Throw_system_error(errc::resource_deadlock_would_occur);
+ }
+}
+
lock.mutex()->lock();
+
也就是说 std::unique_lock
要想调用 lock()
成员函数,必须是当前没有所有权。
所以正常的用法其实是,先上锁了互斥量,然后传递 std::adopt_lock
构造 std::unique_lock
对象表示拥有互斥量的所有权,即可在析构的时候正常解锁。如下:
std::mutex m;
+
+int main() {
+ m.lock();
+ std::unique_lock<std::mutex>lock{ m,std::adopt_lock };
+}
+
简而言之:
std::defer_lock
构造函数不上锁,要求构造之后上锁std::adopt_lock
构造函数不上锁,要求在构造之前互斥量上锁我们前面提到了 std::unique_lock
更加灵活,那么灵活在哪?很简单,它拥有 lock()
和 unlock()
成员函数,所以我们能写出如下代码:
void f() {
+ //code..
+
+ std::unique_lock<std::mutex>lock{ m };
+
+ // 涉及共享资源的修改的代码...
+
+ lock.unlock(); // 解锁并释放所有权,析构函数不会再 unlock()
+
+ //code..
+}
+
而不是像之前 std::lock_guard
一样使用 {}
。
另外再聊一聊开销吧,其实倒也还好,多了一个 bool
,内存对齐,x64 环境也就是 16
字节。这都不是最重要的,主要是复杂性和需求,通常建议优先 std::lock_guard
,当它无法满足你的需求或者显得代码非常繁琐,那么可以考虑使用 std::unique_lock
。
请勿对移动语义和转移所有权抱有错误的幻想,我们说的无非是调用
std::unique_lock
的移动构造罢了:_NODISCARD_CTOR_LOCK unique_lock(unique_lock&& _Other) noexcept : _Pmtx(_Other._Pmtx), _Owns(_Other._Owns) { + _Other._Pmtx = nullptr; + _Other._Owns = false; +} +
将数据成员赋给新对象,原来的置空,这就是所谓的 “所有权”转移,切勿被词语迷惑。
std::unique_lock
是只能移动不可复制的类,它移动即标志其管理的互斥量的所有权转移了。
一种可能的使用是允许函数去锁住一个互斥量,并将互斥量的所有权转移到调用者上,所以调用者可以在这个锁保护的范围内执行代码。
std::unique_lock<std::mutex>get_lock(){
+ extern std::mutex some_mutex;
+ std::unique_lock<std::mutex>lk{ some_mutex };
+ return lk;
+
+}
+void process_data(){
+ std::unique_lock<std::mutex>lk{ get_lock() };
+ // 执行一些任务...
+}
+
return lk
这里会调用移动构造,将互斥量的所有权转移给调用方, process_data
函数结束的时候会解锁互斥量。
std::unique_lock
是灵活的,同样允许在对象销毁之前就解锁互斥量,调用 unlock()
成员函数即可,不再强调。
保护共享数据并非必须使用互斥量,互斥量只是其中一种常见的方式而已,对于一些特殊的场景,也有专门的保护方式,比如对于共享数据的初始化过程的保护。我们通常就不会用互斥量,这会造成很多的额外开销。
我们不想为各位介绍其它乱七八糟的各种保护初始化的方式,我们只介绍三种:双检锁(错误)、使用 std::call_once
、静态局部变量初始化在 C++11 是线程安全。
双检锁(错误)线程不安全
void f(){
+ if(!ptr){ // 1
+ std::lock_guard<std::mutex>lk{ m };
+ if(!ptr){ // 2
+ ptr.reset(new some); // 3
+ }
+ }
+ ptr->do_something(); // 4
+}
+
① 是查看指针是否为空,空才需要初始化,才需要获取锁。指针为空,当获取锁后会再检查一次指针②(这就是双重检查),避免另一线程在第一次检查后再做初始化,并且让当前线程获取锁。
然而这显然没用,因为有潜在的条件竞争。未被锁保护的读取操作①没有与其他线程里被锁保护的写入操作③进行同步,因此就会产生条件竞争。
简而言之:一个线程知道另一个线程已经在执行③,但是此时还没有创建 some 对象,而只是分配内存对指针写入。那么这个线程在①的时候就不会进入,直接执行了 ptr->do_something()
④,得不到正确的结果,因为对象还没构造。
如果你觉得难以理解,那就记住
ptr.reset(new some);
并非是不可打断不可交换的固定指令。这种错误写法在一些单例中也非常的常见。如果你的同事或上司写出此代码,一般不建议指出,因为不见得你能教会他们,不要“没事找事”,只要不影响自己即可。
std::shared_ptr<some>ptr;
+std::mutex m;
+std::once_flag resource_flag;
+
+void init_resource(){
+ ptr.reset(new some);
+}
+
+void foo(){
+ std::call_once(resource_flag, init_resource); // 线程安全的一次初始化
+ ptr->do_something();
+}
+
“初始化”,自然是一次。但是 std::call_once
也有一些例外情况(比如异常)会让传入的可调用对象被多次调用,即“多次”初始化:
std::once_flag flag;
+int n = 0;
+
+void f(){
+ std::call_once(flag, [] {
+ ++n;
+ std::cout << "第" << n << "次调用\\n";
+ throw std::runtime_error("异常");
+ });
+}
+
+int main(){
+ try{
+ f();
+ }
+ catch (std::exception&){}
+
+ try{
+ f();
+ }
+ catch (std::exception&){}
+}
+
静态局部变量初始化在 C++11 是线程安全
class my_class;
+my_class& get_my_class_instance(){
+ static my_class instance; // 线程安全的初始化过程 初始化严格发生一次
+}
+
多线程可以安全的调用 get_my_class_instance
函数,不用为数据竞争而担心。此方式也在单例中多见,是简单合理的做法。
class Settings {
+private:
+ std::map<std::string, std::string> data_;
+ mutable std::shared_mutex mutex_; // “M&M 规则”:mutable 与 mutex 一起出现
+
+public:
+ void set(const std::string& key, const std::string& value) {
+ std::lock_guard<std::shared_mutex> lock(mutex_);
+ data_[key] = value;
+ }
+
+ std::string get(const std::string& key) const {
+ std::shared_lock<std::shared_mutex> lock(mutex_);
+ auto it = data_.find(key);
+ return (it != data_.end()) ? it->second : ""; // 如果没有找到键返回空字符串
+ }
+};
+
std::recursive_mutex
是 C++ 标准库提供的一种互斥量类型,它允许同一线程多次锁定同一个互斥量,而不会造成死锁。当同一线程多次对同一个 std::recursive_mutex
进行锁定时,只有在解锁与锁定次数相匹配时,互斥量才会真正释放。但它并不影响不同线程对同一个互斥量进行锁定的情况。不同线程对同一个互斥量进行锁定时,会按照互斥量的规则进行阻塞,
#include <iostream>
+#include <thread>
+#include <mutex>
+
+std::recursive_mutex mtx;
+
+void recursive_function(int count) {
+ // 递归函数,每次递归都会锁定互斥量
+ mtx.lock();
+ std::cout << "Locked by thread: " << std::this_thread::get_id() << ", count: " << count << std::endl;
+ if (count > 0) {
+ recursive_function(count - 1); // 递归调用
+ }
+ mtx.unlock(); // 解锁互斥量
+}
+
+int main() {
+ std::thread t1(recursive_function, 3);
+ std::thread t2(recursive_function, 2);
+
+ t1.join();
+ t2.join();
+}
+
我们重点的强调了一下这两个成员函数的这个概念,其实也很简单,总而言之就是 unlock
必须和 lock
的调用次数一样,才会真正解锁互斥量。
同样的,我们也可以使用 std::lock_guard
、std::unique_lock
帮我们管理 std::recursive_mutex
,而非显式调用 lock
与 unlock
:
void recursive_function(int count) {
+ std::lock_guard<std::recursive_mutex>lc{ mtx };
+ std::cout << "Locked by thread: " << std::this_thread::get_id() << ", count: " << count << std::endl;
+ if (count > 0) {
+ recursive_function(count - 1);
+ }
+}
+
所以以下函数在多线程运行是线程安全的:
void f(){
+ T* p = new T{};
+ delete p;
+}
+
内存分配、释放操作是线程安全,构造和析构不涉及共享资源。而局部对象 p
对于每个线程来说是独立的。换句话说,每个线程都有其自己的 p
对象实例,因此它们不会共享同一个对象,自然没有数据竞争。
如果 p
是全局对象(或者外部的,只要可被多个线程读写),多个线程同时对其进行访问和修改时,就可能会导致数据竞争和未定义行为。因此,确保全局对象的线程安全访问通常需要额外的同步措施,比如互斥量或原子操作。
T* p = nullptr;
+void f(){
+ p = new T{}; // 存在数据竞争
+ delete p;
+}
+
即使 p
是局部对象,如果构造函数(析构同理)涉及读写共享资源,那么一样存在数据竞争,需要进行额外的同步措施进行保护。
int n = 1;
+
+struct X{
+ X(int v){
+ ::n += v;
+ }
+};
+
+void f(){
+ X* p = new X{ 1 }; // 存在数据竞争
+ delete p;
+}
+
值得注意的是,如果是自己重载 operator new
、operator delete
替换了库的全局版本,那么它的线程安全就要我们来保证。
// 全局的 new 运算符,替换了库的版本
+void* operator new (std::size_t count){
+ return ::operator new(count);
+}
+
以上代码是线程安全的,因为 C++11 保证了 new 运算符的库版本,即 ::operator new
是线程安全的,我们直接调用它自然不成问题。如果你需要更多的操作,就得使用互斥量之类的方式保护了。
总而言之,new
表达式线程安全要考虑三方面:operator new
、构造函数、修改指针。
delete
表达式线程安全考虑两方面:operator delete
、析构函数。
C++ 只保证了 operator new
、operator delete
这两个方面的线程安全(不包括用户定义的),其它方面就得自己保证了。前面的内容也都提到了。
本章讨论了多线程的共享数据引发的恶性条件竞争会带来的问题。并说明了可以使用互斥量(std::mutex
)保护共享数据,并且要注意互斥量上锁的“粒度”。C++标准库提供了很多工具,包括管理互斥量的管理类(std::lock_guard
),但是互斥量只能解决它能解决的问题,并且它有自己的问题(死锁)。同时我们讲述了一些避免死锁的方法和技术。还讲了一下互斥量所有权转移。然后讨论了面对不同情况保护共享数据的不同方式,使用 std::call_once()
保护共享数据的初始化过程,使用读写锁(std::shared_mutex
)保护不常更新的数据结构。以及特殊情况可能用到的互斥量 recursive_mutex
,有些人可能喜欢称作:递归锁。最后聊了一下 new
、delete
运算符的库函数实际是线程安全的,以及一些问题。
本章节主要内容:
\\n多线程共享数据的问题
\\n使用互斥量保护共享数据
\\n保护共享数据的其它方案
\\n在上一章内容,我们对于线程的基本使用和管理,可以说已经比较了解了,甚至深入阅读了部分的 std::thread
源码。所以如果你好好学习了上一章,本章也完全不用担心。
我们本章,就要开始聊共享数据的那些事。
\\n在多线程的情况下,每个线程都抢着完成自己的任务。在大多数情况下,即使会改变执行顺序,也是良性竞争,这是无所谓的。比如两个线程都要往标准输出输出一段字符,谁先谁后并不会有什么太大影响。
"}');export{ot as comp,pt as data}; diff --git "a/assets/04\345\220\214\346\255\245\346\223\215\344\275\234.html-DuqBIYTo.js" "b/assets/04\345\220\214\346\255\245\346\223\215\344\275\234.html-DuqBIYTo.js" new file mode 100644 index 00000000..ec7cb070 --- /dev/null +++ "b/assets/04\345\220\214\346\255\245\346\223\215\344\275\234.html-DuqBIYTo.js" @@ -0,0 +1,382 @@ +import{_ as o}from"./plugin-vue_export-helper-DlAUqK2U.js";import{r as e,o as c,c as l,a as n,b as s,d as t,e as p}from"./app-Oub5ASTw.js";const u={},i=p('"同步操作"是指在计算机科学和信息技术中的一种操作方式,其中不同的任务或操作按顺序执行,一个操作完成后才能开始下一个操作。在同步操作中,各个任务之间通常需要相互协调和等待,以确保数据的一致性和正确性。
本章的主要内容有:
条件变量
std::future
等待异步任务
在规定时间内等待
本章将讨论如何使用条件变量等待事件,介绍 future 等标准库设施用作同步操作。
假设你正在一辆夜间运行的地铁上,那么你要如何在正确的站点下车呢?
一直不休息,每一站都能知道,这样就不会错过你要下车的站点,但是这会很疲惫。
可以看一下时间,估算一下地铁到达目的地的时间,然后设置一个稍早的闹钟,就休息。这个方法听起来还行,但是你可能被过早的叫醒,甚至估算错误导致坐过站,又或者闹钟没电了睡过站。
事实上最简单的方式是,到站的时候有人或者其它东西能将你叫醒(比如手机的地图,到达设置的位置就提醒)。
bool flag = false;
+std::mutex m;
+
+void wait_for_flag(){
+ std::unique_lock<std::mutex>lk{ m };
+ while (!flag){
+ lk.unlock(); // 1 解锁互斥量
+ lk.lock(); // 2 上锁互斥量
+ }
+}
+
第二种方法就是加个延时,这种实现进步了很多,减少浪费的执行时间,但很难确定正确的休眠时间。这会影响到程序的行为,在需要快速响应的程序中就意味着丢帧或错过了一个时间片。循环中,休眠②前函数对互斥量解锁①,再休眠结束后再对互斥量上锁,让另外的线程有机会获取锁并设置标识(因为修改函数和等待函数共用一个互斥量)。
void wait_for_flag(){
+ std::unique_lock<std::mutex>lk{ m };
+ while (!flag){
+ lk.unlock(); // 1 解锁互斥量
+ std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 2 休眠
+ lk.lock(); // 3 上锁互斥量
+ }
+}
+
第三种方式(也是最好的)实际上就是使用条件变量了。通过另一线程触发等待事件的机制是最基本的唤醒方式,这种机制就称为“条件变量”。
`,4),v={href:"https://zh.cppreference.com/w/cpp/thread/condition_variable",target:"_blank",rel:"noopener noreferrer"},m=n("code",null,"std::condition_variable",-1),b={href:"https://zh.cppreference.com/w/cpp/thread/condition_variable_any",target:"_blank",rel:"noopener noreferrer"},_=n("code",null,"std::condition_variable_any",-1),h={href:"https://zh.cppreference.com/w/cpp/header/condition_variable",target:"_blank",rel:"noopener noreferrer"},f=n("code",null,"std::mutex mtx;
+std::condition_variable cv;
+bool arrived = false;
+
+void waitForArrival() {
+ std::unique_lock<std::mutex> lck(mtx);
+ cv.wait(lck, []{ return arrived; }); // 等待 arrived 变为 true
+ std::cout << "到达目的地,可以下车了!" << std::endl;
+}
+
+void simulateArrival() {
+ std::this_thread::sleep_for(std::chrono::seconds(5)); // 模拟地铁到站,假设5秒后到达目的地
+ {
+ std::lock_guard<std::mutex> lck(mtx);
+ arrived = true; // 设置条件变量为 true,表示到达目的地
+ }
+ cv.notify_one(); // 通知等待的线程
+}
+
std::mutex mtx
: 创建了一个互斥量,用于保护共享数据的访问,确保在多线程环境下的数据同步。
std::condition_variable cv
: 创建了一个条件变量,用于线程间的同步,当条件不满足时,线程可以等待,直到条件满足时被唤醒。
bool arrived = false
: 设置了一个标志位,表示是否到达目的地。
在 waitForArrival
函数中:
std::unique_lock<std::mutex> lck(mtx)
: 使用互斥量创建了一个独占锁。
cv.wait(lck, []{ return arrived; })
: 阻塞当前线程,释放(unlock)锁,直到条件被满足。
一旦条件满足,即 arrived
变为 true,并且条件变量 cv
被唤醒(包括虚假唤醒),那么当前线程会重新获取锁(lock),并执行后续的操作。
在 simulateArrival
函数中:
std::this_thread::sleep_for(std::chrono::seconds(5))
: 模拟地铁到站,暂停当前线程 5 秒。
设置 arrived
为 true,表示到达目的地。
cv.notify_one()
: 唤醒一个等待条件变量的线程。
这样,当 simulateArrival
函数执行后,arrived
被设置为 true,并且通过 cv.notify_one()
唤醒了等待在条件变量上的线程,从而使得 waitForArrival
函数中的等待结束,可以执行后续的操作,即输出提示信息。
void wait(std::unique_lock<std::mutex>& lock); // 1
+
+template<class Predicate>
+void wait(std::unique_lock<std::mutex>& lock, Predicate pred); // 2
+
②等价于:
while (!pred())
+ wait(lock);
+
条件变量虚假唤醒是指在使用条件变量进行线程同步时,有时候线程可能会在没有收到通知的情况下被唤醒。问题取决于程序和系统的具体实现。解决方法很简单,在循环中等待并判断条件可一并解决。使用 C++ 标准库则没有这个烦恼了。
在本节中,我们介绍了一个更为复杂的示例,以巩固我们对条件变量的学习。为了实现一个线程安全的队列,我们需要考虑以下两个关键点:
当执行 push
操作时,需要确保没有其他线程正在执行 push
或 pop
操作;同样,在执行 pop
操作时,也需要确保没有其他线程正在执行 push
或 pop
操作。
当队列为空时,不应该执行 pop
操作。因此,我们需要使用条件变量来传递一个谓词,以确保在执行 pop
操作时队列不为空。
基于以上思考,我们设计了一个名为 threadsafe_queue
的模板类,如下:
template<typename T>
+class threadsafe_queue {
+ mutable std::mutex m; // 互斥量,用于保护队列操作的独占访问
+ std::condition_variable data_cond; // 条件变量,用于在队列为空时等待
+ std::queue<T> data_queue; // 实际存储数据的队列
+public:
+ threadsafe_queue() {}
+ void push(T new_value) {
+ {
+ std::lock_guard<std::mutex>lk(m);
+ data_queue.push(new_value);
+ }
+ data_cond.notify_one();
+ }
+ // 从队列中弹出元素(阻塞直到队列不为空)
+ void pop(T& value) {
+ std::unique_lock<std::mutex>lk(m);
+ data_cond.wait(lk, [this] {return !data_queue.empty(); });
+ value = data_queue.front();
+ data_queue.pop();
+ }
+ // 从队列中弹出元素(阻塞直到队列不为空),并返回一个指向弹出元素的 shared_ptr
+ std::shared_ptr<T> pop() {
+ std::unique_lock<std::mutex>lk(m);
+ data_cond.wait(lk, [this] {return !data_queue.empty(); });
+ std::shared_ptr<T>res(std::make_shared<T>(data_queue.front()));
+ data_queue.pop();
+ return res;
+ }
+ bool empty()const {
+ std::lock_guard<std::mutex>lk(m);
+ return data_queue.empty();
+ }
+};
+
请无视我们省略的构造、赋值、交换、try_xx
等操作。以上示例已经足够。
光写好了肯定不够,我们还得测试运行,我们可以写一个经典的:”生产者消费者模型“,也就是一个线程 push
”生产“,一个线程 pop
”消费“。
void producer(threadsafe_queue<int>& q) {
+ for (int i = 0; i < 5; ++i) {
+ q.push(i);
+ }
+}
+void consumer(threadsafe_queue<int>& q) {
+ for (int i = 0; i < 5; ++i) {
+ int value{};
+ q.pop(value);
+ }
+}
+
两个线程分别运行 producer
与 consumer
,为了观测运行我们可以为 push
与 pop
中增加打印语句:
std::cout << "push:" << new_value << std::endl;
+std::cout << "pop:" << value << std::endl;
+
push:0
+pop:0
+push:1
+pop:1
+push:2
+push:3
+push:4
+pop:2
+pop:3
+pop:4
+
这很正常,到底哪个线程会抢到 CPU 时间片持续运行,是系统调度决定的,我们只需要保证一开始提到的两点就行了:
push
与pop
都只能单独执行;当队列为空时,不执行pop
操作。
我们可以给一个简单的示意图帮助你理解这段运行结果:
初始状态:队列为空
++---+---+---+---+---+
+
+Producer 线程插入元素 0:
++---+---+---+---+---+
+| 0 | | | | |
+
+Consumer 线程弹出元素 0:
++---+---+---+---+---+
+| | | | | |
+
+Producer 线程插入元素 1:
++---+---+---+---+---+
+| 1 | | | | |
+
+Consumer 线程弹出元素 1:
++---+---+---+---+---+
+| | | | | |
+
+Producer 线程插入元素 2:
++---+---+---+---+---+
+| | 2 | | | |
+
+Producer 线程插入元素 3:
++---+---+---+---+---+
+| | 2 | 3 | | |
+
+Producer 线程插入元素 4:
++---+---+---+---+---+
+| | 2 | 3 | 4 | |
+
+Consumer 线程弹出元素 2:
++---+---+---+---+---+
+| | | 3 | 4 | |
+
+Consumer 线程弹出元素 3:
++---+---+---+---+---+
+| | | | 4 | |
+
+Consumer 线程弹出元素 4:
++---+---+---+---+---+
+| | | | | |
+
+队列为空,所有元素已被弹出
+
到此,也就可以了。
future
其实就是异步。
`,8),H={href:"https://github.com/Mq-b/Modern-Cpp-templates-tutorial",target:"_blank",rel:"noopener noreferrer"},Q={href:"https://space.bilibili.com/1292761396",target:"_blank",rel:"noopener noreferrer"},W=n("em",null,[n("strong",null,"车到站")],-1),Z={href:"https://zh.cppreference.com/w/cpp/thread#.E6.9C.AA.E6.9D.A5.E4.BD.93",target:"_blank",rel:"noopener noreferrer"},$={href:"https://zh.cppreference.com/w/cpp/header/future",target:"_blank",rel:"noopener noreferrer"},nn=n("code",null,"#include <iostream>
+#include <thread>
+#include <future>
+
+int task(int n){
+ std::cout << "异步任务 ID: " << std::this_thread::get_id() << '\\n';
+ return n * n;
+}
+
+int main(){
+ std::future<int> future = std::async(task, 10);
+ std::cout << "main\\n";
+ std::cout << future.get() << '\\n';
+}
+
struct X{
+ int operator()(int n)const{
+ return n * n;
+ }
+};
+struct Y{
+ int f(int n)const{
+ return n * n;
+ }
+};
+void f(int& p) { std::cout << &p << '\\n'; }
+
+int main(){
+ Y y;
+ int n = 0;
+ auto t1 = std::async(X{}, 10);
+ auto t2 = std::async(&Y::f,&y,10);
+ auto t3 = std::async([] {});
+ auto t4 = std::async(f, std::ref(n));
+ std::cout << &n << '\\n';
+}
+
void f(const int& p) {}
+void f2(int& p ){}
+
+int n = 0;
+std::async(f, n); // OK! 可以通过编译,不过引用的并非是局部的n
+std::async(f2, n); // Error! 无法通过编译
+
我们来展示使用 std::move
,也就移动传递参数:
struct move_only {
+ move_only() { std::puts("默认构造"); }
+ move_only(const move_only&) = delete;
+ move_only(move_only&&)noexcept {
+ std::puts("移动构造");
+ }
+};
+
+void task(move_only x){
+ std::cout << "异步任务 ID: " << std::this_thread::get_id() << '\\n';
+}
+
+int main(){
+ move_only x;
+ std::future<void> future = std::async(task, std::move(x));
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
+ std::cout << "main\\n";
+ future.wait(); // 等待异步任务执行完毕
+}
+
如你所见,它支持只移动类型,我们将参数使用 std::move
传递。
接下来我们聊 std::async
的执行策略,我们前面一直没有使用,其实就是在传递可调用对象与参数之前传递枚举值罢了:
std::launch::async
在不同线程上执行异步任务。std::launch::deferred
惰性求值,不创建线程,等待 future
对象调用 wait
或 get
成员函数的时候执行任务。而我们先前没有写明这个参数,实际上是默认:std::launch::async | std::launch::deferred
,也就是说由实现选择到底是否创建线程执行异步任务。我们来展示一下:
void f(){
+ std::cout << std::this_thread::get_id() << '\\n';
+}
+
+int main(){
+ std::cout << std::this_thread::get_id() << '\\n';
+ auto f1 = std::async(std::launch::deferred, f);
+ f1.wait(); // 在 wait 或 get() 调用时执行,不创建线程
+ auto f2 = std::async(std::launch::async,f); // 创建线程执行异步任务
+ auto f3 = std::async(std::launch::deferred | std::launch::async, f); // 实现选择的执行方式
+}
+
std::async(std::launch::async, []{ f(); }); // 临时量的析构函数等待 f()
+std::async(std::launch::async, []{ g(); }); // f() 完成前不开始
+
如你所见,这并不能创建异步任务,会堵塞,然后逐个执行。
`,2),Wn=p(`被移动的 std::future
没有所有权,失去共享状态,不能调用 get
、wait
成员函数。
auto t = std::async([] {});
+std::future<void> future{ std::move(t) };
+t.wait(); // Error! 抛出异常
+
如同没有线程资源所有权的 std::thread
对象调用 join()
一样错误,这是移动语义的基本语义逻辑。
通常它会和 std::future
一起使用,不过也可以单独使用,我们一步一步来:
std::packaged_task<double(int, int)> task([](int a, int b){
+ return std::pow(a, b);
+});
+task(10, 2); // 执行传递的 lambda,但无法获取返回值
+
如果想要异步的获取返回值,我们需要在调用 operator()
之前,让它和 future 关联,然后使用 future.get()
,也就是:
std::packaged_task<double(int, int)> task([](int a, int b){
+ return std::pow(a, b);
+});
+std::future<double>future = task.get_future();
+task(10, 2); // 此处执行任务
+std::cout << future.get() << '\\n'; // 不堵塞,此处获取返回值
+
先关联任务,再执行任务,当我们想要获取任务的返回值的时候,就 future.get()
即可。值得注意的是,任务并不会在线程中执行,想要在线程中执行异步任务,然后再获取返回值,我们可以这么做:
std::packaged_task<double(int, int)> task([](int a, int b){
+ return std::pow(a, b);
+});
+std::future<double>future = task.get_future();
+std::thread t{ std::move(task),10,2 }; // 任务在线程中执行
+t.join();
+
+std::cout << future.get() << '\\n'; // 并不堵塞,获取任务返回值罢了
+
因为 task
本身是重载了 operator()
的,是可调用对象,自然可以传递给 std::thread
执行,以及传递调用参数。唯一需要注意的是我们使用了 std::move
,这是因为 std::packaged_task
只能移动,不能复制。
简而言之,其实 std::packaged_task
也就是一个“包装”类而已,它本身并没什么特殊的,老老实实执行我们传递的任务,且方便我们获取返回值罢了,明确这一点,那么一切都不成问题。
std::packaged_task
也可以在线程中传递,在需要的时候获取返回值,而非像上面那样将它自己作为可调用对象:
template<typename R, typename...Ts, typename...Args>
+ requires std::invocable<std::packaged_task<R(Ts...)>&, Args...>
+void async_task(std::packaged_task<R(Ts...)>& task, Args&&...args) {
+ // todo..
+ task(std::forward<Args>(args)...);
+}
+
+int main() {
+ std::packaged_task<int(int,int)> task([](int a,int b){
+ return a + b;
+ });
+
+ int value = 50;
+ std::future<int> future = task.get_future();
+ // 创建一个线程来执行异步任务
+ std::thread t{ [&] {async_task(task, value, value); } };
+ std::cout << future.get() << '\\n';
+ t.join();
+}
+
我们套了一个 lambda,这是因为函数模板不是函数,它并非具体类型,没办法直接被那样传递使用,只能包一层了。这只是一个简单的示例,展示可以使用 std::packaged_task
作函数形参,然后我们来传递任务进行异步调用等操作。
我们再将第二章实现的并行 sum
改成 std::package_task
+ std::future
的形式:
template<typename ForwardIt>
+auto sum(ForwardIt first, ForwardIt last) {
+ using value_type = std::iter_value_t<ForwardIt>;
+ std::size_t num_threads = std::thread::hardware_concurrency();
+ std::ptrdiff_t distance = std::distance(first, last);
+
+ if (distance > 1024000) {
+ // 计算每个线程处理的元素数量
+ std::size_t chunk_size = distance / num_threads;
+ std::size_t remainder = distance % num_threads;
+
+ // 存储每个线程要执行的任务
+ std::vector<std::packaged_task<value_type()>>tasks;
+ // 和每一个任务进行关联的 future 用于获取返回值
+ std::vector<std::future<value_type>>futures(num_threads);
+
+ // 存储关联线程的线程对象
+ std::vector<std::thread> threads;
+
+ // 制作任务、与 future 关联、启动线程执行
+ auto start = first;
+ for (std::size_t i = 0; i < num_threads; ++i) {
+ auto end = std::next(start, chunk_size + (i < remainder ? 1 : 0));
+ tasks.emplace_back(std::packaged_task<value_type()>{[start, end, i] {
+ return std::accumulate(start, end, value_type{});
+ }});
+ start = end; // 开始迭代器不断向前
+ futures[i] = tasks[i].get_future(); // 任务与 std::future 关联
+ threads.emplace_back(std::move(tasks[i]));
+ }
+
+ // 等待所有线程执行完毕
+ for (auto& thread : threads)
+ thread.join();
+
+ // 汇总线程的计算结果
+ value_type total_sum {};
+ for (std::size_t i = 0; i < num_threads; ++i) {
+ total_sum += futures[i].get();
+ }
+ return total_sum;
+ }
+
+ value_type total_sum = std::accumulate(first, last, value_type{});
+ return total_sum;
+}
+
// 计算函数,接受一个整数并返回它的平方
+void calculate_square(std::promise<int> promiseObj, int num) {
+ // 模拟一些计算
+ std::this_thread::sleep_for(std::chrono::seconds(1));
+
+ // 计算平方并设置值到 promise 中
+ promiseObj.set_value(num * num);
+}
+
+// 创建一个 promise 对象,用于存储计算结果
+std::promise<int> promise;
+
+// 从 promise 获取 future 对象进行关联
+std::future<int> future = promise.get_future();
+
+// 启动一个线程进行计算
+int num = 5;
+std::thread t(calculate_square, std::move(promise), num);
+
+// 阻塞,直到结果可用
+int result = future.get();
+std::cout << num << " 的平方是:" << result << std::endl;
+
+t.join();
+
void throw_function(std::promise<int> prom) {
+ try {
+ throw std::runtime_error("一个异常");
+ }
+ catch (...) {
+ prom.set_exception(std::current_exception());
+ }
+}
+
+int main() {
+ std::promise<int> prom;
+ std::future<int> fut = prom.get_future();
+
+ std::thread t(throw_function, std::move(prom));
+
+ try {
+ std::cout << "等待线程执行,抛出异常并设置\\n";
+ fut.get();
+ }
+ catch (std::exception& e) {
+ std::cerr << "来自线程的异常: " << e.what() << '\\n';
+ }
+ t.join();
+}
+
等待线程执行,抛出异常并设置
+来自线程的异常: 一个异常
+
你可能对这段代码还有一些疑问:我们写的是 promised<int>
,但是却没有使用 set_value
设置值,你可能会想着再写一行 prom.set_value(0)
?
简而言之,set_value
与 set_exception
二选一,如果先前调用了 set_value
,就不可再次调用 set_exception
,反之亦然(不然就会抛出异常),示例如下:
void throw_function(std::promise<int> prom) {
+ prom.set_value(100);
+ try {
+ throw std::runtime_error("一个异常");
+ }
+ catch (...) {
+ try{
+ // 共享状态的 promise 已存储值,调用 set_exception 产生异常
+ prom.set_exception(std::current_exception());
+ }catch (std::exception& e){
+ std::cerr << "来自 set_exception 的异常: " << e.what() << '\\n';
+ }
+ }
+}
+
+int main() {
+ std::promise<int> prom;
+ std::future<int> fut = prom.get_future();
+
+ std::thread t(throw_function, std::move(prom));
+
+ std::cout << "等待线程执行,抛出异常并设置\\n";
+ std::cout << "值:" << fut.get() << '\\n'; // 100
+
+ t.join();
+}
+
等待线程执行,抛出异常并设置
+值:100
+来自 set_exception 的异常: promise already satisfied
+
之前的例子中都在用 std::future
,不过 std::future
也有局限性。很多线程在等待的时候,只有一个线程能获取结果。当多个线程等待相同事件的结果时,就需要使用 std::shared_future
来替代 std::future
了。
\\"同步操作\\"是指在计算机科学和信息技术中的一种操作方式,其中不同的任务或操作按顺序执行,一个操作完成后才能开始下一个操作。在同步操作中,各个任务之间通常需要相互协调和等待,以确保数据的一致性和正确性。
\\n本章的主要内容有:
\\n条件变量
\\nstd::future
等待异步任务
在规定时间内等待
\\n本章将讨论如何使用条件变量等待事件,介绍 future 等标准库设施用作同步操作。
"}');export{ba as comp,_a as data}; diff --git a/assets/404.html-PJlRDmY_.js b/assets/404.html-PJlRDmY_.js new file mode 100644 index 00000000..66b185c1 --- /dev/null +++ b/assets/404.html-PJlRDmY_.js @@ -0,0 +1 @@ +import{_ as t}from"./plugin-vue_export-helper-DlAUqK2U.js";import{o as e,c as o,a as n}from"./app-Oub5ASTw.js";const a={},r=n("p",null,"404 Not Found",-1),s=[r];function c(l,_){return e(),o("div",null,s)}const m=t(a,[["render",c],["__file","404.html.vue"]]),p=JSON.parse('{"path":"/404.html","title":"","lang":"zh-CN","frontmatter":{"layout":"NotFound"},"headers":[],"git":{},"readingTime":{"minutes":0.01,"words":3},"filePathRelative":null,"excerpt":"404 Not Found
\\n"}');export{m as comp,p as data}; diff --git a/assets/SUMMARY.html-D8dQ4wSd.js b/assets/SUMMARY.html-D8dQ4wSd.js new file mode 100644 index 00000000..07bc726d --- /dev/null +++ b/assets/SUMMARY.html-D8dQ4wSd.js @@ -0,0 +1 @@ +import{_ as a}from"./plugin-vue_export-helper-DlAUqK2U.js";import{r as n,o,c as d,a as t,d as E,w as r,b as e}from"./app-Oub5ASTw.js";const m={},i=t("h1",{id:"summary",tabindex:"-1"},[t("a",{class:"header-anchor",href:"#summary"},[t("span",null,"Summary")])],-1),u=t("code",null,"std::thread",-1),A=t("code",null,"std::scoped_lock",-1),c=t("code",null,"std::async",-1),s=t("code",null,"std::future",-1);function B(_,h){const l=n("RouteLink");return o(),d("div",null,[i,t("ul",null,[t("li",null,[E(l,{to:"/"},{default:r(()=>[e("介绍")]),_:1})]),t("li",null,[E(l,{to:"/md/01%E5%9F%BA%E6%9C%AC%E6%A6%82%E5%BF%B5.html"},{default:r(()=>[e("基本概念")]),_:1})]),t("li",null,[E(l,{to:"/md/02%E4%BD%BF%E7%94%A8%E7%BA%BF%E7%A8%8B.html"},{default:r(()=>[e("使用线程")]),_:1})]),t("li",null,[E(l,{to:"/md/03%E5%85%B1%E4%BA%AB%E6%95%B0%E6%8D%AE.html"},{default:r(()=>[e("共享数据")]),_:1})]),t("li",null,[E(l,{to:"/md/04%E5%90%8C%E6%AD%A5%E6%93%8D%E4%BD%9C.html"},{default:r(()=>[e("同步操作")]),_:1})]),t("li",null,[E(l,{to:"/md/%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/"},{default:r(()=>[e("详细分析")]),_:1}),t("ul",null,[t("li",null,[E(l,{to:"/md/%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/01thread%E7%9A%84%E6%9E%84%E9%80%A0%E4%B8%8E%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.html"},{default:r(()=>[u,e(" 的构造-源码解析")]),_:1})]),t("li",null,[E(l,{to:"/md/%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/02scoped_lock%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.html"},{default:r(()=>[A,e(" 的源码实现与解析")]),_:1})]),t("li",null,[E(l,{to:"/md/%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/03async%E4%B8%8Efuture%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.html"},{default:r(()=>[c,e(" 与 "),s,e(" 源码解析")]),_:1})])])])])])}const g=a(m,[["render",B],["__file","SUMMARY.html.vue"]]),C=JSON.parse('{"path":"/SUMMARY.html","title":"Summary","lang":"zh-CN","frontmatter":{},"headers":[],"git":{"createdTime":1710500650000,"updatedTime":1713323227000,"contributors":[{"name":"归故里","email":"3326284481@qq.com","commits":7}]},"readingTime":{"minutes":0.4,"words":121},"filePathRelative":"SUMMARY.md","localizedDate":"2024年3月15日","excerpt":"\\n"}');export{g as comp,C as data}; diff --git a/assets/SearchResult-4y_nt7Ph.js b/assets/SearchResult-4y_nt7Ph.js new file mode 100644 index 00000000..83a29988 --- /dev/null +++ b/assets/SearchResult-4y_nt7Ph.js @@ -0,0 +1 @@ +import{u as U,f as se,g as te,h as M,i as ae,P as le,t as re,j as ue,k as L,l as k,m as ie,n as Y,p as t,q as oe,R as _,s as ne,v as ce,x as ve,C as de,y as pe,z as he,A as ye,B as Ee,D as me,E as Ae,F as ge,G as $,H as j,I as Be,J as Q,K as fe}from"./app-Oub5ASTw.js";const He=["/","/SUMMARY.html","/md/01%E5%9F%BA%E6%9C%AC%E6%A6%82%E5%BF%B5.html","/md/02%E4%BD%BF%E7%94%A8%E7%BA%BF%E7%A8%8B.html","/md/03%E5%85%B1%E4%BA%AB%E6%95%B0%E6%8D%AE.html","/md/04%E5%90%8C%E6%AD%A5%E6%93%8D%E4%BD%9C.html","/image/%E6%8D%90%E8%B5%A0/","/md/%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/01thread%E7%9A%84%E6%9E%84%E9%80%A0%E4%B8%8E%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.html","/md/%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/02scoped_lock%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.html","/md/%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/03async%E4%B8%8Efuture%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.html","/md/%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/","/404.html","/md/","/image/"],Re="SEARCH_PRO_QUERY_HISTORY",m=U(Re,[]),ke=()=>{const{queryHistoryCount:a}=Q,l=a>0;return{enabled:l,queryHistory:m,addQueryHistory:r=>{l&&(m.value=Array.from(new Set([r,...m.value.slice(0,a-1)])))},removeQueryHistory:r=>{m.value=[...m.value.slice(0,r),...m.value.slice(r+1)]}}},b=a=>He[a.id]+("anchor"in a?`#${a.anchor}`:""),Qe="SEARCH_PRO_RESULT_HISTORY",{resultHistoryCount:I}=Q,A=U(Qe,[]),we=()=>{const a=I>0;return{enabled:a,resultHistory:A,addResultHistory:l=>{if(a){const r={link:b(l),display:l.display};"header"in l&&(r.header=l.header),A.value=[r,...A.value.slice(0,I-1)]}},removeResultHistory:l=>{A.value=[...A.value.slice(0,l),...A.value.slice(l+1)]}}},Ce=a=>{const l=de(),r=M(),w=pe(),i=L(0),f=k(()=>i.value>0),h=he([]);return ye(()=>{const{search:y,terminate:C}=Ee(),g=Be(c=>{const B=c.join(" "),{searchFilter:D=p=>p,splitWord:q,suggestionsFilter:P,...E}=l.value;B?(i.value+=1,y(c.join(" "),r.value,E).then(p=>D(p,B,r.value,w.value)).then(p=>{i.value-=1,h.value=p}).catch(p=>{console.warn(p),i.value-=1,i.value||(h.value=[])})):h.value=[]},Q.searchDelay-Q.suggestDelay);Y([a,r],([c])=>g(c),{immediate:!0}),me(()=>{C()})}),{isSearching:f,results:h}};var qe=se({name:"SearchResult",props:{queries:{type:Array,required:!0},isFocusing:Boolean},emits:["close","updateQuery"],setup(a,{emit:l}){const r=te(),w=M(),i=ae(le),{enabled:f,addQueryHistory:h,queryHistory:y,removeQueryHistory:C}=ke(),{enabled:g,resultHistory:c,addResultHistory:B,removeResultHistory:D}=we(),q=f||g,P=re(a,"queries"),{results:E,isSearching:p}=Ce(P),u=ue({isQuery:!0,index:0}),v=L(0),d=L(0),O=k(()=>q&&(y.value.length>0||c.value.length>0)),x=k(()=>E.value.length>0),F=k(()=>E.value[v.value]||null),z=()=>{const{isQuery:e,index:s}=u;s===0?(u.isQuery=!e,u.index=e?c.value.length-1:y.value.length-1):u.index=s-1},G=()=>{const{isQuery:e,index:s}=u;s===(e?y.value.length-1:c.value.length-1)?(u.isQuery=!e,u.index=0):u.index=s+1},J=()=>{v.value=v.value>0?v.value-1:E.value.length-1,d.value=F.value.contents.length-1},K=()=>{v.value=v.value放一些详细分析源码实现之类的内容。
\\n"}');export{h as comp,p as data}; diff --git a/assets/index.html-BTzaSACv.js b/assets/index.html-BTzaSACv.js new file mode 100644 index 00000000..c4246b82 --- /dev/null +++ b/assets/index.html-BTzaSACv.js @@ -0,0 +1 @@ +import{_ as t}from"./plugin-vue_export-helper-DlAUqK2U.js";import{r as a,o,c as n,d as r}from"./app-Oub5ASTw.js";const l={};function s(c,i){const e=a("Catalog");return o(),n("div",null,[r(e)])}const _=t(l,[["render",s],["__file","index.html.vue"]]),f=JSON.parse('{"path":"/md/","title":"Md","lang":"zh-CN","frontmatter":{"title":"Md","article":false,"feed":false,"sitemap":false},"headers":[],"git":{},"readingTime":{"minutes":0,"words":1},"filePathRelative":null,"excerpt":""}');export{_ as comp,f as data}; diff --git a/assets/index.html-Cr_1xWLb.js b/assets/index.html-Cr_1xWLb.js new file mode 100644 index 00000000..b3c36bfc --- /dev/null +++ b/assets/index.html-Cr_1xWLb.js @@ -0,0 +1 @@ +import{_ as o}from"./plugin-vue_export-helper-DlAUqK2U.js";import{r as a,o as s,c as l,a as e,b as r,d as t}from"./app-Oub5ASTw.js";const c="/ModernCpp-ConcurrentProgramming-Tutorial/image/%E7%8E%B0%E4%BB%A3C++%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B%E6%95%99%E7%A8%8B.png",i={},p={align:"center"},h=e("a",{herf:"https://zh.cppreference.com/w/cpp/thread"},[e("img",{src:c,width:"512px",alt:"cpp"})],-1),m=e("h1",{id:"现代c-并发编程教程",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#现代c-并发编程教程"},[e("span",null,"现代C++并发编程教程")])],-1),d=e("p",null,[r("本仓库用来存放 B 站课程"),e("a",{href:""},"《现代 C++ 并发编程教程》"),r("的教案、代码。")],-1),_={href:"https://creativecommons.org/licenses/by-nc-nd/4.0/deed.zh-hans",target:"_blank",rel:"noopener noreferrer"},u={href:"/image/%E6%8D%90%E8%B5%A0",target:"_blank",rel:"noopener noreferrer"},g={href:"https://github.com/Mq-b/ModernCpp-ConcurrentProgramming-Tutorial/issues",target:"_blank",rel:"noopener noreferrer"},f={href:"https://github.com/Mq-b/ModernCpp-ConcurrentProgramming-Tutorial/pulls",target:"_blank",rel:"noopener noreferrer"},C=e("strong",null,"铭记您的贡献",-1),b=e("hr",null,null,-1),E=e("p",null,[r(" 国内的 C++ 并发编程的教程并不稀少,不管是书籍、博客、视频。然而大多数是粗糙的、不够准确、复杂的。而我们想以更加"),e("strong",null,"现代"),r("、"),e("strong",null,"简单"),r("、"),e("strong",null,"准确"),r("的方式进行教学。")],-1),B={href:"https://github.com/Mq-b/Modern-Cpp-templates-tutorial",target:"_blank",rel:"noopener noreferrer"},k=e("strong",null,"现代C++模板教程",-1),M=e("p",null,[r(" 本教程假设开发者的最低水平为:"),e("strong",null,[e("code",null,"C++11 + STL + template")]),r("。")],-1),x=e("p",null," 虽强调现代,但不用担心,我们几乎是从头教学,即使你从来没使用过 C++ 进行多线程编程,也不成问题。",-1),q=e("p",null," 我们希望您的编译器版本和标准尽可能的高,我们的代码均会测试三大编译器 gcc、clang、msvc。需要更高的标准会进行强调。",-1);function v(T,N){const n=a("ExternalLinkIcon");return s(),l("div",null,[e("div",p,[h,m,d,e("p",null,[r("不管是否购买课程,任何组织和个人遵守 "),e("a",_,[r("CC BY-NC-ND 4.0"),t(n)]),r(" 协议均可随意使用学习。")]),e("p",null,[e("a",u,[r("捐赠"),t(n)]),r("、"),e("a",g,[r("issues"),t(n)]),r("、"),e("a",f,[r("pr"),t(n)]),r(" 均会在致谢列表中"),C,r("。")])]),b,E,e("p",null,[r(" 我们在教学中可能常常为您展示部分标准库源码,自己手动实现一些库,这是必须的,希望您是已经较为熟练使用模板(如果没有,可以先学习 "),e("a",B,[k,t(n)]),r(")。阅读源码可以帮助我们更轻松的理解标准库设施的使用与原理。")]),M,x,q])}const D=o(i,[["render",v],["__file","index.html.vue"]]),P=JSON.parse('{"path":"/","title":"现代C++并发编程教程","lang":"zh-CN","frontmatter":{},"headers":[],"git":{"createdTime":1709094546000,"updatedTime":1713362983000,"contributors":[{"name":"归故里","email":"3326284481@qq.com","commits":5},{"name":"mq白","email":"97590219+Mq-b@users.noreply.github.com","commits":2},{"name":"Suzukaze","email":"1027743497@qq.com","commits":1}]},"readingTime":{"minutes":1.35,"words":404},"filePathRelative":"README.md","localizedDate":"2024年2月28日","excerpt":"本仓库用来存放 B 站课程《现代 C++ 并发编程教程》的教案、代码。
\\n不管是否购买课程,任何组织和个人遵守 CC BY-NC-ND 4.0 协议均可随意使用学习。
\\n\\n我们会收集捐赠者进行感谢,所以请您捐赠了可以选择备注,或者联系我,或者直接在捐赠初始记录名单中进行评论。
\\n"}');export{C as comp,b as data}; diff --git a/assets/photoswipe.esm-SzV8tJDW.js b/assets/photoswipe.esm-SzV8tJDW.js new file mode 100644 index 00000000..4048314e --- /dev/null +++ b/assets/photoswipe.esm-SzV8tJDW.js @@ -0,0 +1,4 @@ +/*! + * PhotoSwipe 5.4.3 - https://photoswipe.com + * (c) 2023 Dmytro Semenov + */function f(r,t,i){const e=document.createElement(t);return r&&(e.className=r),i&&i.appendChild(e),e}function p(r,t){return r.x=t.x,r.y=t.y,t.id!==void 0&&(r.id=t.id),r}function M(r){r.x=Math.round(r.x),r.y=Math.round(r.y)}function A(r,t){const i=Math.abs(r.x-t.x),e=Math.abs(r.y-t.y);return Math.sqrt(i*i+e*e)}function x(r,t){return r.x===t.x&&r.y===t.y}function I(r,t,i){return Math.min(Math.max(r,t),i)}function b(r,t,i){let e=`translate3d(${r}px,${t||0}px,0)`;return i!==void 0&&(e+=` scale3d(${i},${i},1)`),e}function y(r,t,i,e){r.style.transform=b(t,i,e)}const $="cubic-bezier(.4,0,.22,1)";function R(r,t,i,e){r.style.transition=t?`${t} ${i}ms ${e||$}`:"none"}function L(r,t,i){r.style.width=typeof t=="number"?`${t}px`:t,r.style.height=typeof i=="number"?`${i}px`:i}function U(r){R(r)}function q(r){return"decode"in r?r.decode().catch(()=>{}):r.complete?Promise.resolve(r):new Promise((t,i)=>{r.onload=()=>t(r),r.onerror=i})}const _={IDLE:"idle",LOADING:"loading",LOADED:"loaded",ERROR:"error"};function G(r){return"button"in r&&r.button===1||r.ctrlKey||r.metaKey||r.altKey||r.shiftKey}function K(r,t,i=document){let e=[];if(r instanceof Element)e=[r];else if(r instanceof NodeList||Array.isArray(r))e=Array.from(r);else{const s=typeof r=="string"?r:t;s&&(e=Array.from(i.querySelectorAll(s)))}return e}function C(){return!!(navigator.vendor&&navigator.vendor.match(/apple/i))}let F=!1;try{window.addEventListener("test",null,Object.defineProperty({},"passive",{get:()=>{F=!0}}))}catch{}class X{constructor(){this._pool=[]}add(t,i,e,s){this._toggleListener(t,i,e,s)}remove(t,i,e,s){this._toggleListener(t,i,e,s,!0)}removeAll(){this._pool.forEach(t=>{this._toggleListener(t.target,t.type,t.listener,t.passive,!0,!0)}),this._pool=[]}_toggleListener(t,i,e,s,n,o){if(!t)return;const a=n?"removeEventListener":"addEventListener";i.split(" ").forEach(l=>{if(l){o||(n?this._pool=this._pool.filter(d=>d.type!==l||d.listener!==e||d.target!==t):this._pool.push({target:t,type:l,listener:e,passive:s}));const c=F?{passive:s||!1}:!1;t[a](l,e,c)}})}}function B(r,t){if(r.getViewportSizeFn){const i=r.getViewportSizeFn(r,t);if(i)return i}return{x:document.documentElement.clientWidth,y:window.innerHeight}}function S(r,t,i,e,s){let n=0;if(t.paddingFn)n=t.paddingFn(i,e,s)[r];else if(t.padding)n=t.padding[r];else{const o="padding"+r[0].toUpperCase()+r.slice(1);t[o]&&(n=t[o])}return Number(n)||0}function N(r,t,i,e){return{x:t.x-S("left",r,t,i,e)-S("right",r,t,i,e),y:t.y-S("top",r,t,i,e)-S("bottom",r,t,i,e)}}class Y{constructor(t){this.slide=t,this.currZoomLevel=1,this.center={x:0,y:0},this.max={x:0,y:0},this.min={x:0,y:0}}update(t){this.currZoomLevel=t,this.slide.width?(this._updateAxis("x"),this._updateAxis("y"),this.slide.pswp.dispatch("calcBounds",{slide:this.slide})):this.reset()}_updateAxis(t){const{pswp:i}=this.slide,e=this.slide[t==="x"?"width":"height"]*this.currZoomLevel,n=S(t==="x"?"left":"top",i.options,i.viewportSize,this.slide.data,this.slide.index),o=this.slide.panAreaSize[t];this.center[t]=Math.round((o-e)/2)+n,this.max[t]=e>o?Math.round(o-e)+n:this.center[t],this.min[t]=e>o?n:this.center[t]}reset(){this.center.x=0,this.center.y=0,this.max.x=0,this.max.y=0,this.min.x=0,this.min.y=0}correctPan(t,i){return I(i,this.max[t],this.min[t])}}const T=4e3;class k{constructor(t,i,e,s){this.pswp=s,this.options=t,this.itemData=i,this.index=e,this.panAreaSize=null,this.elementSize=null,this.fit=1,this.fill=1,this.vFill=1,this.initial=1,this.secondary=1,this.max=1,this.min=1}update(t,i,e){const s={x:t,y:i};this.elementSize=s,this.panAreaSize=e;const n=e.x/s.x,o=e.y/s.y;this.fit=Math.min(1,n国内的 C++ 并发编程的教程并不稀少,不管是书籍、博客、视频。然而大多数是粗糙的、不够准确、复杂的。而我们想以更加现代、简单、准确的方式进行教学。
我们在教学中可能常常为您展示部分标准库源码,自己手动实现一些库,这是必须的,希望您是已经较为熟练使用模板(如果没有,可以先学习 现代C++模板教程)。阅读源码可以帮助我们更轻松的理解标准库设施的使用与原理。
本教程假设开发者的最低水平为:C++11 + STL + template
。
虽强调现代,但不用担心,我们几乎是从头教学,即使你从来没使用过 C++ 进行多线程编程,也不成问题。
我们希望您的编译器版本和标准尽可能的高,我们的代码均会测试三大编译器 gcc、clang、msvc。需要更高的标准会进行强调。
在我们谈起“并发编程”,其实可以直接简单理解为“多线程编程”,我知道你或许有疑问:“那多进程呢?” C++ 语言层面没有进程的概念,并发支持库也不涉及多进程,所以在本教程中,不用在意。
我们完全使用标准 C++ 进行教学。
并发,指两个或两个以上的独立活动同时发生。
并发在生活中随处可见,我们可以一边走路一边说话,也可以两只手同时做不同的动作,又或者一边看电视一边吃零食。
计算机中的并发有两种方式:
多核机器的真正并行。
单核机器的任务切换。
在早期,一些单核机器,它要想并发,执行多个任务,那就只能是任务切换,任务切换会给你一种“好像这些任务都在同时执行”的假象。只有硬件上是多核的,才能进行真正的并行,也就是真正的”同时执行任务“。
在现在,我们日常使用的机器,基本上是二者都有。我们现在的 CPU 基本都是多核,而操作系统调度基本也一样有任务切换,因为要执行的任务非常之多,CPU 是很快的,但是核心却没有那么多,不可能每一个任务都单独给一个核心。大家可以打开自己电脑的任务管理器看一眼,进程至少上百个,线程更是上千。这基本不可能每一个任务分配一个核心,都并行,而且也没必要。正是任务切换使得这些后台任务可以运行,这样系统使用者就可以同时运行文字处理器、编译器、编辑器和 Web 浏览器。
事实上,对于这两个术语,并没有非常公认的说法。
有些人认为二者毫无关系,指代的东西完全不同。
有些人认为二者大多数时候是相同的,只是用于描述一些东西的时候关注点不同。
我喜欢第二种,那我们就讲第二种。
对多线程来说,这两个概念大部分是重叠的。对于很多人来说,它们没有什么区别。 这两个词是用来描述硬件同时执行多个任务的方式:
“并行”更加注重性能。使用硬件提高数据处理速度时,会讨论程序的并行性。
当关注重点在于任务分离或任务响应时,会讨论程序的并发性。
这两个术语存在的目的,就是为了区别多线程中不同的关注点。
概念从来不是我们的重点,尤其是某些说法准确性也一般,假设开发者对操作系统等知识有基本了解。
我们也不打算特别介绍什么 C++ 并发库的历史发展、什么时候你该使用多线程、什么时候不该使用多线程... 类似问题应该是看你自己的,而我们回到代码上即可。
在标准 C++ 中,std::thread
可以指代线程,使用线程也就是使用 std::thread
类。
在我们初学 C++ 的时候应该都写过这样一段代码:
#include <iostream>
+
+int main(){
+ std::cout << "Hello World!" << std::endl;
+}
+
这段代码将"Hello World!"写入到标准输出流,换行并刷新。
我们可以启动一个线程来做这件事情:
#include <iostream>
+#include <thread> // 引入线程支持头文件
+
+void hello(){ // 定义一个函数用作打印任务
+ std::cout << "Hello World" << std::endl;
+}
+
+int main(){
+ std::thread t{ hello };
+ t.join();
+}
+
std::thread t{ hello };
创建了一个线程对象 t
,将 hello
作为它的可调用(Callable)对象,在新线程中执行。线程对象关联了一个线程资源,我们无需手动控制,在线程对象构造成功,就自动在新线程开始执行函数 hello
。
t.join();
等待线程对象 t
关联的线程执行完毕,否则将一直堵塞。这里的调用是必须的,否则 std::thread
的析构函数将调用 std::terminate()
无法正确析构。
这是因为我们创建线程对象 t
的时候就关联了一个活跃的线程,调用 join()
就是确保线程对象关联的线程已经执行完毕,然后会修改对象的状态,让 std::thread::joinable()
返回 false
,表示线程对象目前没有关联活跃线程。std::thread
的析构函数,正是通过 joinable()
判断线程对象目前是否有关联活跃线程,如果为 true
,那么就当做有关联活跃线程,会调用 std::terminate()
。
如你所见,std::thread 高度封装,其成员函数也很少,我们可以轻易的创建线程执行任务,不过,它的用法也还远不止如此,我们慢慢介绍。
使用 hardware_concurrency
可以获得我们当前硬件支持的并发线程数量,它是 std::thread
的静态成员函数。
#include <iostream>
+#include <thread>
+
+int main(){
+ unsigned int n = std::thread::hardware_concurrency();
+ std::cout << "支持 " << n << " 个并发线程。\n";
+}
+
本节其实是要普及一下计算机常识,一些古老的书籍比如 csapp 应该也会提到“超线程技术”。
英特尔® 超线程技术是一项硬件创新,允许在每个内核上运行多个线程。更多的线程意味着可以并行完成更多的工作。
AMD 超线程技术被称为 SMT(Simultaneous Multi-Threading),它与英特尔的技术实现有所不同,不过使用类似。
举个例子:一款 4 核心 8 线程的 CPU,这里的 8 线程其实是指所谓的逻辑处理器,也意味着这颗 CPU 最多可并行执行 8 个任务。
我们的 hardware_concurrency()
获取的值自然也会是 8。
当然了,都 2024 年了,我们还得考虑一个问题:“ 英特尔从 12 代酷睿开始,为其处理器引入了全新的“大小核”混合设计架构”。
比如我的 CPU i7 13700H
它是 14 核心,20 线程,有 6 个能效核,6 个性能核。不过我们说了,物理核心这个通常不看重,hardware_concurrency()
输出的值会为 20。
我们可以举个简单的例子运用这个值:
template<typename ForwardIt>
+auto sum(ForwardIt first, ForwardIt last){
+ using value_type = std::iter_value_t<ForwardIt>;
+ std::size_t num_threads = std::thread::hardware_concurrency();
+ std::ptrdiff_t distance = std::distance(first, last);
+
+ if(distance > 1024000){
+ // 计算每个线程处理的元素数量
+ std::size_t chunk_size = distance / num_threads;
+ std::size_t remainder = distance % num_threads;
+
+ // 存储每个线程的结果
+ std::vector<value_type>results(num_threads);
+
+ // 存储关联线程的线程对象
+ std::vector<std::thread> threads;
+
+ // 创建并启动线程
+ auto start = first;
+ for (std::size_t i = 0; i < num_threads; ++i) {
+ auto end = std::next(start, chunk_size + (i < remainder ? 1 : 0));
+ threads.emplace_back([start, end, &results, i] {
+ results[i] = std::accumulate(start, end, value_type{});
+ });
+ start = end; // 开始迭代器不断向前
+ }
+
+ // 等待所有线程执行完毕
+ for (auto& thread : threads)
+ thread.join();
+
+ // 汇总线程的计算结果
+ value_type total_sum = std::accumulate(results.begin(), results.end(), value_type{});
+ return total_sum;
+ }
+
+ value_type total_sum = std::accumulate(first, last, value_type{});
+ return total_sum;
+}
+
运行测试。
我们写了这样一个求和函数 sum
,接受两个迭代器计算它们范围中对象的和。
我们先获取了迭代器所指向的值的类型,定义了一个别名 value_type
,我们这里使用到的 std::iter_value_t
是 C++20 引入的,返回类型推导是 C++14 引入。如果希望代码可以在 C++11 的环境运行也可以自行修改为:
template<typename ForwardIt>
+typename std::iterator_traits<ForwardIt>::value_type sum(ForwardIt first, ForwardIt last);
+
运行测试。
num_threads
是当前硬件支持的并发线程的值。std::distance
用来计算 first 到 last 的距离,也就是我们要进行求和的元素个数了。
我们这里的设计比较简单,毕竟是初学,所以只对元素个数大于 1024000
的进行多线程求和,而小于这个值的则直接使用标准库函数 std::accumulate
求和即可。
多线程求和只需要介绍三个地方
chunk_size
是每个线程分配的任务,但是这是可能有余数的,比如 10 个任务分配三个线程,必然余 1。但是我们也需要执行这个任务,所以还定义了一个对象 remainder
,它存储的就是余数。
auto end = std::next(start, chunk_size + (i < remainder ? 1 : 0));
这行代码是获取当前线程的执行范围,其实也就是要 chunk_size
再加上我们的余数 remainder
。这里写了一个三目运算符是为了进行分配任务,比如:
假设有 3 个线程执行,并且余数是 2。那么,每个线程的处理情况如下:
i = 0
时,由于 0 < 2
,所以这个线程会多分配一个元素。i = 1
时,同样因为 1 < 2
,这个线程也会多分配一个元素。i = 2
时,由于 2 >= 2
,所以这个线程只处理平均数量的元素。这确保了剩余的 2 个元素被分配给了前两个线程,而第三个线程只处理了平均数量的元素。这样就确保了所有的元素都被正确地分配给了各个线程进行处理。
auto start = first;
在创建线程执行之前先定义了一个开始迭代器。在传递给线程执行的lambda表达式中,最后一行是:start = end;
这是为了让迭代器一直向前。
由于求和不涉及数据竞争之类的问题,所以我们甚至可以在刚讲完 Hello World
就手搓了一个“并行求和”的简单的模板函数。主要的难度其实在于对 C++ 的熟悉程度,而非对线程类 std::thread
的使用了,这里反而是最简单的,无非是用容器存储线程对象管理,最后进行 join()
罢了。
本节代码只是为了学习,而且只是百万数据通常没必要多线程,上亿的话差不多。如果你需要多线程求和,可以使用 C++17 引入的求和算法 std::reduce
并指明执行策略。它的效率接近我们实现的 sum
的两倍,当前环境核心越多数据越多,和单线程效率差距越明显。
在 C++ 标准库中,只能管理与 std::thread
关联的线程,类 std::thread
的对象就是指代线程的对象,我们说“线程管理”,其实也就是管理 std::thread
对象。
使用 C++ 线程库启动线程,就是构造 std::thread 对象。
当然了,如果是默认构造之类的,那么
std::thread
线程对象没有关联线程的,自然也不会启动线程执行任务。std::thread t; // 构造不表示线程的新 std::thread 对象 +
我们上一节的示例是传递了一个函数给 std::thread
对象,函数会在新线程中执行。std::thread
支持的形式还有很多,只要是可调用(Callable)对象即可,比如重载了 operator()
的类对象(也可以直接叫函数对象)。
class Task{
+public:
+ void operator()()const {
+ std::cout << "operator()()const\n";
+ }
+};
+
我们显然没办法直接像函数使用函数名一样,使用“类名”,函数名可以隐式转换到指向它的函数指针,而类名可不会直接变成对象,我们想使用 Task
自然就得构造对象了
std::thread t{ Task{} };
+t.join();
+
直接创建临时对象即可,可以简化代码并避免引入不必要的局部对象。
不过有件事情需要注意,当我们使用函数对象用于构造 std::thread
的时候,如果你传入的是一个临时对象,且使用的都是 “()
”小括号初始化,那么编译器会将此语法解析为函数声明。
std::thread t( Task() ); // 函数声明
+
这被编译器解析为函数声明,是一个返回类型为 std::thread
,函数名为 t
,接受一个返回 Task
的空参的函数指针类型,也就是 Task(*)()
。
之所以我们看着抽象是因为这里的形参是无名的,且写了个函数类型。
我们用一个简单的示例为你展示:
void h(int(int)); //#1 声明
+void h(int (*p)(int)){} //#2 定义
+
即使我还没有为你讲述概念,我相信你也发现了,#1 和 #2 的区别无非是,#1 省略了形参的名称,还有它的形参是函数类型而不是函数指针类型,没有 *
。
在确定每个形参的类型后,类型是 “T 的数组”或某个函数类型 T 的形参会调整为具有类型“指向 T 的指针”。文档。
显然,int(int)
是一个函数类型,它被调整为了一个指向这个函数类型的指针类型。
那么回到我们最初的:
std::thread t( Task() ); // #1 函数声明
+std::thread t( Task (*p)() ){ return {}; } // #2 函数定义
+
#2
我们写出了函数形参名称 p
,再将函数类型写成函数指针类型,事实上完全等价。我相信,这样,也就足够了。
所以总而言之,建议使用 {}
进行初始化,这是好习惯,大多数时候它是合适的。
C++11 引入的 Lambda 表达式,同样可以作为构造 std::thread
的参数,因为 Lambda 本身就是生成了一个函数对象,它自身就是类类型。
#include <iostream>
+#include <thread>
+
+int main(){
+ std::thread thread{ [] {std::cout << "Hello World!\n"; } };
+ thread.join();
+}
+
启动线程后(也就是构造 std::thread
对象)我们必须在线程对象的生存期结束之前,即 std::thread::~thread
调用之前,决定它的执行策略,是 join()
(合并)还是 detach()
(分离)。
我们先前使用的就是 join(),我们聊一下 detach(),当 std::thread
线程对象调用了 detach(),那么就是线程对象放弃了对线程资源的所有权,不再管理此线程,允许此线程独立的运行,在线程退出时释放所有分配的资源。
放弃了对线程资源的所有权,也就是线程对象没有关联活跃线程了,此时 joinable 为 false
。
在单线程的代码中,对象销毁之后再去访问,会产生未定义行为,多线程增加了这个问题发生的几率。
比如函数结束,那么函数局部对象的生存期都已经结束了,都被销毁了,此时线程函数还持有函数局部对象的指针或引用。
#include <iostream>
+#include <thread>
+
+struct func {
+ int& m_i;
+ func(int& i) :m_i{ i } {}
+ void operator()(int n)const {
+ for (int i = 0; i <= n; ++i) {
+ m_i += i; // 可能悬空引用
+ }
+ }
+};
+
+int main(){
+ int n = 0;
+ std::thread my_thread{ func{n},100 };
+ my_thread.detach(); // 分离,不等待线程结束
+} // 分离的线程可能还在运行
+
主线程(main)创建局部对象 n、创建线程对象 my_thread 启动线程,执行任务 func{n}
,局部对象 n 的引用被子线程持有。传入 100 用于调用 func 的 operator(int)。
my_thread.detach();
,joinable() 为 false
。线程分离,线程对象不再持有线程资源,线程独立的运行。
主线程不等待,此时分离的子线程可能没有执行完毕,但是主线程(main)已经结束,局部对象 n
生存期结束,被销毁,而此时子线程还持有它的引用,访问悬空引用,造成未定义行为。my_thread
已经没有关联线程资源,正常析构,没有问题。
解决方法很简单,将 detach() 替换为 join()。
通常非常不推荐使用 detach(),因为程序员必须确保所有创建的线程正常退出,释放所有获取的资源并执行其它必要的清理操作。这意味着通过调用 detach() 放弃线程的所有权不是一种选择,因此 join 应该在所有场景中使用。 一些老式特殊情况不聊。
另外提示一下,也不要想着 detach() 之后,再次调用 join()
my_thread.detach();
+// todo..
+my_thread.join();
+// 函数结束
+
认为这样可以确保被分离的线程在这里阻塞执行完?
我们前面聊的很清楚了,detach() 是线程分离,线程对象放弃了线程资源的所有权,此时我们的 my_thread 它现在根本没有关联任何线程。调用 join() 是:“阻塞当前线程直至 *this 所标识的线程结束其执行”,我们的线程对象都没有线程,堵塞什么?执行什么呢?
简单点说,必须是 std::thread 的 joinable() 为 true 即线程对象有活跃线程,才能调用 join() 和 detach()。
顺带的,我们还得处理线程运行后的异常问题,举个例子:你在一个函数中构造了一个 std::thread 对象,线程开始执行,函数继续执行下面别的代码,但是如果抛出了异常呢?下面我的 join() 就会被跳过。
std::thread my_thread{func{n},10};
+//todo.. 抛出异常的代码
+my_thread.join();
+
避免程序被抛出的异常所终止,在异常处理过程中调用 join(),从而避免线程对象析构产生问题。
struct func; // 复用之前
+void f(){
+ int n = 0;
+ std::thread t{ func{n},10 };
+ try{
+ // todo.. 一些当前线程可能抛出异常的代码
+ f2();
+ }
+ catch (...){
+ t.join(); // 1
+ throw;
+ }
+ t.join(); // 2
+}
+
我知道你可能有很多疑问,我们既然 catch 接住了异常,为什么还要 throw?以及为什么我们要两个 join()?
这两个问题其实也算一个问题,如果代码里抛出了异常,就会跳转到 catch 的代码中,执行 join() 确保线程正常执行完成,线程对象可以正常析构。然而此时我们必须再次 throw 抛出异常,因为你要是不抛出,那么你不是还得执行一个 t.join()
?显然逻辑不对,自然抛出。
至于这个函数产生的异常,由调用方进行处理,我们只是确保函数 f 中创建的线程正常执行完成,其局部对象正常析构释放。测试代码。
我知道你可能会想到:“我在 try 块中最后一行写一个
t.join()
,这样如果前面的代码没有抛出异常,就能正常的调用 join(),如果抛出了异常,那就调用 catch 中的t.join()
根本不需要最外部 2 那里的 join(),也不需要再次throw
抛出异常”void f(){ + int n = 0; + std::thread t{ func{n},10 }; + try{ + // todo.. 一些当前线程可能抛出异常的代码 + f2(); + t.join(); // try 最后一行调用 join() + } + catch (...){ + t.join(); // 如果抛出异常,就在 这里调用 join() + } +} +
你是否觉得这样也可以?也没问题?简单的测试运行的确没问题。
但是这是不对的,你要注意我们的注释:“一些当前线程可能抛出异常的代码”,而不是
f2()
,我们的try
catch
只是为了让线程对象关联的线程得以正确执行完毕,以及线程对象正确析构。并没有处理什么其他的东西,不掩盖错误,try块中的代码抛出了异常,
catch` 接住了,我们理所应当再次抛出。
“资源获取即初始化”(RAII,Resource Acquisition Is Initialization)。
简单的说是:构造函数申请资源,析构函数释放资源,让对象的生命周期和资源绑定。当异常抛出时,C++ 会自动调用栈上所有对象的析构函数。
我们可以提供一个类,在析构函数中使用 join() 确保线程执行完成,线程对象正常析构。
class thread_guard{
+ std::thread& m_t;
+public:
+ explicit thread_guard(std::thread& t) :m_t{ t } {}
+ ~thread_guard(){
+ std::puts("析构"); // 打印 不用在乎
+ if (m_t.joinable()) { // 没有关联活跃线程
+ m_t.join();
+ }
+ }
+ thread_guard(const thread_guard&) = delete;
+ thread_guard& operator=(const thread_guard&) = delete;
+};
+void f(){
+ int n = 0;
+ std::thread t{ func{n},10 };
+ thread_guard g(t);
+ f2(); // 可能抛出异常
+}
+
函数 f 执行完毕,局部对象就要逆序销毁了。因此,thread_guard 对象 g 是第一个被销毁的,调用析构函数。即使函数 f2() 抛出了一个异常,这个销毁依然会发生(前提是你捕获了这个异常)。这确保了线程对象 t 所关联的线程正常的执行完毕以及线程对象的正常析构。测试代码。
如果异常被抛出但未被捕获那么就会调用 std::terminate。是否对未捕获的异常进行任何栈回溯由实现定义。(简单的说就是不一定会调用析构)
我们的测试代码是捕获了异常的,为了观测,看到它一定打印“析构”。
在 thread_guard 的析构函数中,我们要判断 std::thread
线程对象现在是否有关联的活跃线程,如果有,我们才会执行 join()
,阻塞当前线程直到线程对象关联的线程执行完毕。如果不想等待线程结束可以使用 detach()
,但是这让 std::thread
对象失去了线程资源的所有权,难以掌控,具体如何,看情况分析。
拷贝赋值和拷贝构造定义为 =delete
可以防止编译器隐式生成,同时会阻止移动构造函数和移动赋值运算符的隐式定义。这样的话,对 thread_guard 对象进行拷贝或赋值等操作会引发一个编译错误。
不允许这些操作主要在于:这是个管理类,而且顾名思义,它就应该只是单纯的管理线程对象仅此而已,只保有一个引用,单纯的做好 RAII 的事情就行,允许其他操作没有价值。
严格来说其实这里倒也不算 RAII,因为 thread_guard 的构造函数其实并没有申请资源,只是保有了线程对象的引用,在析构的时候进行了 join() 。
向可调用对象或函数传递参数很简单,我们前面也都写了,只需要将这些参数作为 std::thread
的构造参数即可。需要注意的是,这些参数会拷贝到新线程的内存空间中,即使函数中的参数是引用,依然实际是拷贝。
void f(int, const int& a);
+
+int n = 1;
+std::thread t(f, 3, n);
+
线程对象 t 的构造没有问题,可以通过编译,但是这个 n 实际上并没有按引用传递,而是拷贝了,我们可以打印地址来验证我们的猜想。
void f(int, const int& a) { // a 并非引用了局部对象 n
+ std::cout << &a << '\n';
+}
+
+int main() {
+ int n = 1;
+ std::cout << &n << '\n';
+ std::thread t(f, 3, n);
+ t.join();
+}
+
运行代码,打印的地址截然不同。
可以通过编译,但通常这不符合我们的需求,因为我们的函数中的参数是引用,我们自然希望能引用调用方传递的参数,而不是拷贝。如果我们的 f 的形参类型不是 const 的引用,则会产生一个编译错误。
想要解决这个问题很简单,我们可以使用标准库的设施 std::ref
、 std::cref
函数模板。
void f(int, int& a) {
+ std::cout << &a << '\n';
+}
+
+int main() {
+ int n = 1;
+ std::cout << &n << '\n';
+ std::thread t(f, 3, std::ref(n));
+ t.join();
+}
+
运行代码,打印地址完全相同。
我们来解释一下,“ref” 其实就是 “reference”(引用)的缩写,意思也很简单,返回“引用”,当然了,不是真的返回引用,它们返回一个包装类 std::reference_wrapper
,顾名思义,这个类就是包装引用对象类模板,将对象包装,可以隐式转换为被包装对象的引用。
“cref”呢?,这个“c”就是“const”,就是返回了 std::reference_wrapper<const T>
。我们不详细介绍他们的实现,你简单认为reference_wrapper
可以隐式转换为被包装对象的引用即可,
int n = 0;
+std::reference_wrapper<int> r = std::ref(n);
+int& p = r; // r 隐式转换为 n 的引用 此时 p 引用的就是 n
+
int n = 0;
+std::reference_wrapper<const int> r = std::cref(n);
+const int& p = r; // r 隐式转换为 n 的 const 的引用 此时 p 引用的就是 n
+
如果对他们的实现感兴趣,可以观看视频。
以上代码void f(int, int&)
如果不使用 std::ref
并不会和前面 void f(int, const int&)
一样只是多了拷贝,而是会产生编译错误,这是因为 std::thread
内部会将保有的参数副本转换为右值表达式进行传递,这是为了那些只支持移动的类型,左值引用没办法引用右值表达式,所以产生编译错误。
struct move_only {
+ move_only() { std::puts("默认构造"); }
+ move_only(const move_only&) = delete;
+ move_only(move_only&&)noexcept {
+ std::puts("移动构造");
+ }
+};
+
+void f(move_only){}
+
+int main(){
+ move_only obj;
+ std::thread t{ f,std::move(obj) };
+ t.join();
+}
+
运行测试。
没有 std::ref
自然是会保有一个副本,所以有两次移动构造,一次是被 std::thread
构造函数中初始化副本,一次是调用函数 f
。
如果还有不理解,不用担心,记住,这一切的问题都会在后面的 std::thread
的构造-源码解析 解释清楚。
成员函数指针也是可调用(Callable)的 ,可以传递给 std::thread
作为构造参数,让其关联的线程执行成员函数。
struct X{
+ void task_run(int)const;
+};
+
+ X x;
+ int n = 0;
+ std::thread t{ &X::task_run,&x,n };
+ t.join();
+
传入成员函数指针、与其配合使用的对象、调用成员函数的参数,构造线程对象 t
,启动线程。
如果你是第一次见到成员指针,那么我们稍微聊一下,&X::task_run
是一个整体,它们构成了成员指针,&类名::非静态成员。
成员指针必须和对象一起使用,这是唯一标准用法,成员指针不可以转换到函数指针单独使用,即使是非静态成员函数没有使用任何数据成员。
我们还可以使用模板函数 std::bind
与成员指针一起使用
std::thread t{ std::bind(&X::task_run, &x ,n) };
+
不过需要注意,std::bind
也是默认拷贝的,即使我们的成员函数形参类型为引用:
struct X {
+ void task_run(int& a)const{
+ std::cout << &a << '\n';
+ }
+};
+
+X x;
+int n = 0;
+std::cout << &n << '\n';
+std::thread t{ std::bind(&X::task_run,&x,n) };
+t.join();
+
除非给参数 n
加上 std::ref
,就是按引用传递了:
std::thread t{ std::bind(&X::task_run,&x,std::ref(n)) };
+
void f(const std::string&);
+std::thread t{ f,"hello" };
+
代码创建了一个调用 f("hello")
的线程。注意,函数 f
实际需要的是一个 std::string
类型的对象作为参数,但这里使用的是字符串字面量,我们要明白“A的引用只能引用A,或者以任何形式转换到A”,字符串字面量的类型是 const char[N]
,它会退化成指向它的const char*
指针,被线程对象保存。在调用 f
的时候,这个指针可以通过 std::string
的转换构造函数,构造出一个临时的 std::string
对象,就能成功调用。
字符串字面量具有静态存储期,指向它的指针这当然没问题了,不用担心生存期的问题,但是如果是指向“动态”对象的指针,就要特别注意了:
void f(const std::string&);
+void test(){
+ char buffer[1024]{};
+ //todo.. code
+ std::thread t{ f,buffer };
+ t.detach();
+}
+
以上代码可能导致一些问题,buffer 是一个数组对象,作为 std::thread
构造参数的传递的时候会decay-copy
(确保实参在按值传递时会退化) 隐式转换为了指向这个数组的指针。
我们要特别强调,std::thread
构造是代表“启动线程”,而不是调用我们传递的可调用对象。
std::thread
的构造函数中调用了创建线程的函数(windows 下可能为 _beginthreadex
),它将我们传入的参数,f、buffer ,传递给这个函数,在新线程中执行函数 f
。也就是说,调用和执行 f(buffer)
并不是说要在 std::thread
的构造函数中,而是在创建的新线程中,具体什么时候执行,取决于操作系统的调度,所以完全有可能函数 test
先执行完,而新线程此时还没有进行 f(buffer)
的调用,转换为std::string
,那么 buffer 指针就悬空了,会导致问题。解决方案:
将 detach()
替换为 join()
。
void f(const std::string&);
+void test(){
+ char buffer[1024]{};
+ //todo.. code
+ std::thread t{ f,buffer };
+ t.join();
+}
+
显式将 buffer
转换为 std::string
。
void f(const std::string&);
+void test(){
+ char buffer[1024]{};
+ //todo.. code
+ std::thread t{ f,std::string(buffer) };
+ t.detach();
+}
+
std::this_thread
这个命名空间包含了管理当前线程的函数。
yield
建议实现重新调度各执行线程。get_id
返回当前线程 id。sleep_for
使当前线程停止执行指定时间。sleep_until
使当前线程执行停止到指定的时间点。它们之中最常用的是 get_id
,其次是 sleep_for
,再然后 yield
,sleep_until
较少。
使用 get_id
打印主线程和子线程的 ID。
int main() {
+ std::cout << std::this_thread::get_id() << '\n';
+
+ std::thread t{ [] {
+ std::cout << std::this_thread::get_id() << '\n';
+ } };
+ t.join();
+}
+
使用 sleep_for
延时。当 Sleep
之类的就行,但是它需要接受的参数不同,是 std::chrono
命名空间中的时间对象。
int main() {
+ std::this_thread::sleep_for(std::chrono::seconds(3));
+}
+
主线程延时 3 秒,这个传入了一个临时对象 seconds
,它是模板 std::chrono::duration
的别名,以及还有很多其他的时间类型,都基于这个类。说实话挺麻烦的,如果您支持 C++14,建议使用时间字面量,在 std::chrono_literals
命名空间中。我们可以改成下面这样:
using namespace std::chrono_literals;
+
+int main() {
+ std::this_thread::sleep_for(3s);
+}
+
简单直观。
yield
减少 CPU 的占用。
while (!isDone()){
+ std::this_thread::yield();
+}
+
线程需要等待某个操作完成,如果你直接用一个循环不断判断这个操作是否完成就会使得这个线程占满 CPU 时间,这会造成资源浪费。此时可以判断操作是否完成,如果还没完成就调用 yield 交出 CPU 时间片让其他线程执行,过一会儿再来判断是否完成,这样这个线程占用 CPU 时间会大大减少。
使用 sleep_until
让当前线程延迟到具体的时间。我们延时 5 秒就是。
int main() {
+ // 获取当前时间点
+ auto now = std::chrono::system_clock::now();
+
+ // 设置要等待的时间点为当前时间点之后的5秒
+ auto wakeup_time = now + 5s;
+
+ // 输出当前时间
+ auto now_time = std::chrono::system_clock::to_time_t(now);
+ std::cout << "Current time:\t\t" << std::put_time(std::localtime(&now_time), "%H:%M:%S") << std::endl;
+
+ // 输出等待的时间点
+ auto wakeup_time_time = std::chrono::system_clock::to_time_t(wakeup_time);
+ std::cout << "Waiting until:\t\t" << std::put_time(std::localtime(&wakeup_time_time), "%H:%M:%S") << std::endl;
+
+ // 等待到指定的时间点
+ std::this_thread::sleep_until(wakeup_time);
+
+ // 输出等待结束后的时间
+ now = std::chrono::system_clock::now();
+ now_time = std::chrono::system_clock::to_time_t(now);
+ std::cout << "Time after waiting:\t" << std::put_time(std::localtime(&now_time), "%H:%M:%S") << std::endl;
+}
+
sleep_until
本身设置使用很简单,是打印时间格式、设置时区麻烦。运行结果。
介绍了一下 std::this_thread
命名空间中的四个函数的基本用法,我们后续会经常看到这些函数的使用,不用着急。
std::thread
转移所有权传入可调用对象以及参数,构造 std::thread
对象,启动线程,而线程对象拥有了线程的所有权,线程是一种系统资源,所以可称作“线程资源”。
std::thread 不可复制。两个 std::thread 不可表示一个线程,std::thread 对线程资源是独占所有权。移动就是转移它的线程资源的所有权给别的 std::thread
对象。
int main() {
+ std::thread t{ [] {
+ std::cout << std::this_thread::get_id() << '\n';
+ } };
+ std::cout << t.joinable() << '\n'; // 线程对象 t 当前关联了活跃线程 打印 1
+ std::thread t2{ std::move(t) }; // 将 t 的线程资源的所有权移交给 t2
+ std::cout << t.joinable() << '\n'; // 线程对象 t 当前没有关联活跃线程 打印 0
+ //t.join(); // Error! t 没有线程资源
+ t2.join(); // t2 当前持有线程资源
+}
+
这段代码通过移动构造转移了线程对象 t
的线程资源所有权到 t2
,这里虽然有两个 std::thread
对象,但是从始至终只有一个线程资源,让持有线程资源的 t2
对象最后调用 join()
堵塞让其线程执行完毕。t
与 t2
都能正常析构。
我们还可以使用移动赋值来转移线程资源的所有权:
int main() {
+ std::thread t; // 默认构造,没有关联活跃线程
+ std::cout << t.joinable() << '\n'; // 0
+ std::thread t2{ [] {} };
+ t = std::move(t2); // 转移线程资源的所有权到 t
+ std::cout << t.joinable() << '\n'; // 1
+ t.join();
+
+ t2 = std::thread([] {});
+ t2.join();
+}
+
我们只需要介绍 t2 = std::thread([] {})
,临时对象是右值表达式,不用调用 std::move
,这里相当于是将临时的 std::thread
对象所持有的线程资源转移给 t2
,t2
再调用 join()
正常析构。
函数返回 std::thread
对象:
std::thread f(){
+ std::thread t{ [] {} };
+ return t;
+}
+
+int main(){
+ std::thread rt = f();
+ rt.join();
+}
+
这段代码可以通过编译,你是否感到奇怪?我们在函数 f() 中创建了一个局部的 std::thread
对象,启动线程,然后返回它。
这里的 return t
重载决议[1]选择到了移动构造,将 t
线程资源的所有权转移给函数调用 f()
返回的临时 std::thread
对象中,然后这个临时对象再用来初始化 rt
,临时对象是右值表达式,这里一样选择到移动构造,将临时对象的线程资源所有权移交给 rt
。此时 rt
具有线程资源的所有权,由它调用 join()
正常析构。
如果标准达到 C++17,RVO 保证这里少一次移动构造的开销(临时对象初始化
rt
的这次)。
所有权也可以在函数内部传递:
void f(std::thread t){
+ t.join();
+}
+
+int main(){
+ std::thread t{ [] {} };
+ f(std::move(t));
+ f(std::thread{ [] {} });
+}
+
std::move
将 t 转换为了一个右值表达式,初始化函数f
形参 t
,选择到了移动构造转移线程资源的所有权,在函数中调用 t.join()
后正常析构。std::thread{ [] {} }
构造了一个临时对象,本身就是右值表达式,初始化函数f
形参 t
,移动构造转移线程资源的所有权到 t
,t.join()
后正常析构。
本节内容总体来说是很简单的,如果你有些地方无法理解,那只有一种可能,“对移动语义不了解”,不过这也不是问题,在后续我们详细介绍 std::thread
构造函数的源码即可,不用着急。
std::thread
的构造-源码解析我们上一个大节讲解了线程管理,也就是 std::thread
的管理,其中的重中之重就是它的构造,传递参数。我们用源码实现为各位从头讲解。
了解其实现,才能更好的使用它。
joining_thread
这个类和 std::thread
的区别就是析构函数会自动 join
。如果您好好的学习了上一节的内容,阅读了 std::thread
的源码,以下内容不会对您构成任何的难度。
我们存储一个 std::thread
作为底层数据成员,稍微注意一下构造函数和赋值运算符的实现即可。
class joining_thread {
+ std::thread t;
+public:
+ joining_thread()noexcept = default;
+ template<typename Callable, typename... Args>
+ explicit joining_thread(Callable&& func, Args&&...args) :
+ t{ std::forward<Callable>(func), std::forward<Args>(args)... } {}
+ explicit joining_thread(std::thread t_)noexcept : t{ std::move(t_) } {}
+ joining_thread(joining_thread&& other)noexcept : t{ std::move(other.t) } {}
+
+ joining_thread& operator=(std::thread&& other)noexcept {
+ if (joinable()) { // 如果当前有活跃线程,那就先执行完
+ join();
+ }
+ t = std::move(other);
+ return *this;
+ }
+ ~joining_thread() {
+ if (joinable()) {
+ join();
+ }
+ }
+ void swap(joining_thread& other)noexcept {
+ t.swap(other.t);
+ }
+ std::thread::id get_id()const noexcept {
+ return t.get_id();
+ }
+ bool joinable()const noexcept {
+ return t.joinable();
+ }
+ void join() {
+ t.join();
+ }
+ void detach() {
+ t.detach();
+ }
+ std::thread& data()noexcept {
+ return t;
+ }
+ const std::thread& data()const noexcept {
+ return t;
+ }
+};
+
简单使用一下:
int main(){
+ std::cout << std::this_thread::get_id() << '\n';
+ joining_thread thread{[]{
+ std::cout << std::this_thread::get_id() << '\n';
+ } };
+ joining_thread thread2{ std::move(thread) };
+}
+
使用容器管理线程对象,等待线程执行结束:
void do_work(std::size_t id){
+ std::cout << id << '\n';
+}
+
+int main(){
+ std::vector<std::thread>threads;
+ for (std::size_t i = 0; i < 10; ++i){
+ threads.emplace_back(do_work, i); // 产生线程
+ }
+ for(auto& thread:threads){
+ thread.join(); // 对每个线程对象调用 join()
+ }
+}
+
运行测试。
线程对象代表了线程,管理线程对象也就是管理线程,这个 vector
对象管理 10 个线程,保证他们的执行、退出。
使用我们这节实现的 joining_thread
则不需要最后的循环 join()
:
int main(){
+ std::vector<joining_thread>threads;
+ for (std::size_t i = 0; i < 10; ++i){
+ threads.emplace_back(do_work, i);
+ }
+}
+
运行测试。
如果你自己编译了这些代码,相信你注意到了,打印的是乱序的,没什么规律,而且重复运行的结果还不一样,这是正常现象。多线程执行就是如此,无序且操作可能被打断。使用互斥量可以解决这些问题,这也就是下一章节的内容了。
本章节的内容围绕着:“使用线程”,也就是"使用 std::thread
"展开, std::thread
是我们学习 C++ 并发支持库的重中之重,本章的内容并不少见,但是却是少有的准确与完善。即使你早已学习过乃至使用 C++ 标准库进行多线程编程已经很久,我相信本章也一定可以让你收获良多。
如果是第一次学习,有还不够理解的地方,则一定要多思考,或记住,以后多看。
我尽量讲的简单与通俗易懂。学完本章,你大概率还无法在实际环境使用多线程提升程序效率,至少也要学习到使用互斥量,保护共享数据,才可实际使用。
本章节主要内容:
多线程共享数据的问题
使用互斥量保护共享数据
保护共享数据的其它方案
在上一章内容,我们对于线程的基本使用和管理,可以说已经比较了解了,甚至深入阅读了部分的 std::thread
源码。所以如果你好好学习了上一章,本章也完全不用担心。
我们本章,就要开始聊共享数据的那些事。
在多线程的情况下,每个线程都抢着完成自己的任务。在大多数情况下,即使会改变执行顺序,也是良性竞争,这是无所谓的。比如两个线程都要往标准输出输出一段字符,谁先谁后并不会有什么太大影响。
void f() { std::cout << "❤️\n"; }
+void f2() { std::cout << "😢\n"; }
+
+int main(){
+ std::thread t{ f };
+ std::thread t2{ f2 };
+ t.join();
+ t2.join();
+}
+
std::cout
的单个 operator<< 调用是线程安全的,不会被打断。即:同步的 C++ 流保证是线程安全的(从多个线程输出的单独字符可能交错,但无数据竞争)
只有在涉及多线程修改相同共享数据的时候,才会导致“恶性的条件竞争”。
std::vector<int>v;
+
+void f() { v.emplace_back(1); }
+void f2() { v.erase(v.begin()); }
+
+int main() {
+ std::thread t{ f };
+ std::thread t2{ f2 };
+ t.join();
+ t2.join();
+ std::cout << v.size() << '\n';
+}
+
比如这段代码就是典型的恶性条件竞争,两个线程共享一个 vector
,并对它进行修改。可能导致许多问题,比如 f2
先执行,此时 vector
还没有元素,导致抛出异常。又或者 f
执行了一半,调用了 f2()
,等等。
当然了,也有可能先执行 f,然后执行 f2,最后打印了 0,程序老老实实执行完毕。
但是我们显然不能寄希望于这种操作系统的调度。
而且即使不是一个添加元素,一个删除元素,全是 emplace_back
添加元素,也一样会有问题,由于 std::vector 不是线程安全的容器,因此当多个线程同时访问并修改 v 时,可能会发生未定义的行为。具体来说,当两个线程同时尝试向 v 中添加元素时,但是 emplace_back
函数却是可以被打断的,执行了一半,又去执行另一个线程。可能会导致数据竞争,从而引发未定义的结果。
当某个表达式的求值写入某个内存位置,而另一求值读或修改同一内存位置时,称这些表达式冲突。拥有两个冲突的求值的程序就有数据竞争,除非
- 两个求值都在同一线程上,或者在同一信号处理函数中执行,或
- 两个冲突的求值都是原子操作(见 std::atomic),或
- 一个冲突的求值发生早于 另一个(见 std::memory_order)
如果出现数据竞争,那么程序的行为未定义。
标量类型等都同理,有数据竞争,未定义行为:
int cnt = 0;
+auto f = [&]{cnt++;};
+std::thread t1{f}, t2{f}, t3{f}; // 未定义行为
+
互斥量(Mutex),又称为互斥锁,是一种用来保护临界区[1]的特殊对象,它可以处于锁定(locked)状态,也可以处于解锁(unlocked)状态:
如果互斥锁是锁定的, 通常说某个特定的线程正持有这个互斥锁
如果没有线程持有这个互斥量,那么这个互斥量就处于解锁状态
概念从来不是我们的重点,用一段对比代码为你直观的展示互斥量的作用:
void f() {
+ std::cout << std::this_thread::get_id() << '\n';
+}
+
+int main() {
+ std::vector<std::thread>threads;
+ for (std::size_t i = 0; i < 10; ++i)
+ threads.emplace_back(f);
+
+ for (auto& thread : threads)
+ thread.join();
+}
+
这段代码你多次运行它会得到毫无规律和格式的结果,我们可以使用互斥量解决这个问题:
#include <mutex> // 必要标头
+std::mutex m;
+
+void f() {
+ m.lock();
+ std::cout << std::this_thread::get_id() << '\n';
+ m.unlock();
+}
+
+int main() {
+ std::vector<std::thread>threads;
+ for (std::size_t i = 0; i < 10; ++i)
+ threads.emplace_back(f);
+
+ for (auto& thread : threads)
+ thread.join();
+}
+
当多个线程执行函数 f
的时候,只有一个线程能成功调用 lock()
给互斥量上锁,其他所有的线程 lock()
的调用将阻塞执行,直至获得锁。第一个调用 lock()
的线程得以继续往下执行,执行我们的 std::cout
输出语句,不会有任何其他的线程打断这个操作。直到线程执行 unlock()
,就解锁了互斥量。
那么其他线程此时也就能再有一个成功调用 lock
...
至于到底哪个线程才会成功调用,这个是由操作系统调度决定的。
看一遍描述就可以了,简而言之,被 lock()
和 unlock()
包含在其中的代码是线程安全的,同一时间只有一个线程执行,不会被其它线程的执行所打断。
std::lock_guard
不过一般不推荐这样显式的 lock()
与 unlock()
,我们可以使用 C++11 标准库引入的“管理类” std::lock_guard
:
void f() {
+ std::lock_guard<std::mutex>lc{ m };
+ std::cout << std::this_thread::get_id() << '\n';
+}
+
那么问题来了,std::lock_guard
是如何做到的呢?它是怎么实现的呢?首先顾名思义,这是一个“管理类”模板,用来管理互斥量的上锁与解锁,我们来看它在 MSVC STL 的实现:
_EXPORT_STD template <class _Mutex>
+class _NODISCARD_LOCK lock_guard { // class with destructor that unlocks a mutex
+public:
+ using mutex_type = _Mutex;
+
+ explicit lock_guard(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock
+ _MyMutex.lock();
+ }
+
+ lock_guard(_Mutex& _Mtx, adopt_lock_t) noexcept // strengthened
+ : _MyMutex(_Mtx) {} // construct but don't lock
+
+ ~lock_guard() noexcept {
+ _MyMutex.unlock();
+ }
+
+ lock_guard(const lock_guard&) = delete;
+ lock_guard& operator=(const lock_guard&) = delete;
+
+private:
+ _Mutex& _MyMutex;
+};
+
这段代码极其简单,首先管理类,自然不可移动不可复制,我们定义复制构造与复制赋值为弃置函数,同时阻止了移动等函数的隐式定义。
它只保有一个私有数据成员,一个引用,用来引用互斥量。
构造函数中初始化这个引用,同时上锁,析构函数中解锁,这是一个非常典型的 RAII
式的管理。
同时它还提供一个有额外std::adopt_lock_t
参数的构造函数 ,如果使用这个构造函数,则构造函数不会上锁。
所以有的时候你可能会看到一些这样的代码:
void f(){
+ //code..
+ {
+ std::lock_guard<std::mutex>lc{ m };
+ // 涉及共享资源的修改的代码...
+ }
+ //code..
+}
+
使用 {}
创建了一个块作用域,限制了对象 lc
的生存期,进入作用域构造 lock_guard
的时候上锁(lock),离开作用域析构的时候解锁(unlock)。
“粒度”通常用于描述锁定的范围大小,较小的粒度意味着锁定的范围更小,因此有更好的性能和更少的竞争。
我们举一个例子:
std::mutex m;
+
+void add_to_list(int n, std::list<int>& list) {
+ std::vector<int> numbers(n + 1);
+ std::iota(numbers.begin(), numbers.end(), 0);
+ int sum = std::accumulate(numbers.begin(), numbers.end(), 0);
+
+ {
+ std::lock_guard<std::mutex>lc{ m };
+ list.push_back(sum);
+ }
+}
+void print_list(const std::list<int>& list){
+ std::lock_guard<std::mutex>lc{ m };
+ for(const auto& i : list){
+ std::cout << i << ' ';
+ }
+ std::cout << '\n';
+}
+
std::list<int> list;
+std::thread t1{ add_to_list,i,std::ref(list) };
+std::thread t2{ add_to_list,i,std::ref(list) };
+std::thread t3{ print_list,std::cref(list) };
+std::thread t4{ print_list,std::cref(list) };
+t1.join();
+t2.join();
+t3.join();
+t4.join();
+
完整代码测试。
先看 add_to_list
,只有 list.push_back(sum)
涉及到了对共享数据的修改,需要进行保护,我们用 {}
包起来了。
假设有线程 A、B执行函数 add_to_list()
:线程 A 中的 numbers、sum 与线程 B 中的,不是同一个,希望大家分清楚,自然不存在数据竞争,也不需要上锁。线程 A、B执行完了前面求 0-n
的计算,只有一个线程能在 lock_guard
的构造函数中成功调用 lock() 给互斥量上锁。假设线程 A 成功调用 lock(),那么线程 B 的 lock() 调用就阻塞了,必须等待线程 A 执行完里面的代码,然后作用域结束,调用 lock_guard
的析构函数,解锁 unlock(),此时线程 B 就可以进去执行了,避免了数据竞争,不存在一个对象同时被多个线程修改。
函数 print_list()
就更简单了,打印 list
,给整个函数上锁,同一时刻只能有一个线程执行。
我们的使用代码是多个线程执行这两个函数,两个函数共享了一个锁,这样确保了当执行函数 print_list()
打印的时候,list 的状态是确定的。打印函数 print_list
和 add_to_list
函数的修改操作同一时间只能有一个线程在执行。print_list()
不可能看到正在被add_to_list()
修改的 list。
至于到底哪个函数哪个线程会先执行,执行多少次,这些都由操作系统调度决定,也完全有可能连续 4 次都是执行函数 print_list
的线程成功调用 lock
,会打印出了一样的值,这都很正常。
C++17 添加了一个新的特性,类模板实参推导, std::lock_guard
可以根据传入的参数自行推导,而不需要写明模板类型参数:
std::mutex m;
+std::lock_guard lc{ m }; // std::lock_guard<std::mutex>
+
并且 C++17 还引入了一个新的“管理类”:std::scoped_lock
,它相较于 lock_guard
的区别在于,它可以管理多个互斥量。不过对于处理一个互斥量的情况,它和 lock_guard
几乎完全相同。
std::mutex m;
+std::scoped_lock lc{ m }; // std::scoped_lock<std::mutex>
+
我们在后续管理多个互斥量,会详细了解这个类。
try_lock
try_lock
是互斥量中的一种尝试上锁的方式。与常规的 lock
不同,try_lock
会尝试上锁,但如果锁已经被其他线程占用,则不会阻塞当前线程,而是立即返回。
它的返回类型是 bool
,如果上锁成功就返回 true
,失败就返回 false
。
这种方法在多线程编程中很有用,特别是在需要保护临界区的同时,又不想线程因为等待锁而阻塞的情况下。
std::mutex mtx;
+
+void threadFunction(int id) {
+ // 尝试加锁
+ if (mtx.try_lock()) {
+ std::cout << "线程:" << id << " 获得锁" << std::endl;
+ // 临界区代码
+ std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟临界区操作
+ mtx.unlock(); // 解锁
+ std::cout << "线程:" << id << " 释放锁" << std::endl;
+ } else {
+ std::cout << "线程:" << id << " 获取锁失败 处理步骤" << std::endl;
+ }
+}
+
如果有两个线程运行这段代码,必然有一个线程无法成功上锁,要走 else 的分支。
std::thread t1(threadFunction, 1);
+std::thread t2(threadFunction, 2);
+
+t1.join();
+t2.join();
+
运行测试。
互斥量主要也就是为了保护共享数据,上一节的使用互斥量也已经为各位展示了一些。
然而使用互斥量来保护共享数据也并不是在函数中加上一个 std::lock_guard
就万事大吉了。有的时候只需要一个指针或者引用,就能让这种保护形同虚设。
class Data{
+ int a{};
+ std::string b{};
+public:
+ void do_something(){
+ // 修改数据成员等...
+ }
+};
+
+class Data_wrapper{
+ Data data;
+ std::mutex m;
+public:
+ template<class Func>
+ void process_data(Func func){
+ std::lock_guard<std::mutex>lc{m};
+ func(data); // 受保护数据传递给函数
+ }
+};
+
+Data* p = nullptr;
+
+void malicious_function(Data& protected_data){
+ p = &protected_data; // 受保护的数据被传递
+}
+
+Data_wrapper d;
+
+void foo(){
+ d.process_data(malicious_function); // 传递了一个恶意的函数
+ p->do_something(); // 在无保护的情况下访问保护数据
+}
+
成员函数模板 process_data
看起来一点问题也没有,使用 std::lock_guard
对数据做了保护,但是调用方传递了 malicious_function
这样一个恶意的函数,使受保护数据传递给外部,可以在没有被互斥量保护的情况下调用 do_something()
。
我们传递的函数就不该是涉及外部副作用的,就应该是单纯的在受互斥量保护的情况下老老实实调用 do_something()
操作受保护的数据。
process_data
的确算是没问题,用户非要做这些事情也是防不住的,我们只是告诉各位可能的情况。
试想一下,有一个玩具,这个玩具有两个部分,必须同时拿到两部分才能玩。比如一个遥控汽车,需要遥控器和玩具车才能玩。有两个小孩,他们都想玩这个玩具。当其中一个小孩拿到了遥控器和玩具车时,就可以尽情玩耍。当另一个小孩也想玩,他就得等待另一个小孩玩完才行。再试想,遥控器和玩具车被放在两个不同的地方,并且两个小孩都想要玩,并且一个拿到了遥控器,另一个拿到了玩具车。问题就出现了,除非其中一个孩子决定让另一个先玩,他把自己的那个部分给另一个小孩。但如果他们都不愿意,那么这个遥控汽车就谁都没有办法玩。
我们当然不在乎小孩抢玩具,我们要聊的是线程对锁的竞争:两个线程需要对它们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个线程的互斥量解锁。因为它们都在等待对方释放互斥量,没有线程工作。 这种情况就是死锁。
避免死锁的一般建议是让两个互斥量以相同的顺序上锁,总在互斥量 B 之前锁住互斥量 A,就通常不会死锁。反面示例
std::mutex m1,m2;
+std::size_t n{};
+
+void f(){
+ std::lock_guard<std::mutex>lc1{ m1 };
+ std::lock_guard<std::mutex>lc2{ m2 };;
+ ++n;
+}
+void f2() {
+ std::lock_guard<std::mutex>lc1{ m2 };
+ std::lock_guard<std::mutex>lc2{ m1 };
+ ++n;
+}
+
f
与 f2
因为互斥量上锁顺序不同,就有死锁风险。函数 f
先锁定 m1
,然后再尝试锁定 m2
,而函数 f2
先锁定 m2
再锁定 m1
。如果两个线程同时运行,它们就可能会彼此等待对方释放其所需的锁,从而造成死锁。
简而言之,有可能函数 f 锁定了 m1,函数 f2 锁定了 m2,函数 f 要往下执行,给 m2 上锁,所以在等待 f2 解锁 m2,然而函数 f2 也在等待函数 f 解锁 m1 它才能往下执行。所以死锁。测试代码。
但是有的时候即使固定锁顺序,依旧会产生问题。当有多个互斥量保护同一个类的对象时,对于相同类型的两个不同对象进行数据的交换操作,为了保证数据交换的正确性,就要避免其它线程修改,确保每个对象的互斥量都锁住自己要保护的区域。如果按照前面的的选择一个固定的顺序上锁解锁,则毫无意义,比如:
struct X{
+ X(const std::string& str) :object{ str } {}
+
+ friend void swap(X& lhs, X& rhs);
+private:
+ std::string object;
+ std::mutex m;
+};
+
+void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::lock_guard<std::mutex>lock1{ lhs.m };
+ std::lock_guard<std::mutex>lock2{ rhs.m };
+ swap(lhs.object, rhs.object);
+}
+
考虑用户调用的时候将参数交换,就会产生死锁:
X a{ "🤣" }, b{ "😅" };
+std::thread t{ [&] {swap(a, b); } }; // 1
+std::thread t2{ [&] {swap(b, a); } }; // 2
+
1
执行的时候,先上锁 a 的互斥量,再上锁 b 的互斥量。
2
执行的时候,先上锁 b 的互斥量,再上锁 a 的互斥量。
完全可能线程 A 执行 1 的时候上锁了 a 的互斥量,线程 B 执行
2
上锁了 b 的互斥量。线程 A 往下执行需要上锁 b 的互斥量,线程 B 则要上锁 a 的互斥量执行完毕才能解锁,哪个都没办法往下执行,死锁。测试代码。
其实也就是回到了第一个示例的问题。
C++ 标准库有很多办法解决这个问题,可以使用 std::lock
,它能一次性锁住多个互斥量,并且没有死锁风险。修改 swap 代码后如下:
void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::lock(lhs.m, rhs.m); // 给两个互斥量上锁
+ std::lock_guard<std::mutex>lock1{ lhs.m,std::adopt_lock };
+ std::lock_guard<std::mutex>lock2{ rhs.m,std::adopt_lock };
+ swap(lhs.object, rhs.object);
+}
+
因为前面已经使用了 std::lock
上锁,所以后的 std::lock_guard
构造都额外传递了一个 std::adopt_lock
参数,让其选择到不上锁的构造函数。函数退出也能正常解锁。
std::lock
给 lhs.m
或 rhs.m
上锁时若抛出异常,则在重抛前对任何已锁的对象调用 unlock()
解锁,也就是 std::lock
要么将互斥量都上锁,要么一个都不锁。
C++17 新增了 std::scoped_lock
,提供此函数的 RAII 包装,通常它比裸调用 std::lock
更好。
所以我们前面的代码可以改写为:
void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::scoped_lock guard{ lhs.m,rhs.m };
+ swap(lhs.object, rhs.object);
+}
+
对此类有兴趣或任何疑问,建议阅读std::scoped_lock
的源码实现与解析
使用 std::scoped_lock
可以将所有 std::lock
替换掉,减少错误发生。
然而它们的帮助都是有限的,一切最终都是依靠开发者使用与管理。
死锁是多线程编程中令人相当头疼的问题,并且死锁经常是不可预见,甚至难以复现,因为在大部分时间里,程序都能正常完成工作。我们可以通过一些简单的规则,约束开发者的行为,帮助写出“无死锁”的代码。
避免嵌套锁
线程获取一个锁时,就别再获取第二个锁。每个线程只持有一个锁,自然不会产生死锁。如果必须要获取多个锁,使用 std::lock
。
避免在持有锁时调用外部代码
这个建议是很简单的:因为代码是外部提供的,所以没办法确定外部要做什么。外部程序可能做任何事情,包括获取锁。在持有锁的情况下,如果用外部代码要获取一个锁,就会违反第一个指导意见,并造成死锁(有时这是无法避免的)。当写通用代码时(比如保护共享数据中的 Date
类)。这不是接口设计者可以处理的,只能寄希望于调用方传递的代码是能正常执行的。
使用固定顺序获取锁
如同第一个示例那样,固定的顺序上锁就不存在问题。
std::unique_lock
灵活的锁std::unique_lock
是 C++11 引入的一种通用互斥包装器,它相比于 std::lock_guard
更加的灵活。当然,它也更加的复杂,尤其它还可以与我们下一章要讲的条件变量一起使用。使用它可以将之前使用 std::lock_guard
的 swap
改写一下:
void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::unique_lock<std::mutex>lock1{ lhs.m, std::defer_lock };
+ std::unique_lock<std::mutex>lock2{ rhs.m, std::defer_lock };
+ std::lock(lock1, lock2);
+ swap(lhs.object, rhs.object);
+ ++n;
+}
+
解释这段代码最简单的方式就是直接展示标准库的源码,首先,我们要了解 std::defer_lock
是“假设调用方线程已拥有互斥体的所有权”。没有所有权自然构造函数就不会上锁,但不止如此。我们还要先知道 std::unique_lock 保有的数据成员(都以 MSVC STL 为例):
private:
+ _Mutex* _Pmtx = nullptr;
+ bool _Owns = false;
+
如你所见很简单,一个互斥量的指针,还有一个就是表示对象是否拥有互斥量所有权的 bool 类型的对象 _Owns
了。我们前面代码会调用构造函数:
unique_lock(_Mutex& _Mtx, defer_lock_t) noexcept
+ : _Pmtx(_STD addressof(_Mtx)), _Owns(false) {} // construct but don't lock
+
如你所见,只是初始化了数据成员而已,注意,这个构造函数没有给互斥量上锁,且 _Owns
为 false
表示没有互斥量所有权。并且 std::unique_lock
是有 lock()
、try_lock()
、unlock()
成员函数的,所以可以直接传递给 std::lock
、 进行调用。这里还需要提一下 lock()
成员函数的代码:
void lock() { // lock the mutex
+ _Validate();
+ _Pmtx->lock();
+ _Owns = true;
+}
+
如你所见,正常上锁,并且把 _Owns
设置为 true
,即表示当前对象拥有互斥量的所有权。那么接下来看析构函数:
~unique_lock() noexcept {
+ if (_Owns) {
+ _Pmtx->unlock();
+ }
+}
+
必须得是当前对象拥有互斥量的所有权析构函数才会调用 unlock() 解锁互斥量。我们的代码因为调用了 lock
,所以 _Owns
设置为 true
,函数结束的时候会解锁互斥量。
设计挺奇怪的对吧,这个所有权语义。其实上面的代码还不够简单直接,我们再举个例子:
std::mutex m;
+
+int main() {
+ std::unique_lock<std::mutex>lock{ m,std::adopt_lock };
+ lock.lock();
+}
+
这段代码运行会抛出异常,原因很简单,因为 std::adopt_lock
只是不上锁,但是有所有权,即 _Owns
设置为 true
了,当运行 lock()
成员函数的时候,调用了 _Validate()
进行检测,也就是:
void _Validate() const { // check if the mutex can be locked
+ if (!_Pmtx) {
+ _Throw_system_error(errc::operation_not_permitted);
+ }
+
+ if (_Owns) {
+ _Throw_system_error(errc::resource_deadlock_would_occur);
+ }
+}
+
满足第二个 if,因为 _Owns
为 true
所以抛出异常,别的标准库也都有类似设计。很诡异的设计对吧,正常。除非我们写成:
lock.mutex()->lock();
+
也就是说 std::unique_lock
要想调用 lock()
成员函数,必须是当前没有所有权。
所以正常的用法其实是,先上锁了互斥量,然后传递 std::adopt_lock
构造 std::unique_lock
对象表示拥有互斥量的所有权,即可在析构的时候正常解锁。如下:
std::mutex m;
+
+int main() {
+ m.lock();
+ std::unique_lock<std::mutex>lock{ m,std::adopt_lock };
+}
+
简而言之:
std::defer_lock
构造函数不上锁,要求构造之后上锁std::adopt_lock
构造函数不上锁,要求在构造之前互斥量上锁我们前面提到了 std::unique_lock
更加灵活,那么灵活在哪?很简单,它拥有 lock()
和 unlock()
成员函数,所以我们能写出如下代码:
void f() {
+ //code..
+
+ std::unique_lock<std::mutex>lock{ m };
+
+ // 涉及共享资源的修改的代码...
+
+ lock.unlock(); // 解锁并释放所有权,析构函数不会再 unlock()
+
+ //code..
+}
+
而不是像之前 std::lock_guard
一样使用 {}
。
另外再聊一聊开销吧,其实倒也还好,多了一个 bool
,内存对齐,x64 环境也就是 16
字节。这都不是最重要的,主要是复杂性和需求,通常建议优先 std::lock_guard
,当它无法满足你的需求或者显得代码非常繁琐,那么可以考虑使用 std::unique_lock
。
首先我们要明白,互斥量满足互斥体 (Mutex)的要求,不可复制不可移动。所谓的在不同作用域传递互斥量,其实只是传递了它们的指针或者引用罢了。可以利用各种类来进行传递,比如前面提到的 std::unique_lock
。
std::unique_lock
可以获取互斥量的所有权,而互斥量的所有权可以通过移动操作转移给其他的 std::unique_lock
对象。有些时候,这种转移(就是调用移动构造)是自动发生的,比如当函数返回 std::unique_lock
对象。另一种情况就是得显式使用 std::move
。
请勿对移动语义和转移所有权抱有错误的幻想,我们说的无非是调用
std::unique_lock
的移动构造罢了:_NODISCARD_CTOR_LOCK unique_lock(unique_lock&& _Other) noexcept : _Pmtx(_Other._Pmtx), _Owns(_Other._Owns) { + _Other._Pmtx = nullptr; + _Other._Owns = false; +} +
将数据成员赋给新对象,原来的置空,这就是所谓的 “所有权”转移,切勿被词语迷惑。
std::unique_lock
是只能移动不可复制的类,它移动即标志其管理的互斥量的所有权转移了。
一种可能的使用是允许函数去锁住一个互斥量,并将互斥量的所有权转移到调用者上,所以调用者可以在这个锁保护的范围内执行代码。
std::unique_lock<std::mutex>get_lock(){
+ extern std::mutex some_mutex;
+ std::unique_lock<std::mutex>lk{ some_mutex };
+ return lk;
+
+}
+void process_data(){
+ std::unique_lock<std::mutex>lk{ get_lock() };
+ // 执行一些任务...
+}
+
return lk
这里会调用移动构造,将互斥量的所有权转移给调用方, process_data
函数结束的时候会解锁互斥量。
我相信你可能对 extern std::mutex some_mutex
有疑问,其实不用感到奇怪,这是一个互斥量的声明,可能别的翻译单元(或 dll 等)有它的定义,成功链接上。我们前面也说了:“所谓的在不同作用域传递互斥量,其实只是传递了它们的指针或者引用罢了”,所以要特别注意互斥量的生存期。
extern 说明符只能搭配变量声明和函数声明(除了类成员或函数形参)。它指定外部链接,而且技术上不影响存储期,但它不能用来定义自动存储期的对象,故所有 extern 对象都具有静态或线程存储期。
如果你简单写一个 std::mutex some_mutex
那么函数 process_data
中的 lk
会持有一个悬垂指针。
举一个使用
extern std::mutex
的完整运行示例。当然,其实理论上你new std::mutex
也是完全可行...... 🤣🤣
std::unique_lock
是灵活的,同样允许在对象销毁之前就解锁互斥量,调用 unlock()
成员函数即可,不再强调。
保护共享数据并非必须使用互斥量,互斥量只是其中一种常见的方式而已,对于一些特殊的场景,也有专门的保护方式,比如对于共享数据的初始化过程的保护。我们通常就不会用互斥量,这会造成很多的额外开销。
我们不想为各位介绍其它乱七八糟的各种保护初始化的方式,我们只介绍三种:双检锁(错误)、使用 std::call_once
、静态局部变量初始化在 C++11 是线程安全。
双检锁(错误)线程不安全
void f(){
+ if(!ptr){ // 1
+ std::lock_guard<std::mutex>lk{ m };
+ if(!ptr){ // 2
+ ptr.reset(new some); // 3
+ }
+ }
+ ptr->do_something(); // 4
+}
+
① 是查看指针是否为空,空才需要初始化,才需要获取锁。指针为空,当获取锁后会再检查一次指针②(这就是双重检查),避免另一线程在第一次检查后再做初始化,并且让当前线程获取锁。
然而这显然没用,因为有潜在的条件竞争。未被锁保护的读取操作①没有与其他线程里被锁保护的写入操作③进行同步,因此就会产生条件竞争。
简而言之:一个线程知道另一个线程已经在执行③,但是此时还没有创建 some 对象,而只是分配内存对指针写入。那么这个线程在①的时候就不会进入,直接执行了 ptr->do_something()
④,得不到正确的结果,因为对象还没构造。
如果你觉得难以理解,那就记住
ptr.reset(new some);
并非是不可打断不可交换的固定指令。这种错误写法在一些单例中也非常的常见。如果你的同事或上司写出此代码,一般不建议指出,因为不见得你能教会他们,不要“没事找事”,只要不影响自己即可。
C++ 标准委员会也认为处理此问题很重要,所以标准库提供了 std::call_once
和 std::once_flag
来处理这种情况。比起锁住互斥量并显式检查指针,每个线程只需要使用 std::call_once
就可以。使用 std::call_once
比显式使用互斥量消耗的资源更少,特别是当初始化完成之后。
std::shared_ptr<some>ptr;
+std::mutex m;
+std::once_flag resource_flag;
+
+void init_resource(){
+ ptr.reset(new some);
+}
+
+void foo(){
+ std::call_once(resource_flag, init_resource); // 线程安全的一次初始化
+ ptr->do_something();
+}
+
以上代码 std::once_flag
对象是命名空间作用域声明,如果你有需要,它也可以是类的成员。用于搭配 std::call_once
使用,保证线程安全的一次初始化。std::call_once
只需要接受可调用 (Callable)对象即可,也不要求一定是函数。
“初始化”,自然是一次。但是
std::call_once
也有一些例外情况(比如异常)会让传入的可调用对象被多次调用,即“多次”初始化:std::once_flag flag; +int n = 0; + +void f(){ + std::call_once(flag, [] { + ++n; + std::cout << "第" << n << "次调用\n"; + throw std::runtime_error("异常"); + }); +} + +int main(){ + try{ + f(); + } + catch (std::exception&){} + + try{ + f(); + } + catch (std::exception&){} +} +
测试链接。正常情况会保证传入的可调用对象只调用一次,即初始化只有一次。异常之类的是例外。
静态局部变量初始化在 C++11 是线程安全
class my_class;
+my_class& get_my_class_instance(){
+ static my_class instance; // 线程安全的初始化过程 初始化严格发生一次
+}
+
多线程可以安全的调用 get_my_class_instance
函数,不用为数据竞争而担心。此方式也在单例中多见,是简单合理的做法。
其实还有不少其他的做法或者反例,但是觉得没必要再聊了,因为本文不是详尽的文档,而是“教程”。
试想一下,你有一个数据结构存储了用户的设置信息,每次用户打开程序的时候,都要进行读取,且运行时很多地方都依赖这个数据结构需要读取,所以为了效率,我们使用了多线程读写。这个数据结构很少进行改变,而我们知道,多线程读取,是没有数据竞争的,是安全的,但是有些时候又不可避免的有修改和读取都要工作的时候,所以依然必须得使用互斥量进行保护。
然而使用 std::mutex
的开销是过大的,它不管有没有发生数据竞争(也就是就算全是读的情况)也必须是老老实实上锁解锁,只有一个线程可以运行。如果你学过其它语言或者操作系统,相信这个时候就已经想到了:“读写锁”。
C++ 标准库自然为我们提供了: std::shared_timed_mutex
(C++14)、 std::shared_mutex
(C++17)。它们的区别简单来说,前者支持更多的操作方式,后者有更高的性能优势。
std::shared_mutex
同样支持 std::lock_guard
、std::unique_lock
。和 std::mutex
做的一样,保证写线程的独占访问。而那些无需修改数据结构的读线程,可以使用 std::shared_lock<std::shared_mutex>
获取访问权,多个线程可以一起读取。
class Settings {
+private:
+ std::map<std::string, std::string> data_;
+ mutable std::shared_mutex mutex_; // “M&M 规则”:mutable 与 mutex 一起出现
+
+public:
+ void set(const std::string& key, const std::string& value) {
+ std::lock_guard<std::shared_mutex> lock(mutex_);
+ data_[key] = value;
+ }
+
+ std::string get(const std::string& key) const {
+ std::shared_lock<std::shared_mutex> lock(mutex_);
+ auto it = data_.find(key);
+ return (it != data_.end()) ? it->second : ""; // 如果没有找到键返回空字符串
+ }
+};
+
std::shared_timed_mutex
具有 std::shared_mutex
的所有功能,并且额外支持超时功能。所以以上代码可以随意更换这两个互斥量。
std::recursive_mutex
线程对已经上锁的 std::mutex
再次上锁是错误的,这是未定义行为。然而在某些情况下,一个线程会尝试在释放一个互斥量前多次获取,所以提供了std::recursive_mutex
。
std::recursive_mutex
是 C++ 标准库提供的一种互斥量类型,它允许同一线程多次锁定同一个互斥量,而不会造成死锁。当同一线程多次对同一个 std::recursive_mutex
进行锁定时,只有在解锁与锁定次数相匹配时,互斥量才会真正释放。但它并不影响不同线程对同一个互斥量进行锁定的情况。不同线程对同一个互斥量进行锁定时,会按照互斥量的规则进行阻塞,
#include <iostream>
+#include <thread>
+#include <mutex>
+
+std::recursive_mutex mtx;
+
+void recursive_function(int count) {
+ // 递归函数,每次递归都会锁定互斥量
+ mtx.lock();
+ std::cout << "Locked by thread: " << std::this_thread::get_id() << ", count: " << count << std::endl;
+ if (count > 0) {
+ recursive_function(count - 1); // 递归调用
+ }
+ mtx.unlock(); // 解锁互斥量
+}
+
+int main() {
+ std::thread t1(recursive_function, 3);
+ std::thread t2(recursive_function, 2);
+
+ t1.join();
+ t2.join();
+}
+
运行测试。
lock
:线程可以在递归互斥体上重复调用 lock
。在线程调用 unlock
匹配次数后,所有权才会得到释放。
unlock
:若所有权层数为 1(此线程对 lock() 的调用恰好比 unlock()
多一次 )则解锁互斥体,否则将所有权层数减少 1。
我们重点的强调了一下这两个成员函数的这个概念,其实也很简单,总而言之就是 unlock
必须和 lock
的调用次数一样,才会真正解锁互斥量。
同样的,我们也可以使用 std::lock_guard
、std::unique_lock
帮我们管理 std::recursive_mutex
,而非显式调用 lock
与 unlock
:
void recursive_function(int count) {
+ std::lock_guard<std::recursive_mutex>lc{ mtx };
+ std::cout << "Locked by thread: " << std::this_thread::get_id() << ", count: " << count << std::endl;
+ if (count > 0) {
+ recursive_function(count - 1);
+ }
+}
+
运行测试。
new
、delete
是线程安全的吗?如果你的标准达到 C++11,要求下列函数是线程安全的:
new
运算符和 delete
运算符的库版本new
运算符和 delete
运算符的用户替换版本所以以下函数在多线程运行是线程安全的:
void f(){
+ T* p = new T{};
+ delete p;
+}
+
内存分配、释放操作是线程安全,构造和析构不涉及共享资源。而局部对象 p
对于每个线程来说是独立的。换句话说,每个线程都有其自己的 p
对象实例,因此它们不会共享同一个对象,自然没有数据竞争。
如果 p
是全局对象(或者外部的,只要可被多个线程读写),多个线程同时对其进行访问和修改时,就可能会导致数据竞争和未定义行为。因此,确保全局对象的线程安全访问通常需要额外的同步措施,比如互斥量或原子操作。
T* p = nullptr;
+void f(){
+ p = new T{}; // 存在数据竞争
+ delete p;
+}
+
即使 p
是局部对象,如果构造函数(析构同理)涉及读写共享资源,那么一样存在数据竞争,需要进行额外的同步措施进行保护。
int n = 1;
+
+struct X{
+ X(int v){
+ ::n += v;
+ }
+};
+
+void f(){
+ X* p = new X{ 1 }; // 存在数据竞争
+ delete p;
+}
+
一个直观的展示是,我们可以在构造函数中使用
std::cout
,看到无序的输出,例子。
值得注意的是,如果是自己重载 operator new
、operator delete
替换了库的全局版本,那么它的线程安全就要我们来保证。
// 全局的 new 运算符,替换了库的版本
+void* operator new (std::size_t count){
+ return ::operator new(count);
+}
+
以上代码是线程安全的,因为 C++11 保证了 new 运算符的库版本,即 ::operator new
是线程安全的,我们直接调用它自然不成问题。如果你需要更多的操作,就得使用互斥量之类的方式保护了。
总而言之,new
表达式线程安全要考虑三方面:operator new
、构造函数、修改指针。
delete
表达式线程安全考虑两方面:operator delete
、析构函数。
C++ 只保证了 operator new
、operator delete
这两个方面的线程安全(不包括用户定义的),其它方面就得自己保证了。前面的内容也都提到了。
本章讨论了多线程的共享数据引发的恶性条件竞争会带来的问题。并说明了可以使用互斥量(std::mutex
)保护共享数据,并且要注意互斥量上锁的“粒度”。C++标准库提供了很多工具,包括管理互斥量的管理类(std::lock_guard
),但是互斥量只能解决它能解决的问题,并且它有自己的问题(死锁)。同时我们讲述了一些避免死锁的方法和技术。还讲了一下互斥量所有权转移。然后讨论了面对不同情况保护共享数据的不同方式,使用 std::call_once()
保护共享数据的初始化过程,使用读写锁(std::shared_mutex
)保护不常更新的数据结构。以及特殊情况可能用到的互斥量 recursive_mutex
,有些人可能喜欢称作:递归锁。最后聊了一下 new
、delete
运算符的库函数实际是线程安全的,以及一些问题。
下一章,我们将开始讲述同步操作,会使用到 Futures、条件变量等设施。
"同步操作"是指在计算机科学和信息技术中的一种操作方式,其中不同的任务或操作按顺序执行,一个操作完成后才能开始下一个操作。在同步操作中,各个任务之间通常需要相互协调和等待,以确保数据的一致性和正确性。
本章的主要内容有:
条件变量
std::future
等待异步任务
在规定时间内等待
本章将讨论如何使用条件变量等待事件,介绍 future 等标准库设施用作同步操作。
假设你正在一辆夜间运行的地铁上,那么你要如何在正确的站点下车呢?
一直不休息,每一站都能知道,这样就不会错过你要下车的站点,但是这会很疲惫。
可以看一下时间,估算一下地铁到达目的地的时间,然后设置一个稍早的闹钟,就休息。这个方法听起来还行,但是你可能被过早的叫醒,甚至估算错误导致坐过站,又或者闹钟没电了睡过站。
事实上最简单的方式是,到站的时候有人或者其它东西能将你叫醒(比如手机的地图,到达设置的位置就提醒)。
这和线程有什么关系呢?其实第一种方法就是在说”忙等待(busy waiting)”也称“自旋“。
bool flag = false;
+std::mutex m;
+
+void wait_for_flag(){
+ std::unique_lock<std::mutex>lk{ m };
+ while (!flag){
+ lk.unlock(); // 1 解锁互斥量
+ lk.lock(); // 2 上锁互斥量
+ }
+}
+
第二种方法就是加个延时,这种实现进步了很多,减少浪费的执行时间,但很难确定正确的休眠时间。这会影响到程序的行为,在需要快速响应的程序中就意味着丢帧或错过了一个时间片。循环中,休眠②前函数对互斥量解锁①,再休眠结束后再对互斥量上锁,让另外的线程有机会获取锁并设置标识(因为修改函数和等待函数共用一个互斥量)。
void wait_for_flag(){
+ std::unique_lock<std::mutex>lk{ m };
+ while (!flag){
+ lk.unlock(); // 1 解锁互斥量
+ std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 2 休眠
+ lk.lock(); // 3 上锁互斥量
+ }
+}
+
第三种方式(也是最好的)实际上就是使用条件变量了。通过另一线程触发等待事件的机制是最基本的唤醒方式,这种机制就称为“条件变量”。
C++ 标准库对条件变量有两套实现:std::condition_variable
和 std::condition_variable_any
,这两个实现都包含在 <condition_variable>
头文件中。
condition_variable_any
类是 std::condition_variable
的泛化。相对于只在 std::unique_lock<std::mutex>
上工作的 std::condition_variable
,condition_variable_any
能在任何满足可基本锁定(BasicLockable)要求的锁上工作,所以增加了 _any
后缀。显而易见,这种区分必然是 any
版更加通用但是却又更多的性能开销。所以通常首选 std::condition_variable
。有特殊需求,才会考虑 std::condition_variable_any
。
std::mutex mtx;
+std::condition_variable cv;
+bool arrived = false;
+
+void waitForArrival() {
+ std::unique_lock<std::mutex> lck(mtx);
+ cv.wait(lck, []{ return arrived; }); // 等待 arrived 变为 true
+ std::cout << "到达目的地,可以下车了!" << std::endl;
+}
+
+void simulateArrival() {
+ std::this_thread::sleep_for(std::chrono::seconds(5)); // 模拟地铁到站,假设5秒后到达目的地
+ {
+ std::lock_guard<std::mutex> lck(mtx);
+ arrived = true; // 设置条件变量为 true,表示到达目的地
+ }
+ cv.notify_one(); // 通知等待的线程
+}
+
std::mutex mtx
: 创建了一个互斥量,用于保护共享数据的访问,确保在多线程环境下的数据同步。
std::condition_variable cv
: 创建了一个条件变量,用于线程间的同步,当条件不满足时,线程可以等待,直到条件满足时被唤醒。
bool arrived = false
: 设置了一个标志位,表示是否到达目的地。
在 waitForArrival
函数中:
std::unique_lock<std::mutex> lck(mtx)
: 使用互斥量创建了一个独占锁。
cv.wait(lck, []{ return arrived; })
: 阻塞当前线程,释放(unlock)锁,直到条件被满足。
一旦条件满足,即 arrived
变为 true,并且条件变量 cv
被唤醒(包括虚假唤醒),那么当前线程会重新获取锁(lock),并执行后续的操作。
在 simulateArrival
函数中:
std::this_thread::sleep_for(std::chrono::seconds(5))
: 模拟地铁到站,暂停当前线程 5 秒。
设置 arrived
为 true,表示到达目的地。
cv.notify_one()
: 唤醒一个等待条件变量的线程。
这样,当 simulateArrival
函数执行后,arrived
被设置为 true,并且通过 cv.notify_one()
唤醒了等待在条件变量上的线程,从而使得 waitForArrival
函数中的等待结束,可以执行后续的操作,即输出提示信息。
条件变量的 wait
成员函数有两个版本,以上代码使用的就是第二个版本,传入了一个谓词。
void wait(std::unique_lock<std::mutex>& lock); // 1
+
+template<class Predicate>
+void wait(std::unique_lock<std::mutex>& lock, Predicate pred); // 2
+
②等价于:
while (!pred())
+ wait(lock);
+
这可以避免“虚假唤醒(spurious wakeup)”。
条件变量虚假唤醒是指在使用条件变量进行线程同步时,有时候线程可能会在没有收到通知的情况下被唤醒。问题取决于程序和系统的具体实现。解决方法很简单,在循环中等待并判断条件可一并解决。使用 C++ 标准库则没有这个烦恼了。
在本节中,我们介绍了一个更为复杂的示例,以巩固我们对条件变量的学习。为了实现一个线程安全的队列,我们需要考虑以下两个关键点:
当执行 push
操作时,需要确保没有其他线程正在执行 push
或 pop
操作;同样,在执行 pop
操作时,也需要确保没有其他线程正在执行 push
或 pop
操作。
当队列为空时,不应该执行 pop
操作。因此,我们需要使用条件变量来传递一个谓词,以确保在执行 pop
操作时队列不为空。
基于以上思考,我们设计了一个名为 threadsafe_queue
的模板类,如下:
template<typename T>
+class threadsafe_queue {
+ mutable std::mutex m; // 互斥量,用于保护队列操作的独占访问
+ std::condition_variable data_cond; // 条件变量,用于在队列为空时等待
+ std::queue<T> data_queue; // 实际存储数据的队列
+public:
+ threadsafe_queue() {}
+ void push(T new_value) {
+ {
+ std::lock_guard<std::mutex>lk(m);
+ data_queue.push(new_value);
+ }
+ data_cond.notify_one();
+ }
+ // 从队列中弹出元素(阻塞直到队列不为空)
+ void pop(T& value) {
+ std::unique_lock<std::mutex>lk(m);
+ data_cond.wait(lk, [this] {return !data_queue.empty(); });
+ value = data_queue.front();
+ data_queue.pop();
+ }
+ // 从队列中弹出元素(阻塞直到队列不为空),并返回一个指向弹出元素的 shared_ptr
+ std::shared_ptr<T> pop() {
+ std::unique_lock<std::mutex>lk(m);
+ data_cond.wait(lk, [this] {return !data_queue.empty(); });
+ std::shared_ptr<T>res(std::make_shared<T>(data_queue.front()));
+ data_queue.pop();
+ return res;
+ }
+ bool empty()const {
+ std::lock_guard<std::mutex>lk(m);
+ return data_queue.empty();
+ }
+};
+
请无视我们省略的构造、赋值、交换、try_xx
等操作。以上示例已经足够。
光写好了肯定不够,我们还得测试运行,我们可以写一个经典的:”生产者消费者模型“,也就是一个线程 push
”生产“,一个线程 pop
”消费“。
void producer(threadsafe_queue<int>& q) {
+ for (int i = 0; i < 5; ++i) {
+ q.push(i);
+ }
+}
+void consumer(threadsafe_queue<int>& q) {
+ for (int i = 0; i < 5; ++i) {
+ int value{};
+ q.pop(value);
+ }
+}
+
两个线程分别运行 producer
与 consumer
,为了观测运行我们可以为 push
与 pop
中增加打印语句:
std::cout << "push:" << new_value << std::endl;
+std::cout << "pop:" << value << std::endl;
+
可能的运行结果是:
push:0
+pop:0
+push:1
+pop:1
+push:2
+push:3
+push:4
+pop:2
+pop:3
+pop:4
+
这很正常,到底哪个线程会抢到 CPU 时间片持续运行,是系统调度决定的,我们只需要保证一开始提到的两点就行了:
push
与pop
都只能单独执行;当队列为空时,不执行pop
操作。
我们可以给一个简单的示意图帮助你理解这段运行结果:
初始状态:队列为空
++---+---+---+---+---+
+
+Producer 线程插入元素 0:
++---+---+---+---+---+
+| 0 | | | | |
+
+Consumer 线程弹出元素 0:
++---+---+---+---+---+
+| | | | | |
+
+Producer 线程插入元素 1:
++---+---+---+---+---+
+| 1 | | | | |
+
+Consumer 线程弹出元素 1:
++---+---+---+---+---+
+| | | | | |
+
+Producer 线程插入元素 2:
++---+---+---+---+---+
+| | 2 | | | |
+
+Producer 线程插入元素 3:
++---+---+---+---+---+
+| | 2 | 3 | | |
+
+Producer 线程插入元素 4:
++---+---+---+---+---+
+| | 2 | 3 | 4 | |
+
+Consumer 线程弹出元素 2:
++---+---+---+---+---+
+| | | 3 | 4 | |
+
+Consumer 线程弹出元素 3:
++---+---+---+---+---+
+| | | | 4 | |
+
+Consumer 线程弹出元素 4:
++---+---+---+---+---+
+| | | | | |
+
+队列为空,所有元素已被弹出
+
到此,也就可以了。
future
其实就是异步。
举个例子:我们在车站等车,你可能会做一些别的事情打发时间,比如学习现代 C++ 模板教程、观看 mq白 的视频教程、玩手机等。不过,你始终在等待一件事情:车到站。
C++ 标准库将这种事件称为 future。它用于处理线程中需要等待某个事件的情况,线程知道预期结果。等待的同时也可以执行其它的任务。
C++ 标准库有两种 future,都声明在 <future>
头文件中:独占的 std::future
、共享的 std::shared_future
。它们的区别与 std::unique_ptr
和 std::shared_ptr
类似。std::future
只能与单个指定事件关联,而 std::shared_future
能关联多个事件。它们都是模板,它们的模板类型参数,就是其关联的事件(函数)的返回类型。当多个线程需要访问一个独立 future 对象时, 必须使用互斥量或类似同步机制进行保护。而多个线程访问同一共享状态,若每个线程都是通过其自身的 shared_future
对象副本进行访问,则是安全的。
最简单的作用是,我们先前讲的 std::thread
执行任务是没有返回值的,这个问题就能使用 future 解决。
假设需要执行一个耗时任务并获取其返回值,但是并不急切的需要它。那么就可以启动新线程计算,然而 std::thread
没提供直接接收返回值的机制。所以我们可以使用 std::async
函数模板。
使用 std::async
启动一个异步任务,它会返回一个 std::future
对象,这个对象和任务关联,将持有最终计算出来的结果。当需要这个值的时候,只需要调用 get()
成员函数,就会阻塞直到 future
为就绪为止(即任务执行完毕),返回执行结果。
#include <iostream>
+#include <thread>
+#include <future>
+
+int task(int n){
+ std::cout << "异步任务 ID: " << std::this_thread::get_id() << '\n';
+ return n * n;
+}
+
+int main(){
+ std::future<int> future = std::async(task, 10);
+ std::cout << "main\n";
+ std::cout << future.get() << '\n';
+}
+
运行测试。
与 std::thread
一样,std::async
支持任意可调用(Callable)对象,以及传递调用参数。包括支持使用 std::ref
,以及移动的问题。我们下面详细聊一下 std::async
参数传递的事。
struct X{
+ int operator()(int n)const{
+ return n * n;
+ }
+};
+struct Y{
+ int f(int n)const{
+ return n * n;
+ }
+};
+void f(int& p) { std::cout << &p << '\n'; }
+
+int main(){
+ Y y;
+ int n = 0;
+ auto t1 = std::async(X{}, 10);
+ auto t2 = std::async(&Y::f,&y,10);
+ auto t3 = std::async([] {});
+ auto t4 = std::async(f, std::ref(n));
+ std::cout << &n << '\n';
+}
+
运行测试。
如你所见,它支持所有可调用(Callable)对象,并且也是默认拷贝,必须使用 std::ref
才能传递引用。并且它和 std::thread
一样,内部会将保有的参数副本转换为右值表达式进行传递,这是为了那些只支持移动的类型,左值引用没办法引用右值表达式,所以如果不使用 std::ref
,这里 void f(int&)
就会导致编译错误,如果是 void f(const int&)
则可以通过编译,不过引用的不是我们传递的局部对象。
void f(const int& p) {}
+void f2(int& p ){}
+
+int n = 0;
+std::async(f, n); // OK! 可以通过编译,不过引用的并非是局部的n
+std::async(f2, n); // Error! 无法通过编译
+
我们来展示使用 std::move
,也就移动传递参数:
struct move_only {
+ move_only() { std::puts("默认构造"); }
+ move_only(const move_only&) = delete;
+ move_only(move_only&&)noexcept {
+ std::puts("移动构造");
+ }
+};
+
+void task(move_only x){
+ std::cout << "异步任务 ID: " << std::this_thread::get_id() << '\n';
+}
+
+int main(){
+ move_only x;
+ std::future<void> future = std::async(task, std::move(x));
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
+ std::cout << "main\n";
+ future.wait(); // 等待异步任务执行完毕
+}
+
运行测试。
如你所见,它支持只移动类型,我们将参数使用 std::move
传递。
接下来我们聊 std::async
的执行策略,我们前面一直没有使用,其实就是在传递可调用对象与参数之前传递枚举值罢了:
std::launch::async
在不同线程上执行异步任务。std::launch::deferred
惰性求值,不创建线程,等待 future
对象调用 wait
或 get
成员函数的时候执行任务。而我们先前没有写明这个参数,实际上是默认:std::launch::async | std::launch::deferred
,也就是说由实现选择到底是否创建线程执行异步任务。我们来展示一下:
void f(){
+ std::cout << std::this_thread::get_id() << '\n';
+}
+
+int main(){
+ std::cout << std::this_thread::get_id() << '\n';
+ auto f1 = std::async(std::launch::deferred, f);
+ f1.wait(); // 在 wait 或 get() 调用时执行,不创建线程
+ auto f2 = std::async(std::launch::async,f); // 创建线程执行异步任务
+ auto f3 = std::async(std::launch::deferred | std::launch::async, f); // 实现选择的执行方式
+}
+
运行测试。
其实到此基本就差不多了,我们再介绍两个常见问题即可:
如果从 std::async
获得的 std::future 没有被移动或绑定到引用,那么在完整表达式结尾, std::future 的析构函数将阻塞到异步计算完成。
std::async(std::launch::async, []{ f(); }); // 临时量的析构函数等待 f()
+std::async(std::launch::async, []{ g(); }); // f() 完成前不开始
+
如你所见,这并不能创建异步任务,会堵塞,然后逐个执行。
被移动的 std::future
没有所有权,失去共享状态,不能调用 get
、wait
成员函数。
auto t = std::async([] {});
+std::future<void> future{ std::move(t) };
+t.wait(); // Error! 抛出异常
+
如同没有线程资源所有权的 std::thread
对象调用 join()
一样错误,这是移动语义的基本语义逻辑。
future
与 std::packaged_task
类模板 std::packaged_task
包装任何可调用(Callable)目标(函数、lambda 表达式、bind 表达式或其它函数对象),使得能异步调用它。其返回值或所抛异常被存储于能通过 std::future 对象访问的共享状态中。
通常它会和 std::future
一起使用,不过也可以单独使用,我们一步一步来:
std::packaged_task<double(int, int)> task([](int a, int b){
+ return std::pow(a, b);
+});
+task(10, 2); // 执行传递的 lambda,但无法获取返回值
+
它有 operator()
的重载,它会执行我们传递的可调用(Callable)对象,不过这个重载的返回类型是 void
没办法获取返回值。
如果想要异步的获取返回值,我们需要在调用 operator()
之前,让它和 future 关联,然后使用 future.get()
,也就是:
std::packaged_task<double(int, int)> task([](int a, int b){
+ return std::pow(a, b);
+});
+std::future<double>future = task.get_future();
+task(10, 2); // 此处执行任务
+std::cout << future.get() << '\n'; // 不堵塞,此处获取返回值
+
运行测试。
先关联任务,再执行任务,当我们想要获取任务的返回值的时候,就 future.get()
即可。值得注意的是,任务并不会在线程中执行,想要在线程中执行异步任务,然后再获取返回值,我们可以这么做:
std::packaged_task<double(int, int)> task([](int a, int b){
+ return std::pow(a, b);
+});
+std::future<double>future = task.get_future();
+std::thread t{ std::move(task),10,2 }; // 任务在线程中执行
+t.join();
+
+std::cout << future.get() << '\n'; // 并不堵塞,获取任务返回值罢了
+
运行测试。
因为 task
本身是重载了 operator()
的,是可调用对象,自然可以传递给 std::thread
执行,以及传递调用参数。唯一需要注意的是我们使用了 std::move
,这是因为 std::packaged_task
只能移动,不能复制。
简而言之,其实 std::packaged_task
也就是一个“包装”类而已,它本身并没什么特殊的,老老实实执行我们传递的任务,且方便我们获取返回值罢了,明确这一点,那么一切都不成问题。
std::packaged_task
也可以在线程中传递,在需要的时候获取返回值,而非像上面那样将它自己作为可调用对象:
template<typename R, typename...Ts, typename...Args>
+ requires std::invocable<std::packaged_task<R(Ts...)>&, Args...>
+void async_task(std::packaged_task<R(Ts...)>& task, Args&&...args) {
+ // todo..
+ task(std::forward<Args>(args)...);
+}
+
+int main() {
+ std::packaged_task<int(int,int)> task([](int a,int b){
+ return a + b;
+ });
+
+ int value = 50;
+ std::future<int> future = task.get_future();
+ // 创建一个线程来执行异步任务
+ std::thread t{ [&] {async_task(task, value, value); } };
+ std::cout << future.get() << '\n';
+ t.join();
+}
+
运行测试。
我们套了一个 lambda,这是因为函数模板不是函数,它并非具体类型,没办法直接被那样传递使用,只能包一层了。这只是一个简单的示例,展示可以使用 std::packaged_task
作函数形参,然后我们来传递任务进行异步调用等操作。
我们再将第二章实现的并行 sum
改成 std::package_task
+ std::future
的形式:
template<typename ForwardIt>
+auto sum(ForwardIt first, ForwardIt last) {
+ using value_type = std::iter_value_t<ForwardIt>;
+ std::size_t num_threads = std::thread::hardware_concurrency();
+ std::ptrdiff_t distance = std::distance(first, last);
+
+ if (distance > 1024000) {
+ // 计算每个线程处理的元素数量
+ std::size_t chunk_size = distance / num_threads;
+ std::size_t remainder = distance % num_threads;
+
+ // 存储每个线程要执行的任务
+ std::vector<std::packaged_task<value_type()>>tasks;
+ // 和每一个任务进行关联的 future 用于获取返回值
+ std::vector<std::future<value_type>>futures(num_threads);
+
+ // 存储关联线程的线程对象
+ std::vector<std::thread> threads;
+
+ // 制作任务、与 future 关联、启动线程执行
+ auto start = first;
+ for (std::size_t i = 0; i < num_threads; ++i) {
+ auto end = std::next(start, chunk_size + (i < remainder ? 1 : 0));
+ tasks.emplace_back(std::packaged_task<value_type()>{[start, end, i] {
+ return std::accumulate(start, end, value_type{});
+ }});
+ start = end; // 开始迭代器不断向前
+ futures[i] = tasks[i].get_future(); // 任务与 std::future 关联
+ threads.emplace_back(std::move(tasks[i]));
+ }
+
+ // 等待所有线程执行完毕
+ for (auto& thread : threads)
+ thread.join();
+
+ // 汇总线程的计算结果
+ value_type total_sum {};
+ for (std::size_t i = 0; i < num_threads; ++i) {
+ total_sum += futures[i].get();
+ }
+ return total_sum;
+ }
+
+ value_type total_sum = std::accumulate(first, last, value_type{});
+ return total_sum;
+}
+
运行测试。
相比于之前,其实不同无非是定义了 std::vector<std::packaged_task<value_type()>> tasks
与 std::vector<std::future<value_type>> futures
,然后在循环中制造任务插入容器,关联 tuple,再放到线程中执行。最后汇总的时候写一个循环,futures[i].get()
获取任务的返回值加起来即可。
到此,也就可以了。
std::promise
类模板 std::promise
用于存储一个值或一个异常,之后通过 std::promise
对象所创建的 std::future 对象异步获得。
// 计算函数,接受一个整数并返回它的平方
+void calculate_square(std::promise<int> promiseObj, int num) {
+ // 模拟一些计算
+ std::this_thread::sleep_for(std::chrono::seconds(1));
+
+ // 计算平方并设置值到 promise 中
+ promiseObj.set_value(num * num);
+}
+
+// 创建一个 promise 对象,用于存储计算结果
+std::promise<int> promise;
+
+// 从 promise 获取 future 对象进行关联
+std::future<int> future = promise.get_future();
+
+// 启动一个线程进行计算
+int num = 5;
+std::thread t(calculate_square, std::move(promise), num);
+
+// 阻塞,直到结果可用
+int result = future.get();
+std::cout << num << " 的平方是:" << result << std::endl;
+
+t.join();
+
运行测试。
我们在新线程中通过调用 set_value()
函数设置 promise
的值,并在主线程中通过与其关联的 future 对象的 get()
成员函数获取这个值,如果promise
的值还没有被设置,那么将阻塞当前线程,直到被设置为止。同样的 std::promise
只能移动,不可复制,所以我们使用了 std::move
进行传递。
除了 set_value()
函数外,std::promise
还有一个 set_exception()
成员函数,它接受一个 std::exception_ptr
类型的参数,这个参数通常通过 std::current_exception()
获取,用于指示当前线程中抛出的异常。然后,std::future
对象通过 get()
函数获取这个异常,如果 promise
所在的函数有异常被抛出,则 std::future
对象会重新抛出这个异常,从而允许主线程捕获并处理它。
void throw_function(std::promise<int> prom) {
+ try {
+ throw std::runtime_error("一个异常");
+ }
+ catch (...) {
+ prom.set_exception(std::current_exception());
+ }
+}
+
+int main() {
+ std::promise<int> prom;
+ std::future<int> fut = prom.get_future();
+
+ std::thread t(throw_function, std::move(prom));
+
+ try {
+ std::cout << "等待线程执行,抛出异常并设置\n";
+ fut.get();
+ }
+ catch (std::exception& e) {
+ std::cerr << "来自线程的异常: " << e.what() << '\n';
+ }
+ t.join();
+}
+
运行结果:
等待线程执行,抛出异常并设置
+来自线程的异常: 一个异常
+
你可能对这段代码还有一些疑问:我们写的是 promised<int>
,但是却没有使用 set_value
设置值,你可能会想着再写一行 prom.set_value(0)
?
共享状态的 promise 已经存储值或者异常,再次调用 set_value
(set_exception
) 会抛出 std::future_error 异常,将错误码设置为 promise_already_satisfied
。这是因为 std::promise
对象只能是存储值或者异常其中一种,而无法共存。
简而言之,set_value
与 set_exception
二选一,如果先前调用了 set_value
,就不可再次调用 set_exception
,反之亦然(不然就会抛出异常),示例如下:
void throw_function(std::promise<int> prom) {
+ prom.set_value(100);
+ try {
+ throw std::runtime_error("一个异常");
+ }
+ catch (...) {
+ try{
+ // 共享状态的 promise 已存储值,调用 set_exception 产生异常
+ prom.set_exception(std::current_exception());
+ }catch (std::exception& e){
+ std::cerr << "来自 set_exception 的异常: " << e.what() << '\n';
+ }
+ }
+}
+
+int main() {
+ std::promise<int> prom;
+ std::future<int> fut = prom.get_future();
+
+ std::thread t(throw_function, std::move(prom));
+
+ std::cout << "等待线程执行,抛出异常并设置\n";
+ std::cout << "值:" << fut.get() << '\n'; // 100
+
+ t.join();
+}
+
运行结果:
等待线程执行,抛出异常并设置
+值:100
+来自 set_exception 的异常: promise already satisfied
+
之前的例子中都在用 std::future
,不过 std::future
也有局限性。很多线程在等待的时候,只有一个线程能获取结果。当多个线程等待相同事件的结果时,就需要使用 std::shared_future
来替代 std::future
了。
std::thread
的构造-源码解析我们这单章是为了专门解释一下 C++11 引入的 std::thread
是如何构造的,是如何创建线程传递参数的,让你彻底了解这个类。
我们以 MSVC 实现的 std::thread
代码进行讲解,MSVC STL 很早之前就不支持 C++11 了,它的实现完全基于 C++14,出于某些原因 C++17 的一些库(如 invoke
, _v 变量模板)被向后移植到了 C++14 模式,所以即使是 C++11 标准库设施,实现中可能也是使用到了 C++14、17 的东西。
std::thread
的数据成员std::thread
只保有一个私有数据成员 _Thr
:
private:
+ _Thrd_t _Thr;
+
_Thrd_t
是一个结构体,它保有两个数据成员:
using _Thrd_id_t = unsigned int;
+struct _Thrd_t { // thread identifier for Win32
+ void* _Hnd; // Win32 HANDLE
+ _Thrd_id_t _Id;
+};
+
结构很明确,这个结构体的 _Hnd
成员是指向线程的句柄,_Id
成员就是保有线程的 ID。
在64 位操作系统,因为内存对齐,指针 8 ,无符号 int 4,这个结构体 _Thrd_t
就是占据 16 个字节。也就是说 sizeof(std::thread)
的结果应该为 16。
std::thread
的构造函数std::thread
有四个构造函数,分别是:
默认构造函数,构造不关联线程的新 std::thread 对象。
thread() noexcept : _Thr{} {}
+
移动构造函数,转移线程的所有权,构造 other 关联的执行线程的 std::thread
对象。此调用后 other 不再表示执行线程失去了线程的所有权。
thread(thread&& _Other) noexcept : _Thr(_STD exchange(_Other._Thr, {})) {}
+
_STD 是一个宏,展开就是 ::std::
,也就是 ::std::exchange
,将 _Other._Thr
赋为 {}
(也就是置空),返回 _Other._Thr
的旧值用以初始化当前对象的数据成员 _Thr
。
复制构造函数被定义为弃置的,std::thread 不可复制。两个 std::thread 不可表示一个线程,std::thread 对线程资源是独占所有权。
thread(const thread&) = delete;
+
构造新的 std::thread
对象并将它与执行线程关联。表示新的执行线程开始执行。
template <class _Fn, class... _Args, enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0>
+ _NODISCARD_CTOR_THREAD explicit thread(_Fn&& _Fx, _Args&&... _Ax) {
+ _Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
+ }
+
前三个构造函数都没啥要特别聊的,非常简单,只有第四个构造函数较为复杂,且是我们本章重点,需要详细讲解。(注意 MSVC 使用标准库的内容很多时候不加 std::,脑补一下就行)
如你所见,这个构造函数本身并没有做什么,它只是一个可变参数成员函数模板,增加了一些 SFINAE 进行约束我们传入的可调用对象的类型不能是 std::thread
。函数体中调用了一个函数 _Start
,将我们构造函数的参数全部完美转发,去调用它,这个函数才是我们的重点,如下:
template <class _Fn, class... _Args>
+void _Start(_Fn&& _Fx, _Args&&... _Ax) {
+ using _Tuple = tuple<decay_t<_Fn>, decay_t<_Args>...>;
+ auto _Decay_copied = _STD make_unique<_Tuple>(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
+ constexpr auto _Invoker_proc = _Get_invoke<_Tuple>(make_index_sequence<1 + sizeof...(_Args)>{});
+
+ _Thr._Hnd =
+ reinterpret_cast<void*>(_CSTD _beginthreadex(nullptr, 0, _Invoker_proc, _Decay_copied.get(), 0, &_Thr._Id));
+
+ if (_Thr._Hnd) { // ownership transferred to the thread
+ (void) _Decay_copied.release();
+ } else { // failed to start thread
+ _Thr._Id = 0;
+ _Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);
+ }
+}
+
它也是一个可变参数成员函数模板,接受一个可调用对象 _Fn
和一系列参数 _Args...
,这些东西用来创建一个线程。
using _Tuple = tuple<decay_t<_Fn>, decay_t<_Args>...>
auto _Decay_copied = _STD make_unique<_Tuple>(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...)
make_unique
创建了一个独占指针,指向的是 _Tuple
类型的对象,存储了传入的函数对象和参数的副本。constexpr auto _Invoker_proc = _Get_invoke<_Tuple>(make_index_sequence<1 + sizeof...(_Args)>{})
_Get_invoke
函数,传入 _Tuple
类型和一个参数序列的索引序列(为了遍历形参包)。这个函数用于获取一个函数指针,指向了一个静态成员函数 _Invoke
,用来实际执行线程。这两个函数都非常的简单,我们来看看: template <class _Tuple, size_t... _Indices>
+ _NODISCARD static constexpr auto _Get_invoke(index_sequence<_Indices...>) noexcept {
+ return &_Invoke<_Tuple, _Indices...>;
+ }
+
+ template <class _Tuple, size_t... _Indices>
+ static unsigned int __stdcall _Invoke(void* _RawVals) noexcept /* terminates */ {
+ // adapt invoke of user's callable object to _beginthreadex's thread procedure
+ const unique_ptr<_Tuple> _FnVals(static_cast<_Tuple*>(_RawVals));
+ _Tuple& _Tup = *_FnVals.get(); // avoid ADL, handle incomplete types
+ _STD invoke(_STD move(_STD get<_Indices>(_Tup))...);
+ _Cnd_do_broadcast_at_thread_exit(); // TRANSITION, ABI
+ return 0;
+ }
+
_Get_invoke 函数很简单,就是接受一个元组类型,和形参包的索引,传递给 _Invoke 静态成员函数模板,实例化,获取它的函数指针。
它的形参类型我们不再过多介绍,你只需要知道
index_sequence
这个东西可以用来接收一个由make_index_sequence
创建的索引形参包,帮助我们进行遍历元组即可。示例代码。
_Invoke 是重中之重,它是线程实际执行的函数,如你所见它的形参类型是 void*
,这是必须的,要符合 _beginthreadex
执行函数的类型要求。虽然是 void*
,但是我可以将它转换为 _Tuple*
类型,构造一个独占智能指针,然后调用 get() 成员函数获取底层指针,解引用指针,得到元组的引用初始化_Tup
。
此时,我们就可以进行调用了,使用 std::invoke
+ std::move
(默认移动) ,这里有一个形参包展开,_STD get<_Indices>(_Tup))...
,_Tup 就是 std::tuple 的引用,我们使用 std::get<>
获取元组存储的数据,需要传入一个索引,这里就用到了 _Indices
。展开之后,就等于 invoke 就接受了我们构造 std::thread 传入的可调用对象,调用可调用对象的参数,invoke 就可以执行了。
_Thr._Hnd = reinterpret_cast<void*>(_CSTD _beginthreadex(nullptr, 0, _Invoker_proc, _Decay_copied.get(), 0, &_Thr._Id))
_beginthreadex
函数来启动一个线程,并将线程句柄存储到 _Thr._Hnd
中。传递给线程的参数为 _Invoker_proc
(一个静态函数指针,就是我们前面讲的 _Invoke)和 _Decay_copied.get()
(存储了函数对象和参数的副本的指针)。if (_Thr._Hnd) {
_Thr._Hnd
不为空,则表示线程已成功启动,将独占指针的所有权转移给线程。(void) _Decay_copied.release()
} else { // failed to start thread
_Thr._Id = 0;
_Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);
需要注意,libstdc++ 和 libc++ 可能不同,就比如它们 64 位环境下 sizeof(std::thread)
的结果就可能是 8。libstdc++ 的实现只保有一个 std::thread::id
。参见。不过实测 gcc 不管是 win32
还是 POSIX
线程模型,线程对象的大小都是 8,宏 _GLIBCXX_HAS_GTHREADS
的值都为 1(GThread)。
class thread + { + public: +#ifdef _GLIBCXX_HAS_GTHREADS + using native_handle_type = __gthread_t; +#else + using native_handle_type = int; +#endif +
__gthread_t
即void*
。
我们这里的源码解析涉及到的 C++ 技术很多,我们也没办法每一个都单独讲,那会显得文章很冗长,而且也不是重点。
相信你也感受到了,不会模板,你阅读标准库源码,是无稽之谈,市面上很多教程教学,教导一些实现容器,过度简化了,真要去出错了去看标准库的代码,那是不现实的。不需要模板的水平有多高,也不需要会什么元编程,但是基本的需求得能做到,得会,这里推荐一下:现代C++模板教程。
std::scoped_lock
的源码实现与解析本单章专门介绍标准库在 C++17 引入的类模板 std::scoped_lock
的实现,让你对它再无疑问。
这会涉及到不少的模板技术,这没办法,就如同我们先前聊 std::thread
的构造与源码分析最后说的:“不会模板,你阅读标准库源码,是无稽之谈”。建议学习现代C++模板教程。
我们还是一样的,以 MSVC STL 实现的 std::scoped_lock
代码进行讲解,不用担心,我们也查看了 libstdc++
、libc++
的实现,并没有太多区别,更多的是一些风格上的。而且个人觉得 MSVC 的实现是最简单直观的。
std::scoped_lock
的数据成员std::scoped_lock
是一个类模板,它有两个特化,也就是有三个版本,其中的数据成员也是不同的。并且它们都不可移动不可拷贝,“管理类”应该如此。
主模板,是一个可变参数类模板,声明了一个类型形参包 _Mutexes
,存储了一个 std::tuple
,具体类型根据类型形参包决定。
_EXPORT_STD template <class... _Mutexes>
+class _NODISCARD_LOCK scoped_lock { // class with destructor that unlocks mutexes
+public:
+ explicit scoped_lock(_Mutexes&... _Mtxes) : _MyMutexes(_Mtxes...) { // construct and lock
+ _STD lock(_Mtxes...);
+ }
+
+ explicit scoped_lock(adopt_lock_t, _Mutexes&... _Mtxes) noexcept // strengthened
+ : _MyMutexes(_Mtxes...) {} // construct but don't lock
+
+ ~scoped_lock() noexcept {
+ _STD apply([](_Mutexes&... _Mtxes) { (..., (void) _Mtxes.unlock()); }, _MyMutexes);
+ }
+
+ scoped_lock(const scoped_lock&) = delete;
+ scoped_lock& operator=(const scoped_lock&) = delete;
+
+private:
+ tuple<_Mutexes&...> _MyMutexes;
+};
+
对模板类型形参包只有一个类型情况的偏特化,是不是很熟悉,和 lock_guard
几乎没有任何区别,保有一个互斥量的引用,构造上锁,析构解锁,提供一个额外的构造函数让构造的时候不上锁。所以用 scoped_lock
替代 lock_guard
不会造成任何额外开销。
template <class _Mutex>
+class _NODISCARD_LOCK scoped_lock<_Mutex> {
+public:
+ using mutex_type = _Mutex;
+
+ explicit scoped_lock(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock
+ _MyMutex.lock();
+ }
+
+ explicit scoped_lock(adopt_lock_t, _Mutex& _Mtx) noexcept // strengthened
+ : _MyMutex(_Mtx) {} // construct but don't lock
+
+ ~scoped_lock() noexcept {
+ _MyMutex.unlock();
+ }
+
+ scoped_lock(const scoped_lock&) = delete;
+ scoped_lock& operator=(const scoped_lock&) = delete;
+
+private:
+ _Mutex& _MyMutex;
+};
+
对类型形参包为空的情况的全特化,没有数据成员。
template <>
+class scoped_lock<> {
+public:
+ explicit scoped_lock() = default;
+ explicit scoped_lock(adopt_lock_t) noexcept /* strengthened */ {}
+
+ scoped_lock(const scoped_lock&) = delete;
+ scoped_lock& operator=(const scoped_lock&) = delete;
+};
+
std::mutex m1,m2;
+
+std::scoped_lock<std::mutex>lc{ m1 }; // 匹配到偏特化版本 保有一个 std::mutex&
+std::scoped_lock<std::mutex, std::mutex>lc2{ m1,m2 }; // 匹配到主模板 保有一个 std::tuple<std::mutex&,std::mutex&>
+std::scoped_lock<> lc3; // 匹配到全特化版本 空
+
std::scoped_lock
的构造与析构在上一节讲 scoped_lock
的数据成员的时候已经把这个模板类的全部源码,三个版本的代码都展示了,就不再重复。
这三个版本中,只有两个版本需要介绍,也就是
那这两个的共同点是什么呢?构造上锁,析构解锁。这很明显,明确这一点我们就开始讲吧。
std::mutex m;
+void f(){
+ m.lock();
+ std::lock_guard<std::mutex> lc{ m, std::adopt_lock };
+}
+void f2(){
+ m.lock();
+ std::scoped_lock<std::mutex>sp{ std::adopt_lock,m };
+}
+
这段代码为你展示了 std::lock_guard
和 std::scoped_lock
形参包元素数量为一的偏特化的唯一区别:调用不会上锁的构造函数的参数顺序不同。那么到此也就够了。
接下来我们进入 std::scoped_lock
主模板的讲解:
explicit scoped_lock(_Mutexes&... _Mtxes) : _MyMutexes(_Mtxes...) { // construct and lock
+ _STD lock(_Mtxes...);
+ }
+
这个构造函数做了两件事情,初始化数据成员 _MyMutexes
让它保有这些互斥量的引用,以及给所有互斥量上锁,使用了 std::lock
帮助我们完成这件事情。
explicit scoped_lock(adopt_lock_t, _Mutexes&... _Mtxes) noexcept // strengthened
+ : _MyMutexes(_Mtxes...) {} // construct but don't lock
+
这个构造函数不上锁,只是初始化数据成员 _MyMutexes
让它保有这些互斥量的引用。
~scoped_lock() noexcept {
+ _STD apply([](_Mutexes&... _Mtxes) { (..., (void) _Mtxes.unlock()); }, _MyMutexes);
+}
+
析构函数就要稍微聊一下了,主要是用 std::apply
去遍历 std::tuple
,让元组保有的互斥量引用都进行解锁。简单来说是 std::apply
可以将元组存储的参数全部拿出,用于调用这个可变参数的可调用对象,我们就能利用折叠表达式展开形参包并对其调用 unlock()
。
不在乎其返回类型只用来实施它的副作用,显式转换为
(void)
也就是弃值表达式。在我们之前讲的std::thread
源码中也有这种用法。不过你可能有疑问:“我们的标准库的那些互斥量
unlock()
返回类型都是void
呀,为什么要这样?”的确,这是个好问题,libstdc++ 和 libc++ 都没这样做,或许 MSVC STL 想着会有人设计的互斥量让它的
unlock()
返回类型不为void
,毕竟 互斥体 (Mutex) 没有要求unlock()
的返回类型。
template< class F, class Tuple >
+constexpr decltype(auto) apply( F&& f, Tuple&& t );
+
这个函数模板接受两个参数,一个可调用 (Callable)对象 f,以及一个元组 t,用做调用 f 。我们可以自己简单实现一下它,其实不算难,这种遍历元组的方式在之前讲 std::thread
的源码的时候也提到过。
template<class Callable, class Tuple, std::size_t...index>
+constexpr decltype(auto) Apply_impl(Callable&& obj,Tuple&& tuple,std::index_sequence<index...>){
+ return std::invoke(std::forward<Callable>(obj), std::get<index>(std::forward<Tuple>(tuple))...);
+}
+
+template<class Callable, class Tuple>
+constexpr decltype(auto) apply(Callable&& obj, Tuple&& tuple){
+ return Apply_impl(std::forward<Callable>(obj), std::forward<Tuple>(tuple),
+ std::make_index_sequence<std::tuple_size_v<std::remove_reference_t<Tuple>>>{});
+}
+
其实就是把元组给解包了,利用了 std::index_sequence
+ std::make_index_sequence
然后就用 std::get
形参包展开用 std::invoke
调用可调用对象即可,非常经典的处理可变参数做法,这个非常重要,一定要会使用。
举一个简单的调用例子:
std::tuple<int, std::string, char>tuple{66,"😅",'c'};
+::apply([](const auto&... t) { ((std::cout << t << ' '), ...); }, tuple);
+
运行测试。
使用了折叠表达式展开形参包,打印了元组所有的元素。
如你所见,其实这很简单。至少使用与了解其设计原理是很简单的。唯一的难度或许只有那点源码,处理可变参数,这会涉及不少模板技术,既常见也通用。还是那句话:“不会模板,你阅读标准库源码,是无稽之谈”。
相对于 std::thread
的源码解析,std::scoped_lock
还是简单的多。