第五章:C#并行编程
第五章:C#并行编程基础
并行编程用来拆分CPU密集型任务,并将它们分发给多个线程,它利用多核处理器的能力,使程序中的任务能够并行执行,从而加快处理速度。本章实例仅考虑CPU密集型任务,I/O并行请参考第三章。
并行编程的核心思想是将工作分解成多个部分,并将这些部分分配给不同的处理单元(如 CPU 核心)同时执行。常见的并行编程模式包括数据并行、任务并行和流水线并行。
在 .NET 中,常用的并行编程工具有:
- Task Parallel Library (TPL):提供了任务并行化的接口,常用的类如
Task
和Parallel
。 - PLINQ (Parallel LINQ):允许并行化 LINQ 查询。
- 并行集合:如
ConcurrentDictionary
,提供了线程安全的数据结构,方便并行任务共享数据。
5.1 并行处理:使用 Parallel.ForEach
和 Parallel.For
问题
在进行 CPU 密集型任务时,单线程执行可能会导致程序运行缓慢。例如,在对大量数据进行复杂计算时,单线程处理无法充分利用多核处理器的性能。如何高效地并行处理这些计算任务呢?
解决方案
Parallel.ForEach
和 Parallel.For
是 .NET 中用于并行处理任务的工具,能够帮助开发者轻松利用多核处理器,提升计算效率。Parallel.ForEach
适合遍历集合,而 Parallel.For
适合处理索引范围的循环。
示例 1:并行计算大量数字的平方根
假设我们有一组浮点数,需要对每个数字计算平方根。使用 Parallel.ForEach
可以并行执行计算,加快处理速度。
void CalculateSquareRoots(IEnumerable<double> numbers)
{
Parallel.ForEach(numbers, number =>
{
var result = Math.Sqrt(number);
Console.WriteLine($"Sqrt({number}) = {result}");
});
}
解释:
Parallel.ForEach
并行遍历numbers
集合,对每个数字计算平方根。- 多个线程同时处理,能显著减少计算时间。
示例 2:提前终止并行计算
假设我们在处理一系列数学计算时,如果发现计算结果不符合预期,就希望立即停止进一步计算。这时可以使用 ParallelLoopState
的 Stop()
方法。
void FindFirstPrime(IEnumerable<int> numbers)
{
Parallel.ForEach(numbers, (number, state) =>
{
if (IsPrime(number))
{
Console.WriteLine($"First prime found: {number}");
state.Stop(); // 提前终止循环
}
});
}
bool IsPrime(int number)
{
if (number < 2) return false;
for (int i = 2; i <= Math.Sqrt(number); i++)
{
if (number % i == 0) return false;
}
return true;
}
解释:
- 一旦找到第一个素数,
state.Stop()
会停止分配新的任务。 - 已经开始的任务会继续执行,因此不能保证完全停止所有计算。
示例 3:并行计算时使用 CancellationToken
假设用户可以从外部取消计算任务,例如在 UI 应用中用户点击“取消”按钮。这时可以使用 CancellationToken
来实现取消功能。
void CalculateFactorials(IEnumerable<int> numbers, CancellationToken token)
{
Parallel.ForEach(numbers, new ParallelOptions { CancellationToken = token }, number =>
{
var result = Factorial(number);
Console.WriteLine($"Factorial({number}) = {result}");
});
}
long Factorial(int n)
{
long result = 1;
for (int i = 2; i <= n; i++)
{
result *= i;
}
return result;
}
解释:
- 当外部调用
token.Cancel()
时,未开始的任务会被取消。 - 已经开始的任务会检查
CancellationToken
,并在取消时抛出异常来取消任务。
示例 4:处理共享状态
在并行计算时,如果多个线程访问同一个共享变量,就会出现竞态条件。为了避免这种情况,需要使用锁进行同步。以下示例展示了如何并行计算数据并统计符合特定条件的元素个数。
int CountEvenNumbers(IEnumerable<int> numbers)
{
int evenCount = 0;
object lockObj = new object();
Parallel.ForEach(numbers, number =>
{
if (number % 2 == 0)
{
lock (lockObj)
{
evenCount++;
}
}
});
return evenCount;
}
解释:
evenCount
是共享状态,使用lock
确保线程安全。- 多个线程同时访问时,
lock
防止竞态条件导致计数错误。
Parallel.For
示例:并行处理数组
当处理的是数组或列表这种可以使用索引访问的数据结构时,Parallel.For
是更好的选择。例如,计算一组整数的平方值:
void SquareArrayElements(int[] numbers)
{
Parallel.For(0, numbers.Length, i =>
{
numbers[i] = numbers[i] * numbers[i];
});
}
解释:
Parallel.For
使用索引范围遍历数组,对每个元素并行计算平方值。- 适合处理大数组的 CPU 密集型任务。
小结
Parallel.ForEach
:用于并行处理集合中的每个元素,适合处理非索引结构的数据。Parallel.For
:用于并行处理索引循环,适合处理数组等索引结构的数据。Stop()
和CancellationToken
:用于在特定条件下提前终止循环或取消操作。- 共享状态的同步:当并行访问共享状态时,需使用同步机制(如
lock
)以避免竞态条件。
5.2 并行聚合:利用 Parallel.ForEach
和 PLINQ 实现高效聚合
问题
在处理大规模数据时,聚合操作(如求和、求平均值、最大值等)通常会成为性能瓶颈。单线程聚合计算无法利用多核处理器的优势,尤其在数据量庞大时,处理时间会显著增加。如何通过并行计算提升聚合操作的效率?
解决方案
.NET 中的 Parallel
类和 PLINQ 都支持并行聚合,能充分利用多核 CPU 加快计算速度。Parallel.ForEach
提供了局部变量 (localInit
和 localFinally
) 来支持并行聚合,而 PLINQ 则提供了更加简洁的 API,使代码更具可读性。
示例 1:使用 Parallel.ForEach
实现并行求和
在 Parallel.ForEach
中,使用 localInit
创建每个线程的局部变量,用于累加结果。最后通过 localFinally
聚合所有线程的局部结果。
int ParallelSum(IEnumerable<int> values)
{
object mutex = new object();
int result = 0;
Parallel.ForEach(
source: values,
localInit: () => 0,
body: (item, state, localSum) => localSum + item,
localFinally: localSum =>
{
lock (mutex)
{
result += localSum;
}
}
);
return result;
}
解释:
localInit
:为每个线程创建一个局部变量localSum
,初始值为0
。body
:并行遍历values
集合,计算局部求和。localFinally
:将每个线程的局部结果 (localSum
) 聚合到最终结果result
中,使用lock
确保线程安全。
注意:
- 这种方法虽然有效,但锁的使用可能会影响性能,尤其是在结果聚合阶段有大量线程竞争时。
示例 2:使用 PLINQ 简化并行求和
PLINQ 是 .NET 中用于并行 LINQ 查询的扩展,内置了对常见聚合操作的支持,代码更加简洁。
int ParallelSum(IEnumerable<int> values)
{
return values.AsParallel().Sum();
}
解释:
AsParallel()
方法将集合转换为并行查询。Sum()
方法在内部自动进行并行化,利用多核处理器提高计算效率。
优势:
- 代码简洁明了,不需要显式管理线程和同步。
- PLINQ 内置的并行化支持能自动调整并行度,适应系统的负载情况。
示例 3:使用 PLINQ 的 Aggregate
方法实现自定义聚合
如果需要执行更加复杂的聚合操作,可以使用 PLINQ 的 Aggregate
方法,该方法提供了灵活的聚合功能。
int ParallelSum(IEnumerable<int> values)
{
return values.AsParallel().Aggregate(
seed: 0,
func: (sum, item) => sum + item
);
}
解释:
seed
是初始值,即聚合操作的起点。func
是用于累加的聚合函数,将每个元素累加到sum
中。Aggregate
方法支持更多自定义操作,适合复杂的聚合计算场景。
小结
Parallel.ForEach
提供了基于局部变量的聚合支持,可以在并行循环内部高效计算局部结果,再进行最终聚合。- PLINQ 提供了内置的聚合操作(如
Sum
和Aggregate
),代码更简洁、可读性更高,且性能表现通常优于手动实现的并行聚合。 - 在大多数情况下,使用 PLINQ 会更简单和高效,除非需要特殊控制并行任务的执行方式或需要自定义复杂的聚合逻辑。
最佳实践
- 优先使用 PLINQ:如果聚合逻辑较为简单,推荐使用 PLINQ,代码更简洁,性能优化也较好。
- 避免锁竞争:在并行聚合时,尽量减少锁的使用,或者使用无锁并发结构(如
ThreadLocal<T>
或Interlocked
)。 - 注意线程安全:无论是使用
Parallel.ForEach
还是 PLINQ,都要确保聚合操作对共享状态的访问是线程安全的。
并行聚合能够显著提升性能,但需要正确使用线程同步机制,避免竞态条件,才能充分发挥并行计算的优势。
5.3 并行调用:使用 Parallel.Invoke
实现并行执行
问题
在程序中,有时需要并行执行一系列独立的方法,且这些方法之间没有相互依赖。如果串行执行这些方法,无法充分利用多核 CPU 的并行计算能力,导致性能浪费。如何高效地并行调用多个方法?
解决方案
Parallel
类提供了 Invoke
方法,专门用于并行执行多个彼此独立的方法。Parallel.Invoke
能够并行地调用多个委托,并等待所有方法执行完毕。
示例 1:并行处理数组的两个部分
假设我们有一个数组,需要将其拆分为两个部分,并对每一部分进行独立处理。可以使用 Parallel.Invoke
来并行执行这两个处理任务。
void ProcessArray(double[] array)
{
Parallel.Invoke(
() => ProcessPartialArray(array, 0, array.Length / 2),
() => ProcessPartialArray(array, array.Length / 2, array.Length)
);
}
void ProcessPartialArray(double[] array, int begin, int end)
{
// 进行 CPU 密集型处理……
}
解释:
Parallel.Invoke
接收一组委托(匿名方法或 lambda 表达式),并并行执行这些委托。ProcessPartialArray
方法对数组的一部分进行处理,两个任务独立执行,能充分利用多核 CPU。
示例 2:动态数量的并行调用
如果在运行时才确定需要调用多少次方法,可以将一组委托传递给 Parallel.Invoke
方法。例如,重复执行某个操作 20 次:
void DoAction20Times(Action action)
{
Action[] actions = Enumerable.Repeat(action, 20).ToArray();
Parallel.Invoke(actions);
}
解释:
- 使用
Enumerable.Repeat
创建一个包含 20 个相同委托的数组。 Parallel.Invoke
并行执行数组中的所有委托,适用于动态数量的并行任务。
示例 3:支持取消操作的并行调用
Parallel.Invoke
支持使用 CancellationToken
来取消并行调用。用户可以在运行时请求取消操作,从而停止尚未完成的任务。
void DoAction20Times(Action action, CancellationToken token)
{
Action[] actions = Enumerable.Repeat(action, 20).ToArray();
Parallel.Invoke(new ParallelOptions { CancellationToken = token }, actions);
}
解释:
ParallelOptions
中包含CancellationToken
,用于控制并行操作的取消。- 如果在调用期间触发取消请求,
Parallel.Invoke
会停止尚未开始的任务,已开始的任务会继续执行完毕。
小结
Parallel.Invoke
:适合并行执行一组彼此独立的方法,简化了并行调用的代码编写。- 动态任务支持:可以传递一组委托(数组或集合)给
Parallel.Invoke
,适应动态数量的并行任务。 - 取消支持:通过
CancellationToken
可以灵活控制并行操作的取消,适合用户交互场景。
适用场景与限制
-
适用场景:
- 需要并行执行一组独立任务。
- 任务数量较少且无需返回值时,
Parallel.Invoke
是简洁的选择。
-
不适用场景:
- 如果需要对集合中的每个元素执行操作,使用
Parallel.ForEach
更合适。 - 如果任务需要返回结果,并且需要对结果进行处理或聚合,使用 PLINQ 更为便捷。
- 如果需要对集合中的每个元素执行操作,使用
最佳实践
- 控制任务数量:
Parallel.Invoke
适合数量有限的任务,过多的任务会增加线程上下文切换的开销,影响性能。 - 避免阻塞操作:并行调用的方法应尽量避免使用阻塞操作(如 I/O 操作),以免降低并行效率。
- 利用取消机制:在需要用户动态控制的场景中,使用
CancellationToken
提供灵活的取消支持。
Parallel.Invoke
是一种简单且高效的并行调用工具,能帮助开发者快速实现并行执行。但在更复杂的并行场景下(如大规模数据处理),应根据具体需求选择合适的并行编程工具,如 Parallel.ForEach
或 PLINQ。
5.4 动态并行:使用 Task
实现复杂的动态并行结构
问题
在实际应用中,有时任务的数量和结构是未知的,直到运行时才确定。这种场景通常非常复杂,无法使用 Parallel.ForEach
或 PLINQ 等并行工具进行简单处理。例如,我们可能需要遍历一个结构复杂的二叉树,针对每个节点执行某些计算,而树的结构在运行前无法确定。如何在这种动态并行场景下高效地处理任务?
解决方案
Task
是 .NET 中并行编程的核心类型。Parallel
类和 PLINQ 都是对 Task
的高级封装。如果需要处理动态并行结构,直接使用 Task
是最灵活的选择。
示例 1:动态遍历二叉树
假设我们需要对二叉树的每个节点执行计算,并且计算需要并行处理,但子节点必须在父节点处理完后再开始。
void Traverse(Node current)
{
// 对当前节点进行耗时操作
DoExpensiveActionOnNode(current);
// 递归处理左子节点
if (current.Left != null)
{
Task.Factory.StartNew(
() => Traverse(current.Left),
CancellationToken.None,
TaskCreationOptions.AttachedToParent,
TaskScheduler.Default);
}
// 递归处理右子节点
if (current.Right != null)
{
Task.Factory.StartNew(
() => Traverse(current.Right),
CancellationToken.None,
TaskCreationOptions.AttachedToParent,
TaskScheduler.Default);
}
}
void ProcessTree(Node root)
{
// 启动顶层任务并等待其完成
Task task = Task.Factory.StartNew(
() => Traverse(root),
CancellationToken.None,
TaskCreationOptions.None,
TaskScheduler.Default);
task.Wait();
}
解释:
Task.Factory.StartNew
用于启动新的任务。TaskCreationOptions.AttachedToParent
标志使子任务附属于父任务。这样,父任务会等待所有子任务完成后再结束,并且子任务抛出的异常会传递到父任务。ProcessTree
方法启动对根节点的处理,并等待整个树的遍历任务完成。
为什么使用 AttachedToParent
?
- 父子任务关系:
AttachedToParent
建立了显式的父子任务关系,确保父任务等待所有子任务完成。 - 异常传播:如果子任务抛出异常,它会被传播到父任务,这样可以统一处理异常,而不需要单独处理每个子任务的异常。
示例 2:任务延续(Task Continuation)
在某些情况下,任务之间没有父子关系,而是需要在某个任务完成后继续执行另一个任务。可以使用 ContinueWith
方法实现任务的延续。
Task task = Task.Factory.StartNew(
() => Thread.Sleep(TimeSpan.FromSeconds(2)),
CancellationToken.None,
TaskCreationOptions.None,
TaskScheduler.Default);
Task continuation = task.ContinueWith(
t => Console.WriteLine("任务已完成"),
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
解释:
ContinueWith
会在原始任务完成后执行。这里,t
参数代表原始任务,允许在延续任务中访问原始任务的状态和结果。- 这种方式适用于没有父子依赖关系的任务链,常用于串行化一系列异步操作。
动态并行的最佳实践
-
合理使用
AttachedToParent
:- 在需要显式父子任务关系的情况下使用
AttachedToParent
,可以简化任务的等待和异常处理。 - 对于没有父子依赖的任务,不需要使用
AttachedToParent
,避免不必要的任务层级。
- 在需要显式父子任务关系的情况下使用
-
避免使用阻塞操作:
- 在并行任务中,避免使用
Task.Wait
、Task.Result
等阻塞操作,尽量使用await
、Task.WhenAll
等异步方法。 - 阻塞操作会占用线程资源,影响并行性能,尤其是在高并发场景中。
- 在并行任务中,避免使用
-
选择合适的任务调度器:
- 默认使用
TaskScheduler.Default
,它会根据线程池的调度策略动态分配线程。 - 如果有特殊的调度需求(如 UI 线程调度),可以指定不同的调度器。
- 默认使用
5.5 并行 LINQ(PLINQ):利用并行化提升 LINQ 性能
问题
假设需要对一个数据序列执行计算,生成新的序列,或者对序列进行聚合操作。传统的 LINQ 操作是单线程顺序执行的,在处理大规模数据或 CPU 密集型任务时性能不足。如何在保留 LINQ 易用性的同时,提升并行处理性能?
解决方案
PLINQ(Parallel LINQ)是 LINQ 的并行版本,允许开发者使用与 LINQ 类似的语法来编写并行查询。PLINQ 会自动并行化 LINQ 查询,以充分利用多核 CPU 的计算能力。它非常适合需要对输入序列进行变换或聚合的场景。
示例 1:使用 PLINQ 并行处理序列
假设我们有一个整数序列,想要将其中的每个元素乘以 2。对于简单的 CPU 密集型计算任务,PLINQ 可以显著提升性能。
IEnumerable<int> MultiplyBy2(IEnumerable<int> values)
{
return values.AsParallel().Select(value => value * 2);
}
解释:
AsParallel()
方法将序列转换为 PLINQ 查询,启用并行化。Select
操作会在多个线程上并行执行,每个线程处理一部分数据。
注意:PLINQ 默认不保证输出的顺序。如果顺序很重要,可以使用
AsOrdered()
方法。
示例 2:保留顺序的并行查询
在某些场景下,需要保持输入序列和输出序列的顺序一致。例如,处理日志记录数据时,顺序可能很重要。
IEnumerable<int> MultiplyBy2Ordered(IEnumerable<int> values)
{
return values.AsParallel().AsOrdered().Select(value => value * 2);
}
解释:
AsOrdered()
告诉 PLINQ 保持顺序,因此输出序列的顺序与输入序列一致。- 保留顺序会有一定的性能损失,但在需要顺序的一些场景中这是必要的。
示例 3:并行求和
PLINQ 还可以用于聚合操作,如求和、求平均等。以下示例展示了如何并行求和:
int ParallelSum(IEnumerable<int> values)
{
return values.AsParallel().Sum();
}
解释:
Sum()
是 LINQ 的标准聚合操作。通过AsParallel()
,它会在多个线程上并行执行,利用多核处理器提升性能。- PLINQ 自动处理并行化的细节,不需要手动管理线程或同步状态。
PLINQ 支持的操作符
PLINQ 提供了大量的并行版本运算符,包括:
- 过滤(Where):并行过滤序列中的元素。
- 投影(Select):并行变换序列中的每个元素。
- 聚合(Sum、Average、Aggregate):并行计算聚合结果。
例如,使用 Where
和 Select
的并行查询:
IEnumerable<int> FilterAndMultiply(IEnumerable<int> values)
{
return values.AsParallel()
.Where(value => value % 2 == 0)
.Select(value => value * 2);
}
解释:
- 先使用
Where
过滤出偶数,再用Select
对每个元素进行变换。 AsParallel()
使整个查询并行执行。
使用 PLINQ 的最佳实践
-
使用
AsParallel()
慎重:- 并非所有的 LINQ 查询都能从并行化中获益。对于小数据集,串行执行可能更快。
- 使用
AsParallel()
前,应确保查询包含 CPU 密集型操作,且数据集较大。
-
避免使用
AsOrdered()
除非必要:- 保留顺序会降低并行性能。如果顺序不重要,尽量使用默认的无序查询。
-
处理异常:
- PLINQ 中的异常会被包装在
AggregateException
中。可以使用try-catch
捕获异常,并通过AggregateException.Flatten()
查看所有内部异常。
- PLINQ 中的异常会被包装在
try
{
var result = values.AsParallel().Select(value => 1 / value).ToList();
}
catch (AggregateException ex)
{
foreach (var innerException in ex.Flatten().InnerExceptions)
{
Console.WriteLine(innerException.Message);
}
}
- 监控性能:
- 使用
ParallelQuery
的方法时,可以通过WithDegreeOfParallelism
设置并行度来优化性能。 - 默认情况下,PLINQ 使用所有可用的核心线程。可以根据场景调整并行度以避免线程争用。
- 使用
var result = values.AsParallel()
.WithDegreeOfParallelism(4)
.Select(value => value * 2);