(二)C++11 原生标准多线程:开始多线程

就像第一篇中分析的那样,多线程的概念还是很容易理解的。可进入实践,一切就没那么简单了。里面会有很多坑,一不小心就会出现不容易发现的错误。就如同第一篇末尾我实践的时候遇到的几个问题,及最后《C++ Concurrency in Action》提到的那个案例。第一篇中我遇到的问题是对C++的语法解析和thread库接口声明不了解。

而回归到线程本身的主题上,生命周期对于多线程至关重要。如果我们忽略,很容易造成程序访问已经销毁的对象、函数、变量。很简单的例子就是,调用者和被调用者,宿主环境和新启动的线程,必须时刻注意他们的生命周期。如果在新线程任务中我们不小心访问了宿主环境已经销毁的设施,这时候我们很难察觉出错误的地方。

第一篇我们初步认识了线程的概念,我们也创建了一个线程。线程在程序中存在两种状态,如果我们想控制线程必须了解这两种状态,以避免错误的生命周期调用。为什么会有两种状态,这两种状态是哪两种:

 

join()和detach()

当我们在程序中创建一个新线程,我们应该了解这个线程所执行任务(函数)的生命周期及创建线程的宿主(创建线程的函数或对象成员函数)生命周期。如果我们创建的线程所执行的任务(函数)需要调用宿主环境的变量,线程所执行的任务没有结束,宿主环境却提前结束了,这时我们调用的宿主环境变量肯定已经销毁,将导致程序错误。

解决这个问题的办法就是调用join(),让宿主环境等待线程结束再销毁宿主环境变量。《C++ Concurrency in Action》书中所言,直接调用join()是简单粗暴的。在大部分情况下直接调用join()是可以正常工作的(如果您的程序在创建线程之后,在join()之前运行是正确的。)。问题是在一些情况下我们无法保证在这一段时间内程序不会抛出异常。如果程序抛出异常,最大的可能是停止线程任务,跳过join()。避免应用被抛出的异常所终止,就需要作出一个决定。我们为了避免异常造成的终止,我们应该把join()放在捕获异常的处理程序中,从而避免生命周期的问题。

如何寻找join的位置:

《C++ Concurrency in Action》书中给了我们两种方案,一种是不完美版,但,它一般也能很好的执行:

struct func; // 定义在清单2.1中
void f()
{
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread t(my_func);
    try
    {
        do_something_in_current_thread();
    }
    catch(...)
    {
        t.join(); // 1
        throw;
    }
    t.join(); // 2
}

这一种方案,假设我们对异常比较清楚,对于轻量级的异常捕获完全在掌控之中。那么这种方案完全是可行的。这里仅仅是使用了C++的异常捕获来避免线程终止而跳过join()。其实这里有一点疑问,既然程序创建的线程已经终止我们还有必要join()么,是的我们有必要。这里的错误捕获完全服务于宿主环境函数执行流程,也就是说,这完全是为了避免宿主执行函数因为异常而损害了join()的调用以至于提前释放了宿主环境变量。而线程任务依旧还是在运行的。

完美版调用join()

class thread_guard
{
    std::thread& t;
    public:
        explicit thread_guard(std::thread& t_):t(t_)
        {
        }
        ~thread_guard()
        {
            if(t.joinable()) // 1
            {
                t.join(); // 2
            }
        }
        thread_guard(thread_guard const&)=delete; // 3
        thread_guard& operator=(thread_guard const&)=delete;
};

struct func; // 定义在清单2.1中

void f()
{
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread t(my_func);
    thread_guard g(t);
    do_something_in_current_thread();
} // 4

很显然,这里用到了一个局部对象。在宿主环境中创建一个对象,把创建的线程实例传进对象里,当宿主环境函数执行完毕即将释放时,肯定会释放创建的对象而调用对象的成员析构函数。这样就给了我们一个机会调用join(),这样,无论发生怎样的错误异常,都会调用析构函数而触发join()调用。这样就很完美的解决了join()的位置,执行序列问题,彻底解决了等待线程结束的承诺。

joinable()是一张join()门票,对于一个线程对象只能使用一次。它是线程join()的状态标志,且只能调用一次join()。当新创建的线程还没有join(),那么joinable()将返回true,否则返回false。

 

detach()可以做什么:

detach()和join()好像天生就是一对冤家,做着彼此相反的事情。detach()把新创建的线程明确的分离,这里的分离指的是与宿主环境(一般就是主线程,或者是创建线程的函数方法)撇开关系,不再让宿主环境等待新创建的线程执行结束才释放宿主运行环境(变量,对象等)。

