第2章 线程管理

2.1 线程的基本操作

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

2.1.1 启动线程

   一些情况下, 任务函数对象需要通过某种通讯机制进行参数的传递, 或者执行一系列独立操作, 通过通讯机制传递信号让线程信号停止。 先放下这些特殊情况不谈, 简单来说, 使用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);

   代码中, 提供的函数对象会复制到新线程的存储空间中, 函数对象的执行和调用都在线程的内存空间中进行。
注意:当把函数对象传入线程构造函数中时,如果你传递了一个临时变量, 而不是一个命名的变量。 C++编译器会将其解析为函数声明, 而不是雷翔对象的定义。

std::thread my_thread(background_task());

这相当对声明了一个名为my_thread的函数, 这个函数带有一个函数(函数指针执行没有参数并返回background_task对象的函数), 返回一个std::thread对象的函数。
使用在前面命名函数对象的方式, 或使用多组括号(1), 或者使用统一的初始化语法(2), 都可以避免该问题。

std::thread my_thread((background_task())); //(1)
std::thread my_thread{background_task()}; //(2)

Lambda表达式也能避免这个问题。 Lambda表达式允许使用一个可以捕获局部变量的局部函数(可以避免传递参数)。 之前的例子可以改写为Lambda表达式的方式:

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

   线程启动后是要等待线程结束, 还是让其自动运行。 当std::thread()对象销毁之前还没有做出决定, 程序就会终止(std::thread的析构函数会调用std::terminate())。因此, 即使是有异常存在, 也需要确保线程能够正确汇入(joined)或分离(detached)。
如果不等待线程汇入, 就必须保证线程结束之前, 访问数据的有效性。这不是一个新问题–单线程代码中, 对象销毁之后再去访问, 会产生未定义行为–不过, 线程的生命周期增加了这个问题发生的几率。
这种情况很可能发生在线程还没借宿, 函数已经退出的时候, 这时线程函数还持有函数局部变量的指针或引用。
代码2.1 函数已经返回, 线程依旧访问局部变量

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

void oops()
{
  int some_local_state=0;
  func my_func(some_local_state);
  std::thread my_thread(my_func);
  my_thread.detach();          // (2) 不等待线程结束
}                              // (3) 新线程可能还在运行

代码中, 已经决定不等待线程(使用了detach()(2)), 所以当oops()函数执行完成时(3), 线程中的函数可能还在运行。 如果线程还在运行, 就会去调用do_something(i)(1), 这时就会访问已经销毁的变量。 如同一个单线程程序–允许在函数完成后继续拥有局部变量的指针或引用。 当然, 这种情况发生时, 错误并不明显, 会使多线程更容易出错。 运行顺序参考下表2.1。
表2.1 分离( leaving it
to run)线程在局部变量销毁后, 仍对该变量进行访问。

主线程新线程
使用some_local_state构造my_func
开启新线程my_thread
启动
调用func::operator()
将my_thread分离执行func::operator();可能会在do_something中调用some_local_state的引用
销毁some_local_state持续运行
退出oops函数持续执行func::operator();可能会在do_something中调用some_local_state的引用 --> 导致未定义行为
这种情况的常规处理方法:将数据复制到线程中。 如果使用一个可调用的对象作为线程函数, 这个对象就会复制到线程中, 而后原始对象会立即销毁。 如代码2.1所示, 但对于对象中包含的指针和引用还需谨慎。 使用访问局部变量的函数去创建线程是一个糟糕的注意(bad idea)。
此外, 可以通过join()函数来确保线程在主函数完成前结束。

2.1.2 等待线程完成

  如需等待线程, 需要使用join()。 将代码2.1的my_thread.detach()替换为my_thread.join(), 就可以确保局部变量在线程完成后才销毁。 因为主线程并没有做什么事, 使用独立的线程去执行函数变得意义不大。 但在实际中, 原始线程要么有自己的工作要做, 要么会启动多个子线程来做一些有用的工作, 并等待这些线程结束。
  当你需要对等待中的线程有更灵活的控制时, 如: 看一下某个线程是否结束, 或者只等待一段时间(超过时间就判定为超时)。 想要做到这些, 需要使用其他机制来完成, 比如条件变量和future。 调用join(), 还可以清理了线程相关的内存, 这项std::thread对象将不再与已完成的线程有任何关联。 这意味着, 只能对一个线程使用一次join(), 一旦使用过join(), std::thread对象就不能再次汇入了。 当对使用joinable()时, 将返回flase。

