第二章 线程管控

时间:2023-01-14 19:06:03

主要内容:

  • 启动线程,并通过几种方式为新线程指定运行代码
  • 等待线程完成和分离线程并运行
  • 唯一识别一个线程

2.1 线程的基本管控

​ main函数其本声就是一个线程,在其中又可以启动别的线程和设置其对应的函数入口。

2.1.1 发起线程

​ 不管线程要执行的任务是复杂还是简单,其最终都要落实到标准库的std::thread对象的创建,书中这一章作者提到了所谓的“C++最麻烦的解释”:将函数对象传递给std::thread对象时,传入的是临时对象,而不是具名对象时,编译器会将其解释为函数声明而不是定义对象,这一块书中其实没有给出具体的代码示例,只是给出了如下的一个声明:

std::thread my_thread((background_task()));

下面我写了一个验证程序来验证作者所说的这一种情况:

class background_task
{
public:
   //函数转化操作符,将类转为函数对象
   void operator()() const
   {
      cout<<"background_task's function convert"<<endl;
   }
};

void func_inside_mythread()
{
   cout<<"func_inside_mythread"<<endl;
}

//一个返回background_task对象的函数
background_task do_something()
{
   cout<<"do somthing inside background_task"<<endl;
   return background_task();
}

//这里便是引发编译器歧义的申明,这里既可以声明为thread对象的创建也可以是一个参数为返回background_task函数指针的函数
thread my_thread(background_task(*p)());
//如下便是对于上面t1的函数定义
thread my_thread(background_task(*p)())
{
   (*p)();
   cout<<"I am a function which get function pointer background_task"<<endl;
   return thread(func_inside_mythread);
}
int main()
{
   thread mt = my_thread(do_something);
   mt.join();
   return 0;
}

​ 上面的t1就是引发编译器歧义的地方,也就是作者所举例说明的情况,下面来看一下执行结果:

第二章 线程管控

​ 可以发现编译器把my_thread看作是了一个函数定义,但是实际上这里我传入的参数是故意给了一个具名的返回background_tast对象的p函数指针,实际上还可以这么写:

int main()
{
   //1
   thread my_thread(background_task());
   
   //2
   background_task f;
   thread my_thread(f);

   return 0;
}

​ main里的第一句,其实按照语法上来讲,这里的backgroun_task类已经做过了函数类型转化的操作了,在这里正常时可以解释成我定义了一个thread线程对象,他接收可调用对象background_task函数,但是实际上通过vscode自带的提示器,将鼠标移动上去以后可以看见它仍然提示这是一个函数声明:
第二章 线程管控

​ 那如何解决上述问题呢?其实就是书上说的C11以后引入了新式的统一初始化语法,也叫列表初始化,像下面这样写就不存在编译器把这一行解释成函数的情况了(或者还可以直接传入lambda表达式做临时函数变量也能解决问题):

