在C#开发中,处理大规模数据或计算密集型任务时,并行计算是提升性能的利器。微软为我们提供了几套强大的并行计算框架:
Task Parallel Library (TPL):这是.NET 4.0引入的核心并行编程模型,基于任务(Task)概念构建,提供了高层次的数据和任务并行支持。
Parallel类:TPL的一部分,包含Parallel.For、Parallel.ForEach等方法,简化了数据并行操作。
PLINQ:并行LINQ,通过AsParallel()扩展方法将LINQ查询转换为并行执行。
并发集合:System.Collections.Concurrent命名空间下的线程安全集合类,如ConcurrentBag、ConcurrentDictionary等。
这些框架让开发者能够相对容易地实现并行化,但正如我在实际项目中多次验证过的,如果使用不当,它们带来的问题可能比解决的还要多。我曾经在一个数据处理项目中,由于对TPL理解不深,导致程序性能反而比单线程版本下降了30%,经过一周的排查才找到问题根源。
数据竞争是并行计算中最常见也最危险的问题之一。当多个线程同时访问和修改共享状态时,如果没有适当的同步机制,就会导致不可预测的结果。这种情况在Parallel.ForEach等并行循环中尤为常见。
我见过一个典型案例:一个财务计算系统使用类似下面的代码汇总交易金额:
csharp复制decimal total = 0m;
Parallel.ForEach(transactions, t => {
total += t.Amount; // 危险的数据竞争
});
在测试环境中,这段代码偶尔会返回错误的总额,但问题很难复现,因为数据竞争导致的错误通常是随机的。
对于简单的数值操作,Interlocked类提供原子操作:
csharp复制int total = 0;
Parallel.ForEach(numbers, n => {
Interlocked.Add(ref total, n);
});
Interlocked的性能比锁要高,但只支持有限的原子操作(Add、Increment、Exchange等)。
更优雅的解决方案是使用线程局部存储(Thread-Local Storage):
csharp复制int total = 0;
Parallel.ForEach(numbers,
() => 0, // 初始化局部变量
(n, state, localSum) => localSum + n, // 累加
localSum => Interlocked.Add(ref total, localSum) // 合并
);
这种方式每个线程都有自己的累加器,最后再合并结果,大大减少了锁竞争。
对于复杂数据结构,可以使用并发集合:
csharp复制var results = new ConcurrentBag<int>();
Parallel.ForEach(numbers, n => {
results.Add(Process(n));
});
提示:虽然并发集合使用方便,但在高性能场景下,它们可能成为瓶颈。我曾在一个高吞吐系统中将ConcurrentDictionary替换为分区锁方案,性能提升了40%。
并行度不是越高越好。设置过高会导致:
经验法则是将MaxDegreeOfParallelism设置为处理器核心数的1-2倍:
csharp复制var options = new ParallelOptions {
MaxDegreeOfParallelism = Environment.ProcessorCount * 1.5
};
Parallel.ForEach(numbers, options, n => {
// 处理逻辑
});
对于不均匀负载的任务,可以使用自定义分区器:
csharp复制var partitioner = Partitioner.Create(numbers, true); // 启用动态分区
Parallel.ForEach(partitioner, n => {
// 处理逻辑
});
动态分区器会在运行时根据负载调整分区大小,特别适合处理时间不均衡的任务。
任务粒度过小会导致调度开销占比过高。我建议:
csharp复制var ranges = Partitioner.Create(0, numbers.Length, 1000); // 每1000个元素一个分区
Parallel.ForEach(ranges, range => {
for (int i = range.Item1; i < range.Item2; i++) {
Process(numbers[i]);
}
});
并行任务中的异常会被包装在AggregateException中。正确的处理方式:
csharp复制try {
Parallel.ForEach(numbers, (n, state) => {
try {
if (n < 0) throw new ArgumentException("负数");
Process(n);
} catch (Exception ex) {
LogError(ex);
state.Stop(); // 可选:停止整个循环
throw; // 重新抛出以捕获在AggregateException中
}
});
} catch (AggregateException ae) {
foreach (var e in ae.Flatten().InnerExceptions) {
HandleError(e);
}
}
长时间运行的并行任务应该支持取消:
csharp复制var cts = new CancellationTokenSource();
var options = new ParallelOptions {
CancellationToken = cts.Token,
MaxDegreeOfParallelism = 4
};
Task.Run(() => {
try {
Parallel.ForEach(numbers, options, (n, state) => {
options.CancellationToken.ThrowIfCancellationRequested();
LongRunningProcess(n);
});
} catch (OperationCanceledException) {
Console.WriteLine("任务已取消");
}
});
// 用户取消
cts.CancelAfter(TimeSpan.FromSeconds(30));
PLINQ默认不保留顺序,需要时使用AsOrdered():
csharp复制var result = numbers.AsParallel()
.AsOrdered()
.Select(n => n * n)
.ToList();
注意:AsOrdered()会增加约15%的开销,仅在必要时使用。
某些操作(如Where)默认可能不会并行化,可以使用WithExecutionMode:
csharp复制var result = numbers.AsParallel()
.WithExecutionMode(ParallelExecutionMode.ForceParallelism)
.Where(n => n % 2 == 0)
.ToList();
对于小数据集,可以优化合并行为:
csharp复制var result = numbers.AsParallel()
.WithMergeOptions(ParallelMergeOptions.NotBuffered) // 低延迟
.Select(n => ExpensiveOperation(n))
.ToList();
混合同步和异步代码是危险的:
csharp复制// 错误示例:可能导致死锁
Parallel.ForEach(numbers, async n => {
lock (syncObj) {
await Task.Delay(100);
Process(n);
}
});
使用SemaphoreSlim实现异步同步:
csharp复制private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
async Task ProcessAllAsync(IEnumerable<int> numbers) {
var tasks = numbers.Select(async n => {
await _semaphore.WaitAsync();
try {
await ProcessAsync(n);
} finally {
_semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
使用BenchmarkDotNet进行科学测量:
csharp复制[SimpleJob(RuntimeMoniker.Net60)]
public class ParallelBenchmark {
[Params(1000, 10000)]
public int Size;
private int[] numbers;
[GlobalSetup]
public void Setup() => numbers = Enumerable.Range(1, Size).ToArray();
[Benchmark]
public int SequentialSum() => numbers.Sum();
[Benchmark]
public int ParallelSum() => numbers.AsParallel().Sum();
}
Visual Studio的并发可视化工具可以直观显示:
经过多个并行计算项目的实践,我总结了以下黄金法则:
一个典型的性能优化流程应该是:
我在最近的一个图像处理项目中,通过合理设置并行度和任务分区,将处理时间从45秒降低到7秒,同时保证了系统的稳定性。关键是在每一步都进行严格的测试和验证。