C#9 和 .NET5 企业级应用开发(一)

VB.NET与C#双版本:企业级共享目录访问的5个避坑要点 本文针对企业级开发中VB.NET与C#访问网络共享目录的常见问题,详细解析了身份验证、资源管理、路径处理等5个关键避坑要点。重点阐述了如何根据域/非域环境正确选择LOGON32_LOGON_TYPE登录类型,并提供了双版本工具类的封装思路与实现示例,确保访问的稳定与安全。 阅读详情

原文:Enterprise Application Development with C# 9 and .NET 5

协议:CC BY-NC-SA 4.0

零、前言

.NET 5 是一个开源的免费平台,可以编写针对任何平台的应用。该平台还为您提供了轻松编写应用的机会,面向任何平台,包括云。作为软件开发人员,我们被赋予构建复杂企业应用的责任。在本书中,我们将学习使用 C# 9 和构建企业应用的各种高级架构和概念.NET 5。这本书将作为使用构建企业应用所需的所有功能的圣经.NET 5。

通过对基本概念、实例和自我评估问题的逐步解释,您将深入了解和接触到的每个重要组成部分.NET 5 需要构建一个专业的企业应用。

这本书是给谁的

这本书是为已经熟悉的中级到专家级开发人员准备的。. NET 框架或.NET Core 和 C#。

这本书涵盖了什么

第 1 章设计和架构企业应用,首先讨论了常用的企业架构和设计模式,然后介绍了将企业应用设计和架构为由 UI 层、服务层和数据库组成的三层应用。

第二章介绍.NET 5 Core 和 Standard ,从我们意识到运行时是代码运行的地方开始。在本章中,您将了解的核心和高级概念.NET 5 核心运行时组件。

第三章介绍 C# 9 ,谈 C# 9 中的新功能,随发布.NET 5。

第四章线程和异步操作,帮助你详细了解线程、线程池、任务和async await以及如何.NET Core 允许您构建异步应用。

第五章依存注入.NET ,帮助我们理解什么是依赖注入,以及为什么每个开发人员都涌向依赖注入。我们将学习依赖注入是如何在中工作的.NET 5 并列出其他可用的选项。

第六章配置中.NET Core ,教你如何配置.NET 5,并在应用中使用配置和设置。您还将了解如何扩展?NET 5 配置来定义您自己的部分、处理程序、提供程序等等。

第七章登陆.NET 5 ,讨论事件和日志 API.NET 5。我们还将深入研究使用 Azure 和 Azure 组件进行日志记录,并学习如何进行结构化日志记录。

第 8 章了解缓存,讨论中可用的缓存组件.NET 5 和最佳行业模式和实践。

第九章处理数据.NET 5 ,讨论了两种可能的数据提供者:SQL 和像 RDMS 这样的数据。我们还将在高级别讨论如何使用 NoSQL 数据库进行存储和数据处理.NET 5。本章将讨论.NET Core 与文件、文件夹、驱动器、数据库和内存的接口。

第十章创建 ASP.NET Core 5 Web API,使用 ASP.NET Core 5 Web API 模板开发我们企业应用的服务层。

第十一章创建 ASP.NET Core 5 Web 应用,使用 ASP.NET Core 5 MVC Web 应用模板和 Blazor 开发我们企业应用的 Web 层。

第 12 章了解身份验证,讨论了行业中最常见的身份验证模式,以及如何使用实现它们.NET 5。我们还将介绍如何实现自定义身份验证。

第十三章了解授权,讨论了不同的授权方式,以及 ASP.NET Core 5 让你如何处理。

第 14 章健康与诊断讨论了监控应用健康的重要性,为其构建了健康检查 API.NET Core 应用和 Azure 应用,用于捕获遥测数据和诊断问题。

第十五章测试,论述了测试的重要性。测试是开发中必不可少的一部分,没有适当的测试,任何应用都无法交付,因此我们也将讨论如何对代码进行单元测试。我们还将学习如何衡量应用的性能。

第 16 章在 Azure 中部署应用,讨论了应用在 Azure 中的部署。我们将把我们的代码签入到我们选择的源代码控制中,然后 CI/CD 管道将启动并在 Azure 中部署应用。

为了充分利用这本书

你需要.NET 5 SDK 安装在您的系统上;所有代码示例都是在 Windows 操作系统上使用 Visual Studio 2019/Visual Studio Code 进行测试的。建议使用活动的 Azure 订阅来进一步部署企业应用。可以从https://azure.microsoft.com/en-in/free/创建一个免费账户:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/B15927_Table_1.jpg

下载示例代码文件

可以从https://GitHub . com/PacktPublishing/Enterprise-Application-Development-with-C-Sharp-9-and-下载本书的示例代码文件.NET-5 。如果代码有更新,它将在现有的 GitHub 存储库中更新。

我们还有来自 https://github.com/PacktPublishing/丰富的书籍和视频目录的其他代码包。看看他们!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。可以在这里下载:https://static . packt-cdn . com/downloads/9781800209442 _ color images . pdf

使用的约定

本书通篇使用了许多文本约定。

Code in text:表示文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和推特句柄。这里有一个例子:“这里,如果你在ImageFile类的构造函数中放一个断点,只有当System.Lazy类的Value方法被调用时,它才会被命中。”

代码块设置如下:

Lazy<ImageFile> imageFile = new 
 Lazy<ImageFile>(() => new ImageFile("test"));
var image = imageFile.Value.LoadImage;

当我们希望将您的注意力吸引到代码块的特定部分时,相关的行或项目以粗体显示:

Host.CreateDefaultBuilder(args)
    .ConfigureAppConfiguration((context, 
 config) =>
 {
 config.AddSql("Connection 
 string","Query"); 
 })
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder.UseStartup<Startup>();
    });

任何命令行输入或输出都编写如下:

dotnet new classlib -o MyLibrary

粗体:表示一个新的术语、一个重要的单词或者你在屏幕上看到的单词。例如,菜单或对话框中的单词像这样出现在文本中。下面是一个例子:“在安装选项中,从工作负载中,选择**.NET Core 跨平台开发**为.NET Core 应用,如下图所示。”

提示或重要注意事项

像这样出现。

取得联系

我们随时欢迎读者的反馈。

一般反馈:如果你对这本书的任何方面有疑问,在你的信息主题中提到书名,发邮件给我们customercare@packtpub.com。

勘误表:虽然我们已经尽了最大的努力来保证内容的准确性,但是错误还是会发生。如果你在这本书里发现了一个错误,如果你能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的图书,点击勘误表提交链接,并输入详细信息。

盗版:如果您在互联网上遇到任何形式的我们作品的非法拷贝,如果您能提供我们的位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com联系我们,并提供材料链接。

如果你有兴趣成为一名作者:如果有一个你有专长的话题,你有兴趣写或者投稿一本书,请访问authors.packtpub.com。

评论

请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?然后,潜在的读者可以看到并使用您不带偏见的意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们的书的反馈。谢谢大家!

更多关于 Packt 的信息,请访问packt.com。

一、设计和构建企业应用

企业应用是为企业组织解决大型复杂问题而设计的软件解决方案。它们为信息技术、政府、教育和公共部门的企业客户提供订单到履行功能,并使他们能够通过产品购买、支付处理、自动计费和客户管理等功能实现业务数字化转型。企业应用的集成数量非常多,用户数量也非常多,因为此类应用通常面向全球受众。为了确保企业系统保持高可靠性、高可用性和高性能,正确的设计和架构非常重要。设计和架构是任何好软件的基础。它们构成了软件开发生命周期剩余部分的基础,因此,正确地进行并避免以后的任何返工是非常重要的,根据所需的更改,这可能会非常昂贵。因此,您需要灵活、可扩展、可扩展和可维护的设计和架构。

在本章中,我们将涵盖以下主题:

  • 通用设计原则和模式入门
  • 了解常见的企业架构
  • 确定企业应用需求(业务和技术)
  • 设计企业应用
  • 企业应用的解决方案结构

到本章结束时,您将能够遵循正确的设计原则来设计和构建应用。

技术要求

你需要对……有一个基本的了解.NET Core、C#和 Azure。

常见设计原则和模式的入门

世界上的每一个软件都解决了一个或另一个现实世界的问题。随着时间的推移,事情发生了变化,包括我们对任何特定软件的期望。为了管理这种变化并处理软件的各个方面,工程师们开发了许多编程范例、框架、工具、技术、过程和原则。随着时间的推移,这些原则和模式已经被证明是工程师们用来协作和构建高质量软件的指路之星。

我们将在本章中讨论面向对象编程(OOP ),这是一种基于“对象”概念及其状态、行为和相互作用的范式。我们还将介绍一些常见的设计原则和模式。

原则是设计时要遵循的高级抽象准则;无论使用何种编程语言,它们都是适用的。它们没有提供实施指南。

模式是低层次的特定实现指南,是针对重复出现的问题的经过验证的、可重用的解决方案。我们先从设计原则说起。

设计原则

如果技术被广泛接受、实践并被证明在任何行业都有用,它们就会成为原则。这些原则成为使软件设计更容易理解、更灵活、更易维护的解决方案。我们将在这一部分介绍固体、KISS 和干燥设计原则。

固体

固体原则是由美国软件工程师兼讲师罗伯特·c·马丁提出的许多原则的子集。这些原则已经成为面向对象世界中事实上的标准原则,并且已经成为其他方法和范例的核心哲学的一部分。

固体是以下五个原则的缩写:

  1. Single responsibility principle (SRP): An entity or software module should only have a single responsibility. You should avoid granting one entity multiple responsibilities:

    https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_1.1_B15927.jpg

    图 1.1-SRP

  2. Open-closed principle (OCP): Entities should be designed in such a way that they are open for extension but closed for modification. This means regression testing of existing behaviors can be avoided; only extensions need to be tested:

    https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/FIgure_1.2_B15927.jpg

    图 1.2–OCP

  3. Liskov substitution principle (LSP): Parent or base class instances should be replaceable with instances of their derived classes or subtypes without altering the sanity of the program:

    https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/FIgure_1.3_B15927.jpg

    图 1.3–LSP

  4. Interface segregation principle (ISP): Instead of one common large interface, you should plan multiple, scenario-specific interfaces for better decoupling and change management:

    https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/FIgure_1.4_B15927.jpg

    图 1.4–互联网服务提供商

  5. 依赖倒置原则 ( DIP ):应该避免对具体实现有任何直接依赖。高级模块和低级模块不应该直接相互依赖,相反,两者应该尽可能依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/FIgure_1.5_B15927.jpg

图 1.5–DIP

不要重复自己(干)

有了 DRY,一个系统的设计应该使得一个特性或一个模式的实现不应该在多个地方重复。这将导致维护开销,因为需求的变化将导致需要在多个地方进行修改。如果您错误地在一个地方没有进行必要的更新,系统的行为将变得不一致。相反,该特性应该被打包成一个包,并且应该在所有地方重用。对于数据库,您应该考虑使用数据规范化来减少冗余:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_1.6_B15927.jpg

图 1.6–干燥

这个策略有助于减少冗余和促进重用。这个原则也有助于组织的文化,鼓励更多的合作。

保持简单,愚蠢(KISS)

有了 KISS,一个系统应该是设计得尽可能简单,避免复杂的设计、算法、新的未尝试的技术等等。您应该专注于利用正确的面向对象概念,并重用经验证的模式和原则。只有在必要时才包含新的或非简单的东西,并为实现增加价值。

当您保持简单时,您将能够更好地完成以下工作:

  • 设计/开发时避免错误
  • 保持列车运行(总有一个团队的工作是维护系统,尽管他们不是最初开发系统的团队)
  • 阅读并理解您的系统和代码(您的系统和代码需要能够被新用户或未来使用它的人理解)
  • 做得更好,不容易出错的变更管理

有了这些,我们就完成了普通设计原理的入门;我们已经学习了固体、干燥和 KISS。在下一节中,我们将在现实世界的例子中查看一些常见的设计模式,以帮助您理解原则和模式之间的区别,以及何时利用哪种模式,这是优秀设计和架构必不可少的技能。

设计图案

在遵循 OOP 范式中的设计原则时,您可能会看到相同的结构和模式一遍又一遍地重复。这些重复的结构和技术是常见问题的成熟解决方案,被称为设计模式。经验证的设计模式易于重用、实现、更改和测试。著名的书籍设计模式:可重用面向对象软件的元素,包括被称为四人组 ( GOF )的设计模式,被认为是模式的圣经。

我们可以将 GOF 模式分类如下:

  • 创意:有助于创建对象
  • 结构性:有助于处理物体的构成
  • 行为:有助于定义对象之间的交互和分配责任

让我们用一些现实生活中的例子来看看模式。

创新设计模式

让我们看看一些创造性设计模式以及下表中的相关例子:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/B15927_01_Table_1.jpg

表 1.1

结构设计模式

下表包括一些结构设计模式的例子:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/B15927_01_Table_2.jpg

表 1.2

行为设计图案

下表包括一些行为设计模式的示例:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/B15927_01_Table_3.jpg

表 1.3

有时候,你可能会被摆在桌面上的所有这些模式淹没,但实际上,任何设计都是好的设计,直到它违反了基本原则。我们可以使用的一个经验法则是回到基础,在设计中,原则是基础。

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_1.7_B15927.jpg

图 1.7–模式与原则

这样,我们就完成了普通设计原则和模式的入门。到目前为止,您应该已经很好地理解了不同的原则和模式,在哪里使用它们,以及构建一个伟大的解决方案需要什么。现在让我们花一些时间来看看常见的企业架构。

了解常见的企业架构

在设计企业应用时,通常会实践一些原则和架构。首先,任何架构的目标都是以尽可能低的成本(时间和资源成本)支持业务需求。企业希望软件能够支持它,而不是成为瓶颈。在当今世界,可用性、可靠性和性能是任何系统的三个关键绩效指标。

在这一节中,我们将首先研究单块架构的问题,然后我们将了解如何避免使用广泛采用且经过验证的架构来开发企业应用。

考虑一个经典的整体式电子商务网站应用,如下图所示,所有业务提供商和功能都在一个应用中,数据存储在一个经典的 SQL 数据库中:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_1.8_B15927.jpg

图 1.8–单片应用

单片架构在 15-20 年前被广泛采用,但是随着时间的推移,当系统增长和业务需求扩展时,软件工程团队会遇到很多问题。让我们看看这种方法的一些常见问题。

单一应用的常见问题

让我们来看看缩放问题:

  • 在单一应用中,水平扩展的唯一方法是向系统添加更多计算。这导致更高的运营成本和未优化的资源利用率。有时,由于资源方面的需求冲突,扩展变得不可能。
  • 由于所有功能大多使用单个存储,因此存在锁导致高延迟的可能性,并且单个存储实例的扩展范围也存在物理限制。

以下是一些与可用性、可靠性和性能相关的问题:

  • 系统的任何变化都需要重新部署所有组件,导致停机和低可用性。
  • 任何非持久状态,如存储在网络应用中的会话,在每次部署后都会丢失。这将导致放弃用户触发的所有工作流。
  • 模块中的任何错误,如内存泄漏或安全错误,都会使所有模块变得脆弱,并有可能影响整个系统。
  • 由于模块内资源的高度耦合性和共享性,总是会出现资源的非优化使用,导致系统的高延迟。

最后,让我们看看对业务和工程团队的影响:

  • 变更的影响很难量化,需要广泛的测试。因此,它降低了交付生产的速度。即使是很小的改变也需要重新部署整个系统。
  • 在一个高度耦合的系统中,跨团队协作交付任何功能总是有物理限制的。
  • 移动应用、聊天机器人和分析引擎等新场景将需要更多的努力,因为没有独立的可重用组件或服务。
  • 连续部署几乎不可能。

让我们通过采用一些成熟的原则/架构来尝试解决这些常见问题。

关注点分离/单一责任架构

软件应根据其执行的工作类型划分为组件或模块。每个模块或组件都应该有一个单一的职责。组件之间的交互应该通过接口或消息传递系统进行。让我们看看 n 层和微服务架构,以及如何处理关注点分离。

多层体系结构

