线程的基本操作

启动线程

每个程序至少有一个main()函数的线程,其他线程与主线程同时运行。如main()函数执行完全退出一样,线程执行完函数也会退出。为线程创建std::thread对象后,需要等待这个线程结束。

线程在std::thread对象创建时启动,通常使用的是无参数无返回值的函数。这种函数在执行完毕,线程也就结束了。使用C++线程库启动线程,就是构造std::thread对象:

void do_some_work();
std::thread my_thread(do_some_work);

std::thread可以通过有函数操作符类型的实例进行构造:

class background_task
{
public:
    void operator()() const
    {
        do_something();
        do_something_else();
    }
};

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

代码中,提供的函数对象会复制到新线程的存储空间中,函数对象的执行和调用都在线程的内存空间中进行。

std::thread my_thread(background_task());

这相当于声明了一个名为my_thread的函数,这个函数带有一个参数(函数指针指向没有参数并返回background_task对象的函数),返回一个std::thread对象的函数。

使用在前面命名函数对象的方式,或使用多组括号,或使用统一的初始化语法,都可以避免这个问题。

std::thread my_thread((background_task())); //多组括号
std::thread my_thread{background_task()}; //统一的初始化语法

Lamda表达式可以避免这个问题。允许使用一个可以捕获局部变量的局部函数可以避免传递参数。

std::thread my_thread([]{
    do_something();
    do_something_else();
});

线程启动之后要等待线程结束,还是让其自主运行,当std::thread对象销毁之前还没有做出决定,程序就会终止(std::thread的析构函数会调用std::terminate()),因此,即便是有异常存在,也需要确保线程能够正确汇入(joined)或分离(detached)。

如果不等待线程汇入,就必须保证程序结束之前,访问数据的有效性。这不是一个新问题——单线程代码中,对象销毁以后再去访问,会产生一个未定义行为——不过,线程的生命周期增加了这个问题发生的几率。

这种情况很可能发生在线程还没结束,函数已经退出的时候,这时候线程函数还持有函数局部变量的指针或引用。

struct func
{
    int &i;
    func(int &i_) : i(i_){}
    void operator()()
    {
        for(unsigned j = 0; j < 1000000; j++)
        {
            do_something(i);  //潜在访问隐患,空引用
        }
    }
};

void oops()
{
    int state = 0;
    func my_func(state);
    std::thread my_thread(my_func);
    my_thread.detach();        //不等待线程结束
}                              //新线程可能

代码中,已经决定不等待线程(使用了detach()),所以当oops()函数执行完成时,线程中的函数可能还在运行。如果线程还在运行,就会去调用do_something(i),这时就会访问已经销毁的变量。如同一个单线程程序——允许在函数完成后继续持有局部变量的指针或引用。当然,这种情况发生时,错误并不明显,会使多线程更容易出错。

这种情况的常规处理方法:将数据复制到线程中,如果使用一个可调用的对象作为线程的函数,这个对象就会复制到线程中,而后原始对象会立即销毁。但对于对象中包含的指针和引用还需要谨慎。使用访问局部变量的函数去创建一个线程是一个糟糕的主意。

此外,可以通过join()函数来确保线程在主函数完成前结束。

等待线程完成

如需等待线程,需要使用join(),将上面代码中的detach()修改为join(),就可以确保局部变量在线程完成后才销毁。因为主线程并没有做什么事,使用独立线程去执行函数变得意义不大。但在实际中,原始线程要么有自己的工作要做,要么会启动多个子线程来做一些有用的工作,并等待这些线程结束。

当你需要对等待中的线程有更灵活的控制时,比如:看一下某个线程是否结束,或者只等待一段时间超过时间就判定为超时超过时间就判定为超时。想要做到这些,需要使用其他机制来完成,比如条件变量和future。调用join(),还可以清理线程相关的内存,这样std::thread对象将不再与已经完成的线程有任何关联。这意味着,只能对一个线程使用一次join(),一旦使用过join(),std::thread对象就不能再次汇入了。当对其使用joinable()时,将返回false。

特殊情况下的等待

如果想要分离线程,可以在线程启动后,直接detach()进行分离,如果等待线程,则需要细心挑选使用join()的位置。当在线程运行后产生异常,会在join()调用之前抛出,这样就会跳过join().

避免应用被抛出的异常所终止。通常,在无异常的情况下使用join()时,需要在异常处理过程中调用join(),从而避免生命周期的问题。