2.1.3 特殊情况下的等待

  如果想要分离线程, 可以在线程启动后, 直接使用detach()进行分离。 如果等待线程, 则需要细心挑选使用join()的位置。 当在线程运行后产生的一场, 会在join()调用之前抛出, 这样就会跳过join()。
避免应用被抛出的异常所终止。 通常, 在无异常的情况下使用join()时, 需要在异常处理过程中调用join(), 从而避免生命周期的问题。
代码2.2 等待线程完成

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)
}

代码2.2中使用了try/catch块确保线程退出后函数才结束。 当函数正常退出后, 会执行(2)处。 当执行过程中抛出异常, 程序会执行到(1)。 如果线程在函数之前结束–就要查看是否因为线程函数使用了局部变量的引用–而后确定一下程序可能会退出的途径。有一个叫RAII(Resource Acquisition Is Initialization)的机制可以解决这个问题,提供一个类, 在析构函数中使用join()。
代码2.3 使用RAII等待线程完成

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)

   线程执行到(4)时,局部对象就要被逆序销毁了。 因此, thread_guard对象g是第一个被销毁的, 这时线程在析构函数中被加入(2)到原始线程中。 即使do_something_in_current_thread抛出一个异常, 这个销毁依然会发生。
  在thread_guard析构函数的测试中, 首先判断线程是否可汇入(1)。如果可汇入, 会调用join()(2)进行汇入。拷贝构造函数和拷贝复制操作标记为=delete(3), 是为了不让编译器自动生成。 直接对对象进行拷贝或复制是很危险的, 因为这可能会弄对已汇入的线程。 通过删除生命, 任何尝试给thread_guard对象复制的操作都会引发一个编译错误。 参阅附录A的A.2节。
如果不想等待线程结束, 可以分离线程, 从而避免异常。 不过, 这就打破了线程与std::thread对象的联系, 即使线程仍然在后台运行着, 分离操作也能确保在std::thread对象销毁时不调用std::terminate()。

2.1.4 后台运行线程

  使用detach()会让线程在后台运行, 这就意味着与主线程不能直接交互。 如果线程分离, 就不可能有std::thread对象使用它, 分离线程的确在后台运行, 所以分离的线程不能汇入。 不过C++运行库保证, 当线程退出时, 相关资源的能够正确回收。
  分离线程通常为守护线程(daemon threads)。 UNIX中守护线程,是指没有任何显示的接口, 并在后台运行的线程, 这种线程的特点就是长时间运行。线程的生命周期可能会从应用的起始到结束, 可能会在后台监视文件系统, 还有可能对缓存进行清理, 或者对数据结构进行优化。另外, 分离线程只能确定线程什么时候结束, 发后即忘(fire and forget)的任务使用到就是分离线程。
如2.1.2节所示, 调用std::thread成员函数detach成员函数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()。
   试想如何能让一个文字处理应用同事编辑多个文档。 无论是用户界面, 还是在内部应用内部进行, 都有很多的解决方法。 虽然, 这些窗口看起来是完全独立的, 每个窗口都有自己独立的菜单选项, 但他们却运行在同一个应用实例中。 一种内部处理方式是, 让每个文档处理窗口拥有自己的线程。 每个线程运行同样的代码, 并个隔离不同窗口处理的数据。 如此这般, 打开一个文档就要启动一个新线程。 因为是对独立文档进行操作, 所以没有必要等待其他线程完成, 这里就可以让文档处理窗口运行在分离线程上。
代码2.4使用分离线程处理文档

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);
    }
  }
}

  如果用户选择打开一个新文档, 需要启动一个新线程去打开新文档(1), 并分离线程(2)。 与当前线程做出的操作一样, 新线程只不过是打开另一个文件而已。 所以, edit_document函数可以复用, 并通过传参的形式打开新的文件。
  这个例子也展示了传参启动线程的方法: 不仅可以向std::thread构造函数(1)传递函数名, 还可以传递函数所需的参数(实参)。 当然, 也有其他方法可以完成这项功能, 比如: 使用带有数据的成员函数, 代替需要传参的普通函数。

2.2 传递参数

  如代码2.4所示, 向可调用对象或函数传递参数很简单, 只需要将这些参数作为std::thread构造函数的附加函数即可。这些参数会拷贝至新线程的内存空间中(同临时变量一样)。即使函数中的参数是引用的形式,拷贝操作也会执行。

