C++线程:thread中传递参数和所有权


线程之间的参数传递

在创建线程时,可以向线程执行的函数传递参数,使用方式很简单,直接向thread对象的构造函数中传递即可。这些参数会被复制到对应的线程空间,我们知道C++中的参数传递分为:

  • pass by value
  • pass by reference

另外,对于一些特定类型的参数,不能进行copy,只能move,因此也需要对这一类场景进行说明。

基本使用:pass by value

基本的参数传递方式是pass by value,使用如下:

void do_on_background(int a){
    cout << &a << endl;
    cout << a << ": call do_on_background" << endl;
}

int main(){
    int a = 0;
    cout << &a << endl;
    thread t(do_on_background, a);
    t.join();
    return 0;
}
/**
0x7ffe264ba6bc
0x7f5b3e2cddbc
0: call do_on_background
*/

传递到线程执行的参数中的变量的地址不同,即发生了copy。

引用类型参数 & std::ref

引用类型的变量,应该是指向同一个地址的,那么看如下代码:

void do_on_background(const int &a){
    cout << "thread id : " << this_thread::get_id() << endl;
    cout << "address of a: " << &a << endl;
    cout << a << ": call do_on_background" << endl;
}

int main(){
    int a = 0;
    cout << "thread id : " << this_thread::get_id() << endl;
    cout << "address of a: " << &a << endl;
    
    do_on_background(a);

    thread t(do_on_background, a);
    t.join();
    return 0;
}
/**
thread id : 140598405343040
address of a: 0x7fff65782e0c
thread id : 140598405343040
address of a: 0x7fff65782e0c
0: call do_on_background
thread id : 140598387095296
address of a: 0x5594f464a288
0: call do_on_background
*/

从上述代码发现:

  • 当在同样的线程中时,即main所在的线程和do_on_background(a)执行时所在的线程相同,此时变量a可以按照引用传递;
  • 当在不同的线程中时,即便写成const int&的形式,传递的函数参数也不是引用, 而是不同的地址,即发生了copy,发生的是pass by value,诡异,为啥呢?

这是因为thread构造函数和std::bind的操作在标准库中以相同的机制进行定义,其中涉及到c++11中的一个新概念——Callable Objects。因为Callable Objects有多种定义形式,因为C++11使用统一了它们的操作,将它们通过std::bind绑定到统一的包装形式std::functino,以方便使用。在这种情况下,默认是copy函数参数的,如果想要pass by referecne,需要使用std::ref。

void do_on_background(const int &a){
    cout << "thread id : " << this_thread::get_id() << endl;
    cout << "address of a: " << &a << endl;
    cout << a << ": call do_on_background" << endl;
}

int main(){
    int a = 0;
    cout << "thread id : " << this_thread::get_id() << endl;
    cout << "address of a: " << &a << endl;
    
    do_on_background(a);

    thread t(do_on_background, ref(a));
    t.join();
    return 0;
}
/**
thread id : 0x1195afdc0
address of a: 0x7ffee12c52f8
thread id : 0x1195afdc0
address of a: 0x7ffee12c52f8
0: call do_on_background
thread id : 0x70000bfac000
address of a: 0x7ffee12c52f8
0: call do_on_background
*/

通过使用ref之后,发现在不同的线程中,发现使用的变量是同一个,即函数参数是按照引用传递的。

关于std::function和std::ref的一些参考资料如下:

  1. https://juejin.cn/post/7094192602638974990
  2. https://murphypei.github.io/blog/2019/04/cpp-std-ref
  3. https://www.nextptr.com/tutorial/ta1441164581/stdref-and-stdreference_wrapper-common-use-cases

只支持move的函数参数

在C++11中,一些类的对象只支持move,不支持copy和assignment,例如:unique_ptr。针对这种情况,需要使用std::move函数。

void do_on_background(unique_ptr<int> up){
    cout << *up << ": call do_on_background" << endl;
}

int main(){
    unique_ptr<int> up(new int(10));
    thread t(do_on_background, move(up));
    t.join();
    return 0;
}
/**
10: call do_on_background
*/

转移ownership

