使用任务(而非线程)

使用任务(而非线程) (PDF 190KB)

摘要

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

本文是“英特尔多线程应用开发指南”系列的一部分,该系列介绍了针对英特尔® 平台开发高效多线程应用的指导原则。

背景

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

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

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

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

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。 与英特尔 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;

	}

	}

英特尔 TBB 和 OpenMP API 通过工作窃取来管理任务调度。 在工作窃取过程中,线程池中的每个线程维护一个本地任务池,这些任务池以 deque(双端列队) 的形式组成。 一个线程使用其自己的任务池,如同一个堆栈,同时推动这个堆栈顶部所产生的新的任务。 当线程执行完一个任务后,会努力从本地堆栈的顶部弹出一个任务。 堆栈顶部的任务是最新产生的,因此最有可能访问到数据缓存中的热点数据。 如果本地任务池中没有任务,它会试图从另一线程(受害者)那里窃取工作。 当工作被窃取时,一个线程将会启用受害者的双端队列,例如一个队列,因此,所窃取的仅是受害者的双端队列当中最旧的任务。 对于递归算法,这些最旧的任务均为节点,通常在任务树的高处,因此为大型的工作块,普通工作在受害者的数据缓存中并不是热点。 因此,工作窃取为针对负载平衡的一个高效的机制,同时可维持本地化缓存。

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

使用指南

尽管借助任务通常是针对性能而添加线程处理的最好方法,但如果使用不当,仍会造成一些问题。 英特尔 TBB 和 OpenMP API 使用的任务调度程序均为非抢占型。 因此,任务主要面向无障碍的高性能运算。 如果不太拦截,任务仍然正常运行。 然而,如果任务拦截频繁,那么当一个任务受阻时,性能将会受到损失,而且已经分配任务的线程也无法执行其他任务。 拦截现象通常在长时间等待 I/O 或互斥时发生。 如果线程持有互斥时间过长,那么无论有多少线程,代码也无法被充分执行。 对拦截任务来说,最好使用线程而非任务。

即使最佳的方案使用任务,有时也不一定要从头开始执行任务模式。 英特尔 TBB 库不仅提供了一个任务接口,同时也提供了高级别运算,从而可执行最普通的任务模式,例如 parallel_invokeparallel_forparallel_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 表达式中的 & 表示一个变量应被参考捕获。 当使用 parallel_for 时,TBB 运行库选择了一个适当的迭代次数组成一个任务,进而最大限度地减少开销,同时为负载平衡提供大量任务。

更多资源

有关编译器优化的更完整信息,请参阅优化通知