int main()
{
   thread my_thread{background_task()};
   my_thread.join();
    
   return 0;

第二章 线程管控

​ 接下来作者说到了线程的分离和汇合,这里要总结一个概念:如果什么都不设置,thread对象析构时将自动终止线程程序,如果分离,就算thread对象已经彻底析构了,线程程序还在自己继续跑着。

​ 这也接下来引发了第二个问题,即如果新线程的函数上持有指向主线程的变量或者数据的指针或引用时,但主线程运行退出后,新线程还没结束时,这个时候再访问那些指针的时候就是非法访问了,书配套代码如下:

#include <thread>

void do_something(int& i)
{
    ++i;
}

struct func
{
    int& i;

    func(int& i_):i(i_){}

    void operator()()
    {
        //for(unsigned j=0;j<1000000;++j)
        //这里为了复现非法访问的情况改成了无限循环
        while(1)
        {
            do_something(i);
        }
    }
};


void oops()
{
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread my_thread(my_func);
    my_thread.detach();
}

int main()
{
    oops();
}

​ 这里要引发的问题就在于,主线程detach以后结束了,自然局部变量得到释放,但是新增的线程仍然还在跑着,因为传的是引用类型,所以这个时候再去访问就是非法访问了,以下是我的运行结果,估计是Linux内部的什么机制,主线程一旦退出子线程随即也退出的场景我没有复现出来:

第二章 线程管控

​ 可以看见在gdb中切换到子进程以后输入c命令让程序自动运行,主线程775856先退出,随后子进程立刻也跟着退出了。

​ 解决上述问题的方法作者也给了出来,主要是两点,一是让线程函数完全自含(self-contained),另一种是使用thread的join函数,确保子进程在父进程之前退出,也就是汇合线程操作。如果想要更加精细化地控制线程等待,则要到后面讲条件变量和future的时候继续学习,一旦调用了join,则这个线程相关的任何存储空间都将被立即删除。

2.1.3 在出现异常的情况下等待

​ 接下来说到了在出现异常状况下的join等待,主要的问题在于当新线程启动以后,如果有异常抛出,但是这个时候join在异常的后面,这样join就得不到执行了略过了,先来看一下代码清单2.2中不用try-catch的情况:

#include <thread>
#include <iostream>
using namespace std;

void do_something(int& i)
{
    ++i;
}

struct func
{
    int& i;

    func(int& i_):i(i_){}

    void operator()()
    {
        for(unsigned j=0;j<1000000;++j)
        {
            do_something(i);
        }
    }
};

void do_something_in_current_thread()
{
    cout<<"do something error in current thread"<<endl;

}

void f()
{
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread t(my_func);
    // try
    // {
        do_something_in_current_thread();
        cout<< 3 / 0 <<endl;
    //}
    // catch(...)
    // {
    //     t.join();
    //     throw;
    // }
    t.join();
}

int main()
{
    f();
}

运行结果:通过gdb可以看到出现算术异常时,系统抛出了浮点运算错误,此时新线程2因为浮点错误直接终止了,但是此时主线程收到了子线程传过来的SIGFPE信号,也终止了:

第二章 线程管控

​ 主线程随后也收到了该信号终止:

第二章 线程管控

​ 下面展示成功捕捉到异常然后汇合的场景:

第二章 线程管控

​ 此处新线程在收到SIGFPE时,两个线程同时终止。上述的使用try-catch捕获异常的写法其实稍显冗余,更好的是使用标准RALL手法,如下面配套2.3代码:

#include <thread>
#include <iostream>
using namespace std;

class thread_guard
{
    std::thread& t;
public:
    explicit thread_guard(std::thread& t_):
        t(t_)
    {}
    ~thread_guard()
    {
        if(t.joinable())
        {
            cout<<"Prepare to join"<<endl;
            t.join();
        }
    }
    //=delete不允许系统生成自己的默认拷贝和等号运算符重载
    thread_guard(thread_guard const&)=delete;
    thread_guard& operator=(thread_guard const&)=delete;
};

void do_something(int& i)
{
    ++i;
}

struct func
{
    int& i;

    func(int& i_):i(i_){}

    void operator()()
    {
        for(unsigned j=0;j<1000000;++j)
        {
            do_something(i);
        }
    }
};

void do_something_in_current_thread()
{}


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

int main()
{
    f();
}

​ 这里的要点是,利用析构的顺序这个概念,thread_guard对象一定比thread对象t先析构,又用了RALL手法,所以一定可以汇合,不管后面出不出异常,以下是执行结果:

第二章 线程管控

​ 可以看出在线程2出现异常以后,主线程成功调用了join等待到了与2号线程汇合。

2.1.4 在后台运行线程

​ 这一节主要讲了detach的用法以及一个模拟应用场景,提到了守护线程的概念:即和守护进程一样,被分离出去的线程完全在后台运行,其几乎存在于整个应用程序生命周期内。配套代码2.4给出了文字处理软件编辑多文件的多线程分离应用场景:

#include <thread>
#include <string>

void open_document_and_display_gui(std::string const& filename)
{}

bool done_editing()
{
    return true;
}

enum command_type{
    open_new_document
};


struct user_command
{
    command_type type;

    user_command():
        type(open_new_document)
    {}
};

user_command get_user_input()
{
    return user_command();
}

std::string get_filename_from_user()
{
    return "foo.doc";
}

void process_user_input(user_command const& cmd)
{}

void edit_document(std::string const& filename)
{
    open_document_and_display_gui(filename);
    //while(!done_editing())
    for(int i = 0 ;i < 3 ;i++)
    {
        user_command cmd=get_user_input();
        if(cmd.type==open_new_document)
        {
            std::string const new_name=get_filename_from_user();
            std::thread t(edit_document,new_name);
            t.detach();
        }
        else
        {
            process_user_input(cmd);
        }
    }
}

int main()
{
    edit_document("bar.doc");
}

​ 主要是模拟多线程处理过程,这里就跳过执行结果了。

2.2 向线程函数传递参数

​ 首先总结一个概念:线程的内部是有存储空间的,任何传递给线程的函数参数都会默认先被复制到该处,随后新线程才能访问他们,再然后这些副本被当做右值传给线程上的可调用对象。

​ 上述的概念引出了书中说的第一个错误,示例如下:

void f(int i,std::string const& s);
void oops(int some_param)
{
    char buffer[1024];                  //    ⇽---  ①
    sprintf(buffer, "%i",some_param);
    std::thread t(f,3,buffer);          //    ⇽---  ②
    t.detach();
}

​ 这里的问题在于,因为thread的构造函数需要原样复制所提供的值,然后再转换成可调用对象参数的预期类型,所以有可能oops在这个复制过程中先行崩溃或者退出,导致局部变量buffer被销毁而引发未定义的行为,所以作者提出的解决办法是先给他手工转成string:

std::string(buffer)

​ 然后再传进去就行了。

​ 另一个场景刚好相反,也就是我们期望参数类型是非const引用,而岸上上述的thread构造概念,整个对象却被完全复制了一遍,这个是不合理的情况,编译也过不了,这里作者没给出示例代码,我写了一段验证之:

#include <thread>
#include <iostream>
#include <condition_variable>
#include <queue>
#include <mutex>
#include <stdlib.h>
#include <string.h>
using namespace std;

struct widget_id
{
   int id;
};

struct widget_data
{

};

void update_data_for_widget(widget_id w, widget_data & data)
{

}

void oops_again(widget_id w)
{
   widget_data data;
   //正确情况
   //thread t(update_data_for_widget,w,ref(data));
   //非正确,编译错误
   thread t(update_data_for_widget,w,data);
   t.join();
}

int main()
{
   oops_again(widget_id());
   return 0;
}

​ 编译错误显示如下:

第二章 线程管控

​ 这里的解决方案是利用标准的std::ref函数做一层包装,把它强制转成左值引用传入,这里其实内部还有的讲(即为什么ref之后就会忽略thread构造本身需要复制一遍的事实呢?这里其实是内部用forward实现了完美转发),引用类型按照原先的类型传递到了线程的可调用对象参数列表中。

​ bind函数和thread构造的参数传递机制其实很相似,下一部分作者提到了如何将一个类的非静态成员函数最为thread的调用对象的,其原理译者在下方1号注释中做了说明。

2.3 移交线程归属权

如果thread对象正在管理一个线程,就不能简单地向他赋新值,否则新线程会因此被遗弃。这一节主要讲的是移动语义和线程归属权相互移交的过程,代码清单2-5展示了从函数内部返回thread对象,清单2-6和之前的2-3很相似,只不过在构造函数用了移动语义直接去构造要接管的thread对象,以及本来要引入C17的joining_thread类,这里就不做展示和演示了。

​ 清单2.7展示了线程管控自动化切分的简单实现,用vector管理了一堆线程:

#include <vector>
#include <thread>
#include <algorithm>
#include <functional>

void do_work(unsigned id)
{}

void f()
{
    std::vector<std::thread> threads;
    for(unsigned i=0;i<20;++i)
    {
        threads.push_back(std::thread(do_work,i));
    }
    std::for_each(threads.begin(),threads.end(),
        std::mem_fn(&std::thread::join));
}

int main()
{
    f();
}

​ 这里使用了标准的mem_fn,返回一个指向其参数函数的函数指针用于foreach遍历。

2.4 在运行时选择线程数量

​ 这章简单实现了一个并行版本的accumulate,无特别说明,看懂代码和说明即可。

2.5 识别线程

​ 主要介绍了线程id,如何获取它(调用thread.get_id),获取当前线程的方法(this_thread)以及标准库对其实现了全面的比较运算符支持。