线程传参详解、detach坑与成员函数作为线程函数

线程传参详解、detach坑与成员函数作为线程函数

传递临时对象作为线程参数

前面学习了创建一个线程的基本方法,在实际工作中,可能需要创建不止一个工作线程,例如需要创建10个线程,编号为0~9,这10个线程可能需要根据自己的编号来确定自己要做什么事情,如0号线程加工前10个零件,1号线程加工第11到第20个零件,以此类推,这说明每个线程都需要知道自己的编号。那线程如何知道自己的编号呢?这就需要给线程传递参数。

本节的主要目的是分析各种容易犯错的问题。

以一个范例开始,在lesson3. cpp上面增加如下线程入口函数:

void myprint(const int &i, char *pmybuf)
{
    cout << i << endl;
    cout << pmybuf << endl;
    return;
}

在main主函数中,加入如下代码:

int mvar = 1;
int &mvary = mvar;
char mybuf[] = "this is a test!";
std::thread mytobj(myprint, mvar, mybuf);
mytobj.join();
cout << "main主函数执行结束!" << endl;

执行起来,看一看结果:

1
this is a test!
main主函数执行结束!

要避免的陷阱1

如果把main 主函数中的join换成detach

mytobj.detach();

程序就可能出问题了,根据观察(跟踪调试),函数myprint中,形参i的地址和原来main主函数中mvar地址不同(虽然形参是引用类型),这个应该安全(也就是说thread类的构造函数实际是复制了这个参数),而函数 myprint中形参pmybuf指向的内存铁定是mainmybuf的内存,这段内存是主线程中分配的,所以,一旦主线程退出,子线程再使用这块内存肯定是不安全的。

所以如果真要用detach这种方式创建线程,记住不要往线程中传递引用、指针之类的参数。那么如何安全地将字符串作为参数传递到线程函数中去呢?修改一下myprint如下:

// C++语言只会为const引用产生临时对象,所以第二个参数要加const
void myprint(int i, const string &pmybuf) // 第一个参数不建议使用引用以免出问题,第二个参数//虽然使用了引用&string,但实际上还是发生了对象复制,这个与系统内部工作机理有关
{
    cout << i << endl;
    // cout << pmybuf << endl;
    const char *ptmp = pmybuf.c_str();
    cout << amybuf.c_str() << endl;
    return;
}

main主函数中代码不变。

main主函数中会将mybuf这个char数组隐式构造成string 对象(myprint函数的第二个形参)。string里保存字符串地址和mybuf里的字符串地址通过断点调试发现是不同的,所以改造后目前的代码应该是安全的。但真是这样吗?

要避免的陷阱2

如果以为现在这个程序改造的没有bug(潜在问题)了,那就错了。其实这个程序还是有bug的,只不过这个bug 隐藏得比较深,不太好挖出来。现在来挖一挖,看看如下这句代码:

thread mytobj(myprint, mvar, mybuf);

上面这行代码的本意是希望系统帮助我们把mybuf隐式转换成 string,这样就可以在线程中使用string,线程中就不会引用mainmybuf所指向的内存,那么, mybuf内存的销毁(回收)就跟线程没有什么关系。

但现在的问题是, mybuf是在什么时候转换成string?如果main函数都执行完了,才把mybufstring转,那绝对不行,因为那个时候mybuf都被系统回收了,使用回收的内存转成string类型对象,显然危险依旧存在。其实,上面的范例确实存在mybuf都被回收了(main函数执行完了)才去使用mybuf转换成string类型对象的可能,这程序就危险了(或者称之为程序存在潜在问题)。后面会证明这种危险。

经过查阅资料、测试、比对,最终把上面的代码修改为这样:

thread mytobj(myprint, mvar, string(mybuf));//这里直接将mybuf转换成string对象,这可以保证在线程myprint中所用的pmybuf肯定是有效的

上面这行代码中,这种转一下的写法是用来生成一个临时string 对象,然后注意到myprint函数的第二个参数pmybuf是一个string引用,似乎意味着这个临时对象被绑到了pmybuf上。如下:

void myprint(int i, string &pmybuf){⋯}

