任务取代线程工具

摘要

任务是可以取代线程的小巧解决方案,借助它可提高启动和关机速度,实现更好的负载平衡,高效使用可用资源以及提高抽象化水平。英特尔® 线程构建模块(英特尔® TBB)和 OpenMP* 都是包括基于任务的编程模式。本文将简要介绍基于任务的编程,并会指导您应何时使用线程或任务。

本文是《英特尔® 多线程应用开发指南》系列的一部分,后者用于指导开发人员针对英特尔”® 平台开发高效的多线程应用。

背景

对于多线程编程来说,直接使用本地线程包编程并不理想。这些本地线程包所创建的线程均为逻辑线程,它们通过操作系统映射到硬件的物理线程上。创建太少逻辑线程将会导致系统认购不足,进而造成部分可用硬件资源的浪费。创建太多的逻辑线程将会导致系统认购过多,这样一来操作系统需承担大笔开销,因为需要分段访问硬件资源。通过直接使用本地线程,开发人员负责将应用中的并行处理与硬件中的资源进行匹配。

执行这个平衡任务很困难,最普通的方法是创建一个能在整个应用生命周期中使用的线程池。通常需要为每个物理线程创建一个逻辑线程。然后,应用会动态地向线程池中的线程调度计算。使用线程池不仅有助于并行处理与硬件资源的匹配,同时也可避免因重复创建和销毁线程而产生的花费。

一些并行编程模式,例如 Intel TBB 和 OpenMP API 为开发人员提供了线程池优势,而且他们无需管理线程池。借助这些模式,开发人员用任务来表示应用中的逻辑并行处理,并且运行库会将这些任务调度至工作线程的内部池中。借助任务,开发人员可专注于应用的逻辑并行处理,而无需担心并行器的管理。同时,鉴于任务较线程更加轻便,因此能以更加精细的粒度来表达并行。

下面是使用任务的一个示例。函数fibTBB calculates the nth使用一个 TBBtask_group来计算第 n 个斐波纳契数。每次调用至 n >= 10 时,将会创建一个任务组,并且有两个任务运行。本示例中,描述每个任务的一个 lambda 表达式(拟定 C++0x 标准的一个特性)被传递给函数运行。这些调用产生了任务,从而可被线程池中的线程所执行。随后的调度函数将等待拦截,直至任务组的所有任务都已运行完毕。

int fibTBB(int n) {
  if( n<10 ) {
    return fibSerial(n);
  } else {
    int x, y;
    tbb::task_group g;
    g.run([&]{x=Fib(n-1);}); // spawn a task
    g.run([&]{y=Fib(n-2);}); // spawn another task
    g.wait();                // wait for both tasks to complete
    return x+y;
  }
}

例程 fibSerial 被假定为一个序列变量。尽管任务比线程更能实现精细的并行,但与一个子程序调用相比,其开销仍非常大。因此,串行解决小型子问题更加划算。

另一个支持任务的运行库是 OpenMP API。与 Intel TBB 不同,这些模式均需要编译支持,其特点是接口较为简单,但不便于携带。例如,上述使用 TBB任务执行的斐波纳契示例,同样也可使用 OpenMP任务作为 fibOpenMP 来执行。因为 OpenMP 需要编译支持,只需要简单的编程就可以指示任务。然而,只有支持 OpenMP API 的编译器才会理解这些程序。

int fibOpenMP( int n ) {
  int i, j;
  if( n < 10 ) {
  return fibSerial(n);
  } else {
    // spawn a task
  #pragma omp task shared( i ), untied
    i = fib( n - 1 ); 
    // spawn another task
  #pragma omp task shared( j ), untied
    j = fib( n - 2 );
    // wait for both tasks to complete
  #pragma omp taskwait
  return i + j;
  }
}

Intel TBB 和 OpenMP API 通过工作窃取来管理任务调度。在工作窃取过程中,线程池中的每个线程维护一个双端列队本地任务池。一个线程像使用堆栈一样使用自身的任务池,并将所产生的新任务推堆栈顶部。当一个线程执行了一个任务, 它会首先从本地堆栈的顶部弹出一个任务。堆栈顶部的任务是最新的,因此最有可能访问到数据缓存中的热点数据。如果本地任务池中没有任务,它会试图从另一线程()那里窃取工作。当工作被窃取时,一个线程会将偷窃对象的双端队列作为普通队列来使用,因,所窃取的仅是偷窃对象双端队列中最旧的任务。对于递归算法,这些最旧的任务均为位于任务树高处的节点,因此属于大型工作块,并且通常不是偷窃对象数据缓存中的热点。因此,工作窃取是一个实现负载平衡并且维持本地化缓存的高效机制。

当任务库被启用时,开发人员将无法看到线程池和向线程分配任务的工作窃取调度器。因此,任务提供了高水平的抽象,用户在考虑其应用中的逻辑并行性时,无需担心并行处理器的管理。工作窃取提供的负载平衡、低任务创建与销毁成本使基于任务的平衡处理成为大部分应用的高效解决方案。

使用指南

尽管任务通常是添加线程并增强性能的最好方法,但如果使用不当,仍会造成一些问题。Intel TBB 和 OpenMP API 使用的任务调度器均为非抢占型。因此,任务主要面向无障碍的高性能运算。如果任务阻碍较少,那么也可以使用任务。然而,如果任务阻碍非常频繁,那么当一个任务受阻时,性能将会受到损失,而且已经分配任务的线程也无法执行其他任务。若等待 I/O 或互拆时间过长,通常会发生阻碍现象。如果线程持有互拆时间过长,那么无论有多少线程,代码也无法被充分执行。对于阻碍任务,最好使用线程而非任务。

即使任务为最佳方案时,也不一定要从头开始执行任务模式。Intel TBB 库不仅提供了任务接口,同时也提供了高层次运算来执行最普通的任务模式,例如 parallel_invoke、parallel_for、parallel_reduce 和管线等。OpenMP AP 提供并行循环。由于这些模式已被调校和测试,因此应该尽可能使用这些高层次运算。

下面的示例为一个使用 tbb::parallel_for 进行运算的简单串行循环及其并行版本。

// serial loop
for (int i = 0; i < 10000; ++i)
  a[i] = f(i) + g(i);

// parallel loop
tbb::parallel_for( 0, 10000, [&](int i) { a[i] = f(i) + g(i); } );

上述示例中,TBB parallel_for 创建的任务将循环体,即 a[i] = f(i) + g(i),应用到了 [0,10000) 范围内的所有元素。Lambda 表达式中的 & 表示变量 a 应被参考捕获。当使用 parallel_for 时,TBB 运行库在一个任务中选择了适当数量的迭代,从而最大限度地减少开销,并为负载平衡提供大量任务。

更多资源

分类:
如需更全面地了解编译器优化,请参阅优化注意事项