[翻译].NET framework 4.0并行编程:入门

原文来自Alexandra Rusina在CSharpFAQParallel Programming in .NET Framework 4: Getting Started

从这篇文章开始,我准备启动一个系列来讲述.NET Framework 4中的并行编程并介绍任务并行库(TPL)

我必须承认在多线程或并行计算方面我并非专家。然而,人们总是询问我关于新特性的简单介绍和初学者的简单例子。而在这个领域相对于初学者来讲我有个巨大的优势----我可以询问开发这些类库的人我哪里做错了和我下一步该做什么。

更新:好吧,如果你想询问谁关于你的并行编程下一步该做什么,我推荐你去这个论坛:Parallel Extensions to the .NET Framework Forum


此刻我有个简单目标。我想并行运行一个运行较久的控制台程序然后增加一个有响应的WPF界面。顺便说下,我并没有太专心的去测量性能。我试图表现一些常见的警告,但在大多数情况只是看到应用程序对我来讲运行得很快。

现在,开始这段旅程。这是我希望并行的小程序。SumRootN方法返回所有从1到1千万的整数的n次方根的总和,n是参数。在Main方法中,我为root参数传入从2到19来调用这个方法。我使用Stopwatch类来检查程序运行花费了多少毫秒。

using System.Threading.Tasks;
using System.Threading;
using System.Diagnostics;
using System;

class Program
{

static void Main(string[] args)
{
var watch
= Stopwatch.StartNew();
for (int i = 2; i < 20; i++)
{
var result
= SumRootN(i);
Console.WriteLine(
"root {0} : {1} ", i, result);
}
Console.WriteLine(watch.ElapsedMilliseconds);
Console.ReadLine();
}

public static double SumRootN(int root)
{
double result = 0;
for (int i = 1; i < 10000000; i++)
{
result
+= Math.Exp(Math.Log(i) / root);
}
return result;
}
}

在我的4GB内存3GHz64位双核计算机上,运行这段程序花费大概18秒。(译注:在我的单核老机器上花费大概43秒!)

既然我使用了for循环,那么要添加并行用Parallel.For方法是最容易的方式。我要做的所有事情便是将

for (int i = 2; i < 20; i++)
{
var result
= SumRootN(i);
Console.WriteLine(
"root {0} : {1} ", i, result);
}

 
替换为下面的并行代码:

Parallel.For(2, 20, (i) =>
{
var result
= SumRootN(i);
Console.WriteLine(
"root {0} : {1} ", i, result);
});

 
注意,只是一点点的代码改变了。我提供开始和结束的索引(跟我在简单循环里的一样)和一个lambda表达式形式的委托。我不需要做任何其他改变,而现在我的小程序花费大概9秒。(译注:现在我的单核老机器花费大概36秒。也比之前快了点,我猜是因为多个线程导致分配到CPU的几率变大的原因)

当你使用Parallel.For方法时,.NET Framework自动管理为循环服务的线程,所以你不需要自己做这些。但是记住,在两个处理器上运行并行代码并不保证代码运行一定会有两倍的速度。没有什么是凭空产生的;虽然你不需要自己管理线程,.NET Framework仍然在背后使用他们。那么当然,这会导致开销。事实上,如果你的操作是很简单很快的,你运行大量短小的并行循环,那么也许并行化上的收益将远低于你的期望值。

你可能注意到另外一件事情,当你运行现在的代码时,你看不到正确顺序的结果:不同于看到递增的root,你看到完全不同的画面。但我们假装我们只需要结果,不需要任何特定顺序。在这篇文章里,我将不会解决这个问题。

现在是时候做更进一步的事情。我不想写一个控制台程序;我想要些界面。所以我转换到Windows Presentation Foundation(WPF)。我已经创建了一个小窗口,上面只有一个启动按钮,一个显示结果的文本块,和一个显示过去了多少时间的标签。

顺序执行的事件处理代码看上去非常简单:

