C# 并行编程实战:掌握 Parallel.Invoke 的核心机制
在多核处理器普及的今天,并行编程已成为提升软件执行效率的关键手段。传统的多线程开发往往需要手动管理线程生命周期、处理复杂的同步与锁机制。为此,.NET 引入了任务并行库(Task Parallel Library, 简称 TPL)。TPL 将底层的线程池管理抽象为基于"任务"的模型,极大地简化了并发代码的编写。
在 TPL 中,Parallel 静态类提供了三个核心方法来应对不同的并行场景:Parallel.For 和 Parallel.ForEach 主要用于数据并行(循环迭代),而 Parallel.Invoke 则专注于任务并行,即同时执行多个独立的操作。需要强调的是,并行化并非简单地将串行逻辑替换为并行方法,它要求开发者深入理解任务的独立性、共享状态以及线程调度开销。
Parallel.Invoke 的基础调用
Parallel.Invoke 接受一组 Action 委托,并尽可能地利用线程池并发执行它们。它主要提供两个重载版本:
public static void Invoke(params Action[] actions);
public static void Invoke(ParallelOptions parallelOptions, params Action[] actions);
在实际开发中,我们可以通过 Lambda 表达式、方法组或匿名委托来传递任务:
// 方式一:使用 Lambda 表达式
Parallel.Invoke(
() => ExecuteTaskA(),
() => ExecuteTaskB(),
() => ExecuteTaskC()
);
// 方式二:直接传递方法组
Parallel.Invoke(ExecuteTaskA, ExecuteTaskB, ExecuteTaskC);
// 方式三:混合使用匿名委托与方法组
Parallel.Invoke(
() => { ExecuteTaskA(); Console.WriteLine("A 完成"); },
ExecuteTaskB,
delegate { ExecuteTaskC(); }
);
执行特性与主线程阻塞
为了深入理解 Parallel.Invoke 的运行机制,我们来看一个基础示例。该示例展示了任务的无序性以及主线程的阻塞行为。
public class InvokeBasics
{
public void Execute()
{
var stopwatch = Stopwatch.StartNew();
Console.WriteLine($"[主线程 {Environment.CurrentManagedThreadId}] 启动并行任务...");
Parallel.Invoke(
() => SimulateWorkload("Alpha", 3000),
() => SimulateWorkload("Beta", 1000),
() => SimulateWorkload("Gamma", 2000)
);
stopwatch.Stop();
Console.WriteLine($"[主线程 {Environment.CurrentManagedThreadId}] 所有任务完成,总耗时: {stopwatch.ElapsedMilliseconds} ms");
}
private void SimulateWorkload(string taskName, int delayMs)
{
Thread.Sleep(delayMs);
Console.WriteLine($"任务 {taskName} 由线程 {Environment.CurrentManagedThreadId} 执行完毕。");
}
}
通过运行上述代码,可以观察到以下核心特性:
- 无序执行:子任务的执行顺序是不确定的,且它们可能被分配到不同的线程池线程中,也可能复用同一个线程。
- 屏障语义(Barrier):
Invoke会阻塞调用它的主线程,直到所有传入的委托都执行完毕。这意味着总耗时取决于执行时间最长的那个子任务(木桶效应)。因此,在设计并行任务时,应尽量保证各个子任务的耗时相对均衡,避免单个长耗时任务拖垮整体性能。 - 线程池托管:开发者无需手动创建和销毁线程,TPL 会根据系统负载和可用核心数自动优化线程池的调度。
并行环境下的异常处理
在并行环境中,单个子任务的异常不会直接导致整个进程崩溃,而是会被 TPL 捕获并封装。如果多个任务同时抛出异常,它们会被聚合到一个 AggregateException 中。
public class InvokeExceptionHandling
{
public void Execute()
{
try
{
Parallel.Invoke(
() => SimulateWorkload("Alpha", 1000),
() => { throw new InvalidOperationException("Beta 任务发生严重错误!"); },
() => SimulateWorkload("Gamma", 1000)
);
}
catch (AggregateException aggEx)
{
Console.WriteLine("捕获到聚合异常:");
foreach (var innerEx in aggEx.InnerExceptions)
{
Console.WriteLine($" -> {innerEx.Message}");
}
}
}
private void SimulateWorkload(string taskName, int delayMs)
{
Thread.Sleep(delayMs);
Console.WriteLine($"任务 {taskName} 正常完成。");
}
}
即使某个委托抛出了异常,TPL 仍会等待其他正在运行的任务执行完毕(或触发取消),然后将所有收集到的异常统一抛出。这种机制确保了并行任务的状态完整性,但也要求开发者必须通过遍历 InnerExceptions 来进行精细化的错误处理。
使用 ParallelOptions 进行精细化控制
默认情况下,Parallel.Invoke 会尝试最大化利用系统资源。但在某些场景下(如避免耗尽服务器 CPU 影响其他服务,或需要响应外部取消信号),我们需要通过 ParallelOptions 进行干预。
public class InvokeWithOptions
{
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
public void Execute()
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 2, // 限制最大并发线程数为 2
CancellationToken = _cts.Token
};
// 在后台启动一个定时器,3秒后触发取消信号
Task.Run(async () => {
await Task.Delay(3000);
_cts.Cancel();
Console.WriteLine("已发出取消信号...");
});
try
{
Parallel.Invoke(options,
() => LongRunningTask("Task-A", options),
() => LongRunningTask("Task-B", options),
() => LongRunningTask("Task-C", options)
);
}
catch (OperationCanceledException)
{
Console.WriteLine("并行操作已被成功取消。");
}
}
private void LongRunningTask(string taskId, ParallelOptions options)
{
for (int i = 0; i < 10; i++)
{
// 每次循环检查取消令牌
options.CancellationToken.ThrowIfCancellationRequested();
Thread.Sleep(500);
Console.WriteLine($"{taskId} 正在执行... (线程: {Environment.CurrentManagedThreadId})");
}
}
}
在上述代码中,MaxDegreeOfParallelism 被设置为 2,这意味着即使传入了 3 个任务,同一时刻最多也只有 2 个线程在执行委托,有效控制了资源占用。同时,通过 CancellationToken,长耗时任务可以在每次迭代中检查取消状态,从而实现优雅退出,避免线程泄漏。
在实际工程中,Parallel.Invoke 的每次调用都会产生一定的调度开销。如果子任务的执行时间过短(例如几毫秒内的简单计算),并行化带来的线程切换成本可能会抵消并发带来的收益。因此,在应用此方法前,务必通过基准测试评估任务粒度,并确保子任务之间不存在隐式的共享资源竞争。