最典型的案例应该是后台服务线程,它们无视主线程,无视创建他们的线程环境。只要创建成功一般都会等到整个程序的运行结束,一直提供它力所能及的服务,而不管其他线程是什么情况,它只保证交给他的任务。

需要了解的一点是,当我们调用detach()后,线程对象和执行的任务已经分离开来,执行的任务就是新创建的线程,它不再提供任何接口供我们使用。joinable()同样对detach()有效,一旦我们调用detach()使用了这张门票,那这张joinable()门票就永远作废,返回false。所以join()也无法使用到这个线程。

需要分清的一点概念:std::thread创建的对象不是线程,而对象调用的任务才是真正的线程。当我们调用join()后,线程和对象不会分离,这样我们就有机会控制线程。而detach()调用,会分离线程,这时的对象与运行的线程就没有任何关系了。这也证明在join()调用后,std::thread对象是新创建线程的引用。

detach()的调用时机与join()调用时机基本是相同的。因为无需在意宿主环境的生命周期,前提是我们设计的新线程不依赖宿主环境变量。

 

向进程传递参数:

当我们了解了线程在程序中的两种状态后,我们在设计线程任务时应该明确的区分我们所创建线程属于哪种状态。而后我们很有可能需要向我们调用的函数或是对象构造函数传递参数。完成了传递参数任务后,设计好的线程任务应该就可以正常的启动运行了。

下面是一个简单的传参例子:

Connection::Connection(QObject *parent)
{
    std::thread t(&Connection::threadA,this,6);
    t.join();
}

void Connection::threadA(int num)
{
    while (num >= 1)
    {
        --num;
        std::cout<<"My First ThreadA !"<<std::endl;
    }

}

运行结果:

My First ThreadA !
My First ThreadA !
My First ThreadA !
My First ThreadA !
My First ThreadA !
My First ThreadA !

向可调用对象或函数传递参数在本质上与向std::thread构造函数传递附加参数一样。但必须记住的是,默认情况下,参数被复制到内部存储器中,新创建的执行线程可以访问这些参数,即使函数中的相应参数需要引用。就像上面的例子一样,如果向可调用对象或函数传递参数,我们只要依序传递附加参数列表即可。

传参陷阱:

字符串隐式转换陷阱:

《C++ Concurrency in Action》指出:指向动态变量的指针作为参数传递给线程的情况,这种情况不仅仅发生在线程创建时,只要是字符常量隐式转化成string类型时,都会有可能发生。所以这不是线程独有的。在C++开发的整个过程中,我们都需要注意字符常量隐式转化可能产生的崩溃现象。解决办法就是显示强类型转换。

实例分析:当buffer指针所指向的字符内存空间接受格式化后的字符串,通过buffer传递给thread时,将发生隐式类型转换。char const *类型转化为string类型,在此时将有可能产生转换失败。thread构造函数默认的会把参数变量统统拷贝,这时拷贝的将是一个不可知的类型变量,造成指针悬空发生错误。

//《C++ Concurrency in Action》有可能发生错误的例子:
void f(int i,std::string const& s);
void oops(int some_param)
{
    char buffer[1024]; // 1
    sprintf(buffer, "%i",some_param);
    std::thread t(f,3,buffer); // 2
    t.detach();
}

//《C++ Concurrency in Action》有可能发生错误例子的解决方案:
void f(int i,std::string const& s);
void not_oops(int some_param)
{
    char buffer[1024];
    sprintf(buffer,"%i",some_param);
    std::thread t(f,3,std::string(buffer)); // 使用std::string,避免悬垂指针
    t.detach();
}

对象引用陷阱:

还可能遇到相反的情况:期望传递一个引用,但整个对象被复制了。当线程更新一个引用传递的数据结构时,这种情况就可能发生,比如:

struct strdata
{
        std::string data_a = "Thead";
};

Connection::Connection(QObject *parent)
{
    strdata data;


    //std::ref(data)
    std::thread t(&Connection::threadA,this,1,"I Love Thread!", data);
    t.join();

    std::cout<<"宿主环境变量:"<<data.data_a<<std::endl;
}

void Connection::threadA(int num, std::string const& str, strdata& strs)
{
    while (num >= 1)
    {
        --num;
        std::cout<<"My First ThreadA !"<<std::endl;
        std::cout<<str<<std::endl;
        strs.data_a = "QQQQQQ";
        std::cout<<"线程参数拷贝环境变量:"<<strs.data_a<<std::endl;
    }

}