但上面这种生成临时string 对象的解决方案到底是否有效,还是应该求证一下,也就是说,需要求证的是:是不是必须把mybufstring(mybuf)转一下。转了真就没问题了吗?

给出结论:转了之后,确实是没问题了。但为什么转成临时对象就没问题了呢?所以,要写一些测试代码来求证这个结论。因为string是系统提供的类,不方便测试上面的这个结论,所以我们自己写一个类,方便我们测试的。引入一个新的类A

class A
{
public:
    A(int a) : m_i(a) { cout << "A::A(int a)构造函数执行" << this << endl; }
    A(const A &a) { cout << "A::A(const A)拷贝构造函数执行" << this << endl; }
    ~A() { cout << "~A::A()析构函数执行" << this << endl; }
    int m_i;
};

请注意,这里写的是带一个参数的构造函数,这种写法就可以把一个int数字转成一个A类型对象。

myprint线程入口函数也要修改,注意把类A的定义代码放在myprint线程入口函数代码的上面:

void myprint(int i, const A &pmybuf)
{
    // cout << i<< endl;
    cout << &pmybuf << endl; // 这里打印对象 pmybuf 的地址
    return;
}

main主函数中,代码调整成如下的样子:

int mvar = 1;
int mysecondpar = 12;
thread mytobj(myprint, mvar, mysecondpar); // 希望mysecondpar转成A类型对象传递给myprint的第二个参数
// mytobj. detach();
mytobj.join();
cout << "main主函数执行结束!" << endl;

执行起来,先看一看结果:

A::A(int a)构造函数执行0127F6E8
0127F6E8
~A::A()析构函数执行0127F6E8
main主函数执行结束!

这说明,通过mysecondpar构造了一个A类对象,根据myprint里输出的结果——这个this指针值,说明myprint函数的第二个参数的对象确实是由mysecondpar构造出来的A对象。现在假如把上面代码中的join替换成detach,那么很不幸的事情发生了。执行起来,看看替换成detach后的结果:

main主函数执行结束!

观察到了一个让人揪心的问题:结果只有一行,为什么?

本来希望的是用mysecondpar来构造一个A类对象,然后作为参数传给myprint线程入口函数,但看上面的结果,似乎这个A类对象还没构造出来(没运行A类的构造函数呢),main主函数就运行结束了。这肯定是个问题,因为main 主函数一旦运行结束,mysecondpar就无效了,那么再用mysecondpar构造A类对象,就可能构造出错,导致未定义行为。

所以,仿照上面的解决方案,构造一个临时对象,看看构造完临时对象会有什么变化。main主函数中直接修改创建mytobj代码行如下:

thread mytobj(myprint, mvar, A(mysecondpar));

再次执行起来,看一看结果:

A::A(int a)构造函数执行010FFD44
A::A(const A)拷贝构造函数执行01213E60
~A::A()析构函数执行010FFD44
main主函数执行结束!

因为detach的原因,多次运行可能结果会有差异,但是不管运行多少次,都会发现一个问题:输出结果中都会出现执行一次构造函数、一次拷贝构造函数,而且线程myprint中打印的那个对象(pmybuf)的地址应该就是拷贝构造函数所创建的对象的地址。

这意味着myprint线程入口函数中的第二个参数所代表的A对象肯定是在主线程执行结束之前就构造出来了,所以,就不用担心主线程结束的时候mysecondpar无效导致用mysecondpar构造A类对象可能会产生不可预料问题。

所以这种在创建线程同时构造临时对象的方法传递参数可行。

但是,这里额外发现了一个问题,那就是居然多执行了一次类A的拷贝构造函数,这是事先没有预料到的。虽然myprint线程入口函数希望第二个参数传递一个A类型的引用,但是不难发现, std::thread还是很粗暴地用临时构造的A类对象在thread类的构造函数中复制出来了一个新的A类型对象(pmybuf)。

所以,现在看到了一个事实:只要用这个临时构造的A类对象作为参数传递给线程入口函数(myprint),那么线程中得到的第二参数(A类对象)就一定能够在主线程执行完毕之前构造出来,从而确保detach线程是安全的。

