在C#中,通过使用await和Task可以显著提升程序性能。然而,并非所有情况下都适用。本文将分析在foreach循环中使用await和Task.WhenAll的条件,以及如何避免并发和依赖问题。将比较两种不同的并行编程方法,并探讨在foreach循环中使用它们的潜在问题。
在foreach循环中使用await
首先,来看第一种方法,即在foreach指令中使用await(见图1)。在这种情况下,当达到await时,线程可以继续执行其他任务,而循环内的指令会逐个执行每个任务,直到foreach完成,然后继续执行程序的剩余部分。
这种方法有助于释放循环的控制权以执行其他任务,但方法本身以同步形式执行循环内的任务。这带来了以下好处:
但是,由于循环内的操作以同步形式执行,方法的性能并不理想。
使用Task.WhenAll创建真正的并行循环
第二种选项提供了更好的性能:可以创建一个任务列表,并在循环完成后使用Task.WhenAll。在这种情况下,循环内的任务并行执行,执行时间大大减少。
在图中用绿色箭头说明了如何在不等待结果的情况下启动循环内的所有任务,然后线程在await指令中释放控制权,并等待所有任务完成。然后执行程序的最后一个指令,并返回结果。这个选项,如所见,确实在性能上有了真正的提升,因为它在循环内创建了真正的并行执行任务,减少了执行时间,而不仅仅是所有任务执行时间的总和。但这种方法有两个更大的限制:
下载示例
操作顺序问题
一个典型的需要顺序的任务示例如下:
private async Task DependenTaskAsync(string status)
{
await Task.Delay(1000);
if (status == "low")
{
this.Status.Remove("low");
return;
}
if (status == "medium")
{
if (this.Status.Contains("low"))
{
throw new Exception("Low must not exists when you try to remove medium");
}
this.Status.Remove("medium");
return;
}
if (status == "high")
{
if (this.Status.Contains("low"))
{
throw new Exception("Low must not exists when you try to remove medium");
}
if (this.Status.Contains("medium"))
{
throw new Exception("Medium must not exists when you try to remove height");
}
this.Status.Remove("high");
}
}
在这个任务中,需要按照从低到高的顺序删除列表中的值。如果忽略了顺序,就会抛出异常。可以在附带的代码中运行这个代码。这是一个学校示例,但许多实际任务都有依赖关系,需要一定的操作顺序。
并发问题
如果操作需要隔离执行,那么可能会遇到一个难以理解的问题,但一个简单的例子可以帮助理解。例如,看下面的代码:
private async Task ConcurrencyAffectedTaskAsync(int val)
{
await Task.Delay(1000);
// Similar other processing
Total = Total + val;
}
如果在foreach循环中使用Task.Await运行这段简单的代码,那么就会失败,因为Total是类中的全局变量,无法控制在某个时刻Total的值是否被其他线程改变,结果可能是完全不可预测的。如果运行附带的例子,可以看到如果多次运行这段代码,会得到多少不同的结果。
在某些情况下,可以使用C#中的一个指令来强制程序只允许一个线程在任何时候运行一个确定的代码段。这可能非常有用,但也有局限性。
如果不知道并发问题在哪里,或者需要锁定的代码段消耗了大部分的执行时间,那么就无法获得太多的性能提升,而且还会创建更复杂的代码。在这种情况下,最好在循环中使用正常的await操作符。
并发示例是使用信号量的好候选,它是一个单指令,大部分延迟都在这个指令之外,所以没问题。然后,在这种情况下,可以创建一个信号量Slim,并限制代码段的执行只限于一个线程,从而在没有并发问题的情况下获得超高性能。
可以声明一个信号量:
public class ForEachConcurrentDependency
{
public int Total { get; set; }
public SemaphoreSlim semaphore;
public ForEachConcurrentDependency()
{
this.Total = 0;
semaphore = new SemaphoreSlim(1, 1);
}
// rest of the class...
}
并使用它,注意限制到一个线程的代码部分只是处理时间的一小部分。
private async Task ConcurrencyNoAffectedTaskAsync(int val)
{
await Task.Delay(1000);
// Similar other processing
await semaphore.WaitAsync();
try
{
Total = Total + val;
}
finally
{
semaphore.Release();
}
}
当要执行的任务有数据并发或有严格的执行顺序时,使用foreach中的AWAIT。
如果使用SemaphoreSlim的解决方案没有给带来显著的性能提升,因为锁定过程在时间上是广泛的,使用FOREACH中的AWAIT。
只有当任务是独立的,并且没有任何特定的执行顺序时,才在foreach之外使用TASK.WHENALL。这允许使用并行编程的全部力量,并获得更好的性能。