这是一个传参完整的例子,但是我们在第三个参数传递的是一个对象的引用。我们期望可以修改宿主环境对象的数据。程序运行却不像我们预期的那样执行。thread构造函数直接拷贝了data对象,所以理论上我们已经无法对宿主data对象进行修改。这里的引用已经是拷贝的数据结构。我在实验时使用的是Qt环境,编译器直接提示右值引用不能被调用,直接静态断言错误。我如果把形参生命为string const &,编译器是允许运行的,但是就像我们分析的一样,我们已经失去了对宿主data对象的引用,而无法修改data对象的数据。

解决这个陷阱的方案是,通过std::ref强制告知thread构造函数,这里是一个引用,而不是对象拷贝的引用。

解决方案如:

struct strdata
{
        std::string data_a = "Thead";
};

Connection::Connection(QObject *parent)
{
    strdata data;


    //std::ref(data)
    std::thread t(&Connection::threadA,this,1,"I Love Thread!", std::ref(data));
    t.join();

    std::cout<<"宿主环境变量:"<<data.data_a<<std::endl;
}

void Connection::threadA(int num, std::string const& str, strdata& strs)
{
    while (num >= 1)
    {
        --num;
        std::cout<<"My First ThreadA !"<<std::endl;
        std::cout<<str<<std::endl;
        strs.data_a = "QQQQQQ";
        std::cout<<"线程参数拷贝环境变量:"<<strs.data_a<<std::endl;
    }

}

最后再次提示thread构造函数传递对象成员函数迷惑的地方:如果你熟悉 std::bind ,就应该不会对以上述传参的形式感到奇怪,因为 std::thread 构造函数和 std::bind 的操作都在标准库中定义好了,可以传递一个成员函数指针作为线程函数,并提供一个合适的对象指针作为第一个参数。 std::thread 构造函数的第三个参数就是成员函数的第一个参数,以此类推(代码如下,译者自加)。

 

引言:参数可以移动,但不能拷贝

智能指针是行为类似于指针的类对象。属于标准模板库的功能。它赋予一个普通指针具有析构函数的能力,当一个函数执行完毕,自动调用析构函数完成垃圾回收,避免内存泄漏。

std::unique_ptr禁止对象拷贝和复制,但能移动。来自智能指针的扩展甜饼。

std::unique_ptrc++11起引入的智能指针,为什么必须要在c++11起才有该特性,主要还是c++11增加了move语义,否则无法对对象的所有权进行传递。而智能指针最大的职责就是智能的垃圾回收,为了提供智能的垃圾回收机制,智能指针就必须避免有多个智能指针指向同一个对象,否则当垃圾回收开始就会多次析构不存在的对象。正是有了这样的特性才禁止智能指针对象拷贝和复制,但能移动,就是我们了解的move语义。ownership(所有权)机制就从此而来。需要注意的一点是,我们提到的对象是std::unique_ptr智能指针对象。

ownership(所有权)机制

std::thread和std::unique_ptr智能指针具有相同的所有权特性,当然他们负责完成的职责不一定是一样的,但他们的思想基本相同。所以,std::thread也同样禁止对象拷贝复制,但能移动传递所有权。std::thread 支持移动,就意味着线程的所有权可以在函数外进行转移。就像我们在std::thread detach()调用中了解到的,std::thread 实例对象是可以与执行的线程分离的,也就意味着我们有机会通过move语义实现所有权的转移。std::thread对象在分离执行线程,或是通过move转移了对象以后,这个分离后的std::thread对象就可以接受新的线程或是转移过来的线程。

//来自《C++ Concurrency in Action》的实例
void some_function();
void some_other_function();
std::thread t1(some_function); // 1
std::thread t2=std::move(t1); // 2
t1=std::thread(some_other_function); // 3
std::thread t3; // 4
t3=std::move(t2); // 5
t1=std::move(t3); // 6 赋值操作将使程序崩溃

代码很好理解,其主要思想就是,在同一时间运行的线程只有一个std::thread对象具有它的所有权,std::thread对象也只能指向一个线程。

在实例标记3处,没有用到显示的move移动。因为 td::thread(some_other_function) 是一个临时对象,没有具体标识符(名字),所有者是一个临时对象——移动操作将会隐式的调用move移动所有权。

最后一个移动操作,将some_function线程的所有权转移⑥给t1。不过,t1已经有了一个关联的线程(执行some_other_function的线程),所以这里系统直接调用 std::terminate() 终止程序继续运行。这样做(不抛出异常, std::terminate() 是noexcept函数)是为了保证与 std::thread 的析构函数的行为一致。不能通过赋一个新值给 std::thread 对象的方式来"丢弃"一个线程。

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章