并发编程-6.并行编程概念
TPL 入门
TPL 由添加到 .NET Framework 4.0 中的 System.Threading 和 System.Threading.Tasks 命名空间的类型组成。 TPL 提供的功能使 .NET 开发人员的并行性和并发性变得更简单。 无需在代码中管理 ThreadPool 任务。 TPL 处理线程管理并根据处理器能力和可用性自动调整活动线程的数量。
当开发人员需要在代码中引入并行性或并发性以提高性能时,应使用 TPL。 然而,TPL 并不是适合所有场景的正确选择。 您如何知道何时选择 TPL 以及哪种 TPL 构造是每种场景的最佳选择?
I/O 密集型操作
当处理 I/O 密集型操作(例如文件操作、数据库调用或 Web 服务调用)时,使用 Task 对象进行异步编程和 C# 异步/等待操作是您的最佳选择。 如果您的服务要求循环访问大型集合,为循环中的每个对象进行服务调用,则应考虑重构服务以将数据作为单个服务调用返回。这将最大限度地减少与每个网络操作相关的开销。 它还允许您的客户端代码对服务进行单个异步调用,同时保持主线程空闲以执行其他工作。
I/O 密集型操作通常不适合并行操作,但每个规则都有例外。 如果您需要迭代文件系统中的一组文件夹和子文件夹,则并行循环非常适合此目的。 但是,重要的是,循环的任何迭代都不会尝试访问同一文件,以避免锁定问题。
CPU 密集型操作
CPU 密集型操作不依赖于外部资源,例如文件系统、网络或互联网。 它们涉及在应用程序进程中处理内存中的数据。 有许多类型的数据转换都属于这一类。 您的应用程序可能正在序列化或反序列化数据、在文件类型之间进行转换或处理图像或其他二进制数据。
这些类型的操作对于数据并行性和并行循环特别有意义,但有一些例外。 首先,如果每次迭代对 CPU 的消耗不是很大,那么使用 TPL 就不值得它带来的开销。 如果该过程非常密集,但需要迭代的对象很少,请考虑使用 Parallel.Invoke 而不是并行循环之一:Parallel.For 或 Parallel.ForEach。 由于使用 TPL 的开销,对 CPU 密集程度较低的操作使用并行结构通常会减慢代码速度。 在第10章中,我们将学习如何使用Visual Studio来确定并行和并发代码的性能。
.NET 中的并行循环
在本节中,我们将探讨在 .NET 项目中利用数据并行性的一些示例。 C# for 和 foreach 循环的并行版本 Parallel.For
和 Parallel.ForEach
是 System.Threading.Tasks.Parallel
命名空间的一部分。 使用这些并行循环与在 C# 中使用它们的标准对应循环类似。
一个关键的区别是并行循环的主体被声明为 lambda 表达式。 因此,继续或中断并行循环的方式会发生一些变化。 您可以使用 return
语句,而不是使用 continue
来停止循环的当前迭代而不中断整个循环。 与使用break
退出并行循环等效的是使用Stop()
或Break()
语句。
基本 Parallel.For 循环
我们将创建一个新的 WinForms 应用程序,允许用户在其工作站上选择一个文件夹并检查有关所选文件夹中文件的一些信息。 该项目的 FileProcessor
类将迭代文件以聚合文件大小并查找最近写入的文件:
-
首先在 Visual Studio 中创建一个新的 .NET 6 WinForms 项目
-
添加一个名为
FileData
的新类。 此类将包含来自FileProcessor
的数据:
public class FileData
{
public List<FileInfo> FileInfoList { get; set; } =new();
public long TotalSize { get; set; } = 0;
public string LastWrittenFileName{ get; set; } = "";
public DateTime LastFileWriteTime { get; set; }
}
我们将返回所选文件夹中文件的 FileInfo
对象列表、所有文件的总大小、上次写入文件的名称以及写入文件的日期和时间。
3.接下来,创建一个名为FileProcessor
的新类
- 将名为
GetInfoForFiles
的静态方法添加到FileProcessor
:
public static FileData GetInfoForFiles(string[] files)
{
var results = new FileData();
var fileInfos = new List<FileInfo>();
long totalFileSize = 0;
DateTime lastWriteTime = DateTime.MinValue;
string lastFileWritten = "";
object dateLock = new();
Parallel.For(0, files.Length,
index => {
FileInfo fi = new(files[index]);
long size = fi.Length;
DateTime lastWrite = fi.LastWriteTimeUtc;
lock (dateLock)
{
if (lastWriteTime < lastWrite)
{
lastWriteTime = lastWrite;
lastFileWritten = fi.Name;
}
}
Interlocked.Add(ref totalFileSize, size);
fileInfos.Add(fi);
});
results.FileInfoList = fileInfos;
results.TotalSize = totalFileSize;
results.LastFileWriteTime = lastWriteTime;
results.LastWrittenFileName = lastFileWritten;
return results;
}
Parallel.For
循环及其主体的 lambda 表达式在前面的代码中突出显示。 循环内的代码有几点需要注意:
-
首先,索引作为 lambda 表达式的参数提供,以便表达式主体可以使用它来访问文件数组的当前成员。
-
TotalFileSize
在Interlocked.Add
调用中更新。 这是在并行代码中安全添加值的最有效方法。 -
没有一种简单的方法可以利用
Interlocked
来更新 lastWriteTime DateTime 值。 因此,我们使用带有dateLock
对象的锁块来安全地读取和设置lastWriteTime
方法级变量。 -
接下来,打开
Form1.cs
的设计器并将以下控件添加到表单中:
private GroupBox FileProcessorGroup;
private Button FolderProcessButton;
private Button FolderBrowseButton;
private TextBox FolderToProcessTextBox;
private Label label1;
private TextBox FolderResultsTextBox;
private Label label2;
private FolderBrowserDialog folderToProcessDialog;
完成后,表单设计器应如下所示:
图 6.1 – Visual Studio 中完成的 Form1.cs 设计器视图
- 接下来,双击Form1设计器中的浏览按钮,代码隐藏文件中将生成FolderBrowserButton_Click事件处理程序。 添加以下代码以使用folderToProcessDialog 对象向用户显示文件夹选择器对话框:
private void FolderBrowseButton_Click(object sender,EventArgs e)
{
var result = folderToProcessDialog.ShowDialog();
if (result == DialogResult.OK)
{
FolderToProcessTextBox.Text = folderToProcessDialog.SelectedPath;
}
}
选定的文件夹路径将设置在FolderToProcessTextBox
中以供下一步使用。 用户也可以在字段中手动键入或粘贴文件夹路径。 如果您想阻止手动输入,可以将FolderToProcessTextBox.ReadOnly
设置为true
。
- 接下来,双击设计器视图中的
Process
按钮。 后面的代码中将生成一个FolderProcessButton_Click
事件处理程序。 添加以下代码来调用FileProcessor
并将结果显示在FolderResultsTextBox
中:
private void FolderProcessButton_Click(object sender,EventArgs e)
{
if (!string.IsNullOrWhiteSpace(FolderToProcessTextBox.Text) &&
Directory.Exists(FolderToProcessTextBox.Text))
{
string[] filesToProcess = Directory.GetFiles(FolderToProcessTextBox.Text);
FileData? results = FileProcessor.GetInfoForFiles(filesToProcess);
if (results == null)
{
FolderResultsTextBox.Text = "";
return;
}
StringBuilder resultText = new();
resultText.Append($"Total file count:{results.FileInfoList.Count}; ");
resultText.AppendLine($"Total file size:{results.TotalSize} bytes");
resultText.Append($"Last written file:{results.LastWrittenFileName} ");
resultText.Append($"at{results.LastFileWriteTime}");
FolderResultsTextBox.Text =resultText.ToString();
}
}
这里的代码很简单。 静态 GetInfoForFiles
方法返回带有文件信息的 FileData
实例。 我们使用 StringBuilder
创建要在FolderResultsTextBox
中设置的输出。
- 我们已准备好运行应用程序。 开始在 Visual Studio 中调试项目并尝试一下。 你的结果应该是这样的:
图 6.2 – 运行并行循环应用程序
这里的所有都是它的。 如果您想尝试更高级的操作,可以尝试修改项目以处理所选文件夹的所有子文件夹中的文件。 让我们对项目进行不同的更改,以便减少对 Interlocked.Add
的锁定调用。
具有线程局部变量的并行循环
Parallel.For
构造有一个重载,它允许我们的代码为参与循环的每个线程保留总文件大小的运行小计。 这意味着我们只需要在将每个线程的小计汇总到totalFileSize
时使用Interlocked.Add
。这是通过向循环提供线程局部变量来完成的。 以下代码中的小计是针对每个线程离散存储的。 因此,如果循环有 200 次迭代,但只有 5 个线程参与循环,则 Interlocked.Add
将仅被调用 5 次,而不是 200 次,而不会失去任何线程安全性:
public static FileData GetInfoForFilesThreadLocal(string[] files)
{
var results = new FileData();
var fileInfos = new List<FileInfo>();
long totalFileSize = 0;
DateTime lastWriteTime = DateTime.MinValue;
string lastFileWritten = "";
object dateLock = new();
Parallel.For<long>(0, files.Length, () => 0,
(index, loop, subtotal) => {
FileInfo fi = new(files[index]);
long size = fi.Length;
DateTime lastWrite = fi.LastWriteTimeUtc;
lock (dateLock)
{
if (lastWriteTime < lastWrite)
{
lastWriteTime = lastWrite;
lastFileWritten = fi.Name;
}
}
subtotal += size;
fileInfos.Add(fi);
return subtotal;
},
(runningTotal) => Interlocked.Add(ref totalFileSize, runningTotal)
);
results.FileInfoList = fileInfos;
results.TotalSize = totalFileSize;
results.LastFileWriteTime = lastWriteTime;
results.LastWrittenFileName = lastFileWritten;
return results;
}
总结一下前面的更改,您会注意到我们使用 Parallel.For<long>
泛型方法来指示小计线程局部变量应该是 long
而不是 int
(默认类型)。 大小会添加到第一个 lambda
表达式中的小计中,而无需任何锁定表达式。 我们现在必须返回小计,以便其他迭代可以访问数据。 最后,我们使用 lambda
表达式向 For 添加了最后一个参数,该表达式使用 Interlocked.Add
将每个线程的 runningTotal
添加到totalFileSize
。
如果您更新FolderProcessButton_Click
以调用GetInfoForFilesThreadLocal
,输出将是相同的,但性能将得到改善,也许不明显。 性能改进取决于所选文件夹中的文件数量。
简单的 Parallel.ForEach 循环
Parallel.ForEach
方法(例如 Parallel.For
)在使用上与其非并行对应方法类似。 当您有 IEnumerable
集合要处理时,您可以使用 Parallel.ForEach
而不是 Parallel.For
。 在此示例中,我们将创建一个新方法,该方法接受图像文件的 List<string>
来迭代并转换为 Bitmap
对象:
- 首先在
FileProcessor
类中创建一个名为ConvertJpgToBitmap
的新私有静态方法。 此方法将打开每个 JPG 文件并返回包含图像数据的新位图:
private static Bitmap ConvertJpgToBitmap(string fileName)
{
Bitmap bmp;
using (Stream bmpStream = File.Open(fileName,FileMode.Open))
{
Image image = Image.FromStream(bmpStream);
bmp = new Bitmap(image);
}
return bmp;
}
- 接下来,在名为
ConvertFilesToBitmaps
的同一类中创建一个公共静态方法:
public static List<Bitmap> ConvertFilesToBitmaps(List<string> files)
{
var result = new List<Bitmap>();
Parallel.ForEach(files, file =>
{
FileInfo fi = new(file);
string ext = fi.Extension.ToLower();
if (ext == ".jpg" || ext == ".jpeg")
{
result.Add(ConvertJpgToBitmap(file));
}
});
return result;
}
此方法接受包含所选文件夹中的文件的 List<string>
。 在 Parallel.ForEach
循环内,它检查文件是否具有 .jpg 或 .jpeg 文件扩展名。 如果是,则将其转换为位图并添加到结果集合中。
- 在
Form1.cs
中添加一个新按钮。 将“Name属性设置为ProcessJpgsButton
,将“文本”属性设置为Process
JPGs。 - 双击新按钮以在代码隐藏文件中创建事件处理程序。 将以下代码添加到新的事件处理程序中:
private void ProcessJpgsButton_Click(object sender,
EventArgs e)
{
if (!string.IsNullOrWhiteSpace(FolderToProcessTextBox.Text) &&
Directory.Exists(FolderToProcessTextBox.Text))
{
List<string> filesToProcess = Directory.GetFiles(FolderToProcessTextBox.Text).ToList();
List<Bitmap> results = FileProcessor.ConvertFilesToBitmaps(filesToProcess);
StringBuilder resultText = new();
foreach (var bmp in results)
{
resultText.AppendLine($"Bitmap height: {bmp.Height}");
}
FolderResultsTextBox.Text = resultText.ToString();
}
}
- 现在,运行项目,选择包含一些 JPG 文件的文件夹,然后单击新的
Process
JPGs按钮。 您应该会看到输出中列出的每个转换后的 JPG 的高度。
这就是简单的 Parallel.ForEach
循环所需的全部内容。 如果需要取消长时间运行的并行循环,该怎么办? 让我们更新示例以使用 Parallel.ForEachAsync
来实现这一点。
取消 Parallel.ForEachAsync 循环
Parallel.ForEachAsync
是 .NET 6 中的新增功能。它是 Parallel.ForEach
的可等待版本,以异步 lambda 表达式作为其主体。 让我们更新前面的示例以使用这个新的并行方法并添加取消操作的功能:
- 我们将首先制作名为
ConvertFilesToBitmapsAsync
的ConvertFilesToBitmaps
异步副本。 差异突出如下:
public static async Task<List<Bitmap>>ConvertFilesToBitmapsAsync(
List<string> files,
CancellationTokenSource cts)
{
ParallelOptions po = new()
{
CancellationToken = cts.Token,
MaxDegreeOfParallelism =
Environment.ProcessorCount == 1 ? 1 : Environment.ProcessorCount - 1
};
var result = new List<Bitmap>();
try
{
await Parallel.ForEachAsync(files, po, async (file, _cts) =>{
FileInfo fi = new(file);
string ext = fi.Extension.ToLower();
if (ext == ".jpg" || ext == "jpeg")
{
result.Add(ConvertJpgToBitmap(file));
await Task.Delay(2000, _cts);
}
});
}
catch (OperationCanceledException e)
{
MessageBox.Show(e.Message);
}
finally
{
cts.Dispose();
}
return result;
}
新方法是异步的,返回 Task<List<Bitmap>>
,接受 CancellationTokenSource
,并在创建 ParallelOptions
时使用它来传递给 Parallel.ForEachAsync
方法。 Parallel.ForEachAsync
已等待,并且其 lambda 表达式被声明为异步,因此我们可以等待已添加的新 Task.Delay
,以便我们有足够的时间在循环完成之前单击取消按钮。
将 Parallel.ForEachAsync
包含在处理 OperationCanceledException
的 try/catch
块中,使该方法能够捕获取消。 处理取消后,我们将向用户显示一条消息。
该代码还设置 ProcessorCount
选项。 如果只有一个CPU核心可用,我们将值设置为1; 否则,我们希望使用的核心数不超过可用核心数减一。 .NET 运行时通常会很好地管理此值,因此只有在发现此选项可以提高应用程序的性能时才应更改此选项。
- 在 Form1.cs 文件中,添加一个新的
CancellationTokenSource
私有变量:
private CancellationTokenSource _cts;
- 将事件处理程序更新为异步,将
_cts
设置为CancellationTokenSource
的新实例,并将其传递给ConvertFilesToBitmapsAsync
。同时将等待添加到该调用中。
以下代码片段突出显示了所有必要的更改:
private async void ProcessJpgsButton_Click(object sender, EventArgs e)
{
if (!string.IsNullOrWhiteSpace
(FolderToProcessTextBox.Text) && Directory.Exists(FolderToProcessTextBox.Text))
{
_cts = new CancellationTokenSource();
List<string> filesToProcess = Directory.GetFiles(FolderToProcessTextBox.Text) .ToList();
List<Bitmap> results = await FileProcessor.ConvertFilesToBitmapsAsync(filesToProcess, _cts);
StringBuilder resultText = new();
foreach (var bmp in results)
{
resultText.AppendLine($"Bitmap height:{bmp.Height}");
}
FolderResultsTextBox.Text = resultText.ToString();
}
}
- 在表单中添加一个名为
CancelButton
且标题为Cancel
的新按钮 - 双击取消按钮并添加以下事件处理程序代码:
private void CancelButton_Click(object sender,EventArgs e)
{
if (_cts != null)
{
_cts.Cancel();
}
}
- 运行应用程序,浏览并选择包含 JPG 文件的文件夹,单击
Process
JPGs按钮,然后立即单击Cancel
按钮。 您应该收到一条消息,指示处理已被取消。 不再处理进一步的记录。
并行任务之间的关系
在上一章(第 5 章)中,我们学习了如何使用 async
和 wait
并行执行工作并使用ContinueWith
管理任务流。
在 Parallel.Invoke 的幕后
在第2章中,我们学习了如何使用Parallel.Invoke
方法并行执行多个任务。 我们现在将重新审视 Parallel.Invoke
并了解幕后发生的事情。 考虑使用它来调用两个方法:
Parallel.Invoke(DoFirstAction, DoSectionAction);
这是幕后发生的事情:
List<Task> taskList = new();
taskList.Add(Task.Run(DoFirstAction));
taskList.Add(Task.Run(DoSectionAction));
Task.WaitAll(taskList.ToArray());
将创建两个任务并在线程池中排队。 假设系统有可用资源,这两个任务应该被选取并并行运行。 调用方法将阻塞当前线程,等待并行任务完成。 该操作将在运行时间最长的任务期间阻塞调用线程。
如果这对于您的应用程序来说是可以接受的,那么使用 Parallel.Invoke
可以使代码更清晰且易于理解。 但是,如果您不想阻塞调用线程,有几个选项。首先,让我们对第二个示例进行更改以使用await
:
List<Task> taskList = new();
taskList.Add(Task.Run(DoFirstAction));
taskList.Add(Task.Run(DoSectionAction));
await Task.WhenAll(taskList.ToArray());
通过等待 Task.WhenAll
而不是使用 Task.WaitAll
,我们允许当前线程在等待两个子任务并行完成处理的同时执行其他工作。 为了使用 Parallel.Invoke
获得相同的结果,我们可以将其包装在 Task
中:
await Task.Run(() => Parallel.Invoke(DoFirstTask,DoSecondTask));
可以对 Parallel.For
使用相同的技术,以避免在等待循环完成时阻塞调用线程。 这对于 Parallel.ForEach
来说不是必需的。 我们可以将 Parallel.ForEach
包装在 Task
中,而不是将其替换为 Parallel.ForEachAsync
。我们在本章前面了解到,.NET 6 添加了 Parallel.ForEachAsync
,它返回 Task
并且可以等待。
理解并行子任务
当执行嵌套任务时,默认情况下,父任务不会等待其子任务,除非我们使用 Wait()
方法或await
语句。 但是,在使用 Task.Factory.StartNew()
时,可以通过一些选项来控制此默认行为。 为了说明可用的选项,我们将创建一个新的示例项目:
-
首先,创建一个名为
ParallelTaskRelationshipsSample
的新 C# 控制台应用程序。 -
将一个类添加到名为
ParallelWork
的项目中。 我们将在这里创建父方法及其子方法。 -
将以下三个方法添加到
ParallelWork
类中。 这些将是我们的子方法。每个方法在开始和完成时都会写入一些控制台输出。 延迟是通过Thread.SpinWait
注入的。 如果您不熟悉Thread.SpinWait
,它会将当前线程放入指定迭代次数的循环中,注入等待,而不会从调度程序的考虑中删除该线程:
public void DoFirstItem()
{
Console.WriteLine("Starting DoFirstItem");
Thread.SpinWait(1000000);
Console.WriteLine("Finishing DoFirstItem");
}
public void DoSecondItem()
{
Console.WriteLine("Starting DoSecondItem");
Thread.SpinWait(1000000);
Console.WriteLine("Finishing DoSecondItem");
}
public void DoThirdItem()
{
Console.WriteLine("Starting DoThirdItem");
Thread.SpinWait(1000000);
Console.WriteLine("Finishing DoThirdItem");
}
- 接下来,添加一个名为
DoAllWork
的方法。 该方法将创建一个父任务,该父任务通过子任务调用上述三个方法。 没有添加代码来等待子任务:
public void DoAllWork()
{
Console.WriteLine("Starting DoAllWork");
Task parentTask = Task.Factory.StartNew(() =>
{
var child1 = Task.Factory.StartNew (DoFirstItem);
var child2 = Task.Factory.StartNew (DoSecondItem);
var child3 = Task.Factory.StartNew (DoThirdItem);
});
parentTask.Wait();
Console.WriteLine("Finishing DoAllWork");
}
- 现在,添加一些代码以从
Program.cs
运行DoAllWork
:
using ParallelTaskRelationshipsSample;
var parallelWork = new ParallelWork();
parallelWork.DoAllWork();
Console.ReadKey();
- 运行程序并检查输出。 正如您所期望的,父任务先于其子任务完成:
图 6.3 – 控制台应用程序运行 DoAllWork
- 接下来,我们创建一个名为
DoAllWorkAttached
的方法。 此方法将运行相同的三个子任务,但子任务将包含TaskCreationOptions.AttachedToParent
选项:
public void DoAllWorkAttached()
{
Console.WriteLine("Starting DoAllWorkAttached");
Task parentTask = Task.Factory.StartNew(() =>
{
var child1 = Task.Factory.StartNew(DoFirstItem, TaskCreationOptions.AttachedToParent);
var child2 = Task.Factory.StartNew (DoSecondItem, TaskCreationOptions.AttachedToParent);
var child3 = Task.Factory.StartNew (DoThirdItem, TaskCreationOptions.AttachedToParent);
});
parentTask.Wait();
Console.WriteLine("Finishing DoAllWorkAttached");
}
- 更新
Program.cs
以调用DoAllWorkAttached
而不是DoAllWork
并再次运行应用程序:
图 6.4 – 运行我们的应用程序并调用 DoAllWorkAttached
您可以看到,即使我们没有显式等待子任务,父任务也不会在其子任务完成之前完成。
现在,假设您有另一个父任务不应等待其子任务,无论它们是否使用 TaskCreationOptions.AttachedToParent
选项启动。 让我们创建一个可以处理这种情况的新方法:
- 使用以下代码创建名为
DoAllWorkDenyAttach
的方法:
public void DoAllWorkDenyAttach()
{
Console.WriteLine("Starting DoAllWorkDenyAttach");
Task parentTask = Task.Factory.StartNew(() =>
{
var child1 = Task.Factory.StartNew(DoFirstItem, TaskCreationOptions.AttachedToParent);
var child2 = Task.Factory.StartNew(DoSecondItem, TaskCreationOptions.AttachedToParent);
var child3 = Task.Factory.StartNew (DoThirdItem, TaskCreationOption.AttachedToParent);
}, TaskCreationOptions.DenyChildAttach);
parentTask.Wait();
Console.WriteLine("Finishing DoAllWork DenyAttach");
}
仍使用 AttachedToParent
选项创建子任务,但父任务现在设置了 DenyChildAttach
选项。 这将取代子级附加到父级的请求。
- 更新
Program.cs
以调用DoAllWorkDenyAttach
并再次运行应用程序:
图 6.5 – 控制台应用程序调用 DoAllWorkDenyAttach
您可以看到 DenyChildAttach
确实覆盖了每个子任务上设置的 AttachToParent
选项。 父级无需等待子级即可完成,就像调用 DoAllWork
时一样。
关于这个例子的最后一点说明。 您可能已经注意到,即使我们不需要设置 TaskCreationOption
,我们也使用 Task.Factory.StartNew
而不是 Task.Run
。 这是因为 Task.Run
将禁止任何子任务附加到父任务。 如果您在 DoAllWorkAttached
方法中使用 Task.Run
作为父任务,则父任务将首先完成,就像在其他方法中一样。
并行性的常见陷阱
使用 TPL 时,需要避免一些做法,以确保应用程序获得最佳结果。 在某些情况下,并行性使用不当可能会导致性能下降。 在其他情况下,它可能会导致错误或数据损坏。
不保证并行性
当使用并行循环或 Parallel.Invoke
之一时,迭代可以并行运行,但不能保证这样做。 这些并行委托中的代码应该能够在任一情况下成功运行。
并行循环并不总是更快
我们在本章前面讨论过这一点,但重要的是要记住,for
和 foreach
循环的并行版本并不总是更快。 如果每个循环迭代运行得很快,那么添加并行性的开销可能会减慢应用程序的速度。
在向应用程序引入任何线程时,记住这一点很重要。 始终在引入并发或并行性之前和之后测试代码,以确保性能提升值得线程开销。
注意阻塞UI线程
请记住,Parallel.For
和 Parallel.ForEach
是阻塞调用。 如果您在 UI 线程上使用它们,它们将在调用期间阻塞 UI。 该阻塞持续时间至少是运行时间最长的循环迭代的持续时间。
正如我们在上一节中讨论的,您可以将并行代码包装在对 Task.Run
的调用中,以将执行从 UI 线程移动到线程池上的后台线程。
线程安全
不要在并行循环中调用非线程安全的 .NET 方法。 Microsoft Docs 中记录了每种 .NET 类型的线程安全性。 使用 .NET API 浏览器快速查找有关特定 .NET API 的信息:https://docs.microsoft.com/dotnet/api/。
限制在并行循环中使用静态 .NET 方法,即使它们被标记为线程安全的。 它们不会导致数据一致性错误或问题,但会对循环性能产生负面影响。 即使调用 Console.WriteLine 也只能用于测试或演示目的。 不要在生产代码中使用它们。
用户界面控件
在 Windows 客户端应用程序中,不要尝试访问并行循环内的 UI 控件。 WinForms 和 WPF 控件只能从创建它们的线程访问。 您可以使用 Dispatcher.Invoke
调用其他线程上的操作,但这会对性能产生影响。 最好在并行循环完成后更新 UI。
线程本地数据
请记住在并行循环中利用 ThreadLocal
变量。 我们在本章前面的“使用线程局部变量的并行循环”部分中说明了如何执行此操作。
其中介绍了使用 C# 和 .NET 进行并行编程。 最后,让我们回顾一下我们在本章中学到的所有内容。
总结
在本章中,我们学习了如何在 .NET 应用程序中利用并行编程概念。 我们亲身体验了 Parallel.For
、Parallel.ForEach
和 Parallel.ForEachAsync
循环。 在这些部分中,我们学习了如何安全地聚合数据,同时保持线程安全。 接下来,我们学习了如何管理父任务与其并行子任务之间的关系。 这将有助于确保您的应用程序保持预期的操作顺序。
最后,我们介绍了在应用程序中实现并行性时要避免的一些重要陷阱。开发人员需要密切注意,以避免在自己的应用程序中出现任何这些陷阱。
要了解有关 .NET 中数据并行性的更多信息,Microsoft Docs 上的数据并行性文档是一个很好的起点:https://docs.microsoft.com/dotnet/standard/parallel-programming/data-parallelism-task-parallel-library.