并行计算一定比串行快?
现在我们都知道了并行计算其实也是基于线程的,也知道线程的创建和销毁都需要时间和空间的开销,那么你还会想并行它就一定比串行代码更快?如果循环的循环体很小,那么并行的速度也许比串行更慢,下面这个例子,我们测试一下在并行和串行的情况下的时间消耗:
var sw = Stopwatch.StartNew();
for (int i = 0; i < 2000; i++)
{
for (int j = 0; j < 10; j++)
{
var sum = i + j;
}
}
Console.WriteLine("串行循环耗时:" + sw.ElapsedMilliseconds);
sw.Restart();
Parallel.For(0, 2000, i =>
{
for (int j = 0; j < 10; j++)
{
var sum = i + j;
}
});
Console.WriteLine("并行循环耗时:" + sw.ElapsedMilliseconds);
输出结果如下:
我们发现,当循环体很小的时候,串行循环几乎不耗时,而并行循环耗时123毫秒,并行循环所消耗的性能是串行循环的好几倍。如果我们把循环体里面的10改到更大的时候:
var sw = Stopwatch.StartNew();
for (int i = 0; i < 2000; i++)
{
for (int j = 0; j < 100000; j++)
{
var sum = i + j;
}
}
Console.WriteLine("串行循环耗时:" + sw.ElapsedMilliseconds);
sw.Restart();
Parallel.For(0, 2000, i =>
{
for (int j = 0; j < 100000; j++)
{
var sum = i + j;
}
});
Console.WriteLine("并行循环耗时:" + sw.ElapsedMilliseconds);
输出结果显然并行循环优于串行循环了:
显然和我们预想的100000还有一定的差距,为了保证输出的正确性,我们就必须在并行里面加锁了(假设MyClass的AddCount是第三方提供的API,不允许修改源码):
var mc = new MyClass();
Parallel.For(0, 100000, i =>
{
lock (mc)
{
mc.AddCount();
}
});
Console.WriteLine(mc.Count);
这样就能得到我们预想的结果:
但是,带来了另外的问题,由于同步锁的存在,系统的开销也增加了,线程在上下文的切换也增加了,牺牲了更多的CPU时间和内存,也就是说,这段代码因为同步锁,其实已经是串行代码了,并且还不如串行代码的性能,所以,如果我们要考虑数据的一致性,选择并行还需谨慎,如果循环体里面的全部代码都需要加锁,那么完全不能考虑使用并行。
又一个好东西:并行linq(plinq)
这可是所有编程语言中C#的一大神器,linq可谓是无所不能,对我们的编程体验确实很爽。Linq最基本的功能就是对集合的各种复杂操作,其实仔细想想你会发现,并行编程简直就是专门为这一类应用而准备的。所以,微软不仅有linq,还有plinq,也就是微软专门为linq扩展了一个叫ParallelEnumerable的类,该类也在System.Linq命名空间下,所提供的功能就是让linq支持并行计算,这就是plinq。
我们传统的linq是单线程的,而plinq是并发的、多线程的,通过下面的例子我们可以看出区别:
var list = new List<int>() { 1, 2, 3, 4, 5, 6 };
var query = from i in list select i;
foreach(var i in query)
{
Console.WriteLine(i);
}
Console.WriteLine("----------");
var query2 = from i in list.AsParallel() select i;
foreach(var i in query2)
{
Console.WriteLine(i);
}
输出结果如下:
可以看出我们传统的linq是顺序输出的,而plinq是无序的。
其实并行输出还有另一种方式可以,那就是ForAll:
query2.ForAll(Console.WriteLine);
但是如果以这种方式进行循环输出的话,ForAll会忽略掉查询的AsOrdered请求:
var query2 = from i in list.AsParallel().AsOrdered() select i;
query2.ForAll(Console.WriteLine);
AsOrdered可以对并行之后的结果进行重新排序,以保证数据的顺序,而在ForAll方法中,仍然是无序的,如果要保证排序,我们还是只有老老实实的用普通的for循环了,即:
var query2 = from i in list.AsParallel().AsOrdered() select i;
foreach (var i in query2)
{
Console.WriteLine(i);
}
然而在并行之后再排序,会牺牲掉一定性能的,排序操作包括:OrderBy(Descding)、ThenBy(Descding),所以如果我们要考虑使用并行linq,我们必须考虑集合元素的顺序性是否是重要的,以便程序按照我们预想的结果运行。
还有一些其他的操作,比如Take,如果我们有类似于“随机推荐”的功能,我们可以这么干:
foreach(var i in list.AsParallel().Take(5))
{
Console.WriteLine(i);
}
如果是顺序的,那就会取出前5个元素,而这儿因为Parallel是无序的,所以取前5个相对于源数据也是随机的5个。
虽然使用plinq来迭代集合元素比linq更高效,但一定要注意,不是所有的并行都比串行更快,linq的某些方法串行比并行快,比如ElementAt等。所以实际开发中我们应该根据我们的使用场景去衡量和决定使用串行linq还是并行linq。找到最佳的解决方案。
并行编程的异常处理
关于异常处理,这是并行编程中最头疼的一个问题,异常的处理也是非常重要的一个环节,而且,由于并行的存在,使得我们在并行编程的时候,调试起来也是比较难的,程序出问题了找到问题所在也是非常苦恼的一件事情,所以,在并行编程的时候,我们就必须攻下它们。
从一道面试题说起
这是小编我们公司的一道面试题,也是各大公司喜欢考察的一道面试题,其实很简单,但可能很多人会迷糊,下面这个方法,调用会不会抛异常?
public static async void MyMethod()
{
await Task.CompletedTask;
throw new Exception();
}
很多人只有猜会或者不会,但说不出理由,答案肯定是不会抛异常,那为什么不会抛异常?
因为这是个异步方法,发生异常是在另一个线程里面发生的,并没有抛给调用者,因为它没有与调用线程进行交互,调用者只是调用了它,但有没有发生异常调用者不知道。
好了,其实上面这段代码就反映了在我们实际项目中,有些异步方法发生了异常,但并没有被调用者捕获,导致项目出现一些bug,这样的bug其实很难找出来,所以在异步线程中必须正确地去处理异常。
Task中的异常处理
如果我们的Task是可以进行交互的,比如可以调用Task的Wait、WaitAny、WaitAll等阻塞方法,或者拿Task的Result属性,发生异常是可以被调用者捕获的,能捕获到AggregateException异常,而AggregateException可以看作是并行编程中的最顶层的异常,所以当异步线程中发生异常时,我们都应该把异常包装到AggregateException中,一个异步线程异常的简单处理如下:
var t = Task.Run(() => throw new Exception("我在异步线程中抛出的异常"));
try
{
t.Wait();
}
catch (AggregateException e)
{
foreach (var ex in e.InnerExceptions)
{
Console.WriteLine($"异常类型:{ex.GetType()},异常源:{ex.Source},异常信息:{ex.Message}");
}
}
输出结果: