TaskCapableComponent vs Parallel.For 该怎么选?
在Rhino6更新之后,Grasshopper加入了一个新的API —— TaskCapableComponent
,使得电池对多线程有了更深度的支持。官方也早在2018年就做了文档和简单的例子,让大家能够更好的针对使用这个组件进行开发(官方例子链接)。
以下Grasshopper简称GH
不过官方的例子有点奇怪,为什么我算一个斐波那契数列还要多线程?这玩意儿多线程有啥意义?再进一步说,由于GH的整个画布的单线程架构,我在这个电池内部做计算密集型为什么不直接一个Parallel.For解决还需要实现这么复杂的接口?TaskCapableComponent与Parallel.For到底有什么区别?下面就一一解答。
关于为什么官例要用斐波那契数列,为什么这玩意儿要多线程,有什么意义,这个点放在最后说,因为当把TaskCapableComponent和Parallel.For它俩的区别说完之后,其原因就不言自明了。
注意: 以下代码均需要使用System.Threading.Tasks
命名空间,即
using System.Threading.Tasks;
本文所有代码详见 https://codechina.csdn.net/bwkair/taskcapablevsparallel
Task Capable Component 要怎么实现
首先来看如何实现一个TaskCapableComponent:(笔者这里就不用官例的斐波那契数列了,采用一个很常规的Thread.Sleep()
来实现)
-
首先第一步创建一个Grasshopper电池项目
-
将新建立的GH电池类的继承由
public class TaskCapableComponentExampleComponent : GH_Component
改为
public class TaskCapableComponentExampleComponent : GH_TaskCapableComponent<string>
其中,
string
是每个线程任务执行完成后的返回值的类型,可以认为是等同于电池输出的数据类型。为什么会多出这个?因为GH多线程电池是基于C#中的Task类架构的,主要运算部分会被封装成一个Task类来进行分发。由于我们在GH内做的除开“在Rhino窗口进行图形预览绘制”以外的操作,都是有数据需要输出的,即我们希望使用多线程执行的任务是需要有数据返回的,而这个有返回值的Task类“就是需要用
Task<T>
”泛型类来封装,此时T
就是返回的值的类型。总之,需要在GH电池的输出端输出什么类型的数据,这个T就是对应的类型。
更多有关于Task类的知识参照微软官方链接,此处不进行展开。
-
现在我们已经完成了一个支持多线程的电池架构。
?
“就这?”
需要做的改动当然不止于此了。目前仅仅是完成了“架构”部分的改动,电池是支持多线程了,但是我们的代码并没有真正地“实现多线程”。
实现多线程需要对电池的主运算部分SolveInstance()
进行相应的重构。下面是开发文档的说明:
We will want to break the current flow of a component’s SolveInstance method into three distinct steps:
- Collect input data
- Compute results on given data
- Set output data
—— Steve Baer and Scott Davidson @Rhino Dev. Docs
译:
(要实现TaskCapableComponent的设计,)我们希望把原来电池的SolveInstance内部的程序流重组成为具有3个十分明显特征的步骤:
- 从输入端获取数据
- 由获取的数据运行所需要的计算
- 将计算结果输出
要我说,这其实是废话,因为本质上非多线程电池的对数据处理的逻辑简直是一模一样。我们先把非多线程的电池代码放上
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
pManager.AddIntegerParameter("Seconds", "s", "", GH_ParamAccess.item);
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
{
pManager.AddTextParameter("Output", "o", "", GH_ParamAccess.item);
}
protected override void SolveInstance(IGH_DataAccess DA)
{
// Pre-work and collecting data
// 预处理 以及 从输入端获取数据
Random rd = new Random(2020);
Stopwatch sw = new Stopwatch();
sw.Start();
int sleepTime = 0;
if (!DA.GetData(0, ref sleepTime)) return;
// Compute data on given data
// 运行所需要的计算
Thread.Sleep(sleepTime * 1000 + rd.Next(1000));
@out = $"Slept for {sw.ElapsedMilliseconds}ms";
// Set output data
// 将计算结果输出
DA.SetData(0, @out);
}
显然可见,即使是一个正常的非多线程的程序也需要有上文所提到的几个主要步骤,这个流程其实是贯穿于整个GH电池设计架构之中的。
如何对上面的流程实现多线程?需要用到TaskCapableComponent
里多出来的几个API:
InPreSolve
属性,类型为bool
,用来判断电池是否处于预启动阶段TaskList
属性,类型为List<Task<T>>
,用来存储我们创建的、需要执行和分发的线程任务GetSolveResults()
方法,返回值为bool
,用来获取各个任务的结果,其返回值用来判断任务是否成功执行
首先需要做的是,把运行所需要的计算封包至一个Task对象中
Task<string> task = Task.Run(() =>
{
Thread.Sleep(sleepTime * 1000 + rd.Next(1000));
return $"Slept for {sw.ElapsedMilliseconds}ms";
});
然后把整个预处理、数据获取、封包的Task
对象都装到预启动阶段处理(使用if判断),并将Task
对象添加到API的TaskList
属性中,注意最后的return
,这很重要。
protected override void SolveInstance(IGH_DataAccess DA)
{
if (InPreSolve) // determine if in pre-solve stage
{
// Preparation
Stopwatch sw = new Stopwatch();
sw.Start();
int sleepTime = 0;
if (!DA.GetData(0, ref sleepTime)) return;
// Box the main calculating codes into a Task<T> instance
Task<string> task = Task.Run(() =>
{
Thread.Sleep(sleepTime * 1000);
return $"Slept for {sw.ElapsedMilliseconds}ms";
});
// Add the task to TaskList,
// which is a property of TaskCapableComponent
TaskList.Add(task);
// **IMPORTANT**
return;
}
}
最后,在预启动结束后(if语句之外),使用GetSolveResults()
来获取各个线程的执行结果,并做电池端的输出处理。
protected override void SolveInstance(IGH_DataAccess DA)
{
if (InPreSolve) // determine if in pre-solve stage
{
/// omitted. 省略
}
// Collecting data from tasks
// 从各Task<T>收集返回值,类型为T
GetSolveResults(DA, out string @out);
// Distribute data to output param of component
// 把数据分发至电池的输出端
if (@out != null)
DA.SetData(0, @out);
}
这样,我们就完成了一个多线程电池的构建。让我们来试试看其效果吧:
好像没什么变化?除了电池的左上角出现了两个小点代表着它支持多线程之外,我们并没有观察到有何异样,Thread.Sleep(sleepTime * 1000)
的确按照我们所想的为这个电池的耗时打上了1秒的烙印,但当我们开始把输入端的数据开始增多,事情就会变得不一样了。
执行4个任务,仍然仅需1秒!继续增加输入端的数据量呢?
此时,耗时增加到2秒了。仔细观察右边的输出,发现在第12个输出值(序号为11)之后,计时器需要的运行时间明显滞后了,这是不是意味着笔者电脑就12线程?打开任务管理器,i7-8700K,6核12线程,果然如此。
Task Capable Component 运行逻辑
至此,多线程已经成功的实现了,梳理一下TaskCapableComponent的运行逻辑把:
- 由于在
RegisterInputParams()
中定义了这个电池端口的输入为单个对象(GH_ParamAccess.item
),那么在非多线程架构的普通GH电池父类GH_Component
中,对于每一个输入对象,都会执行一次SolveInstance()
。但在多线程架构的GH_TaskCapableComponent<T>
中,对于每一个输入对象,SolveInstance()
会执行两次。可以在SolveInstance()
方法体中插入下列代码来查看执行次数(结果打印在Rhino的命令栏中):RhinoApp.WriteLine($"RunCount: {this.RunCount}");
- 第一次
SolveInstance()
执行时,InPreSolve
会被置为true
,第二次执行时,InPreSolve
会被置为false
。 - 对于一系列对象的输入,
GH_TaskCapableComponent
会先对所有对象输入依次执行第一遍SolveInstance()
之后,再依次进行第二遍执行。 Task<T>
对象最好使用Task.Run()
来创建并启动,因为GH_TaskCapableComponent
本身并不会“启动”任务;当然,也可以用其他方法来启动任务,只不过Task.Run()
是一种十分轻便简单并且也能满足大部分需求的一个方法。GetSolveResults()
会阻塞UI线程。在第一次执行SolveInstance()
完成所有的任务创建之后,会立刻开始第二次执行SolveInstance()
,UI线程随即被阻塞,所以就感官上而言,电池运行过程中,GH画布和Rhino主窗体仍然是处在假死状态,直到所有的Task<T>
的结果都被获取。可以认为这个函数是一个WaitAll()
。
那么,相同的逻辑,使用Parallel.For
要怎么实现?
Parallel.For 的实现
新建Grasshopper电池项目,此时我们需要做出的最重要的改变就是在RegisterInputParams()
中把输入值的GH_ParamAccess
从item
改为list
。相应的,RegisterOutputParams()
中也要更改。
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
pManager.AddIntegerParameter("Seconds", "s", "", GH_ParamAccess.list);
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
{
pManager.AddTextParameter("Output", "o", "", GH_ParamAccess.list);
}
然后就是SolveInstance()
方法体内部的实现逻辑,Parallel.For
的优势就在于,其在代码的编写过程中,与单线程并没有任何区别,仅仅是将For
循环换成Parallel.For
而已:
protected override void SolveInstance(IGH_DataAccess DA)
{
// Acquiring data & preparation
// 获取数据,前处理
List<int> sleepTimes = new List<int>();
if (!DA.GetDataList(0, sleepTimes)) return;
Stopwatch sw = new Stopwatch();
string[] resultContainer = new string[sleepTimes.Count];
// Run in parallel
// 调用Parallel.For
Parallel.For(0, sleepTimes.Count,
(index) =>
{
// main task
// 主循环(模拟计算密集型任务)
Thread.Sleep(sleepTimes[index] * 1000);
// position the result in a thread-safe way
// 用线程安全的方法将运行结果装载到一个列表中,以方便主线程调用
resultContainer[index] = $"Slept for {sw.ElapsedMilliseconds}ms";
});
// Distribute the result
// 把数据分发至电池的输出端
DA.SetDataList(0, resultContainer);
}
让我们看看结果对比
几乎没有什么区别。相对而言,使用Parallel.For编程的开发比较简单和直观,不需要将任务进行封包,仅需一个lambda函数即可完成,结果的获取也比较直观。使用TaskCapableComponent的话,需要额外使用GetSolveResults()
对数据仅需拆包,稍显繁琐。
但是,如果把电池的输入端执行一个Graft操作,我们将看到下列的一幕
使用TaskCapableComponent构建的电池仍然仅需2秒的执行时间,但使用Parallel.For构建的电池却如同变成单线程运行一般,耗时16秒。
???????
Task Capable Component vs Parallel.For
之所以会出现上图2秒对16秒的原因就出在GH_ParamAccess
的定义上。电池输入端的数据结构和GH_ParamAccess
的定义共同决定着SolveInstance()
会被执行多少次。
-
对于
TaskCapableComponent
而言,由于其GH_ParamAccess
定义为item
,故这个电池的SolveInstance()
的执行次数与输入端一共有多少个对象成正比。 -
对于使用
Parallel.For
的普通电池,如果GH_ParamAccess
定义为list
,那么如果输入端只是一个List
,则SolveInstance()
仅仅会执行一次。但如果输入端是一个Tree
,那么SolveInstance()
会对Tree
的每一个Branch作为一个List
执行一次。
在第二个例子里,直接对输入进行Graft操作的结果是造成了输入端成为一个拥有16个Branch的Tree
结构,导致Parallel.For
电池的SolveInstance()
方法被执行了16次,每一次的输入是一个长度为1的List<int>
,这样当然就没法发挥Parallel.For
的优势了。
要解决这个问题,就需要把Parallel.For
电池的输入端改为使用GH_ParamAccess.tree
,并对数据IO进行一系列的针对性修改。
不过,这样做的最大的问题就是,由于使用GH_ParamAccess.tree
会导致SolveInstance()
内部需要使用GetDataTree()
接口来获取输入端的数据,此时,数据类型必须实现IGH_Goo<T>
接口,很多自定义类型就没法使用了。即便是可以采用GH_ObjectWrapper
来对自定义数据进行封包/拆包,但其复杂程度已经远远超过直接使用TaskCapableComponent
架构。
总而言之,笔者认为如果确定仅仅是需要在List
层面进行多线程,使用Parallel.For
构造电池会方便许多,但如果希望应对更复杂的情况,采用TaskCapableComponent
架构来制作电池将会更有优势。