Kevin

游戏开发闲谈

导航

2-线程管理

2.1 线程管理的基础

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

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

启动了线程,你需要明确是要等待线程结束(加入式——参见2.1.2节),还是让其自主运行(分离式——参见2.1.3节)
如果 std::thread 对象销毁之前还没有做出决定,程序就会终止( std::thread 的析构函数会调用 std::terminate() )
Tips
对象销毁之前做出决定,否则你的程序将会终止(std::thread的析构函数会调用std::terminate(),这时再去决定会触发相应异常)
在线程还没结束,函数已经退出的时候,这时线程函数还持有函数局部变量的指针或引用。下面的清单中就展示了这样的一种情况。
清单2.1 函数已经结束,线程依旧访问局部变量

struct func
{
    int& i;
    func(int& i_) : i(i_) {}
    void operator() ()
    {
        for (unsigned j=0 ; j<1000000 ; ++j)
        {
            do_something(i); // 1. 潜在访问隐患:悬空引用
        }
    }
};
void oops()
{
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread my_thread(my_func);
    my_thread.detach(); // 2. 不等待线程结束
}                                        // 3. 新线程可能还在运行

2.1.2 等待线程完成

如果需要等待线程,相关的 std::thread 实例需要使用join()。清单2.1中,将 my_thread.detach() 替换为 my_thread.join() ,就可以确保局部变量在线程完成后,才被销毁
调用join()的行为,还清理了线程相关的存储部分,这样 std::thread 对象将不再与已经
完成的线程有任何关联。这意味着,只能对一个线程使用一次join();一旦已经使用过
join()std::thread 对象就不能再次加入了,当对其使用joinable()时,将返回false

2.1.3 特殊情况下的等待

清单 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
}

另外一种方式是使用“资源获取即初始化方式”(RAII,Resource Acquisition Is Initialization),并且提供一个类,在析构函数中使用join(),如同下面清单中的代码。看它如何简化f()函数。
清单 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

当线程执行到④处时,局部对象就要被逆序销毁了。因此,thread_guard对g是第一个被销毁的,这时线程在析构函数中被加入②到原始线程中。即使do_something_in_current_thread抛出一个异常,这个销毁依旧会发生。在thread_guard的析构函数的测试中,首先判断线程是否已加入①,如果没有会调用join()②进行加入。这很重要,因为join()只能对给定的对象调用一次,所以对给已加入的线程再次进行加入操作时,将会导致错误。

2.1.4 后台运行线程

使用detach()会让线程在后台运行,这就意味着主线程不能与之产生直接交互。也就是说,不会等待这个线程结束;如果线程分离,那么就不可能有 std::thread 对象能引用它,分离线程的确在后台运行,所以分离线程不能被加入

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

2.2 向线程函数传递参数

std::thread 构造函数中的可调用对象,或函数传递一个参数很简单。需要注意的是,默认参数要拷贝到线程独立内存中,即使参数是引用的形式,也可以在新线程中进行访问.

1.即使参数是引用形式,也可以在新线程中进行访问

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

注意,函数f需要一个 std::string 对象作为第二个参数,但这里使用的是字符串的字面值,也就是 char const * 类型。之后,在线程的上下文中完成字面值向 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();
}

2.期望传递一个引用,但整个对象被复制了

void update_data_for_widget(widget_id w,widget_data& data); // 1
void oops_again(widget_id w)
{
    widget_data data;
    std::thread t(update_data_for_widget,w,data); // 整个都被复制
    std::thread t(update_data_for_widget,w,std::ref(data)); // 用std::ref将参数转化成引用的形式
    display_status();
    t.join();
    process_widget_data(data); // 3
}

3.提供的参数可以移动,但不能拷贝

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(process_big_object,std::move(p));

std::thread 的构造函数中指定 std::move(p) ,big_object对象的所有权就被首先转移到新创建线程的的内部存储中,之后传递给process_big_object函数。

2.3 转移线程所有权

  1. 显式使用 std::move() ,原std::thread对象的所有权就转移给了新的对象。之后,元对象和执行线程已经没有关联了
  2. 只能转移到没有关联的线程
  3. std::thread 支持移动的好处是可以创建thread_guard类的实例(定义见 清单2.3),并且拥有其线程的所有权

清单2.6 scoped_thread的用法

