在现代软件开发中,并发编程成为提升性能的关键。无论是处理大量数据、提升响应速度,还是高效利用多核 CPU,多线程编程都至关重要。在 C++ 中 通过使用 std::thread
类,我们能够轻松地创建并启动一个或多个线程,完成较为复杂的任务。
1. 基本使用
我们使用 std::thread 来创建并启动一个线程。
#if 1 #define _CRT_SECURE_NO_WARNINGS #include<iostream> #include<thread> void do_work(int num1, int num2){} struct MyTask { void task1(int num1, int num2) {} static void task2(int num1, int num2) {} }; struct Task { void operator()(int num1, int num2) {} }; void test01() { // 普通函数 std::thread t1(do_work, 10, 20); // lambda 匿名函数 std::thread t2([]() {}); // 成员函数 MyTask task; std::thread t3(&MyTask::task1, task, 10, 20); std::thread t4(&MyTask::task1, &task, 10, 20); std::thread t5(&MyTask::task2, 10, 20); // 函数对象 std::thread t6{ Task(), 10, 20}; } void test02() { std::thread t([]() {}); // 阻塞当前线程,等到子线程执行完毕,继续向下执行 t.join(); // 子线程从当前线程分离,独立运行,调用线程不需要等待 // t.detach(); // 获得线程编号 std::cout << t.get_id() << std::endl; // 系统最大并发线程数 std::cout << t.hardware_concurrency() << std::endl; } int main() { test02(); return EXIT_SUCCESS; } #endif
2. 参数传递
我们经常看到在构建 std::thread 对象时,使用 std::ref 来传递参数。有些同学可能不清楚,此处为什么这么写?以及这么写有什么好处?接下来,从 std::thread 源码的角度来分析下这个问题。具体会讲解到的问题如下:
- 了解 std::thread 内部如何处理传递进来的参数
- 了解 std::thread 不同的传递参数的方式的区别
- 了解 std::thread 可调用对象的参数类型如何写
#include<thread> struct Data { Data() = default; Data(const Data&) = default; Data(Data&&) = default; }; void do_work(const Data& data){} void test() { Data data; std::thread t(do_work, std::ref(data)); t.join(); }
我们首先从 std::thread 的构造函数开始,直到线程运行起来,看一看参数在这个过程中都发生了什么事情。
class thread { // 调用线程函数,并将 std::tuple 中的参数 move 到线程函数 template <class _Tuple, size_t... _Indices> static unsigned int __stdcall _Invoke(void* _RawVals) noexcept { const unique_ptr<_Tuple> _FnVals(static_cast<_Tuple*>(_RawVals)); _Tuple& _Tup = *_FnVals.get(); _STD invoke(_STD move(_STD get<_Indices>(_Tup))...); _Cnd_do_broadcast_at_thread_exit(); return 0; } template <class _Tuple, size_t... _Indices> static constexpr auto _Get_invoke(index_sequence<_Indices...>) noexcept { return &_Invoke<_Tuple, _Indices...>; } // 参数以值方式保存参数到 std::tuple 中 // 如果参数为左值,则拷贝到元组中 // 如果参数为右值,则移动到元组中 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) { (void) _Decay_copied.release(); } else { _Thr._Id = 0; _Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN); } } // thread 变长参数构造函数 template <class _Fn, class... _Args> explicit thread(_Fn&& _Fx, _Args&&... _Ax) { _Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...); } }
通过对 std::thread 源码分析可知:
std::thread
使用std::tuple
存储参数,如果传递是左值则进行拷贝,如果传递右值则进行移动- 线程启动时,会将
std::tuple
中存储的参数std::move
到线程对象关联的可调用对象中
#if 1 #define _CRT_SECURE_NO_WARNINGS #include<iostream> #include<thread> struct Data { Data() = default; Data(const Data&) { std::cout << "拷贝构造" << std::endl; } // Data(const Data&) = delete; // Data(Data&&) { std::cout << "移动构造" << std::endl; } Data(Data&&) = delete; int value{ 0 }; }; void do_work1(Data data) { data.value = 200; } void do_work2(Data&& data) { data.value = 200; } // 1. 左值方式传递 void test01() { Data data; #if 0 // 拷贝构造函数 -> 移动构造函数 auto do_work = do_work1; #else // 拷贝构造函数 auto do_work = do_work2; #endif std::thread t(do_work, data); t.join(); } // 2. 右值方式传递 void test02() { Data data; #if 0 // 移动构造函数 -> 移动构造函数 auto do_work = do_work1; #else // 如果没有移动构造,则调用拷贝构造 auto do_work = do_work2; #endif std::thread t(do_work, std::move(data)); t.join(); } int main() { test02(); return EXIT_SUCCESS; } #endif
我们发现,上面两种方式在整个参数传递过程中,会存在以下问题:
- 无论以左值、还是右值传递,都不具备在 do_work 中修改 data 对象的能力
- 无论以左值、还是右值传递,都要求参数对象能够被拷贝或移动
- 如果拷贝则降低传递效率,如果移动则有可能导致外部 data 失效
为了进一步提升参数对象传递的效率,我们可以使用 std::ref
引用包装器来进行参数对象传递。这样在参数对象不进行任何拷贝和移动的情况下,实现高效的传递。那么,这个过程是什么样的呢?
- 首先,使用 std::ref 创建一个持有 data 对象指针的 reference_wrapper 匿名对象
- 然后,将 reference_wrapper 拷贝到 tuple 中存储(对于 data 而言,只是拷贝一个对象指针)
- 最后,从 tuple 中取出 reference_wrapper 对象,将其转换为 Data& 引用,传递到可调用对象中
// 注意:参数类型写左值引用,避免拷贝 void do_work(Data& data) { data.value = 200; } void test() { Data data; // 1. 首先,使用 std::ref 创建一个持有 data 对象指针的 reference_wrapper 匿名对象 // 2. 然后,将 reference_wrapper 存储到 tuple 时进行拷贝 std::tuple<std::reference_wrapper<Data>> my_tuple = { std::ref(data) }; // 3. 最后,从 tuple 中取出 reference_wrapper 对象,将其转换为 Data& 引用,传递到可调用对象中 // 注意:如果 do_work 参数类型为在值类型,将会发生拷贝 do_work(std::move(std::get<0>(my_tuple))); std::cout << data.value << std::endl; }
最后,我们来总结一下:
- 当传递对象、或者右值对象时,std::thread 内部会对对象进行拷贝和移动操作,线程可调用对象的参数设置为右值引用能够减少拷贝构造、移动构造调用次数。
- std::ref 可以在不进行任何对象拷贝和移动的情况下传递参数,线程可调用对象的参数设置为左值引用,性能最好。并且,线程可调用对象具备修改外部对象的能力。