Loading

并发编程-2..NET 中多线程编程的演变

.NET 线程多年来

自 2002 年推出 .NET Framework 1.0 和 C# 1.0 以来,在 .NET 和 C# 中使用线程已经发生了很大的变化。第 1 章中讨论的有关 System.Threading.Thread 对象的大多数概念自 .NET 早期就已经存在。 虽然 Thread 对象在 .NET 6 中仍然可用并且可用于简单的场景,但现在有更优雅和现代的解决方案可用。

C# 4 and .NET Framework 4.0

2010 年,Microsoft 发布了 Visual Studio 2010 以及 C# 4 和 .NET Framework 4.0。 虽然一些早期的语言和框架功能(例如泛型、lambda 表达式和匿名方法)将有助于促进后来的线程功能,但这些 2010 版本是自 2002 年以来对于线程功能最重要的版本。.NET Framework 包含以下功能,将在后续章节中更详细地探讨这些功能:

  • 线程安全集合:此集合已添加到 System.Collections.Concurrent 命名空间中,以提供对多线程代码中的数据集合的安全访问。
  • Parallel 类:它通过 Parallel.ForParallel.ForEach 提供对并行循环的支持,并通过 Parallel.Invoke 调用并行操作。
  • 并行 LINQ (PLINQ):这公开了 LINQ 操作的并行实现,并具有 AsParallelWithCancellationWithDegreeOfParallelism 等扩展。

C# 5 and 6 and .NET Framework 4.5.x

2012 年,Microsoft 发布了 .NET 现代多线程编程最重要的功能:使用 asyncwait 进行异步编程。 当 .NET Framework 4.5 添加 TPL 时,asyncawait 关键字已添加到同一版本中的C# 5 中。 TPL 的核心是新 System.Threading 中的 Task 类。 任务命名空间。

Task 对象从异步操作返回,为开发人员提供了一种检查操作状态或等待其完成的方法。 异步任务的工作是在线程池的后台线程上执行的,而不是在主线程中执行

C# 7.x and .NET Core 2.0

.NET 团队发布的 .NET Core 的第二个主要版本包括新的 ValueTaskValueTask<TResult> 类型。 ValueTask 类型是包装 TaskIValueTaskSource 实例并包含一些附加字段的结构。 仅当使用 C# 7.0 或更高版本时才可用。 添加 ValueTask 类型是因为许多异步操作实际上是同步完成的,但仍然会产生分配 Task 实例以返回调用者的开销。 在这些情况下,可以通过将 Task 替换为 ValueTask 来提高性能,ValueTask 在同步完成其工作时不会产生任何分配。 要了解有关引入 ValueTask 背后的动机以及何时使用它的更多信息,您可以阅读 .NET 团队的 Stephen Toub 撰写的以下博客文章:https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/。

C# 7.0 还向该语言引入了丢弃。 C# 中的丢弃由单个下划线字符 (_) 表示,以替换有意未使用的变量。 独立的丢弃取代了声明的变量来保存异步调用返回的任务实例的需要。 通过在这种情况下使用丢弃,它会明确向编译器发出信号,表明您要忽略返回的 Task 实例。 丢弃也可以用作其他场景中变量的占位符。 使用丢弃可以使代码的意图更清晰,并且在某些情况下可以减少内存分配。 您可以在 Microsoft Docs 网站上了解有关其使用的更多信息:https://docs.microsoft.com/dotnet/csharp/fundamentals/featured/discards。

2017 年晚些时候,C# 7.1 发布,添加了异步编程的一个值得注意的功能:能够将类的 Main 方法声明为异步。 这使得直接从 Main 方法等待其他异步方法成为可能。

C# 8 and .NET Core 3.0

当 C# 8 和 .NET Core 3.0 于 2019 年发布时,添加了多种语言和 .NET 功能来支持新的异步流功能。 顾名思义,异步流允许开发人员使用新的 IAsyncEnumerable 类型来提供异步数据的流式源。

让我们检查一下使用 IAsyncEnumerable 的代码片段:

public async IAsyncEnumerable<Order> GetLargeOrdersForCustomerAsync(int custId)
{
    await foreach (var order in GetOrdersByCustomerAsync(custId))
    {
    	if (order.Items.Count > 10) 
            yield return order;
    }
}

在此示例中,新的await foreach 语言功能用于调用异步方法来获取客户的所有订单。 然后,它在处理时使用收益返回操作通过 IAsyncEnumerable 类型返回包含超过 10 个项目的每个订单对象。 我们将在第 5 章中介绍一些使用 IAsyncEnumerable 的更真实的场景。

C# 8 中添加的另一个异步功能是 System.IAsyncDisposable 接口。 实现 IAsyncDisposable 时,您的类必须实现无参数 DisposeAsync 方法。 如果您的类消耗实现 IAsyncDisposable 的托管资源,并且无法按照 async using 块处置它们,则您应该实现 IAsyncDisposable 并在受保护的 DisposeAsyncCore 方法中清理这些资源。 有关使用 IDisposableIAsyncDisposable 的综合示例,您可以查看 Microsoft Docs 示例:https://docs.microsoft.com/dotnet/standard/garbage-collection/implementingdisposeasync#implementboth-dispose-and-async-dispose-patterns。

C# 10 and .NET 6

.NET 6 于 2021 年 11 月与 C# 10 一起发布。.NET 6 中的新功能之一是 System.Text.Json 序列化和反序列化 IAsyncEnumerable 类型的能力。 在 .NET 6 之前,序列化的 IAsyncEnumerable 类型将包含一个空的 JSON 对象。 这被认为是 .NET 6 中的重大更改,但它是一个更好的更改。 更改背后的主要动机是支持 ASP.NET Core MVC 控制器方法中的 IAsyncEnumerable<T> 响应。

异步开发人员值得注意的另一个 .NET 6 功能是 Visual Studio 2021 中的 C# 项目模板进行了现代化改造,以利用多个最新的语言功能,包括 C# 7.1 及更高版本中提供的异步 Main 方法。 2021 年 10 月发布 .NET 6 候选版本 2 时,.NET 团队在博客中介绍了这些更新的模板:https://devblogs.microsoft.com/dotnet/announcing-net-6-release-candidate-2/#net-sdk-c-projecttemplates-modernized。

拓展线程基础知识

在介绍 .NET 和 C# 的并行编程、并发性和异步编程之前,我们还需要介绍一些线程概念。 其中最重要的是 .NET 托管线程池,它由在 C# 中异步执行的等待方法调用使用。

托管线程池

System.Threading 命名空间中的 ThreadPool 类从一开始就是 .NET 的一部分。 它为开发人员提供了一个工作线程池,他们可以利用这些线程在后台执行任务。 事实上,这是线程池线程的关键特性之一。 它们是以默认优先级运行的后台线程当其中一个线程完成其任务时,它将返回到可用线程池以等待其下一个任务。 您可以在可用内存支持的情况下将尽可能多的任务排队到线程池中,但活动线程的数量受到操作系统可以分配给您的应用程序的数量的限制(基于处理器容量和其他正在运行的进程)。

如果要在 .NET 6 应用程序中使用 ThreadPool 类,通常会通过 TPL 来执行此操作,但让我们探讨如何直接将其与 ThreadPool.QueueUserWorkItem 一起使用。 以下代码采用第 1 章的示例场景,但使用 ThreadPool 线程来执行后台进程:

Console.WriteLine("Hello, World!");

ThreadPool.QueueUserWorkItem((o) =>
{
    for (int i = 0; i < 20; i++)
    {
        bool isNetworkUp = System.Net.NetworkInformation.
        	NetworkInterface
            .GetIsNetworkAvailable();
        
        Console.WriteLine($"Is network available? Answer: {isNetworkUp}");
        Thread.Sleep(100);
    }
});

for (int i = 0; i < 10; i++)
{
    Console.WriteLine("Main thread working...");
    Task.Delay(500);
}
Console.WriteLine("Done");
Console.ReadKey();