class scoped_thread
{
 std::thread t;
public:
 explicit scoped_thread(std::thread t_): // 1
  t(std::move(t_))
 {
  if(!t.joinable()) // 2
   throw std::logic_error(“No thread”);
 }
 ~scoped_thread()
 {
  t.join(); // 3
 }
 scoped_thread(scoped_thread const&)=delete;
 scoped_thread& operator=(scoped_thread const&)=delete;
};

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

void f()
{
 int some_local_state;
 scoped_thread t(std::thread(func(some_local_state))); // 4
 do_something_in_current_thread();
} // 5

2.4 运行时决定线程数量

std::thread::hardware_concurrency() 在新版C++标准库中是一个很有用的函数。这个函数将返回能同时并发在一个程序中的线程数量。例如,多核系统中,返回值可以是CPU核芯的数量。返回值也仅仅是一个提示,当系统信息无法获取时,函数也会返回0(比如单核)
清单2.8 原生并行版的 std::accumulate

template<typename Iterator,typename T>
struct accumulate_block
{
 void operator()(Iterator first,Iterator last,T& result)
 {
  result=std::accumulate(first,last,result);
 }
};

template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
 unsigned long const length=std::distance(first,last);
 if(!length) // 1 如果输入的范围为空,就会得到init的值
  return init;

 unsigned long const min_per_thread=25;
 unsigned long const max_threads= (length+min_per_thread-1)/min_per_thread; // 2 用范围内元素的总数量除以线程(块)中最小任务数,从而确定启动线程的最大数量
 unsigned long const hardware_threads= std::thread::hardware_concurrency();
 unsigned long const num_threads= std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads); // 3 计算量的最大值和硬件支持线程数中,较小的值为启动线程的数量(当 std::thread::hardware_concurrency() 返回0,你可以选择一个合适的数作为你的选择;在本例中,我选择了"2"。你也不想在一台单核机器上启动太多的线程)
 unsigned long const block_size=length/num_threads; // 4 每个线程中处理的元素数量,是范围中元素的总量除以线程的个数得出的

 std::vector<T> results(num_threads);
 std::vector<std::thread> threads(num_threads-1); // 5 启动的线程数必须比num_threads少1个,因为在启动之前已经有了一个线程(主线程)

 Iterator block_start=first;
 for(unsigned long i=0; i < (num_threads-1); ++i)
 {
  Iterator block_end=block_start;
  std::advance(block_end,block_size); // 6 使用简单的循环来启动线程:block_end迭代器指向当前块的末尾

  threads[i]=std::thread( // 7 启动一个新线程为当前块累加结果
   accumulate_block<Iterator,T>(),
   block_start,block_end,std::ref(results[i]));

  block_start=block_end; // 8 当迭代器指向当前块的末尾时,启动下一个块
 }

 accumulate_block<Iterator,T>()(
  block_start,last,results[num_threads-1]); // 9 启动所有线程后,线程会处理最终块的结果
  std::for_each(threads.begin(),threads.end(),
   std::mem_fn(&std::thread::join)); // 10 当累加最终块的结果后,可以等待 std::for_each 创建线程的完成

 return std::accumulate(results.begin(),results.end(),init); // 11 使用 std::accumulate 将所有结果进行累加
}

2.5 识别线程

线程标识类型是 std::thread::id,可以通过两种方式进行检索

  1. 调用 std::thread 对象的成员函数 get_id() 来直接获取。如果 std::thread 对象没有与任何执行线程相关联, get_id() 将返回 std::thread::type 默认构造值,这个值表示“没有线程”
  2. 当前线程中调用 std::this_thread::get_id() (这个函数定义在 头文件中)也可以获得线程标识

2.6 本章总结

本章讨论了C++标准库中基本的线程管理方式:启动线程,等待结束和不等待结束(因为需要它们运行在后台)。并了解应该如何在线程启动前,向线程函数中传递参数,如何转移线程的所有权,如何使用线程组来分割任务。最后,讨论了使用线程标识来确定关联数据,以及特殊线程的特殊解决方案。虽然,现在已经可以纯粹的依赖线程,使用独立的数据,做独立的任务(如同清单2.8),但在某些情况下,线程确实需要有共享数据。第3章会讨论共享数据和线程的直接关系。第4章会讨论在(有/没有)共享数据情况下的线程同步操作。

posted on 2018-06-07 16:01  iamkevin  阅读(244)  评论(0编辑  收藏  举报