n 层架构将系统的应用划分为三个(或 n 层:

  • 呈现(称为 UX 层、用户界面层或工作表面)
  • 业务(称为业务规则层或服务层)
  • 数据(称为数据存储和访问层)

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/FIgure_1.9_B15927.jpg

图 1.9–N 层架构

这些层可以单独拥有/管理/部署。例如,多个表示层,如 web、移动和 bot 层,可以利用相同的业务和数据层。

微服务架构

微服务架构由小型、松散耦合、独立和自治的服务组成。让我们看看它们的好处:

  • 服务可以独立部署和扩展。一个服务中的问题会对本地产生影响,只需部署受影响的服务即可解决。不需要共享技术或框架。
  • 服务通过定义良好的应用编程接口或消息传递系统(如 Azure 服务总线)相互通信:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/FIgure_1.10_B15927.jpg

图 1.10–微服务架构

如上图所示,服务可以由独立的团队拥有,并且可以有自己的周期。服务负责管理自己的数据存储。要求更低延迟的场景可以通过引入缓存或高性能 NoSQL 存储来优化。

领域驱动架构

每个逻辑模块不应直接依赖于另一个模块。每个模块或组件应该服务于单个域。

围绕一个域建模服务可以防止服务爆炸。模块应该是松散耦合的,可能一起改变的模块可以组合在一起。

无状态服务架构

服务不应该有任何状态。状态和数据应该独立于服务进行管理,即在外部进行管理。通过向外部委托状态,服务将有资源以高可靠性服务更多的请求。

不应启用会话关联性,因为它会导致粘性会话问题,并会阻止您获得负载平衡、可伸缩性和流量分布的好处。

事件驱动架构

事件驱动架构的主要特点如下:

  • 在事件驱动架构中,模块之间的通信(通常称为发布者-订阅者通信)主要是异步的,并且通过事件来实现。生产者和消费者是完全分离的。事件的结构是他们之间交换的唯一契约。
  • 同一事件可以有多个消费者负责他们的特定操作;理想情况下,他们甚至不会意识到对方。生产者可以持续推动事件,而不用担心消费者的可用性。
  • 发布者通过消息传递基础设施(如队列或服务总线)发布事件。发布事件后,消息传递基础结构负责将事件发送给符合条件的订户:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/FIgure_1.11_B15927.jpg

图 1.11–事件驱动架构

这种架构最适合本质上异步的场景。例如,长时间运行的操作可以排队等待处理。客户端可能会轮询状态,甚至充当事件的订阅者。

数据存储和访问架构

数据存储和访问架构在整个系统的可扩展性、可用性和可靠性方面发挥着至关重要的作用:

  • 服务应该根据操作的需要来决定数据存储的类型。
  • 应该根据给定操作的需要对数据进行分区和建模。无论如何都应该避免热分区。如果您需要来自同一数据的多种类型的结构,应该选择复制。
  • 应该选择正确的一致性模型来降低延迟。例如,能够承受一段时间的陈旧数据的操作应该使用弱/最终一致性。有可能改变状态并需要实时数据的操作应该选择更强的一致性。
  • 缓存适合服务的数据有助于服务的性能。应该确定可以缓存数据的区域。根据给定的需要,可以选择内存内或内存外缓存。

弹性架构

组件之间的通信越多,发生故障的可能性也越大。一个系统应该被设计成能从任何类型的故障中恢复。我们将介绍一些构建容错系统的策略,该系统可以在出现故障时自我修复。

如果您熟悉 Azure,您会知道应用、服务和数据应该在至少两个 Azure 区域中进行全局复制,以应对计划内停机和计划外的暂时或永久故障。在这些场景中,选择 Azure 应用服务来托管网络应用、使用 REST APIs 以及选择一个全球分布式数据库服务(如 Azure Cosmos DB)是明智的。选择 Azure 成对区域将有助于业务连续性和灾难恢复 ( BCDR ),因为如果停机影响多个区域,每对区域中至少有一个区域将优先恢复。现在,让我们看看如何处理不同类型的故障。

瞬时故障可能发生在任何类型的通信或服务中。您需要有一个从瞬时故障中恢复的策略,例如:

  • 识别瞬时故障的操作和类型,然后确定适当的重试次数和时间间隔。
  • 避免反模式,如具有有限重试次数的无限重试机制或断路器。

如果故障不是暂时的,您应该通过选择以下选项来优雅地响应故障:

  • 失败
  • 补偿任何失败的操作
  • 抑制/阻止不良客户端/参与者
  • 在失败的情况下使用领导人选举来选择领导人

遥测在这里起着很大的作用;您应该有自定义的指标来记录任何组件的运行状况。当自定义事件发生或特定指标达到特定阈值时,可以发出警报。

进化和运营架构

演进和运营在持续集成、部署、阶段性功能推出以及减少停机时间和成本方面发挥着至关重要的作用:

  • 服务应该独立部署。
  • 设计一个可扩展的生态系统,使企业能够随着时间的推移而发展和变化。
  • 松散耦合的系统最适合企业,因为任何变更或特性都可以以良好的速度和质量交付。变更可以被管理并限定在单个组件的范围内。
  • 规模的弹性导致更好的资源管理,进而降低运营成本。
  • 持续的构建和发布管道以及蓝绿色的部署策略有助于在系统早期识别问题。这也使得能够在减少生产流量的情况下测试某些假设。

这样,我们就完成了对常见企业架构的覆盖。接下来,我们将通过我们所了解的设计原则和常见架构的镜头来看看企业应用需求和不同的架构。

识别企业应用需求(业务和技术)

在接下来的几章中,我们将构建一个工作的电子商务应用。它将是一个三层应用,由用户界面层、服务层和数据库组成。让我们看看这个电子商务应用的需求。

解决方案需求是要在产品中实现并可用的解决问题或实现目标的能力。

业务需求只是最终客户的需求。在 IT 界,“业务”一般指“客户”这些需求是从不同的利益相关者那里收集的,并作为单一的真实来源记录下来,供大家参考。这最终成为待完成工作的积压和范围。

技术要求是系统应该实现的技术方面,例如可靠性、可用性、性能和 BCDR。这些都是又称服务质量 ( QOS )的要求。

让我们将电子商务应用网站的典型业务需求细分为以下几类:史诗级、特色和用户故事。

应用的业务需求

以下来自 Azure DevOps 的截图显示了我们业务需求的积压汇总。您可以看到我们的应用中预期的不同特性以及用户故事:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_1.12_B15927.jpg

图 1.12–Azure DevOps 的需求积压

应用的技术要求

看了业务需求,现在来看技术需求:

  • 电商应用应该是高可用,即任何 24 小时周期 99.99%的时间可用。
  • 电商应用应该是高可靠,即任何 24 小时周期 99.99%的时间是可靠的。
  • 电子商务应用应该是高性能的 : 95%的操作应该用时小于等于 3 秒。
  • 电子商务应用应该是高度可扩展的:它应该根据变化的负载自动上/下扩展。
  • 电子商务应用应该有监控和警报:如果出现任何系统故障,应该向支持工程师发送警报。

以下是为电子商务应用确定的技术方面及其要求:

前端
  • 使用 ASP.NET 5.0 的网络应用(电子商务)
核心组件
  • C# 9.0 和中的日志/缓存/配置.NET 5.0
中间层
  • 实现身份验证的 Azure 应用编程接口网关
  • 通过 ASP.NET 5.0 网络应用编程接口添加/删除用户的用户管理服务
  • 通过 ASP.NET 5.0 网络应用编程接口提供产品和定价服务,从数据存储中获取产品
  • 通过 ASP.NET 5.0 网络应用编程接口获取域数据(如国家数据)的域数据服务。
  • 通过 ASP.NET 5.0 网络应用编程接口完成支付的支付服务
  • 通过 ASP.NET 5.0 网络应用编程接口提交和搜索订单的订单处理服务
  • 通过 ASP.NET 5.0 网络应用编程接口生成发票的发票处理服务
  • 通知服务通过 ASP.NET 5.0 网络应用编程接口发送电子邮件等通知
数据层
  • 数据访问服务,通过 ASP.NET 5.0 网络应用编程接口与 Azure 宇宙数据库进行对话,以读取/写入数据
  • 实体框架访问数据的核心
天蓝色堆栈
  • Azure 宇宙数据库作为后端数据存储
  • 异步消息处理的 Azure 服务总线
  • 承载网络应用和网络应用接口的 Azure 应用服务
  • Azure 流量管理器可实现高可用性和高响应性
  • 用于诊断和遥测的 Azure 应用洞察
  • 天蓝色配对区域可获得更好的弹性
  • Azure 资源组创建 Azure 资源管理器 (ARM)模板并部署到 Azure 订阅
  • 用于持续集成和持续部署的 azure Pipelines(CI/CD)

我们现在已经完成了企业应用需求。接下来,我们将研究企业应用的架构。

架构企业应用

下面的架构图描述了我们正在构建的东西。当我们设计和开发应用时,我们需要记住我们在本章中看到的所有设计原则、模式和需求。下图显示了我们的电子商务企业应用的建议架构图:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/FIgure_1.13_B15927.jpg

图 1.13–我们的电子商务应用的三层架构图

关注点/SRP 的分离在每一层都得到了处理。包含用户界面的表示层与包含业务逻辑的服务层分开,后者又与包含数据存储的数据访问层分开。

高级组件不知道使用它们的低级组件。数据访问层不知道使用它的服务,服务也不知道使用它们的 UX 层。

每个服务都根据它应该执行的业务逻辑和功能进行分离。

封装已经在架构级别得到了的处理,在开发过程中也应该得到处理。架构中的每个组件都将通过定义良好的接口和契约与其他组件进行交互。如果符合契约,我们应该能够替换图中的任何组件,而不用担心它的内部实现。

这里松散耦合的架构也有助于客户更快地开发和部署到市场。多个团队可以独立地并行处理每个组件。他们在开始时共享集成测试的合同和时间表,一旦内部实现和单元测试完成,他们就可以开始集成测试。

请参考下图:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/FIgure_1.14_B15927.jpg

图 1.14–我们的电子商务应用组件,按章节细分

从图中,我们确定了我们将构建的电子商务应用的不同部分将涵盖的章节,解释如下:

  • 创建 ASP.NET 网络应用(我们的电子商务门户)将作为第 11 章创建 ASP.NET Core 5 网络应用的一部分进行介绍。
  • 认证将作为第 12 章理解认证的一部分。
  • 订单处理服务和发票处理服务是生成订单和开具发票的两个核心服务。他们将是电子商务应用的核心,因为他们是负责收入的人。创建 ASP.NET Core web API 将作为第 10 章的一部分,创建 ASP.NET Core 5 Web API 将作为第 5 章依赖注入的一部分.NET 、 第六章、、配置中.NET Core、第七章登录.NET 5、分别为。DRY 原则将通过重用核心组件和交叉关注来处理,而不是重复实现。
    ** 在 第 8 章了解缓存中,缓存将作为产品定价服务的一部分。缓存将有助于提高我们系统的性能和可伸缩性,经常访问的数据的临时副本在内存中可用。
    数据存储、访问和提供者将作为数据访问层的一部分包含在 第 9 章中.NET 5 。我们采用的体系结构将数据和对数据的访问与应用的其他部分分开,这种体系结构为我们提供了更好的维护。Azure Cosmos DB 是我们的选择,可在全球任意数量的 Azure 地区弹性、独立地扩展吞吐量和存储。默认情况下,它也是安全的,企业级的。

*我们关于构建企业应用的讨论到此结束。接下来,我们将看看我们企业应用的解决方案结构。

企业应用的解决方案结构

我们将为所有项目提供一个单一的解决方案,以保持简单,如下图所示。当解决方案中的项目数量激增并导致维护问题时,也可以考虑为用户界面、共享组件、网络应用编程接口等提供单独解决方案的另一种方法。下面的截图显示了我们应用的解决方案结构:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/FIgure_1.15_B15927.jpg

图 1.15–电子商务应用的解决方案结构

总结

在这一章中,我们学习了常见的设计原则,如固体、干燥和 KISS。我们还用现实世界的例子研究了各种设计模式。然后,我们查看了不同的企业架构,确定了我们将要构建的电子商务应用的需求,并应用我们所学的知识来构建我们的电子商务应用。现在,当您设计任何应用时,您都可以应用在这里学到的知识。在下一章中,我们将了解.NET 5 核心和标准。

问题

  1. What is the LSP?

    a.基类实例应该可以用其派生类型的实例替换。

    b.派生类实例应该可以用其基类型的实例替换。

    c.设计可以处理任何数据类型的泛型。

  2. What is the SRP?

    a.不要有一个通用的大型接口,而是计划多个场景特定的接口,以实现更好的解耦和变更管理。

    b.您应该避免直接依赖于具体的实现;相反,你应该尽可能依赖抽象。

    c.一个实体应该只有单一的责任。你应该避免给一个实体多重责任。

    d.实体应该以这样的方式设计,它们应该对扩展开放,但对修改关闭。

  3. What is the OCP?

    a.实体应该对修改开放,但对扩展关闭。

    b.实体应该对扩展开放,但对修改关闭。

    c.实体应该对组合开放,但对扩展关闭。

    d.实体应该对抽象开放,但对继承关闭。

  4. Which pattern is used to make two incompatible interfaces work together?

    a.代理

    b.桥

    c.迭代程序

    d.介面卡

  5. Which principle ensures that services can be deployed and scaled independently and that an issue in one service will have a local impact and can be fixed by just redeploying the impacted service?

    a.领域驱动设计原则

    b.单一责任原则

    c.无状态服务原则

    d.弹性原则*

二、.NET 5 核心和标准简介

.NET 是一个开发人员平台,提供构建许多不同类型应用的库和工具,如 web、桌面、移动、游戏、物联网 ( 物联网)和云应用。使用.NET,我们可以开发针对很多操作系统的应用,包括 Windows、macOS、Linux、Android、iOS 等等,它支持 x86、x64、ARM32、ARM64 等处理器架构。

.NET 还支持使用多种编程语言进行应用开发,如 C#、Visual Basic 和 F#,使用流行的集成开发环境 ( IDEs )如 Visual Studio、Visual Studio Code 和 Visual Studio for Mac。

之后.NET Core 3.1,.NET 5 现在是一个主要版本,包括 C# 9 和 F# 5,具有许多新特性和性能改进。

本章涵盖以下主题:

  • 介绍.NET 5
  • 了解的核心组成部分.NET 5
  • 了解命令行界面
  • 什么是?NET 标准?
  • 理解.NET 5 跨平台和云应用支持

本章将帮助我们了解中包含的一些核心组件、库和工具.NET 开发应用。

技术要求

需要一台 Windows、Linux 或 Mac 机器。

介绍.NET 5

2002 年,微软发布了第一个版本的.NET 框架,一个开发 web 和桌面应用的开发平台。.NET Framework 提供了很多服务,包括托管代码执行、通过基类库的大量 API、内存管理、通用类型系统、语言互操作性,以及开发框架,如 ADO.NET、ASP.NET、WCF、WinForms、Windows Presentation Framework(**【WPF】**等。最初,它是作为一个单独的安装程序发布的,但后来它被集成并与 Windows 操作系统一起提供。。. NET Framework 4.8 是的最新版本.NET 框架。

2014 年,微软宣布开放源代码,跨平台实现.NET 调用.NET Core。.NET Core 是从零开始构建的,以使其跨平台,目前在 Linux、macOS 和 Windows 上都有。.NET Core 是快速和模块化的,并提供并行支持,因此我们可以运行不同版本的.NET Core 在同一台机器上运行而不影响其他应用。

.NET 5 是一个开源的、跨平台的实现.NET,您可以用它构建可以在 Windows、macOS 和 Linux 操作系统上运行的控制台、网络、桌面和云应用。.NET 5 是.NET 统一之旅,包括许多库、工具/SDK 以及运行时和性能改进。

接下来,让我们了解. NET 的核心特性

了解核心特性

以下是的几个核心特性.NET,我们将更深入地了解:

  • 开源:.NET 是一个免费的(没有许可费用,包括商业用途)开源的开发者平台,为 Linux、macOS 和 Windows 提供了许多开发工具。其源代码由微软和.NET 社区。您可以访问.NET 资源库,网址为。
  • 跨平台:.NET 应用运行在许多操作系统上,包括 Linux、macOS、Android、iOS、tvOS、watchOS 和 Windows。它们还可以跨 x86、x64、ARM32 和 ARM64 等处理器架构一致运行。

和.NET,我们可以构建以下类型的应用:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/B15729_02_Table_1.jpg

表 2.1

  • 编程语言:.NET 支持多种编程语言。用一种语言编写的代码可以被其他语言访问。下表显示了支持的语言:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/B15729_02_Table_2.jpg

表 2.2

  • IDEs: .NET supports multiple IDEs. Let’s understand each one:

    a. Visual Studio 是一个功能丰富的 IDE,可在 Windows 平台上构建、调试和发布.NET 应用。它有三个版本:社区版、专业版和企业版。Visual Studio 2019 社区版对学生、个人开发人员和致力于开源项目的组织是免费的。

    b.用于 Mac 的 Visual Studio是免费的,可用于 macOS。可以使用. NET 为 iOS、安卓和网络开发跨平台应用和游戏

    c. Visual Studio Code 是一个免费、开源、轻量级但功能强大的代码编辑器,可在 Windows、macOS 和 Linux 上使用。它内置了对 JavaScript、TypeScript 和 Node.js 的支持,通过扩展,您可以添加对许多流行编程语言的支持。

    d. Codespaces 目前处于预览阶段,是一个由 Visual Studio Code 提供动力的云开发环境,由 GitHub 托管开发.NET 应用。

  • Deployment models: .NET supports two modes of deployment:

    a.自包含:当. NET 应用以自包含模式发布时,发布的工件包含.NET 运行时、库和应用及其依赖项。独立的应用是特定于平台的,目标机器不需要.NET 运行时已安装。机器使用.NET 运行时与运行应用的应用一起提供。

    b.依赖框架的:当一个. NET 应用以依赖框架的模式发布时,发布的工件只包含应用及其依赖项。那个.NET 运行时必须安装在目标计算机上才能运行应用。

接下来,让我们了解. NET 提供的应用框架

理解应用框架

.NET 通过提供许多应用框架简化了应用开发。每个应用框架都包含一组库来开发目标应用。让我们详细了解每一个:

  • ASP.NET 芯:这是一个开源跨平台应用开发框架,让你构建现代的、基于云的、互联网连接的应用,比如 web、IoT、API 应用。ASP.NET Core 是建立在.NET Core,因此您可以跨平台构建和运行,如 Linux、macOS 和 Windows。
  • WPF :这是一个 UI 框架,让你为 Windows 创建桌面应用。WPF 使用可扩展应用标记语言 ( XAML ),一种用于应用开发的声明性模型。
  • 实体框架 ( EF )核心:这个是一个开源的、跨平台的、轻量级的、对象关系映射 ( ORM )框架来与数据库配合使用.NET 对象。它支持 LINQ 查询、变更跟踪和模式迁移。它适用于流行的数据库,如 SQL Server、SQL Azure、SQLite、Azure Cosmos DB、MySQL 等。
  • 语言-集成查询 ( LINQ ):这增加了查询功能.NET 编程语言。LINQ 允许您使用相同的 API 从数据库、XML、内存数组和集合中查询数据。

在下一节中,让我们了解. NET 的核心组件

了解的核心成分。网

.NET 有两个主要组件:运行时和基类库。运行时包括一个垃圾收集器 ( GC )和准时制 ( JIT )编译器,负责管理的执行.NET 应用和基类库 ( BCLs ),也称为运行时库或框架库,它们包含了的基本构造块.NET 应用。

那个.NET SDK 可在https://dotnet.microsoft.com/download/dotnet/5.0下载。它包含一组开发和运行的库和工具.NET 应用。您可以选择安装软件开发工具包或.NET 运行时。去发展.NET 应用,您应该在开发机器上安装 SDK.NET 运行时.NET 应用。那个。. NET 运行时包含在。因此你不必安装。如果您已经安装了.NET 软件开发工具包:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_2.1_B15927.jpg

图 2.1–的可视化.NET 软件开发工具包

那个.NET 软件开发工具包包含以下组件:

  • 公共语言运行时 ( CLR ): CLR 执行代码并管理内存分配。.NET 应用,在编译时,产生一个中间语言 ( IL )。CLR 使用 JIT 编译器将编译后的代码转换为机器代码。它是一个跨平台运行时,可用于 Windows、Linux 和 macOS。
  • 内存管理:垃圾收集器管理的内存分配和释放.NET 应用。对于每一个新创建的对象,内存在托管堆中被分配,当没有足够的可用空间时,垃圾收集会检查托管堆中的对象,如果它们不再被应用使用,就将其删除。更多信息可以参考https://docs . Microsoft . com/en-us/dotnet/standard/垃圾收集。
  • JIT :时.NET 代码被编译,它被转换成 IL。IL 与平台和语言无关,因此当运行时运行应用时,JIT 会将 IL 转换为处理器能够理解的机器代码。
  • 通用类型系统:这定义了在 CLR 中如何定义、使用和管理类型。它支持跨语言集成并确保类型安全。
  • 基类库:这个包含了像System.String``System.Boolean这样的原语类型的实现,像List<T>``Dictionary<TKey, TValue>这样的集合,以及执行 I/O 操作、HTTP、序列化等等的实用函数。它简化了.NET 应用开发。
  • 罗斯林编译器:罗斯林是一个开源的 C#和 Visual Basic 编译器,拥有丰富的代码分析 API。它支持使用与 Visual Studio 相同的应用编程接口构建代码分析工具。
  • MSBuild :这是一个建造的工具.NET 应用。Visual Studio 使用 MSBuild 来生成.NET 应用。
  • NuGet :这是一个开源的包管理器工具,你可以用它来创建、发布和重用代码。NuGet 包包含编译后的代码、其依赖文件和包含包版本号信息的清单。

在下一节中,让我们了解如何设置开发环境来创建和运行.NET 应用。

设置开发环境

建立一个开发环境非常容易。你需要.NET 软件开发工具包来构建和运行.NET 应用;或者,您可以选择安装支持.NET 应用开发。您需要执行以下步骤来设置。. NET 软件开发工具包:

注意

Visual Studio Community Edition 对个人开发者、课堂学习以及为研究或开源项目做出贡献的组织中的无限用户免费。它提供了与专业版相同的功能,但是对于高级功能,如高级调试和诊断工具、测试工具等,您需要有企业版。要比较功能,您可以访问https://visualstudio.microsoft.com/vs/compare。

  1. 在 Windows 机器上,从https://visualstudio.microsoft.com下载并安装 Visual Studio 16.8 或更高版本。

  2. In the installation options, from Workloads, select .NET Core cross-platform development for .NET Core applications, as shown in the following screenshot:

    https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_2.2_B15927.jpg

    图 2.2–Visual Studio 安装、工作负载选择

  3. 确认选择并继续完成安装。这将安装 Visual Studio 和.NET 5 软件开发工具包。

或者,您也可以执行以下步骤进行设置:

  1. 下载并安装.NET 5 SDK,适用于 Windows、macOS 和 Linux,来自https://dotnet.microsoft.com/download/dotnet/5.0。.NET Core 支持并行执行,因此我们可以安装多个版本的。在开发机器上。

  2. From Command Prompt, run the dotnet --version command to verify the installed version, as shown in the following screenshot:

    https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_2.3_B15927.jpg

    图 2.3–dotnet 命令的命令行输出

  3. Optionally, you can download and install Visual Studio Code from https://code.visualstudio.com to use it to develop the .NET application.

    既然我们了解了如何为?NET,在下一节中,让我们了解.NET CLI 是什么,以及它如何帮助创建、构建和运行.NET 应用从命令行。

    要设置电子商务应用,可以参考https://github . com/packt publishing/Enterprise-Application-Development-with-C-Sharp-9-and-.NET-5/blob/master/Enterprise % 20 application/readme . MD。

    了解命令行界面

    那个.NET CLI 是一个跨平台的命令行界面工具,可用于开发、构建、运行和发布.NET 应用。它包含在.NET SDK。

    CLI 命令结构包含command driver ( dotnet)、command、command-arguments和options,这是大多数 CLI 操作的常见模式。请参考以下命令模式:

    driver command <command-arguments> <options>
    

    例如,以下命令创建了一个新的控制台应用。dotnet是司机,new是命令,console是模板名作为参数:

    dotnet new console
    

    下表说明了命令行界面支持的一些命令和命令的简短描述:

    https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/B15729_02_Table_3.jpg

    表 2.3

    让我们创建一个简单的控制台应用,并使用.NET 命令行界面:

    注意

    要执行以下步骤,作为先决条件,您应该具有.NET 软件开发工具包安装在您的机器上。可以从https://dotnet.microsoft.com/download/dotnet/5.0下载安装。

  4. 在命令提示符下,运行以下命令创建一个名为HelloWorld的控制台应用:

    dotnet new console --name HelloWorld
    
  5. This command will create a new project called HelloWorld based on the console application template. Refer to the following screenshot:

    https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_2.4_B15927.jpg

    图 2.4–新控制台应用的命令行输出

  6. 运行以下命令来构建和运行应用:

    dotnet run --project ./HelloWorld/HelloWorld.csproj
    
  7. 前面的命令将构建并运行应用,并将输出打印到命令窗口,如下所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_2.5_B15927.jpg

图 2.5–运行时控制台应用的命令行输出

在前面的步骤中,我们创建了一个新的控制台应用,并使用.NET 命令行界面。让我们理解global.json的意义。

global . JSON 概述

在开发者机器上,如果有多个.NET SDKs 已安装,在global.json文件中可以定义。用于运行.NET 命令行界面命令。一般来说,当没有定义global.json文件时,会使用最新的版本的 SDK,但是您可以通过定义global.json来覆盖此行为。

运行以下命令将在当前目录中创建一个global.json文件。根据您的要求,您可以选择要配置的版本:

dotnet new globaljson --sdk-version 2.1.811

以下是通过运行前面的命令创建的示例global.json文件:

{
  "sdk": {
    "version": "2.1.811"
  }
}

这里,global.json被配置为使用。. NET SDK 版 2.1.8.11。那个.NET CLI 使用这个 SDK 版本来构建和运行应用。

有关的更多信息.NET CLI,可以参考https://docs.microsoft.com/en-us/dotnet/core/tools。

在下一节中,让我们了解一下.NET 标准是。

是什么.NET 标准?

.NET 标准是一组可用于多种应用的 API 规范.NET 实现。每个新版本的都会添加新的 API.NET 标准。每一个.NET 实现的目标是特定版本的.NET 标准,并且可以访问该标准支持的所有 API.NET 标准版。

针对版本构建的库.NET 标准可用于使用。支持该版本的.NET 标准。因此,当构建库时,目标是.NET Standard 允许使用更多的 API,但只能在使用的版本构建的应用中使用.NET 实现来支持它。

下面的屏幕截图列出了的各种版本.NET 实现,这些实现支持.NET 标准 2.0:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_2.6_B15927.jpg

图 2.6–.NET 标准 2.0 支持.NET 实现

例如,如果您开发了一个目标库.NET 标准 2.0,它可以访问超过 32,000 个 API,但是支持它的版本较少.NET 实现。如果您希望您的库能够以最大数量的.NET 实现,然后选择尽可能低的.NET 标准版,但是您需要在可用的 API 上妥协。

让我们了解何时使用.NET 5 和.NET 标准。

了解使用.NET 5 和。网络标准

.NET 标准使得在不同的.NET 实现,但是.NET 5 提供了一种更好的方式来共享代码并在多个平台上运行。.NET 5 统一了 API 来支持桌面、web、云和跨平台控制台应用。

.NET 5 实现.NET 标准 2.1,所以你现有的代码.NET 标准与.NET 5;你不需要改变目标框架的名字 ( TFM )除非你想要访问新的运行时特性、语言特性或者 API。您可以多目标到.NET 标准和.NET 5,这样您就可以访问新功能,并使您的代码对其他人可用.NET 实现。

如果您正在构建需要使用的新的可重用库.NET 框架,然后将它们定位到.NET 标准 2.0。如果你不需要支持.NET 框架,那么你可以选择.NET 标准 2.1 或.NET 5。建议瞄准.NET 5 来访问新的 API、运行时和语言特性。

使用.NET 命令行界面,运行以下命令创建一个新的类库:

dotnet new classlib -o MyLibrary

它创建一个类库项目,目标框架为.net5.0或开发人员机器上最新可用的 SDK。

如果您检查MyLibrary\MyLibrary.csproj文件,它应该看起来像下面的片段。您会注意到目标框架设置为net5.0:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>
</Project>

在使用创建类库时,您可以强制它使用目标框架的特定版本.NET 命令行界面。以下命令创建一个目标类库.NET 标准 2.0:

dotnet new classlib -o MyLibrary -f netstandard2.0

如果您检查MyLibrary\MyLibrary.csproj文件,它看起来像下面的片段,其中目标框架是netstandard2.0:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
</Project>

如果你创建了一个目标库.NET 标准 2.0,它可以在一个应用中访问.NET Core 以及.NET 框架。

可选地,您可以瞄准多个框架;例如,在下面的代码片段中,库项目被配置为以.NET 5.0 和.NET 标准 2.0:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net5.0;netstandard2.0</TargetFrameworks>
  </PropertyGroup>
</Project>

当您配置应用以支持多个框架并构建项目时,您会注意到它为每个目标框架版本创建工件。参考以下截图:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_2.7_B15927.jpg

图 2.7–构建面向多个框架的工件

我们来总结一下这里的信息:

  • 使用.NET 标准 2.0 到之间共享代码.NET 框架和所有其他平台。
  • 使用.NET 标准 2.1 在 Mono、Xamarin 和之间共享代码.NET Core 3.x。
  • 使用.NET 5 进行代码共享。

在下一节中,让我们了解一下.NET 5 的跨平台能力和云应用支持。

理解.NET 5 跨平台和云应用支持

.NET 有很多实现。每个实现都包含运行时、库、应用框架(可选)和开发工具。有四个.NET 实现:。. NET 框架.NET 5、通用 Windows 平台 ( UWP )、Mono 以及所有这些实现共有的套 API 规范.NET 标准。

多个.NET 实现使您能够创建.NET 应用,目标是许多操作系统。你可以建造.NET 应用,用于以下目的:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/B15729_02_Table_4.jpg

表 2.4

让我们了解更多关于.NET 实现:

  • .NET Framework is the initial implementation of .NET. Using .NET Framework, you can develop Windows, WPF, web applications, and web and WCF services targeting the Windows operating system. .NET Framework 4.5 and above implement .NET Standard, so libraries that are built targeting .NET Standard can be used in .NET Framework applications.

    注意

    。. NET Framework 4.8 是的最后一个版本.NET 框架,未来不会发布新版本。微软将继续在 Windows 中包含它,并通过安全和错误修复来支持它。对于新开发,建议使用.NET 5 或更高版本。

  • UWP 是一个的实现.NET,您可以用它构建可在个人电脑、平板电脑、Xbox 等上运行的支持触摸的窗口应用。

  • Mono 是. NET 的一个实现。它是一个小的运行时,支持 Xamarin 开发原生的 Android、macOS、iOS、tvOS 和 watchOS 应用。它实现了.NET 标准和目标库.NET 标准可以用于使用 Mono 构建的应用。

  • .NET 5 is an open source, cross-platform implementation of .NET with which you can build console, web, desktop, and cloud applications that can run on Windows, macOS, and Linux operating systems. .NET 5 is now the primary implementation of .NET, which is built on a single code base with a uniform set of capabilities and APIs that can be used by .NET applications.

    那个.NET 5 SDK,连同库和工具,也包含多个运行时,包括.NET 运行时、ASP.NET Core 运行时和.NET 桌面运行时。跑步.NET 5 应用,您可以选择安装.NET 5 SDK 或相应的平台和工作负载特定的运行时:

  • The.NET 运行时只包含运行控制台应用所需的组件。要运行网络或桌面应用,您需要安装 ASP.NET Core 运行时和.NET 桌面运行时。那个.NET 运行时在 Linux、macOS 和 Windows 上可用,支持 x86、x64、ARM32 和 ARM64 处理器架构。

  • ASP.NET Core 运行时使您能够运行网络/服务器应用,并且在 x86、x64、ARM32 和 ARM64 处理器架构的 Linux、macOS 和 Windows 上可用。

  • .NET 桌面运行时使您能够在 Windows 上运行基于 Windows/WPF 的桌面应用。它适用于 x86 和 x64 处理器体系结构。

的可用性.NET 多平台运行时使得.NET 5 跨平台;在目标机器上,您只需要安装您的工作负载所需的运行时并运行应用。

现在,让我们来探索 Azure 提供的服务.NET 5。

云支持

.NET 5 得到了包括 Azure、谷歌云和 AWS 在内的热门云服务提供商的支持。让我们了解一些可以运行的服务。Azure 中的. NET 5 应用:

和.NET 5,我们可以开发可以在云中运行的企业服务器应用或高度可扩展的微服务。我们可以为 iOS、安卓和 Windows 操作系统开发移动应用。.NET 代码和项目文件看起来很相似,开发人员可以重用技能或代码来开发针对不同平台的不同类型的应用。

总结

在这一章,我们学到了什么.NET 是及其核心特性。我们了解了.NET 及其支持的不同部署模型。接下来,我们了解了提供的核心组件、工具和库.NET,并学习了如何在机器上设置开发环境。

我们还看了。并使用创建了一个示例应用.NET 命令行界面。接下来,我们学到了什么.NET 标准是什么以及什么时候使用.NET 5 和.NET 标准,然后通过讨论各种。. NET 实现.NET 5 跨平台支持,以及云支持。

在下一章中,我们将学习 C# 9.0 的新特性。

问题

  1. .NET Core is which of the following?

    a.开放源码

    b.跨平台

    c.自由的

    d.上述全部

  2. The .NET Standard 2.0 library is supported by which of the following?

    a.。. NET Framework 4.6.1 或更高版本

    b…NET Core 2.0 或更高版本

    c…NET 5

    d.Mono 5.4+或更高版本

    e.上述全部

  3. The .NET CLI driver that is mandatory to run CLI commands is which of the following?

    a.net

    b.core

    c.dotnet

    d.none

  4. The .NET SDK contains which of the following?

    a.那个.NET 命令行界面

    b.基类库

    c.运行时

    d.上述全部

进一步阅读

了解更多.NET 5,可以参考https://docs.microsoft.com/en-us/dotnet/core/introduction。

三、C# 9 简介

C#是一种优雅且类型安全的面向对象编程语言,它使开发人员能够构建许多类型的安全且健壮的应用,这些应用运行在.NET 生态系统,并且在 GitHub 发布的流行编程语言列表中排名前 5。

C#最初是由安德斯·海尔斯伯格在微软开发的,作为.NET 倡议。自 2002 年 1 月第一次发布以来,该语言不断增加新功能,以提高性能和生产力。

撰写本文时的最新版本 C# 9.0 于 2020 年 11 月发布,目标是.NET 5。虽然就新特性而言,C# 9.0 并不是一个主要版本,但是有很多有趣的增加和增强,我们将在本章中了解:

  • 理解仅初始化设置器
  • 使用记录类型
  • 理解顶层语句
  • 使用模式匹配检查对象
  • 用目标类型表达式理解类型推断
  • 理解静态匿名函数
  • 使用模块初始化器进行紧急初始化

到本章结束时,您将熟悉 C# 9.0 的新增功能。此外,本章将帮助我们提升自我,以 C#构建我们的下一个企业应用。

技术要求

您将需要以下内容来理解本章的概念:

  • 带有的 Visual Studio 2019 版.NET 5.0 运行时
  • 对微软的基本了解。网

本章使用的代码可以在https://github . com/PacktPublishing/Enterprise-Application-Development-with-C-Sharp-9-and-找到.NET-5/树/主/第 03 章。

了解仅初始化设置器

在 C#中,我们使用在 C# 7.0 中引入的对象初始值设定项,通过使其可设置来设置属性值。但是如果我们希望只在对象初始化期间设置它们,我们需要编写大量的样板代码。在 C# 9.0 中,使用只初始化的设置器,可以让属性在对象创建期间初始化。只能为任何类或结构声明 only setters。

下面的代码片段定义了Order类,其中OrderId是一个只初始化的设置器。这强制OrderId仅在对象创建期间初始化:

public class Order
{
        public int OrderId { get; init; }
        public decimal TotalPrice { get; set; }
}

我们可以在创建对象时实例化Order类并初始化OrderId,如下面的代码片段所示:

class Program
{
    static void Main(string[] args)
    {
 Order orderObject = new Order
 {
 OrderId = 1,
 TotalPrice = 10.0M
 };
// orderObject.OrderId = 2 // This will result in 
 compilation error
        Console.WriteLine($”Order: Id: 
         {orderObject.OrderId}, Total price: 
         {orderObject.TotalPrice}”);
    }
}

我们可以在前面的代码中看到,如果我们试图更改OrderId的值,我们会得到一个编译时错误。

仅初始化属性应用于强制可选属性的不变性,构造函数初始化应用于强制必需值。

在下一节中,我们将探索 C# 9.0 引入的新类型定义。

使用记录类型

作为一名开发人员,您希望引用类型具有不变性,尤其是在使用共享数据进行并发编程时。为了实现引用类型的不变性,我们必须编写相当多的代码。C# 9.0 中引入的记录类型提供类型声明来创建不可变的引用类型。与引用类型不同,他们通过比较属性的相等性来合成相等性的方法,而不是比较对象的哈希代码。下面显示了保存形状名称的Shape记录类型的声明:

public record Shape(string Name);

构建项目,并在诸如 ILSpy 或 Reflector 之类的反汇编工具中打开库。你可以从https://marketplace.visualstudio.com/items?安装 ILSpyitemName =夏普发展团队。。在 ILSpy 中,我们看到Shape记录类型的定义如下图所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_3.1_B15927.jpg

图 3.1–形状记录类型的分解

如果我们仔细观察Shape类型定义,我们可以看到由 C#编译器为记录类型合成的所有管道。由此我们可以理解,记录类型基本上是一个类,用Equality和GetHashCode合成来模仿值类型的行为。它还提供了Deconstruct方法,可用于将记录类型解构为其组件属性。ToString方法也被覆盖以打印记录类型的属性。

为了理解所有这些特性,让我们继续创建一个Shape记录类型的实例,如下面的代码所示:

public record Shape(string Name);
class Program
{
    static void Main(string[] args)
    {
        Shape s1 = new Shape(“Shape”);
        Shape s2 = new Shape(“Shape”);
        // ToString of record is overwritten to print the 
            properties of the type
        Console.WriteLine(s1.ToString());
        // GetHashCode of record is overwritten to generate 
           the hash code based on values
        Console.WriteLine($”HashCode of s1 is : 
         {s1.GetHashCode()}”);
        Console.WriteLine($”HashCode of s2 is : 
          {s2.GetHashCode()}”);
        // Equality operator of record type is overwritten 
             to check equality based on the values
        Console.WriteLine($”Is s1 equals s2 : {s1 == s2}”);
    }
}

如果我们运行前面的代码,我们会得到以下输出:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_3.2_B15927.jpg

图 3.2–程序控制台输出

从前面的截图中,我们可以看到,正如我们在前面的代码中了解到的,ToString获得了对象的详细信息。我们收到的s1和s2对象的哈希代码与根据对象的详细信息计算的相同。s1和s2之间的相等检查返回True,因为==操作符被覆盖以比较对象的数据。

我们已经看到了基本上是什么记录类型。在下一节中,我们将了解with表达式,它可以与记录类型一起使用,通过细化零个或多个属性,从现有对象创建新对象。

带表情的

我们可以使用with表达式来指示编译器从另一个实例创建一个记录类型的实例:

Person person = new(“Suneel”, “Kunani”);
Person person2 = person with { FirstName = “Mahanya” };

在前面的代码片段中,Person对象的新实例person2是通过复制字段的值并仅修改在with表达式中指定的属性,从现有的person实例创建的。

我们现在已经了解了记录类型。在下一节中,我们将了解 C# 9.0 中添加的另一个特性,即顶级语句。

理解顶级语句

在 C# 9.0 中,语言团队专注于移除开发人员要编写的冗余代码。这样做的一个特征是顶级语句。这个特性使开发人员能够在应用的主入口点移除仪式代码。

如果我们在 Visual Studio 中创建一个控制台应用,我们在Program.cs中看到的内容如下面的代码片段所示:

using System;
namespace TopLevelStatements
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(“Hello World!”);
        }
    }
}

