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;

线程的启动

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

 #include<iostream>
 #include<thread>
 #include <chrono>
 
 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;
 }

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);

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

std::thread t(task());  

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

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

 #include<iostream>
 #include<thread>
 #include <chrono>
 
 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;
 }

运行结果为:

terminate called without an active exception
Aborted (core dumped)

至于为何 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<iostream>
 #include<thread>
 #include <chrono>
 
 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;
 }

运行结果为:

foo begin...
foo done!
t.joinable(): false
terminate called after throwing an instance of 'std::system_error'
  what():  Invalid argument
Aborted (core dumped)


让线程后台运行:detach()

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

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

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

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

 #include <iostream>
 #include <thread>
 #include <chrono>
 
 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;
 }

上述代码输出:

foo begin...
0
foo end

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

 #include <iostream>
 #include <thread>
 #include <chrono>
 
 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;
 }

上述代码输出:

foo begin...
0
foo end

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

给线程函数传参

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

 #include <iostream>
 #include <thread>
 
 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;
 }

上述代码的输出为:

foo begin, x = 10
foo end, x = 11
after thread join, x = 10

如果一定要传入引用,可以借助于 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

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

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

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

 #include <iostream>
 #include <thread>
 
 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;
 }

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

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

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

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

 #include <iostream>
 #include <string.h>
 #include <thread>
 #include <chrono>
 
 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;
 }

上面的代码输出为:

foo
x: 10
s: 

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

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

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

 #include <iostream>
 #include <thread>
 
 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;
 }

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

 #include <iostream>
 #include <thread>
 
 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;
 }

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

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

 #include <iostream>
 #include <thread>
 
 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;
 }

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;
  • 默认构造函数。创建 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;
  • 移动赋值:可以将一个 std::thread 对象移动赋值给当前 std::thread 。移动赋值前,如果当前 std::thread 是 joinable 的话则调用 std::terminate() 结束程序。
  • 拷贝赋值是被禁用的。

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

 #include <iostream>
 #include <thread>
 #include <chrono>
 
 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;
 }

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

 #include <iostream>
 #include <thread>
 #include <chrono>
 
 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;
 }

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

参考

  • C++ Concurrency in Action Second Edition
  • https://thispointer.com//c11-multithreading-part-3-carefully-pass-arguments-to-threads
  • 10
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值