private void start_Click(object sender, RoutedEventArgs e)
{
textBlock1.Text
= "";
label1.Content
= "Milliseconds: ";

var watch
= Stopwatch.StartNew();
for (int i = 2; i < 20; i++)
{
var result
= SumRootN(i);
textBlock1.Text
+= "root " + i.ToString() + " " +
result.ToString()
+ Environment.NewLine;

}
var time
= watch.ElapsedMilliseconds;
label1.Content
+= time.ToString();
}


编译并运行这个程序以确定一切都正常工作。正如你可能已经注意到的,界面被冻结而文本块在所有计算完成前并没有更新。这是一个说明为什么WPF推荐永远不要在界面线程执行长时间运行操作的好例子。

让我们把for循环改为并行计算:
 
Parallel.For(2, 20, (i) =>
{
var result
= SumRootN(i);
textBlock1.Text
+= "root " + i.ToString() + " " +
result.ToString()
+ Environment.NewLine;

});

 
点击按钮...然而...得到一个InvalidOperationException异常“调用线程不能访问这个对象因为另一个不同的线程拥有它”。

发生了什么事?好吧,就像我之前提到的,任务并行库仍然使用线程。当你调用Parallel.For方法,.NET Framework自动启动新线程。我在控制台程序没有遇到问题是因为Console类是线程安全的。但是在WPF,界面组件只能被专门的界面线程安全的访问。既然Parallel.For使用与界面线程无关的工作线程,那么在循环体直接操作文本块是不安全的。如果说,你使用Windows Forms,你会得到另一个差不多的问题(另一个异常或程序崩溃)。

幸好,WPF提供一个API来解决这个问题。大多数控件提供一个特别的Dispatcher对象,用来允许其他线程通过发送异步消息作用于界面线程。(译注:Windows Forms上控件也有类似的实现,如BeginInvoke方法) 于是我们的并行循环事实上应该是这样:

Parallel.For(2, 20, (i) =>
{
var result
= SumRootN(i);
this.Dispatcher.BeginInvoke(new Action(() =>
textBlock1.Text
+= "root " + i.ToString() + " " +
result.ToString()
+ Environment.NewLine)
,
null);
});

 

在上面的代码中,我们使用Dispatcher来给界面线程发送委托。委托会在界面线程空闲时执行。如果界面忙于做什么其他的事情,委托会被放入一个队列。但是记住,与界面线程的这种交互可能会使你的程序变慢些。

现在我在我计算机上运行我们的并行WPF程序差不多快了两倍。然而这个被冻结的用户界面呢?让这么时尚的程序界面没有响应?然而如果Parallel.For启动新线程,为什么界面线程仍然被阻塞?

原因是Parallel.For试图精确的模拟传统的for循环的行为,所以它阻塞代码的进一步执行直到它完成所有工作。

让我们在这里暂停一下。如果你已经有一个程序,它能正常工作且满足你所有的要求,而你想简单的利用并行处理提升速度,将循环替换成Parallel.ForParallel.ForEach可能足够了。但在很多情况下你需要更高级的工具。

为了让界面有响应,我将使用任务,它是任务并行库带来的新概念。一个任务便是一个通常运行在工作线程的异步操作。.NET Framework优化负载均衡,并提供一个管理任务且使他们之间异步调用的不错的API。为了启动异步操作,我将使用Task.Factory.StartNew方法。

于是我将删除Parallel.For并将之替换为下面的代码,再一次尝试尽量少的改变。

for (int i = 2; i < 20; i++)
{
var t
= Task.Factory.StartNew(() =>
{
var result
= SumRootN(i);
this.Dispatcher.BeginInvoke(new Action(() =>
textBlock1.Text
+= "root " + i.ToString() + " " +
result.ToString()
+ Environment.NewLine)
,
null);

});
}

 

编译,运行....很好,界面有响应了。在程序还在计算结果的时候我可以移动窗口和调整窗口大小。但现在又有两个问题:

1.我的程序告诉我,它执行花了0秒。

2.程序仅仅计算20次方根,并显示给我一样的列表结果。


让我们做最后一步。C#专家可以大声喊出来:闭包(closure)!是的,i是在循环里使用,那么当工作线程启动时,i的值已经改变了。当i等于20的时候循环退出,这便是传递给新创建的任务的值。