在这段代码中,唯一起作用的语句是Console.WriteLine(“Hello World!”);。所有剩下的台词都是不必要的仪式声明。使用 C# 9.0,我们可以在不必要的地方去掉这些语句。现在我们可以用一行代码替换所有这些语句,如下面的代码片段所示:

System.Console.WriteLine(“Hello World!”);

这只是 C#编译器提供的语法糖。当它看到任何顶级语句时,它会添加所有必需的管道。让我们看看反射的代码,如下面的截图所示,带有顶级语句:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_3.3_B15927.jpg

图 3.3–顶层语句的分解

应用中只有一个文件可以有顶级参数,因为该应用只能有一个入口点。如果多个文件包含顶级语句,它将显示编译错误。我们可以访问用args变量名传递的命令行参数,如下面的代码所示:

foreach (var v in args)
{
    System.Console.WriteLine(v);
}

在下一节中,我们将了解模式匹配,这是 C# 7.0 中引入并在后续版本中增强的 C#令人兴奋的特性之一。

用模式匹配检查对象

模式匹配是一种方法,用于检查对象的值或与序列完全或部分匹配的属性的值。这在 C#中以if…else和switch…case语句的形式得到支持。在现代语言中,特别是在函数式编程语言(如 F#)中,有对模式匹配的高级支持。在 C# 7.0 中,引入了新的模式匹配概念。它们在 C#的更高版本(即 8.0 和 9.0)中得到进一步增强。

模式匹配提供了一种不同的方式来表达条件,以获得更易于人类阅读的代码。为了理解模式匹配,我们还将介绍 7.0 和 8.0 版本中的概念。让我们在接下来的章节中更深入地探讨模式匹配。

不变的模式

常量模式匹配根据常量检查对象的值。通过常量模式匹配,我们将针对常量断言一个对象的值。

下面的代码片段是一个示例,应用常量模式检查Width是否为0。我们用恒定模式实现的结果也可以用平等操作符==来实现。唯一的区别是常量模式不能被覆盖:

var rectangle = new Rectangle { Height = 10, Width = 0 };
      if (rectangle.Width is 0)
      {
       Console.WriteLine(“The rectangle]s width is 0, it 
         will look like a standing line”);
      }

常量模式也可以应用于空值检查,如以下代码所示:

if (rectangle is not null)
{
       Console.WriteLine(“The rectangle is defined”);
}

类型模式

顾名思义,类型模式用于检查对象的类型,如下面的代码片段所示:

if (rectangle is Rectangle rect)
{
    Console.WriteLine($”The area of rectangle is 
     {rect.Width * rect.Height}”);
}

如果没有模式匹配,我们将不得不使用一个运算符来检查类型,并使用一个运算符来转换为特定的类型。

属性模式

属性模式用于检查和探索对象属性和嵌套属性。下面的代码片段将rectangle对象的Height属性声明为0:

if (rectangle is { Height: 0 })
{
    Console.WriteLine(“The rectangle’s height is 0, it will 
      look like a sleeping line”);
}

属性模式可以用来创建强大的表达式。

连接和分离模式

合取和析取模式很像 的逻辑&&和||运算符来配对表达式。

以下代码片段加入了使用and连接模式检查矩形的Height属性是否大于0且小于或等于100的条件:

if (newRectangle is Rectangle { Height: > 0 and <= 100 })
{
    Console.WriteLine(“This is a rectangle with maximum height of 100”);
}

析取模式为or检查or条件。在下面的代码片段中,分离模式验证高度小于5或大于或等于10000:

if (newRectangle is Rectangle { Height: < 5 or >= 10000 })
{
    Console.WriteLine(“This is a rectangle is either too 
      small or too big”);
}

在下一节中,我们将学习如何使用模式匹配switch表达式。

与开关表达式的模式匹配

在早期版本的 C#中,switch表达式只支持常量值的int、char、bool、string、case等整型。C#中新的模式匹配特性支持用代码表达控制流的新方法。我们用下面的例子来理解这个。

为了计算给定形状的面积,在旧版本中,我们将检查形状的类型并相应地计算面积。利用 C#中的模式匹配特性,我们可以编写GetShapeArea如下代码所示:

static double GetShapeArea(object o)
{
      var result = o switch
      {
           Circle c => (22.0/7.0)* c.Radius * c.Radius,
           Rectangle r => r.Width * r.Height,
           _ => throw new ArgumentException(“Not recognized 
           shape”)
       };
       return result;
}