void f(int i, std::string const& s);
std::thread t(f, 3, "hello");

  代码创建了一个调用(3, “hello”)的线程。 注意, 函数f需要一个std::string对象作为第二个参数, 但这里使用的是字符串的字面值, 也就是char const*类型, 线程的上下文完成字面值想std::string的转化。 需要特别注意, 指向动态变量的指针作为参数的情况, 代码如下:

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();
}

buffer(1)是一个指针变量, 指向局部变量, 然后此局部变量通过buffer传递到新线程中(2)。此时, 函数oops可能会在buffer转换成std::string之前结束, 从而导致未定义的行为。 因为, 无法保证隐式转换的操作和std::thread构造函数的拷贝操作的顺序, 有可能std::thread的构造函数拷贝的是转换前的变量(buffer指针)。解决方案就是在传递到std::thread构造函数之前, 就将字面值转化为std::string:

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();
}

  相反的情形(期望传递一个非常量引用, 但复制了整个对象)倒是不会出现, 因为会出现编译错误。 比如, 尝试使用线程更新引用传递的数据结构:

void update_data_for_widget(widget_id w, widget_data& data); // (1)
void oops_again(widget_id w) 
{
	widget_data data;
	std::thread t(updata_data_for_widget, w, data); // (2)
	display_status();
	t.join();
	process_widget_data(data);
}

  虽然updata_data_for_widget(1)的第二个参数期待传入一个引用, 但std::thread的构造函数(2)并不知晓, 构造函数无视函数参数类型, 盲目地拷贝已提供的变量。 不过, 内部代码会将拷贝的参数以右值的方式进行传递, 这是为了哪些支持移动的类型, 而后会尝试以右值为实参调用updata_data_for_widget。 但因为函数期望的是一个非常量引用作为参数(而非右值), 所以会在编译时出错。 对于熟悉std::bing的开发者来说, 问题的解决方法很简单:可以使用std::ref将参数转换成引用的形式。 因此可将线程的调用改为以下形式:

std::thread t(update_data_for_widget, w, std::ref(data));

这样update_data_for_widget就会收到data的引用, 而非data的拷贝副本, 这样代码就能顺利的通过编译了。
  如果熟悉std::bind, 就应该不会对以上传参的语法感到陌生, 因为std::thread构造函数和std::bind的操作在标准库中以相同的机制进行定义。 比如, 你也可以传递一个成员函数指针作为线程函数, 并提供一个合适的对象指针作为第一个参数:

class X
{
	public:
		void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_lengthy_work, &my_x); // 1

  另一种有趣的情形是, 提供的参数仅支持移动(move),不能拷贝。 "移动"是指原始对象中的数据所有权转移给另一个对象, 从而这些数据就不再原始对象中保存(类似于剪切)。 std::unique_ptr就是这样一种类型, 这种类型为动态分配的对象提供内存自动管理机制(类似垃圾回收机制)。 同一时间内, 只允许一个std::unique_ptr实例指向一个对象, 并且当这个实例销毁时, 指向的对象也将被删除。 移动构造函数(move constructor)和移动赋值操作符(move assignment operator)允许一个对象的所有权后, 就会留下一个空指针。 使用移动操作可以将对象转换成函数可接受的实参类型, 或满足函数返回值类型要求。 当原对象是临时变量时, 则自动进行移动操作, 但当原对象是一个命名变量, 移动的时候就需啊哟使用std::move()进行显示移动。下面的代码展示了std::move的用法, 展示了std::move 是如何移动态对象的所有权到线程中去的:

void process_big_object(std::unique_ptr<big_object>);
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t(jprocess_big_object, std::move(p));

   通过在std::thread构造函数中执行std::move§, big_object对象的所有权首先被转移到新创建线程的内部存储中, 之后再传递给process_big_object函数。
  C++标准线程库中和std::unique_ptr在所属权上相似的类有好几种, std::thread为其中之一。 虽然, std::thread不像std::unique_ptr能占有动态对象的所有权, 但是它能占用其他资源: 每个实例都负责管理一个线程。 线程的所有权可以在多个std::thread实例中转移, 这依赖于std::thread实例的可移动且不可复制性。 不可复制性表示某一时间点, 一个std::thread实例只能关联一个执行线程。 可移动性是的开发者可以自己决定, 哪个实例拥有线程实际执行的所有权。

  • 20
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值