当你用lambda表达式的形式(译注:事实上匿名委托跟lambda表达式几乎等价,所以这里也应包括匿名委托)分配一些委托时(异步编程通常不可避免),闭包的这个问题是很常见的,所以要留意。解决方案相当简单。只需将循环的变量拷贝到循环内声明的一个变量。然后使用这个本地变量代替循环变量。

for (int i = 2; i < 20; i++)
{
int j = i;
var t
= Task.Factory.StartNew(() =>
{
var result
= SumRootN(j);
this.Dispatcher.BeginInvoke(new Action(() =>
textBlock1.Text
+= "root " + j.ToString() + " " +
result.ToString()
+ Environment.NewLine)
,
null);
});
}

 

让我们来看第二个问题(译注:事实上是第一个):执行时间无法确定。我执行异步的任务,所以没有阻塞任何代码执行。程序启动任务后执行到下一行,这时读取时间并显示它。由于它并没有长时间占用,所以我的计时器得到0。

有时,对于不需要等待线程完成他们的工作便继续是可以的。但还有些时候,你需要在工作完成的时候得到一个信号,因为它影响着你的工作流程。在第二种情景,计时器是个好例子。

为了得到我的时间测量,我需要包装用来读取计时器值的代码到另一个来自任务并行库的方法:TaskFactory.ContinueWhenAll。这正是我需要的:它等待数组中的所有线程完成后执行委托。这个方法只在数组上工作。所以我需要将所有任务存储在某个地方以便可以等待他们完成。


这是我最终的代码,如下所示:

public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
public static double SumRootN(int root)
{
double result = 0;
for (int i = 1; i < 10000000; i++)
{
result
+= Math.Exp(Math.Log(i) / root);
}
return result;
}

private void start_Click(object sender, RoutedEventArgs e)
{
textBlock1.Text
= "";
label1.Content
= "Milliseconds: ";

var watch
= Stopwatch.StartNew();
List
<Task> tasks = new List<Task>();
for (int i = 2; i < 20; i++)
{
int j = i;
var t
= Task.Factory.StartNew(() =>
{
var result
= SumRootN(j);
this.Dispatcher.BeginInvoke(new Action(() =>
textBlock1.Text
+= "root " + j.ToString() + " " +
result.ToString()
+
Environment.NewLine)
,
null);
});
tasks.Add(t);
}

Task.Factory.ContinueWhenAll(tasks.ToArray(),
result
=>
{
var time
= watch.ElapsedMilliseconds;
this.Dispatcher.BeginInvoke(new Action(() =>
label1.Content
+= time.ToString()));
});

}
}


最终,所有事情都如预期的那样工作:我有一个结果列表,界面没有冻结,并且花费的时间正确显示。此代码看上去跟最初的代码完全不同,但令人惊讶的是它并没有那么长,而我可以重用它的大部分(感谢lambda表达式语法)。

同样,我试图覆盖大部分初学者在并行编程和多线程下在没有进入更深的细节时容易遇到的常见问题。但我期望这只是系列文章中的第一篇。但如果你不想等待我的下一篇文章,可以看看MSDN的.NET Framework中的并行编程the parallel framework team blog

当然,就如我在这个blog发布的其他文章一样,我希望它对你是非常有用的。但这篇文章跟之前我写的纯粹C#的文章有点不太一样。所以,这里有个问题:这种初学者的内容对你有多大的用处?你是否想看到更多这样的?如果你现在正在考虑使用并行和异步编程,在你想到的是什么情景?我真的非常需要你们的反馈,我期待你们的评论。(译注:若想给作者反馈请去原文)

P.S.


感谢所有此时帮助我的人:Dmitry Lomov和Michael Blome帮助我了解TPL,Danny Shih和Mads Torgersen检阅这篇文章并提供些意见,Mick Alberts和Robin Reynolds-Haertle进行编辑。

posted @ 2010-06-14 14:08  甜番薯  阅读(3780)  评论(5编辑  收藏  举报