C++ std::thread 使用详解

在现代软件开发中,并发编程成为提升性能的关键。无论是处理大量数据、提升响应速度,还是高效利用多核 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 源码的角度来分析下这个问题。具体会讲解到的问题如下:

  1. 了解 std::thread 内部如何处理传递进来的参数
  2. 了解 std::thread 不同的传递参数的方式的区别
  3. 了解 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 源码分析可知:

  1. std::thread 使用 std::tuple 存储参数,如果传递是左值则进行拷贝,如果传递右值则进行移动
  2. 线程启动时,会将 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

我们发现,上面两种方式在整个参数传递过程中,会存在以下问题:

  1. 无论以左值、还是右值传递,都不具备在 do_work 中修改 data 对象的能力
  2. 无论以左值、还是右值传递,都要求参数对象能够被拷贝或移动
  3. 如果拷贝则降低传递效率,如果移动则有可能导致外部 data 失效

为了进一步提升参数对象传递的效率,我们可以使用 std::ref 引用包装器来进行参数对象传递。这样在参数对象不进行任何拷贝和移动的情况下,实现高效的传递。那么,这个过程是什么样的呢?

  1. 首先,使用 std::ref 创建一个持有 data 对象指针的 reference_wrapper 匿名对象
  2. 然后,将 reference_wrapper 拷贝到 tuple 中存储(对于 data 而言,只是拷贝一个对象指针)
  3. 最后,从 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;
}

最后,我们来总结一下:

  1. 当传递对象、或者右值对象时,std::thread 内部会对对象进行拷贝和移动操作,线程可调用对象的参数设置为右值引用能够减少拷贝构造、移动构造调用次数。
  2. std::ref 可以在不进行任何对象拷贝和移动的情况下传递参数,线程可调用对象的参数设置为左值引用,性能最好。并且,线程可调用对象具备修改外部对象的能力。

未经允许不得转载:一亩三分地 » C++ std::thread 使用详解
评论 (0)

9 + 1 =