这里,主要区别在于不需要将 IsBackground 设置为 true,并且不需要调用 Start()。 该进程将在该项目在 ThreadPool 上排队后立即启动,或者在下一个 ThreadPool 可用时启动。 虽然您可能不会在代码中频繁显式使用 ThreadPool,但 .NET 中的许多常见线程功能都利用了它。 因此,了解它的工作原理非常重要。

线程和定时器

在本节中,我们将研究两个使用 ThreadPool 的计时器类:System.Timers.TimerSystem.Threading.Timer。 这两种类型都可以安全地与托管线程一起使用,并且可在 .NET 6 支持的每个平台上使用。

System.Timers.Timer

您可能最熟悉 System.Timers 命名空间中的 Timer 对象。 此计时器将以 Interval 属性中指定的时间间隔在线程池线程上引发 Elapsed 事件。 可以使用 Enabled 属性设置为Boolean 来停止或启动该机制。 如果您需要 Elapsed 事件仅触发一次,则可以将 AutoReset 属性设置为 false

此示例使用 Timer 对象来检查新消息并在发现任何消息时提醒用户:

  1. 首先声明一个 Timer 对象并在 InitializeTimer 方法中设置它:
private System.Timers.Timer? _timer;

private void InitializeTimer()
{
    _timer = new System.Timers.Timer
    {
    	Interval = 1000
    };
    _timer.Elapsed += _timer_Elapsed;
}
  1. 接下来,创建 _timer_Elapsed 事件处理程序来检查消息并更新用户:
private void _timer_Elapsed(object? sender,System.Timers.ElapsedEventArgs e)
{
    int messageCount = CheckForNewMessageCount();
    
    if (messageCount > 0)
    {
    	AlertUser(messageCount);
    }
}
  1. _timer 对象将其 Enabled 属性设置为 true 后,Elapsed 事件将每秒触发一次。 在WorkingWithTimers项目中,状态由TimerSample类中的StartTimer()StopTimer()方法控制:
public void ()
{
    if (_timer == null)
    {
    	InitializeTimer();
    }
    if (_timer != null && !_timer.Enabled)
    {
    	_timer.Enabled = true;
    }
}

public void StopTimer()
{
    if (_timer != null && _timer.Enabled)
    {
    	_timer.Enabled = false;
    }
}
  1. 运行WorkingWithTimers 项目并尝试使用StopTimerStartTimer按钮。启用计时器后,您应该会在 Visual Studio 的调试输出窗口中看到每秒出现的消息。

在您自己的应用程序中,您将使用 AlertUser 方法向用户显示警报消息或更新 UI 中的通知图标。 接下来,让我们尝试一下 System.Threading.Timer 类。

System.Threading.Timer

现在,我们将使用 System.Threading.Timer 类创建相同的示例。 这个 Timer 类的初始化必须稍有不同:

  1. 首先创建一个新的 InitializeTimer 方法:
private void InitializeTimer()
{
    var updater = new MessageUpdater();
    _timer = new System.Threading.Timer(
        callback: new TimerCallback(TimerFired),
        state: updater,
        dueTime: 500,
        period: 1000);
}

Timer 类的构造函数有四个参数。 回调参数是在计时器周期结束时在线程池上调用的委托。 状态参数是传递给回调委托的对象。 dueTime 参数告诉计时器在第一次触发计时器之前要等待多长时间(以毫秒为单位)。 最后,period 参数指定每次委托调用之间的时间间隔(以毫秒为单位)。

  1. 定时器实例化后,会立即启动。 没有 Enabled 属性来启动或停止此计时器。 完成后,您应该使用 Dispose 方法或 DisposeAsync 方法将其处置。 这发生在我们的 DisposeTimerAsync 方法中:
public void StartTimer()
{
    if (_timer == null)
    {
    	InitializeTimer();
    }
}

