最近在写 c++ 的程序,做了一个类,这个类对象初始化时,需要创建一个使用该类成员函数作为入口函数的线程。自然地就想到在构造函数中启动线程,但是在陈硕《Linux多线程服务端编程》中提到,在构造函数中启动线程是不安全的,于是对这个问题进行了一番学习。
后来在 https://stackoverflow.com/questions/33571921/can-initialising-a-thread-in-a-class-constructor-lead-to-a-crash/33576786#33576786 中看到了有人有同样的担心。就干脆把这个问题写一写,加深自己的印象,也可以帮助在做同样事情的人。
我搞了一个简单的例子程序,就是一个生产者-消费者程序。
#include
#include
#include
#include
#include
class MyClass
{
public:
MyClass() : m_isExit(false), m_thread(&MyClass::threadMain, this)
{
}
~MyClass()
{
std::unique_lock lock(m_mutex); //m_isExit是多线程共享的,需要加锁再修改
m_isExit = true;
m_condition.notify_one();
lock.unlock();
if(m_thread.joinable())
m_thread.join();
}
void addTask(int taskToAdd)
{
std::lock_guard lock_guard(m_mutex);
m_taskQueue.push(taskToAdd);
m_condition.notify_one();
}
private:
void threadMain() //线程入口函数
{
while(1) {
std::unique_lock lock(m_mutex);
m_condition.wait(lock, [&] { return !m_taskQueue.empty() || m_isExit; }); //任务队列中有任务待处理 或者 线程需要退出时唤醒线程
//线程被唤醒,处理任务,或者退出
if(m_isExit) {
//线程需要退出,中断循环
std::cout << "线程退出..." << std::endl;
break;
}
//取出任务
int task = std::move(m_taskQueue.front());
m_taskQueue.pop();
lock.unlock();
std::cout << "一个任务被取出:" << task << std::endl;
// 处理任务
// ...
}
}
bool m_isExit; //退出标志,置为true时,线程需要退出
std::thread m_thread;
std::mutex m_mutex; //互斥锁
std::condition_variable m_condition;//条件变量
std::queue m_taskQueue; //任务队列,元素类型只是简单的 int
};
int main(int argc, char **argv)
{
MyClass myClass;
for(int i = 0; i < 10000; ++i) {
myClass.addTask(i); //简单演示,直接用 i 作为任务加到任务队列
std::cout << "任务已经被添加:" << i << std::endl;
}
}
在linux下用g++编译后运行没有发生问题,和 stackoverflow 里那个提问者说的程序会crash不一样。于是就再试试Windows。
在Windows下,终于出现了崩溃,而且每次都会,我是用 VS 2015 测试的。
引发崩溃的地方在这一行
……
void threadMain() //线程入口函数
{
while(1) {
std::unique_lock lock(m_mutex); //<<<<<<<<这一行在VS2015下引发了崩溃
m_condition.wait(lock, [&] { return !m_taskQueue.empty() || m_isExit; }); //任务队列中有任务待处理 或者 线程需要退出时唤醒线程
……
显然是因为 m_mutex 在线程开始运行后还没有初始化完成。
我尝试调整变量定义的顺序为如下,就是让其它成员都完成初始化之后,在最后才初始化 m_thread,程序正常了。
bool m_isExit; //退出标志,置为true时,线程需要退出
std::mutex m_mutex; //互斥锁
std::condition_variable m_condition;//条件变量
std::queue m_taskQueue; //任务队列,元素类型只是简单的 int
std::thread m_thread;
};
看来老外所言不虚,c++标准定义得比较宽松,像这个例子的情况,各个编译器的实现方式不同,会出现不可预料的结果。
所以,
结论:绝对不可在初始化列表中启动本类成员函数作为入口函数,并且使用到本类定义的成员变量的线程;虽然可以调整变量定义的顺序来避开成员变量未初始化的情况,但是如果是多人合作开发,你很难保证 m_thread 总是会被放在最后一个。
这么看起来,不在初始化列表中启动线程,而是在构造函数中启动线程那应该是没问题了吧!
把构造函数从原来的
MyClass() : m_isExit(false), m_thread(&MyClass::threadMain, this)
{
}
改成
MyClass() : m_isExit(false)
{
m_thread = std::thread(&MyClass::threadMain, this);
}
这样子,当线程启动之前,类的所有成员变量都已经初始化完成了,就不会出现访问成员变量时的异常了。
的确,这样子修改之后对于本例,linux和windows下面都是运行正常的。
但是,
这样做依旧是有风险的,因为它没有考虑另外一种情况,virtual的成员函数是在构造函数执行之后初始化的,在构造函数执行之后,还会构造本类所继承的基类(们)。
我们来把例子改一改:
#include
#include
#include
#include
#include
class MyClass
{
public:
MyClass() : m_isExit(false)
{
m_thread = std::thread(&MyClass::threadMain, this); //启动线程
}
~MyClass()
{
std::unique_lock lock(m_mutex); //m_isExit是多线程共享的,需要加锁再修改
m_isExit = true;
m_condition.notify_one();
lock.unlock();
if(m_thread.joinable())
m_thread.join();
}
void addTask(int taskToAdd)
{
std::lock_guard lock_guard(m_mutex);
m_taskQueue.push(taskToAdd);
m_condition.notify_one();
}
private:
virtual void threadMain() = 0; //线程入口函数
std::thread m_thread;
protected:
std::mutex m_mutex; //互斥锁
std::condition_variable m_condition;//条件变量
bool m_isExit; //退出标志,置为true时,线程需要退出
std::queue m_taskQueue; //任务队列,元素类型只是简单的 int
};
class Derived : public MyClass //以 MyClass 为基类
{
virtual void threadMain()
{
while(1) {
std::unique_lock lock(m_mutex);
m_condition.wait(lock, [&] { return !m_taskQueue.empty() || m_isExit; }); //任务队列中有任务待处理 或者 线程需要退出时唤醒线程
//线程被唤醒,处理任务,或者退出
if(m_isExit) {
//线程需要退出,中断循环
std::cout << "线程退出..." << std::endl;
break;
}
//取出任务
int task = std::move(m_taskQueue.front());
m_taskQueue.pop();
lock.unlock();
std::cout << "一个任务被取出:" << task << std::endl;
// 处理任务
// ...
}
}
};
int main(int argc, char **argv)
{
Derived derived;
for(int i = 0; i < 10000; ++i) {
derived.addTask(i); //简单演示,直接用 i 作为任务加到任务队列
std::cout << "任务已经被添加:" << i << std::endl;
}
}
这个程序,看起来应该是没问题的。Derived类从MyClass类继承并且具体实现了线程入口函数,当Derived初始化完成后,基类MyClass的构造函数会启动一个线程。
同样, linux正常运行,windows出现崩溃,而且这次的崩溃非常难以调试,提示的问题很不明确。
调试时给出的信息:
这种情况就一头雾水了。打了一些断点来看问题。
首先,MyClass的构造函数是正常执行完成的,也就是说m_thread是完成了初始化,线程是启动了。但是在线程入口函数,也就是Derived::threadMain()这个函数的断点一直就没有进去。
什么原因呢?只能是线程启动之后,其实threadMain()这个虚成员函数还没有完成初始化,这样就导致调用了一个不知道什么的东西从而程序崩溃。
那么,我们换一换,不在基类中启动线程,而是在子类中启动线程会有什么结果呢?
class MyClass
{
public:
MyClass() : m_isExit(false)
{
}
……
class Derived : public MyClass //以 MyClass 为基类
{
public:
Derived()
{
m_thread = std::thread(&Derived::threadMain, this); //启动线程
}
这样就可以正常运行了。说明了对象初始化时,先构造子类初始化子类的成员(虚函数除外)、再构造基类并且初始化基类成员(虚函数除外),基类完成构造之后(这里我们启动了线程),再构造子类的所有虚函数。所以出错的地方就在:我们在基类的构造函数中启动了线程,而线程入口函数当时并不存在。
所以,
结论:在有继承结构时,有虚成员函数时,绝对不要在构造函数中启动线程。否则是自找麻烦
我们需要另外定义一个成员函数比如start()用来启动线程,这样才安全。
可以新增另一个类比如ThreadHandler,在这个类的构造函数中启动线程(注意这个线程并没有使用到ThreadHandler的任何成员,所以是安全的),这样也不会因为忘记调用start()而导致没有启动线程。
整个代码现在变成这样:
#include
#include
#include
#include
#include
class MyClass
{
public:
MyClass() : m_isExit(false)
{
std::cout << "MyClass Constructing..." << std::endl;
}
~MyClass()
{
std::unique_lock lock(m_mutex); //m_isExit是多线程共享的,需要加锁再修改
m_isExit = true;
m_condition.notify_one();
lock.unlock();
if (m_thread.joinable())
m_thread.join();
}
void start()
{
m_thread = std::thread(&MyClass::threadMain, this); //启动线程
}
void addTask(int taskToAdd)
{
std::lock_guard lock_guard(m_mutex);
m_taskQueue.push(taskToAdd);
m_condition.notify_one();
}
private:
virtual void threadMain() = 0; //线程入口函数
protected:
std::thread m_thread;
std::mutex m_mutex; //互斥锁
std::condition_variable m_condition;//条件变量
bool m_isExit; //退出标志,置为true时,线程需要退出
std::queue m_taskQueue; //任务队列,元素类型只是简单的 int
};
class Derived : public MyClass //以 MyClass 为基类
{
public:
Derived()
{
std::cout << "Derived Constructing..." << std::endl;
}
private:
virtual void threadMain()
{
while (1) {
std::unique_lock lock(m_mutex);
m_condition.wait(lock, [&] { return !m_taskQueue.empty() || m_isExit; }); //任务队列中有任务待处理 或者 线程需要退出时唤醒线程
//线程被唤醒,处理任务,或者退出
if (m_isExit) {
//线程需要退出,中断循环
std::cout << "线程退出..." << std::endl;
break;
}
//取出任务
int task = std::move(m_taskQueue.front());
m_taskQueue.pop();
lock.unlock();
std::cout << "一个任务被取出:" << task << std::endl;
// 处理任务
// ...
}
}
};
class ThreadHandler{
public:
ThreadHandler()
{
derived.start();
}
void addTask(int i) {
derived.addTask(i);
}
private:
Derived derived;
};
int main(int argc, char **argv)
{
ThreadHandler threadHandler;
for (int i = 0; i < 10000; ++i) {
threadHandler.addTask(i); //简单演示,直接用 i 作为任务加到任务队列
std::cout << "任务已经被添加:" << i << std::endl;
}
}
总的原则:如陈硕在《Linux多线程服务器编程》中提到的,不要在构造函数中把this传给跨线程对象。时刻牢记构造函数执行时,对象还是个半成品,这时暴露了this指针,比如:
m_thread = std::thread(&MyClass::threadMain, this);
总会有潜在的危险,这个危险来自其它线程通过this来访问还没有准备好的成员。