void f()
{
    int state = 0;
    func my_func(state);
    std::thread my_thread(my_func);
    try
    {
        std::cout << "do something in current thread" << std::endl;
    }
    catch (...)
    {
        my_thread.join();   //1
        throw;
    }
    my_thread.join();  //2
}

代码中使用了try/catch块确保线程退出后函数才结束。当函数正常退出后,会执行到②处。当执行过程中抛出异常,程序会执行到①处。如果线程在函数之前结束——就要查看是否因为线程函数使用了局部变量的引用——而后再确定一下程序可能会退出的途径,无论正常与否,有一个简单的机制,可以解决这个问题。

一种方式是使用“资源获取即初始化方式”RAII,Resource Acquisition Is InitializationRAII,ResourceAcquisitionIsInitialization,提供一个类,在析构函数中使用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; // 定义在查看前面

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

线程执行到④处时,局部对象就要被逆序销毁了。因此,thread_guard对象g是第一个被销毁的,这时线程在析构函数中被加入②到原始线程中。即使do_something_in_current_thread抛出一个异常,这个销毁依旧会发生。

在thread_guard析构函数的测试中,首先判断线程是否可汇入①。如果可汇入,会调用join()②进行汇入。

拷贝构造函数和拷贝赋值操作标记为=delete③,是为了不让编译器自动生成。直接对对象进行拷贝或赋值是很危险的,因为这可能会弄丢已汇入的线程。通过删除声明,任何尝试给thread_guard对象赋值的操作都会引发一个编译错误。

如果不想等待线程结束,可以分离线程,从而避免异常。不过,这就打破了线程与std::thread对象的联系,即使线程仍然在后台运行着,分离操作也能确保在std::thread对象销毁时不调用std::terminate()

后台运行线程

使用detach()会让线程在后台运行,这就意味着与主线程不能直接交互。如果线程分离,就不可能有std::thread对象能引用它,分离线程的确在后台运行,所以分离的线程不能汇入,不过C++运行库保证,当线程退出时,相关资源能够正确回收。

分离线程通常称为守护线程(daemon threads)。UNIX中守护线程,是指没有任何显式的接口,并在后台运行的线程,这种线程的特点就是长时间运行。线程的生命周期可能会从应用的起始到结束,可能会在后台监视文件系统,还有可能对缓存进行清理,亦或对数据结构进行优化。另外,分离线程只能确定线程什么时候结束,发后即忘fire and forgetfireandforget的任务使用到就是分离线程。

std::thread成员函数detach()来分离一个线程。之后,相应的std::thread对象就与实际执行的线程无关了,并且这个线程也无法汇入:

std::thread t(do_background_work);
t.detach();
assert(!t.joinable());

为了从std::thread对象中分离线程,不能对没有执行线程的std::thread对象使用detach(),并且要用同样的方式进行检查——当std::thread对象使用t.joinable()返回的是true,就可以使用t.detach()。

试想如何能让一个文字处理应用同时编辑多个文档。无论是用户界面,还是在内部应用内部进行,都有很多的解决方法。虽然,这些窗口看起来是完全独立的,每个窗口都有自己独立的菜单选项,但他们却运行在同一个应用实例中。一种内部处理方式是,让每个文档处理窗口拥有自己的线程。每个线程运行同样的的代码,并隔离不同窗口处理的数据。如此这般,打开一个文档就要启动一个新线程。因为是对独立文档进行操作,所以没有必要等待其他线程完成,这里就可以让文档处理窗口运行在分离线程上。

void edit_document(std::string const& filename)
{
  open_document_and_display_gui(filename);
  while(!done_editing())
  {
    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);  // 1
      t.detach();  // 2
    }
    else
    {
       process_user_input(cmd);
    }
  }
}

如果用户选择打开一个新文档,需要启动一个新线程去打开新文档①,并分离线程②。与当前线程做出的操作一样,新线程只不过是打开另一个文件而已。所以,edit_document函数可以复用, 并通过传参的形式打开新的文件。

这个例子也展示了传参启动线程的方法:不仅可以向std::thread构造函数①传递函数名,还可以传递函数所需的参数实参实参。当然,也有其他方法可以完成这项功能,比如:使用带有数据的成员函数,代替需要传参的普通函数。

你可能感兴趣的:(C++并发编程,开发语言,c++)