public async Task DisposeTimerAsync()
{
    if (_timer != null)
    {
   		 await _timer.DisposeAsync();
    }
}
  1. MessageUpdater 是一个类,用作提供给 TimerCallback 方法的状态对象。 它有一个方法来处理消息计数的更新。 向用户更新新消息的逻辑可以由此类封装。 在我们的例子中,它只会用新消息的数量更新调试输出:

    internal class MessageUpdater
    {
        internal void Update(int messageCount)
        {
        	Debug.WriteLine($"You have {messageCount} new messages!");
        }
    }
    
    1. 最后要检查的是 TimerFired 回调方法:
    private void TimerFired(object? state)
    {
        int messageCount = CheckForNewMessageCount();
        if (messageCount > 0 && state is MessageUpdater updater)
        {
        	updater.Update(messageCount);
        }
    }
    

    与上一个示例中的 _timer_Elapsed 方法类似,此方法只是检查新消息并触发更新。 然而,这一次,更新是由 MessageUpdater 类执行的,在您的应用程序中,可以通过 IMessageUpdater 接口进行抽象并注入到此类中,以改进关注点分离和可测试性。

  2. 通过使用应用程序中的“启动线程计时器”和“停止线程计时器”按钮来尝试此示例。 您应该会在“输出”窗口中看到一条带有新消息计数的调试消息,正如您在上一个示例中所做的那样。

这两个定时器的用途相似; 但是,大多数时候,您会希望使用 System.Threading.Timer 来利用其异步特性。 但是,如果您需要频繁停止和启动计时器进程,则 System.Timers.Timer 类是更好的选择。

并行性简介

在探索 C# 和 .NET 中线程的历史时,我们了解到 .NET Framework 4.0 中向开发人员引入了并行性。 在本节中,将通过 System.Threading.Tasks.Parallel 类在 TPL 中公开要探讨的方面。 此外,我们将通过示例介绍 PLINQ 的一些基础知识。 第 6 章、第 7 章和第 8 章将通过实际示例更详细地介绍这些数据并行性概念。

从高层次来看,并行性是并行执行多个任务的概念。 这些任务可以彼此相关,但这不是必需的。 事实上,并行运行的相关任务遇到同步问题或相互阻塞的风险更大。 例如,如果您的应用程序从订单服务加载订单数据,并从 Azure Blob 存储加载用户首选项和应用程序状态,则这两个进程可以并行运行,而不必担心冲突或数据同步。 另一方面,如果应用程序从两个不同的订单服务加载订单数据并将结果合并到单个集合中,则您将需要同步策略。

使用Parallel.Invoke

Parallel.Invoke 是一个可以执行多个操作的方法,并且它们可以并行执行。 无法保证它们的执行顺序。 每个动作都会在线程池中排队。 当所有操作完成后,Invoke 调用将返回。

在此示例中,Parallel.Invoke 调用将执行四个操作:ParallelInvokeExample 类中名为 DoComplexWork 的另一个方法、一个 lambda 表达式、一个内联声明的 Action 和一个委托。 这是完整的 ParallelInvokeExample 类:

internal class ParallelInvokeExample
{
    internal void DoWorkInParallel()
    {
    Parallel.Invoke(
        DoComplexWork,
        () => {
            Console.WriteLine($"Hello from lambda expression. Thread id:{Thread.CurrentThread.ManagedThreadId}");
        },
        new Action(() =>
        {
            Console.WriteLine($"Hello from Action.
            Thread id: {Thread.CurrentThread.ManagedThreadId}");
        }),
        delegate ()
            {
                Console.WriteLine($"Hello from delegate.
                Thread id: {Thread.CurrentThread.ManagedThreadId}");
            }
        );
    }
    private void DoComplexWork()
    {
        Console.WriteLine($"Hello from DoComplexWork
        method. Thread id: {Thread.CurrentThread.ManagedThreadId}");
    }
}

创建 ParallelInvokeExample 的新实例并从控制台应用程序执行 DoWorkInParallel 将生成类似于以下内容的输出,尽管顺序如下:
操作可能会有所不同:

图 2.1 – DoWorkInParallel 方法产生的输出

image

使用Parallel.ForEach

Parallel.ForEach 可能是 .NET 中 Parallel 类中最常用的成员。 这是因为,在许多情况下,您可以简单地获取标准 foreach 循环的主体并在 Parallel.ForEach 循环中使用它。 但是,在将任何并行性引入代码库时,您必须确保所调用的代码是线程安全的。 如果 Parallel.ForEach 循环的主体修改了任何集合,您将需要采用第 1 章中讨论的同步方法之一,或者使用 .NET 的并发集合之一。 我们将在并发简介部分介绍并发集合。

作为使用 Parallel.ForEach 的示例,我们将创建一个方法,该方法接受数字列表并检查每个数字是否包含在当前时间的字符串表示形式中:

internal void ExecuteParallelForEach(IList<int> numbers)
{
    Parallel.ForEach(numbers, number =>
    {
    bool timeContainsNumber = DateTime.Now.ToLongTimeString().Contains(number.ToString());
    if (timeContainsNumber)
    {
        Console.WriteLine($"The current time contains number {number}. Thread id: {Thread.CurrentThread.ManagedThreadId}");
    }
    else
    {
        Console.WriteLine($"The current time does not contain number {number}. Thread id: {Thread.CurrentThread.ManagedThreadId}"); }
    });
}

以下是从控制台应用程序的 Main 方法对 ExecuteParallelForEach 的调用:

var numbers = new List<int> { 1, 3, 5, 7, 9, 0 };
var foreachExample = new ParallelForEachExample();
foreachExample.ExecuteParallelForEach(numbers);

执行该程序,并检查控制台输出。 您应该看到使用了多个线程来处理循环:

图 2.2 – Parallel.ForEach 循环的控制台输出

image

并行 LINQ 基础知识

本节将介绍向代码添加一些并行性的最简单方法之一。 通过将 AsParallel 方法添加到 LINQ 查询中,您可以将其转换为 PLINQ 查询,并在必要时在线程池上执行 AsParallel 之后的操作。 决定何时使用 PLINQ 时需要考虑许多因素。 我们将在第 8 章中深入讨论这些内容。对于此示例,我们将在 LINQWhere 子句中引入 PLINQ,该子句检查每个给定整数是否为偶数。 为了帮助说明 PLINQ 如何影响序列,还引入了 Task.Delay。 以下是完整的 ParallelLinqExample 类实现:

internal void ExecuteLinqQuery(IList<int> numbers)
{
    var evenNumbers = numbers.Where(n => n % 2 == 0);
    OutputNumbers(evenNumbers, "Regular");
}

internal void ExecuteParallelLinqQuery(IList<int> numbers)
{
    var evenNumbers = numbers.AsParallel().Where(n => IsEven(n));
    OutputNumbers(evenNumbers, "Parallel");
}

private bool IsEven(int number)
{
    Task.Delay(100);
    return number % 2 == 0;
}

private void OutputNumbers(IEnumerable<int> numbers, string loopType)
{
    var numberString = string.Join(",", numbers);
    Console.WriteLine($"{loopType} number string:{numberString}");
}

在控制台应用程序的 Main 方法中,添加一些代码以将整数列表传递给 ExecuteLinqQueryExecuteParallelLinqQuery 方法:

var linqNumbers = new List<int> { 0, 1, 2, 3, 4, 5, 6, 7,
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 };

var linqExample = new ParallelLinqExample();

linqExample.ExecuteLinqQuery(linqNumbers);
linqExample.ExecuteParallelLinqQuery(linqNumbers);

检查输出,您应该看到 PLINQ 序列中数字的顺序已更改:

图 2.3 – LINQ 和 PLINQ 查询的控制台输出

image

并发简介

那么,什么是并发性以及它与 C# 和 .NET 上下文中的并行性有何关系? 这些术语经常互换使用,如果您仔细想想,它们确实具有相似的含义。当多个线程并行执行时,它们就是并发运行。 在本书中,在讨论设计托管线程时应遵循的模式时,我们将使用术语“并发”。此外,我们将在 .NET Framework 4.0 中向 .NET 开发人员引入的并发集合的上下文中讨论并发。 让我们首先了解 System.Collections.Concurrent 命名空间中的并发集合。

.NET 有几个使用内置线程安全创建的集合。 这些集合都可以在 System.Collections.Concurrent 命名空间中找到。 在本节中,我们将
介绍其中的五个集合。 其余三个是 Partitioner 的变体。 这些将在第 9 章中进行探讨,我们将通过实际示例来处理每种集合类型。

ConcurrentBag<T>

ConcurrentBag<T> 集合是一个并发集合,旨在保存无序对象的集合。 允许重复值,当 T 为可空类型时,也允许使用空值。 它是ArrayList<T> 或其他不需要项目排序的 IEnumerable<T> 实例的优秀线程安全替代品。

在内部,ConcurrentBag<T> 为每个添加项目的线程存储项目的链接列表。 当从集合中获取或查看项目时,将优先考虑内部列表,其中包含当前线程添加的项目。 假设线程 1 添加项目 A、B 和 C,线程 2 添加项目 D、E、F 和 G。如果线程 1 调用 TryPeekTryTake 四次,ConcurrentBag<T> 将首先从 A、B 和 C 列表中获取项目,然后再从包含线程 2 项目的链表中获取项目。

以下列表详细介绍了您可能在大多数实现中使用的 ConcurrentBag<T> 的属性和方法:

Add(T):这将一个对象添加到包中。
TryPeek(out T):尝试使用 out 参数从包中获取值,但不会删除该项目。
TryTake(out T):尝试使用 out 参数从包中获取值并将其删除。
Clear():清除包中的所有对象。
Count:返回包中物体的数量。
IsEmpty:返回一个布尔值,指示包是否为空。
ToArray():返回 T 类型的对象数组。

ConcurrentBag<T> 集合有两个构造函数。 一个构造函数不带任何参数,只是创建一个新的空包。 另一个接受要复制到新包的 IEnumerable<T> 类型的对象。

ConcurrentQueue<T>

.NET ConcurrentQueue<T> 集合在实现上与其线程不安全对应项 Queue<T> 类似。 因此,当将托管线程引入现有代码库时,它可以很好地替代 Queue<T>ConcurrentQueue<T> 是一个强类型的对象列表,它强制执行先进先出 (FIFO) 逻辑,这是队列的定义。

先进先出(FIFO)逻辑常见于制造业和仓库管理软件中。 在处理易腐烂的货物时,首先使用最旧的原材料非常重要。 因此,当系统请求该类型的托盘时,最先入库的那些货物托盘应该最先被拉出。

这些是 ConcurrentQueue<T> 类型的常用成员:

Enqueue(T):这会将新对象添加到队列中。
TryPeek(out T):尝试获取队列前面的对象而不将其删除。
TryDequeue(out T):尝试获取队列前面的对象并将其删除。
Clear():这会清除队列。
Count:返回队列中对象的数量。
IsEmpty:返回一个布尔值,指示队列是否为空。
ToArray():以类型 T 的数组形式返回队列中的对象。

ConcurrentBag<T> 集合类似,ConcurrentQueue<T> 集合有两个构造函数:一个无参数,另一个采用 Ienumerable<T> 类型来填充新队列。 接下来我们介绍一个类似的集合:ConcurrentStack<T>

ConcurrentStack<T>

ConcurrentStack<T> 可以被认为是 ConcurrentQueue<T>,但它使用后进先出 (LIFO) 或堆栈逻辑而不是 FIFO。 它支持的操作类似,但它使用 Push 方法而不是 Enqueue,删除项目使用 TryPop 方法而不是 TryDequeueConcurrentStack<T> 集合的另一个优点是它可以在一次操作中添加或删除多个对象。 这些范围操作通过使用 PushRangeTryPopRange 方法来支持。 范围运算采用 T 数组作为参数。

.NET 6 中的 ConcurrentStack<T>ConcurrentQueue<T> 均实现 IReadOnlyCollection<T> 接口。 这意味着一旦创建了集合,它就是只读的,不能重新分配或设置为空。 您只能添加或删除项目或使用 Clear() 清空集合。

BlockingCollection<T>

BlockingCollection<T> 是一个线程安全的对象集合,它实现多个接口,包括 IProducerConsumerCollection<T>IProducerConsumerCollection<T> 接口提供了一组成员,旨在支持需要实现生产者/消费者模式的应用程序。

生产者/消费者模式是一种并发设计模式,其中一组数据由一个或多个生产者线程同时提供。 同时,有一个或多个消费者线程监视并获取正在产生的数据,以并发地消费和处理它。 BlockingCollection 集合是此生产者/消费者模式中的数据存储。您可以在 Wikipedia 上阅读有关生产者/消费者的更多信息:https://en.wikipedia.org/wiki/Producer–consumer_problem。

BlockingCollection<T> 有多种方法和属性来协助生产者/消费者工作流程。 您可以通过调用 CompleteAdding 方法来指示生产者进程已完成将项目添加到集合中。 一旦调用此方法,就无法使用 AddTryAdd 方法将更多项目添加到集合中。 如果您计划在工作流程中使用 CompleteAdding,则最好始终使用 TryAdd 并在将对象添加到集合时检查布尔结果。 如果集合已标记为已完成添加,则调用 Add 将抛出 InvalidOperationException。 此外,您可以检查 IsAddingCompleted 属性以查明是否已调用 CompleteAdding

使用者进程使用 TakeTryTake 方法从 BlockingCollection<T> 中删除项目。 同样,当集合为空时,使用 TryTake 以避免出现任何异常会更安全。 如果已调用 CompleteAdding 并且已从集合中删除所有对象,则 IsCompleted 属性将返回 true

ConcurrentDictionary<TKey, TValue>

您可能已经猜到,在使用托管线程时,ConcurrentDictionary<TKey, TValue>Dictionary<TKey, TValue> 的绝佳替代品。 这两个集合都实现了 IDictionary<TKey, TValue> 接口。 该集合的并发版本添加了以下并发处理数据的方法:

TryAdd:尝试将新的键/值对添加到字典中,并返回一个布尔值,指示对象是否已成功添加到字典中。 如果键已经存在,该方法将返回 false。
TryUpdate:此操作传递一个键以及该项目的现有值和新值。如果现有项目存在于字典中并且提供了现有值,它将把现有项目更新为新值。 返回的布尔值指示字典中的对象是否已成功更新。
AddOrUpdate:此方法将根据键是否存在添加或更新字典中的项目,并使用更新委托根据项目的当前值和新值执行任何逻辑。
GetOrAdd:如果键尚不存在,此方法将添加一个项目。 如果存在,则返回现有值。

这些是 .NET 中需要理解的最重要和最常见的并发集合。 稍后我们将介绍每个集合的一些示例,并了解 System.Collections.Concurrent 中的更多集合,但这部分应该为理解即将发生的内容提供坚实的基础。

async and await的基础知识

当 .NET Framework 4.5 中引入 TPL 时,C# 5.0 还使用 asyncwait 关键字添加了对基于任务的异步编程的语言支持。 这立即成为在 C# 和 .NET 中实现异步工作流的默认方法。 10 年后的今天,async/await 和 TPL 已成为构建健壮、可扩展的 .NET 应用程序不可或缺的一部分。 您可能想知道为什么在应用程序中采用异步编程如此重要。

理解 async 关键字

编写异步代码的原因有很多。 如果您在 Web 服务器上编写服务器端代码,则使用异步允许服务器在您的代码等待长时间运行的操作时处理其他请求。 在客户端应用程序上,释放 UI 线程以使用异步代码执行其他操作可以让您的 UI 保持对用户的响应。

在 .NET 中采用异步编程的另一个重要原因是许多第三方和开源库都在使用 async/await。 甚至 .NET 本身在每个版本中都会将更多 API 公开为异步,尤其是涉及 IO 操作的 API:网络、文件和数据库访问。

编写异步方法

创建和使用异步方法很容易。 让我们尝试一个使用新控制台应用程序的简单示例:

  1. 在 Visual Studio 中创建一个新的控制台应用程序并将其命名为 AsyncConsoleExample
  2. 在项目中添加一个类,命名为NetworkHelper,并在该类中添加以下方法:
internal async Task CheckNetworkStatusAsync()
{
    Task t = NetworkCheckInternalAsync();
    for (int i = 0; i < 8; i++)
    {
        Console.WriteLine("Top level method working...");
        await Task.Delay(500);
    }
    await t;
}

private async Task NetworkCheckInternalAsync()
{
    for (int i = 0; i < 10; i++)
    {
        bool isNetworkUp = System.Net.NetworkInformation
            .NetworkInterface 
            .GetIsNetworkAvailable();
        Console.WriteLine($"Is network available? Answer: {isNetworkUp}");
        await Task.Delay(100);
    }
}

前面的代码中有几点需要指出。 这两个方法都有 async 修饰符,表明它们将等待一些工作并将异步运行。在方法内部,我们使用 await 关键字来调用 Task.Delay。这将确保在等待的方法完成之前不会执行此点之后的任何代码。 然而,在此期间,活动线程可以被释放以在其他地方执行其他工作。

最后,查看对 NetworkCheckInternalAsync 的调用。 我们不是等待这个调用,而是在名为 t 的变量中捕获返回的 Task 实例,并且直到 for 循环之后才等待它。 这意味着两种方法中的 for 循环将同时运行。 相反,如果我们等待 NetworkCheckInternalAsync,则其 for 循环将在 CheckNetworkStatusAsync 中的 for 循环开始之前完成。

  1. 接下来,将 Program.cs 中的代码替换为以下内容:
using AsyncConsoleExample;

Console.WriteLine("Hello, async!");
var networkHelper = new NetworkHelper();
await networkHelper.CheckNetworkStatusAsync();

Console.WriteLine("Main method complete.");
Console.ReadKey();

我们正在等待对 CheckNetworkStatusAsync 的调用。 这是可能的,因为 .NET 6 控制台应用程序中的默认 Main 方法默认是异步的。 如果您尝试在未标记为异步的方法中等待某些内容,您将收到编译器错误。 在第 5 章中,我们将探讨当您必须从现有非异步代码调用异步方法时可以使用的一些选项。

  1. 最后,运行应用程序并检查输出:

图 2.4 – 异步示例应用程序的控制台输出

image-20230723094351919

正如预期的那样,捕获异步方法的结果允许两个循环同时运行。 尝试等待对 NetworkCheckInternalAsync 的调用并查看输出如何变化。 您应该看到私有方法的所有输出都将出现在 CheckNetworkStatusAsync 中的 for 循环的输出开始之前。

这是对 C# 异步编程世界的简要介绍。 在本书的其余部分中,我们将大量使用它。 最后,让我们讨论一下在构建新项目或增强现有应用程序时如何选择要利用的选项。

选择正确的前进道路

现在您已经了解了一些高级托管线程概念、并行编程、并发集合和异步/等待范例,让我们讨论一下它们如何在现实世界中结合在一起。 在 .NET 中选择正确的多线程开发路径通常涉及多个概念。

使用 .NET 6 时,您通常应该选择在项目中创建异步方法。本章讨论的原因非常引人注目。 异步编程使客户端和服务器应用程序保持响应,并且异步在 .NET 本身中广泛使用。

当您的代码需要快速处理一组项目并且执行处理的底层代码是线程安全的时,可以利用一些并行类操作。 这是可以引入并发集合的地方。 如果任何并行或异步操作正在操作共享数据,则数据应存储在 .NET 并发集合之一中。

如果您正在使用现有代码,通常最谨慎的方法是限制添加的多线程代码量。 诸如此类的遗留项目是增量添加一些线程池或并行操作并测试结果的好地方。 测试应用程序的功能和性能非常重要。 第 10 章将介绍托管线程的性能测试工具。

posted @ 2023-10-13 21:17  F(x)_King  阅读(19)  评论(0编辑  收藏  举报