17.3 C++并发与多线程-线程传参详解、detach坑与成员函数作为线程函数

17.1 C++并发与多线程-基础概念与实现
17.2 C++并发与多线程-线程启动、结束与创建线程写法
17.3 C++并发与多线程-线程传参详解、detach坑与成员函数作为线程函数
17.4 C++并发与多线程-创建多个线程、数据共享问题分析与案例代码
17.5 C++并发与多线程-互斥量的概念、用法、死锁演示与解决详解
17.6 C++并发与多线程-unique_lock详解
17.7 C++并发与多线程-单例设计模式共享数据分析、解决与call_once
17.8 C++并发与多线程-condition_variable、wait、notify_one与notify_all
17.9 C++并发与多线程-async、future、packaged_task与promise
17.10 C++并发与多线程-future其他成员函数、shared_future与atomic
17.11 C++并发与多线程-Windows临界区与其他各种mutex互斥量
17.12 C++并发与多线程-补充知识、线程池浅谈、数量谈与总结

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

  3.1 范例演示线程运行的开始和结束

void myprint(const int& i, char* pmybuf)
{
    cout << i << endl;
    cout << pmybuf << endl;
    return;
}
{
    int mvar = 1;
    int& mvary = mvar; 
    char mybuf[] = "this is a test!";
    std::thread mytobj(myprint, mvar, mybuf);
    //std::thread mytobj(myprint, mvar, string(mybuf)); //这里直接将mybuf转换成string对象,这可以保证在线程myprint中所用的pmybuf肯定是有效的
    
    mytobj.join();
    //mytobj.detach();
}

在这里插入图片描述

(1)要避免的陷阱1

    如果把main主函数中的join换成detach:
    程序就可能出问题了,根据观察(跟踪调试),函数myprint中,形参i的地址和原来main主函数中mvar地址不同(虽然形参是引用类型),这个应该安全(也就是说thread类的构造函数实际是复制了这个参数),而函数myprint中形参pmybuf指向的内存铁定是main中mybuf的内存,这段内存是主线程中分配的,所以,一旦主线程退出,子线程再使用这块内存肯定是不安全的。
    所以如果真要用detach这种方式创建线程,记住不要往线程中传递引用、指针之类的参数。那么如何安全地将字符串作为参数传递到线程函数中去呢?
    C++语言只会为const引用产生临时对象,所以第二个参数要加const

void myprint(int i, const string& pmybuf) //第一个参数不建议使用用引用以免出问题,第二个参数虽然使用了引用&string,但实际上还是发生了对象拷贝,这个跟系统内部工作机理有关
{
    cout << i << endl;     
    //cout << pmybuf << endl;
    const char* ptmp = pmybuf.c_str();
    cout << pmybuf.c_str() << endl;
    return;
}

(2)要避免的陷阱2

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;
};
void myprint(int i, const A& pmybuf)
{
    //cout << i << endl; 
    cout << &pmybuf << endl; //这里打印对象pmybuf的地址
    return;
}
{
    int mvar = 1;
    int mysecondpar = 12;
    std::thread mytobj(myprint, mvar, mysecondpar); //希望mysecondpar转成A类型对象传递给myprint的第二个参数
    //std::thread mytobj(myprint, mvar, A(mysecondpar));
    mytobj.detach();
    //mytobj.join(); 
}

在这里插入图片描述
    这说明,通过mysecondpar构造了一个A类对象,根据myprint里输出的结果——这个this指针值,说明myprint函数的第二个参数的对象确实是由mysecondpar构造出来的A对象
    本来希望的是用mysecondpar来构造一个A类对象,然后作为参数传给myprint线程入口函数,但看上面的结果,似乎这个A类对象还没构造出来(没运行A类的构造函数呢),main主函数就运行结束了。这肯定是个问题,因为main主函数一旦运行结束,mysecondpar就无效了,那么再用mysecondpar构造A类对象,就可能构造出错,导致未定义行为。

//使用下面代码创建线程
std::thread mytobj(myprint, mvar, A(mysecondpar));