不构造临时对象直接期望用mysecondpar作为参数传递给线程入口函数不安全,而用mysecondpar构造临时对象,将这个临时对象作为参数传递给线程入口函数就安全,相信这个结论有点让人始料未及。但这就是thread内部的一个处理方式。

后续会进一步验证这个问题,先把这个问题放一放。但是,不管怎样,使用detach都是会把简单问题复杂化。所以,使用detach一定要小心谨慎。

总结

通过刚才的学习,得到一些结论:

  • 如果传递int这种简单类型参数,建议都使用值传递,不要使用引用类型,以免节外生枝。
  • 如果传递类对象作为参数,则避免隐式类型转换(例如把一个char *转成string,把一个int转成类A对象),全部都在创建线程这一行就构建出临时对象来,然后线程入口函数的形参位置使用引用来作为形参(如果不使用引用可能在某种情况下会导致多构造一次临时类对象,不但浪费,还会造成新的潜在问题,后面会演示)。这样做的目的无非就是想办法避免主线程退出导致子线程对内存的非法引用。
  • 建议不使用detach,只使用join,这样就不存在局部变量失效导致线程对内存非法引用的问题。

临时对象作为线程参数

我们把上面的问题继续深入探究一下:为什么手工构建临时对象就安全,而用mysecondpar让系统帮我们用类型转换构造函数构造对象就不安全?这可是thread类内部做的事,但还是希望找到更有利的证据证明这一点。这里尝试找找更进一步的证据。

线程id概念

现在写的是多线程程序,前面的程序代码写的是两个线程的程序(一个主线程,一个是自己创建的线程,也称子线程),也就是程序有两条线,分别执行。

现在引入线程id的概念。id就是一个数字,每个线程(不管主线程还是子线程)实际上都对应着一个数字,这个数字用来唯一标识这个线程。因此,每个线程对应的数字都不同。也就是说,不同的线程,它的线程id必然不同。

线程 id 可以用C++标准库里的函数 std::this_thread::get_id 来获取。

临时对象构造时机抓捕

现在改造一下前面的类A,希望知道类A对象是在哪个线程里构造的。改造后的类A代码如下:

class A
{
public:
    A(int a) : m_i(a)
    {
        cout << "A::A(int a)构造函数执行, this=" << this << ", threadid=" << std::this_thread::get_id() << endl;
    }
    A(const A &a)
    {
        cout << "A::A(const A)拷贝构造函数执行, this =" << this << ", threadid = " << std::this_thread::get_id() << endl;
    }
    ~A()
    {
        cout << "~A::A( )析构函数执行, this=" << this << ", threadid=" << std::this_thread::get_id() << endl;
    }
    int m_i;
};

再写一个新的线程入口函数myprint2来做测试用:

void myprint2(const A &pmybuf)
{
    cout << "子线程 myprint2的参数 paybuf的地址是:" << &pmybuf << ", threadid =" << std::this_thread::get_id() << endl;
}

main主函数中修改为如下代码:

cout << "主线程 id=" << std::this_thread::get_id() << endl;
int mvar = 1;
std::thread mytobj(myprint2, mvar);
mytobj.join(); // 用join方便观察
cout << "main主函数执行结束!" << endl;

执行起来,看一看结果:

主线程 id=25596
A::A(int a)构造函数执行, this=00E8F904, threadid=19300
子线程 myprint2的参数 paybuf的地址是:00E8F904, threadid =19300
~A::A( )析构函数执行, this=00E8F904, threadid=19300
main主函数执行结束!

通过上面的结果来进行观察,因为是通过mvar让系统通过类A的类型转换构造函数生成myprint2需要的pmybuf对象,所以可以清楚地看到, pmybuf对象在构造的时候,threadid值为10527,而10527是所创建的线程(子线程) id,也就是这个对象居然是在子线程中构造的。那可以设想,如果上面的代码不是joindetach,就可能出问题——可能main函数执行完了,才用mvar变量来在子线程中构造myprint2中需要用到的形参,但是mvar因为main主函数执行完毕而被回收,这时再使用它就可能产生不可预料的问题。

