Loading

并发编程-8.并行数据结构和并行Linq

PLINQ 简介

PLINQ 是 LINQ 的一组 .NET 扩展,允许通过利用线程池并行执行部分 LINQ 查询。 PLINQ 实现提供所有可用 LINQ 查询操作的并行版本。

与 LINQ 查询一样,PLINQ 查询提供延迟执行。 这意味着在需要枚举对象之前不会对其进行查询。 如果您不熟悉 LINQ 的延迟执行,我们将通过一个简单的示例来说明该概念。 考虑这两个 LINQ 查询:

internal void QueryCities(List<string> cities)
{
    // Query is executed with ToList call
    List<string> citiesWithS = cities.Where(s =>
                                            s.StartsWith('S')).ToList();
    // Query is not executed here
    IEnumerable<string> citiesWithT = cities.Where(s =>
                                                   s.StartsWith('T'));
    // Query is executed here when enumerating
    foreach (string city in citiesWithT)
    {
        // Do something with citiesWithT
    }
}

在示例中,由于调用了 ToList(),因此会立即执行填充 carsWithSLINQ 查询。 填充 cityWithT 的第二个查询不会立即执行。执行会推迟到需要 IEnumerable 值时为止。 直到我们在 foreach 循环中迭代它们之前,才需要 carsWithT 值。 同样的原则也适用于 PLINQ 查询。

PLINQ 在其他方面也与 LINQ 类似。 您可以在任何实现 IEnumerableIEnumerable<T> 的集合上创建 PLINQ 查询。 您可以使用所有熟悉的 LINQ 操作,例如WhereFirstOrDefaultSelect 等。 主要区别在于,PLINQ 尝试通过跨多个线程的部分或全部查询来利用并行编程的功能。 在内部,PLINQ 将内存中的数据分区为多个段,并对每个段并行执行查询。

PLINQ 和性能

在决定哪些 LINQ 查询适合利用 PLINQ 的强大功能时,您必须考虑许多因素。 要考虑的主要因素是要执行的工作的量级或复杂性是否足以抵消线程的开销。 您应该对大型数据集进行操作,并对集合中的每个项目执行昂贵的操作。 检查字符串第一个字母的 LINQ 示例不太适合 PLINQ,尤其是在源集合仅包含少量项目的情况下。

PLINQ 可能获得的性能的另一个因素是运行查询的系统上可用的内核数量。 PLINQ 可以利用的内核数量越多,潜在收益就越大。 PLINQ 可以将大型数据集分解为更多的工作单元,以便与许多可用的内核并行执行。

与传统 LINQ 查询相比,对数据进行排序和分组可能会产生更大的开销。 PLINQ 数据是分段的,但必须在整个集合中执行分组和排序。 PLINQ 最适合数据顺序不重要的查询。

我们将在保留数据顺序和使用 PLINQ 合并数据部分中讨论影响查询性能的一些其他因素。 现在,让我们开始创建第一个 PLINQ 查询。

创建 PLINQ 查询

PLINQ 的大部分功能都是通过 System.Linq.ParallelEnumerable 类的成员公开的。 此类包含可用于内存中对象查询的所有 LINQ 运算符的实现。 此类中有一些特定于 PLINQ 查询的附加运算符。 要理解的两个最重要的运算符是 AsParallelAsSequentialAsParallel 运算符指示应尝试并行执行所有后续 LINQ 操作。 相反,AsSequential 运算符向 PLINQ 指示应按顺序执行其后面的 LINQ 操作。

让我们看一个使用这两个 PLINQ 运算符的示例。 我们的查询将在 List<Person> 上运行,定义如下:

internal class Person
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public int Age { get; set; }
}

让我们考虑一下我们正在处理数千甚至数百万人的数据集。 我们希望利用 PLINQ 从数据中仅查询 18 岁或以上的成年人,然后按姓氏对他们进行分组。 我们只想并行执行查询的Where 子句。 GroupBy 操作将按顺序执行。 这个方法将会做到这一点:

internal void QueryAndGroupPeople(List<Person> people)
{
    var results = people.AsParallel().Where(p => p.Age > 17)
        .AsSequential().GroupBy(p => p.LastName);
    foreach (var group in results)
    {
        Console.WriteLine($"Last name {group.Key} has {group.Count()} people.");
    }
  // Sample output:
  // Last name Jones has 4220 people.
  // Last name Xu has 3434 people.
  // Last name Patel has 4798 people.
  // Last name Smith has 3051 people.
  // Last name Sanchez has 3811 people.
  // ...
}