在这里插入图片描述
    因为detach的原因,多次运行可能结果会有差异,但是不管运行多少次,都会发现一个现象:输出结果中都会出现执行一次构造函数、一次拷贝构造函数,而且线程myprint中打印的那个对象(pmybuf)的地址应该就是拷贝构造函数所创建的对象的地址。
    这意味着myprint线程入口函数中的第二个参数所代表的A对象肯定是在主线程执行结束之前就构造出来了,所以,就不用担心主线程结束的时候mysecondpar无效导致用mysecondpar构造A类对象可能会产生不可预料问题。
    所以这种在创建线程同时构造临时对象的方法传递参数可行。
    但是,这里额外发现了一个问题,那就是居然多执行了一次类A的拷贝构造函数,这是事先没有预料到的。虽然myprint线程入口函数希望第二个参数传递一个A类型的引用,但是不难发现,std::thread还是很粗暴地用临时构造的A类对象在thread类的构造函数中复制出来了一个新的A类型对象(pmybuf)。
    所以,现在看到了一个事实:只要用这个临时构造的A类对象作为参数传递给线程入口函数(myprint),那么线程中得到的第二参数(A类对象)就一定能够在主线程执行完毕之前构造出来,从而确保detach线程是安全的。

(3)总结

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

  3.2 临时对象作为线程参数继续讲

    手工构建临时对象就安全,而用mysecondpar让系统帮我们用类型转换构造函数构造对象就不安全

(1)线程idg概念

    现在写的是多线程程序,前面的程序代码写的是两个线程的程序(一个主线程,一个是自己创建的线程,也称子线程),也就是程序有两条线,分别执行。
    现在引入线程id的概念。id就是一个数字,每个线程(不管主线程还是子线程)实际上都对应着一个数字,这个数字用来唯一标识这个线程。因此,每个线程对应的数字都不同。也就是说,不同的线程,它的线程id必然不同。
    线程id可以用C++标准库里的函数std::this_thread::get_id来获取。

(2)临时对象构造时机抓捕

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;
    }
public:
    void thread_work(int num) //带一个参数
    {
        cout << "子线程thread_work执行,this = " << this << ",threadid = " << std::this_thread::get_id() << endl;
    }
public:
    void operator()(int num)
    {
        cout << "子线程()执行,this = " << this << "threadid = " << std::this_thread::get_id() << endl;
    }
    int m_i;
    //mutable  int m_i;
};
//void myprint2(A& pmybuf)
void myprint2( const A& pmybuf)
//void myprint2(const A pmybuf)
{
	//pmybuf.m_i = 199; //修改该值不会影响到main主函数中实参的该成员变量
	cout << "子线程myprint2的参数pmybuf的地址是:" << &pmybuf << ",threadid = " << std::this_thread::get_id() << endl;
}
{
    cout << "主线程id = " << std::this_thread::get_id() << endl;
    int mvar = 1;
    std::thread mytobj(myprint2, mvar);
    //std::thread mytobj(myprint2, A(mvar));
    mytobj.join(); //用join方便观察
    cout << "main主函数执行结束!" << endl;
}

在这里插入图片描述
    通过上面的结果来进行观察,因为是通过mvar让系统通过类A的类型转换构造函数生成myprint2需要的pmybuf对象,所以可以清楚地看到,pmybuf对象在构造的时候,threadid值为8456,而8456是所创建的线程(子线程)id,也就是这个对象居然是在子线程中构造的。那可以设想,如果上面的代码不是join而detach,就可能出问题——可能main函数执行完了,才用mvar变量来在子线程中构造myprint2中需要用到的形参,但是mvar因为main主函数执行完毕而被回收,这时再使用它就可能产生不可预料的问题。
在这里插入图片描述
    代码修改后,发现线程入口函数myprint2中需要的形参pmybuf是在主线程中就构造完毕的(而不是在子线程中才构造的)。这说明即便main主函数退出(主线程执行完毕)了,也没问题,这个myprint2入口函数中需要的形参已经被构造完毕,已经存在了。
    这就是经过反复测试得到的结论:给线程入口函数传递类类型对象形参时,只要使用临时对象作为实参,就可以确保线程入口函数的形参在main主函数退出前就已经创建完毕,可以安全使用。
    再次观察上面的结果,看到了类A的拷贝构造函数执行了一次。