现在进一步调整main主函数中的代码,修改thread类对象生成那行所在的代码。修改后的代码如下:

std::thread mytobj(myprint2, A(mvar));

这时可以看到一件神奇事情的发生了(可以多次执行程序)。仔细观察结果:

主线程 id=21836
A::A(int a)构造函数执行, this=00BFFCE8, threadid=21836
A::A(const A)拷贝构造函数执行, this =010C20E8, threadid = 21836
~A::A( )析构函数执行, this=00BFFCE8, threadid=21836
子线程 myprint2的参数 paybuf的地址是:010C20E8, threadid =11212
~A::A( )析构函数执行, this=010C20E8, threadid=11212
main主函数执行结束!

会发现一个事实:线程入口函数myprint2中需要的形参pmybuf是在主线程中就构造完毕的(而不是在子线程中才构造的)。这说明即便main主函数退出(主线程执行完毕)了,也没问题,这个myprint2入口函数中需要的形参已经被构造完毕,已经存在了。

这就是经过反复测试得到的结论:给线程入口函数传递类类型对象形参时,只要使用临时对象作为实参,就可以确保线程入口函数的形参在main主函数退出前就已经创建完毕,可以安全使用。所以前面提到的放一放的问题,这里通过测试,给出了答案和结论。

再次观察上面的结果,看到了类A的拷贝构造函数执行了一次。

如果把线程入口函数myprint2的形参修改为非引用:

void myprint2(const A pmybuf){⋯}

执行起来,看一看结果:

主线程 id=2540
A::A(int a)构造函数执行, this=00B3F720, threadid=2540
A::A(const A)拷贝构造函数执行, this =00B827C0, threadid = 2540
~A::A( )析构函数执行, this=00B3F720, threadid=2540
A::A(const A)拷贝构造函数执行, this =0124F5B8, threadid = 20248
子线程 myprint2的参数 paybuf的地址是:0124F5B8, threadid =20248
~A::A( )析构函数执行, this=0124F5B8, threadid=20248
~A::A( )析构函数执行, this=00B827C0, threadid=20248
main主函数执行结束!

可以看到,上面执行了两次拷贝构造函数,而且这两次执行相关的threadid值还不一样。所以,这第二次执行的拷贝构造函数的执行显然没有必要,而且第二次执行拷贝构造函数的threadid还不是主线程的threadid,而是子线程的threadid,这就又回到刚才的问题;子线程可能会误用主线程中已经失效的内存。所以线程入口函数myprint2的类类型形参 应该使用引用

void myprint2(const A&pmybuf){⋯}

传递类对象与智能指针作为线程参数

已经注意到,因为调用了拷贝构造函数,所以在子线程中通过参数传递给线程入口函数的形参(对象)实际是实参对象的复制,这意味着即便修改了线程入口函数中的对象中的内容,依然无法反馈到外面(也就是无法影响到实参)。

继续对代码做出修改。

A中,把成员变量修改为用mutable修饰,这样就可以随意修改,不受const限制:

mutable int m_i;

修改线程入口函数myprint2,增加一行代码。完整的myprint2函数如下:

void myprint2(const A &pmybuf)
{
    pmybuf.m_i = 199; // 修改该值不会影响到 main 主函数中实参的该成员变量
    cout << "子线程 myprint2的参数 pmybuf的地址是:" << &pmybuf << ", threadid =" << std::this_thread::get_id() << endl;
}

main主函数中,代码调整成如下的样子:

A myobj(10);                         // 生成一个类对象
std::thread mytobj(myprint2, myobj); // 将类对象作为线程参数
mytobj.join();
cout << "main 主函数执行结束!" << endl;

cout 行设置断点并跟踪调试可以发现, myobj 对象的m_i成员变量并没有被修改为199。还有一点要说明,就是线程入口函数myprint2的形参要求是一个const引用:

void myprint2(const A &pmybuf){⋯}

