• C++ 多线程编程系列一:线程管理(std::thread 对象、join、detach、传参、不可拷贝性、所有权转移)


    C++ 多线程编程系列一:线程管理(std::thread 对象、join、detach、传参、不可拷贝性、所有权转移)

    每个 C++ 程序至少有一个线程,并且是由 C++ 运行时启动的,这个线程的线程函数就是 main() 函数。你可以在这个线程中再启动其他线程。std::thread 的构造函数申明如下:

    thread() noexcept;
    thread( thread&& other ) noexcept;
    template< class Function, class... Args >
    explicit thread( Function&& f, Args&&... args );
    thread( const thread& ) = delete;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    线程的启动

    C++ 标准库提供了 std::thread 以支持多线程编程。这里先介绍通过初始化构造函数创建 std::thread 对象的方式启动线程。例如:

     #include
     #include
     #include 
     
     using namespace std::chrono_literals;
    
     void foo() {
          std::cout << "foo begin..." << std::endl;
          std::this_thread::sleep_for(2s);
          std::cout << "foo done!" << std::endl;
     }
     
     int main() {
         std::thread t(foo);
         t.join();
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    std::thread t(foo); 创建了一个 std::thread 对象 t,并指定了线程函数 foo,随机即启动了线程。join() 会阻塞到线程函数执行完成。

    线程任务可以是任意的可调用类型(callable type)。可调用类型包括:

    • 函数指针。我们地一个例子中构建 std::thread 的时候传递的是函数名,会隐式转换为函数指针。例如,上面的例子也可以写成这样:std::thread t(&foo);
    • lambda 表达式。
    • 具有 operator() 成员函数的类对象(也即仿函数)。
    • 可被转换为函数指针的类对象。
    • 类成员(函数)指针。

    例如,可调用类型为仿函数时:

     class task {
         public:
             void operator() () {
                 std::cout << "task begin..." << std::endl;
                 std::this_thread::sleep_for(2s);
                 std::cout << "task done!" << std::endl;
             }
     };
    
    task f;
    std::thread t(f);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    但是,这个时候需要注意下面启动线程的方式就有问题:

    std::thread t(task());  
    
    • 1

    C++ 编译器会将上述语句解释为一个函数申明,原因可以戳 C++’s most vexing parse

    在启动了线程后,你需要决定是否等待线程结束(通过 join())或者让它在后台运行(通过 detach())。否则,离开 std::thread 对象的作用域时会调用 std::thread 的析构函数,程序将会终止( std::thread 的析构函数调用了 std::terminate()),例如:

     #include
     #include
     #include 
     
     using namespace std::chrono_literals;
     
     void foo() {
          std::cout << "foo begin..." << std::endl;
          std::this_thread::sleep_for(2s);
          std::cout << "foo done!" << std::endl;
     }
     
     int main() {
         {
             std::thread t(foo);
             // t.join();
         }
         std::cout << "after thread" << std::endl;
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    运行结果为:

    terminate called without an active exception
    Aborted (core dumped)
    
    • 1
    • 2

    至于为何 std::thread 的析构选择执行 std::terminate(),而不是 joindetach,以及如何自定义 RAIIstd::thread,感兴趣的可以戳 Item 37: Make std::threads unjoinable on all paths. 了解详情。

    join 和 detach

    等待线程执行完成:join()

    启动线程后,如果你想等待线程执行完成,可以通过调用 std::thread 实例的 join() 函数。一旦调用了 join() ,主线程将会阻塞,直到子线程执行结束。并且 join() 也会清理子线程的会相关的内存空间, std::thread 将不会再对应底层的系统线程,也就是说 join 只能调用一次,调用 join() 后,std::thread 实例将不再是 joinable(joinable() 将返回 false)。例如:

     #include
     #include
     #include 
     
     using namespace std::chrono_literals;
     
     void foo() {
          std::cout << "foo begin..." << std::endl;
          std::this_thread::sleep_for(1s);
          std::cout << "foo done!" << std::endl;
     }
     
     int main() {
         std::thread t(foo);
         t.join();
         std::cout << "t.joinable(): " << std::boolalpha << t.joinable() << std::endl;
         t.join();
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    运行结果为:

    foo begin...
    foo done!
    t.joinable(): false
    terminate called after throwing an instance of 'std::system_error'
      what():  Invalid argument
    Aborted (core dumped)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6


    让线程后台运行:detach()

    启动线程后,调用 detach() 将让线程运行在后台。一旦线程 detached,将不能再 joined,线程的所有权和控制权都交给了 C++ 运行时库。

    Detached 线程经常被称为守护线程(daemon threads),它运行在后台,没有显示的用户接口。守护线程一般是长时间运行的,经常会在整个程序的生命周期都在运行,经常被用于一些监控任务。

    执行完 detach() 后,std::thread 对象就和底层系统执行线程就没有关系了,因此 std::thread 对象也就是 unjoinable 了。

    尽量不要传栈中局部变量地址给线程函数,因为局部变量离开作用范围后,子线程中还在通过其地址访问它,这可能导致不符合预期的结果。例如:

     #include 
     #include 
     #include 
     
     using namespace std::chrono_literals;
     
     void foo(int *p) {
         std::cout << "foo begin..." << std::endl;
         std::this_thread::sleep_for(2s);
         std::cout << *p << std::endl;
         *p = 10; 
         std::cout << "foo end" << std::endl;
     }
     
     void startThread() {
         int i = 11; 
         std::thread t(foo, &i);
         t.detach();
     }
     
     int main() {
         startThread();
         std::this_thread::sleep_for(3s);
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    上述代码输出:

    foo begin...
    0
    foo end
    
    • 1
    • 2
    • 3

    类似地,也要注意尽量不要传入指向堆的地址,因为离开了作用范围,堆的内存也可能被释放。例如:

     #include 
     #include 
     #include 
     
     using namespace std::chrono_literals;
     
     void foo(int *p) {
         std::cout << "foo begin..." << std::endl;
         std::this_thread::sleep_for(2s);
         std::cout << *p << std::endl;
         *p = 10; 
         std::cout << "foo end" << std::endl;
     }
     
     void startThread() {
         int* p = new int(11); 
         std::thread t(foo, p);
         t.detach();
         delete p;
         p = nullptr;
     }
     
     int main() {
         startThread();
         std::this_thread::sleep_for(3s);
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    上述代码输出:

    foo begin...
    0
    foo end
    
    • 1
    • 2
    • 3

    处理这种问题的一般方法就是不要使用这种共享数据的方法,而是直接将数据拷贝进子线程。

    给线程函数传参

    给线程函数传参,参数首先被拷贝到线程空间,然后再传递给线程函数。即使线程形参是引用类型,也是先拷贝到线程空间,然后线程函数中对这份拷贝的引用。例如:

     #include 
     #include 
     
     void foo(const int& x) {
         int& y = const_cast<int&>(x);
         std::cout << "foo begin, x = " << y << std::endl;
         y++;
         std::cout << "foo end, x = " << y << std::endl;
     }
     
     int main() {
         int x = 10;
         std::thread t(foo, x);
         t.join();
         std::cout << "after thread join, x = " << x << std::endl;
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    上述代码的输出为:

    foo begin, x = 10
    foo end, x = 11
    after thread join, x = 10
    
    • 1
    • 2
    • 3

    如果一定要传入引用,可以借助于 std::ref 传参,将 std::thread t(foo, x) 修改为 std::thread t(foo, std::ref(x)) ,修改后程序输出为:

    foo begin, x = 10
    foo end, x = 11
    after thread join, x = 11
    
    • 1
    • 2
    • 3

    值得注意的是:为了支持 move-only 类型,实参从主线程传递到子线程其实包括两个步骤:

    • 第一步:在 std::thread 对象构造时,将实参拷贝到子线程的线程空间。也就是说,子线程空间保存的是主线程实参的副本。
    • 第二步:在向线程函数传参时,将副本作为右值(通过 std::move() 转换)传递给线程函数。

    例如,下面的代码是无法编译的:

     #include 
     #include 
     
     void foo(int& x) {
         std::cout << "foo begin, x = " << x << std::endl;
         x++;
         std::cout << "foo end, x = " << x << std::endl;
     }
     
     int main() {
         int x = 10;
         std::thread t(foo, x);
         t.join();
         std::cout << "after thread join, x = " << x << std::endl;
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    因为,无法在子线程给线程函数传递的是一个右值,而右值是无法绑定到一个非 const 的左值的。解决办法,也是在创建线程对象时,使用 std::ref 对实参进行包裹:

    std::thread t(foo, std::ref(x));
    
    • 1

    传地址或者引用时,需要注意的陷阱可以参考上一节的 让线程后台运行:detach()

    前面也提到过:参数首先被拷贝到线程空间,然后再传递给线程函数。如果传入的参数存在隐式转换,也是在先将参数拷贝到线程空间中,然后再传给线程函数时候才隐式转换。例如:

     #include 
     #include 
     #include 
     #include 
     
     using namespace std::chrono_literals;
     
     void foo(int x, const std::string& s) {
         std::cout << __func__ << std::endl;
         std::cout << "x: " << x << std::endl;
         std::cout << "s: " << s << std::endl;
     }
     
     int main() {
         {
             int x = 10;
             const char str[] = "hello world!";
             char *buf = new char[13];
             strcpy(buf, str);
             std::thread t(foo, x, buf);
             t.detach();
             delete buf;
             buf = nullptr;
     
         }
         std::this_thread::sleep_for(1s);
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    上面的代码输出为:

    foo
    x: 10
    s: 
    
    • 1
    • 2
    • 3

    foos 竟然为空。这是因为,创建线程对象 t 的时候,是将 buf 的指针传给了 线程 t 的线程空间,然后在线程 t 的上下文中再隐式转为 std::string 类型,最后传给 foo。但是由于 t.detach() 后就释放了 buf,但很可能在 std::string 构造前,buf 已经释放了。一种解决方案是传参时显示构造 std::string ,也即:

    std::thread t(foo, x, std::string(buf));
    
    • 1

    如果使用类成员函数作为创建 std::thread 对象的线程函数,需要同时传递对象指针。例如:

     #include 
     #include 
     
     class A { 
         public:
             A() {}
             A(const A& a) {}
             void foo(int x) { 
                 std::cout << "A::foo: x = " << x << std::endl;
             }
     };
     
     int main() { 
         A a;
         int x = 10;
         std::thread t(&A::foo, &a, x);
         t.join();
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    如果 fooA 的静态成员函数,则无需传入 A 的对象。例如:

     #include 
     #include 
     
     class A {
         public:
             A() {}
             A(const A& a) {}
             static void foo(int x) {
                 std::cout << "A::foo: x = " << x << std::endl;
             }
     };
     
     int main() {
         A a;
         int x = 10;
         std::thread t(&A::foo, x);
         t.join();
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    这是和 std::bind 的机制类似。

    如果创建 std::thread 对象时,传入的实参类型是不可拷贝的类型(例如 std::unique_ptr),也即 move-only 类型,则需要使std::move 进行转换。此时,上面介绍的传参第一步的拷贝将会调用移动构造。例如:

     #include 
     #include 
     
     void foo(std::unique_ptr<int> up) {
         std::cout << "foo: " << up.get() << std::endl;
     }
     
     int main() {
         std::unique_ptr<int> up(new int(10));
         std::thread t(foo, std::move(up));
         t.join();
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    std::thread 所有权转移

    到这里,我们再看 std::thread 构造函数的申明:

    thread() noexcept;
    thread( thread&& other ) noexcept;
    template< class Function, class... Args >
    explicit thread( Function&& f, Args&&... args );
    thread( const thread& ) = delete;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 默认构造函数。创建 std::thread 对象,但不与任何底层执行线程绑定。
    • 移动构造函数。创建 std::thread 对象,并将 other 的执行线程所有权转移给新创建的 std::thread 对象。调用后,other 将不再对应之前的执行线程。因而 std::thread 是可移动的。
    • 初始化构造函数。创建 std::thread 对象,绑定底层执行线程,并指定调用对象 f 和 参数 args
    • 拷贝构造函数是被禁用的。

    再看 std::thread 赋值操作:

    thread& operator=(thread&& rhs) noexcept;
    thread& operator=(const thread&) = delete;
    
    • 1
    • 2
    • 移动赋值:可以将一个 std::thread 对象移动赋值给当前 std::thread 。移动赋值前,如果当前 std::thread 是 joinable 的话则调用 std::terminate() 结束程序。
    • 拷贝赋值是被禁用的。

    可见,std::thread 是可以移动的,而不可拷贝的。例如:

     #include 
     #include 
     #include 
     
     using namespace std::chrono_literals;
     
     void f1() {
         std::cout << __func__ << std::endl;
         std::this_thread::sleep_for(1s);
     }
     
     void f2() {
         std::cout << __func__ << std::endl;
         std::this_thread::sleep_for(1s);
     }
     
     int main() {
         std::thread t1(f1);
         std::thread t2(f2);
         std::thread t3;
         t3 = std::move(t1);
         t1 = std::move(t2);
         // t1 = std::move(t3); // std::terminate() will be called to terminate the program.
         std::swap(t1, t3);
         t1.join();
         t3.join();
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    由于 std::thread 是可移动的,这意味着可以将其所有权转移到函数外或函数内。例如:

     #include 
     #include 
     #include 
     
     using namespace std::chrono_literals;
     
     void foo() {
         std::cout << __func__ << std::endl;
         std::this_thread::sleep_for(1s);
     }
     
     void f(std::thread t) {
         t.join();
     }
     
     std::thread g() {
         std::thread t(foo);
         return t;
     }
     
     int main() {
         f(std::thread(foo));
         std::thread t = g();
         f(std::move(t));
         return 0;
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    至此,本文结束,更多多线程编程文章,尽情期待!

    参考

    • C++ Concurrency in Action Second Edition
    • https://thispointer.com//c11-multithreading-part-3-carefully-pass-arguments-to-threads
  • 相关阅读:
    一、react简介
    js高级程序设计-代理与反射
    Python学习第八篇:requests 库学习
    Python期末复习题:字符串与产生随机数
    EffiecientNetV2架构复现--CVPR2021
    2022年最新Python大数据之Python基础【九】面向对象与继承
    新版IDEA内置Docker插件不支持远程Build镜像的环境集成
    Java包装类
    java计算机毕业设计家庭理财管理系统源码+数据库+lw文档+系统
    零基础快速上手FFmpeg!一篇就够啦~
  • 原文地址:https://blog.csdn.net/Dong_HFUT/article/details/126448396