GroupBy LINQ 方法将返回 IEnumerable<IGrouping<string, Person>>,其中每个 IGrouping<string, Person> 实例包含具有相同 LastName 的所有人员。 此 GroupBy 操作并行运行还是顺序运行更快取决于数据的构成。 您应该始终测试您的应用程序,以确定引入并行性是否可以提高处理生产数据时的性能。 我们将在第 10 章介绍对代码进行性能测试的方法。

查询语法与方法语法

LINQ 查询可以使用方法语法或查询语法进行编码。 方法语法是将多个方法串联在一起以构建查询的地方。 这就是我们在本节中一直在做的事情。 查询语法略有不同,它类似于 T-SQL 查询语法。 让我们检查一下以两种方式编写的同一个 PLINQ 查询。

下面是一个简单的 PLINQ 查询,用于从使用方法语法编写的人员列表中仅返回成人:

var peopleQuery1 = people.AsParallel().Where(p => p.Age > 17);

以下是使用查询语法编写的完全相同的 PLINQ 查询:

var peopleQuery2 = from person in people.AsParallel()
						where person.Age > 17
						select person;

您应该使用您喜欢的语法。 在本章的其余部分中,我们将使用方法语法作为示例。

将 LINQ 查询转换为 PLINQ

在本节中,我们将介绍一些其他 PLINQ 运算符,并向您展示如何利用它们将现有 LINQ 查询转换为 PLINQ 查询。 您现有的查询可能需要保留数据的顺序。 也许您现有的代码根本不使用 LINQ。 可能有机会将 foreach 循环中的某些逻辑转换为 PLINQ 操作。

将 LINQ 查询转换为 PLINQ 查询的方法是将 AsParallel() 语句插入到查询中,就像我们在上一节中所做的那样。 AsParallel() 后面的所有内容都将并行运行,直到遇到 AsSequential() 语句。

如果您的查询要求保留对象的原始顺序,您可以包含 AsOrdered() 语句:

var results = people.AsParallel().AsOrdered()
    .Where(p => p.LastName.StartsWith("H"));

但是,这不会像不保留数据序列的查询那样高效。 要显式告诉 PLINQ 不保留数据顺序,请使用 AsUnordered() 语句:

var results = people.AsParallel().AsUnordered()
    .Where(p => p.LastName.StartsWith("H"));

如果数据的顺序不重要,则查询的无序版本将执行得更好;您永远不应该将 AsOrdered() 与 PLINQ 一起使用。

让我们考虑另一个例子。 我们将从一个方法开始,该方法使用 foreach 循环迭代人员列表,并为每个 18 岁或以上的人员调用名为 ProcessVoterActions 的方法。 我们假设此方法是处理器密集型的,并且还使用一些 I/O 将选民信息保存在数据库中。 这是起始代码:

internal void ProcessAdultsWhoVote(List<Person> people)
{
    foreach (var person in people)
    {
        if (person.Age < 18) continue;
        ProcessVoterActions(person);
    }
}
private void ProcessVoterActions(Person adult)
{
    // Add adult to a voter database and process their
    data.
}

这根本不会利用并行处理。 我们可以通过使用 LINQ 过滤掉 18 岁以下的儿童来改进这一点,然后使用 Parallel.ForEach 循环调用 ProcessVoterActions

internal void ProcessAdultsWhoVoteInParallel(List<Person>
                                             people)
{
    var adults = people.Where(p => p.Age > 17);
    Parallel.ForEach(adults, ProcessVoterActions);
}

如果 ProcessVoterActions 需要一些时间为每个人运行,这肯定会提高性能。 然而,使用 PLINQ,我们可以进一步提高性能:

internal void ProcessAdultsWhoVoteWithPlinq(List<Person>
                                            people)
{
    var adults = people.AsParallel().Where(p => p.Age > 17);
    adults.ForAll(ProcessVoterActions);
}

现在,Where 查询将并行运行。 如果我们期望 people 集合中有数千或数百万个对象,这肯定会对性能有所帮助。 ForAll 扩展方法是另一个并行运行的 PLINQ 操作。 它旨在用于对查询结果中的每个对象并行执行操作。

ForAll 的性能也将优于前面示例中的 Parallel.ForEach 操作。 一个区别是 PLINQ 的延迟执行。 在迭代 IEnumerable 结果之前,不会执行对 ProcessVoterActions 的这些调用。 另一个优点与在完成对数据的 PLINQ 查询后使用 IEnumerable 执行标准 foreach 循环的优点相同。 数据必须先从多个线程合并回来,然后才能通过 foreachParallel.ForEach 进行枚举。 通过 ForAll 操作,数据可以保持 PLINQ 分段并在最后合并一次。 下图说明了 Parallel.ForEachForAll 之间的区别:

图 8.1 – PLINQ、数据分段和 ForAll 的优点

image

使用 PLINQ 查询处理异常

在 .NET 项目中实现良好的异常处理非常重要。 这是软件开发的基本实践之一。 一般来说,在进行并行编程时,异常处理可能会更加复杂。 PLINQ 也是如此。 当 PLINQ 查询内的并行操作中的任何异常未处理时,查询将引发 AggregateException 类型的异常。 因此,至少,所有 PLINQ 查询都应该在捕获 AggregateException 异常类型的 try/catch 块中运行。

让我们以带有 ProcessVoterActions 的 PLINQ ForAll 示例为例,并添加一些异常处理:

  1. 我们将在 .NET 控制台应用程序中运行此示例,因此在 Visual Studio 中创建一个新项目并添加一个名为 Person 的类:
internal class Person
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public int Age { get; set; }
}
  1. 接下来,添加一个名为 PlinqExceptionsExample 的新类。

  2. 现在向 PlinqExceptionsExample 添加一个名为 ProcessVoterActions 的私有方法。 我们将为任何超过 120 岁的人抛出 ArgumentException

private void ProcessVoterActions(Person adult)
{
    if (adult.Age > 120)
    {
        throw new ArgumentException("This person is  too old!", nameof(adult));
    }
    // Add adult to a voter database and process their
    data.
}
  1. 接下来,添加 ProcessAdultsWhoVoteWithPlinq 方法:
internal void ProcessAdultsWhoVoteWithPlinq
    (List<Person> people)
{
    try
    {
        var adults = people.AsParallel().Where(p =>  p.Age > 17);
        adults.ForAll(ProcessVoterActions);
    }
    catch (AggregateException ae)
    {
        foreach (var ex in ae.InnerExceptions)
        {
            Console.WriteLine($"Exception encountered while processing voters. Message:  {ex.Message}");
        }
    }
}

该方法的逻辑保持不变。 它使用 PLINQ Where 子句过滤掉子级,并调用 ProcessVoterActions 作为 ForAll 的委托。

  1. 最后,打开 Program.cs 并添加一些代码以在名为 GetPeople 的内联函数中创建 List<Person> 的实例。 它可以包含任意数量的人,但至少其中一个人的年龄需要大于 120 岁。调用 ProcessAdultsWhoVoteWithPlinq,传递来自 GetPeople 的数据:
using LINQandPLINQsnippets;
var exceptionExample = new PlinqExceptionsExample();
exceptionExample.ProcessAdultsWhoVoteWithPlinq(GetPeople());
Console.ReadLine();
static List<Person> GetPeople()
{
    return new List<Person>
    {
        new Person { FirstName = "Bob", LastName =
            "Jones", Age = 23 },
        new Person { FirstName = "Sally", LastName =
            "Shen", Age = 2 },
        new Person { FirstName = "Joe", LastName =
            "Smith", Age = 45 },
        new Person { FirstName = "Lisa", LastName =
            "Samson", Age = 98 },
        new Person { FirstName = "Norman", LastName =
            "Patterson", Age = 121 },
        new Person { FirstName = "Steve", LastName =
            "Gates", Age = 40 },
        new Person { FirstName = "Richard", LastName =
            "Ng", Age = 18 }
    };
}
  1. 现在,运行程序并观察控制台输出。 如果 Visual Studio 因异常而中断,只需单击“继续”:

图 8.2 – 在控制台中接收到异常

image

从 PLINQ 查询外部处理异常的问题是整个查询会停止。 它无法运行完成。 如果出现不应停止整个过程的异常,则应在查询内的代码中处理它,并继续处理剩余的项目。

如果您在 ProcessVoterActions 内处理异常,您就有机会优雅地处理它们并继续。

使用 PLINQ 保留数据顺序并合并数据

在为应用程序微调 PLINQ 查询时,有一些扩展方法会影响您可以利用的数据排序。 可能需要保留物品的原始顺序。 我们在本章中接触了 AsOrdered 方法,我们将在本节中对其进行实验。 当 PLINQ 操作完成并且项目作为最终枚举的一部分返回时,数据将从为在多个线程上操作而创建的段中合并。 可以通过使用 WithMergeOptions 扩展方法设置 ParallelMergeOptions 来控制合并行为。 我们将讨论所提供的三个可用合并选项的行为。