在前面的代码中,switch表达式验证形状对象的类型,并利用相应的公式计算形状的面积。

在下一个例子中,让我们看看switch表达式中的关系模式:

static float GetProductDiscount(Product product)
{
    float discount = product switch
    {
        Product p when p.Quantity is >= 10 and < 20 => 
         0.05F,
        Product p when p.Quantity is >= 20 and < 50 => 
         0.10F,
        Product p when p.Quantity is >= 50          => 
         0.10F,
        _ => throw new ArgumentException(nameof(product))
    };
    return discount * product.UnitPrice * product.Quantity;
}

在上例中,switch表达式用于根据产品数量识别折扣。每个案例检查产品数量的上限和下限,并确定要应用的折扣。

元组模式

元组模式允许我们基于多个值构建一个表达式。下面的代码利用元组模式来获得AND门的结果。我们知道,如果所有值都为真,那么AND门返回true,否则返回false:

static bool AndGate(bool x, bool y) =>
            (x, y) switch
            {
                (false, false) => false,
                (false, true) => false,
                (true, false) => false,
                (true, true) => true
            };

随着 C#中模式匹配的新增加,我们可以编写更复杂的表达式,并提高代码的可读性。

在下一节中,我们将了解目标类型表达式,其中 C#编译器将帮助根据上下文找到类型。

用目标类型表达式理解类型推理

目标类型表达式删除了创建对象时对类型的多余提及。对于目标类型表达式,类型是从其使用的上下文中推断出来的。在 C#中,我们使用new关键字通过指定类型来实例化一个对象。要创建StringBuilder的对象,以下是示例代码片段:

StringBuilder sb = new StringBuilder();

在这个代码片段中,StringBuilder类型可以从上下文中推断出来。使用 C# 9.0,使用目标类型表达式,我们可以编写与下面示例中所示相同的代码片段。这里StringBuilder类型是推断出来的:

StringBuilder sb = new ();

在下一节中,我们将学习静态匿名函数如何帮助我们识别和解决程序执行的非预期行为。

理解静态匿名函数

匿名函数是一组语句或表达式,可以在期望委托类型时使用或执行。使用匿名函数时,如果我们不小心引用了匿名函数中的局部变量,我们可能会得到意想不到的行为。通过使用静态匿名函数,我们可以避免无意中使用状态或局部变量。

考虑下面的代码片段来理解静态匿名函数:

string formatString = “Format String”;
void GenerateSummary(string[] args)
{
    GenerateOrderReport(() =>
    {
        return formatString;
    });
}
static string GenerateOrderReport(Func<string> getFormatString)
{
    var order = new
    {
        Orderid = 1,
        OrderDate = DateTime.Now
    };
    return string.Format(getFormatString(), order.Orderid);
}

从前面的代码中,我们看到GenerateOrderReport函数接受了一个接受格式字符串的函数参数。传递到GenerateOrderReport的匿名函数返回formatString实例格式字符串值。

如果这里的意图是让成为生成订单报告的格式字符串,我们将得到意想不到的结果。我们不会得到任何编译错误,因为代码是合法的。

为了解决这些错误,我们可以利用静态匿名函数,如下所示:

void GenerateSummary(string[] args)
{
    GenerateOrderReport(static () =>
 {
 // return formatString; // Will get error
 return $”Order Id:{1}, Order Date:{1}”;
 });
}

如前面的代码片段所示,将匿名函数更改为静态匿名函数将导致编译错误,因为静态函数中使用了非静态变量。这将迫使开发人员修复正确的格式字符串。

在下一节中,让我们了解一下模块初始化器,这将有助于在加载程序集时执行急切的初始化代码。

用模块初始化器进行急切初始化

可能会有这样一种情况,当模块初始化时,必须急切地执行某些代码。虽然这是一个利基场景,但如果我们有这样的要求,它将变得相当难以实现。随着 C# 9.0 中模块初始化器的引入,这很容易实现。

要在模块初始化时运行任何代码,我们只需用ModuleInitializer属性标记该方法,如下代码所示:

[ModuleInitializer]
internal static void Initialize()
{
     Console.WriteLine(“Module Initialization”);
}

那个.NET 运行时将在首次加载程序集时执行标记有ModuleInitializer属性的方法。

下面是模块初始化器方法的要求:

  • 一定是static法。
  • 返回类型必须是void。
  • 它不能是泛型方法。
  • 它必须是无参数方法。
  • 该方法必须可从包含模块中访问。

如果我们不遵守这些,我们将会得到一个编译错误。

总结

在本章中,我们已经了解了 C#语言特性的主要新增功能。我们已经了解了记录类型,它可以帮助我们用最少的管道代码构建不可变的引用类型。我们还学习了模式匹配,通过利用这一点,我们可以编写具有增强的代码可读性的复杂表达式。我们还看到了 C# 9.0 如何通过移除冗余的仪式代码来帮助开发人员处理顶级语句和目标类型表达式。

通过这一章,我们已经掌握了在企业电子商务应用中利用这些新的 C# 9.0 特性的技能,我们将在接下来的章节中构建这些特性。我们将重点介绍 C# 9.0 和的新特性.NET 5,同时实现我们的电子商务应用的不同特性。

在下一节中,我们将了解构成电子商务应用构建模块的交叉问题。

问题

阅读本章后,您应该能够回答以下问题:

  1. True or False: We can only set the value to init-only setters during object initialization?

    a.真实的

    b.错误的

  2. True or False: Record types are basically class types?

    a.真实的

    b.错误的

  3. True or False: Top-level statements can be present in more than one file of an application?

    a.真实的

    b.错误的

  4. In which version of C# was pattern matching first introduced?

    a.C# 9

    b.C# 8

    c.C# 1

    d.C# 7

四、线程和异步操作

到目前为止,我们看了各种设计原则,模式,什么是新的.NET 5,以及我们将在本书中使用的架构指南。在本章中,我们将看到如何在构建企业应用时利用异步编程。任何 web 应用的关键衡量标准之一是可伸缩性,也就是说,可伸缩性可以减少服务请求所需的时间,增加服务器可以处理的请求数量,并在不增加加载时间的情况下增加应用可以同时服务的用户数量。对于移动/桌面应用,缩放可以提高应用的响应能力,允许用户在不冻结屏幕的情况下执行各种操作。适当使用异步编程技术和并行构造可以在改进这些度量方面创造奇迹,在 C#中最好的事情就是任务并行库 ( TPL )的简化语法,async-wait,用它我们可以编写干净的异步代码。

在本章中,我们将涵盖以下主题:

  • 理解行话
  • 线程去神秘化、延迟初始化和ThreadPool
  • 理解锁、信号量和SemaphoreSlim
  • 介绍任务和相似之处
  • 引入异步等待
  • 使用并行集合实现并行

技术要求

你需要对……有一个基本的了解.NET Core、C#和 LINQ 的基础知识。本章的代码示例可以在这里找到:https://github . com/PacktPublishing/Enterprise-Application-Development-with-C-Sharp-9-and-.NET-5/树/主/第 04 章

这里有一些代码说明:

https://github . com/PacktPublishing/Enterprise-Application-Development-with-C-Sharp-9-and-.NET-5/树/主/企业% 20 应用

理解行话

在我们深入线程和异步操作的技术细节之前,让我们举一个真实的例子,在现实生活中的多任务和并行编程之间建立一个类比。想象一下,你在一家餐馆排队点餐,在排队等候时,你回复了一封电子邮件。然后,在点了食物并等待食物到达的同时,你回复了另一封邮件。在餐厅里,有多个柜台,在那里接受订单,在下订单时,食物由厨师准备。

当你排队等候时,你同时回复了一封电子邮件。同样,当你在点菜的时候,餐馆也在许多其他柜台平行点菜。下订单时,厨师正在平行烹饪。此外,还会给你一个代币,让你从取货柜台取食物;但是,根据您食物的准备时间,在您之后下的订单可能会在您之前到达取货柜台。

说起并行编程,有些关键术语会出现多次。下图表示了这种行话:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_4.1_B15927.jpg

图 4.1–并发与并行和异步

让我们涵盖每个术语:

  • 并行:这需要同时独立执行多个任务,就像从不同柜台下多个餐厅订单的例子一样。就企业应用而言,并行性是在多核 CPU 中同时执行多个线程/任务。然而,单核 CPU 也通过超线程支持并行性,这通常涉及将单核逻辑划分为多个内核,如支持超线程的双核 CPU,其行为类似于四核,即四核。
  • 并发:这需要同时做很多任务,比如我们前面的例子在餐厅柜台排队的时候回复邮件,或者厨师给菜 1 调味,给菜 2 加热锅。就企业应用而言,并发涉及多个线程共享一个核心,并基于它们的时间分片来执行任务和执行上下文切换。
  • 异步:异步编程是一种依赖于异步执行任务的技术,而不是在当前线程等待时阻塞它。在我们的例子中,异步是等待你的令牌被调用,让你去取货柜台,而厨师正在准备你的食物,但是当你等待的时候,你已经离开了订餐柜台,从而允许下其他订单。这就像一个异步执行的任务,在等待输入/输出任务时释放资源(例如,在等待数据库调用的数据时)。异步的美妙之处在于任务可以并行执行,也可以并发执行,这完全是由框架从开发人员那里抽象出来的,让开发人员将开发精力集中在应用的业务逻辑上,而不是管理任务上。我们将在任务和平行部分看到这一点。
  • 多线程:多线程是一种实现并发的方式,新线程手动创建并并发执行,就像CLR ThreadPool一样。在多核/多处理器系统中,多线程通过在不同的内核中执行新创建的线程来帮助实现并行性。

现在我们已经理解了并行编程中的关键术语,接下来让我们看看如何创建线程以及ThreadPool在中的作用.NET Core。

解除线程神秘化、延迟初始化和线程池

线程是 Windows 中最小的单元,它在处理器中执行指令。进程是一个更大的执行容器,进程内部的线程是使用处理器时间和执行指令的最小单位。要记住的关键一点是,每当你的代码在一个进程中需要被执行时,它应该被分配给一个线程。每个处理器一次只能执行一条指令;这就是为什么在单核系统中,在任何点时间只有一个线程在执行。有一些调度算法用于为线程分配处理器时间。一个线程通常有一个堆栈(跟踪执行历史)、存储各种变量的寄存器和保存需要执行的指令的计数器。

快速查看任务管理器将为我们提供有关物理和逻辑内核数量的详细信息,导航到资源监视器将告诉我们每个内核的 CPU 使用情况。下图显示了支持超线程的四核 CPU 的详细信息,它可以在任意时间点并行执行八个线程:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_4.2_B15927_Corrected_(1).jpg

图 4.2–任务管理器和资源监视器

中的典型应用.NET Core 启动时只有一个单线程,通过手动创建可以添加更多线程。下面几节将简要介绍是如何做到的。

使用系统。螺纹,螺纹

我们可以通过创建一个System.Threading.Thread的实例并传递一个方法委托来创建新的线程。下面是一个简单的例子,它模拟从应用编程接口检索数据和从磁盘加载文件:

        static void Main(string[] args)
        {
            Thread loadFileFromDisk = new 
             Thread(LoadFileFromDisk);
            loadFileFromDisk.Start();
            Thread fetchDataFromAPI = new 
             Thread(FetchDataFromAPI);
            fetchDataFromAPI.Start("https://dummy/v1/api"); //Parameterized method
            Console.ReadLine();
        }
        static void FetchDataFromAPI(object apiURL)
        {
            Thread.Sleep(2000);
            Console.WriteLine("data returned from API");
        }
        static void LoadFileFromDisk()
        {
            Thread.Sleep(2000);
            Console.WriteLine("File loaded from disk");
        }

在之前的代码中,FetchDataFromAPI和LoadFileFromDisk是将在新线程上运行的方法。

小费

在任何时间点,每个内核上只执行一个线程;也就是说,只有一个线程被分配了 CPU 时间。因此,为了实现并发性,操作系统 ( 操作系统)会在分配了 CPU 时间的线程空闲时或高优先级线程到达队列时进行上下文切换(也可能有其他原因,例如线程正在等待同步对象或达到了分配的 CPU 时间)。因为一个被切换出去的线程不会完成它的工作,在某个时候它会再次被分配 CPU 时间。因此,操作系统需要保存线程的状态(其堆栈、寄存器等),并在分配给线程 CPU 时间时再次检索。上下文切换通常非常昂贵,也是性能提升的关键领域之一。

Thread类的所有属性和方法可以在上进一步查看。view=net-5.0 。

尽管管理的优势在于可以更好地控制线程的执行方式,但它也带来了以下形式的开销:

  • 管理线程的生命周期,如创建线程、回收线程和上下文切换。
  • 实现一些概念,例如线程执行的进度跟踪/报告。此外,取消相当复杂,支持有限。
  • 线程上的异常需要适当处理,否则可能导致应用崩溃。
  • 调试、测试和代码维护可能会变得有点复杂,如果处理不当,有时会导致性能问题。

这就是公共语言运行时 ( CLR ) ThreadPool发挥作用的地方,这将在下一节讨论。

线程池

可以通过使用由管理的线程池来创建线程.NET Core,更俗称 CLR ThreadPool。CLR ThreadPool是一组工作线程,与 CLR 一起加载到您的应用中,并负责线程生命周期,包括回收线程、创建线程和支持更好的上下文切换。CLR ThreadPool可以被System.Threading.ThreadPool类中可用的各种 API 使用,特别是对于调度线程上的操作,有QueueUserWorkItem方法,它接受需要调度的方法的委托。在前面的代码中,让我们用下面的代码替换创建新线程的代码,这意味着应用将使用ThreadPool:

ThreadPool.QueueUserWorkItem(FetchDataFromAPI);

顾名思义,ThreadPool类的QueueUserWorkItem确实使用了队列,因此任何应该在ThreadPool线程上执行的代码都会被排队,然后出列,也就是说,以先进先出(先进先出)的方式分配给一个工作线程。

ThreadPool的设计方式是它有一个全局队列,当我们执行以下操作时,项目会在其中排队:

  • 使用不属于ThreadPool线程的线程调用QueueUserWorkItem或ThreadPool类的类似方法。
  • 通过任务并行库 ( 第三方物流)调用。

当在ThreadPool中创建新线程时,它维护自己的本地队列,该队列实际上查看全局队列,并以先进先出的方式将工作项出列;但是,如果在这个线程上执行的代码进一步创建了另一个线程,比如一个子线程,那么它就会在本地队列中排队,而不是在全局队列中排队。工作线程的本地队列中的操作的执行顺序总是后进先出,其原因是最近创建的工作项在缓存中可能仍然很热,因此可以快速执行。同样,我们可以说在任何时间点都会有ThreadPool中的 n+1 队列,其中 n 是ThreadPool中的线程数,即 n 本地队列,而 1 是指全局队列。

ThreadPool的高级表示如下图所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_4.3_B15927.jpg

图 4.3–线程池高级表示

除了QueueUserWorkItem之外,还有很多其他属性/方法可用于ThreadPool类,例如:

  • SetMinThreads:用于设置程序启动时ThreadPool将拥有的最小工作线程和异步 I/O 线程。
  • SetMaxThreads:用于设置ThreadPool将拥有的最大工作线程和异步 I/O 线程,之后新的请求将排队。

ThreadPool类的所有属性和方法可以在上进一步查看。view=net-5.0 。

虽然通过ThreadPool线程的QueueUserWorkItem编写多线程代码简化了线程的生命周期管理,但它也有自己的局限性:

  • 我们无法从ThreadPool线程上调度的工作中得到响应,因此委托的返回类型是无效的。
  • 跟踪在ThreadPool线程上安排的工作进度并不容易,所以像进度报告这样的事情并不容易实现。
  • 它不适合长时间运行的请求。
  • ThreadPool线程始终是后台线程;因此,与前台线程不同,如果一个进程关闭,它不会等待ThreadPool线程完成工作。

由于QueueUserWorkItem有限制,ThreadPool线程也可以通过第三方物流消耗,我们将在我们的企业应用中使用它,这将在本章后面介绍。英寸 NET Core,第三方语言无疑是实现并发/并行的首选方法,因为它克服了我们迄今为止看到的所有限制,并最终有助于实现允许应用扩展和响应的目标。

惰性初始化

类的惰性初始化是一种模式,其中对象的创建被推迟到第一次使用时。这种模式基于这样一个前提,即只要没有使用类的属性,初始化对象就没有任何好处。因此,这会延迟对象的创建,并最终减少应用的内存占用并提高性能。例如,只有当您要从数据库中检索数据时,才创建数据库连接对象。惰性初始化非常适合保存大量数据并且创建成本可能很高的类。例如,用于加载电子商务应用中所有产品的类只能在需要列出产品时进行延迟初始化。

如下所示,此类的典型实现限制了构造函数中属性的初始化,并具有一个或多个填充类属性的方法:

        public class ImageFile
        {
            string fileName;
            object loadImage;
            public ImageFile(string fileName)
            {
                this.fileName = fileName;
            }
            public object GetImage()
            {
                if (loadImage == null)
                {
                    loadImage = File.ReadAllText(fileName);
                }
                return loadImage;
            }
        }

假设这是一个用于从磁盘加载映像的类,那么在构造函数中加载映像是没有用的,因为在调用GetImage方法之前无法使用它。因此,惰性初始化模式建议不要在构造函数中初始化loadImage对象,而应该在GetImage中初始化,这意味着只有在需要时才会将图像加载到内存中。这也可以通过如下所示的属性来实现:

        object loadImage;
        public object LoadImage
        {
            get
            {
                if (loadImage == null)
                {
                    loadImage = File.ReadAllText(fileName);
                }
                return loadImage;
            }
        }

如您所见,这是缓存对象的典型做法,也称为缓存备用模式,当第一次访问特定对象时,我们会在缓存中加载一个对象。然而,这种实现在多线程代码中有一个挑战,在多线程代码中,对同一文件的磁盘调用可能会发生多次;也就是说,如果两个线程调用LoadImage方法或属性,会导致多次调用磁盘。因此,这里肯定需要通过锁或其他机制进行同步,这显然会增加维护开销,并且类实现可能会变得更加复杂。

因此,即使我们可以实现自己的惰性加载模式,在 C#中,我们也有System.Lazy类来处理这样的实现。使用System.Lazy类的一个主要优点是它是线程安全的。

System.Lazy类提供多个构造函数来实现惰性初始化。以下是我们可以利用的两种最常见的方法:

  • Wrapping the class around Lazy and using the Value method of that object to retrieve data. This is typically used for classes that have initialization logic in constructors. Some sample code follows:

            public class ImageFile
            {
                string fileName;
                public object LoadImage { get; set; }
                public ImageFile(string fileName)
                {
                    this.fileName = fileName;
                    this.LoadImage = $"File {fileName} 
                     loaded from disk";
                }
            }
    

    初始化此类时,我们将使用System.Lazy类的泛型类型,并将ImageFile类作为其类型传递,并将ImageFile的对象作为委托传递:

            Lazy<ImageFile> imageFile = new 
             Lazy<ImageFile>(() => new ImageFile("test"));
            var image = imageFile.Value.LoadImage;
    

    这里,如果在ImageFile类的构造函数中放置一个断点,那么只有在调用System.Lazy类的Value方法时才会被命中。

  • For classes that have a method to load various parameters, we can pass the method to the Lazy class as a delegate. Taking the same previous sample code and moving the file-retrieving logic to a separate method is shown here:

            public class ImageFile
            {
                string fileName;
                public object LoadImage { get; set; }
                public ImageFile(string fileName)
                {
                    this.fileName = fileName;
                }
                public object LoadImageFromDisk()
                {
                    this.LoadImage = $"File 
                     {this.fileName} loaded from disk";
                    return LoadImage;
                }
            }
    

    在初始化这个类时,我们将一个 lambda 传递给泛型委托,该泛型委托被传递来初始化System.Lazy类的一个对象,如下面的代码所示:

            Func<object> imageFile = new Func<object>(() 
             => { var obj = new ImageFile("test");
            return obj.LoadImageFromDisk(); });
            Lazy<object> lazyImage = new 
             Lazy<object>(imageFile);
            var image = lazyImage.Value;
    

这两种方式都将延迟对象的初始化,直到调用Value方法。我们需要注意的一件重要的事情是,虽然Lazy对象是线程安全的,但是通过值创建的对象不是线程安全的。因此,在这种情况下,lazyImage是线程安全的,但image不是。因此,它需要在多线程环境中同步。