线程的所有权这方面,与unique_ptr很类似,即可以move,但是不能copy。

  • 即线程对象可以在多个线程对象之间转移所有权,这让我们决定可以让哪个线程对象有线程执行的决策权;
  • 但是,线程不可以复制,即,任意一个时间点上,只有一个线程对象关联一个实际执行的线程;

线程所有权转移的使用场景,可有3种,如下所示。

直接使用移动构造函数

在新的线程对象的构造中,直接使用move来转移线程对象的所有权。

void do_on_background(){
    cout << "thread id: " << this_thread::get_id() << endl;
    cout << "call do_on_background" << endl;
}

int main(){
    thread t1(do_on_background);
    thread t2 = move(t1);   
    t2.join();
}
/**
thread id: 0x7000026cd000
call do_on_background
*/

值得注意的是,当线程的所有权被转移后,其原来的线程对象与实际线程的执行没有一毛钱关系,也就不能调用join或者detach。当一个线程对象的所有权被转移后,如果尝试调用join和detach时,则会报错:

thread t1(do_on_background);
thread t2 = move(t1); 
t1.join();  
t2.join();

报错如下:

所有权被转移的线程对象尝试调用join时

另外,当一个已有对应执行线程的线程对象在接受一个新的执行对象所有权的转移时,是会导致程序崩溃的。

thread t1(do_on_background);
thread t2(do_on_background);
t1 = move(t2);
t1.join();

当执行时报错如下:

线程对象尝试控制多个执行线程时报错

当一个线程对象尝试控制2个甚至更多的执行线程时,会导致系统直接调用std::terminate直接终止程序运行。

作为函数返回值

thread对象作为函数返回值时,不需要使用move,因为函数调用技术,线程对象本来就要销毁,是一个典型的将亡值,是右值,因此自动调用移动构造函数。

thread func1(){
    return thread(do_on_background);
}

thread func2(){
    thread t(do_on_background);
    return t;
}

int main(){
    thread t1 = func1();
    thread t2 = func2();
    t1.join();
    t2.join();
}
/** output
thread id: 0x700007582000
call do_on_background
thread id: 0x700007605000
call do_on_background
*/

作为函数参数

void func(thread t){
    cout << "thread id: " << this_thread::get_id() << endl;
    t.join();
}

int main(){
    thread t(do_on_background);
    func(move(t));
    func(thread(do_on_background));
}

这里,如果将thread对象作为参数传递给函数时,如果是一个左值,比如t1,需要使用move函数。如果是一个右值,则不用,例如上述的临时线程对象。注意,上述代码的输出是不确定的。

一些其他的用法

线程的数量与硬件的关系

线程的频繁调度也会影响性能,因为涉及到一些状态的保存和恢复,因此了解计算机实际支持的并发线程能力很重要,C++提供了一个函数可以查询:

cout << thread::hardware_concurrency() << endl;
// output: 8

其会返回允许的并发线程的数量。在多核系统中,返回值可以是CPU核的数量。当返回值无法获取时,也会返回0。

识别具体的线程

有两种方法可以识别当前程序运行的线程:

void do_on_background(){
    cout << "thread id: " << this_thread::get_id() << endl;
    cout << "call do_on_background" << endl;
}
int main(){
    thread t(do_on_background);
    // 1. method 1
    thread::id thread_id = t.get_id();
    cout << "thread t id: " << thread_id << endl;
    // 2. method 2
    cout << "thread id: " << this_thread::get_id() << endl;
    t.join();
}
/**
output:
thread t id: 0x700001428000
thread id: thread id: 0x11114ddc0
0x700001428000
call do_on_background
*/

当使用t.get_id()时,如果有对应的执行线程,可以保证返回对应的thread id,如果没有,则会返回thread::type。

thread t1(do_on_background);
thread t2 = move(t1);

thread::id thread_id = t1.get_id();
cout << "thread t1 id: " << thread_id << endl;
t2.join();
/**
thread t1 id: 0x0
thread id: 0x7000003eb000
call do_on_background
*/

当t1的所有权被转移给t2时,如果查询t1对应的线程id,则会返回0x0。

参考资料

  1. C++ Concurrency in action - 2.2 - 2.5

文章作者: alex Li
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 alex Li !
  目录