0%

C++并发:从std::thread()开始

<thread>头文件的作用

<thread> 是C++11新引入标准库基础设施,提供对多线程操作的支持。

我们可以用 std::thread 来控制线程的创建、运行、回收。

学习 std::thread 的用法是了解C++多线程编程的第一步。

构造std::thread对象

  • 方法一:传入函数对象
1
2
3
4
5
6
7
8
9
10
class background_task {
public:
void operator()() const {
do_something();
do_something_else();
}
};

background_task f;
std::thread my_thread(f);

在这种情况下,函数对象先被 copystd::thread 对象的内部,然后再传参、被调用

  • 方法二:传入lambda表达式(也是callable对象)
1
2
3
4
std::thread my_thread([]{
do_something();
do_something_else();
})
  • 方法三:传入函数指针和参数
1
2
void f(int i);
std::thread(f, 3);
  • 方法四:传入对象的成员函数
1
2
3
4
5
6
7
class X {
public:
void do_lengthy_work();
};

X my_x;
std::thread t(&X::do_lengthy_work, &my_x); // 后面可以加上一系列参数,如果需要的话

std::thread成员函数

.join()

作用:

  • 等待线程执行完毕
  • 清除对象内部与具体线程相关的内存,当前对象将不再和任何线程相关联
  • 只能调用一次 .join() ,调用后 .joinable() 将永远返回 false

例子:

1
2
3
if (t.joinable()) {
t.join();
}

.detach()

作用:

  • 把线程放在后台运行,线程的所有权和控制权交给 C++ Runtime Library
  • 当前对象将不再和任何线程相关联
  • 调用后 .joinable() 将永远返回 false

thread function传参可能遇到的问题

问题一:传入临时的callable对象,编译器会误以为是函数声明

1
2
// Wrong
std::thread my_thread(background_task())

解决方案:用圆括号或者花括号加以说明

1
2
3
// Correct
std::thread my_thread((background_task()));
std::thread my_thread{background_task()};

问题二:因为传指针or局部变量的引用,导致thread function可能访问已经被销毁的内容

解决方案:

  • 把需要使用的临时变量copystd::thread内部,不要和局部上下文共享临时变量
  • 使用RAII(资源获取即初始化)
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
29
30
class thread_guard {
std::thread &t;
public:
// Constructor
explicit thread_guard(std::thread &t_): t(t_) {}

// Destructor
~thread_guard() {
if (t.joinable()) {
t.join();
}
}

// Copy Constructor
thread_guard(const thread_guard &) = delete;

// Copy-assignment Operator
thread_guard& operator=(const thread_guard &) = delete;
}

struct func; // a callable object

void f {
int some_local_stats = 0;
func my_func(some_local_stats);
std::thread t(my_func);
thread_guard g(t);

do_something_in_current_thread(); // 函数返回时,自动调用thread_guard的析构函数,等待线程join
}

问题三:因为传指针or局部变量的引用,导致在thread function入参时因强制类型转换而访问已经被销毁的内容

理解这个问题之前,需要先梳理一下 std::thread 对象创建后发生了什么:

  1. 原线程:调用 std::thread 的构造函数or拷贝赋值运算符
  2. 原线程:一个callable对象和它的参数被拷贝到新创建的 std::thread 内部
  3. 新线程:之前被拷贝的一系列参数,现在被传入callable对象(发生强制类型转换)
  4. 新线程:调用callable对象
  5. ……

其中,第3步发生在新线程内,我们只知道它发生在第2步之后,却不知道具体的发生时间。

如果第3步发生时,原线程已经退出了相关上下文,那么新线程在传参时,可能对已经被销毁的内容进行类型转换操作。

1
2
3
4
5
6
7
8
void f(int i, const std::string &s);

void oops(int some_param) {
char buffer[1024];
sprintf(buffer, "%i", some_param);
std::thread t(f, 3, buffer); // const char*类型的buffer被转换成std::string的时机是未知的
t.detach();
}

解决方案:由程序员显式完成传参操作,避免出现类型转换

1
2
3
4
5
6
void not_oops(int some_param) {
char buffer[1024];
sprintf(buffer, "%i", some_param);
std::thread t(f, 3, std::string(buffer)); // 避免类型转换
t.detach();
}

问题四:callable对象的参数要求传引用,但实际传入的是内部拷贝对象的引用(无法对原来的对象进行修改)

1
2
3
4
5
6
7
8
9
void update_data_for_widget(widget_id w, widget_data &data);

void oops_again(widget_id w) {
widget_data data;
std::thread t(update_data_for_widget, w, data); // 传入的data被拷贝,拷贝后的临时data的引用被传入update函数
display_status();
t.join();
process_widget_data(data);
}

解决方案:使用 std::ref() 声明传引用

1
std::thread t(update_data_for_widget, w, std::ref(data));

使用上的技巧

Trick 1:传入只可move不可copy的对象

在这种情况下,如果原对象是无名的临时对象,那么 move 操作是自动完成的。

如果原对象是命名对象(左值引用),那就需要用 std::move() 来将它转换成(右值引用),之后的的拷贝就自动是 move 完成的。

1
2
3
4
5
void process_big_object(std::unique_ptr<big_object>);

std::unique_ptr<big_object> p(new big_object); // std::unique_ptr是典型的不可copy只能move对象,std::thread也是
p->prepare_data(42);
std::thread t(process_big_object, std::move(p));

Trick 2:std::thread的move操作

1
2
3
4
5
6
7
8
void some_function();
void some_other_function();
std::thread t1(some_function); // constructor
std::thread t2 = std::move(t1); // move-assignment operator
t1 = std::thread(some_other_function); // constructor, then move-assignment operator
std::thread t3; // default constructor
t3 = std::move(t2); // move-assignment operator
t1 = std::move(t3); // Error: std::terminate()

上述程序的最后一行中,对象t1已经与一个正在运行的线程互相绑定,不能接受move的对象,因此整个程序会调用 std::terminate() 退出。

不能 move 给已经绑定了线程的对象。

Trick 3:在函数传参和返回时使用转移std::thread的所有权

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::thread f() {
void some_function();
return std::thread(some_function); // 自动调用move
}

std::thread g() {
void some_other_function(int);
std::thread t(some_other_function, 32);
return t; // 自动调用move
}

void f(std::thread t);

void g() {
void some_function();
f(std::thread(some_function)); // 自动调用move
std::thread t(some_function);
f(std::move(t)); // 显式调用move
}

Trick 4:运行时确定新开线程的个数

1
const unsigned long hardware_threads = std::thread::hardware_concurrency();

Trick 5:获取线程id

线程id有个单独定义的类型,std::thread::id ,该类对象有如下性质:

  • 调用 std::thread 对象的 get_id() 方法可以得到一个 std::thread::id 类型对象
  • std::thread::id 支持大小比较和相等判断,
    • 相等即为同一线程
    • 若a<b,b<c,那么a<c
  • 如果当前对象不与任何正在运行的线程绑定,那么 get_id() 返回一个默认构造的 std::thread::id 对象
  • get_id() 可以被 std::cout 打印出来,但它的值没有任何具体意义,标准库也不对它的具体实现类型作保证

参考来源

  • C++ Concurrency in Action, 2nd Edition