一般来说,惰性初始化非常适合缓存类和单例类,并且可以进一步扩展到初始化成本较高的对象。

Lazy类的所有属性可以在https://docs.microsoft.com/en-us/dotnet/api/system.lazy-1?进一步查看视图= net-5.0 #定义。

虽然惰性初始化可以通过用System.Lazy类包装底层对象来实现,但是在中也有LazyInitializer静态类.NET,可通过其EnsureInitialized方法用于惰性初始化。

它有几个构造函数,正如 MSDN 文档中提到的:view=net-5.0。然而,想法是相同的,因为它期望一个对象和一个函数来填充该对象。举前面的例子,如果我们必须使用LazyInitializer.EnsureInitialized进行惰性初始化,我们需要将对象的实例和创建实际对象的 lambda 传递给LazyInitializer.EnsureInitialized,如下面的代码所示:

        object image = null;
        LazyInitializer.EnsureInitialized(ref image, () =>
            {
                var obj = new ImageFile("test");
                return obj.LoadImageFromDisk();
            });

这里我们是传递两个参数:一个是保存image类属性值的对象,另一个是创建image类对象并返回图像的函数。因此,这就像调用System.Lazy属性的Value属性一样简单,而不需要初始化对象的开销。

显然,使用LazyInitializer进行惰性初始化的一个额外的小优势是,没有额外的对象没有被创建,这意味着内存占用更小。另一方面,System.Lazy提供了可读性更强的代码。所以,如果有明确的空间优化,那就去找LazyInitializer;否则,使用System.Lazy获得更加清晰易读的代码。

理解锁、信号量和信号量限制

在前几节中,我们看到了如何在中使用各种 API.NET 来实现并行性。然而,当我们这样做的时候,我们需要额外注意共享变量。让我们以我们在本书中构建的企业电子商务应用为例。想想购买物品的工作流程。假设两个用户计划购买一个产品,只有一个商品可用。假设两个用户都将商品添加到购物车,用户 1 下订单,当订单通过支付网关处理时,用户 2 也尝试下订单。

在这种情况下,第二个订单应该失败(假设第一个订单成功),因为该书的数量现在是 0;只有对线程间的数量进行适当的同步,这种情况才会发生。此外,如果第一个订单在支付网关中失败或者用户 1 取消他们的交易,则第二个订单应该通过。因此,我们在这里说的是,在处理第一个订单时,数量应该被锁定,并且只有在订单完成时才应该被释放(以成功或失败告终)。在我们进入处理机制之前,让我们快速回顾一下关键部分是什么。

临界截面和螺纹安全

关键部分是应用中读取/写入由多个线程使用的变量的部分。我们可以把这些看作是跨应用使用的全局变量,在不同的时间或同一时间在不同的地方被修改。在多线程场景中,在任何时间点都只允许一个线程修改这些变量,并且只允许一个线程进入临界区。如果应用中没有这样的变量/部分,它可以被认为是线程安全的。因此,在应用中识别那些不是线程安全的变量并相应地处理它们总是明智的。为了保护对临界区的访问不受非线程安全变量的影响,有各种可用的构造,称为同步原语或同步构造,主要分为两类:

  • 锁定构造:允许一个线程进入临界区保护对共享资源的访问,所有其他线程等待,直到锁被获取的线程释放。
  • 信号结构:这些允许线程通过发信号通知资源的可用性来进入关键部分,就像在生产者-消费者模型中一样,生产者锁定资源,消费者等待信号而不是轮询。

让我们在下一节讨论一些同步原语。

引入锁

锁是一个基本类,允许您在多线程代码中实现同步,其中锁语句之间的任何变量只能由一个线程访问。在锁中,获取锁的线程需要释放锁,在此之前,任何试图进入锁的其他线程都会进入等待状态。可以创建一个简单的锁,如下面的代码所示:

            object locker = new object();
            lock (locker)
            {
                quantity--;
            }

第一个执行此代码的线程将获取锁,并在代码块完成后释放锁。也可以使用Monitor.Enter和Monitor.Exit获取锁,实际上使用锁编译器会将线程内部转换为Monitor.Enter和Monitor.Exit。关于锁的几个要点如下:

  • 由于它们的线程关联性,它们应该总是用在引用类型上。
  • 就性能而言,它们非常昂贵,因为它们会在允许线程恢复之前暂停想要进入关键部分的线程,这增加了一些延迟。
  • 双重检查获取锁也是一个很好的实践,就像在单例实现中是如何做到的一样。

锁确实有一些问题:

  • 您需要锁定正在被修改或枚举的共享数据/对象。在应用中很容易遗漏关键部分,因为关键部分更像是一个逻辑术语。如果关键部分周围没有任何锁,编译器不会标记它。

  • 如果处理不当,你可能会陷入僵局。

  • Scalability is a problem as only one thread can access a lock at a time, while all other threads have to wait.

    注意

    还有一个重要的概念叫做原子性。只有当没有任何方法可以读取变量的中间状态或将中间状态写入变量时,操作才是原子的。例如,如果一个整数值从 2 修改为 6,任何读取该整数值的线程将只能看到 2 或 6;没有一个线程会看到该线程的中间状态,其中整数只被部分更新。任何线程安全的代码都会自动保证原子性。

    使用并发集合(将在后面一节中介绍)而不是锁,因为并发集合在内部处理锁定关键部分。

互斥体(仅限窗口)

互斥体也是锁的一种,它不仅支持锁定进程内的资源,还支持跨多个进程锁定资源。可以使用System.Threading.Mutex类创建互斥体,任何想要进入临界区的线程都需要调用WaitOne方法。通过ReleaseMutex方法释放互斥体;因此,我们基本上创建了一个System.Threading.Mutex类的实例,并调用WaitOne / ReleaseMutex来进入/退出临界区。以下是关于互斥体的几个要点:

  • 互斥体有线程亲缘关系,所以调用WaitOne的线程需要调用ReleaseMutex。
  • System.Threading.Mutex类的构造函数接受互斥体的名称,该互斥体可用于跨进程共享。

引入信号量和信号量限制

信号量是一种非排他锁,它通过允许多个线程进入一个关键部分来支持同步。然而,与排他锁不同,信号量用于需要限制对资源池的访问的场景,例如,允许应用和数据库之间固定数量的连接的数据库连接池。回到我们在电子商务应用中购买产品的例子,如果一个产品的可用数量是 10,这意味着 10 个人可以将这个项目添加到他们的购物车中并下订单。如果同时下 11 个订单,应允许 10 个用户下订单,第 11 个订单应暂停,直到前 10 个订单完成。

英寸 NET 中,可以通过创建System.Threading.Semaphore类的实例并传递两个参数来创建信号量:

  • 活动请求的初始数量
  • 同时允许的请求总数

下面是一个创建信号量的简单代码片段:

Semaphore quantity = new Semaphore(0, 10);

在这种情况下,0表示没有请求获得共享资源,最多允许 10 个并发请求。获取共享资源需要调用WaitOne(),释放资源需要调用Release()方法。

为了创建信号量,中还有另一个轻量级类.NET,这就是SemaphoreSlim,苗条的版本,通常依赖于一个叫做旋转的概念。在这种情况下,无论何时需要锁定共享资源,而不是立即锁定资源,SemaphoreSlim使用一个运行几微秒的小循环,这样它就不必经历阻塞、上下文切换和内部内核转换的昂贵过程(信号量使用 Windows 内核信号量来锁定资源)。最终,如果共享资源仍然需要锁定,则SemaphoreSlim返回到锁定状态。

创建SemaphoreSlim实例几乎与创建信号量完全相同;唯一不同的是,对于锁定,它有WaitAsync而不是WaitOne。还有CurrentCount可用,告诉我们获取的锁数。

关于信号量和SemaphoreSlim的一些关键事实如下:

  • 由于信号量是用来访问资源池的,信号量和SemaphoreSlim没有线程亲缘关系,任何线程都可以释放资源。
  • Semaphore班在.NET Core 支持命名信号量。命名信号量可用于跨进程锁定资源;但是,SemaphoreSlim类不支持命名信号量。
  • 与Semaphore不同,SemaphoreSlim类支持异步方法和取消,这意味着它可以很好地与异步等待方法一起使用。async-wait 关键字有助于编写非阻塞异步方法,这将在本章后面介绍。

选择正确的同步结构

还有其他信号结构要覆盖;下表为您提供了它们的高级使用视图和真实例子:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/01_-_01.jpg

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/02_-_01.jpg

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/03_-_01.jpg

表 4.1

到目前为止,我们已经涵盖了以下内容:

  • 使用Thread和ThreadPool类多线程的各种方式及其局限性
  • 惰性初始化的重要性及其在多线程环境中的作用
  • 中可用的各种同步构造。网

当我们创建一些交叉组件时,我们将在后面的章节中使用这些概念。

在下一节中,我们将看到如何通过任务和第三方物流的使用来克服Thread和ThreadPool的限制。

介绍任务和对比

我们知道异步编程有助于我们的应用更好地扩展和响应,所以实现异步应用不应该成为开发人员的开销。Thread和ThreadPool在帮助实现异步的同时,增加了大量开销,也带来了局限性。因此,微软提出了任务,这使得开发异步应用变得更加容易。事实上,大多数更新的 API 都在.NET 5 只支持异步的编程方式。例如,通用 Windows 平台 ( UWP )甚至没有公开 API 来创建没有任务的线程。因此,理解任务和第三方语言是使用 C#编写异步程序的基础。我们将在这一节深入探讨这些主题,稍后,我们将看到 c# async-wait 关键字与第三方语言相结合如何简化异步编程。

任务和第三方物流简介

异步编程背后的思想是没有线程应该等待一个操作;也就是说,框架应该有能力将一个操作包装到某种抽象中,然后一旦操作完成就恢复,而不阻塞任何线程。这个抽象只不过是Task类,它通过System.Threading.Tasks公开,有助于在. NET 中编写异步代码

Task类简化了包装任何等待操作,无论是从数据库中检索的数据、从磁盘加载到内存中的文件,还是任何高 CPU 密集型操作,简化了在单独的线程上运行它,如果需要的话。它具有以下重要特征:

  • Task支持操作通过其泛型类型Task<T>完成后返回值。
  • Task负责调度ThreadPool上的线程,并相应地负责分区操作和调度来自ThreadPool的多个线程,所有这些都是在抽象执行它的复杂性的同时完成的。
  • 报告完成支持通过CancellationToken取消,通过IProgress进度报告。
  • Task支持创建子任务,管理子任务和父任务之间的关系。
  • 即使对于多层次父/子任务,异常也会传播到调用应用。
  • 最重要的是,Task支持异步等待,一旦任务中的操作完成,这有助于恢复调用应用/方法中的处理。

第三方物流是由提供的一组应用编程接口.NET,并提供创建和管理任务的方法。任务可以通过创建一个System.Threading.Tasks.Task类的对象并传递一段需要在任务上执行的代码来创建。我们可以通过多种方式创建任务:

  • 您可以创建一个Task类的对象,并传递一个 lambda 表达式。这种方法需要显式启动,如下面的代码所示:

                Task dataTask = new Task(() => 
                 FetchDataFromAPI("https://foo.com/api"));
                dataTask.Start();
    
  • 也可以使用Task.Run创建任务,如下代码所示,支持在不显式调用Start() :

    Task dataTask = Task.Run(() => FetchDataFromAPI ("https://foo.com/api")); 
    

    的情况下创建并启动任务

  • 创建任务的另一种方法是使用Task.Factory.StartNew :

    Task dataTask = Task.Factory.StartNew(() => FetchDataFromAPI("https://foo.com/api"));
    

在所有这些方法中,ThreadPool线程用于运行FetchDataFromAPI方法,通过dataTask对象被引用,该对象被返回给调用方以跟踪操作/异常的完成。由于此任务将在ThreadPool线程上异步执行,并且所有ThreadPool线程都是后台线程,因此应用不会等待FetchDataFromAPI方法完成。第三方物流公开了一种等待任务完成的Wait方法,类似于dataTask.Wait()。下面是一个使用任务的小型控制台应用的代码片段:

    class Program
    {
        static void Main(string[] args)
        {
            Task t = Task.Factory.StartNew(() => 
             FetchDataFromAPI("https://foo.com"));
            t.Wait();
        }
        public static void FetchDataFromAPI(string apiURL)
        {
            Thread.Sleep(2000);
            Console.WriteLine("data returned from API");
        }
    }

在这个片段中,我们使用了一个 lambda 表达式。但是,它可以是委托或操作委托(在无参数方法的情况下),因此类似下面这样的东西也可以用来创建任务:

Task t = Task.Factory.StartNew(delegate { FetchDataFromAPI("https://foo.com");});

无论哪种方式,您都会收到对Task对象的引用,并对其进行相应的处理。如果一个方法是返回值,那么我们可以使用通用版本的Task类,并使用Result方法从Task中检索数据。例如,如果FetchDataFromAPI返回一个字符串,我们可以使用Task<String>,如下面的代码片段所示:

            Task<string> t = 
             Task.Factory.StartNew<string>(()
             => FetchDataFromAPI(""));
            t.Wait();
            Console.WriteLine(t.Result);

这些方法都有各种附加参数,一些重要的参数如下:

  • 使用使用CancellationTokenSource类生成的CancellationToken类的对象取消。
  • 通过TaskCreationOptions枚举控制任务创建和执行的行为。
  • TaskScheduler的自定义实现,控制任务如何排队。

TaskCreationOptions是第三方物流中的一个枚举,它告诉TaskScheduler我们正在创建什么样的任务。例如,我们可以创建一个长期运行的任务,如下所示:

Task<string> t = Task.Factory.StartNew<string>(() => FetchDataFromAPI(""), TaskCreationOptions.LongRunning);

虽然这不能保证更快的输出,但它更像是对调度程序本身的一个提示,以进行优化。例如,如果调度程序看到一个长时间运行的任务正在被调度,它可以启动更多的线程。这个枚举的所有选项都可以在https://docs . Microsoft . com/en-us/dotnet/API/system . threading . tasks . taskscreationoptions?view=net-5.0 。

Task还支持一起等待多个任务,通过创建并将所有任务作为参数传递给以下方法:

  • WaitAll:等待所有任务完成,阻塞当前线程。不建议用于应用开发。
  • WhenAll:等待所有任务完成,不阻塞当前线程。通常与异步等待一起使用。推荐用于应用开发。
  • WaitAny:等待其中一个任务完成,并阻塞当前线程,直到此时。不建议用于应用开发。
  • WhenAny:等待其中一个任务完成,不阻塞当前线程。通常与异步等待一起使用。不建议用于应用开发。

与线程不同,任务具有全面的异常处理支持。让我们在下一节看到这一点。

处理任务异常

任务中的异常处理很简单,在任务周围写一个try块,然后捕捉异常,这些异常通常被包裹在AggregateException中;也就是说,它就像下面的代码片段一样简单:

            try
            {
                Task<string> t = 
                 Task.Factory.StartNew<string>(()
                 => FetchDataFromAPI(""));
                t.Wait();
            }
            catch (AggregateException agex)
            {
                //Handle exception
                Console.WriteLine(agex.InnerException.Message);
            }

在前面的代码中,agex.InnerException将给出实际的异常,因为我们正在等待一个任务。然而,如果我们在等待多个任务,我们可以循环通过的将是InnerExceptions集合。此外,它还带有一个Handle回调方法,可以在catch块中订阅,回调一旦被触发就会有关于异常的信息。

与前面的代码一样,对于传播异常的任务,我们需要调用Wait方法或其他阻塞构造,如WhenAll来触发catch阻塞。然而,在引擎盖下,Task的任何异常实际上都保存在Task类的Exception属性中,该属性属于AggregateException类型,可以观察到任务中的任何潜在异常。

此外,如果任务是附加子任务或嵌套任务的父任务,或者您正在等待多个任务,则可能会引发多个异常。为了将所有异常传播回调用线程,基础设施将它们包装在一个AggregateException实例中。

关于处理异常的更多细节可以在 https://docs . Microsoft . com/en-us/dotnet/standard/parallel-programming/exception-handling-task-parallel-library 上找到。

实施任务取消

.NET 提供两个主要类来支持任务的取消:

  • CancellationTokenSource:创建取消令牌并支持通过Cancel方法取消令牌的类
  • CancellationToken:监听取消并在任务被取消时触发通知的结构

对于取消任务,有两种取消类型:一种是任务被错误执行,需要立即取消,另一种是任务已经开始,需要中途停止(中止)。对于前者,我们可以创建一个支持取消的任务。我们使用第三方应用编程接口,并将取消令牌传递给构造函数,如果任务需要取消,则调用CancellationTokenSource类的Cancel方法,如以下代码片段所示:

            cts = new CancellationTokenSource();
            CancellationToken token = cts.Token;
            Task dataFromAPI = Task.Factory.StartNew(()
             => FetchDataFromAPI(new List<string> {
                "https://foo.com",
                "https://foo1.com",}), token);
            cts.Cancel();

所有的。支持异步调用的 NET Core APIs,比如HttpClient类的GetAsync / PostAsync,有接受取消令牌的重载。对于后一种情况(中止任务),决定基于将要运行的操作是否支持取消。假设它支持取消,我们可以将取消令牌传递给方法,并在方法调用中检查取消令牌的IsCancellationRequested属性并进行相应的处理。