它这块的语法规则就是这样,如果不加const修饰,大概老一点的编译器不会报语法错,但新一点的编译器(如Visual Studio 2019编译器)会报语法错。因为myprint2线程入口函数的形参涉及产生临时对象,所以必须加const。如果解释的再细致一点就像下面这样解释:

“临时对象不能作为非const引用参数,也就是必须加const修饰,这是因为C++编译器的语义限制。如果一个参数是以非const引用传入,C++编译器就有理由认为程序员会在函数中修改这个对象的内容,并且这个被修改的引用在函数返回后要发挥作用。但如果把一个临时对象当作非 const引用参数传进来,由于临时对象的特殊性,程序员并不能操作临时对象,而且临时对象随时可能被释放掉,所以,一般来说,修改一个临时对象毫无意义。据此,C++编译器加入了临时对象不能作为非const引用的语义限制,意在限制这个非常规用法的潜在错误”。

但一个问题随之而来,如果加了const修饰,那么修改pmybuf对象中的数据成员就变得非常不便,上面使用了mutable来修饰成员变量,但不可能每个成员变量都写成用mutable来修饰。而且还有一个重要问题就是,myprint2 函数的形参明明是一个引用,但是修改了这个pmybuf对象的成员变量,而后返回到main主函数中,调用者对象myobj(实参)的成员变量也并没有被修改。这个问题又如何解决呢?

这时就需要用到std::ref了,这是一个函数模板。

现在要这样考虑,为了数据安全,往线程入口函数传递类类型对象作为参数的时候,不管接收者(形参)是否用引用接收,都一概采用复制对象的方式来进行参数的传递。如果真的有需求明确告诉编译器要传递一个能够影响原始参数(实参)的引用过去,就得使用std::ref,读者这里无须深究,某些场合看到了std::ref,自然就知道它该什么时候出场了。

main主函数中,修改创建thread类型对象的行。修改后如下:

std::thread mytobj(myprint2, std::ref(myobj));

此时,也就不涉及调用线程入口函数myprint2会产生临时对象的问题了(因为这回传递的参数真的是一个引用了而不会复制出一个临时的对象作为形参),所以myprint2的形参中可以去掉const修饰:

void myprint2(A& pmybuf){⋯}

A中成员变量m_i前面的 mutable 也可以去掉

int m_i;

设置断点,调试程序,发现线程入口函数myprint2执行完毕后,myobj里的m_i值已经变为199。

如果不设置断点而直接执行,结果如下:

A::A(int a)构造函数执行, this = 004FFBF0, threadid = 12360
子线程myprint2的参数pmybuf的地址是:004FFBF0, threadid = 5024
main主函数执行结束!
~A::A()析构函数执行, this = 004FFBF0, threadid = 12360

从结果可以看到,没有执行类A的拷贝构造函数,说明没有额外生成类A的复制对象。如果将断点设置在子线程中,也可以观察对象pmybuf(形参)的地址。不难看到,该对象实际就是main中的myobj对象(看上面的结果,可以知道这两个对象的地址相同,都是004FFBF0)。

再考虑一个有趣的问题。如果将智能指针作为形参传递到线程入口函数,该怎样写代码呢?这回将myprint3作为线程入口函数。代码如下

void myprint3(unique_ptr<int> pzn)
{
    return;
}

main主函数中,代码调整成如下的样子:

unique_ptr<int> myp(new int(100));
std::thread mytobj(myprint3, std::move(myp));
mytobj.join();
cout << "main主函数执行结束!" << endl;

std:: move 将一个 unique_ptr 转移到其他的 unique_ptr,上面代码相当于将myp转移到了线程入口函数myprint3pzn形参中,当 std::thread所在代码行执行完之后, myp指针就应该为空。

此外,上述main主函数中用的是join,而不是detach,否则估计会发生不可预料的事情。因为不难想象,主线程中new出来的这块内存,虽然子线程中的形参指向这块内存,但若使用detach,那么主线程执行完毕后,这块内存估计应该会泄漏而导致被系统回收,那如果子线程中使用这段已经被系统回收的内存,是很危险的事情。

用成员函数作为线程入口函数

