c++11 中能不能在构造函数中创建并启动线程,这样安全吗?

最近在写 c++ 的程序,做了一个类,这个类对象初始化时,需要创建一个使用该类成员函数作为入口函数的线程。自然地就想到在构造函数中启动线程,但是在陈硕《Linux多线程服务端编程》中提到,在构造函数中启动线程是不安全的,于是对这个问题进行了一番学习。

后来在 https://stackoverflow.com/questions/33571921/can-initialising-a-thread-in-a-class-constructor-lead-to-a-crash/33576786#33576786 中看到了有人有同样的担心。就干脆把这个问题写一写,加深自己的印象,也可以帮助在做同样事情的人。

一. 在构造函数初始化列表中启动线程肯定是不安全

我搞了一个简单的例子程序,就是一个生产者-消费者程序。

#include <iostream>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>

class MyClass
{
public:
    MyClass() : m_isExit(false), m_thread(&MyClass::threadMain, this)
    {
    }

    ~MyClass()
    {
        std::unique_lock<std::mutex> 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<std::mutex> lock_guard(m_mutex);
        m_taskQueue.push(taskToAdd);
        m_condition.notify_one();
    }

private:
    void threadMain()                   //线程入口函数
    {
        while(1) {
            std::unique_lock<std::mutex> 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<int> 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<std::mutex> 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<int> 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 <iostream>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>

class MyClass
{
public:
    MyClass() : m_isExit(false)
    {
        m_thread = std::thread(&MyClass::threadMain, this); //启动线程
    }

    ~MyClass()
    {
        std::unique_lock<std::mutex> 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<std::mutex> 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<int> m_taskQueue;        //任务队列,元素类型只是简单的 int
};

class Derived : public MyClass          //以 MyClass 为基类
{
    virtual void threadMain()
    {
        while(1) {
            std::unique_lock<std::mutex> 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 <iostream>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>

class MyClass
{
public:
	MyClass() : m_isExit(false)
	{
		std::cout << "MyClass Constructing..." << std::endl;
	}

	~MyClass()
	{
		std::unique_lock<std::mutex> 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<std::mutex> 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<int> 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<std::mutex> 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来访问还没有准备好的成员。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值