让我们创建一个简单的控制台应用,创建一个支持取消的任务。这里我们正在创建一个FetchDataFromAPI方法,它接受一个 URL 列表,并从这些 URL 中检索数据。该方法还支持使用CancellationToken取消。在实现中,我们遍历 URL 列表并继续,直到请求取消或者循环完成所有迭代:

    class Program
    {
        static void Main(string[] args)
        {}
        public static string FetchDataFromAPI(List<string> 
         apiURL, CancellationToken token)
        {
            Console.WriteLine("Task started");
            int counter = 0;
            foreach (string url in apiURL)
            {
                if (token.IsCancellationRequested)
                {
                    throw new TaskCanceledException($"data 
                     from API returned up to iteration 
                       {counter}");
                    //throw new OperationCanceledException($"data from API returned up to iteration {counter}"); // Alternate exception with same result
                    //break; // To handle manually 
                }
                Thread.Sleep(1000);
                Console.WriteLine($"data retrieved from 
                 {url} for iteration {counter}");
                counter++;
            }
            return $"data from API returned up to iteration 
             {counter}";
        }
    }

现在用主方法中的四个网址列表调用FetchDataFromAPI,如下面的代码所示。这里我们使用CancellationTokenSource类的Token属性创建CancellationToken,并将其传递给FetchDataFromAPI方法。我们正在模拟 3 秒后的取消,以便在检索第四个网址之前取消FetchDataFromAPI:

CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
Task<string> dataFromAPI;
try
{
    dataFromAPI = Task.Factory.StartNew<string>(() => 
     FetchDataFromAPI(new List<string> {
    "https://foo.com","https://foo1.com","https://foo2.com","https://foo3.com",
    "https://foo4.com",
}, token));
    Thread.Sleep(3000);
    cts.Cancel(); //Trigger cancel notification to cancellation token
    dataFromAPI.Wait(); // Wait for task completion
    Console.WriteLine(dataFromAPI.Result); // If task is completed display message accordingly
}
catch (AggregateException agex)
{// Handle exception}

一旦我们运行了这段代码,我们可以看到三个网址的输出,然后是一个异常/中断(基于在FetchDataFromAPI方法中被注释掉的那一行)。

在前面的示例中,我们使用for循环和Thread.Sleep模拟了一个长时间运行的代码块,取消了任务并相应地处理了代码。但是,可能会出现长时间运行的代码块不支持取消的情况。在这些情况下,我们必须编写一个接受取消令牌的包装器方法,并让包装器在内部调用长时间运行的操作;然后,在主方法中,我们调用包装代码。下面的代码片段显示了一个使用TaskCompletionSource的包装方法,这是第三方物流中的另一个类。它是用来通过类中可用的Task属性将非基于任务的异步方法(甚至包括基于异步方法的方法)转换为任务。在这种情况下,我们将取消令牌传递给TaskCompletionSource,以便其Task相应更新:

        private static Task<string> 
         FetchDataFromAPIWithCancellation(List<string> 
         apiURL, CancellationToken cancellationToken)
        {
            var tcs = new TaskCompletionSource<string>();
            tcs.TrySetCanceled(cancellationToken);
            // calling overload of long running operation that doesn't support cancellation token
            var dataFromAPI = Task.Factory.StartNew(() =>
             FetchDataFromAPI(apiURL));
            // Wait for the first task to complete
            var outputTask = Task.WhenAny(dataFromAPI, 
             tcs.Task);
            return outputTask.Result;
        }

在这种情况下,通过TaskCompletionSource的Task属性跟踪CancellationToken,我们创建了另一个任务来调用我们长期运行的操作(没有取消令牌支持的那个),无论哪个任务先完成,都是我们返回的那个。

当然,需要更新Main方法来调用这里所示的包装器(其余代码保持不变):

            dataFromAPI = Task.Factory.StartNew(() => 
             FetchDataFromAPIWithCancellation(new 
             List<string>
                {
                        "https://foo.com",
                        "https://foo1.com",
                        "https://foo2.com",
                        "https://foo3.com",
                        "https://foo4.com",
                    }, token)).Result;

这不会取消基础方法,但仍允许应用在基础操作完成之前退出。

任务取消是一种非常有用的机制,有助于减少尚未开始或已经开始但需要停止/中止的任务中不必要的处理。因此,中的所有异步 API.NET 不支持取消。

实现延续

在企业应用中,大多数情况下需要创建多个任务,构建任务的层次结构,创建相关任务,或者创建任务之间的子/父关系。任务延续可以用来定义这样的子任务/子任务。它像 JavaScript 承诺的那样工作,并支持将任务链接到多个级别。就像承诺一样,层次结构中的后续任务在第一个任务之后执行,这可以进一步链接到多个级别。

实现任务延续的方法有很多种,但最常见的方法是使用Task类的ContinueWith方法,如下例所示:

class Program
    {
        static void Main(string[] args)
        {
            Task.Factory.StartNew(() => Task1(1)) // 1+2 = 3
                .ContinueWith(a => Task2(a.Result)) // 3*2 = 6
                    .ContinueWith(b => Task3(b.Result))// 6-2=4
                        .ContinueWith(c => 
                          Console.WriteLine(c.Result)); 
            Console.ReadLine();
        }
        public static int Task1(int a) => a + 2;
        public static int Task2(int a) => a * 2;
        public static int Task3(int a) => a - 2;
}

正如你可能已经猜到的,这里的输出将是4,并且一旦前一个任务的执行完成,每个任务就会执行。

ContinueWith接受一个名为TaskContinuationOptions的重要枚举,支持不同条件下的延续。例如,我们可以通过TaskContinuationOptions.OnlyOnFaulted作为参数来创建一个延续任务,当前面的任务出现异常时执行,或者通过TaskContinuationOptions.AttachedToParent来创建一个延续任务,强制父子关系,并强制父任务仅在子任务之后完成执行。

就像WhenAll、WhenAny一样,ContinueWith也有类似的兄弟姐妹,如下:

  • Task.Factory.ContinueWhenAll:这将接受多个任务引用作为参数,并在所有任务完成时创建一个延续。
  • Task.Factory.ContinueWhenAny:这将接受多个任务引用作为参数,并在引用的任务之一完成时创建延续。

抓住任务继续对于理解异步等待的幕后工作至关重要,我们将在本章后面讨论。

同步上下文

SynchronizationContext是System.Threading中的一个抽象类,有助于线程间的通信。例如,从并行任务更新 UI 元素需要线程连接回 UI 线程并恢复执行。SynchronizationContext主要通过这个类的Post方法提供这种抽象,该方法接受一个委托在稍后的阶段执行。所以,在前面的例子中,如果我需要更新一个 UI 元素,我需要取 UI 线程的SynchronizationContext,调用它的Post方法,传递必要的数据来更新 UI 元素。

由于SynchronizationContext是一个抽象类,这个类有多种派生类型。比如 Windows 窗体有WindowsFormsSynchronizationContext,WPF 有DispatcherSynchronizationContext,等等。

SynchronizationContext作为一个抽象的主要优点是,不管Post方法的覆盖实现如何,它都有助于对委托进行排队。

任务调度器

当我们使用前面描述的各种方法创建任务时,我们看到一个任务在ThreadPool线程上被调度,但是问题是谁或者什么实际上做了那件事。System.Threading.Tasks.TaskScheduler是第三方物流中负责在ThreadPool线程上排队和执行任务委托的类。

当然,这是一个抽象类,框架附带了两个派生类:

  • ThreadPoolTaskScheduler
  • SynchronizationContextScheduler

TaskScheduler公开一个Default属性,默认设置为ThreadPoolTaskScheduler。因此,默认情况下,所有任务都被调度到ThreadPool线程;然而,图形用户界面应用通常使用SynchronizationContextScheduler,以便任务可以成功返回并更新用户界面元素。

.NET Core 附带了复杂的派生类型TaskScheduler和SynchronizationContext类。然而,它们在 async-wait 中起着主要作用,并且它们有助于快速调试任何与死锁相关的问题。

请注意,查看TaskScheduler和SynchronizationContext的内部工作方式超出了本书的范围,留给您作为练习。

实现数据并行

数据并行就是将一个源集合划分成多个可并行执行的任务,这些任务并行执行相同的操作。对于第三方物流,这在Parallel静态类中是可用的,该类公开了像For和ForEach这样的具有多个重载的方法来处理这样的执行。

假设你有一百万个数字的集合,你需要找到质数。数据并行性在这里非常有用,因为集合可以分成多个范围,并且可以计算质数。一个典型的并行for循环如下面的代码片段所示:

            List<int> numbers = Enumerable.Range(1, 
             100000).ToList();
            Parallel.For(numbers.First(), numbers.Last(), x 
             => CalculatePrime(x));

然而,一个更现实的例子是类似图像处理应用的东西,它需要处理图像中的每个像素,并将每个像素的亮度降低五个点。这种操作可以极大地受益于数据并行性,因为每个像素彼此独立,因此可以并行处理。

同样的,在Parallel静态类中有一个ForEach方法,可以使用如下:

Parallel.ForEach(numbers, x => CalculatePrime(x));

这里列出了使用Parallel.For和Parallel.ForEach进行数据并行的一些关键优势:

  • 适合取消循环;他们在一个常规的for循环中工作,就像break一样。在Parallel.For中,这是通过将ParallelStateOptions传递给代表,然后调用ParallelStateOptions.Break来支持的。当其中一个任务遇到Break时,ParallelStateOptions类的LowestBreakIteration属性被设置,所有并行任务将迭代,直到达到该数量。ParallelLoopResult是Parallel.For和Parallel.ForEach的返回类型,具有IsCompleted属性,表示循环是否过早执行。

  • 他们也支持通过ParallelStateOptions.Stop立即停止循环。还有Parallel.For / Parallel.ForEach的部分构造函数接受取消代币,也可以用来模拟ParallelStateOptions.Stop;然而,一个环应该被包裹在一个try…catch块内,因为OperationCanceledException会被抛出。

  • 如果其中一个任务抛出异常,所有任务将完成当前迭代,然后停止处理。和任务一样,AggregateException被抛出。

  • 通过传递ParallelOptions和设置MaxDegreeOfParallelism来支持并行度,这将控制任务可以并行执行的内核数量。

  • 通过范围分区或块分区支持源集合的自定义分区。

  • 支持线程或分区范围内的线程安全局部变量。

  • 支持嵌套Parallel.For循环,它们的同步是自动处理的,不引入任何手动同步。

  • Thread-safety: If each iteration uses a shared variable, synchronization needs to be implemented explicitly. So, to gain the most out of data parallelism, use it for operations that can execute independently for each iteration without depending on shared resources.

    小费

    数据并行应该谨慎使用,因为有时会被误用。就像 4 个人分 40 个任务。如果在 4 个人之间组织这项工作(拆分和合并)比仅仅执行 40 个任务的整体工作要多得多,那么数据并行不是正确的选择。进一步阅读请参考 https://docs . Microsoft . com/en-us/dotnet/standard/parallel-programming/data-parallelism-task-parallel-library。

使用平行 LINQ (PLINQ)

PLINQ 是 LINQ 的并行实现;这是ParallelEnumerable类中可用的一组 API,支持并行执行 LINQ 查询。让 LINQ 查询并行运行的最简单方法是在 LINQ 查询中嵌入AsParallel方法。请参见下面的代码片段,它调用一个计算 1000 个数字的质数的方法:

List<int> numbers = Enumerable.Range(1, 1000).ToList();
var resultList = numbers.AsParallel().Where(I => CalculatePrime
(i)).ToList();

使用 LINQ 查询语法,如下所示:

var primeNumbers = (from i in numbers.AsParallel()            
                                    where CalculatePrime(i)    
                                            select i).ToList();

在内部,这个查询被分成多个较小的查询,在每个处理器上并行执行,因此加快了查询速度。分区的源需要在主线程上合并回来,这样结果(输出集合)就可以循环通过,以便进一步处理/显示。

让我们创建一个控制台应用,使用 PLINQ 结合Parallel.For打印给定范围内的所有素数。加入以下方法,取一个数,如果是质数则返回true,否则返回false:

    class Program
    {
        static void Main(string[] args)
        {           
        }
        static bool CalculatePrime(int num)
        {
            bool isDivisible = false;
            for (int i = 2; i <= num / 2; i++)
            {
                if (num % i == 0)
                {
                    isDivisible = true;
                    break;
                }
            }
            if (!isDivisible && num != 1)
                return true;
            else
                return false;
        }
    }

现在在主方法中,添加以下代码,它创建了一个前 100 个数字的列表我们将使用 PLINQ 循环遍历,然后将其传递给CalculatePrime方法;然后,我们将最终使用Parallel.ForEach显示质数列表:

List<int> numbers = Enumerable.Range(1, 100).ToList();
try
{
  var primeNumbers = (from number in numbers.AsParallel() where CalculatePrime(number) == true                                    select number).ToList();
  Parallel.ForEach(primeNumbers, (primeNumber) =>
  {
    Console.WriteLine(primeNumber);
  });
}
catch (AggregateException ex)
{
  Console.WriteLine(ex.InnerException.Message);
}

这个示例的输出将是一个素数列表;但是,可以看到输出不会是升序的质数,而是随机的顺序,因为CalculatePrime方法是用多个数字并行调用的。

下面是前面代码的内部工作图:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_4.4_B15927.jpg

图 4.4–PLINQ 和平行。为每一个

PLINQ 还提供了一种处理每个分区/线程结果的方法,无需使用ForAll将结果合并到调用线程的开销,前面的代码可以进一步优化如下:

            (from i in numbers.AsParallel()
             where CalculatePrime(i) == true
             select i).ForAll((primeNumber) => 
               Console.WriteLine(primeNumber));

小费

玩 LINQ/PLINQ 最好的工具之一是 LINQPad 我绝对推荐你从https://www.linqpad.net/Download.aspx下载。

PLINQ 需要记住的一些重要事情如下:

  • 将结果合并到主线程可以通过使用WithMergeOption方法并通过ParallelMergeOperation枚举传递适当的值来配置。
  • 与其他并行扩展一样,任何异常都作为AggregateException返回,所有迭代的执行立即停止。当然,如果异常被包含在委托中而不是被抛出,那么执行可以继续。
  • 还有各种其他的扩展方法,比如AsSequential和AsOrdered,这些可以组合在一个单独的 LINQ 查询中。比如基于此,AsSequential可以和AsParallel结合,让一些分区可以顺序运行,其他分区可以并行执行。
  • 支持使用WithCancellation方法取消。
  • 通过WithDegreeOfParallelism支持平行度。

数据并行和 PLINQ 提供了很多 API,可以用来快速启用代码的并行执行,而不会给应用逻辑增加任何额外的开销。但是,正如上一节所解释的,它们之间有细微的区别,因此应该有不同的用法。

小费

PLINQ 和第三方物流共同构成了并行扩展。

在本节中,我们在许多地方使用了Thread.Sleep,但这主要是为了模拟长时间运行的操作;但是,不建议您在生产中使用它。

在下一节中,我们将看到如何将任务与异步等待联系起来,并在企业应用中使用异步等待。

引入异步等待

到目前为止,我们已经讨论了使用任务编写异步代码,以及第三方物流如何简化任务的创建和管理。然而,任务主要依靠延续、回调或事件在任务完成后继续执行。在企业应用中,管理这样的代码会很困难;如果链接了太多任务,任何运行时异常都很难调试。这就是 C#引入 async-wait 的地方,async-wait 是 C# 5.0 中引入的一种语言特性,它简化了异步代码的编写,使其更具可读性和可维护性,改进了异常处理,并使调试变得容易。所以,让我们开始异步等待。

async是 C#中的一个关键字,用作修饰符,当作为任何方法(或 lambda)的前缀时,会将方法转换为状态机,使该方法能够在其主体中使用await关键字。

await是 C#中的一个关键字,用作运算符,后跟一个返回可调用对象(通常是任务)的表达式。await只能在有async修饰符的方法中使用,一旦调用方遇到await语句,控制就会返回,事情就会恢复;await之后,使用延续完成任务。

基于任务的异步模式

基于任务的异步模式 ( TAP )是一种用于实现异步方法的模式,在这种模式中,我们使用async修饰符,然后在包装在任务中的异步操作上使用await(或任何暴露GetAwaiter()的自定义唤醒类型)。简单来说,这种模式包括使用一个具有async修饰符并返回任务的方法来表示异步操作;使用await进一步等待任何异步操作。下面是一个示例代码片段,它异步下载文件,并使用 TAP 实现:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_4.5_B15927.jpg

图 4.5–使用异步等待的异步方法示例

在上图中,控制流程如下(使用图中的数字标签):

  1. 应用使用Main方法开始执行。由于Main以async方法为前缀,它被转换成实现状态机的类型。执行继续,直到在await DownloadFileAsync遇到await,线程返回给调用者。
  2. 在返回给调用者之前,对DownloadFileAsync方法的调用被存储在Task对象中,并且对Task对象的引用也被保留。Main方法的剩余代码包装在该任务的延续中。
  3. 一个ThreadPool线程将开始执行一个DownloadFileAsync方法,并重复相同的步骤;也就是说,它将一个方法转换成实现状态机的类型,继续执行直到遇到await,然后被引用的任务被传递回来;剩余的代码被移到该任务的继续部分。
  4. 在某个时刻,当DownloadDataTaskAsync方法完成时,任务继续被触发,并将执行剩余的代码。
  5. 重复该过程,直到引用DownloadFileAsync的任务完成并执行其继续,在这种情况下为Console.WriteLine("File downloaded!!"),然后应用退出。

在粗略的高级别,代码将被转换,如下所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_4.6_B15927.jpg

图 4.6–转换后的示例异步方法

虽然这是对异步等待的幕后工作的过度简化,但我们可以看到编译器做了很多繁重的工作,包括生成一个实现状态机的类型,并使用回调的状态继续执行。

我们已经看到编写异步方法是多么简单,在本书的整个过程中,我们将在企业应用中编写许多这样的方法。然而,异步等待不是万灵药;它不是每个应用问题的答案。我们需要验证某些因素来使用异步等待。让我们看看使用异步等待的原则是什么。

注意

如果有SynchronizationContext,前面的代码会稍有变化。例如,在 Windows 窗体或 WPF 应用中,使用SynchronizationContext或TaskScheduler.FromCurrentSynchronizationContext的Post方法在当前的SynchronizationContext上发布延续。根据标准的命名约定,为了可读性,异步方法以单词async作为后缀,但是在语法上并不需要。

异步等待的使用原则

当我们开始使用异步等待时,有一些推荐的做法可以让应用利用异步原理。例如,对于嵌套调用,我们应该始终使用 async-wait;不要使用.Result等等。这里有一些指导方针可以帮助你有效地使用异步等待。

链异步-一路等待

使用 async-await 实现的异步方法应该从 async-await 方法中触发,以便正确等待。如果我们试图使用任务的Result方法或Wait方法从同步方法调用异步方法,可能会导致死锁。让我们来看下面来自 WPF 应用的代码片段,该应用通过单击按钮从网络下载文件。然而,我们没有等待对异步方法的调用,而是使用了Task的Result方法:

        private void Button_Click(object sender, 
         RoutedEventArgs e)
        {
            var task = DownloadFileAsync("https://github.com/Ravindra-a/largefile/blob/master/README.md", @$"{System.IO.Directory.GetCurrentDirectory()}\download.txt");
            bool fileDownload = task.Result; // Or task.GetAwaiter().GetResult()
            if (fileDownload)
            {
                MessageBox.Show("file downloaded");
            }
        }
        private async Task<bool> DownloadFileAsync(string 
         url, string path)
        {
            // Create a new web client object
            using WebClient webClient = new WebClient();
            // Add user-agent header to avoid forbidden errors.
            webClient.Headers.Add("user-agent", 
              "Mozilla/5.0 (Windows NT 10.0; WOW64)");
            byte[] data = await 
              webClient.DownloadDataTaskAsync(url);
            // Write data in file.
            Using var fileStream = File.OpenWrite(path);
            {
                await fileStream.WriteAsync(data, 0, 
                 data.Length);
            }
            return true;
        }

在此方法中,await webClient.DownloadDataTaskAsync(url);之后的代码将永远不会执行,原因如下:

  • 一旦遇到等待,参考对象通过GetAwaiter方法在TaskAwaitable中捕捉SynchronizationContext。
  • 一旦async操作完成,该await的继续需要在SynchronizationContext上执行(通过SynchronizationContext.Post)。
  • 然而,SynchronizationContext已经被阻塞,因为点击按钮对task.Result的调用在同一个SynchronizationContext上,并且正在等待DownloadDataTaskAsync完成,因此导致死锁。

所以,千万不要挡async法;一路异步的最好方法。因此,在前面的代码中,您将调用更改为await DownloadFileAsync(按钮点击的async void–await需要一个方法来拥有async修饰符)。

注意

同样的代码在 ASP.NET Core 5 应用中运行良好,不会导致死锁,因为 ASP.NET Core 5 没有SynchronizationContext并且延续在ThreadPool线程上执行,不涉及任何请求上下文;但是,即使在 ASP.NET Core 5 中,仍然不建议阻塞异步调用。

配置等待

在前面的讨论中,因为我们有端到端的应用代码,所以更容易找到死锁的原因。但是,如果我们正在开发一个可以在 WPF、ASP.NET Core 5 或.NET Framework 应用,我们需要确保库内的异步代码不会导致死锁,即使调用方可能正在通过同步方法(GetAwaiter().GetResult())使用库方法。

在这种情况下,Task提供了一个名为ConfigureAwait的方法,该方法接受一个布尔值,当true将使用调用方的原始上下文,当false将在await之后恢复操作,而不依赖于原始上下文。通俗地说,await之后的任何代码都将独立执行,而与发起请求的上下文状态无关。

使用ConfigureAwait(false),特别是如果你正在实现一个库方法,因为它将避免在原始上下文上运行一个延续。对于库方法,必须使用ConfigureAwait(false),因为它们永远不应该依赖于调用/原始上下文来继续。例如,以下代码不会导致死锁:

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            string output = GetAsync().Result; //Blocking code, ideally should cause deadlock.
            MessageBox.Show(output);
        }
        //  Library code         
        public async Task<string> GetAsync()
        {
            var uri = new Uri("http://www.google.com");
            return await new HttpClient().
             GetStringAsync(uri).ConfigureAwait(false);
        }

默认情况下,每个await表达式都有ConfigureAwait(true),所以建议尽可能显式调用ConfigureAwait(false)。除了避免死锁之外,ConfigureAwait(false)还提高了性能,因为没有原始上下文的封送处理。

这就把我们带到了是否有场景需要使用ConfigureAwait(true)的问题。答案是,在某些场景中,正在构建的自定义SynchronizationContext需要回调使用,然后建议使用ConfigureAwait(true),或者至少不要使用ConfigureAwait(false),因为任何任务的默认行为都与ConfigureAwait(true)相同。

中央处理器限制与输入/输出限制

总是使用异步等待来处理输入输出绑定的工作,使用第三方语言来处理中央处理器绑定的工作来实现异步。诸如数据库调用、网络调用和文件系统调用等输入/输出操作可以用异步等待异步方法包装。然而,像计算 pi 这样的 CPU 密集型操作最好使用第三方物流来处理。

回到我们前面的讨论,异步编程的思想是释放ThreadPool线程,而不是等待操作完成。当我们将出站呼叫表示为任务并使用异步等待时,这很容易实现。

然而,对于一个 CPU 密集型操作来说,ThreadPool线程将继续在工作线程上执行指令(因为这是一个 CPU 密集型操作,需要 CPU 时间),并且显然不能释放该线程。这意味着在异步等待中包装一个 CPU 密集型操作不会产生任何好处,这与同步运行它是一样的。因此,处理 CPU 密集型操作的更好方法是使用第三方物流。

这并不意味着我们会在遇到 CPU 密集型方法时停止使用异步等待。推荐的方法是仍然使用异步等待来管理与第三方语言绑定的中央处理器操作,并且仍然不违反我们始终使用异步等待的第一原则。

下面是一个简单的代码片段,使用 async-wait 来管理受 CPU 限制的工作:

        private async Task CPUIOResult()
        {
            var doExpensiveCalculationTask = Task.Run(() => DoExpensiveCalculation()); //Call a method that does CPU intense operation         
           var downloadFileAsyncTask = DownloadFileAsync();
            await Task.WhenAll(doExpensiveCalculationTask, 
             downloadFileAsyncTask);
        }
private async Task DownloadFileAsync(string url, string path)
        {
            // Implementation
        }
        private float DoExpensiveCalculation()
        {
            //Implementation
        }

正如在前面的代码中看到的,仍然可以通过异步等待和第三方语言的混合来管理受 CPU 限制的工作;开发人员需要评估所有可能的选项,并相应地编写他们的代码。

避免异步作废

如果一个方法预计不会返回任何东西,那么一定要确保使用异步等待而不是void来实现异步方法的返回类型为Task或Task<T>。这样做的原因是Task是一个复杂的抽象,为我们处理很多事情,比如异常处理和任务完成状态。然而,如果一个异步方法有一个异步void的返回类型,它就像一个一次性的方法,任何调用这个方法的人都不能知道操作的状态,即使有一个异常。这是因为在async void方法中,一旦遇到await表达式,调用就会返回给调用者,而不引用Task,因此没有为其引发异常的引用。对于像 WPF 这样的用户界面应用来说,async void方法上的任何异常都将导致应用崩溃,但是async void事件处理程序是一个例外。

async void方法的另一个缺点是无法正确编写单元测试和断言。因此,总是建议使用异步Task异常作为顶层事件处理程序(这里顶层是关键),因为顶层事件(如按钮点击或鼠标点击)更多的是单向信号,在异步代码中的使用与同步代码没有任何不同。

在异步 lambdas 的情况下也需要考虑同样的,我们需要避免将异步 lambdas 作为参数传递给以Action类型为参数的方法。请参见以下示例:

    class Program
    {
        static void Main(string[] args)
        {
            long elapsedTime = AsyncLambda(async() =>
            {
                await Task.Delay(1000);
            });
            Console.WriteLine(elapsedTime);
            Console.ReadLine();
        }
        private static long AsyncLambda(Action a)
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            for (int i = 0; i < 10; i++)
            {
                a();
            }
            return sw.ElapsedMilliseconds;
        }
}

这里预计elapsedTime的数值会在 1 万左右。然而,出于同样的原因,它接近 100;也就是说,由于Action是返回类型void的委托,对AsyncLambda的调用会立即返回到Main方法(就像对任何async void方法一样)。这可以通过如下更改AsyncLambda来解决(或者只需将参数更改为Func<Task>并相应地处理a()上的等待),然后强制调用方始终使用异步:

        private async static Task<long> AsyncLambda(Func<Task> a)
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            for (int i = 0; i < 10; i++)
            {
                await a();
            }
            return sw.ElapsedMilliseconds;
        }