这里正好借用类A做一个用成员函数指针作为线程入口函数的范例。上一节讲解了创建线程的多种方法,讲过用类对象创建线程,那时调用的是类的operator()来作为线程的入口函数。现在可以指定任意一个成员函数作为线程的入口函数。

在类A中,增加一个public修饰的成员函数:

public:
    void thread_work(int num) // 带一个参数
    {
        cout << "子线程 thread_work 执行, this=" << this << ", threadid=" << std::this_thread::get_id() << endl;
    }

main主函数中,代码调整成如下的样子:

A myobj(10);
std::thread mytobj(&A::thread_work, myobj, 15);
mytobj.join();
cout << "main主函数执行结束!" << endl;

执行起来,看一看结果:

A::A(int a)构造函数执行, this=00CFF88C, threadid=22396
A::A(const A)拷贝构造函数执行, this =010D2644, threadid = 22396
子线程 thread_work 执行, this=010D2644, threadid=20592
~A::A( )析构函数执行, this=010D2644, threadid=20592
main主函数执行结束!
~A::A( )析构函数执行, this=00CFF88C, threadid=22396

通过上面的结果不难看到,类A的拷贝构造函数是在主线程中执行的(说明复制了一个类A的对象),而析构函数是在子线程中执行的。

当然, main主函数中创建thread对象mytobj时的第二个参数可以是一个对象地址,也可以是一个std::ref。修改main 主函数中的创建thread对象这行代码:

std::thread mytobj(&A::thread_work, &myobj, 15); // 第二个参数也可以是 std::ref(myobj)

执行起来,看一看结果:

A::A(int a)构造函数执行, this=0135FDC4, threadid=19048
子线程 thread_work 执行, this=0135FDC4, threadid=10880
main主函数执行结束!
~A::A( )析构函数执行, this=0135FDC4, threadid=19048

此时就会发现,没有调用类A的拷贝构造函数,当然也就没有复制出新对象来,那main中也必须用mytobj.join();,而不能使用mytobj. detach();,否则肯定是不安全的。

另外,这里再把上次的用类来创建线程的写法完善一下。

在类A中增加如下public修饰的圆括号重载,这里带一个参数:

public:
    void operator()(int num)
    {
        cout << "子线程( )执行, this=" << this << " threadid=" << std::this_thread::get_id() << endl;
    }

main主函数中,代码调整成如下的样子:

A myobj(10);
thread mytobj(myobj, 15);
mytobj.join();
cout << "main主函数执行结束!" << endl;

执行起来,看一看结果:

A::A(int a)构造函数执行, this=00DEF6D8, threadid=23892
A::A(const A)拷贝构造函数执行, this =01115094, threadid = 23892
子线程( )执行, this=01115094 threadid=25200
~A::A( )析构函数执行, this=01115094, threadid=25200
main主函数执行结束!
~A::A( )析构函数执行, this=00DEF6D8, threadid=23892

通过上面的结果不难看到,类A的拷贝构造函数是在主线程中运行的(说明复制了一个类A的对象),而析构函数是在子线程中执行的。

然后,修改main主函数中的创建thread对象这行代码,使用std::ref。看一看:

thread mytobj(std::ref(myobj), 15);//第二个参数无法修改为&myobj,编译会报错

执行起来,看一看结果:

A::A(int a)构造函数执行, this=00CFF8F0, threadid=13132
子线程( )执行, this=00CFF8F0 threadid=6820
main主函数执行结束!
~A::A( )析构函数执行, this=00CFF8F0, threadid=13132

此时就会发现,没有调用类A的拷贝构造函数,当然也就没有复制出新对象来。那main中也必须用mytobj.join();,而不能使用mytobj. detach();,否则肯定是不安全的。

总之,在思考、学习以及实践的过程中,遇到不太理解或者理解不透彻需要求证的地方,完全可以仿照上述的做法,在类的构造函数、拷贝构造函数、析构函数以及线程入口函数甚至主线程中增加各种输出语句,把对象的this指针值、对象或者变量的地址、线程的id等各种重要信息输出到屏幕供查看,以便更深入和透彻地理解问题和学习知识,达到更好的学习效果。

代码地址

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值