PLINQ 数据顺序示例

在本节中,我们将创建五个方法,每个方法都接受相同的数据集并对输入数据执行相同的过滤。 但是,每个 PLINQ 查询中的排序将以不同的方式处理。 我们将使用与上一节相同的 Person 类。 因此,您可以使用同一项目,也可以创建一个新的 .NET 控制台应用程序项目并添加上一示例中的 People 类。 让我们开始吧:

  1. 首先,打开 Person 类并添加一个名为 IsImportant 的新 bool 属性。 我们将使用它添加第二个数据点以在 PLINQ 查询中进行过滤:
internal class Person
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public int Age { get; set; }
    public bool IsImportant { get; set; }
}
  1. 接下来,将一个新类添加到名为 OrderSamples 的项目中。

  2. 现在是时候开始添加查询了。 在第一个查询中,我们没有指定 AsOrderedAsUnordered。 默认情况下,PLINQ 不应尝试保留数据的原始顺序。 在每个查询中,我们返回年龄小于 18IsImportant 设置为 true 的每个 Person 对象:

internal IEnumerable<Person>
    GetImportantChildrenNoOrder(List<Person> people)
{
    return people.AsParallel()
        .Where(p => p.IsImportant && p.Age < 18);
}
  1. 在第二个示例中,我们在 AsParallel 之后显式地将 IsUnordered 添加到查询中。行为应该与第一个查询相同,PLINQ 不关心项目的原始顺序:
internal IEnumerable<Person>
    GetImportantChildrenUnordered(List<Person> people)
{
    return people.AsParallel().AsUnordered()
        .Where(p => p.IsImportant && p.Age < 18);
}
  1. 第三个示例将过滤器分解为两个单独的Where 子句; IsSequential 添加在第一个Where 子句之后。 您认为这将如何影响项目顺序? 当我们运行程序时我们会发现:
internal IEnumerable<Person> GetImportantChildrenUnknownOrder(List<Person>  people)
{
    return people.AsParallel().Where(p => p.IsImportant)
        .AsSequential().Where(p => p.Age < 18);
}
  1. 在第四个示例中,我们使用 AsParallel().AsOrdered() 向 PLINQ 发出信号,表明我们希望保留项目的原始顺序:
internal IEnumerable<Person> GetImportantChildrenPreserveOrder(List<Person>   people)
{
    return people.AsParallel().AsOrdered()
        .Where(p => p.IsImportant && p.Age < 18);
}
  1. 在第五个也是最后一个示例中,我们在 AsOrdered 之后添加一个 Reverse 方法。 这应该以相反的方式保留项目的原始顺序:
internal IEnumerable<Person> GetImportantChildrenReverseOrder(List<Person> people)
{
    return people.AsParallel().AsOrdered().Reverse()
        .Where(p => p.IsImportant && p.Age < 18);
}
  1. 接下来,打开 Program.cs 并添加两个本地函数。 我们将创建一个 Person 对象列表以传递给每个方法。 另一个将迭代 List<Person> 以将每个 FirstName 输出到控制台:
static List<Person> GetYoungPeople()
{
    return new List<Person>
    {
        new Person { FirstName = "Bob", LastName =
            "Jones", Age = 23 },
        new Person { FirstName = "Sally", LastName =
            "Shen", Age = 2, IsImportant = true },
        new Person { FirstName = "Joe", LastName =
            "Smith", Age = 5, IsImportant = true },
        new Person { FirstName = "Lisa", LastName =
            "Samson", Age = 9, IsImportant = true },
        new Person { FirstName = "Norman", LastName =
            "Patterson", Age = 17 },
        new Person { FirstName = "Steve", LastName =
            "Gates", Age = 20 },
        new Person { FirstName = "Richard", LastName =
            "Ng", Age = 16, IsImportant = true }
    };
}
static void OutputListToConsole(List<Person> list)
{
    foreach (var item in list)
    {
        Console.WriteLine(item.FirstName);
    }
}
  1. 最后,我们将添加调用每个方法的代码。 时间戳(包括毫秒)在每次方法调用之前和结束时都会输出到控制台。 您可以多次运行应用程序来检查每个方法调用的性能。 尝试在具有更多或更少内核和不同大小数据集的 PC 上运行它,看看这对输出有何影响:
using LINQandPLINQsnippets;
var timeFmt = "hh:mm:ss.fff tt";
var orderExample = new OrderSamples();
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. AsParallel children:");
OutputListToConsole(orderExample.GetImportantChildrenNoOrder(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. AsUnordered children:");
OutputListToConsole(orderExample.GetImportantChildrenUnordered(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. Sequential after Wherechildren:");
OutputListToConsole(orderExample.GetImportantChildrenUnknownOrder(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. AsOrdered children:");
OutputListToConsole(orderExample.GetImportantChildrenPreserveOrder(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. Reverse order children:");
OutputListToConsole(orderExample.GetImportantChildrenReverseOrder(GetYoungPeople()).ToList());
Console.WriteLine($"Finish time: {DateTime.Now.ToString(timeFmt)}");
Console.ReadLine();
  1. 现在,运行程序并检查输出:

图 8.3 – 比较五个 PLINQ 查询中的项目顺序

image

您可以从输出中看到,只有在我们指定了 AsOrdered()AsOrdered().Reverse() 的最后两个示例中,项目的顺序才是可预测的。 在如此小的数据集上很难衡量不同 PLINQ 操作的影响。 如果您运行多次,您可能会在时间上看到不同的结果。 尝试自行添加更大的数据集来试验性能。

在 PLINQ 查询中使用 WithMergeOptions

了解每个选项的行为很重要。 让我们回顾一下 ParallelMergeOptions 枚举的每个成员。

ParallelMergeOptions.NotBuffered

NotBuffered 选项视为流数据。 每个项目在处理完成后立即从查询中返回。 有一些 PLINQ 操作不支持此选项并将忽略它。 例如,在对合并数据完成排序之前,OrderByOrderByDescending 操作无法返回项目。 这些始终是FullyBuffered。但是,使用AsOrdered 的查询可以使用此选项。 如果您的应用程序需要以流式传输方式使用项目,请使用此选项。

ParallelMergeOptions.AutoBuffered

AutoBuffered 选项返回收集的项目集。 项目集的大小以及返回以清除缓冲区的频率是不可配置的或您的代码无法得知的。 如果您想以这种方式提供数据,此选项可能适合您的需求。 再次强调,OrderByOrderByDescending 操作将不接受此选项。 这是大多数 PLINQ 操作的默认设置,并且在大多数情况下总体上是最快的。 AutoBuffered 选项使 PLINQ 能够根据当前系统条件灵活地根据需要缓冲项目。

ParallelMergeOptions.FullyBuffered

在查询处理并缓冲所有结果之前,FullyBuffered 选项不会提供任何结果。 该选项将花费最长的时间来使第一个项目可用,但很多时候,它是提供整个数据集最快的。

ParallelMergeOptions.Default

还有 ParallelMergeOptions.Default 值,其作用与根本不调用 WithMergeOptions 相同。 您应该根据数据的使用方式来选择合并选项。 如果没有严格的要求,通常最好不要设置合并选项。

WithMergeOptions 的实际应用

让我们创建对每个合并选项使用相同的 Person 查询并且根本没有设置合并选项的示例:

  1. 首先将 MergeSamples 类添加到您之前创建的控制台应用程序项目中。 首先,添加以下三个方法来测试合并类型:
internal IEnumerable<Person> GetImportantChildrenNoMergeSpecified(List<Person>  people)
{
    return people.AsParallel()
        .Where(p => p.IsImportant && p.Age < 18)
        .Take(3);
}
internal IEnumerable<Person> GetImportantChildren  DefaultMerge(List<Person> people)
{
    return people.AsParallel()
		.WithMergeOptions(ParallelMergeOptions.Default)
        .Where(p => p.IsImportant && p.Age < 18).Take(3);
}
internal IEnumerable<Person> GetImportant ChildrenAutoBuffered(List<Person> people)
{
    return people.AsParallel().WithMergeOptions
        (ParallelMergeOptions.AutoBuffered)
		.Where(p => p.IsImportant && p.Age < 18).Take(3);
}
  1. 接下来,将以下两个方法添加到 MergeSamples 类中:
internal IEnumerable<Person> GetImportant
    ChildrenNotBuffered(List<Person> people)
{
    return people.AsParallel().WithMergeOptions
        (ParallelMergeOptions.NotBuffered)
        .Where(p => p.IsImportant && p.Age <
               18).Take(3);
}
internal IEnumerable<Person> GetImportantChildren
    FullyBuffered(List<Person> people)
{
    return people.AsParallel().WithMergeOptions
        (ParallelMergeOptions.FullyBuffered)
		.Where(p =>p.IsImportant && p.Age < 18).Take(3);
}

最后两个步骤中的每个方法都会执行一个 PLINQ 查询,该查询会筛选 IsImportant 等于 trueAge 小于 18。然后执行 Take(3) 操作以仅返回查询中的前三项。

  1. Program.cs 中添加代码以调用每个方法并输出每次调用之前的时间戳以及最后的最终时间戳。 这与我们在上一节中调用测试排序的方法时使用的过程相同:
using LINQandPLINQsnippets;

var timeFmt = "hh:mm:ss.fff tt";
var mergeExample = new MergeSamples();

Console.WriteLine($"Start time: {DateTime.Now.ToString (timeFmt)}. NoMerge children:");
OutputListToConsole(mergeExample.GetImportantChildrenNoMergeSpecified(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. DefaultMerge  children:");
OutputListToConsole(mergeExample.GetImportantChildrenDefaultMerge(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString (timeFmt)}. AutoBuffered children:");
OutputListToConsole(mergeExample.GetImportantChildrenAutoBuffered(GetYoungPeople()).ToList());
Console.WriteLine($"Start time:  {DateTime.Now.ToString(timeFmt)}. NotBuffered    children:");
OutputListToConsole(mergeExample.GetImportantChildrenNotBuffered(GetYoungPeople()).ToList());
Console.WriteLine($"Start time:{DateTime.Now.ToString(timeFmt)}.FullyBuffered children:");
OutputListToConsole(mergeExample.GetImportantChildrenFullyBuffered(GetYoungPeople()).ToList());
Console.WriteLine($"Finish time: { DateTime.Now.ToString(timeFmt)}");
Console.ReadLine();
  1. 现在,运行程序并检查输出:

图 8.4 – 查看 PLINQ 合并选项方法的输出

image

第一个未指定合并选项的选项运行时间最长,但通常,第一次运行 PLINQ 查询时,它会比后续执行慢。 剩下的查询都非常快。 您应该在自己数据库中的一些大型数据集上测试这些查询,并查看不同 PLINQ 运算符和不同合并选项的计时有何不同。 您甚至可以在每个项目的输出之间进行计时,以了解 NotBufferedFullBuffered 的第一个项目返回的速度有多快。

.NET 中并行编程的数据结构

在 .NET 中进行并行编程以及使用 PLINQ 时,您应该利用 .NET 提供的数据结构、类型和原语。 在本节中,我们将讨论并发集合和同步原语。

并发集合

在进行并行编程时,并发集合非常有用。 我们将在第 9 章中详细介绍它们,但让我们快速讨论一下在使用 PLINQ 查询时如何利用它们。

如果您只是使用 PLINQ 选择和排序数据,则无需承担 System.Collections.Concurrent 命名空间中的集合所添加的开销。 但是,如果您使用 ForAll 调用修改源数据中的项目的方法,则应使用这些当前集合之一,例如 BlockingCollection<T>ConcurrentBag<T>ConcurrentDictionary<TKey, TValue>。 它们还可以防止对集合进行任何同时的添加或删除操作。

同步原语

如果您无法将并发集合引入现有代码库,提供并发性和性能的另一种选择是同步原语。 我们在第 1 章中介绍了其中的许多类型。System.Threading 命名空间中的这些类型(包括 BarrierCountdownEventSemaphoreSlimSpinLockSpinWait)提供了线程安全性和性能的适当平衡。 其他锁定机制(例如锁和互斥锁)的实施成本可能更高,从而对性能产生更大的影响。

如果我们想要保护使用 ForAllSpinLock 的 PLINQ 查询之一,我们可以简单地将方法包装在 try/finally 块中,并使用 SpinLock 上的 EnterExit 调用。以这个例子为例,我们正在检查一个人的位置 年龄大于120。假设代码也修改了年龄:

private SpinLock _spinLock = new SpinLock();
internal void ProcessAdultsWhoVoteWithPlinq2(List<Person>  people)
{
    var adults = people.AsParallel().Where(p => p.Age > 17);
    adults.ForAll(ProcessVoterActions2);
}
private void ProcessVoterActions2(Person adult)
{
    var hasLock = false;
    if (adult.Age > 120)
    {
        try
        {
            _spinLock.Enter(hasLock);
            adult.Age = 120;
        }
        finally
        {
            if (hasLock) _spinLock.Exit();
        }
    }
}
posted @ 2024-03-30 03:52  F(x)_King  阅读(34)  评论(0编辑  收藏  举报