在这里插入图片描述
    可以看到,上面执行了两次拷贝构造函数,而且这两次执行相关的threadid值还不一样。所以,这第二次执行的拷贝构造函数的执行显然没有必要,而且第二次执行拷贝构造函数的threadid还不是主线程的threadid,而是子线程的threadid,这就又回到刚才的问题:子线程可能会误用主线程中已经失效的内存。所以线程入口函数myprint2的类类型形参应该使用引用:

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

    已经注意到,因为调用了拷贝构造函数,所以在子线程中通过参数传递给线程入口函数的形参(对象)实际是实参对象的复制,这意味着即便修改了线程入口函数中的对象中的内容,依然无法反馈到外面(也就是无法影响到实参)。
继续对代码做出修改。
    类A中,把成员变量修改为用mutable修饰,这样就可以随意修改,不受const限制:

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

在这里插入图片描述
    临时对象不能作为非const引用参数,也就是必须加const修饰,这是因为C+/+编译器的语义限制。如果一个参数是以非const引用传入,C+/+编译器就有理由认为程序员会在函数中修改这个对象的内容,并且这个被修改的引用在函数返回后要发挥作用。但如果把一个临时对象当作非const引用参数传进来,由于临时对象的特殊性,程序员并不能操作临时对象,而且临时对象随时可能被释放掉,所以,一般来说,修改一个临时对象毫无意义。据此,C+/+编译器加入了临时对象不能作为非const引用的语义限制,意在限制这个非常规用法的潜在错误”。
在这里插入图片描述
    这时就需要用到std::ref了,这是一个函数模板。
    现在要这样考虑,为了数据安全,往线程入口函数传递类类型对象作为参数的时候,不管接收者(形参)是否用引用接收,都一概采用复制对象的方式来进行参数的传递。如果真的有需求明确告诉编译器要传递一个能够影响原始参数(实参)的引用过去,就得使用std::ref,读者这里无须深究,某些场合看到了std::ref,自然就知道它该什么时候出场了。
    此时,也就不涉及调用线程入口函数myprint2会产生临时对象的问题了(因为这回传递的参数真的是一个引用了而不会复制出一个临时的对象作为形参),所以myprint2的形参中可以去掉const修饰。
    类A中成员变量m_i前面的mutable也可以去掉了。
    从结果可以看到,没有执行类A的拷贝构造函数,说明没有额外生成类A的复制对象。如果将断点设置在子线程中,也可以观察对象pmybuf(形参)的地址。不难看到,该对象实际就是main中的myobj对象(看上面的结果,可以知道这两个对象的地址相同,都是004FFBF0)。

void myprint3(unique_ptr<int> pzn) {
    return;
}
{
    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转移到了线程入口函数myprint3的pzn形参中,当std::thread所在代码行执行完之后,myp指针就应该为空。读者可以设置断点进行跟踪调试并观察。
    此外,上述main主函数中用的是join,而不是detach,否则估计会发生不可预料的事情。因为不难想象,主线程中new出来的这块内存,虽然子线程中的形参指向这块内存,但若使用detach,那么主线程执行完毕后,这块内存估计应该会泄漏而导致被系统回收,那如果子线程中使用这段已经被系统回收的内存,笔者认为是很危险的事情

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

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

void thread_work(int num) //带一个参数
{
    cout << "子线程thread_work执行,this = " << this << ",threadid = " << std::this_thread::get_id() << endl;
}
{
    A myobj(10);
    std::thread mytobj(&A::thread_work, myobj, 15);//1
    std::thread mytobj(&A::thread_work, &myobj, 15);//2
    std::thread mytobj(&A::thread_work, std::ref(myobj), 15);//3
    mytobj.join();
    cout << "main主函数执行结束!" << endl;
}

    代码1调用后运行结果
通过上面的结果不难看到,类A的拷贝构造函数是在主线程中运行的(说明复制了一个类A的对象),而析构函数是在子线程中执行的。
    然后,修改main主函数中的创建thread对象这行代码,使用std::ref。看一看
代码2 3调用后运行结果
    此时就会发现,没有调用类A的拷贝构造函数,当然也就没有复制出新对象来,那main中也必须用“mytobj.join();”,而不能使用“mytobj.detach();”,否则肯定是不安全的。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值