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()
,而不是 join
或 detach
,以及如何自定义 RAII
的 std::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()
将让线程运行在后台。一旦线程 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:
foo
中 s
竟然为空。这是因为,创建线程对象 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;
}
如果 foo
是 A
的静态成员函数,则无需传入 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