一、节点应用
在前面的文章中已经分析过在TBB中图的作用。而图中最重的就是节点和边。一个代表着实际的功能处理;一个代表着数据的流动。其实就是生产者如何与消费者进行匹配的问题。可以简单的把节点当成生产者或消费者(或者是二者的统一体),而在节点中可以对各种状态、数据以及并发任务等进行设置和处理。节点是实际框架运行的核心,负责各种功能的组合、拆解或者限定等等。
二、边的应用
如何说节点是干活的,那么边就是做管理的。它负责数据处理的分配即多线程间的协调。从某种意义上来讲也就是节点间多线程的同步。边配合着并行任务可以确定并行的方式和数量,可以保持数据的同步,确保数据的结果的安全。下面看一个TBB官网的例子:
graph g;
function_node< int, int > n( g, unlimited, []( int v ) -> int {
cout << v;
spin_for( v );
cout << v;
return v;
} );
function_node< int, int > m( g, 1, []( int v ) -> int {
v *= v;
cout << v;
spin_for( v );
cout << v;
return v;
} );
make_edge( n, m );
n.try_put( 1 );
n.try_put( 2 );
n.try_put( 3 );
g.wait_for_all();//此处
一个非常简单的应用,注意最后的代码。
三、节点与任务
在上面的例程中看到了,并发任务的设置。但需要注意的是,每个并发任务并不代表着一个线程的实际应用。它只代表这些任务是可以并发的,所以才会出现上面的不准确的结果。举一个理想的例子,有三个线程和三个任务,此时的一个任务就可以对应着一个线程。但如果有十个任务,只有三个线程那么就只能等待三个线程切换时再执行其它的任务了。
当然任务和线程不是这么简单的对应关系,它更涉及到前后节点间的生产者和消费的关系以及数据流动过程中是否需要处理一致性的问题。如果后端的节点处理时间过长,前端的并行就需要控制。同样,为了保证任务的安全执行,需要使用图中的g.wait_for_all()这个函数来处理任务与线程的匹配,保证线程一直运行可处理的任务也就是人们常说的歇人不歇马。
另外一个还需要注意的是,在节点的任务中,数据吞吐正常情况下均为异步处理,这样就不会出现阻塞的情况,提高整体个框架的可用性。
graph g;
int src_count = 1;
int global_sum = 0;
int limit = 100000;
input_node< int > src( g, [&]( oneapi::tbb::flow_control& fc ) -> int {
if ( src_count <= limit ) {
return src_count++;
} else {
fc.stop();
return int();
}
} );
src.activate();
function_node< int, int > f( g, unlimited, [&]( int i ) -> int {
global_sum += i; // data race on global_sum
return i;
} );
make_edge( src, f );
g.wait_for_all();
cout << "global sum = " << global_sum
<< " and closed form = " << limit*(limit+1)/2 << "\n";
这个例程正常的情况下会产生数据竞争,导致结果的不准确。最简单的方法是将并行任务unlimited修改为1个,即可控制同步数据结果正确。
四、总结
基础是很重点的,这个勿需多言。学习TBB中基础的例子,就可以明白其运行的最基础的方式。重要的不是把例程跑通,而是理解其中的图的作用,进而明白节点和边在其中起到的作用。当明白图是如何在整个TBB中利用边动态组合节点时。基本上对这个框架的主要框架就知晓了。
家乡的老话,有了骨头还怕不长肉。