提醒一下:如果你的应用中有接受Action类型参数的方法,建议你有一个接受Func<Task>或Func<Task<T>>的重载。一个好的方面是 C#编译器自动处理这个,总是以Func<Task>作为参数调用重载。

小费

使用 Visual Studio 2019 异常帮助器功能来调试由框架代码重新引发的异步异常。

带 IAsyncEnumerable 的异步流

我们都知道foreach是用来绕过IEnumerable<T>或者IEnumerator<T>的。让我们看一下下面的代码,其中我们从数据库中检索所有员工的身份证,并循环遍历每个员工以打印他们的身份证:

        static async Task Main(string[] args)
        {
            var employeeTotal = await 
             GetEmployeeIDAsync(5);
            foreach (int i in employeeTotal)
            {
                Console.WriteLine(i);
            }
        }

GetEmployeeIDAsync实现如下:

        static async Task<IEnumerable<int>> 
         GetEmployeeIDAsync(int input)
        {
            int id = 0;
            List<int> tempID = new List<int>();
            for (int i = 0; i < input; i++) //Some async DB iterator method like ReadNextAsync
            {
                await Task.Delay(1000); // simulate async
                id += i; // Hypothetically calculation
                tempID.Add(id);
            }
            return tempID;
        }

在这里,您可以看到,我们必须使用一个临时列表,直到我们从数据库中收到所有记录,最后我们返回该列表。然而,如果我们的方法中有迭代器,那么 C#中的yield是一个显而易见的选择,因为这有助于立即返回结果并避免临时变量。现在,假设您使用了yield,如下代码所示:

yield return id;

编译时,您会收到以下错误:

 The body of 'Program.GetEmployeeIDAsync(int)' cannot be an iterator block because 'Task<IEnumerable<int>>' is not an iterator interface type

因此,需要能够将yield用于异步方法,并且能够通过集合循环来异步调用应用。这就是 C# 8.0 通过IAsyncEnumerable提出异步流的地方,它主要使您能够立即返回数据并异步使用一个集合。因此,前面的代码可以更改如下:

        static async Task Main(string[] args)
        {
           await foreach (int i in GetEmployeeIDAsync(5))
            {
                Console.WriteLine(i);
            }
       }
        static async IAsyncEnumerable<int> 
         GetEmployeeIDAsync(int input)
        {
            int id = 0;
            List<int> tempID = new List<int>();
            for (int i = 0; i < input; i++)
            {
                await Task.Delay(1000);
                id += i; // Hypothetically calculation
                yield return id;
            }
        }

所以,在这里你可以看到,一旦一个方法开始返回,IAsyncEnumerable循环可以异步迭代,这在很多情况下有助于编写更干净的代码。

遗留模式的包装器

异步处理程序甚至在第三方物流和异步等待之前就已经存在了.NET 框架主要支持两种模式:

  • 异步编程模型 ( APM ):用一对方法表示异步操作,通常采用Begin / End方法的命名约定
  • 基于事件的异步模式 ( EAP ):表示通过事件使用方法和回调的异步操作

我们可以使用TaskCompletionSource类编写一个包装器,它可以使用一个任务来表示前面提到的模式中的库方法,从而使它与 TAP 兼容。让我们看看在下面的例子中。

EAP 上的 TAP 包装

对于包装器方法的实现,让我们来看一个WebClient类,它有一个DownloadStringAsync方法和一个完成事件,DownloadStringCompleted。我们需要将这两者打包成一个任务并返回。我们将利用TaskCompletionSource并处理其TrySetResult和TrySetException来更新TaskCompletionSource的Task属性。下面是代码片段:

        public static Task<string> DownLoadStringEAPtoTAPWrapper(string url, CancellationToken token)
        {
            TaskCompletionSource<string> tcsWrapperForEAP = 
             new TaskCompletionSource<string>();
            WebClient wc = new WebClient();
            token.Register(() => { 
                tcsWrapperForEAP.TrySetCanceled(); 
            });
            wc.DownloadStringAsync(new Uri(url));
            wc.DownloadStringCompleted += (sender, 
             downloadStringCompletedEventArgs) =>
            {
                if (downloadStringCompletedEventArgs.Error 
                 != null)
                    tcsWrapperForEAP.TrySetException(downloadStringCompletedEventArgs.Error);
                else
                    tcsWrapperForEAP.SetResult(downloadStringCompletedEventArgs.Result);
            };
            return tcsWrapperForEAP.Task;
        }

在这里可以看到我们可以简单地利用TaskCompletionSource为基于 EAP 的异步 API 编写一个包装器。让我们在下一节中看看如何为基于 APM 的异步库做到这一点。

APM 上的 TAP 包装

让我们以为例,使用FileStream.BeginRead和FileStream.EndRead读取遗留代码中的文件。我们编写包装器的方法是创建一个TaskCompletionSource<T>的对象,并使用其Task属性来设置成功或异常情况场景并返回任务。下面是一个示例方法:

        public Task<int> ReadAsyncAPMWrapper(FileStream filest, byte[] buffer, int offset, int noOfBytes, CancellationToken token)
        {
            var tcsWrapperForAPM = new 
             TaskCompletionSource<int>();
            //Registering cancellation token, this can be further improved
            token.Register(() => { 
             tcsWrapperForAPM.TrySetCanceled(); });
            filest.BeginRead(buffer, offset, noOfBytes, 
             iAsyncResult =>
            {
                try
                {
                    var asyncState = 
                     iAsyncResult.AsyncState as FileStream;
                    var result = 
                     asyncState.EndRead(iAsyncResult);
                    tcsWrapperForAPM.TrySetResult(result); //Set result on Task
                }
                catch (Exception ex)
                {
                    tcsWrapperForAPM.TrySetException(ex); // Set exception on Task
                }
            }, filest);
            return tcsWrapperForAPM.Task;
        }

正如您在前面的代码中看到的,我们使用了TaskCompletionSource类的TrySetResult / TrySetException,它在内部更新实例的Task属性,并最终返回任务,该任务可以在任何异步等待方法中使用。

线程池饥饿

假设您有一个带有异步代码的应用。但是,您已经注意到,在高负载期间,请求的响应时间会周期性地急剧增加。您进一步研究了它,但是您的服务器的 CPU 没有得到充分利用,您的进程的内存也不高,并且您的数据库也没有成为瓶颈。在这种情况下,您的应用可能会导致所谓的ThreadPool饥饿。

ThreadPool饥饿是一种状态,在这种状态下,不断添加新的线程来服务并发请求,最终到达一个点ThreadPool无法添加更多线程,请求开始出现延迟响应时间,甚至在最坏的情况下开始失败。即使ThreadPool能够以每秒一到两个线程的速度增加线程,新的请求可能会以更高的速度到来(如假日期间网络应用的突发负载)。因此,响应时间显著增加。出现这种情况有多种原因;这里列出了其中一些:

  • 消耗更多线程来加速长时间运行的受 CPU 限制的工作
  • 使用GetAwaiter().GetResult()在同步方法中调用异步方法
  • 同步原语的不正确使用,例如长时间持有锁的线程和等待获取锁的其他线程

在前面的所有要点中,常见的是阻塞代码;因此,使用阻塞代码,如Thread.Sleep即使持续时间很短,或类似GetAwaiter().GetResult()的东西,或试图为一个受 CPU 限制的项目分配更多的线程,会增加ThreadPool的线程数量,并最终导致饥饿。

ThreadPool饥饿可以使用性能视图等工具进一步诊断,在该工具中,您可以捕获例如 200 秒的跟踪,并验证流程中线程的增长。如果您看到线程在峰值负载期间快速增长,那么就有可能出现饥饿。

防止ThreadPool饥饿的最好方法是在整个应用中使用异步等待,永远不要阻塞任何异步调用。此外,对新创建的操作进行限制也会有所帮助,因为它限制了一次可以排队的项目数量。

在这一节中,我们讨论了两个重要的构造:async-wait 和 TPL,当它们结合在一起时,编写异步代码变得更加简单。在下一节中,我们将了解中可用的各种数据结构.NET 5 来支持同步/线程安全,而无需编写任何额外的代码。

使用并行集合进行并行处理

集合类是封装、检索和修改相关数据的枚举集合的最常用类型之一。Dictionary、list、queue和array是一些常用的集合类型,但它们不是线程安全的。如果一次只从一个线程访问它们,这些集合是很好的。现实环境将是多线程的,为了使其线程安全,您必须实现各种同步结构,如前一节所述。为了解决这个问题,微软提出了并发收集类,如ConcurrentQueue、ConcurrentBag、ConcurrentDictionary、ConcurrentStack等,这些类在内部实现同步时是线程安全的。让我们在接下来的章节中详细了解它们。

并发词典

让我们使用字典来模拟一个多线程环境。将任务t1视为来自正在添加到字典中的客户端的一个操作,将任务t2视为来自正在读取字典的另一个客户端的第二个操作。

我们在每个任务中添加Thread.Sleep来模拟现实场景,以确保在本例中一个任务不会先于另一个任务完成。让我们考虑一个包含以下代码片段的示例控制台应用:

// Task t1 as one operation from a client who is adding to the dictionary.
Dictionary<int, string> employeeDictionary = new Dictionary<int, string>();            
            Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 100; ++i)
                {
                    employeeDictionary.TryAdd(i, "Employee" 
                     + i.ToString());
                    Thread.Sleep(100);

                }
            });

这是Task t2作为另一个正在读字典的客户端的第二个操作:

            Task t2 = Task.Factory.StartNew(() =>
            {
                Thread.Sleep(500);
                foreach (var item in employeeDictionary)
                {
                    Console.WriteLine(item.Key + "-" + 
                      item.Value);
                    Thread.Sleep(100);
                }
            });

现在两个任务同时执行,如下图:

try
            {
                Task.WaitAll(t1, t2); // Not recommended to use in production application.
            }
            catch (AggregateException ex) 
            {
                Console.WriteLine(ex.Flatten().Message);
            }
            Console.ReadLine();

当您运行此程序时,您将获得以下异常,该异常表明您不能同时修改和枚举集合:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/04.jpg

表 4.2

您可能认为现在我们可以添加一个锁来管理线程同步,并为了线程安全避免多线程场景中的这种异常。我在代码中添加了一个锁,只要修改并枚举字典来同步线程。以下是更新后的代码片段:

首先,我们将Task t1作为正在添加到字典中的客户端的一个操作:

Dictionary<int, string> employeeDictionary = new Dictionary<int, string>();            
            Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 100; ++i)
                {
                    //Lock the shared data
                    lock (syncObject)
                    {
                        employeeDictionary.TryAdd(i, 
                          "Employee" + i.ToString());
                    }
                    Thread.Sleep(100);

                }
            });

然后我们有Task t2作为另一个正在读字典的客户的第二个操作:

            Task t2 = Task.Factory.StartNew(() =>
            {
                Thread.Sleep(500);
                //Lock the shared data
                lock (syncObject)
                {
                    foreach (var item in 
                     employeeDictionary)
                    {
                        Console.WriteLine(item.Key + "-" + 
                         item.Value);
                        Thread.Sleep(100);
                    }
                }
            });

现在我们同时执行了这两个任务:

try
            {
                Task.WaitAll(t1, t2); // Not recommended to use in production application.
            }
            catch (AggregateException ex) 
            {
                Console.WriteLine(ex.Flatten().Message);
            }
            Console.ReadLine();

当您运行这段代码时,您不会看到任何异常。但是,正如前面提到的,锁有一些问题,因此可以使用并发集合重写这段代码。他们在内部使用多线程同步技术,这有助于很好地扩展,防止数据损坏,并避免锁的所有问题。

我们可以使用ConcurrentDictionary重写代码,它在System.Collections.Concurrent命名空间中可用。将样本代码中的Dictionary替换为ConcurrentDictionary。您也可以删除对System.Collections.Generic名称空间的引用,因为现在不使用Dictionary。此外,拆除所有锁。更新后的代码如下,我们把Dictionary换成了ConcurrentDictionary,拆了锁:

我们将Task t1作为一个来自添加到字典中的客户端的操作,并发集合不需要显式锁:

ConcurrentDictionary<int, string> employeeDictionary = new ConcurrentDictionary<int, string>();
            Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 100; ++i)
                {
                    employeeDictionary.TryAdd(i, "Employee" 
                      + i.ToString());
                    Thread.Sleep(100);

                }
            });

然后我们有Task t2作为从字典中读取的另一个客户端的第二个操作,并发集合不需要显式锁:

            Task t2 = Task.Factory.StartNew(() =>
            {
                Thread.Sleep(500);
                foreach (var item in employeeDictionary)
                {
                    Console.WriteLine(item.Key + "-" + 
                       item.Value);
                    Thread.Sleep(100);
                }
            });

现在两个任务同时执行:

try
            {
                Task.WaitAll(t1, t2);
            }
            catch (AggregateException ex) // You will not get Exception
            {
                Console.WriteLine(ex.Flatten().Message);
            }
            Console.ReadLine();

当你现在运行程序的时候,你不会得到任何异常,因为所有的操作在ConcurrentDictionary都是线程安全和原子的。在开发人员实现锁并在项目变大时维护它们没有开销。这里有一些关于并发集合的注意事项,比如ConcurrentDictionary,你需要记住:

  • 如果两个线程调用AddOrUpdate,不能保证调用哪个工厂委托,甚至不能保证如果工厂委托产生一个项目,该项目将存储在字典中。
  • GetEnumerator调用得到的枚举器不是快照,在枚举过程中可以修改(这不会导致任何异常)。
  • 键和值属性是相应集合的快照,可能不对应于实际的字典状态。

我们已经详细看了ConcurrentDictionary;让我们在下一节中看看其他并发集合。

生产者-消费者并发集合

在生产者-消费者并发集合中,一个或多个线程可以产生任务(例如,添加到队列、堆栈或包),一个或多个其他线程可以消费来自同一集合(队列、堆栈或包)的任务。

ConcurrentDictionary,我们在上一节看到的,是一个通用的集合类,您可以在其中添加您想要的项目,并指定您想要读取的项目。其他并发集合是为特定问题设计的:

  • ConcurrentQueue是针对你想要先进先出的场景。
  • ConcurrentStack是指你想要后进先出 ( 后进先出)的场景。
  • ConcurrentBag适用于场景,在该场景中,您希望相同的线程产生和消费存储在包中的数据,并且顺序无关紧要。

这三个集合也称为生产者-消费者集合,其中一个或多个线程可以从同一个集合中产生任务和消费任务,如下图所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/enter-app-dev-cs9-dnet5/img/Figure_4.7_B15927.jpg

图 4.7–生产者-消费者并发收集

这三个集合都实现了IProducerConsumerCollection<T>界面,最重要的方法是TryAdd和TryTake,如图所示:

 // Returns: true if the object was added successfully; otherwise, false.        
 bool TryAdd(T item);
 // Returns true if an object was removed and returned successfully; otherwise, false.
 bool TryTake([MaybeNullWhen(false)] out T item);

我们以生产者-消费者为例,使用ConcurrentQueue进行模拟:

  • 生产者:客户端向 web 服务发送请求,服务器将请求存储在队列中
  • 消费者:从队列中拉出请求并进行处理的工作线程

实现如下代码所示:

//Producer: Client sending request to web service and server storing the request in queue.
ConcurrentQueue<string> concurrentQueue = new ConcurrentQueue<string>();            
            Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 10; ++i)
                {
                    concurrentQueue.Enqueue("Web request " + i);
                    Console.WriteLine("Sending "+ "Web request " + i);
                    Thread.Sleep(100);
                }
            });

现在我们有了Consumer,其中Worker线程从队列中提取请求并处理它:

            Task t2 = Task.Factory.StartNew(() =>
            {
                while (true)
                {
                    if (concurrentQueue.TryDequeue(out 
                     string request))
                    {
                        Console.WriteLine("Processing "+ 
                         request);
                    }
                    else
                    {
                        Console.WriteLine("No request");
                    }
                }
            });

生产者和消费者任务同时成功执行。等待所有提供的任务在指定的毫秒数内完成执行。请参考以下代码片段:

try
            {                
                Task.WaitAll(new Task[] { t1, t2 }, 1000);
            }
            catch (AggregateException ex) // No exception
            {
                Console.WriteLine(ex.Flatten().Message);
            }

这是根据微软的方法定义:

  • concurrentQueue.Enqueue:这在ConcurrentQueue<T>的末尾增加了一个对象。
  • concurrentQueue.TryDequeue:这是在ConcurrentQueue开始的时候尝试移除和返回物体。

运行程序时,可以看到task t1产生请求,task t2轮询,然后消耗请求。我们过一会儿再谈细节。我们也说过这些类实现IProducerConsumerCollection<T>。因此,我将对前面的代码进行三处修改:

  • 将ConcurrentQueue<string>替换为IProducerConsumerCollection<string>。
  • 将concurrentQueue.Enqueue替换为concurrentQueue.TryAdd。
  • 将concurrentQueue.TryDequeue替换为concurrentQueue.TryTake。

代码现在是这样的:

IProducerConsumerCollection<string> concurrentQueue = new ConcurrentQueue<string>();
//Removed code for brevity.
Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 10; ++i)
                {
                    concurrentQueue.TryAdd("Web request " + i);
//Removed code for brevity.
Task t2 = Task.Factory.StartNew(() =>
            {
                while (true)
                {
                    if (concurrentQueue.TryTake(out string 
                     request))
//Removed code for brevity.

现在继续运行程序。您可以看到task t1产生请求,task t2轮询,然后消费请求。你可以看到所有 10 个请求由task t1产生,由task t2消耗。但是有两个问题:

  • 生产者按照自己的速度生产,消费者按照自己的速度消费,没有同步。
  • task t2有消费者连续不定的轮询,对性能和 CPU 使用率都不好,你可以看到没有请求正在打印,而我们没有收到concurrentQueue.TryTake的任何处理请求。

这就是BlockingCollection<T>派上用场的地方。

封锁集合< T >类

BlockingCollection<T>支持包围和阻挡。边界允许您为集合指定最大容量。控制集合的最大大小有助于防止生成线程在使用线程之前移动太远。多个生产线程可以同时向BlockingCollection<T>添加项目,直到集合达到其最大大小,之后它们将被阻止,直到某个项目被消费者移除。

类似地,多个消费线程可以同时从阻塞集合中移除项,直到集合变空,之后它们将被阻塞,直到生产者添加一个项。当不再添加项目时,产生线程可以调用CompleteAdding方法,并指示它已经完成添加。这将有助于消费者监控IsCompleted属性,以了解当集合为空时不会再添加任何项目。创建BlockingCollection<T>类以及边界容量时,还可以根据场景指定要使用的并发集合类型。默认情况下,当您没有指定类型时,收集类型为BlockingCollection<T>的ConcurrentQueue<T>。

生产者:客户端向网络服务发送请求,服务器将请求存储在队列中。

下面是一个示例代码片段:

BlockingCollection<string> blockingCollection = new BlockingCollection<string>(new ConcurrentQueue<string>(),5);    
            Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 10; ++i)
                {
                    blockingCollection.TryAdd("Web request 
                     " + i);
                    Console.WriteLine("Sending " + "Web 
                      request " + i);
                    Thread.Sleep(100);
                }
                blockingCollection.CompleteAdding();
            });

然后具有Worker线程的消费者从队列中取出项目并处理它:

            Task t2 = Task.Factory.StartNew(() =>
            {
                while (!blockingCollection.IsCompleted)
                {
                    if (blockingCollection.TryTake(out 
                     string request,100))
                    {
                        Console.WriteLine("Processing " + 
                         request);
                    }
                    else
                    {
                        Console.WriteLine("No request");
                    }
                }
            });

现在生产者线程和消费者线程正在并发访问。

代码中有几点需要考虑:

  • 5的指定边界:BlockingCollection<string> blockingCollection = new BlockingCollection<string>(new ConcurrentQueue<string>(),5);。
  • 当不再添加项目时,产生线程调用CompleteAdding方法来指示它已经完成添加:blockingCollection.CompleteAdding();。
  • 消费者监控IsCompleted属性,发现集合为空时不再添加项目:while (!blockingCollection.IsCompleted)。
  • 尝试在指定的时间段内从BlockingCollection<T>中移除一个项目。比如我已经去了 100 毫秒:if (blockingCollection.TryTake(out string request, 100))。

这就是阻塞集合的力量。生产者和消费者都是解耦的,它们可以由不同的团队独立编码,在运行时它们使用一个阻塞的并发集合来互相共享数据;另外,与此同时,流量是通过边界容量来控制的,这样生产者就不会在消费者之前走得太远。

注意

除了我们已经看到的TryTake方法,您还可以使用foreach循环从阻塞集合中移除项目。你可以在这里阅读:https://docs . Microsoft . com/en-us/dotnet/standard/collections/thread-safe/how-foreach-to-remove。

在阻止收藏的情况下,将会出现消费者不得不处理多个收藏并购买或添加物品的情况。在这种情况下,TakeFromAny和AddToAny方法会对你有所帮助。您可以在这里进一步阅读关于这两种方法:

https://docs . Microsoft . com/en-us/dotnet/API/system . collections . concurrent . blocking collection-1 . take from any?视图=净-5.0

https://docs . Microsoft . com/en-us/dotnet/API/system . collections . concurrent . blocking collection-1 . addto any?视图=净-5.0

总结

包装、编写和维护干净的异步代码是困难的。但是,使用中提供的各种构造.NET 和 C#,开发人员现在可以用更少的框架开销编写异步代码,并更加专注于业务需求。在本章中,我们介绍了使用第三方语言、异步等待和并发集合编写可伸缩异步代码的各种方法,还介绍了线程的基础知识和中的ThreadPool.NET 来理解框架内部,并为企业应用编写更干净的代码。现在,我们对多线程以及如何在多线程环境中保护共享数据有了更深入的了解。我们学习了使用 async-wait 创建任务和实现异步函数,最后我们学习了中可用的并发集合.NET Core 及其在各种并发场景中的实现。

在下一章中,我们将研究.NET 5 以及它如何在松散耦合企业应用中的各种低级类方面发挥重要作用。

问题

  1. In a multi-threaded environment, which of the following data structures should you use to protect data from getting overwritten/corrupted?

    a.async - await。

    b.任务。

    c.同步结构,如锁。

    d.数据永远不会损坏。

  2. If you have a WPF application that retrieves data from a REST API, which of the following should you implement for better responsiveness?

    a.并行收集

    b.平行。为

    c.async - await为 REST API 调用

  3. Which of the following should be passed to cancel a task?

    a.CancellationToken

    b.ConcurrentDictionary

    c.SemaphoreSlim

  4. Which of the following is the recommended return type for an asynchronous method that uses async-await and does not return anything?

    a.async void

    b.async Task

    c.async book

    d.async Task<bool>

进一步阅读

.NET高效操作Office组件DsoFramer详解与实战 DsoFramer是一款专为.NET平台设计的Office文档操作组件,支持在Web及桌面应用中实现对Word、Excel等文档的在线编辑、内容操作与格式转换。其底层基于COM Interop与ActiveX技术,封装了对Office自动化对象的调用,提供简洁的API接口,极大提升了开发效率。相较于Aspose、OpenXML等组件,DsoFramer在开发便捷性与兼容性方面具有显著优势。 阅读详情

相关推荐

手机也能跑!5.6MB的AnimeGANv3模型部署实战:从PyTorch到ONNX再到移动端推理

本文详细介绍了如何将轻量级动画生成器AnimeGANv3从PyTorch模型转换为ONNX格式,并部署到移动端进行实时推理。通过5.6MB的极简模型设计和优化策略,实现在手机等边缘设备上的高效动漫风格转换,涵盖模型准备、格式转换、移动端优化及性能调优全流程。

weixin_33724059的博客 383

C#基础到WEB API的开发学习路线

文章的编写是以一个java开发人员的观点出发,怎么按Spring SpringBoot SpringMvc Mybatis的思想在C#中完成一个WEB API服务的编写,里面将以详细的步骤进行分析,要怎么创建项目、引入依赖、ORM映射、API编写、测试等的实现,希望对你有帮助

esunlang的博客 1159

西电 程序设计基础课程设计 作业

当时做的程序设计课程的作业. 一共五题, 以报告的形式提交. 质量应该比较高(高傲). 完整内容在这里 高精度计算 问题描述 涉及知识点: 数组, 流程控制, 函数等 要求: 用整型数组表示10进制大整数(超过 2322^{32}232 的整数), 数组的每个元素存储大整数的一位数字, 实现大整数的加减法. 问题分析 结构设计 可将高精度整数写成一个类. 成员变量有: int a[MAXN], len; bool negative; 其中, 数组a用来存储大整数的一个数位, a[1]为个位上的数字, a

Wings 2513

C#9 和 .NET5 高级教程(一)

原文:Pro C# 9 with .NET 5 协议:CC BY-NC-SA 4.0 一、C# 和 .NET(Core)5 简介 微软的。NET 平台和 C# 编程语言大约在 2002 年正式引入,并迅速成为现代软件开发的中流砥柱。的。NET 平台使得大量的编程语言(包括 C#、VB.NET 和 F#)能够相互交互。用 C# 写的程序可以被用 VB.NET 写的另一个程序引用。本章稍后将详细介绍这种互操作性。 2016 年,微软正式推出。NET 核心。比如。网,。NET Core 允许语言之间的互操作(

龙哥盟 2339

Zotero搭配CNKI.js插件:一键批量下载知网文献的完整教程(附常见问题解决)

本文提供Zotero搭配CNKI.js插件实现知网文献批量下载的完整教程。详细介绍了从环境准备、插件安装到实战操作的全流程,并针对常见问题提供解决方案,旨在帮助科研人员高效构建自动化文献管理工作流,显著提升文献收集与整理效率。

e4f5g6h7的博客 1202

pikachu靶场: 暴力破解第四关(token防爆破)

Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个 Token 同时将此 Token 返回给客户端。此外,Token 是有时效的,一段时间之后或下一次请求时用户需要重新验证。

X_shiguang的博客 1720

C#9 和 .NET5 企业级应用开发(三)

任何网络应用,无论是内容管理系统、社交网络平台还是电子商务应用,都需要将数据保存到永久存储中,以便用户可以根据需要检索、消费和处理数据。在第八章理解缓存中,我们讨论了使用缓存存储;但是,缓存存储是临时存储,数据仍然需要保存到永久存储中。所以,我们需要一个不只是支持不同实体上各种CRUD(简称创建/读取/更新/删除)操作,还支持高可用性,并在宕机时恢复任何数据的存储,也就是灾难恢复。更好的系统设计的关键标准之一是在系统的早期阶段设计一个数据模型。

龙哥盟 517

(五)激光线扫描-位移台标定

线激光属于主动测量方式,但是由于线激光的特性,我们只能通过提取激光中心线获取这一条线上的高度信息,那么要进行三维重建的话,就需要通过平移或者是旋转的方式,来让线激光扫描被测物体的完整轮廓,也就是整个表面。激光线的密度越高还原出来的物体越细腻,但由于数据量大,也会导致速度变慢。下面以位移台为例讲解,如何标定位移台。

FlyWine的博客 2890

C#9 和 .NET5 企业级应用开发(四)

ASP.NET Core 身份是一个基于会员资格的系统,提供了一个简单的方法来添加登录和用户管理功能到您的应用。它提供了一个 UI 和应用编程接口API)来创建新的用户帐户、提供电子邮件确认、管理用户配置文件数据、管理密码(如更改或重置密码)、登录、注销等,并启用多因素身份验证MFA它还允许您与外部登录提供商集成,如微软账户、谷歌、脸书、推特和许多其他社交网站,以便用户可以使用他们现有的账户注册,而不是创建新的账户,从而增强用户体验。

龙哥盟 792

华为OD机试 - 猴子吃桃 - 二分查找(Java 新系统 100分)

每一题都有详细的答题思路、详细的代码注释、样例测试,发现新题目,随时更新,全天优快云在线答疑。

学Java,找哪吒 3850

PyTorch深度学习实战(29)——变分自编码器(Variational Autoencoder, VAE)

变分自编码器 (Variational Autoencoder, VAE) 是一种结合了自编码器和概率建模的生成模型,通过编码器将输入数据映射到潜在空间中的概率分布,并通过解码器将从潜在空间采样得到的潜在变量映射回原始数据空间,实现了数据的生成和特征学习。

盼小辉丶的博客 1万+

NET 5、NET 6、NET 7、NET 8 和 .NET 9 之间的区别

各版本的演进体现了微软对云原生、高性能计算和开发者体验的持续投入,企业可根据自身需求和技术路线选择最适合的版本。

Code365 6266

下载和运行GitHub上的代码教程总结

从昨天开始就想着从GitHub上下载一个开源的Vue的实战项目,希望能从中学习更多的Vue的实用内容,结果搞了半天好不容易下载了,不知道怎么弄。然而,今天终于成功了,激动地我赶紧来记录一下。 如何从GitHub上下载自己需要的项目: 1.首先,你要有一个自己的GitHub(https://github.com/)的账号。关于如何注册GitHub的账号以及如何获取你的SSH密钥这个问题,请大家参...

不怕万人阻挡,只怕自己投降 29万+

《C#高级编程(第9版)——C# 5.0 & .NET 4.5.1》试读试评论

关于厚书,我想很多程序员应该是出于一种“宝典情结”而喜欢的,而这本《C#高级编程》就像武功秘笈《葵花宝典》,当然,我的意思不是说学C#得自宫,而是因为我觉得它算得上博大精深,也能在掌握后用C#快速构建应用,正好符合《葵花宝典》中的武功以“快”为主的特点。

养心殿 1266

SAP B1中文培訓教材_SDK

公司内部培训资料,对开发工具 ,DIAPI, UIAPI,Add-ON有详细解说

jre9下载 jdk9

(Java SE Runtime Environment 9.0) 9.0u175 官方最新版 【x86|x64】

C#9 和 .NET5 企业级应用开发(二)

企业应用可能面临的一个大问题是将不同的元素连接在一起并管理它们的生命周期的复杂性。为了解决这个问题,我们使用控制反转 ( IoC )原理,该原理建议消除对象之间的依赖性。通过委派控制流,IoC 使程序可扩展并增加了模块化。事件、回调委托、观察者模式和依赖注入 ( DI )是实现 IoC 的一些方法。在本章中,我们将了解以下内容:到本章结束时,您将对直接投资有一个很好的了解,充分利用直接投资.NET 5 应用,ASP.NET Core 5 中提供的范围类型,以及如何在项目中利用它们。对微软的基本了解.NET

龙哥盟 475

廖雪峰Python教程[完整版]

廖雪峰的python教程整理的,已经排好版了,而且还是完整的,适合小白

C#9 和 .NET5 企业级应用开发(五)

为了将一个产品的想法变为现实,不管你的团队规模有多大,你都需要一种有效的方法来规划你的工作,在你的团队中进行协作,以及构建、测试和部署。Azure DevOps 帮助您应对这些挑战,并为您的成功提供各种服务和工具。Azure DevOps 服务可以通过网络访问,也可以从流行的开发 ide 中访问,如 Visual Studio、Visual Studio Code、Eclipse 等。使用 Azure DevOps 服务器,Azure DevOps 服务既可以在云中使用,也可以在内部使用。Boards。

龙哥盟 452

计算机控制技术及应用

《计算机控制技术及应用》通过总结作者的科研成果和吸收国内外的先进理论、方法和技术,以计算机控制系统的网络化、开放化、智能化和集成化发展趋势为主线索,主要介绍计算机控制系统的基本概念和计算机控制系统的典型形式,计算机控制系统的数学描述与基本分析方法,以及数字PID控制器、最少拍控制系统、达林算法、状态变量设计法等设计方法及其各种改进算法。重点突出计算机控制系统输入输出通道接口的设计方法和典型控制系统的设计案例与实现方法等。

C#9 和 .NET5 软件架构(一)

本书涵盖了现代基于云和分布式软件架构中涉及的最常见设计模式和框架。它通过提供实际的现实场景,讨论了何时以及如何使用每种模式。本书还介绍了 DevOps、微服务、Kubernetes、持续集成和云计算等技术和流程,以便您可以开发并交付给客户最佳的软件解决方案。本书将帮助您了解客户想要的产品。它将指导您在开发过程中解决可能遇到的最大问题。它还涵盖了在云环境中管理应用程序时需要遵循的注意事项,以及您需要遵循的做和不做。

龙哥盟 1203

【2021年新书推荐】Enterprise Application Development with C# 9 and .NET 5

各位好,此账号的目的在于为各位想努力提升自己的程序员分享一些全球最新的技术类图书信息,今天带来的是2021年3月由Packt出版社最新出版的一本关于C# 9和.NET 5的书。 Enterprise Application Development with C# 9 and .NET 5 作者:Ravindra Akella , Arun Kumar Tamirisa , Suneel Kumar Kunani 出版社:Packt 出版时间:2021-03-19 ISBN:9781800209442 图书

tushufenxiang的博客 316

【C#.Net】C#开发的未来前景

C#的前景稳定且有增量:企业级后端、Unity游戏开发是基本盘,云原生、MAUI、AI+NET是增量方向,不会被淘汰;核心竞争力在高价值场景:Unity游戏、金融/政务企业级开发、跨平台桌面应用是C#不可替代的优势领域;学习C#需绑定具体场景:单纯学语法价值有限,结合Unity/企业级后端/云原生等场景,能大幅提升就业竞争力。

u013136510的博客 1377

C#8 和 .NET Core3 Azure 项目(一)

.NET Core 是. NET 的通用、模块化、跨平台和开源实现.NET Core 3 具有改进的性能以及对桌面应用的支持。.NET Core 3 不仅应该吸引新开发人员开始学习框架,还应该说服旧开发人员开始迁移他们的应用。这本书是第二版 C# 7 和.NET Core 2.0 蓝图,更新了 C# 8 和的最新特性和增强功能.NET Core 3.0。这本书是一个全面的指南,提供了 10 个真实世界的企业应用。它将帮助您同时学习和实现这些概念,并通过在 ASP.NET Core 和 Azure 上构建满足

龙哥盟 1783

C#9 和 .NET5 软件架构(二)

微服务架构允许将解决方案的每个模块独立于其他模块进行扩展,以实现最大吞吐量和最小成本。事实上,对整个系统进行扩展而不是当前瓶颈部分必然会导致资源的明显浪费,因此对子系统扩展的细粒度控制对系统的整体成本有着重要影响。然而,微服务不仅仅是可扩展的组件-它们是可以独立开发、维护和部署的软件构建块。

龙哥盟 1041

视频教程-C#/.Net企业级系统架构设计实战精讲教程-.NET

C#/.Net企业级系统架构设计实战精讲教程 徐照兴,教研室主任,硕士,副教...

weixin_31708717的博客 974

用.NET9+Blazor+Semantic Kernel,打造企业级AI知识库和智能体平台——AntSK深度解读

实现接口,注册到服务容器即可。AntSK不是“又一个AI知识库”,而是“企业级AI应用的万能底座”。它用.NET9和Blazor的现代技术栈,集成了Semantic Kernel、Kernel Memory等AI能力,支持多模型、多插件、文生图、OCR等丰富功能,适合企业、开发者、AI爱好者快速搭建自己的AI应用。无论你是想打造企业知识库、智能客服、AI写作助手,还是开发自己的AI App,AntSK都能让你“少踩坑、多赚钱”。还等什么?赶紧下载体验,开启你的AI赋能之旅吧!

许泽宇的技术分享 1384

C#.NET 超大文件上传解决方案:分片断点上传(一)

C#.NET 超大文件上传解决方案:分片断点上传(一),最近一个客户提了这个需求,客户也是做信息行业的,有自己的产品和解决方案,主要就是希望能够为现有的系统和产品做升级,增加这个功能,因为他们的产品也是销售给客户,主要是政府单位,所以对易用性,稳定性,可维护性要求比较高,技术支持这块要做好,基本上要做到7*8吧,有问题的话,随时都需要联系的,电话,远程随时都要联系上。基本上全部讲的是文件的上传,如何分片,但是没有讲下载,也没有讲文件夹如何上传,服务器后端逻辑是如何处理,如何实现的。

alisky119的博客 1040

C#/.NET/.NET Core优秀项目和框架2025年12月简报

公众号每月定期推广和分享的C#/.NET/.NET Core优秀项目和框架(每周至少会推荐两个优秀的项目和框架当然节假日除外),公众号推文中有项目和框架的详细介绍、功能特点、使用方式以及部分功能截图等(打不开或者打开GitHub很慢的同学可以优先查看公众号推文,文末一定会附带项目和框架源码地址)。追逐时光者,第一时间获取每周精选分享资讯🔔)。

专注于C#/.NET/.NET Core学习、工作、面试干货和实战教程分享。这里聚焦了大量的C#/.NET/.NET Core优质文章、开源项目、实用工具和学习、工作、面试心得。 1289
上一篇: Ink 脚本语言动态故事脚本编程(二)
下一篇: C#9 和 .NET5 企业级应用开发(二)
绝不原创的飞龙
绝不原创的飞龙 优质创作者: 人工智能、数据科学与机器学习技术领域 优质创作者: 人工智能、数据科学与机器学习技术领域
博客等级 码龄15年 4万+粉丝 2万+原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值