C--和--NET-系统编程-全-
C# 和 .NET 系统编程(全)
原文:
zh.annas-archive.org/md5/56ec4473fdab4d33463dddef4fa20d6b译者:飞龙
前言
当人们想到软件时,大多数人会想到图形用户界面(GUI)应用程序。软件是用户与之交互的代码。但如今,这已经不再是这样了。所有现代应用程序、网络服务器、网络应用和移动解决方案主要依赖于隐藏的、不可见的系统软件。这是为其他软件构建的软件。它在需要时才会活跃,完成工作后就会进入休眠状态。这些程序是我们生态系统的无名英雄,在后台默默工作。同时,GUI 系统处于聚光灯下。然而,不要低估这些勤奋的系统:它们必须非常快速、可靠和安全。因此,它们对于良好的系统运行至关重要,而且编写起来也很困难。这本书教你所有你需要知道的内容来编写这些应用程序。
这本书面向的对象
编写系统软件的人不是初级开发者。理想情况下,你应有几年使用 C#和.NET 开发软件的经验。我不会解释变量是什么,或者 while 循环与 for 循环有何不同。你知道如何使用 NuGet。如果我要求你在 Visual Studio 中将模式从 Debug 切换到 Release,你知道我在要求你做什么。
但我不期望你知道 CPU 使用哪些指令。当我们在书中达到那个点时,我会解释这些。所以现在没有必要深入到那个低层次。
这本书是为那些想要编写系统软件的人准备的。系统软件通常是普通用户看不到的软件。然而,它对于运行在您系统上的完整软件生态系统的良好运行至关重要。
这意味着你必须对运行速度快且稳定的程序充满热情。这也意味着我们编写的软件不是最容易维护的:随着性能的提高,可读性往往会降低。这不是给胆小的人准备的:编写这类软件是硬核开发。但如果你对程序在机器内部深处是如何真正工作的感到好奇,这本书就是为你准备的。
在这里学到的经验当然可以应用于各种项目。性能和稳定性可以惠及所有程序。所以,如果你准备好将你的 C#和.NET 技能提升到下一个层次,请继续阅读!
这本书涵盖的内容
系统编程概述为背景设定,并解释了系统编程究竟是什么。
第一章,低级秘密那章,深入探讨了低级 API、BCL 和 CLR,以及如何使用 Win32 API。
第二章,速度至上那章,探讨了如何使你的软件尽可能快地运行。
第三章,记忆游戏那章,讨论了内存处理、垃圾回收器以及如何尽可能提高内存效率。
第四章,线程纠缠之处,探讨了线程和异步编程。
第五章,文件系统编年史之处,教授输入/输出、文件处理、加密和文件压缩。
第六章,进程低语之处,讲述了如何在同一台机器或网络上使程序进行通信。
第七章,操作系统探戈之处,深入探讨了操作系统的服务和如何使用它们。
第八章,网络导航之处,讨论了您在应用程序中需要了解的所有关于网络的知识,无论是作为服务器还是客户端。
第九章,硬件握手之处,处理连接外部硬件和与其他设备通信。
第十章,系统检查之处,讨论了记录和监控您的软件。
第十一章,调试舞蹈之处,全部关于调试您的软件。
第十二章,安全防护之处,讨论了您软件的安全性。
第十三章,部署戏剧之处,教您如何将软件部署到生产机器。
第十四章,Linux 跳跃之处,讨论了我们大部分软件将运行的操作系统:Linux。
为了充分利用这本书
我在这本书中使用 Visual Studio 2022 作为主要的软件开发工具。建议您对此有实际了解,包括创建控制台应用程序、类库和工作者服务。只要您能创建一个默认的工作者服务,您就不需要了解工作者服务是什么。
每一章可能都有您可能想要尝试的软件。您将在相关章节的技术要求部分找到详细说明。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Visual Studio | Windows 10 或 11 |
如果您使用的是这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载这本书的示例代码文件:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
在这本书中使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“其中一个选项是Share。我们将其设置为FileShare.Delete”。
代码块设置为以下格式:
using var serialPort = new SerialPort(
"COM3",
9600,
Parity.None,
8,
StopBits.One);
serialPort.Open();
try
{
serialPort.Write([42],0, 1);
}
finally
{
serialPort.Close();
}
任何命令行输入或输出都按照以下方式编写:
docker tag image13workerfordocker:dev localhost:5000/image13workerfordocker:dev
粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“紧凑对象表示法:有时,您可以通过将数据智能地组合到其他数据结构中来节省一些内存”。
小贴士或重要提示
它看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 如果您对这本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,我们非常感谢您能提供位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用 C#和.NET 进行系统编程》,我们非常乐意听到您的想法!请点击此处直接转到该书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载这本书的免费 PDF 副本
感谢您购买这本书!
你喜欢在路上阅读,但又无法携带你的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不仅限于此,您还可以获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容。
按照以下简单步骤获取优惠:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/978-1-83508-268-3
-
提交您的购买证明
-
那就足够了!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件
系统编程概述
所以,你想学习.NET 中使用 C#的系统编程。至少,我假设你想学习这个;你很可能读了这个书的标题,并决定这是一个好选择。也许你已经涉足系统编程,并想提高这方面的技能。或者,也许你还没有接触过这个主题,并想开始。或者,也许你选错了书。如果是后一种情况,我希望你还有收据,这样你可以退回这本书并得到其他东西。对于所有人:欢迎!
让我们定义一下系统编程
在我们深入系统编程的细节之前,我们需要做好铺垫。我们需要对一些事情有一个共同的理解。例如,“系统编程”这个术语到底是什么意思?它是用来做什么的?它是为谁准备的?
让我从定义开始。
系统编程是系统的编程。这在技术上可能是正确的,但我认为这并不能帮助我们前进。
让我们分解一下:什么是系统?
那个很简单。我们构建系统已经有很多年了,所以我们知道我们所说的系统是什么意思。
让我给你展示一个定义:
系统是一组或排列相关或连接的物品,以便形成一个统一体或有机整体。它是一组相互作用的组件或部分,以实现功能。这个术语在物理学、生物学、计算机科学和商业管理等各个领域都有使用,每个领域都有略微不同的含义。
很好。但这个定义有点宽泛。我们可能想专注于计算机科学或软件开发。没问题;也有几个定义可以选择:
系统是一组相互交互以执行特定功能或一系列功能的软件组件。
这样就更好了。如果我们进一步深入探讨,我们可以区分不同的系统组:
-
软件系统:这是一个集成的软件组件集合,它们一起执行特定的功能或一系列功能。这些组件可以是数据库服务器、微服务和一个前端。这些组件构成了完整的系统,例如 CRM 系统、源代码控制系统和其他类似系统。
-
操作系统(OSs):你可能知道什么是操作系统。我认为你看到这个术语太多次了,以至于你甚至没有意识到它是一个系统。但确实是一个包含许多部分和组件的操作系统,如驱动程序、工具、辅助工具和日志。它们共同提供了一个系统,你可以作为用户使用它来运行你的软件,而不依赖于硬件。
-
分布式系统:我们通常将网络上松散连接的组件称为分布式系统。每个部分都是相互隔离的,但它们必须协作以实现有价值的目标。例如,Azure DevOps 在 Azure 云中的许多不同服务器上运行。所有组件可能运行在不同的服务器和机器上,这些组件甚至可能运行在世界上的不同部分。然而,它们共同工作,为最终用户提供一个完整的解决方案。
-
嵌入式系统:嵌入式系统通常是由硬件和软件的组合。组件之间紧密耦合。开发者通常编写软件以匹配特定的规格,以便最好地使用硬件。例如,想想你车上的系统。如果你有一辆相当新的车,你无疑在车上有一个娱乐系统。在“娱乐系统”这个词中,“系统”这个词有点提示:它由许多不同的组件组成。很可能有一个设备可以从空气中收集电磁波(我们称之为收音机)。该设备连接到一些软件,这些软件解释这些波并将其转换为电信号以供扬声器使用。旁边,一个组件会向你,作为用户,显示你正在听的内容。我确信你可以在你的车上找到很多其他系统,也许在你的电视、手机或冰箱上也能找到。
有更多的例子,但我希望你能看到,一个系统总是由单独的组件组成,这些组件单独使用时没有用处,但结合在一起,可以解决一个问题。
但等等。我们还没有完成。
根据这些定义和例子,你可能会认为系统编程的艺术就是这些系统的编程,你不会错。但通常情况下,系统编程并不是这个意思。我所说的系统编程绝对不是这个意思。
大概、非常粗略地来说,我们可以将软件分为两种类型:
-
面向用户的软件:这是一种为人们编写的软件。它有一个用户界面(UI),包括按钮、列表、标签等。人们通过使用各种输入方式与软件进行交互。
-
面向软件的软件:这是一种为其他软件设计的软件。由于我们没有用户,所以没有用户界面(UI)。我们可以说是其他组件是用户,但当我提到用户时,我指的是人。面向软件的软件通过 API、RPC(远程过程调用)调用、文件传输以及许多其他方式与其他组件交互。在这个过程中不涉及任何人类。
这本书中我们最感兴趣的第二种类型——软件是为了被其他软件使用的。
系统何时是面向用户的,何时不是?
并非总是清楚人们是某些代码的主要用户还是其他进程。我们可以非常严谨地说,任何带有 UI 的东西都是面向用户的;其他一切都是面向系统的。如果我们想要一个明确的定义,这将使生活更容易。然而,在现实世界中,边界往往变得模糊。
让我给你举一个例子。看看这个 Visual Studio 解决方案:

图 0.1:带有计算器项目的解决方案资源管理器
在这里我们有一个非常、非常简单的解决方案。它有一个名为MyAwesomeCalculator的主程序,其中包含主代码。这是我们的应用程序的入口点,使用控制台作为 UI。所有逻辑都在MathFunctions类库中。这就是魔法发生的地方。
如果我们回到我们对系统编程的定义,我们可以说编写MathFunctions类库是系统编程的一部分。毕竟,没有用户会与该库中的类和接口交互。真正使用它的代码是MyAwesomeCalculator中的代码。
太棒了!这意味着编写MathFunctions库是系统编程!嗯,但别急。如果我们看看解释流程的序列图,我们可能会得出另一个结论。图 0.2展示了这个序列图。

图 0.2:我们的计算序列图
正如你在图 0.2中看到的,用户启动了一个操作:他们想要将两个数字相加。他们在Main类的 UI 中输入它。然后Main类实例化Adder类的一个实例。在创建之后,Main类调用AddUp(a,b)方法。结果被传回Main类并展示给用户。在这之后,我们可以丢弃Adder实例。
太棒了。边界在哪里?如果我们这样看,我们可以说Adder中的代码以及因此MathFunctions库中的代码与用户操作直接相关。所以,它是面向用户的代码,而不是面向系统的代码。
我仍然喜欢用谁在使用代码的问题来确定我们正在编写什么类型的软件。但显然,这还不够。我们需要深入一点。
MyAwesomeCalculator和MathFunctions中的代码在单独的组件中。用户与一个组件交互;另一个组件仅通过代码访问。但它们仍然可以被视为一个整体。如果我们运行应用程序,运行时将为我们创建AppDomain。
.NET 中的AppDomain与.NET Framework 中的AppDomain不同。后者有更多方式将代码彼此隔离。这很好,但它是一个典型的 Windows 功能。这不太适合其他平台。因此,为了使.NET 应用程序在其他平台上运行,它们需要重新设计这一点。这导致AppDomain比以前不那么限制性。尽管如此,AppDomain仍然是不同进程之间的逻辑边界。代码在一个应用程序域中运行,不能直接访问其他应用程序域。
这里,我们又有另一个线索:我们的MyAwesomeCalculator应用程序和相关的MathFunctions程序集都在同一个AppDomain中运行。对于操作系统来说,它们是同一个。由于我们决定实际的人使用Main方法,所以这也适用于该特定AppDomain中的所有其他代码片段。
让我们稍微重写一下我们的解决方案。请看下面的截图。

图 0.3:带有工作进程的我们的解决方案
我们移除了包含所有工作的代码的类库。相反,我们创建了一个新的项目。这个项目是一个工作进程。技术上,我应该保留那个类库并引用它,但我希望保持事情简单。
工作进程是一个始终运行的后台进程(在技术上并不完全正确,但就现在而言,这已经足够了)。它只是坐在那里什么也不做。然后,突然,发生了有趣的事情,它活跃起来,完成工作,然后再次进入空闲模式。
如图 0**.4所示,在这种情况下,序列图也有所不同。

图 0.4:新修订架构的序列图
MyAwesomeCalculator和MathFunctionServices工作进程现在是相互独立的。它们各自在自己的AppDomain中运行。当用户想要执行计算时,他们在 UI 中输入,这会调用服务。Worker类获取命令,创建Adder类的实例,调用AddUp方法,然后再次使用结果调用MyAwesomeCalculator。
如您所见,所有类之间的调用都是同步的(由实心箭头线表示),除了Main和Worker之间的调用。这是异步的(由线和开放箭头表示)。
这是有道理的;计算器无法知道命令是否到达或服务是否正在监听。它只是发射并忘记,交叉着数字手指,希望一切顺利。
这更接近了。这确实是编写供其他软件使用的软件(我在这里谈论的是MathFunctionServices,而不是MyAwesomeCalculator)。
我还没有向你展示Main中的代码是如何调用Worker的,以及结果是如何从Worker流回Main的。毕竟,它们位于不同的应用程序域中。因此,它们不能共享内存,对吗?这是正确的。我没有向你展示这一点。但不要担心,我有一些章节专门讨论这个问题。
重要的是要认识到MathFunctionServices在通常意义上并没有用户界面。没有用户会触摸这段代码。它在那里,处于休眠状态,直到需要其服务。如果我们将其与第一个例子进行比较,我们会看到差异。第一个例子中,所有代码都是在用户需求下加载的,并且它们以某种方式对用户的操作做出响应。
一个更好的定义
因此,如果我们结合所有这些,我们可以确定系统编程是编写能够执行功能或一系列功能但仅与其他组件交互的组件的艺术。
这正是本书的主题。我们将学习如何编写将被其他软件消费的软件。与面向人类的软件相比,这是一种完全不同的看待软件、需求、设计考虑等方面的方式。
为软件编写软件意味着以其他方式思考通信、性能、内存使用、安全性等问题。本书中涵盖了所有这些主题。现在,你可能会说:“等等,为用户编写的软件也应该考虑到性能!”你说得对,但与面向人类的软件相比,软件之间的通信有独特的需求。
后续章节将展示如何实现期望的性能,并解释为什么这一点很重要。让我们达成共识:一个可能每秒被调用数千次的组件,在性能方面需要比一个用户可能每小时点击一次的按钮屏幕投入更多的思考。我在这里夸张了,但我相信你明白了这个观点。
同样的情况也适用于内存消耗。我相信我们始终应该考虑到内存消耗来编写所有软件。然而,一个被许多其他系统频繁使用的组件,往往比其他软件程序更容易受到内存问题的困扰。
当我们思考编写嵌入式系统时,性能和内存压力是至关重要的。嵌入式软件通常在非常有限的硬件上运行,因此我们必须尽力利用书中所有的技巧,使其尽可能快地运行,并尽可能少地使用内存。
正如承诺的那样,我们将花大量时间研究与这类软件通信的方法。
对于我来说,系统编程是软件开发最纯粹的形式。它全部关乎算法、微调,以及尝试书中所有的技巧来最大限度地利用它。系统编程是软件开发的大联盟。当你掌握了这些,你编写的所有其他软件也将从你新获得的知识中受益。你在编写系统软件时所学的知识将变得习以为常,并且你会提高你的整体软件开发技能。这听起来令人兴奋吗?那么,让我们开始吧!
在系统编程中使用 C# 和 .NET
我们已经遇到了一个问题。你很可能是 C# 开发者。也许你是 VB.Net 开发者。但无论什么语言,你都是 .NET 开发者。毕竟,这本书就是关于这个的。
传统上,系统编程是在汇编语言、C 和 C++ 中完成的。系统编程一直是那些对所从事的系统了如指掌的硬核开发者的领域。在上个世纪的五十年代初期,人们使用开关编写系统软件。开关处于向上位置表示 1,而处于向下位置表示 0。这些早期的计算机有 8、16 或更多的开关,指向读取或写入的内存地址。然后,8 个开关代表该内存地址的字节中的所有位。在这些开关之上,有一些小灯(不,不是 LED:那个发明发生得晚些)。这些小灯,如果点亮,表示该字节中的 1(如果没有点亮,则表示 0)。这样,你就可以读取该内存地址的内容。
不要担心;这种低级编程不是这本书的主题。如果你感兴趣,有很好的 Altair 8800 复制品,它开启了一家名为微软的公司。你可以用这种方式编程这台计算机:使用前面板上的开关和灯来输入你的软件。这就是比尔·盖茨和保罗·艾伦编写他们第一份软件的方式。但我们有其他工具可以利用。
由于系统软件依赖于高效、快速和内存感知的代码,人们通常使用接近硬件的编程语言。这通常意味着使用机器代码之类的语言——例如我之前提到的开关。汇编语言是另一种使用的语言,尤其是在上个世纪的七十年代和八十年代。C 和后来的 C++ 是其他可以利用硬件特定功能的语言示例。例如,Windows 的大部分代码都是用 C 编写的。
然而,系统开发者并不局限于低级语言。让我给你举一个例子。
系统编程的高级语言
在 1965 年,IBM 发布了一本名为《PL/I 语言规范 C28-6571》的手册。这个相对冷门的书名读起来非常有趣:它概述了PL/I 编程语言的规范。PL/I,即Programming Language One的缩写,是一种高级编程语言。它包含块结构以允许递归,许多不同的数据类型,异常处理,以及我们今天视为理所当然的许多其他特性。它确实是一种高级语言。然而,他们用它来编写 IBM 早期操作系统的部分。记住,这是六十年代,当时每毫秒都很宝贵。与现在的系统相比,机器运行得非常慢,所以他们不得不利用书中的每一个技巧来使事情工作。然而,高级语言被认为是合适的。这意味着今天没有理由不使用高级语言,尤其是考虑到内存分析器和编译器的技术优势。
内核模式与用户模式
操作系统(OSs)和驱动程序通常不是使用.NET 构建的。原因是驱动程序和操作系统的绝大部分都在内核模式(kernel mode)下运行。
你电脑中的 CPU 可以运行在两种模式下:内核模式或系统模式和用户模式。用户模式是大多数应用程序运行的地方。CPU 通过将应用程序放置在沙盒中来保护应用程序,防止它们使用其他内存或进程空间。处理器通过这种方式处理这一级别的安全性。
然而,内核模式没有这些限制。在内核模式下运行的软件受到的限制较少,控制较少,且更受信任。这是有道理的:操作系统的某些部分应该能够在系统的所有部分运行,包括在其他应用程序的空间中。
然而,要在内核模式下运行,编译的代码需要设置某些标志,二进制文件的布局应该非常具体。这正是我们面临的问题。我们的 C#代码严重依赖于.NET 运行时,而这个运行时不是为在内核模式下使用而构建的。所以,即使我们能够编译我们的代码以便操作系统接受它,由于应用程序没有加载运行时,它仍然无法工作。
有一些方法可以绕过这个问题。有方法可以预编译并将运行时类包含到你的二进制文件中。然后,你可以修改这个二进制文件以在内核模式下运行。然而,结果可能会有所不同,整个过程将是不可靠的。不可靠的代码与设备驱动程序或操作系统部分应该具备的特性正好相反,所以我们不会在本书中涉及这一点。这是一个黑客行为,而不是一种标准的工作方式。
虽然这本书没有涉及内核模式应用程序,但我想要给你一些见解。特别是,因为系统编程通常可以称为“接近金属”的编程,也就是说,我们正在与在内核模式下运行的系统交互。
内核模式是 CPU 的一种模式。系统可以请求 CPU 打开内核模式。如果请求它的代码具有适当的权限,CPU 就会这样做,从而解锁之前不可用的内存部分。代码执行它需要执行的操作,然后 CPU 返回用户模式。由于代码仍然在内存中执行各种操作,所以说一个应用程序是内核或用户模式应用程序是非常错误的。一些应用程序可以将 CPU 切换到那种状态,但应用程序几乎总是以混合模式运行:大多数时间在用户模式,有时在内核模式。哦,当我提到 CPU 时,我指的是逻辑 CPU。这种切换发生在那个层面,而不是在芯片本身(但它也可以做到)。
我在我的机器上安装了 Adobe Creative Cloud。我们都知道 Photoshop、Illustrator 和 Premiere,但这些应用程序旨在通过 Creative Cloud 应用程序访问。此应用程序监控系统,并在需要时启动所需的任何应用程序。它还会更新背景,并跟踪您的字体、文件、颜色和其他类似事物。
每当您读到“在后台运行”这样的内容时,您可能会期望有一些系统编程在进行,确实如此。
例如,如果我启动 Adobe 桌面服务进程的% Privileged Time和% User Time计数器,我会得到这个图像。

图 0.5:性能监视器显示内核和用户时间
在图 0.5中,红线显示了 Adobe 桌面服务在用户时间中花费的时间。然而,绿色线显示服务在特权时间中运行的时间,而特权时间只是内核时间的另一种说法。
如您所见,此应用程序在内核时间中做了大量工作。虽然我必须承认,我对它在那里做什么一无所知,但我确信它肯定有很好的理由。
我们将在其他章节中遇到内核模式,但我们将不会构建在其中运行的应用程序。
为什么使用.NET?
因此,我们确定我们无法在.NET 中构建操作系统或设备驱动程序。这可能会引发问题:“我们能否使用.NET 进行系统编程?”答案是肯定的。否则,这将是一本非常薄且简短的书。
我们是否应该看看我们最近发现的系统编程的定义?“编写用于其他软件的软件,作为更大系统的一部分,共同实现某个目标。”我简化了这个定义,但它就是这个意思。
从这个角度来看,我们可以使用.NET 来编写这样的软件。更好的是:我敢打赌.NET 是做这件事的最佳选择之一。
.NET 相对于纯 C 或甚至 C++(不是托管类型的 C++,那种仍然是.NET)提供了许多优势。
在过去,当我们使用基于 .NET Framework 的应用程序时,将其用于系统编程是个糟糕的主意。然而,随着最新版本的 .NET 的引入,许多缺点都得到了解决。随着许多缺点被消除,基于 .NET 的系统成为这些类型系统的可行选择。
C 和 C++ 仍然是底层系统代码的优秀语言。然而,C# 和 .NET Core 也有其优势。
此表列出了差异。
| 主题 | C# 和 .NET Core | C/C++ |
|---|---|---|
| 性能 | 与 .NET Framework 相比,.NET Core 的性能有所提高,但由于其运行时,可能仍然存在开销。这对大多数应用程序来说不会是问题,但对于高度性能关键的系统来说可能很重要。 | C/C++ 提供了对硬件的直接控制,并且通过仔细优化,可以在性能关键系统中提供优越的性能。 |
| 内存管理 | .NET Core 仍然提供自动垃圾回收,减少了内存泄漏的机会,但给予开发者的控制较少。这更适合应用级编程。 | C/C++ 允许开发者直接控制内存分配和释放,使其更适合需要精细内存管理的系统编程。 |
| 系统级编程 | 由于其高级抽象和安全功能,某些系统级编程任务在 .NET Core 中可能仍然比较困难。 | C/C++ 通常用于系统级编程,因为它允许直接访问硬件和低级系统调用,这对于内核开发、设备驱动程序等至关重要。 |
| 可移植性 | .NET Core 应用程序可以在多个平台上运行而无需重新编译,但必须在目标机器上安装 .NET 运行时。这比 .NET Framework 有所改进。 | C 和 C++ 代码几乎可以在任何系统上编译和运行,但通常需要仔细管理平台特定的差异。 |
| 运行时要求 | .NET Core 应用程序仍然需要在目标机器上安装 .NET Core 运行时。这可能会限制其在资源有限的系统上的使用。 | C 和 C++ 应用程序编译成机器代码,不需要单独的运行时。这对于系统级应用程序或与资源受限系统一起工作时可能是有益的。 |
| 直接控制 | C# 和 .NET Core 仍然提供许多可以增加生产力的抽象,但这些抽象可能会限制对系统和代码运行方式的直接控制。 | C/C++ 提供了对系统的更多直接控制,允许进行精细调优的优化,并精确控制代码的运行方式。 |
| 社区和支持 | .NET Core 和 C#拥有不断增长的社区和丰富的支持资源,包括跨平台开发。 | C/C++拥有庞大且成熟的社区,许多开源项目以及大量的现有系统级代码。 |
表 0.1:C#和 C/C++的比较
如你所见,两种选择都有其优缺点。然而,大多数.NET Core 的缺点可以通过巧妙的方法和智能编程来消除。这些就是本书剩余部分要讨论的主题。
C#是一种非常成熟且设计精良的语言。其功能远超开发者在使用 C 语言构建,例如 Unix 操作系统时所能拥有的。
那么.NET 究竟是什么呢?
.NET Core 是旨在帮助开发者快速完成工作的二十多年老框架的下一个版本。
所有这一切都始于 2002 年的.NET Framework 1。微软将其作为解决开发者面临许多问题的终极解决方案。有趣的事实:该项目内部代号为 Project 42。如果你知道他们为什么选择这个名字,你会得到额外的分数。
在引入后的几年里,我们看到了.NET Framework 的许多不同功能。微软于 2019 年 4 月 18 日发布了.NET Framework 的最后一个版本。
在此之前,微软意识到他们需要支持其他平台。他们希望.NET 能够在任何地方可用,包括 Linux、Macintosh 以及大多数移动设备。这意味着他们必须对运行时和框架进行根本性的改变。他们决定不再为每个平台提供不同的运行时版本,而是选择了一个统一的版本。这成为了.NET Core。微软于 2016 年 6 月发布了这个版本。
.NET Standard 是一套规范。这些规范告诉所有开发者运行时在哪个版本中提供了哪些功能。大多数开发者并不理解.NET Standard 的目的,并假设它又是运行时的另一个版本。但一旦他们理解了其背后的理念,它就变得非常有意义。如果你需要一个特定的 API,查阅文档,看看它支持哪个.NET Standard 版本,然后检查你想要的运行时是否支持该版本的.NET Standard。
这里举一个例子可能会有所帮助。假设你构建了一个在屏幕上执行一些复杂绘图的程序。你之前已经使用过System.Drawing.Bitmap,所以你希望再次使用它。然而,你的新应用程序应该在.NET Core 上运行。你能重用你的代码吗?如果你查阅System.Drawing.Bitmap类的文档,你会看到以下内容:
| 产品 | 版本 |
|---|---|
| .NET 框架 | 1.1, 2.0, 3.0, 3.5, 4.0, 4.5, 4.5.1, 4.5.2, 4.6, 4.6.1, 4.6.2, 4.7, 4.7.1, 4.7.2, 4.8, 4.8.1 |
| .NET 平台扩展 | 2.1, 2.2, 3.0, 3.1, 5, 6, 7, 8 |
| Windows 桌面 | 3.0, 3.1, 5, 6, 7, 8 |
表 0.2:System.Drawing.Bitmap 的支持情况
真糟糕。这个类不是 .NET 标准的一部分。它不是所有运行时都有的。你需要找到另一种方式来绘制你的图像。
你的应用程序也与外部世界进行通信。它使用 HttpClient 类,该类位于 System.Net.Http 命名空间中。你能将其移动到其他平台吗?再次,我们需要查找该类的文档。在那里,我们看到这个表格:
| 产品 | 版本 |
|---|---|
| .NET | Core 1.0, core 1.1, core 2.0, core 2.1, core 2.2, core 3.0, core 3.1, 5, 6, 7, 8 |
| .NET framework | 4.5, 4.5.1, 4.5.2, 4.6, 4.6.1, 4.6.2, 4.7, 4.7.1, 4.7.2, 4.8, 4.8.1 |
| .NET standard | 1.1, 1.2, 1.3, 1.4, 1.6, 2.0, 2.1 |
| Uwp | 10.0 |
| Xamarin.ios | 10.8 |
| Xamarin.mac | 3.0 |
表 0.3: 对 Sstem.Net.Http.HttpClient 的支持
现在更像样子了。HttpClient 是 .NET 标准规范的一部分,这意味着所有支持提到的 .NET 标准版本的运行时都实现了这个类。你可以开始了!
.NET、.NET Framework、.NET 标准——这些都是什么?
表 0.3 显示了 .NET Framework、.NET 标准和 .NET,但没有 .NET Core。我们确实看到了 .NET。这究竟是怎么回事?
.NET Core 的引入是为了与 .NET Framework 并行。微软原本打算让 .NET Framework 支持 Windows 设备。然而,正如我解释的那样,微软后来决定支持其他设备、操作系统和其他硬件架构;因此引入了 .NET Core。然后,他们意识到这使事情变得更加复杂。人们失去了对可以使用什么以及在哪里使用的跟踪。这个解决方案是引入 .NET 标准规范,但这使事情变得更糟——甚至那些最初没有困惑的人也失去了对正在发生什么的跟踪。
版本编号也是一个问题。我们有与 .NET Standard 2.1 匹配的 .NET Framework 版本 4.8.1。.NET Core 3.1 也支持 .NET Standard 2.1。许多人不知道发生了什么。他们无法理解为什么 .NET(Core)3.0 版本比 .NET 4.5 更新。
微软也看到了这个问题。他们也有内部问题:他们必须将库中的大量代码回溯,以便在所有地方都可以使用。为了彻底解决这个问题,他们宣布 .NET Framework 4.8 将是最后一个版本。.NET Core 3.1 也将是最后一个版本。从现在开始,所有内容都统一在称为 .NET 的东西中。然后,为了防止编号问题,.NET 从数字 5 开始。
他们还使跟踪新版本发布的时间变得更加容易。每年,都会有新的 .NET 版本。到目前为止,奇数版本处于长期支持(LTS)状态;偶数版本处于标准期限支持(STS)状态。STS 为 18 个月,LTS 为 3 年。
.NET 5 是一个短期支持版本,自 2020 年 11 月发布以来,支持已于 2022 年 5 月结束。.NET 6 是一个长期支持版本。于 2021 年 11 月发布,支持将于 2024 年 11 月结束。.NET 7 再次是一个短期支持版本,于 2022 年 11 月发布,生命周期结束于 2024 年 5 月。
在撰写这本书的时候,.NET 8 的预览版已经发布,并且它将是一个长期支持版本。
这本书中我使用的就是这个版本。
现在,版本号是清晰的。发布周期是可理解的。我们终于可以放手了。我们可以专注于构建酷炫的东西,而不是担心版本。
编程语言 – 一个需要做出的选择
我们还没有完成。我们已经确定了需要哪个版本的运行时。但是,运行时只是那样:一个运行时。一组我们可以使用的库。这些库提供了许多工具和预构建的类,因此我们不必自己编写这些。这真是太棒了。然而,我们仍然需要自己编写一些代码。我们用编程语言来做这件事,然后链接到库,编译代码,并拥有可以部署和运行的二进制文件。
我们应该使用哪种语言?
微软为我们提供了三种选择。其他人已经创建了他们自己的与 .NET 兼容的语言,但我们忽略它们。如今,编写 .NET 代码的主要语言是 C#、F# 和 Visual Basic。
F# 是一种用于函数式编程的语言。这是一种与大多数人习惯的编程方式不同的方法,但金融领域和数据密集型系统大量使用它。
Visual Basic 是一种非常适合刚开始开发的人使用的语言。在上个世纪的九十年代,它是人们快速构建 GUI 系统的少数几种选择之一。当 .NET 出现时,微软迅速将 Visual Basic 移植到支持这个框架,这样开发者就不需要那么陡峭的学习曲线。然而,随着微软停止与 C# 共同进化,Visual Basic 的使用正在减少。
这本书中我们使用的是 C# 语言。
虽然与可用的运行时不耦合,但微软似乎在发布新的 .NET 版本的同时发布新的语言版本。该语言的第 11 版于 2022 年 11 月发布。当撰写这本书时,C# 的第 12 版现在处于预览阶段。
语言的新版本都有改进,但许多都是语法上的。这意味着如果你不能使用最新的语言版本,你仍然可以使用运行时中的所有功能。它们是官方解耦的。有时,这仅仅需要更多的打字工作。
.NET 运行时是构建各种系统的优秀基础。围绕 .NET 的生态系统非常广泛。接下来,一大群人每天都在为框架做出贡献。很难想象一个不能用 .NET 或可用的数千个 NuGet 包完成的任务。
再次强调,对于真正的内核模式系统,例如设备驱动程序,最好使用非托管语言来构建。然而,对于所有其他用途,.NET 和 C# 是一个极好的选择。
现在又是什么呢?
恭喜!你已经迈出了成为系统程序员的第一个步骤。你现在知道什么是系统编程,以及它与你可能习惯的日常编程有何不同。
你了解编程的背景以及我们的前辈所面临的挑战,你也知道为什么.NET 是构建系统软件的如此出色的工具。
我们已经准备好迈出下一步。我们将深入探讨细节。然而,在我们这样做之前,我们需要讨论 API 和.NET 框架,它的优点和缺点。那么,让我们开始吧!
设置你的开发环境
我要求你跟我一起做。我要求你打开你的开发环境并做我做的事情。然而,为了做到这一点,你需要设置正确类型的开发环境,这样你才能真正做到我做的事情。
让我来帮你。
我使用Visual Studio 2022 Enterprise。我使用企业版的原因没有特别之处,只是因为我机器上有这个版本。还有两个版本:专业版和免费的社区版。所有三个版本都适合我们想要做的事情。然而,企业版确实有一些我们在讨论调试时可能需要的调试工具。当那个时候到来时,我会指出差异,并展示其他实现目标的方法。
其他替代品,如 JetBrains Rider 和 Visual Studio Code 也适用,但当我们进入性能调整和调试时,你可能需要自己做更多的工作。再次提醒,当我到达那里时,我会告诉你这些信息。
我对 Rider 的经验有限,所以不能确切地告诉你你需要做什么,但我确信当你成为一名经验丰富的开发者时,你可以将我展示的内容翻译成你熟悉和喜爱的工具。
使用你所拥有的和所知道的一切。我对此很满意。
如果你决定使用我强烈推荐的 Visual Studio,你应该使用 2022 版本而不是 2019 版本。.NET 和 C#的最新版本提供了许多与性能调整和内存优化相关的功能。这些版本仅在 Visual Studio 2022 版本中可用。所以,确保你的设备上有这个版本。
此外,我们还将进行大量的控制台操作。这意味着使用 PowerShell:使用cmd.exe的日子已经过去了。
我强烈推荐下载 Windows Terminal。有了终端,你可以拥有各种控制台。我们大部分时间会使用 PowerShell,但当我们谈到 Linux 时,我们会使用 WSL 功能来将我们的机器作为 Linux 机器使用。
下载和安装终端非常简单:你可以在 Microsoft Store 中找到它。
确保还安装了 Windows Subsystem for Linux。有关如何操作的说明在网上到处都是;我不会在这里重复。
一旦你安装了所有你喜欢的工具,你可以在你的终端中选择其中任何一个。我的看起来像这样:

图 0.6:不同壳的 Windows Terminal
如你所见,我已经安装了 PowerShell、命令提示符、Ubuntu、Azure Cloud Shell 以及一些其他的东西。选择其中之一只需点击一下。
在 Linux 和 Windows 之间切换从未如此简单!
我们稍后会使用的一个工具是 WinDbg。WinDbg 是一个功能极其强大的外部调试器。它可以提供大量关于你感兴趣进程的信息。它独立运行,因此你不需要将 Visual Studio 附加到进程上。它有 X86 和 ARM 两种版本,因此可以在许多设备上使用。你可以在微软网站上找到 WinDbg,网址是 learn.microsoft.com/en-us/windows-hardware/drivers/debugger/。下载并安装它。WinDbg 可能会成为你最新的最佳拍档之一。
接下来,你可能想安装 PerfView。这是一个来自微软的免费开源性能监控工具,专门为分析 .NET 应用程序的性能而构建。
你可以在 github.com/Microsoft/perfview 找到源代码。你可以下载源代码自行构建工具,或者获取预构建版本。这些版本也在同一个网站上。我建议你自己构建并查看源代码:这里有如何构建此类软件的一些极好的示例。我并不打算描述工具的内部工作原理,但我会讨论性能时使用它。
现在,你只需要一杯你最喜欢的饮料,我们就可以出发了!
第一章:拥有底层秘密的那一个
理解 底层 API
编写软件可能是一项艰巨的任务。当您试图将您的想法转化为机器上可以运行的东西时,您需要考虑许多因素。毕竟,在计算机执行任何有用的操作之前,您需要告诉计算机很多事情。
但我们很幸运。我们需要提供给 CPU 的许多指令都被封装在框架、工具、包和其他软件组件中。这些构建块使我们能够专注于我们想要构建的内容,而不是 CPU 如何解释我们的指令。这使得生活变得更加容易!
本章探讨了这些构建块,它们如何帮助我们,以及我们如何最好地使用它们。本章还涵盖了.NET 的工作原理及其来源。这很重要:大多数开发者都理所当然地认为.NET 具有优势。这是可以接受的,因为框架隐藏了许多复杂性。然而,在编写底层系统软件时,了解.NET 中的事物为何如此工作以及如何在需要时使用其他解决方案至关重要。此外,偶尔提醒一些基本的事情也无妨,尤其是在您可能需要偏离面向用户的软件开发者所走的道路时。
因此,我们将涵盖以下主题:
-
什么是底层 API?
-
基类库(BCL)如何帮助我们.NET 开发者?
-
什么是公共语言运行时(CLR)?
-
Win32 API 是什么,我们如何调用它们?
总而言之,我们将深入探讨并全面了解技术。
但在我们深入探讨.NET 生态系统为我们提供的构建块之前,我们需要讨论 API——更准确地说,是底层 API 和高级 API 之间的区别。
技术要求
您可以访问以下链接查看本章中所有代码:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter01。
底层 API 是什么,它们与高级抽象有何不同?
嗯,也许我们走得有点快了。在我们能够查看底层和高级 API 之前,我们需要就 API 的含义达成一致。
API 是应用程序编程接口的缩写。虽然技术上正确,但它并没有告诉我们太多。我们需要一个更好的 API 定义。
接口是什么?
让我们从术语接口开始。仅凭这一点,根据您询问的人的不同,就可以完全不同地定义。
接口可以是一个软件接口,它是两块软件之间的边界。例如,SQL Server 这样的数据库允许用户通过接受 SQL 查询来访问数据。这就是该数据库系统的主要接口。
接口的一个另一种定义将是硬件接口。您电脑上的 USB 端口以及您通过它们连接到机器的外设都是硬件接口。
当然,在 C#中,我们也有接口。大多数面向对象编程语言以某种方式支持接口。例如,C++有纯虚类的概念。Python 支持抽象基类,它们具有相同的目的。
API 是软件与程序员使用的其他软件之间的接口。这定义了给定代码集的边界以及如何与之交互。
因此,可以创建一个充满奇妙且高度复杂代码的巨大库。作为库用户,你将获得一系列方法、类、接口(是的,C#类型的)、枚举以及其他与该库交互的方式。
这很棒,因为你可以在不担心编写代码的情况下使用那个库。
底层和高级 API
API 的级别是一个任意的区分,用来给你一个 API 实际硬件接近程度的想法。
没有任何度量标准告诉我们何时某个东西是底层或高级 API。一切都是相对的,并且可以公开讨论。然而,这里我们不会这样做。
通常,底层 API 比高级 API 提供对硬件更细粒度的控制。然而,高级 API 通常更易于移植,并且可以更快地实现目标。
如果这一切听起来有点抽象,不要担心。让我通过一些例子来澄清这一点。例如,想象你想通过网络发送一些数据。当我说网络时,我的意思是我们将它发送到 IP 地址127.0.0.1。换句话说,我们发送到本地主机;我们在和自己说话。
要做到这一点,我们需要调用 Windows SDK 为我们提供的许多底层 API。代码看起来是这样的:
static void UseLowLevelAPI()
{
WSAData wsaData;
if (WSAStartup(0x0202, out wsaData) != 0)
{
Console.WriteLine("WSAStartup failed");
return;
}
IntPtr sock = socket(2 /* AF_INET */, 1 /* SOCK_STREAM */, 0);
if (sock == new IntPtr(-1))
{
Console.WriteLine("socket() failed");
WSACleanup();
return;
}
sockaddr_in sin = new sockaddr_in();
sin.sin_family = 2; // AF_INET
sin.sin_port =(ushort)IPAddress.HostToNetworkOrder((short)8000); // Port 8000
sin.sin_addr = BitConverter.ToUInt32(IPAddress.Parse("127.0.0.1") .GetAddressBytes(), 0);
if (connect(sock, ref sin, Marshal.SizeOf(typeof( sockaddr_in))) != 0)
{
Console.WriteLine("connect() failed");
closesocket(sock);
WSACleanup();
return;
}
byte[] data = Encoding.ASCII.GetBytes("Hello, server!");
if (send(sock, data, data.Length, 0) == -1)
{
Console.WriteLine("send() failed");
}
closesocket(sock);
WSACleanup();
}
正如你所见,为了完成这样一个相对简单的任务,必须发生许多事情。我已经省略了所有我们需要访问 API 以及类和结构体(如WSAData)定义的代码。我还简化了这个示例,没有使用很多错误处理或内存管理。
我不会解释前面代码中发生的事情,因为它不是我要展示的内容的一部分。我们将在本书讨论网络时再次回到这个话题。我提供这段代码是为了展示底层 API 的样子。在这里,我希望你注意对WSAStartup()、WSACleanup()、socket()、connect()、send()和closesocket()的调用。这些来自 Windows SDK 的 API。它们是 Windows 中帮助我们设置网络接口连接、转换地址、打开和关闭套接字以及发送数据的部分。
注意
记住 Windows SDK 是一个包装器是很好的。SDK 内部的代码,主要用 C 语言编写,部分用 C++编写,执行了繁重的任务并调用硬件。我们不必担心这一点:微软的人已经想出了如何完成所有这些。
就像我说的一样,低级和高级术语取决于你如何看待它们。这完全是相对的。当你从 C 程序员的视角来看时,他们必须做所有繁重的工作,你可以将 Windows SDK API 视为高级 API。
但作为.NET 开发者,我们将其视为相当低级。这是因为,作为.NET 开发者,我们有更易于使用的工具。前面的代码不是大多数开发者会写的。相反,他们会写以下内容:
static void UseHighLevelAPI()
{
try
{
// Connect to server at 127.0.0.1:8000
using (TcpClient client = new TcpClient("127.0.0.1", 8000))
using (NetworkStream stream = client.GetStream())
{
// Prepare the message
byte[] data = Encoding.ASCII.GetBytes("Hello, server!");
// Send the message
stream.Write(data, 0, data.Length);
Console.WriteLine("Sent: Hello, server!");
}
}
catch (SocketException e)
{
Console.WriteLine($"SocketException: {e}");
}
catch (IOException e)
{
Console.WriteLine($"IOException: {e}");
}
catch (Exception e)
{
Console.WriteLine($"Exception: {e}");
}
}
这段代码更容易且更小。前面的大部分代码都是捕获异常。
TcpClient类正在做艰苦的工作。我们实例化它的一个实例,给它我们想要连接的地址,从它那里获取一个NetworkStream实例,然后写入一些字节。很简单。它工作得非常出色。
那么,你为什么要关心这些低级的东西呢?
尽管低级代码工作量更大且更复杂,但它给你一个显著的优势:更多的控制。
我们在这里使用 TCP/IP。但如果你想要通信的设备没有 TCP 怎么办?在你还没来得及说“现在所有东西都是基于 IP 的”之前,我非常确信你家里的电脑可能还在使用较旧的技术进行通信。你可能每天都在使用没有 TCP 的设备。我指的是大多数电视机的遥控器。它们使用红外线。许多设备仍然使用红外线。它成本低,易于理解,安装快速,在用例方面稳健。它也不受.NET 支持。
但当涉及到低级 API 时,它相当简单。在设置连接的方式上存在一些差异:没有 IP 地址,所以你必须使用设备 ID,但连接本身并不难使用。看看我们设置socket()调用的那行代码。我们使用2作为第一个参数,它代表AF_INET,意味着 TCP。将其改为26 (AF_IRDA),底层库就会切换到红外设备。
这不能用可用的.NET 库来完成。
高级 API 非常出色,帮助我们快速编写易于理解的代码。然而,作为系统程序员,我们必须处理硬件和其他低级系统。那时我们就必须使用低级 API。
在我们深入探讨如何使用这些 API 之前,让我们看看.NET 库本身。在此过程中,我们将检查 CLR,以便你对.NET 能给我们带来什么有一个清晰的了解。
.NET Core 运行时组件概述(CLR、BCL)
之前,我们探讨了低级和高级编程语言之间的区别。就像 API 一样,低级和高级意味着你离实际机器有多近或有多远。用 C 语言编程意味着你非常接近硬件;用 C#编程意味着你远离。当然,距离越远意味着你在抽象层面工作。优点是许多事情都简化了,正如本章前面所看到的。此外,由于许多抽象,将你的代码迁移到其他平台更容易管理。
使这一切成为可能的是.NET 运行时。从第一个版本开始,设计者就始终致力于尽可能多地屏蔽低级内容。这让你可以快速编写代码,专注于功能而不是样板代码。
.NET 是一个复杂的话题。但简而言之,它是一套以多种形式存在的工具,帮助你实现目标。
有趣的事实
在其最初发布之前,该项目的代码名称是 Project 42。42 是来自科幻作家道格拉斯·亚当斯的书、电视剧和主要科幻电影《银河系漫游指南》中,对生命、宇宙和万物之答案。亚当斯写道,42 是万物的答案;因此,.NET 设计者认为将所有开发者问题的解决方案命名为 Project 42 是合适的。
.NET 并不能解决所有问题,但它使生活变得更加容易。让我们看看它是如何做到这一点的。
我们可以确定.NET 帮助我们解决三个主要领域:
-
开发工具
-
CLR
-
BCL
我不会在这里花费时间讨论开发工具。相反,我想讨论 CLR 和 BCL。这两个构成了.NET 生态系统的核心。在后面的章节中,我将介绍.NET 生态系统的其他重要部分,例如公共类型系统(CTS)。
CLR
CLR 是我们代码运行的运行时环境。
编译器(本书后面将详细介绍)编译我们编写的代码。目前,我们可以想象编译器将我们人类可读的文本转换为计算机可以理解和使用的某种形式。
嗯,并不完全是这样。我需要在这里澄清一些事情。虽然我在讨论编译器时写的内容在技术上是对的,但这只适用于真正的编译器,例如使用 C 或 C++找到的编译器。这并不适用于.NET 世界。
.NET 编译器在针对通用运行时而不是我们运行的硬件进行编译。
编译器的输出不是针对硬件的。相反,它输出一种称为中间语言(IL)的东西。这是一种“中间”形式。它不是人类可读的,但对于计算机来说又过于抽象。它介于这两种形式之间。
让我通过一个例子来澄清这一点。
我编写了一个没有任何顶层语句的.NET 控制台应用程序。换句话说,这是我们使用.NET 可以编写的最简单的代码片段。整个程序由一行代码组成:
Console.WriteLine("Hello, System Programmers!");
我不需要解释我在这里做什么,对吧?
如果我们使用 Visual Studio 来编译代码,它将获取所有我们的文件,将它们交给编译器,并指示它构建一个二进制文件。这看起来是这样的:
.method private hidebysig static void '<Main>$'(string[] args) cil managed
{
.entrypoint
// Code size 12 (0xc)
.maxstack 8
IL_0000: ldstr "Hello, System Programmers!"
IL_0005: call void
[System.Console]System.Console::WriteLine(string)
IL_000a: nop
IL_000b: ret
} // end of method Program::'<Main>$'
这有点难以理解,但并不太难。首先,有一些代码用于设置环境(.maxstack 8)。我们通过调用ldstr函数来加载字符串,然后调用System.Console::WriteLine(string)方法,然后我们就完成了。
再次强调,这并不是机器代码。那看起来要复杂得多,我不会向您展示。如果编译成 CPU 可以理解和执行的代码,这段代码会有几页长。
然而,我将向您展示其中的一部分,以便您先尝尝味道:
00007FF9558C06B0 push rbp
00007FF9558C06B1 push rdi
00007FF9558C06B2 push rsi
00007FF9558C06B3 sub rsp,20h
00007FF9558C06B7 mov rbp,rsp
00007FF9558C06BA mov qword ptr [rbp+40h],rcx
00007FF9558C06BE cmp dword ptr [7FF95597CFA8h],0
00007FF9558C06C5 je
Program.<Main>$(System.String[])+01Ch (07FF9558C06CCh)
00007FF9558C06C7 call 00007FF9B54D7A10
00007FF9558C06CC mov rcx,1A871002068h
00007FF9558C06D6 mov rcx,qword ptr [rcx]
00007FF9558C06D9 call qword ptr
[CLRStub[MethodDescPrestub]@00007FF9559C17E0
(07FF9559C17E0h)]
00007FF9558C06DF nop
00007FF9558C06E0 nop
00007FF9558C06E1 lea rsp,[rbp+20h]
00007FF9558C06E5 pop rsi
00007FF9558C06E6 pop rdi
00007FF9558C06E7 pop rbp
这段微小的汇编代码段指示 CPU 获取字符串所在内存的指针,然后调用WriteLine方法的第一部分。
再次强调,完整的代码会有几页长。
我希望您开始欣赏.NET 系统的简洁性和简单性。但我也想让您知道幕后发生了什么。在编写系统软件时,我们有时需要做一些在.NET 中不可能完成的事情。然后,我们必须依赖其他方式来实现我们的目标。我们不会在这本书中编写纯汇编代码:那会太复杂了。但我确实想让您知道正在发生的事情,这将在以后给您带来巨大的好处。
好的。在我向您展示的 IL 代码和我向您展示的汇编代码之间,是 CLR 存在的地方。
如learn.microsoft.com/en-us/dotnet/standard/clr所述,CLR 为我们提供了相当多的事物:
-
性能改进
-
能够轻松使用其他语言开发的组件
-
由类库提供的可扩展类型
-
面向对象编程的语言特性,如继承、接口和重载
-
支持显式多线程,允许创建多线程和可扩展的应用程序
-
支持结构化异常处理
-
支持自定义属性
-
垃圾回收
这条信息直接来自微软的文档,所以如果您想了解更多,我强烈建议您查找并阅读更多关于它的内容。后面的章节将讨论一些这些项目,例如线程、异常处理和垃圾回收。现在,了解当我们编译我们的代码时,我们为 CLR 使用和运行它做准备,CLR 将负责其余部分,并在实际硬件上运行得很好就足够了。
在 CLR 上运行的代码就是我们所说的托管代码。所有其他代码(因此不在 CLR 控制下的代码)都是非托管的。您将大部分时间都在处理托管代码,但编写系统软件时,您会经常遇到非托管代码。但别担心:我会引导您通过这一过程!
BCL
.NET 的设计者心中设定的一个目标就是消除开发者所说的 DLL 地狱。
这个想法是,当编写软件时,开发者很快意识到反复编写相同的代码会感到乏味,并且难以维护。相反,他们创建了包含可重用函数的库。这些库将在需要时加载,并与调用代码链接。这就是动态链接库(DLL)这个名字的由来。
当然,开发者作为开发者,并不满足于他们或其他人之前写的 DLL,并对它们进行了修改。这些修改并不总是向后兼容的。这意味着作为一个 DLL 的用户,你必须确保你使用了正确的版本。如果没有测试这个特定版本的 DLL 是否与你的代码兼容,你就不能轻易升级。
DLL(动态链接库)有两种类型。一种专属于你的代码。你将这些 DLL 放在与你的应用程序相同的目录中,因此你只需要在你的应用程序目录中加载这些 DLL。如果推出了新的应用程序版本,它将附带它自己的 DLL 集合。
由于大多数 DLL 没有太多变化(如果它们甚至有所变化),因此有许多重复和重复的 DLL。因此,我们不是在复制代码,而是在复制 DLL。
幸运的是,有一个解决方案:你可以将 DLL 放在共享空间中。在 Windows 上,那就是C:\Windows\System32目录。运行时知道,如果它需要加载一个 DLL,但在applications目录中找不到它,它可以在System32目录中查找它。
在做这件事的时候,你需要确保你保持了向后兼容性。
自然,事情出了问题。更新会替换 DLL 为更新的、不兼容的版本。有时,应用程序更新 DLL 时没有意识到其他东西依赖于它。有时,更新会删除 DLL,从而破坏应用程序。在许多情况下,开发者部署了错误的版本。问题层出不穷。这给许多开发者带来了许多挫折,并导致他们称之为 DLL 地狱。项目 42 被设立来解决这个问题。从某种意义上说,它确实做到了。
几十年前,String类是新 C++程序员会写的第一件事。C 和 C++没有这样的东西:字符串不是语言的原生部分(它们现在还不是,但现在包含它们的辅助类是标准的一部分)。字符串可以非常简单:它只是一个指向内存中某个位置的指针,所有后续的字节形成一个长字符串。字符串在系统看到值为 0 的字节时结束(零,不是字符 o)。就是这样。String类将包含该字节数组的地址,一些分配和清除内存的辅助方法,以及如Length()这样的附加函数。就是这样。
很快,每个人都编写了不同的版本,它们都会略有不同。.NET 通过提供一个String类来解决这一问题。这个类是框架附带的一个 DLL 的一部分。系统注册了这个 DLL 及其版本号。因此,所有开发者需要做的只是告诉系统它正在使用哪个版本的框架,然后通过魔法,诸如字符串之类的功能就可用。我在这里简化了事情,但基本上就是这样工作的。
作为.NET 开发者,你可以使用一个庞大的库。你可以在C:\Windows\assembly中看到它。如果你使用 Windows 资源管理器,你会看到一个过滤后的内容视图。你可以使用终端或命令行查看实际内容。
这些 DLL 是 BCL 的一部分。BCL 是一组辅助类、函数、方法和枚举,可以帮助你完成工作。你不需要自己弄清楚所有代码,它是安装的一部分,可以直接使用。
构成 BCL 的 DLL 中的类和其他代码结构被组织到命名空间中。BCL 包含大量有用的代码,其中一些如下:
-
System命名空间,其中包含Object、String、Array等类。 -
System.IO命名空间,用于处理文件、流等。 -
System.Net用于处理网络。 -
System.Threading用于处理——你猜对了——多线程。 -
System.Data用于处理数据库中的数据存储和其他持久化数据的方式。 -
System.Xml在这里,你可以用它来处理 XML 文件和数据。 -
System.Diagnostics帮助你识别代码中的问题。我们稍后会深入探讨这个问题。 -
System.Security命名空间,以及所有与安全和加密相关的内容。
还有许多其他命名空间,但这些都是最常用的几个。我们稍后会重新访问它们。
然而,请记住,这些类是为了帮助你。它们以良好的方式封装了复杂和广泛的代码,对大多数开发者来说都是如此。但是,如果你发现 BCL 代码无法让你达到想要的目标,没有任何阻止你亲自编写代码。正如我们之前看到的,如果你想要设置 TCP/IP 连接,BCL 代码是出色的,但如果你想要使用红外连接,你必须自己完成。
好消息是你可以混合使用。在可能的地方使用 BCL,在需要的地方使用低级 API。
使用 P/Invoke 调用低级 API
我们已经确定.NET 为你提供了许多快速开发工具。它还通过屏蔽底层操作系统的低级细节来帮助你。但它也允许你在需要时使用低级 API。
但我们如何访问这些 API 呢?答案是平台调用,或(P/Invoke)。我们可以使用这个工具直接访问 Win32 API。P/Invoke 在两个平台之间架起桥梁,这样我们就可以随心所欲地混合使用。
注意
Win32 是这个 SDK 和可用的 API 的名称。没有 Win64 API 这样的东西。如果你在 64 位 Windows 平台上运行代码,我们的代码是针对 64 位 Windows 编译的,但我们(以及微软)仍然称之为 Win32 API。
P/Invoke 是如何工作的?
P/Invoke 涉及几个步骤。这些是你必须遵循的步骤,以便在.NET 应用程序中使用 Win32 API:
-
找到你想要使用的 API。
-
找到 API 所在的 DLL。
-
在你的程序集加载该 DLL。
-
声明一个存根,告诉你的应用程序如何调用该 API。
-
将.NET 数据类型转换为 Win32 API 可以理解的形式。
-
使用 API。
警告!
你已经离开了.NET Framework 和 CLR 的关爱之手。你不再受到错误的保护。你现在处于一个非托管的世界。在旧时代,他们可能会在这个文档的部分标记上警告“此处有龙”。你现在需要负责比以往更多的事情,比如内存管理和错误处理。你现在对系统的控制力更强,但请记住:大权在握伴随着巨大的责任!
让我从一个例子开始。这展示了.NET Framework 的强大功能,但也展示了之前提到的步骤在实际中是如何工作的。我们将进行一个简单的“Hello World”。
为了确保我们处于同一页面上,让我给你展示这个程序的.NET 版本:
Console.WriteLine(“Hello, System Programmers!”);
是的,这是我们之前看到的同一个示例。嘿,我们必须从某个地方开始,对吧?
现在,Console是 BCL 中的一个类。它有一个静态方法WriteLine,可以将字符串输出到输出。但如果我们假设我们不想使用这个类呢?那么我们应该如何处理这个问题?换一种方式来提出这个问题,WriteLine是如何在内部工作的?毕竟,在执行过程中,代码必须调用 Win32 API。这可以通过 CLR 或我们来实现,但必须有人或某物来调用它。
让我们使用 P/Invoke 重写代码。我会先展示整个程序,然后逐行剖析并解释它是如何工作的:
01: using System.Runtime.InteropServices;
02:
03: [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
04: static extern bool WriteConsole(
05: IntPtr hConsoleOutput,
06: string lpBuffer,
07: uint nNumberOfCharsToWrite,
08: out uint lpNumberOfCharsWritten,
09: IntPtr lpReserved);
10:
11: [DllImport("kernel32.dll", SetLastError = true)]
12: static extern IntPtr GetStdHandle(int nStdHandle);
13:
14: const int STD_OUTPUT_HANDLE = -11;
15:
16: IntPtr stdHandle = GetStdHandle(STD_OUTPUT_HANDLE);
17: if (stdHandle == IntPtr.Zero)
18: {
19: Console.WriteLine("Could not retrieve standard output handle.");
20: return;
21: }
22:
23: string output = "Hello, System Programmers!";
24: uint charsWritten;
25:
26: if (!WriteConsole(
27: stdHandle,
28: output,
29: (uint)output.Length,
30: out charsWritten,
31: IntPtr.Zero))
32: {
33: Console.WriteLine("Failed to write using Win32 API.");
34: }
这段代码很多,但让我们逐行分析。
在第 1 行,我们导入了一个命名空间,它允许我们使用 P/Invoke。.NET 使用InteropServices这个名字,所以导入它是合理的。
在第 3 行,我们看到魔法发生了。还记得我们必须采取的步骤吗?步骤 1是“找到你想要使用的 API”。由于我们想在屏幕上打印一些东西,WriteConsole API 听起来像是一个不错的选择。
微软的官方文档指出,WriteConsole API“从当前光标位置开始将字符字符串写入控制台屏幕缓冲区。”这听起来不错。
文档接着给出了这个 API 的签名:
BOOL WINAPI WriteConsole(
_In_ HANDLE hConsoleOutput,
_In_ const VOID *lpBuffer,
_In_ DWORD nNumberOfCharsToWrite,
_Out_opt_ LPDWORD lpNumberOfCharsWritten,
_Reserved_ LPVOID lpReserved
);
如果你是一个 .NET 开发者,这可能会看起来很奇怪。有很多我们不知道或理解的事情在发生。我们需要将这些类型转换为 CLR 能够理解的东西。幸运的是,有人已经为我们解决了这个问题。为了使生活更加简单,他们还为我们做了 步骤 2(找到 DLL)和 步骤 4(声明存根)。给定正确的参数,CLR 会处理 步骤 3(加载 DLL)。
“弄清楚这一点的人”是 pinvoke.net 背后的人。你可以在那里搜索 API 并学习如何使用它们。
官方文档有一个名为 Kernel32.dll 的部分(Pinvoke.Net 也提供了这个信息)。
第 3 行 是告诉 InteropServices 加载 DLL 的部分。让我们深入探讨一下:
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
这一行告诉 CLR 加载 kernel32.dll。然后指定如何处理字符。字符和字符串可能很复杂。表示单个字符有几种不同的方式。它可以是一个 ASCII 字符,也可以是 Unicode,或者可以是 ANSI。它们在内存中都有不同的表示形式。在这里,我们说我们想要使用 Auto。当这样做时,系统会查看我们使用的完整字符串,找出它可以用来表示完整字符串的集合,并使用它找到的第一个。由于它首先尝试将其适应到 ASCII 字符串中,然后“向上移动”到更复杂、更慢、更占用内存的方式,这保证了我们得到表示这个字符串的最佳方式。
接下来,我们可以看到 SetLastError = true。这指示系统在发生错误时通知我们。在出现错误的情况下,它调用 GetLastError API 来获取错误并将其返回给我们。我们将在以后大量使用这个。现在,我建议你始终将 SetLastError 设置为 true。
我们的运行时现在知道要加载 kernel32.dll。但我们必须告诉它我们想要使用哪个特定的 API。这发生在下一行。函数的签名必须始终紧跟在 DllImport 之后:它们总是属于一起。如果你想要从同一个 DLL 加载多个函数,你仍然必须为每个函数使用 DllImport。
下一行是函数的存根:
static extern bool WriteConsole(
IntPtr hConsoleOutput,
string lpBuffer,
uint nNumberOfCharsToWrite,
out uint lpNumberOfCharsWritten,
IntPtr lpReserved);
这看起来像我们从官方文档中看到的代码,但现在,类型已经被转换成了它们的 .NET 等价物。再次强调,Pinvoke.Net 是你的好朋友!
参数基本上是自我解释的,除了第一个。让我们跳过那个,看看其他的:
| String lpBuffer | 我们想要打印的字符串 |
|---|---|
| nNumberOfCharsToWrite | 我们想要从给定字符串中打印的字符数 |
| lpNumberOfCharsWritten | 写入系统的字符数 |
| lpReserved | 这个参数没有被使用,所以可以忽略 |
表 1.1:WriteConsole 的参数
这里需要注意的一点是,Win32 API 使用匈牙利命名法为其参数命名。这种风格规定你必须使用类型的缩写前缀来修饰每个参数,这样当你以后阅读代码时,就能知道这个参数代表什么类型。在当前现代和快速的 IDE 出现之前,这非常有帮助:你不能将鼠标悬停在变量上以查看其类型;你必须滚动代码以找到其声明。通过前缀,你可以立即看到它。如今,我们不再需要这样做,但 C 和 C++ 开发者仍然使用这个标准。
因此,正如你所看到的,要打印的字符串是一个长指针(lp),要写入的字符数是一个数字(n)。
但让我们看看 hConsoleOutput。它是一个句柄(以 h 开头),在 .NET 中将其转换为 IntPtr。
指针只是内存中某个东西的地址。在我们的例子中,这个内存属于控制台所在的位置。但我们如何获取它?控制 Console 的代码在哪里?
答案是,我们不知道。没有固定的位置;这可以,并且每次你重启程序时都会改变。因此,我们需要去寻找它。
幸运的是,这并不难做,因为有一个我们可以使用的 API 来完成这个任务。这个 API 被称为 GetStdHandle,它位于 kernel32.dll 中。我们知道如何导入它,我们可以在我们的代码的第 11 和 12 行中看到它:
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr GetStdHandle(int nStdHandle);
没有字符串,所以我们不需要设置 CharSet。然而,我们需要设置 SetLastError。
查找地址的方法被称为 GetStdHandle,它接受一个参数:nStdHandle。这告诉此 API 我们正在寻找哪种类型的控制台。有三种类型可供选择:STD_INPUT_HANDLE, STD_OUTPUT_HANDLE 和 STD_ERROR_HANDLE。这三个常量的值分别为 -10、-11 和 -12。如果你认为它们是负值很奇怪,那么你是正确的。这很奇怪。然而,在 Win32 中,这些值是无符号的。它们是整数范围的终点,因此不会妨碍你定义的其他任何类型的控制台。将无符号整型的高值转换为有符号整型会导致负值。
在 第 14 行,我们定义了 STD_OUTPUT_HANDLE 常量,并给它赋值为 -11。这类事情很常见:Win32 API 中充满了魔法数字和常量。
在 第 16 行,我们使用 GetStdHandle 来获取内存中 Console 的指针,并给它传递 STD_OUTPUT_HANDLE。如果出错,我们会得到一个 0(零)。但由于 .NET 是强类型的,我们不能使用这个数字。相反,我们必须使用 IntPtr.Zero 常量,它在正确的类型中做同样的事情。
每次从 Win32 API 获取一个 0,都表示存在错误情况。我们需要处理这种情况,但这将是后续讨论的主题。
假设一切顺利,我们可以定义我们的字符串,out 变量会告诉我们写入了多少个字符(第 23 和 24 行)。
然后,在 第 26 行,我们调用实际的 API:
if (!WriteConsole(
stdHandle,
output,
(uint)output.Length,
out charsWritten,
IntPtr.Zero))
{
Console.WriteLine("Failed to write using Win32 API.");
}
现在应该很清楚了。我们调用 API,给它正确的参数,并检查结果是否为0(IntPtr.Zero)。
CLR 将复杂的.NET String类型转换为以 0 结尾的简单字节数组。我们不必担心这一点。我们可以给这个 API 提供一个 C#字符串,一切都会顺利。
就这样。我们已经使用 Win32 API 向控制台写入了一些内容!
处理错误
在前面的例子中,我们做了一些错误检查。如果我们无法获取句柄,我们会显示一个消息。如果我们无法写入控制台,我们也会这样做。我明白写入无法写入的系统控制台听起来很滑稽(例如,看看第 33 行),但你应该明白我的意思。
但如果你想知道真正的情况,这还不够。我们需要一种更彻底的方式来处理错误。
在.NET 中,我们习惯于在出现问题时获取异常。我们知道如何处理它。在低级世界中,事情有所不同。当出现问题时,我们会得到0,然后我们必须处理它。我们可以继续编写代码而不会被错误打扰。我们甚至可以忽略 API 调用的结果。然而,这将导致灾难。你应该始终检查并处理 API 调用的结果。如何处理这个问题是我们将在本节中讨论的内容。
有一个名为GetLastError的低级 API 可以帮助我们。P/Invoke 的签名如下:
[DllImport("kernel32.dll")]
static extern uint GetLastError();
这看起来相当简单。没有需要担心的参数,我们也不必在这里设置那个SetLastError值。由于SetLastError确保任何错误都保存在注册表中,以便GetLastError可以读取它,所以在这里设置它没有意义。如果设置了,并且将其设置为 false,那么GetLastError将如何工作呢?
这个函数返回一个无符号整数。这个数字对应一个错误;你可以在文档中查找这个数字的含义。
但这里有个问题:它不起作用。嗯,它确实起作用,但结果没有保证。
BCL 和CLR始终与低级 Win32 API 一起工作。这是显而易见的:BCL 是 Win32 API 的包装器,CLR 使用这个包装器调用操作系统的核心系统。我们可以自己调用 API,就像我们刚才做的那样,但 CLR 也会调用它。有时,它在同一个线程上调用。有时,它在另一个线程上调用。在 CLR 调用 API 的过程中也可能出错。这可能导致GetLastError返回没有错误或错误的错误。嗯,技术上,它们不是错误的错误,但它们可能与我们正在做的事情无关。
幸运的是,.NET 的设计者考虑到了这一点,并在 System.Runtime.InteropServices 命名空间中添加了一个名为 Marshal 的类。该类用于在托管和非托管代码之间进行封送处理——或者,用我们在这里所做的事情来说,就是在 Win32 API 和我们的 C# .NET 代码之间。
让我们假设我们犯了一个错误。我知道这很难想象,但请在这里忍受一下。我们不是将 -11 分配给 STD_OUTPUT_HANDLE,而是将其设置为 11。我们都会犯错误,对吧?
我们随后调用 GetStdHandle 并传入 11。这是不正确的;我们都知道这一点。文档说明,如果发生任何错误,该函数返回 0(或在 C# 中为 IntPtr.Zero)。但在我们的情况下,它返回了其他东西:0xffffffffffffffff。这是有符号值 -1 的无符号版本。换句话说,对 API 的调用返回 -1,这不是一个有效的句柄。
然而,我们没有检查这一点。我们只检查 0 值。如果你这样想,这是有道理的。毕竟,0 表示在调用该函数时出了问题。这种情况没有发生:该函数工作得完美无缺。它只是没有找到与我们所提供的 ID 匹配的内容(11 而不是 -11)。所以,从 API 的角度来看,没有错误。
但然后我们来到了调用 WriteConsole 的地方。我们给它传递控制台的句柄——或者更确切地说,我们认为我们这样做。相反,我们传递了一个值为 -1(0xffffffffffffffff)。这不是 WriteConsole 可以处理的有效的句柄。
在 .NET 中,你会得到一个异常,但这里没有发生这种情况。代码继续愉快地运行,没有任何抱怨。它只是没有输出任何内容。
这些错误可能很难找到和解决。在这种情况下,它相当直接,但想象一下,当你尝试设置与红外接收器的连接并且出了问题的情况。然而,我们继续进行,因为我们没有检查那个结果。当我们准备发送数据时,什么也没有发生——或者更糟,系统崩溃了。我们开始查看实际发送数据的代码,但那里没有问题。需要花费很多时间和仔细的调试才能看到错误发生在我们设置连接时。让我重复一下我之前说过的话:你应该始终检查所有 API 调用的结果。这个责任在你身上。.NET 运行时在这些情况下生成异常,但如果你处于非托管环境中,你必须负责这样做。
让我们改进一下我们的代码。
首先,我们将我们的 WriteConsole 调用包裹在一个 try-catch 块中,并捕获 Exception,尽管这通常是一个糟糕的想法。然而,在这里,这已经足够好了。
如果 WriteConsole 返回 IntPtr.Zero,我们就遇到了问题,并且出了错。在非托管环境中,你会调用 GetLastError 来查看发生了什么,但在这里不起作用。相反,我们使用我之前提到的那个 Marshal 类:
if(!WriteConsole(stdHandle, output, (uint)output.Length, out charsWritten, IntPtr.Zero))
{
var lastError = Marshal.GetLastWin32Error();
Console.WriteLine($ something went wrong. Error code:
{lastError}");
}
当将STD_OUTPUT_HANDLE设置为11时运行此代码,系统会报告出错了。它甚至告诉我们错误代码是6。
在官方文档中查找这个信息,结果如下:
ERROR_INVALID_HANDLE
6 (0x6)
这个句柄是无效的。
这正是正在发生的事情。
“等一下,”我几乎能听到你这么说。“我不能要求我的用户每次出问题时都去查阅官方文档来查看错误消息的含义!”
嗯,你说的没错。.NET 设计团队也同意这一点。他们增加了一些获取错误消息的方法。有两种方法可以获取它,你可以选择你想要的任意一种。
首先,如果你想获取那个错误消息,你可以用以下代码来获取:
if(!WriteConsole(stdHandle, output, (uint)output.Length, out charsWritten, IntPtr.Zero))
{
var lastError = Marshal.GetLastWin32Error();
var errorMessage = Marshal.GetPInvokeErrorMessage(lastError);
Console.WriteLine($"Something went wrong. Error message: {errorMessage}");
}
再次,我们首先获取错误代码。我们总是必须这样做,而且我们应该尽可能快地这样做,以免其他地方的另一个错误破坏了事情。
然后,我们调用Marshal.GetPInvokeErrorMessage方法,并给它那个lastError代码。它返回一个字符串告诉我们The handle is invalid.。
很好。但如果这个错误的影响如此之大以至于我们无法继续呢?.NET 告诉我们在这种情况下使用异常。良好的实践教导我们永远不要抛出异常,而应该使用一个专门的派生异常。我们正好有这样一个东西:Win32Exception。
我们可以将这个错误消息抛出,并将消息设置为从GetPInvokeErrorMessage获取的消息,但鉴于这是一个非常常见的场景,.NET Framework 为我们提供了一个快捷方式来做这件事。看看下面的代码:
try
{
if(!WriteConsole(stdHandle, output, (uint)output.Length, out charsWritten, IntPtr.Zero))
{
var lastError = Marshal.GetLastWin32Error();
throw new Win32Exception(lastError);
}
}
catch(Win32Exception e)
{
Console.WriteLine($"Error: {e.Message}");
};
这看起来要好得多。这段代码会在我们的屏幕上显示一条消息,内容为Error: The handle is invalid。好吧,既然这只是一个简单的例子,我未能正确处理这个问题(在这里重新抛出是个好主意)。在出现此类错误后如何继续取决于你的编码风格、用例以及你想要达到的目标。
获取错误消息还有另一种方法。这个方法相当不错,但不如我们之前讨论的其他方法直接:FormatMessage。
FormatMessage函数来自 Win32 API。其声明如下:
[DllImport("kernel32.dll")]
static extern uint FormatMessage(
uint dwFlags,
IntPtr lpSource,
uint dwMessageId,
uint dwLanguageId,
[Out] StringBuilder lpBuffer,
uint nSize,
IntPtr Arguments);
如果我们有错误代码,我们可以这样使用它:
var lastError = Marshal.GetLastWin32Error();
int bufferSize = 256;
var errorBuffer = new StringBuilder(bufferSize);
var res = FormatMessage(
0x00001000,
IntPtr.Zero,
(uint)lastError,
0,
errorBuffer,
(uint)bufferSize,
IntPtr.Zero);
if(res != IntPtr.Zero)
{
var formattedError = errorBuffer.ToString();
Console.WriteLine(formattedError);
}
首先,我们创建StringBuilder。API 使用它来构建带有错误消息的字符串。我们给它分配了256个字符的大小。这个大小对于大多数,如果不是所有错误来说应该足够了。我们需要给出这个大小,因为在 C 和 C++中,你需要事先分配一个缓冲区;它不能动态扩展(嗯,它可以,但如果你想要高性能,你不会这样做)。我们使用 0x00001000 标志调用FormatMessage。这个标志意味着“使用提供的错误代码。”我们可以使用其他标志,但这个标志是最常用的。我们没有想要格式化的消息,所以第二个参数是IntPtr.Zero。然后,我们给它lastError,语言为 0(即系统默认,通常是英语),缓冲区,缓冲区的大小,以及另一个IntPtr.Zero参数。最后一个参数意味着我们不使用参数。在这里,参数与我们在 C#中想要格式化字符串时的参数相同:
Console.WriteLine("Hello {0}", 42);
在这里,42是参数。
当我们运行这段代码时,我们会得到相同的“句柄无效”消息。
你可能想使用这个 API,因为它可以做一些很酷的技巧。例如,将languageId代码 0 替换为代码 0x0413。这个languageId是荷兰的 Windows 语言 ID(请使用你想要的任何语言。)
结果是De ingang is ongeldig,这基本上是对原始错误的良好翻译。
这样,你可以有格式良好、翻译过的错误消息!
这里还有最后一件事要说明:许多在线示例使用以下代码:
if(!WriteConsole(stdHandle, output, (uint)output.Length, out charsWritten, IntPtr.Zero))
{
var lastError = Marshal.GetLastWin32Error();
var errorMessage = new Win32Exception(lastError).Message;
Console.WriteLine($"Error: {errorMessage}");
}
从技术上讲,这并没有什么问题。但这并不是异常存在的原因。仅仅为了得到消息就创建一个异常是错误的。然而,我见过这种情况很多次,所以我认为我应该警告你。如果你不想抛出异常,就不要创建它。在这种情况下,调用Marshal.GetPInvokeErrorMessage代替。这将对你和那些维护你的代码的人大有裨益。
使用低级 API 调试代码时的问题
使用如 Win32 API 这样的低级 API 可以打开一个充满新且强大工具的宝库。然而,这也带来了一些缺点。调试你的代码突然变得困难得多,而且它也变得更加关键。
当你想使用低级 API 调试代码时,有几个区域你需要注意:
-
错误处理
-
互操作性
-
调试工具
-
兼容性和可移植性
-
文档和社区支持
这些都可能构成挑战,要求你在开始编码之前考虑你的调试策略。让我们看看潜在的问题。
错误处理
如前所述,使用低级 API 时,错误处理由你负责。当调用函数出错时,你不会从函数中得到异常。你必须始终小心地检查调用代码的返回码,看它是否为 0。即使如此,也不能保证事情会按照你预期的方向发展。例如,当我们给GetStdHandle函数一个无效的ConsoleId类型时,调用是正常的,但结果仍然不是我们预期的。你必须对这些类型的调用非常小心。理想情况下,我们会立即捕捉到这个问题,并通知系统出现了错误。
即使你捕捉到了所有的错误代码,这也并不意味着你可以确定发生了什么错误。有时,错误信息如此晦涩,你必须阅读文档才能了解发生了什么。
API 中有一个名为CoCreateInstance的方法。它用于创建 COM 对象,这些对象用于连接到其他系统,例如 Word 或 Excel。为了建立这种连接,给它你想要连接的对象的 ID。这些 ID 的形式是 GUID,你必须手动输入。如果曾经有过容易出错的情况,那可能就是这种情况。
使用不存在的ClassID会返回错误代码0x80004005。如果我们使用之前描述的方法来获取错误信息,你可能会读到类似Invalid ClassId或COM 对象未找到的内容。不幸的是,你得到的是E_FAIL: 未指定错误。
唉。
这完全无助于解决问题,对吧?失败了。好吧,我们知道了。但是为什么?是什么失败了?我们不知道。系统在这里根本不帮助你。你必须知道你在做什么,系统期望什么,并且逐行检查代码以找出错误。这并不容易。
互操作性
正如我们讨论的,在调用 Win32 API 时,你必须采取的步骤之一是将 C#中使用的类型转换为它们的 Win32 等效类型,反之亦然。有时,这很简单;有时,这可能相当具有挑战性。
框架设计者做了很多工作来帮助我们:当 Win32 API 期望一个字符串时,你通常可以给它一个 C#字符串,CLR 会自动在两者之间进行类型转换,即使你不知道。但是,仍然有一些转换在进行。C 风格字符串是内存中字符位置的指针。下一个字符紧挨着它,以此类推,直到系统找到一个值为 0 的值。这是字符串的结束标记。这与我们在 C#中拥有的String类完全不同(内部,String类仍然有以 0 结尾的字符列表,但我们从未看到过,所以我们可以假装它根本不存在)。
C#中的大多数类型在 Win32 中都有一个兄弟类型。以下是最常用类型的列表:
| C# 类型 | Win32 类型 | 描述 |
|---|---|---|
byte |
BYTE |
8-bit 无符号整数。 |
sbyte |
CHAR |
8-bit 有符号整数,通常用于 ASCII 字符。 |
short |
SHORT |
16 位有符号整数。 |
ushort |
WORD |
16 位无符号整数。 |
int |
INT 或 LONG |
32 位有符号整数 |
uint |
UINT 或 DWORD |
32 位无符号整数。也用于标志和枚举。 |
long |
LONGLONG |
64 位有符号整数。 |
ulong |
ULONGLONG |
64 位无符号整数。 |
float |
FLOAT |
32 位浮点数。 |
double |
DOUBLE |
64 位浮点数。 |
char |
WCHAR 或 TCHAR |
在 C# 中为 16 位 Unicode 字符,而在 Win32 中 WCHAR/TCHAR 则不同。 |
bool |
BOOL |
布尔类型。在 C# 中为 True 或 False,在 Win32 中通常为 TRUE 或 FALSE。在这里,FALSE 被定义为 0,而 TRUE 被定义为 NOT FALSE,即任何其他值,但通常为 1。 |
IntPtr |
HANDLE、HINSTANCE、HWND 等 |
表示指针或句柄。类型根据上下文而变化。 |
UIntPtr |
在 Win32 中很少使用 | 无符号指针或句柄。 |
T[] |
T* 或 SAFEARRAY |
T 类型的数组。其在 Win32 中的表示取决于上下文。 |
DateTime |
FILETIME 或 SYSTEMTIME |
表示日期和时间。在 Win32 中的表示不同。 |
Guid |
GUID 或 UUID |
GUID。128 位数字。(GUID 通常与 Windows 平台相关联,而 UUID 在其他平台上找到。尽管如此,它们基本上是相同的。) |
TimeSpan |
通常由 DWORDs 的组合表示 |
时间间隔。在 Win32 上不可用。 |
表 1.2:C# 和 Win32 类型
如你所见,大多数类型可以轻松地在平台之间转换。当我们深入研究更复杂的类型时,事情会变得有点复杂,因为许多类型都依赖于上下文或实现。这使得在平台之间传输类型具有挑战性。
另一个需要考虑的是所谓的调用约定。调用约定定义了在调用函数时如何处理参数。最常见的是 stdcall 和 cdecl。Win32 API 通常使用 stdcall,而大多数其他 C 库期望 cdecl。
我不会深入探讨这两种调用约定。然而,让我们总结一下最重要的区别:
-
stdcall:被调用者清理堆栈。它具有固定数量的参数,并且通常用于 Windows API。在这里,函数名称通常会被装饰。 -
cdecl:调用者清理堆栈并允许可变长度的参数列表。它通常用于 C 标准库。在这里,函数名称不会被装饰。
如你所见,了解如何调用函数是至关重要的。错误的约定可能会搞乱堆栈,并将参数传递给函数或返回错误的结果。你甚至可能会搞乱内存,这在编写托管代码时几乎是不可能的。
当你没有指定调用约定时,默认为 stdcall。如果你需要调用另一个库,你应该提供正确的调用约定。
可能一个例子会在这里有所帮助。我们之前使用 WriteConsole 将内容写入控制台,但有一个更简单的方法:printf 函数。这个函数是 Microsoft msvcrt.dll 库中的 C 运行时的一部分。如果你想使用这个函数,可以使用现在众所周知的 DllImport 声明来导入它:
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]
static extern int printf(string format, int i, double d);
printf("Hello, System Programmers!\n", 1, 2.0);
由于这个函数不是 Win32 API 的一部分,而是位于一个单独的 DLL 中,你必须小心并指定正确的调用约定。在这里,我们需要使用 cdecl,我们可以通过设置 CallingConvention = CallingConvention.Cdecl 来指定它。
其他类型包括 WinAPI、StdCall(它们基本上是相同的)、ThisCall 和 FastCall。你几乎不会遇到后两种,但至少你现在已经听说过它们了。
当你调用 API 并遇到奇怪的错误或意外行为时,你可能想要了解一下如何进行类型打包或调用约定。系统在这里并没有通过提供良好的错误信息来帮助你。
调试工具
Visual Studio 调试器很棒。然而,当混合托管代码和非托管代码时,事情可能会变得复杂。如果出现问题,系统可能会停止并显示一个断点。但由于被调用的代码不是 C#,调试器可能不会显示你需要看到的内容。它会尽力而为,所以它可能会反汇编代码并显示出错的汇编代码。
我在本章开头展示了些汇编代码。如果你想要在代码中查找错误,这并不是你想要看到的东西。好吧,我不知道这对你是否适用,但我知道我肯定不想看到那种。
如果发生这种情况,你可能想要使用其他调试器,例如 WinDbg。在本书的后续章节中,当我们介绍调试时,我们会更详细地探讨这个工具。但请相信我,调试混合代码可不是件轻松的事情。
兼容性和可移植性
Windows 会发生变化。有时,变化很大;有时,变化很微妙。尽管微软以尽可能保持向后兼容而闻名,但有时 API 会发生变化。签名可能会改变,或者行为可能会改变。而且你只有在事情变得非常糟糕时才会发现这一点。再次强调,你很少看到异常或错误信息,所以你只能自己调试代码并逐步执行。
一旦你开始使用 Win32 API,你就是在将自己绑定到一组有限的设备和平台。
更不要考虑将前面的代码部署到 Linux 平台。当然,.NET 在 Linux 上运行得很好,但当你开始使用 P/Invoke 时就不一样了。而且可能你的代码在一个 Windows 版本上运行得很好,但在下一个来自雷德蒙德的版本上却完全崩溃。我们可以称之为“工作保障”,因为这将要求我们不时更新我们的代码,但我不认为这很有趣。
文档和社区支持
Win32 API 文档的主要受众是 C 和 C++开发者。作为一个 C#开发者,很难找到所需的信息。像pinvoke.net这样的网站有所帮助,但前提是你知道它们是如何工作的。
作为.NET 开发者,你可能想要使用的第三方 DLL 的文档甚至更难找到。有时,你必须检查 DLL,了解其内部工作方式,然后将其转换为正确的 DLL 导入语句。如果你这样做,确保你有正确的调用约定和数据类型!
在混合托管和非托管代码时,社区支持也是一个挑战。大多数开发者会落入两个阵营之一:他们在非托管世界中工作,或者他们在托管世界中工作。两者兼顾是非常罕见的。
能够同时做到这两点的优秀开发者很少。好消息是,通过阅读这本书,你正走在成为那个非常精英群体中的一员的正确道路上!
下一步
本章探讨了低级 API 和高级 API 之间的区别。我们通过检查 BCL 和 CLR 来深入研究.NET 的基础。然后,我们探讨了如何调用低级 API,例如 Win32 API。我们通过重新实现无处不在的Console.WriteLine到 Windows 操作系统可以运行的代码中,而没有使用 BCL 来实现这一点。这导致我们讨论了错误发现和错误处理,以及如何最好地处理它们。
我们还讨论了你开始进行那种编码时可能会遇到的问题。我们提到了类型系统的差异以及你在处理调试器时可能遇到的问题。
希望这一章让你更加欣赏.NET 框架,以及 BCL 和 CLR 作为开发者为你所做的大量工作。但我也希望你意识到,当你使用 Win32 API 或其他用 C 或 C++编写的第三方库时,你获得的强大能力。
系统编程在很大程度上依赖于这些技术。尽管使用这些 API 将你绑定到正在为其开发的操作系统或该系统的特定版本,但这通常是实现你结果唯一的方法。坦白说,我认为与这些 API 一起工作很有趣。这完全是为了回归基础。
与低级 API 一起工作可能会很具挑战性。它们可能导致许多难以解决的错误。但是,当正确使用时,它们可以提高你的代码性能。在编写系统软件时,这一点非常重要。正如之前讨论的,系统软件不应该妨碍用户或用户直接与之交互的系统。相反,它应该尽可能快。因此,使用正确的 API 可能会给你带来所需的额外性能。我认为这一点非常重要,所以我专门写了一章关于性能的内容,恰好是下一章。我们生来就是为了奔跑,所以让我们尽可能快地奔向下一部分!
第二章:速度至上的那一章
为性能而写作
大多数用户都认为应用程序永远不会足够快。每次你与人谈论软件中的烦恼,无论是性能还是缺乏性能,它都是排在最前面的一个问题。
这是有道理的:我们都很忙,我们当然不希望浪费时间等待机器赶上我们。它必须反过来!
但如果你仔细想想,你会意识到计算机在合理的时间内做任何事情都是一个奇迹。如果你觉得自己很忙,看看计算机需要做的一切!你可以进行以下实验:
-
重新启动你的电脑。
-
登录。
-
启动(如果你使用的是 Windows)任务管理器(提示:使用Ctrl + Shift + Esc组合键)。
-
看看在“后台进程”部分有多少事情在进行。
所有这些进程都是系统编程的例子。它们都在那里帮助系统完成其工作或帮助面向用户的软件完成任务。而且有很多这样的进程。这些进程都占用一点 CPU 时间、一点网络资源和一些内存。大多数都是休眠状态,只是在等待有趣的事情发生,但它们仍然存在。它们从面向用户的软件中夺取资源。
我想这很清楚:系统软件需要尽可能小和快,以便为系统的其余部分——即用户关心的部分——留下足够的资源。下一章将讨论如何使其尽可能小(或内存效率尽可能高)。
在本章中,我们将涵盖以下主题
-
为什么速度很重要?
-
公共类型系统(CTS)是什么?
-
值类型和引用类型之间的区别是什么?
-
封箱与性能有什么关系,它到底是什么?
-
如何选择合适的数据结构和算法,以尽可能快地完成任务
-
字符串是如何工作的,我们如何使它们更快?
-
不安全代码是什么,我们如何安全地处理它?
-
一些有助于加速的编译器标志
总结来说,本章将向您展示如何使您的系统尽可能快。所以,系好安全带;我们即将加速!
技术要求
你可以在以下链接中找到本章的所有代码:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter02。
设置舞台
到目前为止,我们已经确定我们需要尽可能多的性能来允许其他系统完成它们的工作。但还有其他原因你可能想要优化你的代码:
-
可访问性
-
域名托管费用
-
计划性淘汰
-
能耗
让我们逐一检查这些内容。
可访问性
每当我提到软件的可访问性时,软件开发者通常都会想到使软件对有身体挑战的人可用。我喜欢更广泛地思考。并不是每个人都能负担得起最新和最快的硬件。许多人需要使用较旧、较慢的机器。假设您的代码使这台已经缓慢的机器变得更慢。在这种情况下,您可能要为这些人无法再使用设备负责。
其他人在共享设备上使用。这些设备通常在机构中找到,有多个人使用这些设备,每个人都添加他们的软件。如果您的软件使机器变慢,这会影响所有人。
域名托管费用
如今,越来越多的软件在云端运行,在这种情况下,您必须按使用量付费。如果您的软件需要大量的计算能力来运行,它可能会增加成本。当累积起来,每一次性能损失都会影响每月的云服务提供商账单。
计划性淘汰
机器有财务寿命和经济寿命。这些寿命决定了公司何时决定更换设备。财务寿命的计算很简单:当组织购买一台机器时,会计师会告诉您在多少年后其价值变得太低,不值得保留。他们会计算每年的折旧,并在他们的电子表格中记录下来。我在这里简化了一些事情,但我也不是会计师。
经济寿命的计算比较困难。这个寿命通常是指机器变得如此不可用,以至于不再值得升级或投资的时候。一台变得太慢无法使用的电脑应该被更换,即使其财务寿命尚未到期。
您的软件可能会导致这种情况发生。如果您的性能太低,组织可能会比预期更早地报废机器。这会导致大量的电子废物:性能良好的电脑仅仅因为软件编写得不够完美而被更换。
能耗
使用更多的 CPU 功率意味着使用更多的电力。您可能会认为这不会造成太大的差异,但最终,全球所有这些机器消耗了大量的能源。尽可能高效地编写您的代码可以节省电力消耗,并有助于环境保护。就这么简单。
性能可以在很多地方得到提升,甚至在您处理谦逊的整数时也是如此。让我们来讨论一下!
哪个整数是最快的?
选择正确的整数类型可能会影响您系统的性能。我并不会对此过于担心:CLR 在优化您的代码方面做得相当不错,并且对于遍历代码片段的 for 循环也是如此。如果我们有不到 255 次迭代,我们可能会倾向于使用字节。毕竟,一个字节只是 1 字节。如果您使用整数,它将是 4 字节。这意味着更多的内存,并且可能需要更长的时间来处理,对吧?
错误!
不要试图欺骗编译器。它对系统的了解比你多得多。
让我给您展示一下。
我们有以下四行 C# 代码:
var a = byte.MaxValue;
var b = UInt16.MaxValue;
var c = UInt32.MaxValue;
var d = UInt64.MaxValue;
我们将四个变量设置为一些值。下表描述了每种类型的详细信息:
| C# 类型 | 简称 | 描述 | MaxValue (十六进制) |
|---|---|---|---|
| System.Byte | byte | 一个字节 | 0xFF |
| System.UInt16 | ushort | 无符号 16 位整数 | 0xFFFF |
| System.UInt32 | uint | 无符号 32 位整数 | 0xFFFFFFFF |
| System.UInt64 | ulong | 无符号 64 位整数 | 0xFFFFFFFFFFFFFFFF |
表 2.1:具有最大值的数值类型
让我们来看看编译器是如何处理这些代码的。如果你想亲自查看,请在 Visual Studio 中创建一个新的 C# 控制台程序(使用 .NET 7 或 .NET 8),使用顶层语句,并复制这四行。然后,在第一行设置一个断点并运行它。一旦你触发了断点,按 Ctrl + K,G。这样做会打开反汇编器。
你会得到类似这样的结果(我已经删除了一些对我们来说不是很有用的代码):
01: # var a = byte.MaxValue;
02: 00007FFF956076EE mov dword ptr [rbp+3Ch],0FFh
03: # var b = UInt16.MaxValue;
04: 00007FFF956076F5 mov dword ptr [rbp+38h],0FFFFh
05: # var c = UInt32.MaxValue;
06: 00007FFF956076FC mov dword ptr
[rbp+34h],0FFFFFFFFh
07: # var d = UInt64.MaxValue;
08: 00007FFF95607703 mov eax,0FFFFFFFFh
09: 00007FFF95607708 cdqe
10: 00007FFF9560770A mov qword ptr [rbp+28h],rax
我知道我承诺我们不会进行汇编编程,但如果你想让你的代码尽可能快地运行,你需要知道发生了什么。让我带你了解一下。
第 1、3、5 和 7 行是注释行,显示了导致这些汇编代码的 C# 代码。
在第 2 行,我们可以看到当我们要将值设置到变量中时 CPU 处理的代码。实际的命令是 MOV,意思是移动。它然后接受两个参数。第一个是 MOV 的目标,第二个是值。有几种类型的 MOV 命令;这个特定的命令移动一个 DWORD。在 Win32 中,DWORD 代表 Double Word,我们知道它是一个无符号 32 位整数。我们将硬编码的值 0FFh(十进制中的 255)移动到 [rbp+3Ch]。如果你想知道,rbp 是栈指针。因此,我们将我们的值 0xFF 移动到我们的栈上的 3C 位置。
很好。我们应该知道值类型是放在栈上而不是堆上的。如果你没有意识到这一点,不要担心。下一章将全部关于内存。现在,只需接受我们有两种内存:一个小但快速的栈和一个慢但巨大的堆。这个字节将放在栈上。
第 4 行将 0xFFFF 移至 [rbp+38h]。再次强调,我们在这里移动的是一个 DWORD。
第 6 行大致做了同样的事情:我们将 0xFFFFFFFF 移至堆栈。再次强调,它是一个 DWORD。
当编译时,一个字节、一个 UInt16 和一个 UInt32 被视为一个 DWORD。它们之间没有区别。如果你查看汇编代码,你无法知道 C# 希望使用哪种类型。这意味着在使用 8 位字节或 32 位无符号整数时,性能上没有区别。如果你想知道,有符号 32 位整数看起来相同,区别在于 Int32.MaxValue 是 UInt32.MaxValue 的一半。然而,编译后的代码是相同的。
看看代码,将 64 位整数复制到栈上的操作完全不同。在第 8 行,我们将0xFFFFFFFF移动到一个寄存器(寄存器是 CPU 内部的一个特殊内存区域,用于存储临时变量)。然后,我们调用 CDQE。这会将 EAX 寄存器(可以存储 32 位)中的内容复制到RAX寄存器,它可以存储 64 位。然后,在第 10 行,它将内容的前 32 位复制到栈上。
正如你所见,将变量设置为Int64.MaxValue比其他三个变体要复杂得多。它要慢得多:CPU 需要做更多的工作。
然而——这很重要——这并不总是这种情况。这是我现代的、强大的 64 位 Windows 11 机器上的情况。在运行 Linux 的 ARM 处理器的低功耗 Raspberry PI 上,事情可能完全不同。这就是系统编程的一个挑战:你必须了解类型的行为才能获得最高的性能。
我认为现在是讨论 CTS 的时候了。
CTS
CTS 是一组描述.NET 程序中使用的类型的规则。仅此而已。没有二进制操作正在进行;它只是一组规则——一个编译器、语言和运行时必须遵守的标准。
在.NET Framework 上可用的语言有多种。微软有 C#、VB.Net 和 F#。他们还提供了 J#,这是一个在 CLR 上运行的 Java 变体。你还可以用 C 或 C++编写.NET 程序。其他供应商也提供了你可以选择的语言和工具。例如,考虑 IronPython 或 Delphi.NET。
所有这些语言都必须遵守规则。编译器必须生成 IL 代码(再次强调,IL 看起来像汇编语言,但并不是)。然后 JIT 编译器将 IL 转换为 CPU 可以理解和运行的机器代码。
CTS 中有一组被称为[assembly: CLSCompliant(true)]属性的规则子集。
我们在这里的目标不是设计语言,所以我们不会深入探讨这个问题。
在.NET 语言中使用的所有类型都必须遵守 CTS 规则。这本书不是关于学习.NET 编程的。然而,如果你是系统程序员,了解内部工作原理是至关重要的。在这里,我们将仅介绍 CTS 的要点。
值类型和引用类型
在本章的后面部分,我会更详细地讨论值类型和引用类型。在这里,我只想简单地说,值类型直接持有它们的值。相比之下,引用类型是指向内存中其他位置的值的指针。
类和结构体
基于.NET 的语言应该是面向对象的。从这个意义上说,这些语言应该支持类。这些类也必须具有特定的特征。
类应该具有可见性。它们可以是公共的、内部的、受保护的或私有的。我们都知道这些分类符的含义。
类有方法、属性、字段、委托等。这些项可以是私有的、受保护的或公共的。你可能已经知道所有这些;我无需解释这些是什么。
然而,许多开发者都在结构体上遇到困难。对于旁观者来说,它们或多或少是相同的。是的,它们确实相似。它们都可以有方法、属性、字段等。它们都可以实现接口。它们都可以有静态成员。
类和结构体之间的区别更有趣。首先,类实例存在于堆上,你会得到一个存储在栈上的指针。然而,结构体存在于栈上。
由于“持有”类的变量是指向存储数据的堆内存的指针,因此该变量可以是 null。在这种情况下,它指向空;它只是该类未来实例的一个占位符。
结构体不能为 null。有一个边缘情况:可空类型,如MyStruct?可以是 null,但这正是可空类型的目的。结构体不能相互继承。尽管如此,它们可以像类一样实现接口。这也意味着你不能有一个“抽象”或“密封”的结构体。这两个修饰符是为了必须继承的类而设计的。由于我们不能从结构体中继承,这没有意义。
看到这里,你可能会认为类是一个更好的选择:与结构体相比,使用它们的缺点很少,优点很多。你并没有错。但是,结构体相对于类有一个显著的优势:它们在栈上初始化,而不是在堆上。正如我之前所说的,栈比堆快得多。由于我们追求最大性能,我们的应用程序中使用的结构体比其他地方多得多。
浮点数
我们已经看到,对于大多数情况,你使用的整数类型并不重要。UInt64、Int64、UInt128 和 Int128 通常比其他类型慢,所以只有在你经过深思熟虑并决定你需要它们时才使用它们。
然而,对于浮点数来说,情况略有不同。在 CLS 和 C#中,我们有三种浮点数类型。请查看以下表格,以了解它们是哪些:
| 类型 | C# 类型 | 描述 |
|---|---|---|
| float | System.Single |
32 位单精度浮点数 |
| double | System.Double |
64 位双精度浮点数 |
| decimal | System.Decimal |
128 位的十进制类型更精确,但范围比双精度浮点数小 |
表 2.2:浮点数类型
你选择哪种类型取决于你的场景。如果你需要比 float 更精确的精度,你必须选择十进制。这将是显而易见的。但是,如果你不需要十进制提供的 128 位精度,事情就会稍微复杂一些。
在 64 位机器上,双精度浮点数(System.Double)是最快的浮点数。CPU 可以原生理解它,因此不需要转换。从性能角度来看,这是你的最佳选择。然而,单精度浮点数(System.Single)在内存效率上更高。然而,这只在 64 位机器上成立。如果你针对其他平台,结果可能会有所不同。例如,如果你想在基于 ARM 的设备(如 Raspberry Pi)上运行你的代码,你会发现 CPU 优化了浮点类型。因此,如果你关心性能,最好使用单精度版本。再次强调,如果你的用例需要更高的精度,请使用其他类型之一。毕竟,它们的存在是有原因的。
类型存储的位置——值类型和引用类型之间的差异
CTS 中的类型可以是值类型或引用类型。了解这两种选项之间的差异至关重要。值类型在栈上操作,而引用类型在堆上存在。位于栈上的内容通常比堆上的操作要快得多。
从这个角度来看,你可能会认为在栈上使用值类型是获得期望的良好性能的最佳方式。不幸的是,事情并非如此。引用类型的存在是有原因的,如果使用得当,它们可以给你带来显著的性能提升!
栈和堆
在讨论值类型和引用类型之间的差异之前,我们需要快速看一下栈和堆之间的差异。我已经提到栈比堆快但更小。这是真的,但还有更多内容。
下表显示了两种内存类型之间的差异:
| 特性 | 栈 | 堆 |
|---|---|---|
| 分配/释放 | 快速,编译时 | 慢速,运行时 |
| 生命周期 | 限于作用域 | 超出作用域 |
| 大小限制 | 较小,固定大小 | 较大,动态大小 |
| 数据类型 | 值类型(通常) | 引用类型 |
| 行为 | 确定性 | 非确定性 |
| 碎片化 | 无 | 可能 |
| 线程 | 线程特定 | 在线程之间共享 |
表 2.3:栈内存和堆内存之间的差异
栈变量的内存分配是在编译时完成的,内存被推入和弹出栈。这使得分配和释放非常快。对于堆变量,内存是在运行时动态分配的。
然而,栈上变量的生命周期仅限于函数的作用域或代码块。一旦你的代码不再需要该变量,例如,因为你到达了 for 循环的末尾,该变量的内存将自动释放。对于堆,当它不再需要时,由你或垃圾回收器来处理内存的释放。
栈较小,你更有可能耗尽栈内存而不是堆内存。堆内存可以非常大,尤其是与栈内存相比。
如果你想知道那个堆栈有多大,答案是,“这取决于。”你甚至可以自己指定它。由于堆栈与线程相关联,你可以在使用新线程时设置堆栈大小:
// Create a new thread with a stack size of 1 MB
var thread = new Thread(new ThreadStart(ThreadMethod), 1024 * 1024);
thread.Start();
在这里,我们创建了一个新的线程,并给它一个1 MB的堆栈。这很容易确定!如果你想限制线程使用的内存量,你可以估计你需要多少,并以此方式分配它。
顺便提一下,大多数开发者都知道StackOverflow.com。奇怪的是,我遇到了很多不知道这个名字来源的开发者。
当你创建一个具有给定堆栈大小的线程,但尝试使用比可用更多的内存时,你会得到一个StackOverflowException错误。这就是这个名字的由来。
让我给你展示。哦——不要在生产代码中使用这个。这个示例只是为了说明目的:
try
{
Recur();
}
catch (StackOverflowException e)
{
Console.WriteLine($"Oh oh.. {e.Message}");
}
return;
static void Recur()
{
Recur();
}
前面的代码调用了一个递归函数,它只做一件事:它调用自己。当你调用一个函数或方法时,系统会存储函数结束时返回的地址。系统将这个返回地址存储在堆栈上。毕竟,这是短暂的,需要快速。你希望在函数调用后继续你的常规流程。
但这段代码除了反复调用一个函数并且从不从中返回之外,什么也不做。因此,返回地址被添加到堆栈上数千次,直到内存耗尽,然后你会得到那个著名的StackOverflowException错误。
如果你想要尝试这个,请在单独的线程中运行前面的代码,并给它不同的堆栈大小。这样做将给你一个关于正确堆栈大小有多重要的影响的概念。
封箱和拆箱
到目前为止,事情看起来相当简单。值类型存在于栈上;引用类型存在于堆上。一个整数是值类型;因此,它在栈上。你定义的类在堆上,因为它是引用类型。如果你想使你的类运行得更快,你可以将其转换为结构体,并且可以更快地访问它,因为它位于栈上。你可能认为这很简单,但你会错的。事情可能比这复杂得多。
让我们看看我们的好朋友,整数。一个整数是一个整数,所以它没有小数点。正如我们之前看到的,我们有几种整数的变体。我们有 16 位、32 位、64 位,甚至 128 位版本。我们还有有符号和无符号版本。我们甚至有一个字节:从技术上讲,它不是一个整数,但由于它编译为 DWORD,我们可以将其归入同一类别。整数是值类型,所以它位于栈上。然而,如果你看表 2.1,你会看到整数的官方名称是System.Int32。我不知道你是否如此,但这看起来像是一个类或结构体的名称。
结构体仍然位于栈上,但与简单的整数相比,它的性能可能不如你预期的那么好。幸运的是,编译器帮助我们处理这个问题。正如我们之前看到的,编译器将我们的整数转换为 DWORD,所以没有性能损失。但有时,事情会有所不同。因此,我们需要讨论装箱和拆箱。
C# 是一种真正的面向对象的语言。这意味着一切都是对象,并且所有对象都从基类派生。在顶级,有一个基类是所有其他类的祖先。那就是 System.Object。我们的整数也不例外:System.Int32 结构体从 System.ValueType 类派生,而 System.ValueType 类又是 System.Object 的后代。所以,我们仍然遵循面向对象的原则。尽管如此,这里似乎有类和结构体的混合。不用担心;这些都是语义问题,编译器在需要时处理它们。
“处理”有时意味着运行时将值类型转换为引用类型,或者相反。这就是我们所说的装箱和拆箱。
当系统将值类型转换为引用类型时,就发生了装箱。将引用类型转换为值类型则称为拆箱。可以这样想,将我们的值类型放入一个形状为类的盒子中,或者如果你选择相反的方向,再将其从盒子中取出:
int i = 42;
object o = i; // Boxing
int j = (int)o; // Unboxing
第一行声明了一个简单的 32 位整数,并给它赋值。我们之前见过;这是一条相对简单且快速的指令。在汇编中,我们将一个硬编码的值移动到栈上的 DWORD 位置。
我们想要复制它,但这次我们使用对象而不是整数。由于 System.Int32 从 System.Object 派生(中间是 System.ValueType),你可能不会期望这需要太多工作。最终,我们仍然有一个整数。但事情更复杂。再次,让我们看看汇编代码。为了清楚起见,你不需要了解汇编,但如果你知道底层发生了什么,更容易理解如何获得最佳性能。
在这里,object o = i 翻译成相当多的代码:
1: object o = i; // Boxing
2: 00007FF9625E76F1 mov rcx,7FF96254E8D0h
3: 00007FF9625E76FB call CORINFO_HELP_NEWSFAST (07FF9C20D0960h)
4: 00007FF9625E7700 mov qword ptr [rbp+20h],rax
5: 00007FF9625E7704 mov rdx,qword ptr [rbp+20h]
6: 00007FF9625E7708 mov ecx,dword ptr [rbp+3Ch]
7: 00007FF9625E770B mov dword ptr [rdx+8],ecx
8: 00007FF9625E770E mov rdx,qword ptr [rbp+20h]
9: 00007FF9625E7712 mov qword ptr [rbp+30h],rdx
我不会解释这里发生的每一件事,但这里有很多动作。然而,第 3 行是重要的:CORINFO_HELP_NEWSFAST 是 CLR 中的一个方法,它在堆上分配内存。是的,堆。不是栈。这就是我们所说的非常昂贵的操作:它需要相对较长的时间。之后,发生了很多复制,所有这些都需要时间。
将这个与不经过装箱将整数变量复制到另一个整数变量进行比较:
1: int j = i;
2: 00007FF9625B7716 mov eax,dword ptr [rbp+3Ch]
3: 00007FF9625B7719 mov dword ptr [rbp+2Ch],eax
这段汇编代码将变量 i(在 [rbp+0x3C] 内存位置中的值)移动到 eax 寄存器。然后,它将该寄存器的内容转移到 [rbp+0x2C],那里是新的变量 j。
这只是两个快速移动调用,从栈到寄存器(非常快)和从寄存器回到栈。这几乎不花时间。
从堆到栈的转换似乎更快,因为这里进行的编码更少。在这里,int j = (int)o导致拆箱。这段代码的汇编代码如下:
1: int j = (int)o; // Unboxing
2: 00007FF9625F7726 mov rdx,qword ptr [rbp+30h]
3: 00007FF9625F772A mov rcx,7FF96255E8D0h
4: 00007FF9625F7734 call qword ptr [CLRStub[MethodDescPrestub]@00007FF9625EB8D0 (07FF9625EB8D0h)]
5: 00007FF9625F773A mov eax,dword ptr [rax]
6: 00007FF9625F773C mov dword ptr [rbp+2Ch],eax
这段汇编代码没有那个昂贵的内存分配调用。这是有道理的:栈不需要这个。栈有固定数量的内存,所以如果需要,你可以使用它。如果你用完了它,你会得到我们之前看过的StackOverflow异常。其余的只是移动数据。这里仍然有比我们复制两个整数时看到的更多的代码。但看起来并不那么糟糕,不是吗?
不要被骗:如果我们决定从现在开始使用j变量而不是再使用o,它可以从堆中移除。垃圾回收器会处理这件事,所以你不必担心。但是垃圾回收器也会带来很多性能损失。垃圾回收器是另一章的主题,但请放心,它可能会成为巨大的性能瓶颈。这一点从这段代码中并不明显。这里还涉及一些隐藏的成本。
隐藏的装箱和拆箱
将值类型,例如整数,复制到引用类型会导致装箱。如果你能避免这种情况,你应该这样做。但有时,装箱和拆箱会在你意想不到的时候发生。看看下面的代码:
internal void DoSomething()
{
int i = 42;
DoSomethingElse(i);
}
internal void DoSomethingElse(object o)
{
Console.WriteLine(o.ToString());
}
这里,我们在DoSomething()中声明了一个整数i。然后,我们用这个整数调用DoSomethingElse()。DoSomethingElse的原作者试图编写可重用的代码。因此,他们决定接受System.Object作为参数。由于最终一切都是从这个派生出来的,这似乎是个好主意。但这并不正确。在这里,i在传递给DoSomethingElse之前会被装箱,并伴随着装箱时发生的性能损失。
如果开发者能像这样编写方法会更好:
internal void DoSomething()
{
int i = 42;
DoSomethingElse(i);
}
internal void DoSomethingElse<T>(T o)
{
Console.WriteLine(o.ToString());
}
这里,我们不是接受一个对象,而是接受一个泛型类型。由于我们将其作为整数传递,编译器理解这是一个值类型,并且不会将其转换为对象。这里没有发生装箱。这段代码比之前的版本要快得多。
这行代码怎么样?
int i = 42;
string message = "Hello Integer " + i;
这看起来很简单。但再次强调,这里发生了装箱。在字符串连接之前,i变量首先被装箱为引用类型。
下一个也是不错的:
var list = new ArrayList();
list.Add(i); // boxing!
int j = (int)list[0]; // unboxing!
值类型是通常位于堆上的引用类型的一部分。因此,它们需要装箱。获取这些值将导致拆箱。
将值类型移动到引用类型会导致这种行为。看看下面的代码:
IComparable i = 42;
这看起来是安全的,对吧?我们并没有进行转换;我们只是声明我们对整数的一部分感兴趣,这部分属于IComparable接口。System.Int32结构体实现了很多接口,这恰好是其中之一。尽管如此,它仍然是一个结构体,所以一切应该都很好。
让我们快速看一下那个简单的 C#代码行的相关汇编代码:
1: IComparable i = 42;
2: 00007FF9625E76F1 mov rcx,7FF96254E8D0h
3: 00007FF9625E76FB call CORINFO_HELP_NEWSFAST (07FF9C20D0960h)
4: 00007FF9625E7700 mov qword ptr [rbp+20h],rax
5: 00007FF9625E7704 mov rax,qword ptr [rbp+20h]
6: 00007FF9625E7708 mov dword ptr [rax+8],2Ah
7: 00007FF9625E770F mov rax,qword ptr [rbp+20h]
8: 00007FF9625E7713 mov qword ptr [rbp+30h],rax
你现在应该已经认识到了这一点,特别是对CORINFO_HELP_NEWSFAST的调用。这是装箱操作。当使用IEquatable<int> = 42行时,也会发生同样的事情。尽管我们现在使用泛型,但我们仍然会遇到装箱。
让我们再看一个例子。这个例子有点愚蠢:
object myString = "some string";
var stuff = true ? 42 : myString;
这里,我们有一个字符串,我们将其分配给一个对象,myString(这不是愚蠢的部分)。然后,我们根据true为真(它总是为真;这是愚蠢的部分)将某个东西分配给stuff。如果true为真,我们将42分配给stuff。如果不为真,我们将myString复制到var。乍一看,你可能会期望stuff是int类型,因为true总是为真。但静态类型语言并不是这样工作的。它需要在编译时知道stuff的类型。条件运算符? :期望两边是等效类型。因此,它决定一部分是对象,并将整型文字转换为对象。因此,它将42装箱到对象实例中,而这里的stuff是另一个对象实例。这就是你所看到的:更多的装箱。
装箱和拆箱允许你混合和匹配值类型和引用类型。否则编写可重用代码会很困难。但要注意这一点,并注意装箱和拆箱相关的成本。它发生在你可能没有意识到的地方。这导致性能不佳。
选择合适的数据结构和算法
面向对象编程的全部内容就是将数据和该数据上的操作在一个紧密且松散耦合的结构中放在一起。这正是类和结构体所做的事情:它们将两者结合起来。这样,你可以以对系统功能有意义的这种方式定义你的数据结构。
但是,当谈到性能时,其他因素也会发挥作用。拥有静态类通常是一个你必须避免的代码问题。然而,它们运行得很快。你不需要实例化任何东西,从而避免了分配堆内存的昂贵调用。而且,这些内存不需要由垃圾回收器稍后清理。
当然,如果你为那个类有成员变量,你不妨实例化它。最终,所有发生的事情就是那些变量最终出现在堆上(附带一点家务)。方法本身是应用程序代码的一部分,并且以不同的方式存储。
BCL(Base Class Library)也有许多类和数据结构可以用来存储数据。其中一些更适合高性能。你选择哪一个取决于你的用例,但我认为如果这意味着你可以使用更有效的类,写一点更多的代码是值得的。
数组、列表和链表
数组、列表和链表都是你可以用来按顺序存储数据的结构。这些数据也存储在堆上。是的,你读得对。看看以下两行代码:
int i = 42;
int[] r = { 42 };
第一行是一个简单的赋值操作。系统将硬编码的值42(十六进制中的0x2A)复制到一个 DWORD 中,并将其存储在栈上。第二行创建了一个新的数组,在堆上为它分配内存,初始化数组,然后将42复制到第一个位置。
再读一遍,并尝试猜测是否有任何装箱操作在进行。
你可能期望有,但这里没有装箱。数组持有指向堆中包含单个 DWORD 值的内存位置的指针。它知道每个值有多长(精确到 32 位),因此它可以直接移动值而不做任何改变。此外,从数组中取出一个元素并将其存储在局部变量中时,不会发生解箱。系统复制 DWORD 值并保持原样。
列表与数组相同。内部,数据存储在一个数组中。然而,列表提供了动态调整大小的选项。除此之外,它还有一些很好的方法,如Add()、Remove()和IndexOf(),这些方法可能非常有帮助。但没有什么是不需要付出代价的:这些方法需要时间来执行,动态重新分配在性能方面非常昂贵。你必须判断你是否需要这些额外的方法和动态重新分配。如果你需要,使用列表。如果你可以不使用它们,使用数组。
存在一个中间方案:你可以使用List<T>并用适当的大小初始化它。毕竟,你必须为数组做同样的事情:你需要知道它的大小。这样做会导致List类初始化它内部使用的数组到那个确切的大小,并且不会发生真正的重新分配——除非,当然,你发现你需要更多的空间。但那很好;你不会耗尽内存。是的,你会在那种情况下获得性能惩罚,但那没关系。如果你预先初始化List类,性能几乎与纯基本数组相同。
LinkedList类有一些很好的特性。它是一个双链表,这意味着每个项目都伴随着指向下一个和前一个对象的指针。这意味着需要更多的数据来存储东西:我们不仅需要存储项目本身,系统还必须添加那些指针。这导致行为变慢:那些指针也必须被计算和复制。所以,当你考虑性能时,你可能会认为LinkedList是错误的。
然而,如果你的用例需要插入和删除,LinkedList可能是一个很好的选择。插入一个项目只是意味着存储对象并调整一些指针。在数组或列表中,插入意味着当你想要某个项目位于中间时,需要在内部数组中向上移动一个位置。
再次,运用你的判断力。如果你可以,使用数组(或预先初始化的列表),选择未初始化的列表,然后才考虑LinkedLists。
栈和队列
栈 和 队列 看起来非常相似。它们在性能上或多或少相似,但有一个很大的区别:如果你需要访问最新添加的项目,栈会很快,而当你需要快速访问按进入顺序排列的项目时,队列会非常快。换句话说,栈优化了 后进先出 (LIFO) 场景,而队列在 先进先出 (FIFO) 场景中表现更好。
然而,如果你的代码可以通过使用栈而不是队列来运行得更快,那么你的代码会更快。栈在处理其工作方面比队列略有效率,至少足以使重写代码变得值得。
HashSets 和列表
HashSet。当你在添加、删除或查找项目时,HashSet 可以非常高效。
HashSet 在性能上相对于列表有一个显著的优势:HashSet 的添加、删除和搜索操作的平均时间复杂度是常数时间。然而,列表的搜索时间复杂度是线性的。在日常英语中,HashSet 查找项目所需的时间总是相同的,无论它包含多少元素。当向列表中添加更多项目时,搜索所需的时间会更多。
但要注意:常数时间意味着时间不会改变。这并不意味着 HashSet 更快!恰恰相反:HashSet 可能相当慢。这很有道理:在将项目添加到 HashSet 之前,它需要计算该项目的唯一哈希值。这个哈希值是用于存储对象位置的键。然后,它必须检查是否已经添加了具有该哈希值的对象。
当然,一旦完成这些操作,查找项目就会非常快:它需要哈希值,然后可以轻松找到它。此外,当你拥有这些两个集合之一并需要添加项目时,在许多情况下 HashSet 比列表更快。
与大多数这些情况一样,查看你的需求并尝试进行一些基准测试,以查看你可以使用什么最佳。
SortedList、SortedDictionary 和 Dictionary
HashSet,但最大的区别是你可以通过其键在 Dictionary 中检索项目。你可以在 HashSet 中检索数据,但必须使用 foreach 语句来获取所有项目或使用 Linq 语句,如 Where()。
SortedList、SortedDictionary 和 Dictionary 中的键必须是唯一的。如果你的用例允许这样做,这些集合可以发挥神奇的作用,但前提是你选择了正确的一个。以下表格比较了这三种类型在性能方面的差异:
| 属性 | Dictionary <****TKey, TValue> | SortedList <****TKey, TValue> | SortedDictionary <TKey,TValue> |
|---|---|---|---|
| 基础数据结构 | 哈希表。 | 键的数组,值的数组。键已排序。 | 平衡的二叉搜索树。 |
| 排序 | 元素无排序。 | 按键排序。 | 按键排序。 |
| 插入 | O(1) 平均时间复杂度。 | O(n) 时间复杂度,因为它可能需要移动元素以保持顺序。 | O(log n) 时间复杂度。 |
| 删除 | O(1) 平均时间复杂度。 | O(n) 时间复杂度,原因与插入相同。 | O(log n) 时间复杂度。 |
| 查找 | O(1) 平均时间复杂度。 | O(log n) 时间复杂度。 | O(log n) 时间复杂度。 |
| 内存 | 通常比 SortedList 内存效率低,但比 SortedDictionary 高。 | 由于它使用数组作为键,比 SortedDictionary 内存效率更高。 | 通常内存效率较低。 |
| 用例 | 当你不需要排序但需要快速插入、删除和查找时。 | 当你有一个相对较小的数据集,你希望保持排序并且将进行大量查找时。 | 当你有一个较大的数据集,你希望保持排序,并且需要比 SortedList 提供的更快的插入和删除操作时。 |
表 2.4:基于键的集合
再次,检查你的需求和基准测试,看看什么最适合你。
字典或最后的元组/对象
List
Dictionary 中的查找速度非常快。由于你查找的是键而不是实际的项目,你可以比在列表中迭代整个列表以找到所需内容时实现更好的性能。此外,使用 Dictionary 进行插入和删除操作既快又恒定。
然而,使用 Dictionary 时,键必须是唯一的。使用列表则不必如此。再次强调,通过一些重写,你可能能够使用 Dictionary 而不是列表,并从中获得一些高度需要的性能提升。
For 与 ForEach
ForEach 是惊人的。它帮助我们更快地编写代码。然而,它也可能使我们的代码变慢。
ForEach 非常有用,以至于构建编译器的人们添加了各种优化。ForEach 做了很多工作:它获取枚举器,然后使用 MoveNext() 等方法遍历集合。这些操作都需要时间,你可能会认为它比使用 for 循环要慢得多。然而,这些优化使得在数组或 List<T> 上使用 For 或 ForEach 时,差异微乎其微。
但假设你使用自己的集合,其中你实现了 IEnumerable<T> 和 IEnumerator<T>。在这种情况下,C# 团队可能没有在编译器中针对该操作进行优化。这可能会导致循环比常规的 for 循环慢。
如往常一样,基准测试使用更易读的 ForEach 是否比常规的 for 循环更好。
字符串
在过去,字符串很简单。你确定存储一个句子所需的长度,分配内存,然后在一行中复制每个字符的 ASCII 值。然后,你在末尾放一个 0(零),这样就完成了。很简单。但后来你意识到你需要更动态的东西,因为你不确定字符串会有多长。所以,你编写了代码来改变存储它的缓冲区大小。你也意识到你需要对这些字符进行一些操作。例如,你可能想知道字符串有多长,而不用每次都数字符,或者你可能想将所有字符转换为大写。所以,你也为此编写了代码。到那时,你有一些以字符形式存在的数据(末尾有一个零)和一些数据上的方法。这就是类的定义,所以在 C++中,你编写一个String类。
当你意识到其他文化使用其他字符时,事情变得更加复杂。幸运的是,其他人也意识到了这一点,所以他们创建了Unicode 标准。但现在,你必须存储一个 Unicode 字符,而不是每个字符一个字节。这可以是 8 位(在 UTF-8 中)到 4 个字节。然后,你了解到虽然单个字符可以占用 32 位,但这在技术上是不正确的:这适用于码点。码点通常是字符,但有时,它不是。在这些情况下,你想要显示的字符在字符串中有多个码点。这就是大多数人放弃的时候。
好消息是,你再也不必为此担心了,因为我们有.NET 中的System.String类。它负责所有这些细节,而且看起来欺骗性地简单。将一个句子赋值给String类的实例就像以下代码一样简单:
string someMessage = "Hello, World!";
string theSameEmoji = "\U0001F600";
string someEmoji = "😀";
第一行将"Hello, World!"赋值给someMessage变量。当我们这样做时,编译器会生成所有必要的代码来创建System.String类的一个实例,并用正确的文本初始化它。
以下两行包含相同的 Unicode 字符:一个友好的笑脸。第一行使用 Unicode 字符,而第二行使用实际的字符。是的,这是有效的 C#!
字符串是引用类型,所以它们存在于堆上。我们之前学到堆比栈慢,但在这个情况下我们没有选择。当我们复制引用类型到一个新变量时,指针会被复制。这意味着我们有两个变量指向相同的数据结构。当我们复制字符串时也会发生这种情况:创建一个新的指针并指向那个类的相同实例。
字符串是不可变的。你不能改变字符串的内容。如果你这样做,CLR 会创建一个新的字符串,而旧的字符串则准备好被垃圾回收。这再次可能导致不希望的性能问题。
当我们谈论字符串性能时,我们必须考虑一些其他的事情。让我们来看看它们。
使用 StringBuilder 进行连接
当谈到字符串性能时,这一点最受关注。而且有很好的理由:这个简单的“技巧”可以帮助你的应用程序更快。想法是在循环中,不要连接字符串。创建一个StringBuilder对象并使用它。性能差异是巨大的。这很有道理:字符串的更改是不可能的,所以每次添加到字符串时,都会创建一个新的字符串,内容是添加的字符串,并且旧的字符串被丢弃。
在循环中使用StringBuilders。你可以继续这样做。
字符串的内部化
字符串被内部化。如果你的代码中有一个字符串,并且实际的文本在编译时已知,任何具有相同内容的其他字符串都将指向同一个类。看看这段代码:
string str1 = "Hello Systems Programmers";
string str2 = "Hello Systems Programmers";
// Reference equality test
if (Object.ReferenceEquals(str1, str2))
Console.WriteLine("Both strings point to the same memory location.");
else
Console.WriteLine("Strings do not point to the same memory location.");
当你运行这段代码时,你会得到一条消息,说明这两个字符串指向相同的内存位置。
但如果你从控制台读取两个字符串的内容,使用Console.ReadLine()。如果你输入相同的字符串两次,它们将不会被内部化。这是因为内部化是在编译时发生的。
你可以自己调用String.Intern。这将检查你想要内部化的字符串是否已经存在,如果存在,它将使其指向那个位置,而不是拥有自己的副本。这可以节省大量的内存,但会有性能上的损失。所以,要明智地使用它。
使用String.Concat或String.Join
我说当你在循环中连接字符串时应该使用StringBuilder。但是如果你不在循环中,只想向字符串添加一次,创建一个StringBuilder对象就有点过度了。在这种情况下,你应该使用String.Concat或String.Join。
只为了清楚起见:如果你在循环中,使用StringBuilder。StringBuilder对象是连接字符串最快的方式。但是创建一个StringBuilder类的实例需要时间(它是一个类,因此位于堆上)。如果你只想向现有的字符串添加一个或两个字符串,String.Concat在整体上比使用StringBuilder对象更快。
它看起来像这样:
var startString = "Welcome to System ";
var longString = startString.Concat("Programmers!");
String.Join对象是构建字符串的另一种好方法。当你想要将一组项目组合成一个字符串时,可以使用这个方法。项目列表可以是任何东西,因为 CLR 会调用它们的ToString()方法。在这里,ToString()需要有意义;否则,你会得到一个长长的类名列表。
它看起来像这样:
string[] myElements = {"C#", "VB.Net", "F#", "Delphi.Net"};
string result = string.Join(",", myElements);
打印result将在你的屏幕上显示C#,VB.Net,F#,Delphi.Net。
注意你用作元素列表的内容。如果那些是ValueTypes,会发生大量的装箱。这抵消了我们使用合适的字符串方法时的性能提升。
比较
很有可能你需要在代码中比较字符串。在这样做时,有几个方法可以提高你的性能。例如,考虑到文化因素比不考虑文化因素要花费更长的时间。如果你不需要特定的文化检查,你应该指定这一点。同样,对于 casing:如果你在比较时不关心 casing,请不要使用那些处理 casing 的比较。
比较字符串有几种方法。最明显的是相等运算符:
string a = "my string";
string b = "my string";
var areTheyEqual = a == b; // true
在这种情况下,根本不需要比较。由于编译器会内联字符串,指针指向相同的数据。对于这种情况的相等检查会返回 true。
你也可以这样做:
string a = "my string";
string b = "my string";
var areTheyEqual = a.Equals(b); // true
这段代码做的是同样的事情,同样需要注意内联的问题。这里,operator == 调用 Equals(),所以结果相同,性能也相同。
现在,看看这段代码:
string a = "my string";
string b = "my string";
var areTheyEqual = a.Equals(b,
StringComparison.InvariantCultureIgnoreCase); // true
这种比较方式比之前的例子慢得多。CLR 现在必须比较字符串的所有不同形式:在所有 sorts of cultures 和所有 casing 中。
如果你需要它,这种方法非常好,但如果你不需要,请省略选项!
我看到很多人编写这种代码:
string a = "my string";
string b = "my string";
var areTheyEqual = a.ToUpper() == b.ToUpper(); // true
这种比较方式是做这件事最糟糕的方式。调用 ToUpper() 并不会将字符串转换成全部大写。相反,它创建了一个包含所有大写字符的新字符串。再次强调,字符串是不可变的,所以每次你更改内容时,运行时都会创建一个新的字符串。这里我们做了两次,以便进行比较。
使用 StringComparison.IgnoreCase 比调用 ToUpper()(或 ToLower())快五倍。
预先分配 StringBuilder
最后一点建议:当使用 StringBuilder 时,如果你知道结果的字符串长度,这会非常有帮助。预先分配有助于优化代码并减少许多分配,从而提高性能。
编写不安全代码
在我们开始讨论不安全代码之前,有一个警告。这就是为什么它被称为“不安全”。当你离开安全代码时,你可能会遇到很多麻烦。
当你运行代码时,CLR 会为你检查很多事情。例如,它确保类型安全,并确保你不会在内存中玩弄不属于你的空间。
在“旧”的日子里,当在 Windows 开发中使用 C++ 或 C 时,这是程序崩溃的主要来源。开发者们在指针运算中犯了一点小错误,最终读取或写入他们没有访问权限的内存。操作系统立即终止你的进程,你得到了那个令人讨厌的 AccessViolationException 错误。这是最后的警告:操作系统告诉你不要进入别人的内存。有时,情况会更糟:操作系统可能没有捕捉到它,而你搞砸了操作系统或另一个程序。这可能导致更糟糕的情况:整个机器可能崩溃。
.NET 中 CLR 的安全环境实际上完全消除了这一点。CLR 控制你做的每一件事,并确保你留在被允许停留的区域。
你可能已经意识到这很好,但检查所发生的事情总是会有性能损失。没有什么是免费的。我们为了稳定的系统而放弃了一些性能。
如果你想要恢复性能,你可以告诉 CLR 不要干涉你的方式。CLR 将会服从并将控制权交给你。再次强调,你现在完全独立,并负责不要搞砸事情。但现在运行得更快了!
让我们考虑一个例子。
数组是指向连续项目列表的指针。所以,int[1000] 只是指向一个由一千个整数组成的很长列表的指针,所有这些整数都整齐排列。
你可以通过给数组提供你想要的项目索引来访问列表中的这些项。首先,CLR 会检查数组是否已初始化并且没有指向内存中的某个奇怪随机位置。然后,它会检查你的索引是否在 CLR 为数组分配的范围内。如果检查无误,它会为你获取并返回项目。很好。
下面的代码示例遍历数组并计算所有值之和:
long sum = 0;
for (int i = 0; i < array.Length; ++i)
{
sum += array[i];
}
这段代码运行得很好,但可以更快。所有这些检查都需要时间,我们可能决定我们不需要它们。我们告诉 CLR 休息一下,让它全部由我们来处理!
下面的代码片段展示了如何做到这一点:
unsafe
{
long sum = 0;
fixed (int* pArray = array)
{
int* pEnd = pArray + array.Length;
for (int* p = pArray; p < pEnd; p++)
{
sum += *p;
}
}
}
我们使用 unsafe 关键字声明我们想要优化的代码块。该块中的所有内容现在将不再进行检查。
然后,我们检索数组的指针。我们将其标记为 fixed。这个关键字意味着垃圾收集器在我们完成之前不会移动数组。如果我们访问它时垃圾收集器将数组移动到内存中的另一个位置,那将是灾难性的。fixed 关键字防止这种情况发生。
然后,我们在内存中获取数组的末尾指针,以便我们知道何时结束。在 for 循环中,我们获取元素的指针,读取该内存位置的数据,并将 sum 变量相加。
这段代码运行正常。它也比安全版本要快。但为了好玩,我们可以稍微玩一下指针。不要让它结束在数组的末尾,让它结束在当前位置加上 0xFFFF。现在,没有办法知道会发生什么。它可能会继续读取数组末尾之后的内容,将所有这些字节加到 sum 上。这意味着你得到了错误的结果。更有可能的是,你会得到 AccessViolationException 错误,然后你的程序被终止。
我们使用不安全代码来提高性能,例如在前面示例中,以及当我们需要与用 C/C++ 编写的本地库交互时。但如果在不牺牲太多性能的情况下可以避免,请尽量避免。
编译器优化
我之前已经说过,现在再重复一遍:不要试图欺骗编译器。C# 编译器是一块非常出色的软件,可以做到我们甚至无法想象的事情。但有时,我们可以帮助编译器做出影响性能的良好选择。
侵略性优化
看看下面的方法:
private int AddUp(int a, int b)
{
return a + b;
}
我相信你同意这并不是一个令人兴奋的方法。然而,调用它却需要花费很多时间:调用方法必须存储返回地址,将所有参数(整数值,a 和 b)移动到正确的位置,跳转到方法,检索参数,执行实际工作,将返回值存储在正确的位置,检索返回地址,跳转到那个返回地址,并将结果赋值给调用方法中的变量。
编译器知道这一点。所以,在这种情况下,它可能会优化它并“内联”它。但如果你认为编译器不知道这一点,你可以指示它更仔细地查看代码,并对此更加积极。你可以这样做:
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
private int AddUp(int a, int b)
{
return a + b;
}
这告诉编译器在优化代码时要积极。这是一个对编译器的提示:没有保证它会按照你的要求去做。但在这个例子中,它可能会尊重你的请求(再次强调,它可能已经这样做了)并内联方法。
内联意味着它将方法体直接注入到调用方法中。所以,现在它将执行代码内联,就像它是原始方法的一部分一样。这是之前我描述的所有复制和移动操作。
这当然要快得多。这也意味着你的原始方法变大了:它现在包含那额外的代码片段,所有使用这个 AddUp() 方法的其他方法也是如此。它被复制到各个地方。
这是个选择的问题:更多的性能与更高效的内存使用。
优化标志
编译器可以优化你的代码。但它并不总是这样做。你可以添加 optimize 标志到编译器来强制优化。
有几种方法可以做到这一点。首先,如果你使用命令行来构建你的代码,你可以将其添加到命令行中:
dotnet build -c Release -property:Optimize=true
或者,你可以使用 MSBuild:
msbuild /p:Configuration=Release /p:Optimize=true
它们两者都达到了相同的结果。
你也可以在CSProj文件中将它设置为选项。这样做最好的方式是将它添加到项目属性中:

图 2.1:显示优化代码选项的项目属性
如你所见,你可以为调试和发布设置优化代码。
这将在你的.csproj文件中添加或更改以下设置:
<PropertyGroup>
<Optimize>True</Optimize>
</PropertyGroup>
好知道默认情况下,以调试配置编译的程序关闭了优化。相比之下,以发布配置编译的程序则开启了优化。
在调试时,使用非优化代码会更好。在发布时,情况则相反。
下一步
性能——这正是本章的主题。我们了解到为什么编写尽可能快和高效的软件对于系统编程至关重要。
首先,我们检查了 BCL 和 CLR,并看到了不同数据类型如何影响性能,但也看到了事情并不总是像预期的那样表现。
然后,我们检查了 CTS 中的类型,并确定了哪些类型能给我们带来最佳性能以及应该避免什么。我们在Strings类上花费了不少时间。我们还学习了如何重写我们的代码,以便使用这个类提供的最佳工具使其运行更快。
之后,我们深入到不安全类型的黑暗世界中,并看到它们可以给我们带来更多的性能,但代价是可能以最壮观的方式崩溃我们的应用程序,甚至我们的系统。
最后,我们探讨了帮助编译器使我们的系统更快的方法。在这里,我们了解到编译器足够智能,可以做出这些改变。值得重复的是,你不应该试图超越系统。你真的应该只在基准测试显示你有问题时才使用不安全代码和编译器技巧。否则,让这两者保持原样。然而,如果你确实需要它们,了解这些技巧是很好的。
然而,更好的性能往往会导致内存使用效率降低。这是一个权衡。有时,拥有一个更内存高效的系统比拥有一个快速的系统更好。有时,你必须混合使用。在下一章中,我们将考虑内存并更详细地探讨这些方面。
第三章:内存游戏
高效 内存管理
性能对于系统编程至关重要。我们在上一章讨论了这一点,并概述了为什么它至关重要。内存消耗同样重要。问题是,更好的性能往往会导致更差的内存使用。而试图优化内存使用往往会导致性能更差。就像生活中的所有事情一样,这是一个权衡的问题。
话虽如此,你也可能遇到同时遇到两种情况的情况——例如,使用栈而不是堆(或值类型而不是引用类型)会导致代码运行更快,内存使用更少。
然而,在追求其中一种的同时,通常不会免费获得另一种。你必须做出明智的决定和正确的选择。这正是本章的主要内容。我希望当我们到达本章的结尾时,你能记住大部分内容!
本章我们将涵盖以下主题:
-
内存管理概述
-
垃圾回收器(GC)概述
-
如何正确使用
IDisposable -
一系列关于如何节省内存的技巧和窍门
-
不安全代码和指针
技术要求
本章中的所有内容都可以在普通的 C# 安装中完成。如果你在跟随学习,可能需要额外的只是 NuGet MessagePack 包。你可以通过 Visual Studio Code 或使用以下 CLI 命令来安装:
dotnet add package MessagePack
GC 概述
.NET 是一个受管理的系统。正如之前所讨论的,许多开发者必须处理的问题现在都由 公共语言运行时(CLR)来处理。CLR 抽象掉了开发者面临的大部分繁琐任务,使他们能够专注于功能。
内存管理是一项棘手但非常重要的任务。做错通常会导致内存泄漏或软件不稳定。尽管没有软件应该有这种情况,系统编程需要避免这种情况。它可能导致系统不稳定,使整个计算机无法使用。因此,.NET 开发者不必担心这一点。GC 管理了大部分内存,并处理那些复杂的细节。
学习 GC 的工作原理是值得的,这样你的代码就会更加内存高效。这意味着了解内存分配在 .NET 中的工作方式。
我们已经讨论了栈和堆之间的区别。但为了提醒一下,栈是短期、较小但较快的内存部分,用于值类型,而堆是长期、更广泛但较慢的内存部分。
如果你在一个代码块中声明一个整数,CLR 会将其放在栈上。该内存会在该代码块的作用域结束时释放。堆的工作方式不同。由于堆上的项目可以存活更长时间,我们需要另一种处理这种内存的方法。这就是垃圾回收器(GC)的作用所在。
GC 过程可以在单独的线程上运行或在主线程或用户线程中运行。目前,假设 GC 在后台线程上运行是最简单的。我们稍后会处理现实世界的情况。
GC 及其代数
GC 是一个代数系统。这意味着它与代数一起工作。这有帮助吗?我想没有。好吧,让我详细说明。
查看以下代码片段:
1: {
2: object a = new object();
3: }
4: {
5: object b = new object();
6: }
这段代码不是我们最激动人心的代码片段,但我们必须从某个地方开始。这里的括号是必要的。
上述代码片段产生的活动比预期的要少,尤其是如果你有 C 或 C++的背景。
以下图将帮助您理解在运行上述代码片段时发生的情况:

图 3.1:空的、已分配的堆
在程序启动期间,CLR 分配了一个连续的内存块。这个块不是很大,但足够容纳所有启动对象,以及它确定需要的任何其他东西。到那时,创建了一个指针,指向项目可用的第一个区域。
在第 1 行,我们开始一个代码块。然后,在第 2 行,我们创建一个Object类型的实例并将其存储在a变量中。属于该对象的所有数据内存都位于堆上。运行时初始化,计算a应该占用多少内存,并将分配指针移动到块中下一个可用的内存块。在栈上创建了一个指针(我们称之为a),该指针指向堆上存储其数据内存块:

图 3.2:创建对象 a 后的堆
在第 3 行,我们结束该变量的作用域。正如我们所学的,栈上的变量只在其所属的作用域内存在。因此,a指针被清除,其占用的内存被释放。但在堆上,没有任何变化。a的数据仍然存在,分配指针仍然指向相同的位置:

图 3.3:变量 a 超出作用域后的堆
然后,在第 4 行,我们创建一个新的作用域块;在第 5 行,我们创建一个新的Object实例并称之为b。整个表演又从头开始,但b的数据现在存储在a的上面。没有人知道这一点;a的数据已经变得不可达。但它仍然在那里!

图 3.4:分配对象 b 时的堆
当然,在第 6 行,作用域结束,因此栈变量b再次被移除。再次,堆上没有任何变化:

图 3.5:变量 b 也超出作用域后的堆
如你所见,我们在堆上不分配或释放内存。在这里,每当我们需要一个新对象时,指针就会向上移动。移动指针比分配和释放内存要快得多。在性能上,分配和释放,或者说释放内存,是非常昂贵的。尽可能避免这些操作是.NET 应用程序可以运行如此之快的原因之一。
然而,你可能已经看到了一个潜在的问题。当我们用完堆上的空间时会发生什么?分配指针不能移动到该块的末尾,那么接下来会发生什么?
很高兴你问了。这就是垃圾回收器(GC)发挥作用的时候。当我们用完最初分配的块中的内存时,GC 会查看该块中的所有项目。
首先,它会遍历堆中的所有对象,看看哪些仍然有活跃的指针指向它们。在我们的例子中,我们没有,但想象一下我们有一些其他对象分配了,这些对象仍然在作用域内。
GC 将这些孤儿内存位置标记出来,以便知道它可以回收这些内存。但 GC 无法移除的项目怎么办?
这个问题的答案涉及到 GC 是“代式的”。CLR 将每个对象放置在堆的一个特定部分,该部分用代数标记。所有新对象都在第 0 代。
当 GC 施展其魔法时,它会将所有仍然存活并在作用域内的对象移动到下一代。它们现在在第 1 代堆中。
有一点更详细
事实上,只有两个堆:一个用于所有代,一个用于大型对象堆(LOH)(我们将在后面更详细地介绍)。堆被分成几个部分,每个部分对应一个代。然而,我们可以将每个代视为有自己的堆。虽然这从技术上讲是不正确的,但这样思考会使理解正在发生的事情变得容易一些。
现在,所有在垃圾回收过程中幸存的对象都在第 1 代堆中;所有无法再到达的对象都准备好被清理。GC 清除内存并将分配指针设置回堆的起始位置。现在,一切都可以从头开始。
这相当不错,不是吗?但还有一个问题。如果我们的第 1 代堆填满了会怎样?
在这种情况下,我们看到类似的行为。第 1 代中所有不再可到达的项目(包括不是从其他代中的对象可达的项目)都被标记为删除;GC 将所有其他项目提升到第 2 代。
好的;让我们继续。当第 2 代填满时会发生什么?如果你猜测所有可到达的项目都移动到第 3 代,那你就错了。没有第 3 代。如果我们填满第 2 代,运行时将分配一个足够大的新块来容纳当前堆,并且足够添加更多对象。然后,它将所有对象移动到新的堆,并将旧堆返回给操作系统。
有时候,CLR 请求更多堆内存,但操作系统会对其进行惩罚,因为没有更多的内存可用。在这种情况下,我们会看到可怕的OutOfMemoryException错误。
处理 OutOfMemoryException 错误
处理异常的规则是,你应该只捕获你知道如何处理的异常,以便将系统恢复到稳定状态。对于OutOfMemory,你无法做到这一点。OutOfMemoryException错误是最好让它自行处理的异常之一。在这里你无法做太多来帮助。
LOH
你可能可以想象,在内存中移动数据需要花费很多时间,这将阻碍你的性能。这是正确的:当 GC 运行时,性能会受到巨大打击。
GC 被优化以尽可能防止这种情况,但内存操作本身是昂贵的。特别是重新分配内存和将字节移动到所有不同位置需要花费大量时间。
CLR 设计者为了稍微缓解这个问题,声明了一个特殊的堆,称为 LOH。
如其名所示,这是一个用于大对象的堆。目前,它处理大对象——即大于 85,000 字节的对象。
那么大或更大的对象不会进入常规堆。它们不受系统其他部分的代际行为的影响。
GC 确实有助于保持 LOH 的清洁,但它运行的频率远低于其他堆。此外,LOH 没有代际。
当 GC 从大型对象堆(LOH)中清除对象时,内存会变得碎片化。这意味着过了一段时间后,我们的内存块看起来有点像瑞士奶酪:到处都是孔洞。曾经被对象占用、现在已被回收的内存区域现在是空的。过了一段时间,内存就由有效的对象和空空间组成。这意味着虽然技术上还有足够的内存来分配新的对象,但系统找不到一块连续的内存。如果发生这种情况,GC 将压缩 LOH,使内存再次连续。但这只会在非常罕见的情况下发生。这种方式意味着 LOH 比其他堆慢得多。
此外,LOH 没有预定义的大小。如果需要,它会增长。这又是一个昂贵且缓慢的操作。
好消息是,这些大对象在常规堆中不会妨碍你,所以它们不会减慢那里的 GC。
创建大对象时要小心。它们可能会让你的应用程序陷入停顿。
终结器
你可能已经用.NET 编程超过十年,从未见过或使用过终结器。如果是这样,做得好。我们不需要它们。嗯,我们大多数情况下不需要。有些边缘情况我们需要;其中一个是当你使用IDisposable模式时。这个模式在本章后面有专门的章节介绍。
我想向你展示,如果你在你的类中添加一个终结器(finalizer),垃圾回收器(GC)会发生什么。
有趣的事实!
终结器经常被误认为是析构函数。这很有道理:如果我们有一个构造函数在对象的生存期开始时,为什么不在结束时也有析构函数呢?毕竟 C++有它们。但我们没有。所以,永远不要将终结器当作析构函数来调用。它们不会销毁。它们是和平主义者,只想在它们之后进行清理。
让我简要解释一下什么是终结器。终结器是 C#类中的一个方法,运行时会在这个对象被清理和移除之前调用它。就像构造函数一样,它有一个特殊名称。下面的代码块提供了一个终结器的示例:
class MyClass
{
public MyClass()
{
// Initialize everything here...
}
~MyClass()
{
// Clean up here
// (well, don't. Use IDisposable for that).
}
}
这个类,MyClass,既有构造函数也有终结器。构造函数具有类的名称,一个访问修饰符(在这种情况下为public),没有返回类型(因为它不是一个方法),并且可能有一些参数。这里我没有参数,但如果需要,我可以添加它们。
这个构造函数是在 CLR 分配内存之后调用的。你可以将其视为“new”操作的一部分。你知道它何时被调用:一旦你创建了一个实例,CLR 就会调用构造函数。很简单,对吧?
因此,一个类的实例可以创建如下:
var myClass = new MyClass();
终结器有点不同。它没有访问修饰符,没有返回类型,也没有参数。它是类名前加上波浪号(~)。你永远不会调用这段代码。CLR 会调用。你无法设置任何参数。
当然,问题是它何时被调用?答案是,我们不知道。
让我们回到 GC 运行过程。0 代空间已满,因此 GC 必须进行清理。它会寻找所有超出作用域的对象以释放内存。假设myClass也超出了作用域。
我之前解释了 GC 如何清理内存,但省略了 GC 也采取的两个步骤。
第一个额外步骤是,在它找到所有没有活跃变量指向它们的内存位置后,它会寻找那些区域具有终结器的对象。如果找到了一个,它将把对该内存结构的指针放入一个称为FReachableQueue的特殊队列中(F 代表终结器)。然后,它就不再管它了。该对象的堆内存不会被回收。它也不会移动到另一个代。它只是存活在清理过程中。现在,它再次静静地坐在那里。
好吧,直到 GC 再次运行。这就是第二个步骤发挥作用的地方。在清理代之前,它会遍历FReachableQueue。对于队列中的所有对象,CG 会调用终结器。然后,它从FReachableQueue中移除指针,现在对象最终准备好被垃圾回收。
这有一些深远的影响:
-
具有终结器的对象会经历额外的垃圾回收轮次。它们存在的时间更长,增加了内存压力。
-
具有终结器的对象将调用它们的终结器,但我们不知道何时调用。毕竟,我们不知道 GC 何时运行。
-
移动指针是垃圾回收器的一个额外步骤,使得事情变得更慢。
终结器是一个巨大的性能瓶颈。最好根本不用它。除非,当然,你使用 IDisposable 模式来清理。我们将在下一节讨论这个问题。
IDisposable
.NET 是一个托管环境。我之前说过,我还会再次提到。我一直在重复这一点,因为许多人认为“托管”意味着“我不必担心这些事情。”正如我们所看到的,这根本不是真的。是的,CLR 去除了其他开发者所承受的许多痛苦,但仍然,还有很多事情你必须自己去做——尤其是如果你,就像我们一样,在编写系统软件。
CLR 做的一件事是在我们之后清理资源。值类型位于堆栈上,不需要清理。引用类型需要清理,但垃圾回收器会处理。然而,正如我们所看到的,清理并不总是发生在我们期望它发生的时候。
并且还有一个问题:垃圾回收器不会清理所有已使用的资源。CLR 只清理托管对象。非托管对象是你的责任去清理和处置。大多数解释这种行为的例子都提到了文件和数据库连接等类。坦白说,对于大多数开发者来说,这些是他们处理非托管资源时唯一会遇到的现实生活中的情况。对于我们来说,这有点不同。当我们编写系统软件时,我们比平时更经常地遇到来自低级 API、外部硬件、与第三方软件接口、将我们的代码附加到外部调试器等情况。我们将在本书后面讨论文件系统、网络和与其他硬件接口时看到这些示例。
因此,你必须理解如果垃圾回收器没有为你做这件事,你应该如何清理。这就是 IDisposable 发挥作用的地方。
IDisposable 接口非常简单。它看起来是这样的:
public interface IDisposable
{
void Dispose();
}
实现此接口的类必须确保它们有一个不带参数的 void 方法,名为 Dispose。
它是一个接口,所以它不做任何事情。如果你将它添加到一个类中,什么都不会发生。CLR 会忽略它。这个声明很重要。我会重复一遍:CLR 对实现此接口的类不做任何处理。
IDisposable 接口更像是一个合同。我们将其添加到处理非托管资源的类中。其他开发者看到类声明中的该接口,就会假设他们必须处理非托管资源。
就这样。
那么,我们如何实现它?让我们看看下面的示例:
class ResourceUser
{
private readonly IntPtr _ptr;
public ResourceUser()
{
// Allocate an 8 KB block of memory
_ptr = Marshal.AllocHGlobal(8 * 1024);//
}
~ResourceUser()
{
Marshal.FreeHGlobal(_ptr);
}
}
在构造函数中,我们分配了一个 8 KB 的内存块。我们将该块的指针存储在 ptr; 中。
这块内存是不受管理的。因此,清理它也取决于我们。我们决定在终结器中完成这项工作。毕竟,它是保证要运行的,所以我们在这里做得很好!
但是,我们已经确定我们不确定这将在何时发生。我们不想在垃圾回收器决定运行之前(由于它位于终结器中,所以是两次!)分配一大块完美的内存。这只是在浪费内存和大量的 CPU 周期。
我们需要另一种清理方式。让我们重写代码:
class ResourceUser
{
private IntPtr _ptr;
public ResourceUser()
{
// Allocate an 8 KB block of memory
_ptr = Marshal.AllocHGlobal(8 * 1024);//
}
~ResourceUser()
{
//nothing to do here!
}
public void Cleanup()
{
if (_ptr == IntPtr.Zero) return;
Marshal.FreeHGlobal(_ptr);
_ptr = IntPtr.Zero;
}
}
这段代码将清理代码移动到一个名为Cleanup的新方法中。如果我们想使用这个类,我们可以简单地创建一个实例,然后确保我们始终调用Cleanup()。我们可以通过使用try-finally块来确保这一点。让我们这样做:
var myClass = new ResourceUser();
try
{
// Do something with myClass
}
finally
{
myClass.Cleanup();
}
这很简单,对吧?说实话,这就是IDispose接口的全部内容。最显著的区别是,我们不再有一个名为Cleanup()的方法,而是有一个名为Dispose()的方法。我们用正确的接口标记我们的类,这是对其他开发者的一个礼貌。这样,他们就知道在使用我们的类之后必须进行清理。让我们使用以下代码块来做这件事:
class ResourceUser : IDisposable
{
private IntPtr _ptr;
public ResourceUser()
{
// Allocate an 8 KB block of memory
_ptr = Marshal.AllocHGlobal(8 * 1024);//
}
~ResourceUser()
{}
public void Dispose()
{
if (_ptr == IntPtr.Zero) return;
Marshal.FreeHGlobal(_ptr);
_ptr = IntPtr.Zero;
}
}
这就是我们需要做的全部。在我们的调用代码中,我们应该调用Dispose()而不是Cleanup(),这样我们的代码才能编译。让我们这样做。我这里不会展示那段代码,因为我相信你知道如何做。然而,我会展示中间语言(IL)代码。作为一个提醒,IL 是一种既不是 C#也不是机器码的语言。它介于两者之间。但它确实给我们提供了一个很好的指示,说明编译器在将其转换为实际机器码之前对我们的代码做了什么。IL 代码看起来是这样的:
01: .method private hidebysig static void '<Main>$'(string[] args) cil managed
02: {
03: .entrypoint
04: // Code size 21 (0x15)
05: .maxstack 1
06: .locals init (class ConsoleApp1.ResourceUser V_0)
07: IL_0000: newobj instance void ConsoleApp1.ResourceUser::.ctor()
08: IL_0005: stloc.0
09: .try
10: {
11: IL_0006: nop
12: IL_0007: nop
13: IL_0008: leave.s IL_0014
14: } // end .try
15: finally
16: {
17: IL_000a: nop
18: IL_000b: ldloc.0
19: IL_000c: callvirt instance void ConsoleApp1.ResourceUser::Dispose()
20: IL_0011: nop
21: IL_0012: nop
22: IL_0013: endfinally
23: } // end handler
24: IL_0014: ret
25: } // end of method Program::'<Main>$'
IL 代码几乎与我们的 C#代码相同。对我们来说,关键部分在 15 到 23 行。这是包含对Dispose()方法调用的finally块。我们现在知道,无论如何,我们的资源都将被清理。
这太棒了。它非常有用(而且很重要),C#语言的背后的人给了我们一个新的结构,帮助我们做到这一点:他们给了我们using语句。
使用那个语句意味着当不再需要资源时,会调用Dispose()。这种调用可以通过两种方式完成:作为块语句或作为内联语句。
块语句看起来是这样的:
using (var myClass = new ResourceUser())
{
// Do something with myClass
}
在这里,using开始一个新的作用域块。资源可以在作用域结束时被释放和清理。
内联版本甚至更简单:
using var myClass = new ResourceUser();
// Do something with myClass
编译器会自动检测myClass何时超出作用域。一旦发生这种情况,using语句的典型工作流程就会继续。
“但是,”我几乎能听到你说,“你刚才告诉我 CLR 对那个 IDisposable 接口没有任何操作,但在这里它理解如何处理它!”
这是一个聪明的观察,但关于IDisposable的知识并不在 CLR 中。编译器才是那个聪明的。如果我们取using的内部版本,构建我们的程序,并检查 IL,我们会看到以下代码:
.method private hidebysig static void '<Main>$'(string[] args) cil managed
{
.entrypoint
// Code size 20 (0x14)
.maxstack 1
.locals init (class ConsoleApp1.ResourceUser V_0)
IL_0000: newobj instance void ConsoleApp1.ResourceUser::.ctor()
IL_0005: stloc.0
.try
{
IL_0006: leave.s IL_0013
} // end .try
finally
{
IL_0008: ldloc.0
IL_0009: brfalse.s IL_0012
IL_000b: ldloc.0
IL_000c: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
IL_0011: nop
IL_0012: endfinally
} // end handler
IL_0013: ret
} // end of method Program::'<Main>$'
这段代码和我们在自己调用Dispose()时的代码之间有一些细微的差别,但这些差别并不重要。重要的是编译器查看我们的代码,并将其转换为包含在finally部分调用Dispose()方法的try-finally块。换句话说,它确实做了完全相同的事情。
因此,using只是一个方便的简写,用来指示编译器。如果我们使用了Cleanup()而不是Dispose(),编译器就不会理解它。但最终,在处理器上运行的代码是相同的。没有差别。使用IDisposable()没有涉及任何魔法。
IDisposable模式
很遗憾,我们还没有完成。前面的代码是有效的。它在我们不再需要资源时进行清理和执行这些操作。但我们依赖于我们的ResourceUser类的用户做正确的事情:他们必须使用Dispose()或using语句。如果他们不这样做,我们可能会出现内存泄漏。而且别忘了,那个未能做到这一点的开发者可能就是你,六个月后你可能会忘记你做了什么。
我们需要一种更好的方法来做这件事。
IDisposable模式是一个确保资源得到清理的方案,无论发生什么情况。
例如,如果我们的类的用户没有直接或通过using语句调用Dispose(),会发生什么?无论发生什么情况,我们都需要清理。幸运的是,我们可以做到这一点。我们有终结器。它总是运行,尽管它可能不是在最佳时间运行。但至少我们可以确信我们的资源最终会得到清理。
我们可以将清理代码复制到我们的终结器中。然而,我们不希望清理两次。确保我们的资源被处置的首选方式是编写一个Dispose的重载版本。整个实现看起来像这样:
01: class ResourceUser : IDisposable
02: {
03: private IntPtr _ptr;
04: private IDisposable? _someOtherDisposableClass;
05: private bool _isDisposed;
06: public ResourceUser()
07: {
08: // Allocate an 8 KB block of Memory
09: _ptr = Marshal.AllocHGlobal(8 * 1024); //
10: }
11: public void Dispose()
12: {
13: Dispose(true);
14: GC.SuppressFinalize(this);
15: }
16: ~ResourceUser()
17: {
18: Dispose(false);
19: }
20: private void Dispose(bool isDisposing)
21: {
22: if (_isDisposed)
23: return;
24: if (isDisposing)
25: {
26: _someOtherDisposableClass?.Dispose();
27: }
28: if (_ptr != IntPtr.Zero)
29: {
30: Marshal.FreeHGlobal(_ptr);
31: _ptr = IntPtr.Zero;
32: }
33: _isDisposed = true;
34: }
35: }
让我们看看这里会发生什么。
在第 3 行,我们有指向我们的非托管内存块的指针。在第 4 行,我添加了一个新字段,用于另一个实现IDisposable的类。这个字段可以是任何东西,比如一个文件或数据库。它是什么并不重要。我们在这里需要知道的是,它是一个在使用后必须清理的托管类。在第 5 行,我添加了一个布尔值,我们用它来查看这个类的实例是否已经被处置。
第 6 行到第 10 行构成了构造函数的主体,其中我们分配了我们的 8K 内存块。
在第 11 行,我们有我们的Dispose方法。在那里,我首先调用一个重载的Dispose方法,并给它一个true参数。我们使用这个参数来跟踪谁调用了重载的Dispose。这个参数的作用我在下面几行中解释,但在那之前,我必须解释GC.SuppressFinalize(this)这一行。这是魔法行。它告诉 GC 在执行其魔法时不要将这个实例移动到FReachableQueue。实际上,这从我们的类中移除了终结器代码,这样当 GC 运行时,它可以立即清理堆栈上的内存,而不是等待另一次运行。
然后,我们有终结器。终结器只有在类用户忘记调用Dispose(或using)并且由于GC.SuppressFinalize(this)调用而触发时才会被调用。这次,我们调用Dispose(false)。
让我们讨论我添加到Dispose()方法中的参数,并承诺要解释的。在第 20 行,我们有清理的实际代码。到现在为止,我希望你已经理解了isDisposing标志的作用。如果这个标志设置为true,我们就到了这里,因为类的用户调用了Dispose()。如果标志是false,开发者没有使用Dispose(),而是让它由终结器处理。
当然,我们首先检查是否已经清理,这是通过在第 22 行检查_isDisposed变量来完成的。
第 24 行是至关重要的。我们的类有一个需要清理的托管资源。但如果我们从终结器来,我们就不知道这段代码会在什么时候运行。可能会出现 GC 已经清理了由_someOtherDisposableClass分配的内存的情况。我们无法知道。如果它已经被释放,那么调用它的Dispose()将导致严重错误,并可能导致我们的系统崩溃。因此,我们必须确保只有在确定它仍然存在的情况下才调用该成员的Dispose()。如果我们通过终结器进入这个方法,我们就不能确定。事物被销毁的顺序是非确定性的。我们能确定的时间只有当我们通过调用Dispose()进入这里时。
然而,内存块是另一回事。那个块是未管理的,所以我们知道 GC 还没有清理它。它不能。这就是为什么我们称它为未管理。因此,我们在第 28 行到 32 行清理它,无论发生什么。
就这样。如果你有一个从这个类派生出来的派生类,但并不复杂到你自己无法理解(提示:使void Dispose(bool isDisposing)受保护的虚拟),事情会变得稍微复杂一些。
如果你想让你的代码尽可能高效地使用内存,IDisposable接口非常重要。在这里,你学习了如何正确实现它,以及如何编写代码以消除内存泄漏。再次强调,由于我们作为系统程序员更有可能需要处理未管理代码,而不是其他开发者,这是至关重要的知识。
但仅仅了解 IDisposable 是不够的。我还有许多关于在您的应用程序中节省内存的技巧和窍门想要与您分享。
节省内存的技巧和窍门
系统程序员需要意识到他们所编写的系统所使用的内存。因此,我想分享一些可以帮助您减少内存压力的建议。内存压力是一个术语,用来表示与可用内存相比使用的内存量。再次强调,一些这些建议可能会使您的系统变慢。作为系统程序员,您必须做出明智的选择,在快速和内存高效的代码编写之间进行权衡。有时,您会幸运地两者兼得。其他时候,您必须考虑选项,选择两个恶行中较轻的一个。以下将涵盖您可以采取的具体措施来减少系统上的内存压力。
-
使用值类型而不是引用类型:堆栈上的值类型通常比引用类型小。指向类的指针和堆本身中的指针的开销可能是转向值类型(如结构体)而不是使用引用类型(如类)的原因。然而,如果您的结构体变得太大,您可能会注意到性能损失。值类型在用作参数时按值复制,复制大结构体需要更长的时间。
-
ObjectPool<T>类持有一个对象池,您可以在使用完毕后将其返回。您不需要创建类的实例并等待 GC 清理,而是可以创建几个实例并将它们存储在池中。最初,这可能会增加内存压力,但根据您的场景,这可能会节省一些内存使用。 -
List<T>。列表提供了很多功能。它可以非常灵活,但代价是更高的内存消耗。 -
List<T>,有时,使用它来存储一些项目可能会很有诱惑力。同样适用于Dictionary<TKey, TValue>。但您并不总是需要它。如果您知道您想在类中存储什么,可能更有效的是声明更简单的变量来存储这些内容,并使用这些变量代替。我看到有人使用
Dictionary<TKey, TValue>来存储用户名和电子邮件地址。使用两个固定的字符串会更简单、更快、更节省内存。做一个聪明的开发者吧! -
使用 Span
和 Memory :假设您有一个整数数组。没有什么特别的,只是像这样:int[] myBuffer = new int[100];
数组是引用类型,因此这会在堆上分配一个内存块。这并没有什么问题。您可能出于某种原因想要将数组分成两部分。有多种方法可以做到这一点,但最简单(尽管不是最快的)方法是使用 Linq,如下所示:
int[] firstHalf = myBuffer.Take(50).ToArray();
int[] secondHalf = myBuffer.Skip(50).ToArray();
现在,我们在堆上有三个数组。一个是原始数组,其余的是两个新数组。这会消耗很多内存。即使我没有提到复制所有这些数据所带来的性能损失。
可能你需要一份副本。如果是这样,那么这是一个好的方法。然而,如果你只需要分割,那么你应该使用Span<T>。这个类是你给它提供的内存的视图。它不是复制;它只是原始数据的窗口。
那段代码看起来是这样的:
var firstHalf = new Span<int>(myBuffer, 0, 50);
var secondHalf = new Span<int>(myBuffer, 50, 50);
这个代码示例不会复制数据或分配新的数组。它只是给你一个数据的视图。
当然,如果原始数组被垃圾回收,span 将指向无效的内存。
在这里,Memory<T>大致相同,但当你使用异步操作时更好。此外,span 始终位于堆栈上。因此,你不能在类中将 span 作为字段(记住,类是引用类型,所以它们的所有数据都存储在堆上)。相比之下,Memory<T>可以在堆上使用,这样你就可以将它们作为类的字段使用。
-
避免装箱:值类型速度快且内存效率高,只要它们保持为值类型。正如我们之前讨论的,值类型突然有了变成引用类型的讨厌习惯。我们称这个过程为装箱。装箱比简单的值类型占用更多的内存。因此,尝试意识到这些情况并在可能的情况下避免它们。
-
使用延迟初始化:如果你创建了一个复杂类的实例,你可能不需要在构造函数中初始化所有字段。有时,只在需要时这样做更好。这种方式被称为延迟初始化:尽可能推迟初始化。
-
System.IO.Compression。这个命名空间包含许多帮助您压缩和解压数据的类。 -
卸载不必要的数据:你可以选择移除那些你不需要一直保留的数据。然后,当你需要它时,你可以按需重新加载它。如果你有大量数据集并且并不总是需要它们,那么这样做可能值得。
-
WeakReference<T>引用。这意味着你告诉 GC 如果需要就移除对象。让我给你展示一下我的意思:var myObject = new object(); var myObjectReference = new WeakReference<object>(myObject); // Much further in the code, we might need myObject if (myObjectReference.TryGetTarget(out var retrievedObject)) { // Do something with retrievedObject } else { // We need to recreate myObject myObject = new object(); myObjectReference.SetTarget(myObject); }首先,我们创建一个名为
myObject的对象实例。然后,我们获取它的弱引用。假设在我们的代码中稍后我们还需要myObject。首先,我们询问WeakReference对象是否仍然可用或 GC 是否已经收集了它。如果它可用,我们可以使用它。否则,我们重新创建它并将新的指针存储在WeakReference中。非常巧妙。 -
紧凑的对象表示:有时,通过将数据智能地组合到其他数据结构中,你可以节省一些内存。让我给你展示一下。我们可以用以下方式表达客户可能拥有的三种状态:
bool customerHasPayed= false; bool customerHasCredit = true; bool customerPaymentIsLate = true;在这里,
bool通常在内部用字节表示。因此,这需要 3 个字节。
我们可以将其重写如下。首先,我们创建一个新的enum值:
[Flags]
enum CustomerPaymentStatus : byte
{
CustomerHasPayed = 1 << 0,
CustomerHasCredit = 1 << 1,
CustomerPaymentIsLate = 1 << 2
};
我用来分配值的表示法让我想起了我在序列中的位置:通过左移,我可以轻松地对项目进行编号(0、1和2)。
位移动
在系统编程中,我们经常处理位和字节。因此,你应该了解这种表示法。
<< 运算符将一个字节的全部位向左移动一位,实际上是将值乘以 2。所以,1 << 0 不移动任何位,1 << 1 将所有位移动一位,结果为值 2,而 1 << 2 将位移动两位,结果为 4。在二进制中,结果是 00000001、00000010 和 00000100。
我们可以这样设置一个变量:
CustomerPaymentStatus customerStatus =
CustomerPaymentStatus.CustomerHasCredit &
CustomerPaymentStatus.CustomerPaymentIsLate;
我们有与第一个例子中相同的信息,但这次我们只使用了一个字节。这减少了 66% 的内存使用率!
-
null允许它们被清理。由于 CLR 将大对象存储在很少清理的 LOH 上,将它们设置为null可以使 GC 在那里清理它们。 -
考虑使用静态类:实例类在其成员和其数据之间有许多指针来回移动。这些指针和成员数据可能会占用额外的内存。使用静态类消除了这种开销。节省可以相当显著。
在这一点上,我想重申,对于系统开发者来说,尽可能提高内存效率非常重要。我刚才与您分享的技巧和窍门应该成为您开发风格的一部分。节省内存可以释放 GC 的时间,并使您的程序加载和通常执行得更快。这有助于为用户提供更好的体验。当然,这些技巧和窍门可以应用于各种 C# 编程。每个程序都可以使用更好的内存管理。然而,对于不安全代码和指针来说,情况并非如此。这些是大多数开发者很少会遇到的话题。然而,作为系统程序员,我们可能无法避免它们。因此,我认为我们应该花些时间来研究它们。
C# 中的不安全代码和指针
如果您担心内存,您可以接管 CLR 和 GC,并自己完成所有操作。我不建议这样做,但有时您别无选择。尽管编译器、CLR 和 GC 做了惊人的事情,但它们并不能总是预测您试图实现什么或您的限制是什么。特别是对于系统开发者来说,这有时可能会阻碍您实现目标。在这种情况下,您可能不得不自己管理内存。我认为这里应该举一个例子。
让我们从一个非常简单的类开始:
[MessagePackObject]
public class SimpleClass
{
[Key(0)]
public int X { get; set; }
[Key(1)]
public string Y { get; set; }
}
MessagePackObject 和 Key 属性来自 MessagePack NuGet 库。
MessagePack 库是一个工具,它使您能够将类的实例序列化和反序列化成二进制表示。另一个流行的序列化格式是 JSON,它在内存效率方面远不如二进制格式。这就是为什么我们在这里使用二进制格式的原因。
我已经编写了两个方法:一个用于序列化,一个用于反序列化。序列化器先来:
public static byte[] SerializeToByteArray(SimpleClass simpleClass)
{
byte[] data = MessagePackSerializer.Serialize(simpleClass);
return data;
}
这相当简单。我们获取一个对象,并将其传递给 MessagePackSerializer 静态类的 Serialize 方法。这将返回一个 byte[] 值,我们将其返回给此方法的调用者。
当然,这也需要进行反序列化:
public static SimpleClass DeserializeFromByteArray(IntPtr ptr, int length)
{
byte[] data = new byte[length];
Marshal.Copy(ptr, data, 0, length);
var simpleClass = MessagePackSerializer. Deserialize<SimpleClass>(data);
return simpleClass;
}
此方法稍微复杂一些:我们获取一个内存块的指针和我们的数据长度。我们创建一个正确大小的byte[]值。然后,我们将堆中的内存复制到字节数组中,以便我们可以使用MessagePackSerializer类进行反序列化。然后,返回我们得到的对象。
我们可以使用以下方式使用这些方法:
var simpleClass = new SimpleClass()
{
X = 42,
Y = "Systems Programming Rules!"
};
var memory = IntPtr.Zero;
try
{
byte[] serializedData =
MemoryHandler.SerializeToByteArray(simpleClass);
memory = Marshal.AllocHGlobal(serializedData.Length);
Marshal.Copy(serializedData, 0, memory,
serializedData.Length);
SimpleClass deserializedSimpleClass =
MemoryHandler.DeserializeFromByteArray(
memory,
serializedData.Length);
}
finally
{
Marshal.FreeHGlobal(memory);
}
在这里,我们创建了一个SimpleClass的实例并给它一些数据。
然后,我们使用我们之前讨论的新的SerializeToByteArray方法来序列化该对象。这给我们一个包含原始数据的byte[]值。然后,我们在堆上分配我们想要存储数据的内存。我们复制数据。然后,我们可以丢弃simpleClass实例:它可以被垃圾回收。
注意,GC 永远不会清理我们刚刚分配的内存。我们的数据存储在我们的内存中。
如果我们要使用它,我们需要再次进行反序列化,这可以通过调用DeserializeFromByteArray来实现。我们提供分配的内存的指针和占用的大小。
当然,我们还需要在完成时释放内存。GC 不会为我们做这件事。我们对此负责。
在这个例子中,我们只用了 29 字节来存储数据,这并不多。如果需要,我们可以分配这些内存,并在我们决定时释放它们。这是处理我们系统内存的一种非常快速和高效的方式。
警告
不要使用BinaryFormatter来做这件事。尽管使用BinaryFormatter要简单得多,但它本质上是不安全的。你最好使用我这里展示的MessagePack,或者使用基于 JSON 的序列化和反序列化器。更多信息,请参阅aka.ms/binaryformatter。
我们可以更进一步。使用指针算术,我们可以手动将所有数据复制到我们的内存块中。由于指针算术是不安全的,我们需要通过使用unsafe关键字并将项目选项设置为允许不安全来告诉编译器我们想要这样做,正如我们在上一章末尾所讨论的。
序列化和反序列化保持一致。反序列化更简单。将比特存储到我们内存中的代码略有不同。然而,整个代码运行更快且更节省内存。下面是代码:
var pointer = IntPtr.Zero;
try
{
byte[] serializedData = MemoryHandler. SerializeToByteArray(simpleClass);
pointer = Marshal.AllocHGlobal(serializedData.Length);
unsafe
{
// copy the data using pointer arithmetic
byte* pByte = (byte*)pointer;
for (int i = 0; i < serializedData.Length; i++)
{
*pByte = serializedData[i];
pByte++;
}
//deserialization is done here
byte[] deserializeData = new byte[serializedData.Length];
pByte = (byte*)pointer;
for (int i = 0; i < serializedData.Length; i++)
{
deserializeData[i] = *pByte;
pByte++;
}
var deserializedObject = MessagePackSerializer. Deserialize<SimpleClass>(deserializeData);
}
}
finally
{
Marshal.FreeHGlobal(pointer);
}
我们以使用MessagePack获取我们对象的二进制表示的方式开始。但不是使用Marshal.Copy(),我们自行复制字节。我们有一个指向数据开始的指针;我们取第一个字节,将其复制到我们分配的内存块中,增加指针,然后重复此操作,直到复制整个数据。
反序列化工作方式相同。我们获取我们分配的内存块的指针,现在它包含我们的数据。我们读取第一个字节,将其复制到数组中,然后重复,直到完成。
然后,我们通过调用MessagePackSerializer.Deserialize()方法进行反序列化,该方法接受一个类型,我们给它一个包含所有字节的数组。
再次强调,这是一种快速且高效的内存处理方式,但它确实伴随着许多风险。记住,一个小错误可能会让你的日子变得一团糟。
不安全代码和在你的代码中使用指针可以大大加快速度。但我想要确保你理解其影响:你正在接管 CLR 的所有控制权。你需要负责确保你的程序运行良好且安全。确保当你选择这条路线时,你知道自己在做什么。如果你这样做,在速度和内存效率方面会有很多好处!
下一步
我希望你能记住我们讨论的大多数内容,但以防万一你忘记了,我们将再次过一遍最关键的点。
首先,我们讨论了 CLR 和 GC 如何协同工作以减轻内存管理的痛苦。我们探讨了 GC 的工作原理,世代的意义,以及 LOH 的作用。
我们还讨论了终结器以及为什么它们可能会影响你的性能。我们还看到,当你使用IDisposable模式时(只要你不忘记调用GC.SupressFinalize(this)来移除不必要的终结器),它们确实有存在的理由。
然后,我分享了一些你可以使用的技巧来优化你的内存使用,如果你需要你的系统中使用最少的内存量。
我想重申关于内存优化的一个关键点。在 99 个案例中,CLR 和 GC 都做得非常出色。试图超越它们并不总是能导致更好的系统。这些工具背后的团队在他们的领域里很擅长,他们使用了书中所有的技巧(以及一些书中没有的技巧!)来帮助你减轻内存压力。
作为系统程序员,你可能会遇到 GC 和 CLR 工作得不够好的情况,这时这里讨论的主题就能帮到你。但请务必非常小心。管理内存如果出错可能会导致奇怪甚至灾难性的后果。
在调整内存使用之前,你应该测试和基准测试你的代码。但如果你遵循我的建议和忠告,你可以得到非凡的结果!然而,一旦你的系统中有多线程,事情就会变得复杂得多。我们需要讨论线程。很多。这正是我们将在下一章中要做的!
第四章:线程缠结的问题
并发 和线程
线程和并发是大多数开发者认为他们已经全部了解的东西。理论听起来很简单,但在实践中,线程是许多错误发生的地方,也是所有那些令人沮丧的 bug 的起源。线程可能相当复杂,但 BCL 和 CLR 团队的人们已经尽他们所能帮助我们,使事情尽可能简单。
一旦你掌握了它,线程就是你技能的一个很好的补充,并且可以在你的系统中产生重大影响。
在本章中,我们将探讨以下主题:
-
并发和线程是什么?
-
线程在.NET 和 Windows 内部是如何工作的?
-
CLR 是如何帮助我们的?
-
async/await 是什么?
-
我们如何同步线程并使它们协同工作?
-
我如何确保我的代码在处理线程时表现良好?
-
我如何使用线程上的集合?
让我们深入了解这个迷人的主题!
技术要求
本章中所有的源代码和示例都可以从本书的 GitHub 仓库下载,网址为github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter04。
并发和线程——基础知识
今天早上,我像往常一样醒来。我起床,洗了个澡,然后穿好衣服。然后我遛狗 30 分钟(今天是星期天)。我回到家,泡了些咖啡,然后坐下来写这篇文档。
我敢肯定,你的日常活动在总体上看起来是一样的。你做一件事,然后做下一件事。事情按顺序完成。有时,当我遛狗时,我会给其他时区的人打电话,但大多数时候,我一次只做一件事。这样更有效率。如果我坐下来写这一章,但五分钟后停下来遛狗,然后让我跑回家写五分钟,再跑回去接狗再走 500 米,事情永远也完成不了。我会因为来回跑而得到锻炼,但这会很低效。
这是一种愚蠢的生活方式(没有评判;如果你这样做,我对此表示理解,但这对我来说不起作用)。
然而,在计算机的情况下,我们倾向于认为这种工作方式可以使工作更快地完成。我们为什么这么想?
计算机不能同时做两件事。不,等等。让我换个说法。CPU 核心不能同时做两件事。在 2005 年 AMD 发布了Athlon 64 X2 处理器之前,以及同一年 Intel 发布了 Pentium D 之前,普通的计算机都是单核的。这意味着在 2005 年之前,计算机通常一次只能做一件事。
现在,大多数设备都有多个核心。你的电脑、笔记本电脑和手机都有多核处理器。然而,作为系统程序员,你可能会遇到只有单个核心的设备。想想物联网设备:它们需要便宜且功耗非常低。这些系统通常只有一个核心。系统程序员遇到单核设备比编写其他软件的人更常见。
然而,最终,这并不重要。我的主要开发机器有 16 个核心。这听起来很多。然而,如果我看我的任务管理器,我可以看到许多事情同时运行,远超过这 16 个核心可以处理的。所以,即使在多核环境中,机器也必须做些事情来使所有这些任务得以运行。作为系统程序员,我们必须意识到如何编写我们的软件,以从这些核心中获得最大利益。
因此,我们在这里处理两个独立的话题。一个是并发;另一个是线程。
并发是一种概念,即系统在重叠的时段内执行多个操作序列。这并不是真正的同时执行;那被称为并行。它完全是关于任务在看似相同的时间运行,而不需要等待其他任务。这是一个概念,而不是一种编程技术。
另一方面,线程是程序员的结构。线程是实现并发的一种方式。
值得了解
线程可以是硬件线程或软件线程。CPU 处理第一种类型;第二种类型在我们的软件中处理。操作系统(OS)可以将线程分配给实际的硬件线程,但作为开发者,你几乎总是要处理软件线程。在这里,我主要会谈论软件线程,但当我指的是硬件线程时,我会指出这一点。
并发的起源——中断请求(IRQ)
目前,让我们忽略这样一个事实:除了将负载分散到 CPU 可能拥有的物理核心之外,计算机无法进行多任务处理。为了简化问题,我们将假设计算机可以同时做两件事。
这并不总是如此。在早期,计算机一次只做一件事。这意味着如果你为计算机编写了一些软件,你就完全控制了所有可用的硬件。一切都是你的,而且是你的独有。
嗯,当我说那是你的,我的意思是那主要是你的。有时,会发生一些需要 CPU 注意的事情。在那些日子里,我们有一种叫做中断请求(IRQ)的东西。中断请求是一种通常与硬件相关的硬件功能。一个外部设备,如软盘驱动器或调制解调器,可以通过在 CPU 的特定连接上施加电压来向 CPU 发出信号。当发生这种情况时,CPU 会完成它正在执行的指令,将所有状态存储在内存中,查找属于那个中断请求的地址(可能有多个),然后在该地址处启动代码。当该功能完成时,整个过程会逆转:CPU 将加载之前存储的状态,并继续执行原始代码,就像什么都没发生一样。
这种机制工作得相当不错,但存在许多潜在问题。例如,只有少数几个中断请求线路可用。如果你的代码覆盖了附加到某些硬件的另一段代码的注册,那么该硬件将无法正常工作。
更糟糕的是,如果你犯了一个愚蠢的错误,你的代码从未从中断请求中返回,你可能会使整个机器停止运行。它将简单地永远不会从你的代码中返回,并且正在运行的程序将无限期地挂起。因此,你必须非常小心,确保你的代码中没有这样的错误!
中断请求(IRQs)今天仍在使用,尤其是在像 Raspberry Pi 这样的低功耗设备中。我们将在本书的后面遇到它们。
协作式和抢占式多任务处理
中断请求(IRQs)工作得还可以,但它们应该由硬件设备使用。由于中断请求的数量并不多,并且它们有杀死正在运行进程的潜在能力,所以我们已经不再在常规软件中使用它们。
然而,拥有计算机却只能用它做一件事情,这似乎是一种资源的浪费。计算机变得越来越强大。它们很快就能做比我们要求它们做的事情更多的事情。那时,多任务操作系统就出现了。
例如,Windows 95 之前的 Windows 版本,如 Windows 3.1,使用了一种叫做协作式多任务处理的东西。原理相当简单。一段代码会做某事,当它认为可以休息一下时,它就会告诉操作系统:“嘿,我在休息;如果你需要我做些什么,请告诉我。”然后它会停止执行。这意味着操作系统可以将 CPU 时间分配给另一个进程。
我们称之为协作式多任务处理,因为我们期望软件能够合作并公平地共享资源。
当然,如果一个程序行为不当,它仍然可以声称所有的 CPU 时间,从而阻止其他软件按预期运行。
需要一种更好的方法。Windows NT 3.1 以及后来的 Windows 95 做得更好:它们引入了抢占式多任务处理。
这个想法很简单:为进程分配一些运行时间,当时间到了,就存储该进程的状态,将其停放某处,然后继续下一个进程。当原始进程再次需要做某事时,操作系统将程序重新加载到内存中,并恢复状态,然后进程可以继续。除非进程跟踪时钟,否则进程对它休眠的时间一无所知。
进程不能再声称所有可用的 CPU 时间。如果其时间已用完,操作系统将暂停该进程。
预先多任务处理仍然是现代操作系统今天的工作方式。
然而,所有这些都涉及在计算机上同时运行的多个进程。我们如何让一个进程同时做多件事情呢?好吧,一个解决方案就是使用线程。
C#中的线程
线程是一个概念,它允许计算机在您的程序中同时做更多的事情。就像操作系统允许多个程序同时运行一样,线程允许您的程序在应用程序中并发运行多个流程。线程不过是您程序中的一个执行流程。您始终至少有一个线程:当程序开始执行时启动的那个线程。我们称之为主线程。运行时管理这个线程,您对其控制很少。然而,所有其他的线程都是您的,您可以随意对它们进行操作。
线程并不是什么神奇的东西。基本原理很简单:
-
创建一个您想要运行的方法、函数或任何其他代码片段。
-
创建一个线程,给它传递方法的地址。
-
启动线程。
-
操作系统或运行时在运行主线程的同时执行那个方法或函数。
-
您可以监控那个线程的进度。您可以等待它结束,或者您可以使用一种“发射后不管”的策略,只需让它完成其工作即可。
您如何执行这些步骤取决于您想使用哪个版本。您是选择.NET 方式还是选择我们熟知的 Win32 API 的兔子洞?
在.NET 中,线程由一个实际的类(或者更准确地说,是一个类的实例)表示。在 Win32 中,它们只是由 Win32 API 创建的东西。
Win32 线程
在 Win32 中,您使用CreateThread API 创建线程。我想向您展示它是如何工作的,但我要坦白:您可能永远不会在您的代码中这样做。使用 Win32 API 创建线程的方法有很多更好的选择。尽管如此,在某些情况下,完全控制 Win32 线程可能是必要的。
让我向您展示如何在 Win32 API 中完成这个操作。
我们将首先声明一个delegate。这个delegate是包含线程执行的工作的函数的形式:
public delegate uint ThreadProc(IntPtr lpParameter);
由于我们正在调用 Win32 API,我们需要导入它们:
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr CreateThread(
IntPtr lpThreadAttributes,
uint dwStackSize,
ThreadProc lpStartAddress,
IntPtr lpParameter,
uint dwCreationFlags,
out uint lpThreadId
);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern uint WaitForSingleObject(IntPtr
hHandle, uint dwMilliseconds);
我们将导入三个 API:CreateThread、CloseHandle和WaitForSingleObject。
在我们可以使用这些 API 之前,我们必须编写执行有用操作的代码。在这种情况下,这并不是真正有用的代码,但这是将在线程中执行的代码:
public uint MyThreadFunction(IntPtr lpParameter)
{
for (int i = 0; i < 1000; i++)
Console.WriteLine("Unmanaged thread");
return 0;
}
这个 MyThreadFunction 函数与之前定义的委托相匹配。
在清除所有这些之后,我们可以创建线程,并让我们的程序执行某些操作。或者更确切地说,它可以同时执行许多操作。下面是操作步骤:
public void DoWork()
{
uint threadId;
var threadHandle = CreateThread(
IntPtr.Zero,
0,
MyThreadFunction,
IntPtr.Zero,
0,
out threadId
);
// Wait for the thread to be finished
WaitForSingleObject(threadHandle, 1000);
// Clean up
CloseHandle(threadHandle);
}
DoWork() 方法通过调用 CreateThread Win32 API 创建线程。此 API 有一些参数。让我借助 表 4.1 来解释它们的作用:
| 参数 | 描述 |
|---|---|
| IntPtr lpThreadAttributes | 安全属性结构的指针 |
| uint dwStackSize | 此线程所需的堆栈大小 |
| ThreadProc lpStartAddress | 线程运行的函数的指针 |
| IntPtr lpParameter | 指向传递给线程的变量的指针 |
| uint dwCreationFlags | 确定线程创建方式的附加标志 |
| out uint lpThreadId | 一个输出参数,包含线程的 ID |
表 4.1:CreateThread Win32 API 的参数
安全属性定义了谁或什么可以访问线程以及此线程可以使用什么。安全属性相当复杂。在这里,我们不会深入探讨它们,主要是因为在处理线程时它们并不常用。在这里,我们将安全属性设置为 IntPtr.Zero。
dwStackSize 参数定义了线程使用的堆栈大小。如前所述,每个线程都有自己的堆栈,可以在其中存储其值类型。当线程完成后,此堆栈将被回收。
然后,我们获取线程启动时将执行的函数指针。在 C#中,我们可以传递方法的名称,让编译器完成找出内存地址的繁琐工作。
在提供方法启动地址后,我们得到一些更有趣的东西:我们可以将数据传递到线程方法中。lpParameter 参数是指向数据所在内存的指针。除非你想使用简单的 Int32,否则将数据放入线程中相当繁琐。毕竟,IntPtr 是一个 32 位值,所以你可以将一个 int 转换回转换以获取线程函数中的数据。这里我没有传递任何东西,但稍后在本章中我会向你展示如何做到这一点。
接下来是定义系统如何创建线程的标志。除了默认的 0,表示“不执行特殊操作”之外,我们可以使用两个标志。这些标志在 表 4.2 中解释。
| 标志 | 值 | 含义 |
|---|---|---|
| 0 | 0x00000000 | 不执行特殊操作 |
CREATE_SUSPENDED |
0x00000004 | 创建线程,但立即挂起而不是启动。 |
STACK_SIZE_PARAM_IS_A_RESERVATION |
0x00010000 | 如果设置此标志,则堆栈大小是预留的。如果没有设置,则堆栈大小是已提交的。 |
表 4.2:线程创建选项
CREATE_SUSPENDED创建线程,但在创建时将其置于挂起状态。默认行为是立即运行lpStartAddress指向的代码。
STACK_SIZE_PARAM_IS_A_RESERVATION是一个有趣的标志。这个标志是您可能想要使用 Win32 线程创建版本而不是.NET 版本的主要原因之一。每个线程都有自己的栈。您可以指定该栈应该有多大,但当你这样做时,发生的所有事情只是系统保留该内存。这种保留是一个快速操作。保留只是告诉系统您希望在某个时候使用这么多内存。如果系统没有足够的内存来满足您的请求,您将收到错误。
然而,内存尚未提交。提交意味着操作系统为您请求的内存保留,并将其标记为被进程使用。保留只是告诉它您希望在以后使用该内存。
页面错误
当您的应用程序请求内存或尝试从系统访问内存时,可能会发生某些事情。
第一种情况发生在内存已在您的栈或堆中可用时。您获得了该内存的指针;现在它完全属于您了。
如果内存尚未在您的栈或堆中,但系统上可用,接下来会发生什么。这会导致软页面错误。系统会将新内存添加到当前的栈或堆中。
接下来,可能您想要访问的内存不在您计算机的内存芯片中。在这种情况下,它可能已经被交换到磁盘上。这是一个硬页面错误。操作系统将从磁盘加载内存并将其添加到您的工作集。
页面错误对于增加系统的灵活性非常有用。然而,它们会带来很大的性能损失。
当您保留内存并想要访问它时,可能会发生页面错误。当这种情况发生时,您的应用程序性能将下降。
如果您提交内存,则它在需要时保证可用。这使得您的内存占用更大、速度更快,因为您不会遇到页面错误。
您必须在这里做出选择:您更喜欢两种场景中的哪一种?您可以通过STACK_SIZE_PARAM_IS_A_RESERVATION标志来控制栈。
代码示例以两个语句结束:WaitForSingleObject()和CloseHandle()。本章中的同步线程部分更详细地解释了WaitForSingleObject()。不过,简短的描述如下:在主线程继续之前等待线程完成。
CloseHandle清理所有已使用的资源。是的,这是一个未管理资源。这是一个使用IDisposable模式的好地方。
.NET 线程
.NET BCL 中的线程使用起来要简单得多。当然,当某件事被简化时,您通常会因此牺牲灵活性。
以下示例显示了如何使用.NET 结构执行与 Win32 线程相同的工作。
我们将从线程函数开始,它在新的线程上运行。它几乎与 Win32 示例相同。以下代码片段显示了我们要在线程内运行的代码:
void MyThreadFunction()
{
for (var i = 0; i < 1000; i++)
Console.WriteLine("Managed thread");
}
在我们的代码的主体中,我们创建线程,给它运行的功能,然后启动它:
var myManagedThread = new Thread(MyThreadFunction);
myManagedThread.Start();
myManagedThread.Join();
我们创建Thread类的新实例,并在构造函数中传递我们想要使用的方法。然后我们启动它。然后,我们使用Join()等待它,实际上暂停了主线程,直到我们的新线程完成它正在做的事情。
就这样。如果你与 Win32 版本进行比较,我确信你会欣赏这种简单性。
然而,不要被这种简单性欺骗:这并不意味着你不能控制你的线程。你可以控制它们,并且你可以做比我所展示的更多的事情。例如,你也可以指定你想要为你的线程使用的堆栈大小:
var myHugeStackSize = 8 * 1024 * 1024; // 8 MB
var myManagedThread = new Thread(MyThreadFunction, myHugeStackSize);
在这里,我们为新线程分配了 8 MB 的堆栈。
很高兴了解
32 位应用程序的默认堆栈大小为 1 MB;对于 64 位应用程序,为 4 MB。你很少需要超过这个大小。只有在测试了你的应用程序并发现你确实需要它时,才应该请求大堆栈。
在 Win32 示例中,我们必须明确指出我们想要创建一个处于挂起状态的线程。如果我们没有这样做,它将立即启动。在.NET 中,情况不同。在.NET 中创建的新线程被认为是未启动。这意味着它不会立即启动。它也还没有挂起;在行为上存在相当大的差异。
一个挂起的线程已经完全形成,并被放置在操作系统的调度器列表中。它的堆栈已分配,所有资源都存在。
一个Thread类。堆栈尚未分配,它还没有被分配给操作系统,因此它还没有在调度器上,等等。
当我们在.NET 线程上调用Start()时,运行时会做所有这些工作。创建线程比 Win32 中的CreateThread()调用要快得多,但当你启动线程时,这种性能提升就丢失了。把它想象成懒加载初始化。
CLR 的设计者利用了这一点。如果创建线程相对便宜,而只有在使用它们时才变得昂贵,为什么不将创建的负担移到程序开始时呢?启动应用程序需要时间;如果我们稍微延长这一点,那就没关系了。然而,这意味着当它在使用时,我们有一个更快的系统。当我们需要一两个线程时,我们可以有一个线程池可用。这正是他们所做的事情。
一个例子可能会使这更清晰。然而,在我能向你展示那之前,我们必须做一些修改。我们想要创建许多同时运行的线程。为了区分每个线程的输出,我们需要向那个线程传递一些数据,以便它可以显示它。因此,我们需要有存储数据的地方。
我们需要不可变数据,原因将在本章后面讨论线程安全性时变得清晰。C# 9 中添加的record是一个实现这一点的绝佳方式:
internal record ThreadData(int LoopCounter);
我们现在可以开始编写在特定线程中执行的方法:
void MyThreadFunction(object? myObjectData)
{
// Verify that we have a ThreadData object
if (myObjectData is not ThreadData myData)
throw new ArgumentException("Parameter is not a ThreadData object");
// Get the thread ID
var currentThreadId = Thread.CurrentThread.ManagedThreadId;
// Write the data to the Console
Console.WriteLine(
$"Managed thread in Thread {currentThreadId} " +
$"with loop counter {myData.LoopCounter}");
}
线程获取一个Nullable<object>类型的参数。我们不能将其声明为其他类型,因为这是运行时所期望的。
要使用这些数据,我们需要将其转换为正确的类型。
然后,我们将获取当前线程的 ID。每个线程都有一个唯一的 ID,因此我们可以与之交互,尽管我们在这里只会显示它。
让我们创建一些线程:
for (int i = 0; i < 100; i++)
{
ThreadData threadData = new(i);
var newThread = new Thread(MyThreadFunction);
newThread.Start(threadData);
}
Console.ReadKey();
我们将创建一百个线程,并在创建后立即启动它们。我们将给它们一些数据,以查看我们在循环中的位置。
在循环之后,我添加了Console.ReadKey(),以确保在所有线程完成之前程序不会退出。当你运行程序时启动的主线程是特殊的:如果它结束,CLR 将结束整个程序并卸载所有内存。所以,保持你的主线程活跃直到你确信所有工作都完成是至关重要的。在实际场景中,你不会使用Console.ReadLine()来做这件事,但在这个演示中,它工作得很好。
如果你运行这个程序,你可能会看到线程 ID 随着循环计数器的增加而增加。它们并不相等。CLR 在你运行循环之前已经创建了一打或更多的线程。
如果你将循环增加到执行更多的迭代次数,你最终会看到线程 ID 偶尔相同。CLR 会重用线程以避免线程饥饿。
然而,我承诺要向你展示线程池。将代码中我们原本的 for 循环部分替换为以下代码:
for (int i = 0; i < 100; i++)
{
ThreadData threadData = new(i);
ThreadPool.QueueUserWorkItem(MyThreadFunction, threadData);
}
Console.ReadKey();
我们将在这里使用线程池,在需要时从池中取出线程。如果你运行这个程序,你会反复看到相同的线程 ID。线程从池中被取出并使用正确的数据启动。当线程完成时,它会关闭,其资源会被释放,然后被放回池中,以便在需要时再次使用。
负载很小,优势巨大。使用这种系统的系统效率更高。
ThreadPool隐藏了许多你可以使用的秘密和技巧,但它的使用在很大程度上已经被任务并行库(TPL)所取代,它为你处理了大部分工作。让我们看看。
任务和并行库 – TPL
TPL 已经存在了一段时间。它是在 2010 年随着.NET 4.0 的发布而引入的。
TPL 简化了我们过去用线程做的许多事情。线程仍然有其位置,尤其是在处理第三方库时。然而,在大多数情况下,我们可以让 TPL 来处理这些事情。
在 TPL 中,Task类是主要的操作类。Task是一个在需要时处理线程实例化的类。它做得多得多,但我们稍后再讨论。
我说“当需要时”,因为它是足够智能的,能够确定何时需要一个新的线程。
让我们从简单的例子开始,然后逐步深入:
Task myTask = Task.Run(() => { Console.WriteLine("Hello from the task."); });
Console.WriteLine("Main thread is done.");
Console.ReadKey();
Task只是另一个 C#类,它为我们处理了大部分并发。在这种情况下,我们调用static method Run(),它接受一个委托来执行。
我们可以将其重写如下:
Task myTask = Task.Run(DoWork);
Console.WriteLine("Main thread is done.");
Console.ReadKey();
return 0;
void DoWork()
{
Console.WriteLine("Hello from the task.");
}
这个代码片段做的是同样的事情,但我们调用方法而不是使用 lambda 表达式。
我们可以用稍微不同的方式做到同样的事情:
Task myTask = new Task(DoWork);
myTask.Start();
我省略了Console相关的内容和实际的方法;它们将保持不变(直到我说我已经改变了它们)。
这段代码基本上与上一个示例做的是同样的事情。区别在于Task不会启动,除非我们明确调用Start()。
第二个示例为你提供了对任务更多的控制。你可以在启动任务之前设置属性和改变任务的行为。Task.Run()主要设计用于“发射后不管”的场景。Start()更加灵活;它允许我们改变调度,例如,指定它在一个特定的线程上运行。你也可以这样指定Task的优先级。
这个例子并不非常吸引人。让我们尝试让它变得更有趣。我们可以将我们的方法改为以下内容:
void DoWork(int id)
{
Console.WriteLine($"call Id {id}.");
}
我们将在我们的方法中添加一个参数来识别调用者。由于我们现在有一个参数,我们必须也改变如何将这个参数传递给Task构造函数。让我们不要止步于此。想象一下,我们想要链式调用方法。在Task完成使用Id 1的DoWork之后,我们希望它再次调用那个方法,但这次使用Id 2。在现实生活中,你可能会链式调用两个完全不同的方法,但工作方式是相同的。
代码看起来是这样的:
Task myTask = new Task(() => DoWork(1));
myTask.ContinueWith((prevTask) => DoWork(2));
myTask.Start();
我们已经更改了构造函数中的参数,以便我们可以将那个1整数传递给方法。下一行更有趣。它说:“当你完成第一步后,再次调用DoWork,但这次使用Id 2。”prevTask参数是已经完成工作的前一个Task。这触发了第二个Task的开始。
如果你运行这个程序,你会看到控制台按正确顺序打印的行。
让我们重写一次被调用的方法:
void DoWork(int id)
{
Console.WriteLine($"call Id {id}, " +
$"running on thread " +
$"{Thread.CurrentThread.ManagedThreadId}.");
}
我们将这个方法运行的线程的id添加到输出中。我还想在开始任务之前看到这个id线程。我们的调用代码现在看起来是这样的:
Console.WriteLine($"Our main thread id =
{Thread.CurrentThread.ManagedThreadId}.");
Task myTask = new Task(() => DoWork(1));
myTask.ContinueWith((prevTask) => DoWork(2));
myTask.Start();
如果你运行这个程序,你可能会看到任务在不同的线程上运行,而不是主线程。如果你重复几次,甚至可能发生第二个任务在第一个任务不同的线程上运行的情况。这种情况何时发生是不可预测的;调度器会根据当前条件选择最佳方案。我们不必担心这个问题。它只是正常工作。这不是很酷吗?
TPL 中另一个很棒的类是Parallel类。它允许我们并行执行操作。让我们看看这个:
Console.WriteLine($"Our main thread id =
{Thread.CurrentThread.ManagedThreadId}.");
int[] myIds = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
Parallel.ForEach(myIds, (i) => DoWork(i));
首先,我们将打印当前线程的 id。然后,我们将创建一个从 1 到 10 的整数数组;这里没有什么特别的。之后,我们将调用 Parallel 类的静态 ForEach 方法,并给它提供数组和要调用的 lambda 表达式。该方法并行地遍历数组,并使用正确的参数调用 lambda 表达式。它这样做的方式不是顺序的,而是与标准的 ForEach 循环不同。
当你运行这个程序时,你会看到一些令人兴奋的结果。程序打印 ID 的顺序完全是随机的。你会看到运行时使用了多个线程,但有时它会重用其中的一些线程。
再次强调,TPL 确定最佳做法,并为你处理所有线程的创建和调度。
TPL 非常强大。它也是 async/await 模式的支柱。这是一个简化并发工作的模式,以至于大多数用户都没有意识到幕后发生了什么。凭借你新获得的知识,你应该没有问题跟踪正在发生的事情。那么,让我们来看看 async/await。
Async/await
软件几乎从不孤立运行。大多数软件需要在某个时刻超出其边界并访问代码块之外的东西。例如,读取和写入文件、从网络读取数据、向打印机发送数据等等。假设一台典型的机器可以在大约 10 纳秒内访问内存中的一个字节。从 SSD 读取相同的字节大约需要 1,000,000 纳秒或更长。从外部设备读取数据通常比从内存中读取本地数据慢 100,000 到 1,000,000 倍。当你尝试优化代码时,如果你知道你的软件在将数据传输到外部硬件和从外部硬件传输数据时,请考虑这一点。
让我们更进一步。让我们假设你有一台处理数据速度相当快的机器。你需要从外部网站读取数据。你的程序必须等待很长时间,数据才可用。数据到达我们这里可能需要毫秒。对我们这些普通人来说,这已经很快了,但计算机在这段时间内本可以做一百万件其他任务。这似乎是对我们昂贵的资源的巨大浪费,对吧?
线程当然可以帮助我们。你可以创建一个线程来调用外部网站,并等待它完成,同时做其他事情。然而,正如我们所看到的,线程可能会相当繁琐。TPL 有所帮助,但事情仍然可能变得复杂。从外部源读取数据或将数据写入外部目标如此常见,以至于 CLR 设计者决定通过引入 async/await 来帮助我们。
自顶向下的方法很简单:任何耗时超过简单操作的事情都应该异步执行。然而,我们不想直接处理线程本身。Async/await,它内部使用 TPL,是一种可以帮助我们的模式。
它所做的是这样:一旦你有需要异步运行的代码,编译器就会注入代码,将我们的代码包装到一个状态机中。这个状态机跟踪线程和我们的代码的进度,并在需要关注的代码块之间来回切换。
这听起来很复杂吗?嗯,确实是。然而,使用方法是直接的。然而,在我向你展示它之前,我想先介绍一个小小的辅助代码,我经常在讨论 async/await 时使用。这段代码只是对 string 类的一个扩展方法,并将一个 string 输出到控制台,并添加 ManagedThreadId。它甚至允许对输出进行着色,使得区分不同的线程更容易。如果你想使用这个,请随意。如果你宁愿在所有地方都使用 Console.WriteLine(),那也请随意。然而,使用这个可以使代码的关键部分更容易阅读。以下是我的扩展方法:
using static System.Threading.Thread;
namespace ExtensionLibrary;
public static class StringExtensions
{
public static string Dump(this string message, ConsoleColor printColor = ConsoleColor.Cyan)
{
var oldColor = Console.ForegroundColor;
Console.ForegroundColor = printColor;
Console.WriteLine($"({CurrentThread.ManagedThreadId})\t : {message}");
Console.ForegroundColor = oldColor;
return message;
}
}
你也可以在 GitHub 仓库中找到这段代码。
首先,我想给你展示一个最简单的例子:
using ExtensionLibrary;
DoWork();
// The program is paused until DoWork is finished.
// This is a waste of CPU!
"Just before calling the long-running DoWork()"
.Dump(ConsoleColor.DarkBlue);
"Program has finished".Dump(ConsoleColor.DarkBlue);
Console.ReadKey();
void DoWork()
{
"We are doing important stuff!".Dump(ConsoleColor.DarkYellow);
// Do something useful, then wait a bit.
Thread.Sleep(1000);
}
想象一下,我们想在 DoWork() 方法中做一件需要很长时间的事情,比如从存储中读取文件。我在这里通过暂停当前线程一秒钟来模拟这一点。当我们在这个主方法中调用它时,整个程序都会暂停。我们昂贵的强大 CPU 被闲置(至少不是为我们程序)。这似乎很浪费!我们已经看到我们可以使用线程或 TPL 来改进这一点。然而,这段代码也被包装在 async/await 模式之中,所以为什么不使用这个呢?
要做到这一点,我将 Thread.Sleep() 替换为对 Task.Delay() 的调用。这基本上做了同样的事情,但允许我们改进我们的代码。记住:这个 Thread.Sleep() 和新的 Task.Delay() 方法只是我们应用程序应该做的实际工作的替代品。在代码中有一个 Sleep() 或 Delay() 方法通常是一个坏主意。
如果你必须调用一个异步方法,你必须等待它。所以,我们在调用 Task.Delay() 之前添加了 await 关键字。
一旦我们完成了替换,我还会在方法前加上 async 关键字。这个关键字告诉编译器应该将这个方法包装在我之前提到的状态机中。然而,任何异步方法都不应该返回 void,原因将在稍后变得清楚。我们需要返回一个 Task 或 Task<>,如果你实际上返回了某些内容。所以,我们将我们的 void 改为 Task。同样,任何异步方法都需要用 await 关键字来调用。所以,结果看起来像这样:
using ExtensionLibrary;
"Just before calling the long-running DoWork()"
.Dump(ConsoleColor.DarkBlue);
await DoWork();
// The program is no longer paused until DoWork is finished.
// This allows the CPU to keep working!
"Program has finished".Dump(ConsoleColor.DarkBlue);
Console.ReadKey();
async Task DoWork()
{
"We are doing important stuff!".Dump(ConsoleColor.DarkYellow);
// Do something useful, then wait a bit.
await Task.Delay(1000);
}
运行这个看看会发生什么。
你可能会看到程序从一个线程开始,然后在同一个线程上执行 DoWork() 方法,但完成之后会切换到新的线程。这是因为编译器看到了我们的 Task.Delay() 等待操作,并决定释放 CPU 去做其他事情。运行时将我们的当前线程挂起,并将其状态存储在内存中,这样我们的主代码就可以自由地做其他事情。只有当 Task.Delay() 完成后,我们的主线程才会被恢复。然而,由于主线程不再与我们的代码相关联,我们需要一个新的线程。这个线程是从 ThreadPool 中拉取的(记住:那里很快,因为线程是在启动时创建的),并填充了我们之前的状态。然后系统可以继续在这个线程上运行。程序也是在那个新线程上结束的!
我提到所有异步方法都需要 async 修饰符,并应该返回一个 Task 而不是 void。这样做有一个简单的理由。如果你不这样做,你的代码会工作,但不会像预期的那样。异步一直到底规则很简单,但非常重要。
异步一直到底!
如果你有一个包含 await 关键字的方法,那么这个方法必须是异步的,并返回一个 Task。然而,由于你可能会在某个地方自己调用这个方法,所以调用代码也必须是异步的,并返回某种形式的 Task 或 Task<>。因为这个方法也会被调用……嗯,你明白这个意思。规则是:异步一直到底!链中的每个方法都需要有异步!
另一条规则,它不像“异步一直到底”规则那样严格,是所有异步方法都应该这样命名。我们的 DoWork() 方法应该重命名为 DoWorkAsync()。
然而,在我们这样做之前,让我们看看如果我们粗心大意,没有返回一个 Task 会发生什么。试试看:将 Task 返回类型替换为 void,并在 DoWork() 之前移除 await(你不能等待 void,所以如果你不移除它,你会得到一个错误)。
运行它。它运行得很好,对吧?好吧,没有创建新的线程,但谁在乎呢?软件做了它需要做的事情。
现在,让我们稍微修改一下我们的 DoWork() 方法:
using ExtensionLibrary;
"Just before calling the long-running DoWork()"
.Dump(ConsoleColor.DarkBlue);
DoWork();
"Program has finished".Dump(ConsoleColor.DarkBlue);
//Console.ReadKey();
async void DoWork()
{
"We are doing important stuff!"
.Dump(ConsoleColor.DarkYellow);
await Task.Delay(1000);
throw new Exception(
"Something went terribly wrong."
);
"We're done with the hard work."
.Dump(ConsoleColor.DarkYellow);
}
我也暂时移除了 ReadLine(),使程序更贴近现实。主线程在一切完成后结束。
运行它。看到我们没有得到 “我们已经完成了艰苦的工作” 的消息。这是有道理的;它前面有一个异常。然而,请注意,我们也没有看到那个异常。
为什么会这样?这很复杂,但简化的解释是,由于DoWork仍然是一个异步方法,状态机仍然被创建。异常是在不同的线程上抛出的(在Task.Delay()等待之后)。然而,由于状态机没有配置为等待所有结果(因为我们省略了await关键字),它只是忽略了那个线程。如果你将那个“我们已经完成了艰苦的工作”的Dump()行移动到异常之前的行,你会看到它没有被调用。实际上,它确实被调用了;你只是没有看到它。这个线程已经变成了一个“发射并遗忘”的线程。你失去了对它的所有控制。
你能想象一个复杂的软件,其中在代码深处出了问题吗?你能想象没有获取到异常吗?你能想象调试那个的恐怖吗?
如果你一路使用 async/await,你会得到那个异常。
哦,在我忘记之前:我移除Console.ReadKey()行的原因是我这样做迫使主线程尽快退出,从而卸载应用程序从内存中。如果你恢复那行代码,你会看到异常,因为主线程在那里暂停。现在其他事情将被允许发生。
然而,这并不是我们问题的真正解决方案。你不想在获取异常之前等待主线程空闲。这可能需要很长时间才能发生。
请恢复 async/await 关键字,将DoWork()中的 void 替换为Task,然后运行它。异常正是在你预期的地方抛出的。
这真的很重要,所以我喜欢重复一遍:从上到下都是异步的!
Task.Wait()和 Task.Result
关于为什么不应该使用Task.Wait()或Task.Result有很多博客文章和文章。这个原因很简单:这些调用会阻塞当前线程。使用它们会移除调度器在Task完成时恢复调用线程工作并返回执行流程的能力。如果你这样做,为什么还要使用 async/await 呢?Async/await 还允许线程同步,因此不需要使用Wait()和Result。
等一下。有些情况下,你可能会决定仍然使用它们:
-
如果你正在对正在现代化的遗留代码进行工作,你可能想使用它们。规则是“从上到下都是异步的”,这可能需要大量的代码重构。这并不总是可行的。在这些情况下,你可能使用
Wait()和Result代替。 -
在单元测试中,你可以模拟或存根异步方法。然而,有时单元测试使用
Wait()和Result可能更好。 -
在系统编程中,你可能不关心主线程保持响应。毕竟,没有用户界面。所以,阻塞主线程可能不是一个大问题。我仍然认为不使用 async/await 是不好的做法,但在这种情况下,你可以用
Wait()和Result来解决问题。
就像软件开发中的所有规则一样,对这些规则保持警惕,尽可能多地应用它们,只有在你有充分的理由并且深思熟虑之后才打破这些规则。此外,请为你未来的自己行个方便,在源代码中记录你选择偏离常规工作方式的原因。
因此,现在你知道如何使用 async/await。尽管它们并不总是导致多线程,但它们是平衡应用程序负载的绝佳方式。它们在保持代码组织方面大有裨益。你无需再承担在线程之间进行所有同步的负担。然而,这并不意味着你永远不需要关心同步。这是不可避免的,所以我认为我们现在应该讨论一下。
线程同步
async/await 模式让我们的开发者生活变得更加容易。如果你有一个长时间运行的任务(记住:任何使用 CPU 之外的设备的事情都是长时间运行的),你可以异步调用该方法。然后你可以坐下来等待它完成,而不会阻塞应用程序其他地方的执行。TPL 负责线程管理。
然而,有时你可能想要有更多的控制权。你可能遇到这样的情况,你必须等待一个方法完成才能继续。想象一下,你有一个主线程并调用 A() 方法。该方法运行时间较长,因此你将其改为异步(将其重命名为以“async”结尾的名称)并更改返回类型为 Task 或 Task<>。现在你可以等待它。然而,另一个线程可能必须等待你的 Aasync() 方法完成。你如何做到这一点?
欢迎来到线程同步的奇妙世界。
同步——我们如何做到这一点?
在过去,当我们仍然使用线程和 ThreadPool 时,同步可能会很麻烦。然而,随着 Task 和 async/await 的出现,事情变得容易多了,而且没有真正的缺点。在我向你展示这一点之前,我想先展示如何同步线程而不是任务。
让我从基础程序开始:
using ExtensionLibrary;
"In the main part of the app.".Dump(ConsoleColor.White);
ThreadPool.QueueUserWorkItem(DoSomethingForTwoSeconds);
ThreadPool.QueueUserWorkItem(DoSomethingForOneSecond);
"Main app is done.\nPress any key to
stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
void DoSomethingForOneSecond(object? notUsed)
{
$"Doing something for one second.".Dump(ConsoleColor.Yellow);
Thread.Sleep(1000);
$"Finished something for one second".Dump(ConsoleColor.Yellow);
}
void DoSomethingForTwoSeconds(object? notUsed)
{
"Doing something for two
seconds.".Dump(ConsoleColor.DarkYellow);
Thread.Sleep(2900);
"Done doing something for two
seconds.".Dump(ConsoleColor.DarkYellow)
}
这个示例现在应该很清楚。我有两个方法,它们执行一些需要很长时间才能完成的事情。我从 ThreadPool 中拉出一些线程,并使所有这些同时运行。
如果你运行这个,在 Console.ReadKey() 位置。如果我们想在继续之前等待两个方法完成,我们能做什么?
答案是使用同步机制。这意味着我们有一个对象,我们可以用它来标记某些状态。我们可以自己编写它,但我们必须注意很多同步和线程安全问题。幸运的是,我们不必这样做。Win32 API 提供了一些工具,它们被巧妙地封装在 BCL 类中。
其中之一是 CountdownEvent 类。正如其名所示,它允许我们进行事件倒计时。
将你的主方法修改如下:
"In the main part of the app.".Dump(ConsoleColor.White);
// Tell the system we want to wait for 2 threads to finish.
CountdownEvent countdown = new(2);
ThreadPool.QueueUserWorkItem(DoSomethingForOneSecond);
ThreadPool.QueueUserWorkItem(DoSomethingForTwoSeconds);
// Do the actual waiting.
countdown.Wait();
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
我们将创建一个 CountdownEvent 类的新实例并将其初始化为 2。
然后,我们将获取线程并允许它们完成工作。
在方法中的代码,我添加了一行:
void DoSomethingForOneSecond(object? notUsed)
{
$"Doing something for one second.".Dump(ConsoleColor.Yellow);
Thread.Sleep(1000);
$"Finished something for one second".Dump(ConsoleColor.Yellow);
countdown.Signal();
}
在方法底部,你会看到 .Signal() 倒计时。由于这个实例在这个方法中是可访问的,我可以使用它。Signal() 告诉倒计时减少等待事件的数量。
我对 DoSomethingForTwoSeconds() 方法也做了同样的处理。
这意味着当两个方法都完成后,它们会在倒计时上调用 Signal()。在主方法中,我在 ThreadPool 代码之后添加了 countdown.Wait(),告诉主线程暂停,直到倒计时达到零。
如果你运行这个程序,你会看到它运行得非常出色,并且主线程的其他部分与线程完美同步。
然而,如果我想在 DoSomethingForOneSecond 完成后启动 DoSomethingForTwoSeconds 方法呢?
这几乎同样简单。我们可以使用其他同步类之一来帮助我们。让我展示如何使用 ManualResetEvent 来完成这个操作。这个类或多或少与 CountdownEvent 类似。区别在于 ManualResetEvent 类不计数,它只是等待信号。
在主方法中,在调用 ThreadPool 之前,我添加了这一行:
ManualResetEvent mre = new(false);
我将其设置为初始的 False 状态。这样做会导致任何线程等待事件被设置。
在 DoSomethingForOneSecond() 中,我在最后添加了一行:
// Tell the second thread it can start
mre.Set();
对 Set 的调用告诉 ManualResetEvent 任何等待的线程都可以继续。
在 DoSomethingForTwoSeconds() 中,我在方法的开头添加了以下内容:
// Wait for the first thread to finish.
mre.WaitOne();
WaitOne() 告诉代码暂停线程,直到 mre 收到信号(这发生在 DoSomethingForOneSecond() 的末尾)。
如果你现在运行你的程序,你会注意到一切都很完美地同步,等待其他任务完成。
当然,你可能通过不使用线程就达到了完全相同的结果。我们基本上从我们的应用程序中移除了所有多任务。如果你需要同步线程,现在你知道如何做了。然而,要小心:如果你出错,可能会引入奇怪的错误。相信我:调试多线程应用程序可不是件轻松的事。
使用 async/await 进行同步
到现在为止,你可能已经猜到了,使用 async/await 可以显著降低处理线程和它们之间同步的复杂性。
让我们回到 DoSomethingForOneSecond 和 DoSomethingForTwoSeconds 方法的例子。这次,我们将重新编写它们以使用 async/await。
你的 DoSomethingForOneSecond 应该是这样的:
async Task DoSomethingForOneSecondAsync()
{
$"Doing something for one second.".Dump(ConsoleColor.Yellow);
await Task.Delay(1000);
$"Finished something for one second".Dump(ConsoleColor.Yellow);
}
我将函数重命名,以异步结尾,因为我应该早就这样做。
DoSomethingForTwoSecondsAsync() 应该得到同样的处理。
在主方法中调用这些方法现在看起来是这样的:
"In the main part of the app.".Dump(ConsoleColor.White);
await DoSomethingForOneSecondAsync();
await DoSomethingForTwoSecondsAsync();
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
结果与我们在自己进行所有同步的示例中得到的相同,唯一的区别是我们不再有阻塞的线程。所以这不仅更容易做,而且也更好。
然而,如果我们不想按顺序执行这些方法,而是想让它们同时运行会怎样?如果我们想让它们同时运行会怎样?
好吧,这很简单。由于我们的方法返回一个 Task,我们可以处理它。我们不是逐个等待它们,而是可以同时等待它们。让我给你展示一下:
var task1 = DoSomethingForOneSecondAsync();
var task2 = DoSomethingForTwoSecondsAsync();
// Wait for all tasks to be finished
Task.WaitAll(task1, task2);
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
我们将利用我们获取任务的事实。Task 类有一个名为 WaitAll() 的静态方法,它只有在所有任务都完成时才返回。
还有其他方法,例如 WaitAny(只有当任何任务完成时才继续),WhenAll(当它们都完成时执行某些操作),以及 WhenAny(你可以自己弄清楚)。
WaitAll 或 WaitAny 与 WhenAll 或 WhenAny 之间的区别在于 WaitXXX 是一个阻塞调用。它会阻塞当前线程,直到条件满足。WhenXXX 返回一个 Task 本身,你可以 await 它,因此不会阻塞线程。
然而,这里有一个更大的区别:WhenAll 允许你捕获返回的结果。如果你想要等待完成的任何任务返回一个结果,你可以通过 WhenAll 获取它。WhenAll 将结果以数组的形式返回给你。你可以访问它们,这是 WaitAll 或 WaitAny 无法做到的。
如果你有所怀疑,WhenAny 返回一个 Task<T>。这个 Task<T> 有一个名为 Result 的属性;你可以读取这个属性来获取对那个 Task 结果的访问。这是一个使用 Result 实际上是一件好事的例子!
取消线程
有时候,你可能想要停止一个线程的运行。这样做可能有几个很好的理由,但无论你的理由是什么,一定要清理自己的“战场”。线程使用起来成本很高,将它们留在未知状态是一种糟糕的做法:你有一天会自食其果。
在 .NET Framework 的日子里,Thread 类有一个名为 Abort() 的方法。然而,结果证明这个方法弊大于利,因此 BCL 和 CLR 的人决定废除它。如果你尝试中止一个线程,你会得到一个 PlatformNotSupportedException。我想他们真的不希望我们再使用它了。
停止正在运行的线程的最佳方式与停止正在运行的 Task 的方式相同:使用我们称之为协作取消的东西。调用线程可以请求另一个线程停止。这取决于第二个线程是否遵守那个请求——或者不遵守。没有保证。
做这件事的标准方式是使用一个 CancellationToken。CancellationToken 是我们用来表示我们想要取消某事的信号的对象。
当然,你可以自己编写这个类。除了线程安全之外,没有太多的事情发生。然而,在你的线程或任务中包含一个 CancellationToken 可以清楚地让用户知道它可以被取消。
我将稍微重写我们的 DoSomethingForOneSecondAsyncMethod():
async Task DoSomethingForOneSecondAsync()
{
$"Doing something for one second.".Dump(ConsoleColor.Yellow);
for(int i=0;i<1000;i++)
await Task.Delay(1);
$"Finished something for one second".Dump(ConsoleColor.Yellow);
}
代替使用Task.Delay(1000)调用,我使用1000 await Task.Delay1)。从理论上讲,这将导致一秒的延迟。然而,当你运行这个时,它实际上会花费更长的时间。await 调用本身也会占用一些时间。
我可以测量它花费的时间然后重新计算迭代次数,或者我可以简单地将方法重命名为DoSomethingForSomeUnderterminedAmountOfTimeAsync()。我将把这个决定留给你。
假设我们在主方法中等待了 500 毫秒后感到无聊,并决定停止这个线程。我们该如何实现这一点?
这就是CancellationToken发挥作用的地方。再次强调,CancellationToken是一个简单的类。如果你想创建一个,当然可以,但最好使用一个专门的类。这个CancellationTokenSource类就是为此而创建的,并且能在各种奇怪的情况下工作。它是线程安全的。
让我们在主方法的开始处创建一个:
using ExtensionLibrary;
"In the main part of the app.".Dump(ConsoleColor.White);
using var cts = new CancellationTokenSource();
var task1 = DoSomethingForOneSecondAsync();
Task.WaitAny(task1);
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
我在这里使用WaitAny是因为我们想在创建任务后和它完成前取消该任务。同时,注意using语句。CancellationTokenSource实现了IDisposable接口,因此我们必须遵守这一点。
取消很简单。在var task1和Task.WaitAny()行之间,添加以下内容:
await Task.Delay(500);
"We got bored. Let's cancel.".Dump(ConsoleColor.White);
cts.Cancel();
我们会等待一会儿,然后感到无聊并调用cts.Cancel()。
然而,如果你运行这个,什么都不会发生。这并不完全正确;会有很多事情发生。更准确地说,DoSomethingForOneSecondAsync中的整个循环都会发生。
CancellationToken并不是取消正在运行的任务的魔法方法。你必须自己检查这个令牌。
我们必须给我们的方法添加一个CancellationToken类型的参数。现在方法签名将看起来像这样:
async Task DoSomethingForOneSecondAsync(CancellationToken cancellationToken)
我们在调用它时必须传入这个令牌。在我们的主方法中,将调用此方法的行更改为以下内容:
var task1 = DoSomethingForOneSecondAsync(cts.Token);
我们将获取我们的CancellationTokenSource并获取它的令牌。这就是我们将传递给我们的方法的。
在我们的方法内部,我们必须检查是否需要取消。是的,这就是为什么我在Delay周围添加了循环。现在完整的方法将看起来像这样:
async Task DoSomethingForOneSecondAsync(CancellationToken cancellationToken)
{
$"Doing something for one second.".Dump(ConsoleColor.Yellow);
bool hasBeenCancelled = false;
int i = 0;
for (i = 0; i < 1000; i++)
{
if (cancellationToken.IsCancellationRequested)
{
hasBeenCancelled = true;
break;
}
await Task.Delay(1);
}
if(hasBeenCancelled)
{
$"We got interrupted after {i} iterations.".Dump(ConsoleColor. Yellow);
}
else
{
$"Finished something for one second".Dump(ConsoleColor. Yellow);
}
}
如果有人调用CancellationTokenSource的Cancel,令牌上的IsCancellationRequested标志将被设置为True。我们必须尊重这一点。我通过跳出for循环来实现这一点。我还设置了一个hasBeenCancelled变量,这样我就可以通知我们的用户我们已经取消了循环,并告诉他们是在多少次迭代后取消的。
我们本可以跳过这个布尔值并再次使用IsCancellationRequested。然而,可能存在这样的风险:请求是在循环完成后、打印消息之前到达的。在这种情况下,循环没有被中断。但我们还是说它被中断了,这是不正确的。这样我们就可以避免打印错误的消息。
运行它并看看会发生什么。在我的机器上,它大约迭代了 40 次后就会取消。
这段代码中有一个错误。将CancellationToken传递给任何接受它的方法是一种良好的实践。在我们的例子中,那就是Task.Delay()。它有一个接受CancellationToken的重载。
我故意在这里省略了它。因为代码几乎 100%的时间都会在那个行上,等待 Delay,我们会取消它,永远不会看到任何打印的消息。然而,现在让我们添加它:
await Task.Delay(1, cancellationToken);
重新运行它并看看会发生什么。
你可能会注意到我们缺少了很多屏幕输出。原因很简单。Task.Delay()在取消时抛出OperationCancelledException。然而,我们在主方法中没有在Task上使用await,所以我们会错过这个异常。记得我之前说过,当一切没有做对的时候,错过异常是多么容易吗?
同步有助于防止错误发生。然而,有许多技术可以确保我们的代码是线程安全的。现在让我们深入探讨这些技术。
线程安全编程技术
看看这段代码。运行它并看看会发生什么:
using ExtensionLibrary;
int iterationCount = 100;
ThreadPool.QueueUserWorkItem(async (state) =>
{
await Task.Delay(500);
iterationCount = 0;
$"We are stopping it...".Dump(ConsoleColor.Red);
});
await WaitAWhile();
$"In the main part of the app.".Dump(ConsoleColor.White);
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
async Task WaitAWhile()
{
do
{
$"In the loop at iterations {iterationCount}". Dump(ConsoleColor.Yellow);
await Task.Delay(1);
}while (--iterationCount > 0) ;
}
我们有一个从 100 倒数到 0 的Task。由于我们在这里await它,代码的主要部分会优雅地等待这个操作完成后再继续。然而,我们还有一个等待 500 毫秒后设置计数器为0的第二个线程。结果是循环提前结束。
我们看到的是容易调试的。每一行代码都在一个屏幕上,所以我猜想你将能够很容易地找到这个错误。
然而,如果这里使用的整数是类的一个成员呢?正如你所知,类的实例是引用类型。引用类型是通过引用传递的,而不是通过值。所以如果 Task 可以访问那个实例,它可以改变那个实例的成员。然而,其他任何任务、线程或代码都会看到这些变化的效果。
线程安全就是避免这些情况。
值类型本质上是安全的。如果你将一个整数值类型等值类型传递给Task,你将不会有任何问题。整数的值被复制,你并没有改变原始值。
然而,如果你需要访问更复杂的数据类型,你需要考虑这一点。好消息是运行时为我们提供了几个工具来减轻这个问题。
你得到的一个工具是Lock()关键字。
Lock()
保护你的数据最简单的方法是在它周围使用锁。锁是一个对象,它在一定程度上就像是一段代码的护城河。锁确保只有一个线程可以同时进入那个代码块。语法很简单:
lock (new object())
{
iterationCount--;
}
锁接受一个参数。它使用这个参数来识别要锁定的区域。它对这个对象不做任何事情;这只是为了将锁钩到某个地方。所以,有一个新的object()就足够了。
代码块中的任何代码都是安全的,这意味着只有一个线程可以同时递减iterationCount。当另一个线程尝试同时执行相同的操作时,它会在到达锁语句时立即阻塞。那个线程会一直阻塞,直到之前的线程退出代码块。
是的,这意味着如果其他线程在那个代码中崩溃(在这个例子中不太可能:在--运算符上崩溃非常罕见),整个系统永远无法进入那个代码块。
Lock()是围绕监视器对象的一种语法糖。编译器实际上使用Monitors。所以,以下代码会产生相同的中间语言(IL):
var lockObject = new object();
Monitor.Enter(lockObject);
try
{
iterationCount--;
}
finally
{
Monitor.Exit(lockObject);
lockObject = null;
}
我不知道你,但对我来说,lock()语句看起来要轻松得多。
记录
确保数据不会意外覆盖的最佳方式是确保数据不能被更改。不可变类型正是为此而设计的。
让我先创建一个记录:
record Counter(int InitialValue);
记录是一种引用类型,所以它的内存是在堆上分配的。然而,记录旨在是不可变的。你可以创建不可变的记录,但这在这里没有帮助。
目前,我有一个只有一个成员InitialValue的记录。在构建Counter时,我必须设置该值,但之后我永远不能更改它。所以,没有线程可以再来修改这个值了。
然而,由于我无法在任何地方更改它,我也必须在Task中的代码进行更改。现在它看起来是这样的:
async Task WaitAWhile()
{
var actualCounter = myCounter.InitialValue;
do
{
$"In the loop at iterations {actualCounter}". Dump(ConsoleColor.Yellow);
await Task.Delay(1);
} while (--actualCounter > 0);
}
我已经复制了值并在循环中递减它。如果你和我有点相似,你可能会说,“等等。为什么我没有只是将原始的iterationCount复制到一个局部变量中,并使用它而不是这个记录?”
我看到很多人这样做。然而,这并不保证能行得通。如果你在复制之前另一个线程改变了iterationCount的值怎么办?你将从一个错误的初始值开始。
不可变记录保证其内部值永远不会改变,永远如此。你很安全。
避免使用静态成员和类
我知道创建类的实例可能会很麻烦。有时,似乎更容易创建一个静态类,其中包含静态成员,并使用它们代替。它们确实有一些用例。然而,请记住这一点:静态类默认不是线程安全的。静态成员在多个线程之间共享,因此任何人都可以更改它们。
使用volatile关键字
有时,代码看起来很简单,但可能并非如此。看看这一行:
int a=42;
我们知道这是如何工作的。这个整数位于栈上。如果我们更改其值,该内存地址的值也会改变。简单,对吧?错了。编译器会使用各种技巧来优化我们的代码,尤其是在你以发布模式构建时。以发布模式构建意味着编译器可能会缓存简单整数的值以加快速度。它甚至可能决定将这一行移动到代码的另一个位置,如果它认为这不会影响执行。
这不是问题,直到多个线程或任务处理这段代码。编译器可能会出错。它无法确定哪些任务可以同时访问该变量。
是的,即使在多线程系统中简单地写入一个整数也可能出错。
如果我们使用lock(),我们可以保证只有一个线程可以访问那个代码块,但这仍然不能减轻编译器优化的影响。
为了解决这个问题,我们可以使用volatile关键字。它看起来是这样的:
private static volatile int _initialValue = 100;
与使用缓存值不同,编译器确保我们始终直接访问内存地址和存储的值。这意味着所有线程都将前往同一位置并使用相同的整数,从而消除了在旧、过时或缓存数据上工作的风险。
你可能会想将volatile关键字添加到每个地方,但我建议你避免这样做。它会干扰编译器的优化技术。你应该只在怀疑特定代码片段可能存在问题时使用它。
因此,现在你知道如何在与线程打交道时更加安全。这非常重要:如果你搞砸了,你可能会在代码中遇到可怕且难以调试的错误。这尤其适用于你在多个线程中处理集合的情况。你是如何保持它们同步的?幸运的是,BCL 已经为我们解决了这个问题。让我们来谈谈并发集合。
.NET 中的并发集合
集合是许多程序的核心。数组、列表、字典——我们经常使用它们。然而,它们是线程安全的吗?让我们来了解一下:
using ExtensionLibrary;
var allLines = new List<string>();
for(int i = 0; i < 1000; i++)
{
allLines.Add($"Line {i:000}");
}
ThreadPool.QueueUserWorkItem((_) =>
{
Thread.Sleep(1000);
allLines.Clear();
});
await DumpArray(allLines);
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
async Task DumpArray(List<string> someData)
{
foreach(var data in someData)
{
data.Dump(ConsoleColor.Yellow);
await Task.Delay(100);
}
}
我们有一个List<string>。然后我们向该列表中添加了 1000 个字符串。我们有一个任务会遍历它们,将它们显示在屏幕上,并稍作等待。
我们还有一个单独的线程在等待一秒后清除列表。
如果你已经阅读了本章前面的部分,你可能会预期任务中的循环会提前终止。它不应该打印所有项目,因为列表突然为空,因此ForEach()停止。
然而,如果你运行它,你会看到不同的结果。你会得到一个友好的InvalidOperationException,告诉你集合已被修改,这破坏了ForEach代码。
BCL 中的集合不是线程安全的。如果一个线程正在处理它们,而另一个线程决定需要处理那个集合,事情就会出错。
以下集合不是线程安全的,在处理任务时应避免使用:
-
List<T> -
Dictionary<TKey和TValue> -
Queue<T> -
Stack<T> -
HashSet<T> -
ArrayList -
HashTable -
SortedList<TKey,TValue>, 和TSortedList
不要在多个线程或任务中同时使用这些。
一些集合是线程安全的。以下是它们的用途:
| 集合名称 | 描述 |
|---|---|
ConcurrentDictionary<TKey, TValue> |
一个线程安全的键值对集合。它允许并发添加、更新和删除。 |
ConcurrentQueue<T> |
一个线程安全的先进先出(FIFO)集合版本。 |
ConcurrentStack<T> |
一个线程安全的后进先出(LIFO)集合版本。 |
ConcurrentBag<T> |
一个线程安全、无序的对象集合。适用于顺序不重要的场景。 |
BlockingCollection<T> |
表示一个可以限制大小的线程安全集合。它提供阻塞和非阻塞的add和take操作。 |
表 4.3:线程安全集合
前四个集合只是我们已知的集合的线程安全版本。然而,大多数人可能不会认出最后一个:BlockingCollection<T>集合。
这个集合首先是一个线程安全的集合。它还允许阻塞。让我给你举个例子:
using ExtensionLibrary;
using System.Collections.Concurrent;
// We have a collection that blocks as soon as
// 5 items have been added. Before this thread
// can continue, one has to be taken away first.
var allLines = new BlockingCollection<string>(boundedCapacity:5);
ThreadPool.QueueUserWorkItem((_) => {
for (int i = 0; i < 10; i++)
{
allLines.Add($"Line {i:000}");
Thread.Sleep(1000);
}
allLines.CompleteAdding();
});
// Give the first thread some time to add items before
// we take them away again.
Thread.Sleep(6000);
// Read all items by taking them away
ThreadPool.QueueUserWorkItem((_) => {
while (!allLines.IsCompleted)
{
try
{
var item = allLines.Take();
item.Dump(ConsoleColor.Yellow);
Thread.Sleep(10);
}
catch (InvalidOperationException)
{
// This can happen if
// CompleteAdding has been called
// but the collection is already empty
// in our case: this thread finished before the
// first one
}
}
});
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
这里发生了很多事情,让我带你了解一下。
首先,我们创建了一个BlockingCollection的实例。这个类有一个很好的重载构造函数,它只允许添加这个数量的项目。如果有更多,则阻塞线程。我这里不需要这个功能,但我发现添加它很有趣。
然后我们启动了一个新线程,向这个集合添加项目。我们可以尝试添加 10 个,但同样,它只允许五个项目。所以,当第五个项目被添加时,这个线程会阻塞,直到我们移除其中一个项目。
在循环结束时,我们告诉集合我们没有剩下要添加的内容。我们通过调用CompleteAdding()来完成这个操作。
在我们读取第二个线程中的数据之前,我们等待了几秒钟,以便第一个线程有时间填充集合。
第二个线程(如果你也计算主线程的话是第三个)从这个集合中取出了一个项目。它是一个先进先出(FIFO)的集合,所以我们可以取出的第一个项目是列表中第一个添加的项目。我们展示了我们取出的内容,并等待了一段时间。我们需要捕获InvalidOperationException。如果在我们已经从集合中取出所有项目之后,由于时间原因调用了CompleteAdding,将会发生异常。我们需要捕获这个异常。
由于我们的时间安排和Thread.Sleep()调用,我们将看到一个迷人的效果。第一个线程用五个项目填充集合。然后它等待。这个操作总共需要五秒钟。程序开始后的六秒钟,我们将开始取项目。由于项目很多(确切地说有五个),程序将快速将这些项目打印到屏幕上。当我们取一个项目时,第一个线程获得添加新项目的权限。然而,由于添加一个项目需要一秒钟,第二个线程必须等待直到项目被添加。如果还没有可取的项目,Take()也会阻塞。
只有当第一个线程调用CompleteAdding()方法时,第二个线程才知道它已经完成(因为我们检查了IsCompleted属性)。然后,我们可以退出线程。
在幕后有许多同步操作,但它工作得非常出色。这无疑是您工具箱中的一个优秀补充!
下一步
这真是一次相当刺激的经历。线程编程可能很复杂,但我们成功地完成了所有这些。
在本章中,我们探讨了众多不同的事情。我们描述了多任务是什么,从老式的中断请求(IRQs)开始,经过协作多任务,最终到达现代的抢占式多任务。
然后,我们研究了 Win32 线程及其.NET 对应物。我们看到了如何创建线程,但很快发现Threadpool在大多数情况下提供了更好的方法。然而,我们了解到大多数这些都是多余的,因为 TPL 为我们处理了很多细节。
特别是,我们了解到 async/await 隐藏了很多复杂性,使得编写多线程代码变得轻而易举。就像所有工具一样,我们也了解到 async/await 伴随着风险。您必须知道会发生什么,以及坏事情可能发生在哪里。幸运的是,我们也涵盖了那些情况。
我们探讨了集合以及如何使您的代码线程安全。我们还学习了一些关于 async/await 的基本知识:从底层到顶层的 async!
异步编程在处理 CPU 外部的设备时是必不可少的。我们需要广泛使用这些技术的领域之一是文件系统。然而,文件系统还有很多其他您需要了解的事情。所以,下一章处理这个主题真是太好了!
第五章:文件系统编年史
文件系统 和 IO
计算机是令人难以置信的机器,但它们有一个缺点。如果电源关闭,它们会忘记一切。如果我们不希望丢失我们的工作,我们必须将其存储在其他地方。我们可以打印数据,将其放在网络上,或者存储在永久存储中。这是最常见的选项。当然,我们需要一种方法将数据输入 CPU。我们可以从文件或网络中读取数据。我们甚至可以使用键盘输入数据。这是我们(程序员)和我(作者)都非常熟悉的事情。
当我们编写软件时,我们提到 流 的概念。流表示随时间提供的一系列数据元素。这个序列可以存储在磁盘上,可以是通过网络线缆流动的数据,也可以是内存芯片的状态。无论我们使用什么物理介质,数据都必须来回流动。这一章处理这个主题,涵盖流、文件以及其他 输入和输出(IO)的方式。
在本章中,我们将不会深入探讨的主题是网络。网络是一个如此不同的概念,以至于将有一个单独的章节来处理这个主题。您可以在 第八章 中找到所有低级网络细节。然而,通过网络处理数据的概念对于文件和其他媒体是相同的。因此,这里阐述的原则仍然适用。
在本章中,我们将涵盖以下主题:
-
如何使用 .NET 处理文件
-
如何使用 Win32 API 与文件系统交互
-
如何处理目录和路径
-
为什么以及如何使用异步 IO
-
如何使用加密和压缩
我们有很多内容要覆盖,所以让我们深入探讨吧!
技术要求
要查看本章中的所有代码,您可以访问以下链接:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter05。
文件写入基础
没有什么比写入文件更直接的了,对吧?这就是为什么我认为这是一个好的起点。以下是实现这一点的代码:
var path = System.IO.Path.GetTempPath();
var fileName = "WriteLines.txt";
var fullPath = Path.Combine(path, fileName);
File.WriteAllText(fullPath, "Hello, System Programmers");
第一行获取系统 temp 路径。然后我们指定文件名,将其添加到 temp 路径中,并向该文件写入一行文本。
这个例子足够简单,但它已经展示了某些有用的内容。首先,我们可以快速访问 temp 文件夹;我们不需要在我们的代码中指定它的位置。其次,我们可以组合文件名和路径,而不用担心路径分隔符。在 Windows 上,路径的部分由反斜杠分隔,而在 Linux 上,这是一个正斜杠。CLR 会确定应该使用什么,并使用正确的一个。
File.WriteAllText 然后使用这些数据创建一个文件,打开它,写入字符串,然后关闭文件。如果文件已经存在,系统将覆盖它。
如果我们想要使用临时文件名而不是 WriteLines.Text,代码可以更加简单:
var path = System.IO.Path.GetTempFileName();
File.WriteAllText(path, "Hello, System Programmers");
系统会查找 temp 文件的路径,生成一个具有唯一文件名的新的文件,并使用该文件写入字符串。缺点是现在我们不知道它是哪个文件。我们必须将其记录在某个地方;否则,我们的 temp 文件夹会很快填满未使用的文件(尽管大多数操作系统都会清理 temp 文件夹,所以这里没有真正的担忧)。
您当然可以使用任何您想要的文件夹。然而,如果您想使用一些特殊文件夹,例如 Windows 上的 Documents 文件夹,系统也可以帮助您访问这些文件夹。看看以下代码片段:
var path = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var fileName = "WriteLines.txt";
var fullPath = Path.Combine(path, fileName);
File.WriteAllText(fullPath, "Hello, System Programmers");
这段代码查找我的机器上 My Documents 的位置,并返回该位置,以便我可以将文件写入该位置。您可以从一个很长的特殊位置列表中进行选择,所有这些位置都是 SpecialFolder 枚举的一部分。我不会列出所有这些位置;您可以在以下链接中找到它们:learn.microsoft.com/en-us/dotnet/api/system.environment.specialfolder?view=net-8.0。
这种写文件的方式毫不费力。然而,正如我们之前多次看到的,方便往往伴随着控制力的减少。作为系统程序员,我们希望获得尽可能多的控制权。让我们夺回一些控制权。
FileStream
静态的 File 类易于使用,如果您想快速向文件写入或从文件读取内容,它非常方便。然而,它并不是最快的方式。至少,如果我们谈论执行时间,它不是最快的。作为系统程序员,我们非常关注速度,即使这意味着放弃编码的便利性。
以下示例比之前的示例快约 20%,但它执行的是相同的事情。它只需要几行额外的代码:
var fileName = Path.GetTempFileName();
var info = new UTF8Encoding(true).GetBytes("Hello, System Developers!");
using FileStream? fs = File.Create(fileName, info.Length);
try
{
fs.Write(info, 0, info.Length);
}
finally
{
fs.Close();
}
这个示例使用了 File.Create() 返回的 FileStream。我们当然可以自己创建一个。将创建 FileStream 的行替换为以下内容:
using var fs = new FileStream(
path: fileName,
mode: FileMode.Create,
access: FileAccess.Write,
share: FileShare.None,
bufferSize:0x1000,
options: FileOptions.Asynchronous);
我在这里使用了最全面的重载来向您展示您可以使用的部分选项。大多数选项都是不言自明的,但我想要强调两个参数:share 和 options。
Share 是一个标志,用于告诉操作系统在使用文件时如何共享文件。它有以下选项:
| 标志 | 值 | 描述 |
|---|---|---|
None |
0 | 不允许共享。任何尝试访问文件的进程都将失败。 |
Read |
1 | 当我们仍在使用文件时,其他进程可以读取文件。 |
Write |
2 | 其他进程可能同时向文件写入。 |
ReadWrite |
3 | 这结合了 Read 和 Write 标志。 |
Delete |
4 | 这允许我们在使用文件的同时请求删除文件。 |
Inheritable |
16 | 文件句柄可以被子进程继承。然而,这在 Win32 应用程序中不起作用。 |
表 5.1:文件共享选项
虽然指定这个列表中的标志可能表明其他进程在我们使用文件时可以对我们文件进行操作,但并不能保证这些其他进程实际上可以这样做。通常,它们还需要其他权限。
Delete是一个很好的标志。它允许我们在使用文件的同时进行删除。这可能会导致奇怪的情况。如果我们创建一个文件并指定我们允许删除,我们可能在另一个进程已经删除文件的情况下向文件写入。系统不会抱怨并继续运行。然而,你最终会失去那个文件,这意味着永远失去了你的数据。让我给你展示一下我的意思:
using System.Text;
var fileName = Path.GetTempFileName();
var info = new UTF8Encoding(true).GetBytes("Hello fellow System Developers!");
using (var fs = new FileStream(
path: fileName,
mode: FileMode.Create,
access: FileAccess.Write,
share: FileShare.Delete, // We allow other processes to delete the //file.
bufferSize: 0x1000,
options: FileOptions.Asynchronous))
{
try
{
fs.Write(info, 0, info.Length);
Console.WriteLine($"Wrote to the file. Now try to delete it. You can find it here:\n{fileName}");
Console.ReadKey();
fs.Write(info);
Console.WriteLine("Done with all the writing");
Console.ReadKey();
}
finally
{
fs.Close();
}
}
Console.WriteLine("Done.");
Console.ReadKey();
这个例子很简单。我们首先获取一个临时文件名。然后,我们获取构成有效载荷的字节。之后,我们将创建一个FileStream实例,在创建过程中设置一些属性。
其中之一是Share选项。我们将其设置为FileShare.Delete。
我们将向文件写入一些数据,然后暂停程序。如果你运行它,这就是你获取输出的时候,它会告诉你文件的名称和位置,然后将其删除。你应该注意到你可以这样做。然后继续程序。正如你所看到的,下一行再次将相同的数据写入我们刚刚删除的文件。什么也没有发生。真的,什么也没有发生。没有错误,也没有任何数据被写入任何地方。
在大多数情况下,这是你想要避免的行为。然而,也许你的用例需要的就是这种行为。在这种情况下,现在你知道如何做到这一点。
更快的是 – Win32
存在一种更快的方式来写入文件。如果我们移除了 CLR 的开销,我们可以将文件写入速度提高大约 20%。速度提高 20%可能意味着一个运行缓慢的应用程序和一个看起来闪电般快速的应用程序之间的区别。通常,这会带来一定的代价。CLR 为我们提供的一切现在都掌握在我们自己的手中。我们必须做更多的工作。然而,如果你在寻找将数据写入文件的最快方式,Win32 方法再次是做这件事的最佳方式。
我们将首先声明一些常量:
private const uint GENERIC_WRITE = 0x40000000;
private const uint CREATE_ALWAYS = 0x00000002;
private const uint FILE_APPEND_DATA = 0x00000004;
GENERIC_WRITE告诉系统我们想要写入文件。CREATE_ALWAYS指定每次调用此方法时都想要创建一个新文件。FILE_APPEND_DATA意味着我们想要向当前文件添加内容(这没有太多意义,因为我们刚刚创建了文件)。
是时候导入 Win32 API 了:
[DllImport("kernel32.dll", SetLastError = true)]
private static extern SafeFileHandle CreateFile(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WriteFile(
SafeFileHandle hFile,
byte[] lpBuffer,
uint nNumberOfBytesToWrite,
out uint lpNumberOfBytesWritten,
IntPtr lpOverlapped);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(SafeFileHandle hObject);
我们将从kernel32.dll导入三个方法。CreateFile创建一个文件,WriteFile向该文件写入,而CloseHandle关闭句柄,在我们的例子中,是文件的句柄。
这就是我们需要的所有内容。让我给你展示它是如何工作的:
public void WriteToFile(string fileName, string textToWrite)
{
var fileHandle = CreateFile(
fileName,
GENERIC_WRITE,
0,
IntPtr.Zero,
CREATE_ALWAYS,
FILE_APPEND_DATA,
IntPtr.Zero);
if (!fileHandle.IsInvalid)
try
{
var bytes = Encoding.ASCII.GetBytes(textToWrite);
var writeResult = WriteFile(
fileHandle,
bytes,
(uint)bytes.Length,
out var bytesWritten,
IntPtr.Zero);
}
finally
{
// Always close the handle once you are done
CloseHandle(fileHandle);
}
else
Console.WriteLine("Failed to open file.");
}
根据你现在的知识,你应该能够跟上。我们首先将创建一个具有正确参数的文件。如果这成功了,我们将得到我们想要写入的字节,然后使用WriteFile进行实际的写入。之后,我们将关闭句柄。我们在finally块中这样做;句柄很昂贵,并且锁定对文件的访问。我们希望关闭它,以便其他进程可以访问文件。
如果你认为这看起来还不错,你部分是正确的。这非常简单。然而,我省略了很多东西,比如错误检查。你还记得我之前关于性能的讨论吗?我在前面的章节中说,与正常的 CPU 操作相比,文件 I/O 要慢得多。因此,我们必须尽可能多地使用异步方法。你可以用 Win32 做到这一点,但这相当复杂。在这里,我不会向你展示如何做,但如果你在 Win32 API、CreateFile和FILE_FLAG_OVERLAPPED上快速搜索,你可以了解它是如何工作的。简而言之,你将不得不自己检查一切。我的建议是坚持使用 CLR 函数。我们将在本章后面讨论异步 I/O。
我们已经学习了如何写入文件以及与之相关的所有内容。然而,这仅仅是故事的一部分。让我们转向等式的另一半:读取文件。
文件读取基础
太棒了。我们已经写了一个文件。现在我们应该能够读取它,对吧?好的,让我们深入探讨一下。我们将从一个简单的例子开始:一个包含一些文本行的文件,我们希望将其读取到字符串中:
public string ReadFromFile(string fileName)
{
var text = File.ReadAllText(fileName);
return text;
}
我无法使它比这更简单了。我们有一个静态的ReadAllText方法,它接受一个文件名并将所有文本读取到字符串中。然后我们返回它。请记住,并非所有文件都包含文本。我甚至敢说大多数文件都不包含文本。它们是二进制的。现在,从技术上讲,一个text文件也是一个binary文件。所以,让我们再次读取文件,但现在是通过读取实际的字节。这次我使用FileStream,这样我们就可以对发生的事情有更多的控制:
public string ReadWithStream(string fileName)
{
byte[] fileContent;
using (FileStream fs = File.OpenRead(fileName))
{
fileContent = new byte[fs.Length];
fs.Read(fileContent, 0, (int)fs.Length);
fs.Close();
}
return Encoding.ASCII.GetString(fileContent);
}
FileStream的好处是它知道流的长度。这意味着我们可以为我们的数组分配足够的空间来包含所有数据。
我们将通过一次调用fs.Read()来读取所有数据,给它一个字节数组,起始位置0,以及要读取的总字节数。再次强调,当我们完成时,我们将关闭流。
最后,我们将假设内容是 ASCII 字符,将文件转换为字符串。
如果文件相对较小,这种方式读取是可行的。在这种情况下,你可以一次性读取所有内容。然而,如果文件太大,你必须分块读取。
为了做到这一点,Read()方法通过告诉你它读取了多少数据来帮助你。你可以创建一个循环并遍历整个文件。
我们可以像这样重写读取文件的部分:
fileContent = new byte[fs.Length];
int i = 0;
int bytesRead=0;
do
{
var myBuffer = new byte[1];
bytesRead = fs.Read(myBuffer, 0, 1);
if(bytesRead > 0)
fileContent[i++] = myBuffer[0];
}while(bytesRead > 0);
fs.Close();
这是一个愚蠢的方法,但它说明了我的观点。我们将继续读取文件,直到我们有了所有数据,在这种情况下 fs.Read() 返回 0。
读取二进制数据
如果你有一个你知道结构的 binary 文件,你可以使用 BinaryReader 来帮助。
二进制数据通常比文本数据更节省内存。由于我们作为系统程序员总是在寻找更高效的代码,这值得一看。
假设我有一个以下这样的类。这并没有什么特殊的意义;它只是一个数据集合:
class MyData
{
public int Id { get; set; }
public double SomeMagicNumber { get; set; }
public bool IsThisAGoodDataSet { get; set; }
public MyFlags SomeFlags { get; set; }
public string? SomeText { get; set; }
}
[Flags]
public enum MyFlags
{
FlagOne,
FlagTwo,
FlagThree
}
假设我已经创建了一个具有属性 42、3.1415、True、MyFlags.One | MyFlags.Three 和 Hello, Systems Programmers 的该类实例。我可以使用 JSON 序列化将其写入文件。这会产生一个 114 字节的文件。如果使用二进制格式,我可以将其缩小到 44 字节。这是一个相当大的节省,尤其是在将数据放在网络上时。
使用 BinaryReader 类读取该文件是直接的。让我给你展示一下:
public MyData Read(string fileName)
{
var myData = new MyData();
using var fs = File.OpenRead(fileName);
try
{
using BinaryReader br = new(fs);
myData.Id = br.ReadInt32();
myData.IsThisAGoodDataSet = br.ReadBoolean();
myData.SomeMagicNumber = br.ReadDouble();
myData.SomeFlags = (MyFlags)br.ReadInt32();
myData.SomeText = br.ReadString();
}
finally
{
fs.Close();
}
return myData;
}
这样做意味着你必须非常小心。你必须精确地知道文件的结构。你必须负责以正确的顺序获取所有数据,并确切地知道每个字段的类型。然而,这样做可以确保效率,并且可以节省你许多 CPU 周期。
我们现在已经了解了如何读取和写入文件。然而,文件系统中的不仅仅是文件。我们需要一种方法来组织所有这些文件。这把我们带到了 IO 的下一个主题:目录!
目录操作
想象一下有一个只有一个根文件夹的文件系统。你的驱动器上的所有文件都存储在那里。你将很难找到所有文件。幸运的是,操作系统都支持文件夹或目录的概念。CLR 通过给我们提供两个类来帮助我们处理路径、文件夹和目录:Path 和 Directory。
路径类
Path 是一个具有处理路径的辅助方法的类。我指的是表示目录名称的字符串。当处理实际的目录和文件时,你应该使用 Directory 类。
我们已经在之前的示例中看到了 Path 类。我使用它来获取临时文件名和 Documents 文件夹的名称。我还用它来合并路径和文件名,以避免自己处理路径分隔符。
Path 类中有许多实用的方法和属性。你可以在以下表中看到一些最常用的。
| 方法 | 描述 |
|---|---|
Path.Combine |
将两个或多个字符串合并为一个路径 |
Path.GetFileName |
返回指定路径字符串的文件名和扩展名 |
Path.GetFileNameWithoutExtension |
返回指定路径字符串的文件名,不带扩展名 |
Path.GetExtension |
获取指定路径字符串的扩展名(包括点) |
Path.GetDirectoryName |
获取指定路径字符串的目录信息 |
Path.GetFullPath |
将相对路径转换为绝对路径 |
Path.GetTempPath |
返回系统临时文件夹的路径 |
Path.GetRandomFileName |
返回一个尚未被使用的随机文件名 |
Path.GetInvalidFileNameChars |
返回当前平台上不允许在文件名中使用的字符数组 |
Path.GetInvalidPathChars |
返回当前平台上不允许在路径字符串中使用的字符数组 |
Path.ChangeExtension |
改变文件路径的扩展名 |
Path.HasExtension |
确定路径是否包含文件名扩展名 |
Path.IsPathRooted |
获取一个值,指示指定的路径字符串是否包含根 |
Path.DirectorySeparatorChar |
用于路径字符串的平台特定分隔符 |
表 5.2:Path 类及其方法和属性
如您所见,Path 类有一组非常好用且方便的辅助工具。当我们调查其他平台时,我们还会遇到它们,但现在,请记住尽可能多地使用它们。
目录类
Directory 类处理您文件系统中的实际目录。这个类与 Path 类紧密合作。如果您需要指定目录的名称(以及其位置),您将使用 Path 类。
假设我们想在 Windows 机器上的 Pictures 文件夹中列出所有图像。您会这样做:
var imagesPath =
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
string[] allFiles =
Directory.GetFiles(
path: imagesPath,
searchPattern: "*.jPg",
searchOption: SearchOption.AllDirectories);
foreach (string file in allFiles)
{
Console.WriteLine(file);
}
我在这里使用 Environment.SpecialFolder.MyPictures 来标识包含所有图片的文件夹。实际的路径取决于您的操作系统、用户名以及您如何设置您的机器。这意味着有很多可能的变体,但我们不必过于担心这一点。让操作系统去处理这个问题,只要我们得到正确的文件夹即可。
我使用了 Directory.GetFiles() 方法来遍历该文件夹。我想获取所有子文件夹中收集的所有 JPEG 图像。注意我在 searchPattern 变量中如何拼写扩展名:*.jPg。在 Windows 上,文件名不区分大小写。在 Linux 上,它们是区分大小写的。因此,在基于 Linux 的机器上,这不会起作用。好吧,它将起作用,但它不会返回您可能期望得到的所有文件。不幸的是,GetFiles() 无法设置不区分大小写的过滤器。如果您想获取所有 JPG 图像,无论它们的扩展名看起来如何,您必须以另一种方式来做:
var regex = new Regex(@"\.jpe?g$", RegexOptions.IgnoreCase);
var allFiles =
Directory.EnumerateFiles(imagesPath)
.Where(file => regex.IsMatch(file));
我在这里创建了一个正则表达式,表示我想过滤以 .jpg 或 jpeg 结尾的字符串,并忽略大小写。然后我使用 Directory.EnumerateFiles() 并应用 Where() LINQ 操作符来应用 regex 过滤器。
此方法在所有平台上都工作得很好。您本可以通过以下代码避免使用 regex 过滤器,该代码更冗长,但我假设对许多人来说更易读:
var files = Directory.EnumerateFiles(imagesPath)
.Where(file => file.EndsWith(".jpg",
StringComparison.OrdinalIgnoreCase) ||
file.EndsWith(".jpeg",
StringComparison.OrdinalIgnoreCase));
我在下面的表中为您收集了Directory类最常用的方法和属性:
| 方法 或属性 | 描述 |
|---|---|
Directory.CreateDirectory |
在指定的路径中创建所有目录和子目录,除非它们已经存在 |
Directory.Delete |
删除指定的目录,以及可选地删除目录中的任何子目录和文件 |
Directory.Exists |
确定给定的路径是否指向磁盘上的现有目录 |
Directory.GetCurrentDirectory |
获取应用程序的当前工作目录 |
Directory.GetDirectories |
获取指定目录中子目录的名称(包括它们的路径) |
Directory.GetFiles |
返回指定目录中文件的名称(包括它们的路径) |
Directory.GetFileSystemEntries |
返回指定目录中所有文件和子目录的名称 |
Directory.GetLastAccessTime |
返回指定文件或目录最后访问的日期和时间 |
Directory.GetLastWriteTime |
返回指定文件或目录最后写入的日期和时间 |
Directory.GetParent |
获取指定路径的父目录,包括绝对路径和相对路径 |
Directory.Move |
将文件或目录及其内容移动到新位置 |
Directory.SetCreationTime |
设置指定文件或目录的创建日期和时间 |
Directory.SetCurrentDirectory |
将应用程序的当前工作目录设置为指定的目录 |
Directory.SetLastAccessTime |
设置指定文件或目录最后访问的日期和时间 |
Directory.SetLastWriteTime |
设置指定文件或目录最后写入的日期和时间 |
表 5.3:目录类的属性和方法
Directory有一些不错的辅助方法和属性。你可以自己找出所有这些属性,但为什么麻烦自己去做,如果 CLR 足够友好地帮助你呢?当我们以后转移到其他平台时,这些属性也将是有益的。
DirectoryInfo类
我还想讨论另一个类:DirectoryInfo类。Directory和DirectoryInfo之间的区别在于前者使用静态方法,而后者用作实例。Directory返回关于目录的字符串信息。DirectoryInfo返回包含更多信息的对象。让我给你举一个例子:
var imagesPath = Environment.GetFolderPath(
Environment.SpecialFolder.MyPictures);
var directoryInfo = new DirectoryInfo(imagesPath);
Console.WriteLine(directoryInfo.FullName);
Console.WriteLine(directoryInfo.CreationTime);
Console.WriteLine(directoryInfo.Attributes);
我创建了一个DirectoryInfo类的实例,并给它提供了我们images文件夹的路径。这个实例有很多有价值的属性,例如完整名称、创建时间、属性等等。我在下表中列出了最常用的属性和方法。
| 方法 或属性 | 描述 |
|---|---|
DirectoryInfo.Create |
创建一个目录 |
DirectoryInfo.Delete |
删除此 DirectoryInfo 实例,指定是否删除子目录和文件 |
DirectoryInfo.Exists |
获取一个值,指示目录是否存在 |
DirectoryInfo.Extension |
获取表示目录扩展部分的字符串 |
DirectoryInfo.FullName |
获取目录或文件的完整路径 |
DirectoryInfo.Name |
获取此 DirectoryInfo 实例的名称 |
DirectoryInfo.Parent |
获取指定子目录的父目录 |
DirectoryInfo.Root |
获取路径的根部分 |
DirectoryInfo.GetFiles |
从当前目录返回文件列表 |
DirectoryInfo.GetDirectories |
返回当前目录的子目录 |
DirectoryInfo.GetFileSystemInfos |
获取表示当前目录中的文件和子目录的 FileSystemInfo 对象数组 |
DirectoryInfo.MoveTo |
将 DirectoryInfo 实例及其内容移动到新路径 |
DirectoryInfo.Refresh |
刷新对象的状态 |
DirectoryInfo.EnumerateFiles |
返回当前目录中文件信息的可枚举集合 |
DirectoryInfo.EnumerateDirectories |
返回当前目录中目录信息的可枚举集合 |
DirectoryInfo.Enumerate FileSystemInfos |
返回当前目录中文件系统信息的可枚举集合 |
表 5.4:DirectoryInfo 属性和方法
如您所见,Path、Directory 和 DirectoryInfo 在处理文件时可以提供很大帮助。
文件系统监控
作为系统程序员,我们必须找到与我们的应用程序通信的方法。毕竟,没有用户界面让用户可以表明他们的期望操作。
那个类别的多数应用程序都监听网络端口或以其他方式让系统与之通信。其中一种方式是等待文件或目录的变化。
监视文件或文件夹是一个相当常见的场景。例如,我们可以构建一个系统来处理通过电子邮件系统获取的文件。一旦文件作为附件发送,邮件客户端就会将其放置在一个目录中,我们的系统就会去获取它。
这意味着我们需要有一种方法来监视那个文件夹。幸运的是,这并不太难实现。这确实需要一些解释,所以让我带你了解一下。
我们将从其他类与之交互的类开始:
internal class MyFolderWatcher : Idisposable
{
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
// Dispose managed state (managed objects).
}
}
~MyFolderWatcher()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
我们稍后需要清理一些资源,所以我在这里实现了 IDisposable 接口。我们需要清理的类是 FileSystemWatcher 类型的实例。这个类在实例化时监视一个文件夹,并且可选地监视文件名过滤器。如果那里发生了一些有趣的事情,FileSystemWatcher 会通知我们。定义“有趣的事情”是什么取决于我们。
让我们将它设置为我们类的一个私有成员:
private FileSystemWatcher? _watcher;
我们可以将我们的 Dispose(bool disposing) 方法改为清理这些内容,但我会暂时保留这个。我们需要做的不仅仅是销毁 FileSystemWatcher。
FileSystemWatcher 是资源密集型的。监视一个文件夹可能会导致大量的 CPU 压力。因此,我们必须确保只有在需要时才启用它。
然后,我们将添加一个启用监视器并设置一些设置的方法:
public void SetupWatcher(string pathToWatch)
{
if(_watcher != null)
throw new InvalidOperationException(
"The watcher has already been set up");
if(!Path.Exists(pathToWatch))
throw new ArgumentOutOfRangeException(
nameof(pathToWatch),
"The path does not exist");
// Set the folder to keep an eye on
_watcher = new FileSystemWatcher(pathToWatch);
// We only want notifications when a file is created or
// when it has changed.
_watcher.NotifyFilter =
NotifyFilters.FileName |
NotifyFilters.LastWrite;
// Set the callbacks
_watcher.Created += WatcherCallback;
_watcher.Changed += WatcherCallback;
// Start watching
_watcher.EnableRaisingEvents = true;
}
我们将开始进行两项检查。首先,我们将查看监视器是否已经被创建。如果已经创建,我们将抛出一个错误。第二是检查提供的路径是否存在。
如果这两个检查都通过,我们将创建一个 FileSystemWatcher 类的实例,并给它我们想要监视的路径。
您可以指定您想要监视的内容。这由 NotifyFilter 属性控制。这个属性接受一个枚举或 NotifyFilter 枚举的组合。您可以在以下表中查看您的选项。
| NotifyFilters 枚举 | 描述 |
|---|---|
Attributes |
监视文件或文件夹属性的变化 |
CreationTime |
监视文件和目录的创建时间的变化 |
DirectoryName |
监视目录名称的变化 |
FileName |
监视文件名称的变化 |
LastAccess |
监视文件和目录的最后访问时间的变化 |
LastWrite |
监视文件和目录的最后写入时间的变化 |
Security |
监视文件和目录的安全设置的变化 |
Size |
监视文件和目录的大小变化 |
表 5.5:NotifyFilters 选项
我只对文件夹中的新文件或更改的文件感兴趣。因此,我给它设置了 NotifyFilters.FileName | NotifyFilters.LastWrite 的值。当然,当您第一次创建文件时,文件的 FileName 会发生变化。我也可以选择 CreationTime,它几乎不会变化。我还会监视 LastWrite,它告诉我文件何时发生变化。
在此之后,我将给 _watcher 一个回调,当两个我关心的任意一个事件被触发时调用。由于所有事件共享相同的签名,我可以用一个方法来完成。这个方法就是我们接下来要看的。然而,在我们这样做之前,我们需要通过将 _watcher.EnableRaisingEvents 设置为 True 来启动监视器。下一段代码包含了 eventhandler 的主体:
private void WatcherCallback(object sender, FileSystemEventArgs e)
{
switch (e.ChangeType)
{
case WatcherChangeTypes.Created:
FileAdded?.Invoke(this, new FileCreatedEventArgs (e.FullPath));
break;
case WatcherChangeTypes.Changed:
FileChanged?.Invoke(this, new
FileChangedEventArgs(e.FullPath));
break;
}
}
当监视器调用这个回调时,我们得到一个 FileSystemEventArgs 类的实例。这个类包含一个名为 ChangeType 的字段,它指示触发了这个调用的变化类型。它还包含受影响的文件的完整路径和名称,在 FullPath 属性中。
我们将切换到 ChangeType 字段,并调用我们类中的一个事件处理器。我们类中的两个事件处理器如下所示:
public event EventHandler<FileCreatedEventArgs>? FileAdded;
public event EventHandler<FileChangedEventArgs>? FileChanged;
FileCreatedEventArgs和FileChangedEventArgs类型对于EventHandler来说也很直接。我本可以使用一个类型。然而,为了未来的使用,我决定为它们提供不同的类,我可能在某个时候通过更多信息扩展它们。它们看起来像这样:
public class FileCreatedEventArgs : EventArgs
{
public FileCreatedEventArgs(string filePath)
{
FilePath = filePath;
}
public string FilePath { get; }
}
public class FileChangedEventArgs : EventArgs
{
public FileChangedEventArgs(string filePath)
{
FilePath = filePath;
}
public string FilePath { get; }
}
FileSystemWatcher实现了IDisposable。因此,当我们不再使用它时,我们必须将其释放。我们需要重写自己的Dispose(bool disposing)方法,使其看起来像这样:
protected virtual void Dispose(bool disposing)
{
if (!disposing) return;
if (_watcher == null)
return;
// Stop raising events
_watcher.EnableRaisingEvents = false;
// Clean whoever has subscribed to us
// to prevent memory leaks
FileAdded = null;
FileChanged = null;
_watcher.Dispose();
_watcher = null;
}
在进行了一些检查后,我们将停止系统接收任何事件。然后我们将清除事件。如果我们不这样做,其他对象可能会持有对我们类的引用,从而阻止此类从内存中释放。
当那件事完成时,我们将释放_watcher并将其设置为 null。
就这样。如果你从你的程序中运行它,给它一个文件夹,并附加一些eventhandlers,你将能够看到当你向该文件夹添加或更改文件时会发生什么。
几乎完美。几乎——但并不完全。
如果你添加一个文件,你会得到多个事件。如果你这么想,这是有道理的。毕竟,文件是在文件系统中创建的,然后立即被更改。如果你愿意,你可以更改我们的类来考虑这一点。这并不难做,所以我会把它留给你。
异步 I/O
我之前已经说过,但这一点非常重要,所以我必须在这里重复一遍:I/O 很慢。任何与 I/O 一起工作的代码都应该异步执行。幸运的是,System.IO命名空间中的大多数类都有我们可以使用的异步成员,我们可以与 async/await 一起使用。
如果微软决定将System.IO中所有非异步方法标记为过时,我会很高兴。
天真的方法
你在System.IO中知道的大部分方法都有一个异步版本。所以,只需在方法名后添加async后缀并等待它即可。很简单!
仔细想想,不。这并不简单。
让我给你举一个例子:
public async Task CreateBigFileNaively(string fileName)
{
var stream = File.CreateText(fileName);
for (int i = 0; i < Int32.MaxValue; i++)
{
var value = $"This is line {i}";
Console.Writeline(value);
await stream.WriteLineAsync(value);
await Task.Delay(10);
}
Console.WriteLine("Closing the stream");
stream.Close();
await stream.DisposeAsync();
}
此方法创建一个文件,然后向其中写入一行字符串。完成后,它关闭文件并很好地释放它。它是异步执行的。所以这就是事情应该这样做的方式,对吧?
让我们使用这个方法:
var asyncSample = new AsyncSample();
await asyncSample.CreateBigFileNaively(@"c:\temp\bigFile.txt");
将这两行代码添加到你的主控制台应用程序中。运行它并让它运行几秒钟。注意屏幕上写入的行(它应该说的是类似于“这是第 n 行”,其中n是行的编号)。然后按Ctrl + C取消操作。程序将停止。现在,请打开文件并看看它走了多远。有很大可能性你会看到文件上最后写入的行不是你在屏幕上看到的数字。
你可能会想知道为什么?CLR 确保我们的代码的性能尽可能高。所以,所有写入文件系统的数据都会在发送到 SSD 或其他媒体之前缓冲到缓存中。毕竟,写入存储是慢的。然而,由于我们终止了进程,CLR 没有时间刷新缓存。
使用取消令牌
当然,在现实世界中这种情况不会经常发生。然而,你可能想要取消一个长时间运行的 IO 进程,然后你可能会遇到这种情况。
这有一个解决方案。还记得我们讨论线程的那一章吗?还记得我说过有一个叫做CancellationToken的东西吗?那就是我们需要的东西。
让我们重写写入文件的代码。让我们从方法名称中移除naïve;我们现在知道得更多了:
public async Task CreateBigFile(string fileName, CancellationToken cancellationToken)
{
var stream = File.CreateText(fileName);
for (int i = 0; i < Int32.MaxValue; i++)
{
if (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("We are being cancelled");
break;
}
else
{
var value = $"This is line {i}";
Console.WriteLine(value);
await stream.WriteLineAsync(value);
try
{
await Task.Delay(10, cancellationToken);
}
catch (TaskCanceledException)
{
Console.WriteLine("We are being cancelled");
break;
}
}
}
Console.WriteLine("Closing the stream");
stream.Close();
await stream.DisposeAsync();
}
我们在这里添加了很多代码。让我带你看看。首先,我们向方法添加了一个CancellationToken类型的参数。我们将在循环中不断检查是否请求了Cancel。如果是这样,我们将在屏幕上打印消息并优雅地退出循环。
在Task.Delay()中,我们也传入了CancellationToken。毕竟,当系统等待这个延迟时,也可以请求取消。然而,当这发生在Task.Delay()期间时,CLR 将抛出一个TaskCanceledException类型的异常。我们必须捕获它以防止我们的程序崩溃并停止。这就是为什么我们在这里有try..catch块。我们需要这个try..catch块来防止异常向上冒泡到调用堆栈。
我们必须模拟从外部中断循环。将调用此方法的代码更改为以下内容:
var cancellationTokenSource = new
CancellationTokenSource();
ThreadPool.QueueUserWorkItem((_) =>
{
Thread.Sleep(10000);
Console.WriteLine("About to cancel the operation");
cancellationTokenSource.Cancel();
});
var asyncSample = new AsyncSample();
await asyncSample.CreateBigFile(
@"c:\temp\bigFile.txt",
cancellationTokenSource.Token);
首先,我们将创建一个新的CancellationtokenSource。然后,我们将从ThreadPool中拉取一个线程并给它一些事情做。等待 10 秒后,它将请求取消。
现在调用CreateBigFile时有一个CancellationTokenSource。
运行它并看到它在 10 秒后停止。注意它停止在哪一行,并检查实际的文件以查看是否是最后写入的一行。在我的机器上,这工作得很好。
记住:在处理异步文件处理时,无论你做什么,尽量使用CancellationSourceToken。同时,确保你处理任何副作用。确保在请求取消后进行清理,以便 CLR 可以正确刷新缓存并清理其资源。
BufferedStream
CLR 在最大化 I/O 操作性能方面做得相当不错。正如我们所见,它可以在写入外部设备之前缓存数据。这种缓存加快了我们的代码,因为我们不再需要等待缓慢的写入操作完成。然而,CLR 对那些缓存做出了明智的猜测。有时它会出错。如果我们知道我们想要写入的数据的大小,我们可以利用这个知识从我们的应用程序中获得更高的性能。
假设我们有一个系统,它会将以下记录写入 I/O:
internal readonly record struct DataRecord
{
public int Id { get; init; }
public DateTime LogDate { get; init; }
public double Price { get; init; }
}
这个块长 24 字节。我们可以通过将int、DateTime和double的大小相加来快速确定这一点。
如果我们将这些内容写入文件,CLR 会将其缓存,直到系统找到一个合适的时机将实际的数据写入存储。然而,我们可以改进这一点。我们可以使用BufferedStream类首先将数据写入缓冲区。然后,当 CLR 认为这是最佳时机时,可以将该缓冲区刷新到底层存储。这里的优势在于我们可以控制该缓冲区或缓存的大小。如果我们指定的大小恰到好处,我们就不会浪费内存。然而,我们也不会将其设置得太小,以免刷新过于频繁。这对我们来说刚刚好。
实现这一功能的代码看起来像这样:
public async Task WriteBufferedData(string fileName)
{
var data = new DataRecord
{
Id = 42,
LogDate = DateTime.UtcNow,
Price = 12.34
};
await using FileStream stream = new(fileName, FileMode.CreateNew, FileAccess.Write);
await using BufferedStream bufferedStream = new(stream,
Marshal.SizeOf<DataRecord>());
await using BinaryWriter writer = new(bufferedStream);
writer.Write(data.Id);
writer.Write(data.LogDate.ToBinary());
writer.Write(data.Price);
}
首先,我们将创建一个FileStream。这个FileStream是我们写入的实际文件的句柄。然后,我们将创建一个BufferedStream,并给它提供FileStream以及我们想要写入的记录的大小。之后,我们将创建一个BinaryWriter,以便尽可能高效地将我们的数据写入缓冲区。
一切都设置好之后,我们再进行写作。
一个警告
如果你不确定数据的大小,使用BufferedStream可能会对你不利。BufferedStream在执行大量已知大小的较小、频繁的数据写入时表现最佳。否则,缓存管理最好留给 CLR。
文件系统安全
文件是我们存储东西的地方。那些东西可能不是每个人都想看到的。有时,我们必须隐藏数据或确保只有我们信任的程序可以访问它。操作系统可以提供帮助。每个操作系统都有处理文件和目录访问的方式。你通常可以允许或拒绝对这些文件的读取或写入访问。
然而,当你想要分享文件时会发生什么呢?让我们假设你想要通过电线传输数据或者将其存储在另一个驱动器上,比如可移动的 USB 驱动器。在这种情况下,确保那样的安全级别是非常具有挑战性的。这意味着你可能需要加密数据以防止其被滥用。
安全性——一个独立的话题
我在这里只介绍安全性和加密的基础知识。这并不是这个复杂且广泛主题的完整指南。仅关于这个主题就有数百本书籍被撰写。我想让你知道你可以进行安全和加密。然而,如果你想要认真对待这个问题,我建议你出去寻找一些关于这些主题的优质资源,并从那里学习。
加密基础
基本上,我们有两种加密类型:对称和非对称算法。尽管它们之间有很多相似之处,但一个很大的区别在于它们处理密钥的方式。
让我们讨论一个基本的例子。假设你有一条信息,想要将它传输给其他人。由于信息内容敏感,你不想让其他人能够阅读它,所以你决定对其进行加密。这意味着你需要改变信息的内文,使得没有人能够理解它。接收者随后解密它,将你的文本转换成可理解的内容。我们称人们可以实际阅读并理解的内容为明文。相反,加密的、不可读的文本是我们所说的密文。人们阅读明文;密文需要解密。
这种保护信息的方式并不新颖。2000 多年前,凯撒就做过这样的事情。他使用了一种简单的替换算法。他所做的就是取一段他想发送给战场指挥官的文字,然后将所有字符向左或向右移动一定的位置。这里的数字就是我们所说的他的密钥。
因此,如果凯撒选择了3这个密钥,他信息中的所有 A 都会变成 D。字符 B 会变成 E,以此类推。
如果你知道了密钥,你就可以用他的密文进行逆向操作,恢复成明文。
这里的问题是传输实际要使用的数字。双方都需要知道密钥,否则事情永远不会成功。你需要一种安全的方式告诉对方使用哪个密钥,这样他们才能将你的密文解密成明文。
如果你认识对方,共享这个密钥并不难。你可以走上前去,将密钥写在一张密封的信封里递给他们,并告诉他们只有在收到加密信息时才能打开。然而,如今这样做要困难得多。计算机不知道它们想要与之通信的其他计算机。安全地交换密钥很困难。
解决这个问题的可能方法是使用非对称加密和解密。这个解决方案很复杂,但其基础是这样的:你有两个密钥。一个密钥用于加密数据,另一个用于解密数据。其中一个密钥是私有的,另一个是公开的。私钥只属于你一个人。你用它来加密文件。任何拥有公钥的人都可以解密它。当然,如果你想信息只被另一个特定方阅读,你可以反过来操作。你可以要求对方与你共享他们的公钥。然后,你用那个密钥加密信息。现在,只有对方才能用他们的私钥再次解密。
对称算法比非对称算法快得多。然而,它们面临密钥共享的问题。这就是为什么大多数算法结合两种方法的原因。它们使用非对称算法加密一个密钥,这个密钥可以用于对称加密。这个密钥相对较小,因此加密和解密可以相对快速地进行。然后,使用这个对称密钥来加密整个消息。这样,对称密钥就可以成为消息的一部分。它本身被加密,所以只有预期的接收者才能解密密钥,从而解密消息的其余部分。
如果这听起来很复杂,我有好消息:CLR 有许多类可以帮助我们完成这项工作。它们的使用也很简单。
对称加密和解密
让我们看看我们是否可以在 C#代码中加密和解密一个简单的消息:
public static void EncryptFileSymmetric(string inputFile, string outputFile, string key)
{
using (FileStream inputFileStream = new
FileStream(inputFile, FileMode.Open, FileAccess.Read))
using (FileStream outputFileStream = new FileStream(outputFile, FileMode.Create, FileAccess.Write))
{
byte[] keyBytes = Encoding.UTF8.GetBytes(key);
using (Aes aesAlg = Aes.Create())
{
aesAlg.Key = keyBytes;
aesAlg.GenerateIV();
byte[] ivBytes = aesAlg.IV;
outputFileStream.Write(ivBytes, 0, ivBytes.Length);
using (CryptoStream csEncrypt = new
CryptoStream(outputFileStream, aesAlg.CreateEncryptor(),
CryptoStreamMode.Write))
{
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead =
inputFileStream.Read(buffer, 0, buffer.Length)) > 0)
{
csEncrypt.Write(buffer, 0, bytesRead);
}
}
}
}
}
这种方法接受输入文件名、输出文件名和一个密钥。然后,它打开输入文件,读取其内容,加密它,并将密文写入输出文件。
这种工作方式非常直接。
首先,我们将创建两个流。然后,我们将获取密钥并生成其字节数组。密钥必须是 128 位、192 位或 256 位数组。换句话说,它必须是 16、24 或 32 字节长。密钥越长,破解就越困难。然而,长密钥也会减慢加密和解密过程。选择权在你。
我们将创建一个Aes类的实例。高级加密标准(AES)被广泛认为是一个好且安全的加密算法。为了使事情更加安全,我们将使用初始化向量(IV)增强我们的密钥。你可以将其视为我们添加到密钥中以使其更难以阅读的东西。我们将把这个 IV 作为文件中的第一件事写入。
然后,我们将创建一个CryptoStream类的实例。这个类帮助我们写入加密数据,正如你将在接下来的代码块中看到的那样。我们将字节数组传递给CryptoStream类。由于我们使用 AES 类(更确切地说,是该类CreateEncryptor调用的结果)初始化了CryptoStream类,因此它使用我们的密钥来加密数据。
解密也很简单。它遵循相同的原理:从密钥获取文件,从文件中读取 IV,然后解密其余部分并将其存储在新文件中。这看起来是这样的:
public static void DecryptFileSymmetric(string inputFile, string outputFile, string key)
{
using (FileStream inputFileStream = new FileStream(inputFile, FileMode.Open, FileAccess.Read))
using (FileStream outputFileStream = new FileStream(outputFile, FileMode.Create, FileAccess.Write))
{
byte[] keyBytes = Encoding.UTF8.GetBytes(key);
using (Aes aesAlg = Aes.Create())
{
byte[] ivBytes = new byte[aesAlg.BlockSize / 8];
inputFileStream.Read(ivBytes, 0,
ivBytes.Length);
aesAlg.Key = keyBytes;
aesAlg.IV = ivBytes;
using (CryptoStream csDecrypt =
new CryptoStream(outputFileStream,
aesAlg.CreateDecryptor(), CryptoStreamMode.Write))
{
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead =
inputFileStream.Read(buffer, 0, buffer.Length)) > 0)
{
csDecrypt.Write(buffer, 0, bytesRead);
}
}
}
}
}
我们现在不是从CryptoStream获取Encryptor,而是获取Decryptor。其余的现在应该很容易理解了。
非对称加密和解密
在前面的例子中,我们生成了一个简单的 128 位、192 位或 256 位密钥。例如,你可以传递一个字符串,如SystemSoftware42,并获取字节。相同的密钥用于加密和解密。
对于非对称加密,获取密钥要困难一些。然而,有辅助类可以帮助完成这项工作,所以在实践中并不困难。以下是代码:
public static (string, string) GenerateKeyPair()
{
using RSA rsa = RSA.Create();
byte[] publicKeyBytes = rsa.ExportRSAPublicKey();
byte[] privateKeyBytes = rsa.ExportRSAPrivateKey();
string publicKeyBase64 = Convert.ToBase64String(publicKeyBytes);
string privateKeyBase64 = Convert.ToBase64String(privateKeyBytes);
return (publicKeyBase64, privateKeyBase64);
}
我使用了RSA类来生成密钥对。Rivest, Shamir, and Adleman(RSA)类是以发明这个算法的三位密码学家命名的。
我们将通过调用Create()来创建一个RSA实例。然后,我们将调用ExportRSAPublicKey()和ExportRSAPrivateKey()来从其中获取生成的密钥。
由于密钥是字节数组,我们将使用ToBase64String()来使它们更易于阅读。这使得共享密钥变得更加容易。
现在我们有了密钥对,我们可以用它来加密一条消息。当然,我们也可以再次解密它。这段代码看起来是这样的:
public static byte[] EncryptWithPublicKey(
byte[] data,
byte[] publicKeyBytes)
{
using RSA rsa = RSA.Create();
rsa.ImportRSAPublicKey(publicKeyBytes, out _);
return rsa.Encrypt(data, RSAEncryptionPadding.OaepSHA256);
}
public static byte[] DecryptWithPrivateKey(
byte[] encryptedData,
byte[] privateKeyBytes)
{
using RSA rsa = RSA.Create();
rsa.ImportRSAPrivateKey(privateKeyBytes, out _);
return rsa.Decrypt(encryptedData, RSAEncryptionPadding. OaepSHA256);
}
这段代码足够简单。我只想指出rsa.Encrypt()和rsa.Decrypt()方法中的最后一个参数。在这里我们将使用填充来向结果中添加额外的数据(并且在解密时我们会将其移除)。这种填充使得攻击者尝试破解我们的消息变得更加困难。
你可以将这三个方法结合起来使用,如下所示:
(string, string) keyPair = Encryption.GenerateKeyPair();
keyPair.Item1.Dump();
keyPair.Item2.Dump();
var publicKey = Convert.FromBase64String(keyPair.Item1);
var privateKey = Convert.FromBase64String(keyPair.Item2);
string message = "This is the text that we, as System Programmers, want to secure.";
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
byte[] encryptedBytes = Encryption.EncryptWithPublicKey(messageBytes, publicKey);
string encrypted = Encoding.UTF8.GetString(encryptedBytes);
encrypted.Dump(ConsoleColor.DarkYellow);
byte[] decryptedBytes = Encryption. DecryptWithPrivateKey(encryptedBytes, privateKey);
string decrypted = Encoding.UTF8.GetString(decryptedBytes);
decrypted.Dump(ConsoleColor.DarkYellow);
首先,我们将创建一个密钥对。我们的方法将这个对作为字符串返回,这样我们就可以打印它们(我再次使用我们方便的Dump()扩展方法)。然而,密钥需要以二进制格式存在,所以我将它们转换成字节数组。
我将定义我想要加密的消息,获取该消息的字节,并对其进行加密。然后,我将打印加密后的消息。如果你这样做,我想你会同意这很难看到实际的消息。它是一堆字符的混乱。
然后,我们将通过调用DecryptWithPrivateKey()来反转它。这个方法返回我们的字符串。
如果我们将我们的公钥的Base64版本发送给某人,然后传输编码后的消息,他们可以用这个公钥来解码它。他们会确信我们发送了这条消息;除了我们之外,没有人能够生成可以被该公钥解密的消息。毕竟,私钥和公钥是一对。你需要一个来加密,以便第二个可以解密。
朱利叶斯·凯撒会为我们感到骄傲!
然而,我们还有另一件事要讨论。我们需要减轻负担。好吧,不是我们个人,而是我们文件中的有效载荷可能会从中受益。让我们谈谈文件压缩。
文件压缩
文件可能会变得相当大。正如我们已经讨论过的,文件 I/O 和网络 I/O 需要很长时间,尤其是与 CPU 的速度相比。我们能够做的任何减少从 I/O 读取或写入所需时间的操作都可能是有价值的。即使这意味着我们必须让 CPU 做更多的工作,这也同样是正确的。当然,你需要测量这一点并看看它是否也适用于你的情况,但有时,为了加快 I/O 速度而牺牲 CPU 时间可能会产生巨大的差异。
实现这一点的其中一种方法是通过限制写入文件或网络流中的数据量。这可以通过压缩来完成。
在 CLR 中,你有选择。你可以使用 DeflateStream 或 GZipStream 来做这件事。GZipStream 在内部使用 DeflateStream,所以 DeflateStream 显然更快。然而,GZipStream 生成的压缩文件可以被外部软件读取。GZip 是一个标准化的压缩算法。
压缩一些数据
让我们使用 GZipStream 压缩一个字符串:
public async Task<byte[]> CompressString(string input,
CancellationToken cancellationToken)
{
// Get the payload as bytes
byte[] data =
System.Text.Encoding.UTF8.GetBytes(input);
// Compress to a MemoryStream
await using var ms = new MemoryStream();
await using var compressionStream = new GZipStream(ms,
CompressionMode.Compress);
await compressionStream.WriteAsync(data, 0,
data.Length, cancellationToken);
await compressionStream.FlushAsync(cancellationToken);
// Get the compressed data.
byte[] compressedData = ms.ToArray();
return compressedData;
}
由于压缩和解压缩可能需要很长时间才能完成,我们在这里真的应该使用 Async/Await 模式。
我们将取一些想要压缩的字符串并将它们传递给输入变量。在这个例子中,我使用了 MemoryStream,但你也可以使用你喜欢的任何流。大多数现实世界的例子使用某种形式的 FileStream。
我将创建一个 GZipStream 类的实例,并给它一个 MemoryStream 实例。这个内存流是它写入数据的地方。我还会告诉这个类我想要压缩数据。
然后,我只需将数据写入其中,刷新缓冲区,并从中获取字节。
就这样!我已经压缩了一个字符串。
解压缩一些数据
解压缩同样简单。看看下面的代码示例:
public async Task<string> DecompressString(byte[] input,
CancellationToken cancellationToken)
{
// Write the data into a memory stream
await using var ms = new MemoryStream();
await ms.WriteAsync(input, cancellationToken);
await ms.FlushAsync(cancellationToken);
ms.Position = 0;
// Decompress
await using var decompressionStream = new GZipStream(ms, CompressionMode.Decompress);
await using var resultStream = new MemoryStream();
await decompressionStream.CopyToAsync(resultStream, cancellationToken);
// Convert to readable text.
byte[] decompressedData = resultStream.ToArray();
string decompressedString =
System.Text.Encoding.UTF8.GetString(decompressedData);
return decompressedString;
}
在这里,我使用了两个 MemoryStream 类的实例。我使用一个作为数据的源,另一个作为未压缩数据的目的地。再次提醒,请使用你想要的任何流。
你可以使用以下方法:
var cts = new CancellationTokenSource();
var myText = "This is some text that I want to compress.";
var compression = new Compression();
var compressed = await compression.CompressString(myText, cts.Token);
var decompressed = await
compression.DecompressString(compressed, cts.Token);
decompressed.Dump(ConsoleColor.DarkYellow);
那并不太难,对吧?
然而,我们还没有完成。我们想要存储或读取的数据需要以某种格式存在。如果你有一个包含数据的 C# 类,你不能简单地将其写入文件。我们需要以某种方式将其转换。这就是序列化的用武之地。
序列化 – JSON 和二进制
在本章的早期,我们看到了如何将二进制数据写入流。我们可以调用所有 write 方法将各种类型写入文件。然而,这可能会相当困难,并且也容易出错。你必须跟踪数据的格式。一个简单的错误会使你的文件无法读取。
更好的方法是将你的数据序列化成流可以理解的格式。有两种方法可以做到这一点:JSON 和 二进制。
JSON 很简单:大多数编程语言和平台都理解它。JSON 已经成为在文本中显示结构的既定标准。在大多数地方,JSON 已经取代了 XML。JSON 更小,更轻量。
然而,它还可以更轻量。你还可以将你的数据序列化为二进制流。这需要更多的编码,但通常会产生更小文件和数据流。再次提醒,这可能是我们作为系统程序员所寻找的。
JSON 序列化
要将对象序列化为 JSON 格式,人们过去默认使用 NewtonSoft.JSON。NewtonSoft.JSON 是首选库。它易于使用(并且仍然如此)并提供了许多人们喜欢的功能,例如自定义转换器。然而,微软随后发布了 System.Text.Json,它执行相同的操作但效率更高。作为系统程序员,我们关心内存效率和速度,因此我将重点关注这一点。
在我们能够序列化某个对象之前,我们需要一个可以序列化的对象。System.Text.Json 的优势在于我无需更改带有属性的类。框架足够智能,能够找出所需的内容并完成它。
我将使用本章前面看到的相同数据类在这些示例中。然而,为了节省您翻页的时间,我再次在这里向您展示它:
class MyData
{
public int Id { get; set; }
public double SomeMagicNumber { get; set; }
public bool IsThisAGoodDataSet { get; set; }
public MyFlags SomeFlags { get; set; }
public string? SomeText { get; set; }
}
[Flags]
public enum MyFlags
{
FlagOne,
FlagTwo,
FlagThree
}
如果我们想将此序列化为 JSON 以存储为文本并在以后重新读取,我们将使用以下代码:
public string SerializeToJSon(MyData myData)
{
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var result =
System.Text.Json.JsonSerializer.Serialize(myData,options );
return result;
}
这里展示的代码相当直接。我们将使用 MyData 类并将其传递给静态 Serialize 方法,System.Text.Json.JsonSerializer。此方法有几个重载。我将使用一个接受 JsonSerializerOptions 类实例的重载。这样,我可以格式化输出。我将 WriteIdented 属性设置为 True。如果没有这样做,我将会得到整个字符串在一行上。诚然,这会节省我几个换行符和制表符字符,但为了可读性,我更喜欢这种方式。
如果我们在类中运行一些值,我们将得到以下结果:
{
"id": 42,
"someMagicNumber": 3.1415,
"isThisAGoodDataSet": true,
"someFlags": 2,
"someText": "This is some text that we want to serialize"
}
反序列化,即逆转该过程,同样简单:
public MyData DeserializeFromJSon(string json)
{
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var result = System.Text.Json.JsonSerializer. Deserialize<MyData>(json, options);
return result!;
}
如您所见,这个过程足够简单。
二进制序列化
这并不是将对象编码以便存储在文件中的最有效方式。它相对较快,但实际数据也相当大。二进制格式化需要更多的工作,结果也不是人类可读的,但它确实导致了文件更小。这意味着将数据写入和读取到慢速存储介质所需的时间显著减少。当然,权衡是 CPU 会变得稍微忙碌一些,但这可能值得。一如既往,先衡量,然后决定这是否适用于您的情况。
在 .NET Framework 中,在 .NET Core 和 .NET 之前,我们有一个名为 BinaryFormatter 的类。然而,该类现在已被标记为过时。与该类相关的存在严重的安全担忧,因此微软决定将其淘汰。
您可以使用第三方包来实现相同的目标。然而,如果您不想使用那些,您始终可以自己完成。我们已经讨论了 BinaryWriter 类及其方法。使用该类并没有什么问题,但缺点是您必须编写所有代码,包括写入和读取每个字段或属性。BinaryFormatter 类就是这样做的。坦白说,这相当方便。
目前实现相同功能的最佳包是 protobuf-net。此包可在 NuGet 上找到,这使得在项目中安装变得容易。如果您想使用 protobuf-net,您必须在序列化之前对您的类进行注解。再次使用我们的 MyData 类,它看起来会是这样:
[ProtoContract]
public class MyData
{
[ProtoMember(1)]
public int Id { get; set; }
[ProtoMember(2)]
public double SomeMagicNumber { get; set; }
[ProtoMember(3)]
public bool IsThisAGoodDataSet { get; set; }
[ProtoMember(4)]
public MyFlags SomeFlags { get; set; }
[ProtoMember(5)]
public string? SomeText { get; set; }
}
我们用 ProtoContract 属性装饰了类。然后,我们用 ProtoMember 属性装饰了属性。这个属性可以关联数据,但第一个是必需的。这是标签,它定义了字段在文件中的存储位置。除了一个规则之外,没有关于编号或顺序的硬性规定:你不能从 0 开始。是的。确实如此。我听到你在那里倒吸一口凉气。这是我能想到的唯一一个在编程中从 0 开始是禁止的例子。如果你想从 42 开始,你可以这样做。然而,数字必须是一个正整数,而 0 不是一个正整数。
序列化和反序列化很简单。您必须确保数据可以在内存流中可用或可以写入内存流,但这只是稍微复杂的一件事。这是代码:
public async Task<byte[]> SerializeToBinary(MyData myData)
{
await using var stream = new MemoryStream();
ProtoBuf.Serializer.Serialize(stream, myData);
return stream.ToArray();
}
public async Task<MyData> DeserializeFromBinary(byte[] payLoad)
{
await using var stream = new MemoryStream(payLoad);
var myData =
ProtoBuf.Serializer.Deserialize<MyData>(stream);
return myData;
}
就这样了。我承诺这会很简单,不是吗?
如果我将与 JSON 序列化相同的数据进行比较,我可以看到二进制版本的数据量要小得多。即使我使用不写入预期文件的选择,从而节省了换行符和制表符,JSON 版本也有 131 字节。相比之下,二进制版本只有 60 字节长。这是一个很大的差异!
下一步
I/O 对所有软件都是至关重要的。没有软件是孤立运行的,尤其是为系统编写的软件。毕竟,这些应用程序没有传统的用户界面;它们是供其他软件使用的。与该软件的唯一通信方式是通过以某种方式交换数据。
本章探讨了将数据序列化和反序列化到存储中的方法。我们了解到 JSON 简单且生成可读性强的数据。然而,数据可能相当庞大。相比之下,二进制版本的数据量要小得多,但这样的数据不再适合人类阅读。此外,它还需要第三方包。最佳解决方案是什么?这取决于您的使用场景!无论您使用文件还是网络连接,它们都是 I/O 的方法。在本章中,您看到了如何高效、快速且安全地实现这一点。
然而,对于系统软件来说,一种更高效的方式来通信是通过直接在 进程间通信(IPC)上进行通信。IPC 是系统软件建立其他软件可以与之交谈或监听的接口层的一个完美方式。这也是下一章的主题。
第六章:进程低语篇
进程间 通信 (IPC)
在上一章中,我们讨论了输入/输出。我们的大部分注意力都集中在文件上。文件是人们想到与其他系统共享数据时首先想到的事情之一。另一种常用的方法是网络。然而,系统之间还有其他通信方式。如果你想要长时间保留数据,文件是很好的选择。网络连接是连接不同机器上系统之间更直接连接的绝佳方式。但文件和网络更多地关于传输数据的基础技术。我们还必须决定如何使用这些方法连接到系统。简而言之,这就是进程间通信(IPC)的全部内容。我们如何让两个系统相互交谈?
在本章中,我们将涵盖以下主题:
-
什么是 IPC?
-
设计 IPC 时,我们需要关注哪些考虑因素?
-
Windows 消息 - 一种 Windows 原生的消息方式
-
管道 - 命名和无名
-
套接字 - 一种基于网络的 messaging 系统
-
共享内存 - 一种快速简单的本地 messaging 系统
-
远程过程调用(RPC)——控制其他机器
-
Google 远程过程调用(gRPC)——新加入的成员
欢迎来到迷人的低语系统世界!
技术要求
你可以在以下链接中找到本章中所有的代码:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter06.
IPC 概述及其在现代计算中的重要性
大多数软件都有用户界面。毕竟,用户应该通过这种方式与应用程序交互。用户点击按钮,输入文本,并在屏幕上读取响应。屏幕是数据、用户和应用程序交换数据和指令的方式。
人们不使用系统软件。其他软件会使用。因此,它需要不同的交互方式。我想技术上可能可以编写一个常规的用户界面并使用技巧来读取或输入数据,但这并不真正高效。
应用程序在相互交谈时会有不同的通信方式。它们有自己的语言和自己的协议。这就是进程间通信(IPC)的全部内容——进程之间的通信。
由于系统的性质,在设计系统之间的接口时,我们必须考虑几个关键点。在设计这个接口时,我们做出的选择与设计面向人的用户界面不同。这里有许多因素需要考虑。让我们逐一来看。
-
明智地选择你的语言:系统可以使用许多不同的方式相互通信,就像人类的对话一样,如果所有参与方都说同一种语言,这会极大地帮助。本章描述了我们可以如何让系统相互通信,但还有更多方法。某些方法可能比其他方法更适合特定的环境或用例,所以你必须仔细思考。不要选择你感到最舒适的那个,因为你已经知道那个解决方案。考虑所有的用例场景,然后选择合适的协议。
-
安全性:安全性是一个巨大的话题,尤其是在系统编程中。我们正在处理数据,系统隐藏在我们的计算机深处。大多数人不知道他们的机器上正在运行多个进程,因此他们不太可能检查它们并评估其安全性水平。
-
数据格式和序列化:当你的数据从一个系统转移到另一个系统时,你必须考虑最佳的数据转换方式。数据必须是包、信封或其他传输方式的一部分。有众多不同的格式和序列化方式,但你选择哪一种取决于许多因素。例如,如果你在同一个 Windows 机器上的两个 64 位进程之间使用直接内存连接,你可以使用一个非常高效、轻量级的二进制表示。然而,如果你必须与地球上另一侧运行不同操作系统的机器通信,那么你必须设计出两个系统都能理解的序列化机制。
-
错误处理和健壮性:软件可能会出错,我们都知道这一点。如果你在谈论多个独立的系统,那么错误和可用性的问题会呈指数级增长。因此,你必须对此保持警觉。你还必须考虑你的需求。你是否需要保证交付?你是否需要错误恢复?这两者可能很有用,但它们是有代价的。毕竟,没有什么是免费的。你需要考虑这些场景。通常,你必须设计一个可以应对的解决方案,而不是过度依赖错误纠正方案。
-
性能和可扩展性:在进程内部传输内存块是非常快的。在进程之间移动数据可能非常慢,甚至难以想象地慢。通过传输控制协议(TCP)连接将一个内存块移动到另一台机器比在内存中这样做慢数千倍。将数据写入磁盘,即使是快速的固态硬盘,也比这还要慢。
-
这意味着你必须确保这些用例的最佳 I/O 策略。建立连接或创建文件可能很慢,但你必须为每次传输只做一次。一旦你有了这个,你就可以写入数据。如果你有很多小数据包,你可能想将它们捆绑在一起,这样你只需要启动一次。正如我们之前所述,在传输之前压缩数据可能是个好主意。是的,压缩会占用 CPU 周期,但考虑到将数据传输到另一个系统要慢得多,这可能值得。
-
同步和死锁:一旦你的数据离开你的系统,你就不再知道它发生了什么。其他进程可能也在请求接收方的注意,或者接收方可能已经没有数据了。你必须非常小心,以确保数据同步。或者不。这当然取决于你的用例。此外,死锁也可能发生。你可能会等待接收方完成某个操作,但如果那个操作在等待你的系统,你就遇到了问题。要留意那些问题区域。
-
文档和维护性:与其他系统共享数据意味着与其他开发者共享你的数据结构。别忘了,“其他开发者”可能是六个月后的你自己,当你回顾你所做的一切并想知道你在想什么时。记录你的工作、你的想法和你的数据结构可以为你和你的同伴节省很多麻烦。做件好事,记录你的数据及其结构、你为了满足所有限制所做的工作,以及你的假设。这使得你的代码更容易维护。当然,这适用于数据共享场景和所有软件开发,但在你需要与其他系统共享数据时,这一点尤为重要。不要跳过这一步!
-
平台和环境限制:你未必总是清楚你的数据将会与哪种硬件共享。如果你不知道这一点,你必须考虑所有可用的选项。假设最坏的情况,并为此做好准备。例如,如果你传输几个吉字节的数据包,加密并使用压缩算法包装,你可能会收到投诉,说接收方是一个非常低端的 IOT 设备,内存和 CPU 处理能力有限。
并非所有平台都支持我在本章中概述的所有策略。例如,我们接下来要讨论的 Windows 消息,仅在 Windows 上可用。名字多少有点暗示,不是吗?要意识到平台和环境限制,并围绕这些限制设计你的数据共享。
因此,现在你已经知道了在选择通信方法时要考虑的因素,让我们看看我们有哪些可用方法。我们从一个经典的方法开始:Windows 消息。
Windows 消息
Windows 消息是 Windows 中最老类型的 IPC。当编写系统软件时,它们可能不是最佳选择,但它们可能很有帮助。更重要的是,它们非常快且轻量级。然而,正如其名所示,它们是 Windows 特有的功能。
消息与窗口一起工作。我说的不是操作系统;我指的是您监视器上的屏幕。Windows 中的几乎所有 GUI 元素都是一个窗口。窗口显然是,但按钮、编辑框、文本框、滑块等等也都是。操作系统通过向窗口发送消息与您的应用程序通信。您的应用程序至少有一个主窗口,然后它会将消息分发到子窗口或处理那些子窗口的消息。然而,每个窗口都可以有自己的消息处理逻辑。
由于消息与图形屏幕元素(如按钮、标签和列表框)一起工作,你可能认为它们不能用于控制台应用程序或 Windows 服务。这在技术上是对的,但我们可以绕过这一点。我们可以创建一个隐藏的窗口来接收这些消息。
消息很简单。它只是一个包含四个数字参数的结构。这就是参数是什么以及它们用于什么。
| 名称 | 类型 / C# 类型 | 描述 |
|---|---|---|
hWnd |
HWND / IntPtr | 要接收消息的窗口的唯一句柄 |
Msg |
UINT / uint | 消息的 ID |
wParam |
WPARAM / IntPtr | 一个附加参数,或数据结构的指针 |
lParam |
LPARAM / IntPtr | 一个附加参数,或数据结构的指针 |
表 6.1:Windows 消息中的参数
消息就这么多。wParam 和 lParam 指针指向包含有效载荷的内存。如果只想发送数字,它们也可以只是一个数字。在 16 位 Windows 中,wParam 是 16 位,lParam 是 32 位。在 Windows 的 32 位版本中,它们都是 32 位长,在 64 位版本中,它们都是 64 位长。因此,在长度方面,wParam 和 lParam 没有真正的区别了。
这些消息都是操作系统向您的应用程序发送的通信。如果用户将鼠标移到您的窗口上,您会收到通知。好吧,在鼠标移动的情况下,您会收到数百个通知。如果用户按下一个键,您会收到一个消息。如果用户调整窗口大小,您会收到另一个消息。操作系统上发生的任何可能对您的应用程序有趣的事情都会以消息的形式发送给您。您的应用程序会一直接收到数百甚至数千条消息。您的应用程序需要监听这些消息。我们很快就会看到它是如何工作的。
消息标识符可以是预定义的;它可以是您选择的数字,或者操作系统可以生成它。
让我解释一下我的意思。
如果 Windows 发送一个消息,它就是预定义的其中之一。例如,如果鼠标移动了,你会收到 WM_MOUSEMOVE 消息。WM_MOUSEMOVE 是一个值为 0x0200 的常量。wParam 包含有关鼠标按钮和键的状态的信息,例如你键盘上的 Ctrl 键。你可以解码这些标志来查看鼠标移动时是否按下了按钮。lParam 包含鼠标相对于接收消息的窗口(左上角)的 X 和 Y 位置。lParam 的前半部分包含 Y 坐标,后半部分包含 X 坐标。
一个有趣的消息是 WM_CLOSE。它的值是 0x0010。如果一个窗口收到这个消息,用户想要关闭它。如果这发生在你的主窗口上,应用程序就会结束。
你也可以定义自己的消息。有一个名为 WM_USER 的常量(值为 0x0400)。你可以在你的应用程序中自由使用 WM_USER 和 0x7FFF 之间的任何值来定义你的消息。有一个注意事项:你只能在你向应用程序的其他窗口发送这些消息时使用它们。你不能用它们与其他应用程序通信。原因很简单:你不知道系统外谁使用这些值。
如果你想要向其他应用程序发送消息,你需要向 Windows 注册。你可以调用一个 API 来保留一个在计算机运行期间唯一的保留号码。如果两个应用程序保留了相同的消息名称,它们将获得相同的 ID。这可以用来在进程之间进行通信,这正是我们现在要做的。
一个示例
要处理消息,我们需要使用大量的 Win32 API。逻辑并不复杂,但这个示例需要大量的设置。
我们可以将其分解如下:
-
Window类。它就像面向对象编程一样:首先定义一个类,然后创建实例。窗口就是这样。 -
定义消息循环方法:这个方法在消息可用时立即被调用。
-
创建窗口:一旦发生,消息就开始流动。
-
WM_CLOSE,关闭应用程序。如果你想处理这个消息,请这样做。如果不处理,就将其传递给所有应用程序都有的默认处理器。
就这些了。
本书 GitHub 仓库中的源代码包含一个示例。我没有在这里包含它,因为该示例需要大量的样板代码,占据了数页。我决定将其从本章中省略,因为除了某些特定情况外,Windows 消息并未使用。然而,如果你感兴趣,只需查看示例代码。有了前面的解释,你可以很好地跟随。
现在你已经了解了 Windows 消息的工作原理,我们可以继续下一步,看看其他的方法。我们从一个简单的方法开始:管道。
在本地 IPC 中使用管道
管道最初来自 Unix,但也已经出现在其他平台上。管道就像两个系统之间的直接连接。它非常轻量级且易于设置。您可以使用它们在同一台机器上的进程之间以及通过网络在不同机器之间进行通信。从理论上讲,您可以使用管道在 Linux 和 Windows 之间进行通信。我说理论上是因为由于两个平台上的管道实现差异很大,您必须跳过许多步骤才能使其工作。实际上,您必须做的这项工作非常繁重,您可能更愿意使用其他方式,例如套接字,来实现相同的结果。这将更容易实现。
有两种类型的管道:命名管道和匿名管道。命名管道是最简单的一种。
命名管道
命名管道是如果您想在同一台机器上的一个进程与另一个进程之间进行通信时的一个很好的解决方案。通过网络进行通信并不复杂,但需要更多关于安全和访问权限的思考。
在.NET 中,您可以使用NamedPipeServerStream和NamedPipeClientStream类来实现这一点。
代码很简单。例如,让我们看看一个等待连接的服务器。我们还添加了一个连接到该服务器的客户端。一旦建立连接,服务器就会向客户端发送一条消息,该消息将在屏幕上显示。
这里是服务器代码:
using System.IO.Pipes;
"Starting the server".Dump(ConsoleColor.Cyan);
await using var server = new
NamedPipeServerStream("SystemsProgrammersPipe");
"Waiting for connection".Dump(ConsoleColor.Cyan);
await server.WaitForConnectionAsync();
await using var writer = new StreamWriter(server);
writer.AutoFlush = true;
writer.WriteLine("Hello from the server!");
再次,我使用我的Dump()扩展方法在这里快速着色屏幕上的消息。
首先,我创建了一个NamedPipeServerStream的实例。作为一个参数,我给它提供了一个唯一的名称。如果我使用一个已经注册的名称,我将能够访问那个其他命名管道。这些名称在您的机器上是唯一的,但一旦NamedPipeServerStream被销毁,它们就会消失。
然后,我们等待连接。当客户端连接时,我们创建StreamWriter,将其命名为管道服务器流,并将数据写入流。
我们在写入器上使用AutoFlush:我们不希望数据悬挂在那里。
让我们看看客户端代码:
using System.IO.Pipes;
await using var client = new NamedPipeClientStream(".", "SystemsProgrammersPipe");
"Connecting to the server".Dump(ConsoleColor.Yellow);
await client.ConnectAsync();
using var reader = new StreamReader(client);
string? message = await reader.ReadLineAsync();
message.Dump(ConsoleColor.Yellow);
这段代码看起来应该很熟悉。我们创建了一个NamedPipeClientStream(而不是服务器)的实例,并给它提供了两个参数。第一个参数是网络上计算机的名称(在我们的例子中,是我们自己的计算机,由点指定)。第二个参数是管道的名称。显然,这应该与我们用于服务器流的名称相同。
我们将客户端连接到管道,使用该客户端创建一个StreamReader实例,并读取数据。最后,我们显示来自服务器的数据。
匿名管道
匿名管道的工作方式与命名管道大致相同。它们提供了一种轻量级的方式将进程连接起来。然而,命名管道和匿名管道之间存在一些差异。以下表格突出了其中最重要的几个:
| 特性 | 命名管道 | 匿名管道 |
|---|---|---|
| 识别 | 命名的。你可以通过名称找到它们。 | 未命名的。你必须知道运行时句柄才能连接。 |
| 通信 | 本地和网络通信。 | 只有本地通信。 |
| 对等方 | 每个服务器可以有多个客户端。可以设置为处理双向对话。 | 只有一对一。也只有单向。 |
| 复杂性 | 更复杂。允许进行异步通信,也能处理“发送后即忘”的场景。 | 更简单。直接的一对一父-子通信。 |
| 安全性 | 支持 ACL 以启用安全通信。 | 没有安全功能可用。 |
| 速度 | 由于控制更多而较慢。 | 快速。几乎没有开销。 |
表 6.2:命名管道和匿名管道功能比较。
设置匿名管道的代码实际上非常简单。让我们从服务器代码开始:
using System.IO.Pipes;
await using var pipeServer = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability. Inheritable);
$"The pipe handle is: {pipeServer.GetClientHandleAsString()}". Dump(ConsoleColor.Cyan);
pipeServer.DisposeLocalCopyOfClientHandle();
await using var sw = new StreamWriter(pipeServer);
sw.AutoFlush = true;
sw.WriteLine("From server");
pipeServer.WaitForPipeDrain();
让我带你了解这个过程。
首先,我创建了一个 AnonymousPipeServerStream 的实例。这个类处理所有通信的设置。我们可以看出它既可以发送也可以接收代码。我们不能使用提供的 PipeDirection.InOut 枚举:这将抛出异常。记住:匿名管道是单向的。
我们需要确保句柄可以被继承。这是因为客户端需要“继承”这个句柄。毕竟,这是我们唯一能够识别管道的方式。没有名称;它是匿名的!
我们调用 GetClientHandleAsString 以确定客户端应使用的内容。
当你创建 AnonymousPipeServerStream 时,它也会自动创建一个客户端。如果你想在进程内部进行通信,这会很有用。然而,如果另一个进程需要与这个服务器通信,你将遇到问题。匿名管道仅支持单连接。调用 DisposeLocalCopyOfClientHandle 会移除本地客户端,这样我们就有空间为另一个客户端服务。
然后,我们创建一个流,将其与管道关联,并向其写入。
最后,我们调用 WaitForPipeDrain,这是一个阻塞调用,只有当客户端读取完所有数据时才会继续。
客户端甚至更简单:
"Enter the pipeHandle".Dump(ConsoleColor.Yellow);
var pipeHandle = Console.ReadLine();
using var pipeClient = new AnonymousPipeClientStream(PipeDirection.In, pipeHandle);
using var sr = new StreamReader(pipeClient);
while (sr.ReadLine() is { } temp)
temp?.Dump(ConsoleColor.Yellow);
我们首先从控制台读取句柄。这是服务器的输出,所以我们有可用的内容。然后,我们通过创建 AnonymousPipeClientStream 的实例来创建客户端,告诉它准备接收数据,并给它句柄。
然后,我们创建 stream 并从其读取。就这样!
有一个很大的注意事项。假设你编写了这两个控制台应用程序并运行它们。在这种情况下,你会看到,当你尝试创建AnonymousPipeClientStream的实例时,你会得到一个InvalidHandle异常。原因是 Windows 将进程分开,确保安全性尽可能高。如果你运行两个进程,它们无法相互访问句柄。因此,它无法访问管道,这意味着你无法通信。恐怕我们对此无能为力。如果你仔细想想,这确实是有道理的。你只能进行一对一的通信。所以,如果多个控制台应用程序连接到服务器,你怎么确保这种一对一的行为呢?答案是:你不能。
如果你想要独立的控制台应用程序,你应该使用命名管道。
然而,如果你想使用我提供的示例,你可以确保客户端和服务器在同一个地址空间中运行。你可以通过从服务器启动客户端来实现这一点。看起来是这样的:
Process pipeClient = new Process();
pipeClient.StartInfo.FileName = @"pipeClient.exe";
// Pass the client process a handle to the server.
pipeClient.StartInfo.Arguments =
pipeServer.GetClientHandleAsString();
pipeClient.StartInfo.UseShellExecute = false;
pipeClient.Start();
不要忘记将客户端更改为从Main方法提供的args参数中获取句柄,而不是通过控制台从用户那里获取。
这里的秘密在于我们设置UseShellExecute为False的那一行。如果它是True,客户端将在另一个 shell 中启动,从而将其与服务器隔离开来。通过将其设置为False,我们防止了这种情况,并可以访问句柄,从而可以访问管道。
如果它们适合你的场景,匿名管道是通信工具箱中的绝佳补充。它们速度快且轻量级,这正是我们作为系统程序员所喜欢的。然而,还有其他可能更好的通信方式,尽管它们并不那么简单。让我们来谈谈套接字…
使用套接字建立基于网络的 IPC
套接字很棒。它们有点像通信领域的瑞士军刀。当你转向套接字时,管道和 Windows 消息的缺点就消失了。当然,没有免费的午餐,所以请准备好花大量时间思考错误处理和内存管理。尽管如此,一旦你掌握了这个概念,套接字并不难使用。
套接字是两个系统之间通过网络连接的端点。当然,系统可以位于同一台机器上,但它们也可以位于世界的两端。多亏了自 20 世纪 60 年代以来人们为构建网络所做的大量工作,我们现在可以接触到全球的各种机器。
网络基础
计算机网络已经存在很长时间了。然而,每个供应商都有自己的方式让机器相互通信。随着时间的推移,标准出现了。正如标准所做的那样,有好多可供选择。如今,我们在设置网络方面已经或多或少实现了标准化,所以你再也不必担心这个问题了。
但在我们深入具体细节之前,我们需要先谈谈开放系统互联(OSI)。
OSI 是一个分层架构,你可以用它来描述网络的工作方式。每一层都建立在上一层之上(第一层似乎是一个例外)。
共有七层,以下是它们的描述:
-
第 1 层 – 物理层:这是描述硬件的部分。例如,电缆的外观,开关的工作方式,施加的电压等。
-
第 2 层 – 数据链路层:这一层描述了系统如何在物理层上连接。在这里,我们描述了以太网或 Wi-Fi 的工作方式。MAC 地址(每个网络设备的唯一编号)在这里定义。
-
第 3 层 – 网络层:这一层完全是关于路由和寻址。在第 3 层定义了几个协议,例如互联网控制消息协议(ICMP),用于网络诊断和错误报告,地址解析协议(ARP),用于地址解析,蓝牙,当然还有互联网协议(IP),包括 v4 和 v6。
-
第 4 层 – 传输层:这一层负责端到端通信和可靠性。TCP 和用户数据报协议(UDP),本章的主题,位于这一层。
-
第 5 层 – 会话层:这一层管理应用程序之间的会话。
-
第 6 层 – 表示层:这一层确保数据以其他系统可以理解的形式呈现。
-
第 7 层 – 应用层:这里列出了使用网络的程序。
硬件和操作系统处理第 1 层至第 4 层。第 5 层至第 7 层由我们来负责。
几乎所有系统都使用 TCP 作为传输层,但有时人们会选择 UDP。我先解释 TCP 及其使用方法,然后在本文这部分结束时转向 UDP。IP 几乎是既定的。我们可以选择其他网络层协议,但这会使生活变得不必要地复杂。
设置会话(第 5 层)是我们编写代码在客户端或服务器上建立连接的地方。表示层(第 6 层)是关于我们如何打包数据:我们如何序列化,使用什么编码等。我们已经对此进行了广泛的讨论。第 7 层只是我们的应用程序;我将这一层留给你。
那么,让我们编写一些第 5 层的代码!
基于 TCP 的聊天应用程序
网络的“hello-world”应用程序是一个聊天应用程序。这类应用程序使我们能够研究系统如何连接并交换数据,而不必处理它们之间传递的数据类型的技术细节。数据类型是应用程序的一部分,我们在 OSI 模型中了解到它是第 7 层。我们对此不感兴趣。第 6 层是表示层,但对于一个简单的聊天应用程序,我们可以简单地处理:我们取一个字符串并将其编码为 UTF8 字节(当然,也可以反向操作)。由于操作系统负责第 1 层至第 3 层,我们只需处理第 4 层和第 5 层。
让我们让它成为现实。
我想在这里使用 TCP,这是一个非常优秀的协议,它为我们提供了可靠性并保证了数据到达的顺序。它也非常容易设置。
服务器看起来是这样的:
01: using System.Net;
02: using System.Net.Sockets;
03: using System.Text;
04:
05: "Server is starting up.".Dump();
06:
07: var server = new TcpListener(IPAddress.Loopback, 8080);
08: server.Start();
09:
10: "Waiting for a connection.".Dump();
11:
12: var client = await server.AcceptTcpClientAsync();
13: "Client connected".Dump();
14:
15: var stream = client.GetStream();
16: while (true)
17: {
18: var buffer = new byte[1024];
19: var bytes = await stream.ReadAsync(buffer, 0, buffer.Length);
20: var message = Encoding.UTF8.GetString(buffer, 0, bytes);
21: $"Received message: {message}".Dump();
22:
23: if (message.ToLower() == "bye")
24: break;
25:
26: "Say something back".Dump();
27: var response = Console.ReadLine();
28: var responseBytes = Encoding.UTF8.GetBytes(response);
29: await stream.WriteAsync(responseBytes, 0, responseBytes. Length);
30:
31: if (response.ToLower() == "bye")
32: break;
33: }
34:
35: client.Close();
36: server.Stop();
37: "Connection closed.".Dump();
由于发生了很多事情,我决定在这里使用行号。这使得引用我所解释的内容变得容易一些。
在第 7 行,我们创建了一个新的TcpListener类实例。这个类处理所有关于通信的细节,但它需要我们从它那里获取一些信息。我们向构造函数提供了两个参数,告诉它所有需要知道的信息。第一个是我们使用的地址。地址是网络适配器的唯一标识符,例如您的以太网或 Wi-Fi 适配器。这个 IP 地址是 OSI 模型的第 3 层,即网络层的一部分。它是 IP 规范的一部分。然而,多个应用程序可以同时在一个计算机中使用网络适配器。我们可以指定端口号以确保所有应用程序都能获得它们所需的数据,并将其发送到线路另一端的正确应用程序。这个或多或少是任意选择的数字决定了连接到该 IP 地址的应用程序将获得哪些数据。这个端口号是 OSI 模型的第 4 层。我说这个数字或多或少是任意的。技术上,你可以选择你想要的任何数字,但关于这些数字有一些约定。由于端口号决定了哪个应用程序获取或发送数据,因此标准有助于确保我们所有人都为相同的应用程序使用相同的端口。例如,Web 服务器默认监听端口80,除非它们使用安全的 HTTPS 协议。后者使用端口443。有很多“保留”的数字,但技术上,没有任何东西阻止你为你的聊天应用程序使用端口80。尽管如此,我不建议这样做:这会混淆其他人。
我想确保我们的聊天服务器监听端口8080,这是一个“免费使用”的数字。
我在这里多次使用了“监听”这个词。监听意味着应用程序等待另一个进程连接,无论是我们机器上的还是外部机器上的。将其与等待电话响铃进行比较:你在等待你的铃声响起,并准备好接听。
由于您的机器可以有多个网络适配器,您必须指定您想要监听哪一个。在这种情况下,我选择了一个固定的 IP 地址,IPAddress.Loopback,它对应于127.0.0.1 IPv4 地址。这个地址是本地机器,没有连接到任何实际的适配器。换句话说,我们只监听来自同一物理机器的连接。
第 8 行很简单:我们启动服务器。在第 14 行的AcceptTcpClientAsync调用中,我们告诉服务器接受任何传入的连接。
多个客户端可以同时连接到同一个服务器。这里的客户端代表的是已连接的客户端。我们只期望有一个客户端,所以我们不需要处理会话。记住:会话管理是 OSI 模型的第 5 层。我们假设只有一个客户端,并将它存储在变量client中。客户端的类型是TcpClient,以防你有所疑问。
这个调用是阻塞的,并且只有当客户端连接时才会继续,我们通过第 15 行的消息告诉用户这一点。
一旦我们建立了连接,我们就打开一个流来访问客户端的数据或使我们能够向该客户端发送数据。这个流是NetworkStream类型,它是双向的。我们在第 17 行将这个流存储在变量stream中。
数据以二进制形式传入。因此,我们使用ReadAsync来读取一个数据缓冲区。我假设没有传入的数据超过 1,024 字节。在现实世界的应用中,你可能无法做出这个假设,所以你必须继续读取,直到你有了所有数据。在这里,我们将这些数据存储在一个长度为 1,024 字节的字节数组中(第 20 和 21 行),并将其转换为 UTF8 字符串(第 22 行)。这就是我们的数据呈现方式,它是 OSI 模型的第 6 层。一旦我们有了这个字符串,我们就显示它。如果字符串是“bye”,我们就认为客户端想要断开连接。否则,我们允许服务器端的用户输入一个响应,并在将其转换为另一个字节数组后将其发送给客户端。我们在这里使用相同的流。
如果流中没有更多数据,或者有人在对话中使用“bye”这个词,我们将在第 37 行关闭连接,并在第 38 行停止监听。
客户端的代码非常相似。下面是代码:
01: using System.Net.Sockets;
02: using System.Text;
03:
04: "Client is starting up.".Dump(ConsoleColor.Yellow);
05:
06: var client = new TcpClient("127.0.0.1", 8080);
07: "Connected to the server. Let's chat!".Dump(ConsoleColor.Yellow);
08: var stream = client.GetStream();
09:
10: while (true)
11: {
12: "Say something".Dump(ConsoleColor.Yellow);
13: var message = Console.ReadLine();
14: var data = Encoding.UTF8.GetBytes(message);
15: await stream.WriteAsync(data, 0, data.Length);
16: if (message.ToLower() == "bye")
17: break;
18:
19: var buffer = new byte[1024];
20: var bytesRead = await stream.ReadAsync(buffer, 0, buffer. Length);
21: var response = Encoding.UTF8.GetString(buffer, 0, bytesRead);
22: $"Server says: {response}".Dump(ConsoleColor.Yellow);
23: if (response.ToLower() == "bye")
24: break;
25: }
26:
27: client.Close();
28: "Connection closed.".Dump(ConsoleColor.Yellow);
在第 6 行,我们创建了一个新的TcpClient类实例。同样,我们必须给它一个 IP 地址和一个端口号。这次,我们必须使用一个实际的数字。我们使用127.0.0.1,这意味着我们正在寻找同一台机器上的服务器。端口号仍然是8080;否则,我们的服务器将看不到任何进入的连接。
这个调用又是阻塞的,所以它不会继续,直到建立了一个连接。一旦我们有了连接,我们就可以访问流,就像第 8 行那样。这个流,再次,是NetworkStream类型,所以我们有一个双向连接。
我们为服务器所做的同样的事情。我们假设消息大小为 1,024 字节或更少。我们使用 UTF8 作为编码将字符串转换为字节数组,并从字节数组转换回字符串。我们使用“bye”这个词来表示停止交谈的愿望,并使用client.Close()来最终化连接。
如你所见,代码与服务器非常相似。我们在这里简化了许多事情:我们不考虑多个客户端连接到单个服务器的情况。我们对消息大小做了许多假设,并在出错时必须回退或重试机制。当跨机器工作连接时,事情出错是常有的事,所以你必须意识到这一点,并相应地编写代码。然而,由于这与实际的网络代码无关,正如我向你展示的那样,我可以安全地将这留给你去解决。
UDP
TCP 是一个优秀的协议,但它并非唯一。UDP 更直接且更轻量。当然,这也伴随着一些缺点。以下表格中,我概述了这两种协议之间的差异:
| 考虑因素 | TCP | UDP |
|---|---|---|
| 主要目标 | 可靠性 | 速度 |
| 顺序 | 保证顺序 | 不保证消息顺序 |
| 握手 | 是 | 否 |
| 错误检查 | 是 | 否 |
| 拥塞控制 | 是 | 否 |
| 用例 | 网络浏览、聊天、文件传输、电子邮件 | 视频流、在线游戏、VOIP |
表 6.3:TCP 和 UDP 对比
TCP 是可靠的。消息几乎总是到达。当事情出错时,TCP 会尝试重新发送数据,直到数据被成功交付。UDP 不关心这一点。它只是尽可能快地将数据发送出去。
TCP 确保消息按照发送的顺序到达。然而,UDP 并不保证:消息可能会以不同于离开源地的顺序到达目的地。
TCP 确保另一端准备好通信。UDP 只是开始发送数据。
TCP 检查数据以查看在传输过程中是否发生错误,甚至可以修复一些错误。UDP 并不关心:只要数据被发送,它就对此感到满意。
如果网络拥塞,TCP 可以减慢传输速度以帮助缓解。UDP 会尽可能快地丢弃数据,而不考虑网络条件。
当你必须有一个可靠、无错误的传输数据方式时,TCP 是最佳选择。例如,在聊天中,消息必须以预期的顺序正确传达。然而,UDP 完全是关于速度的。视频流就是一个例子:如果数据流中有时丢失了一部分,那并不是什么大问题。但是,缓慢的流会毁掉体验。
UDP 不常被使用,但它可以成为你工具箱中的一个宝贵工具。
使用共享内存在进程间交换数据
到目前为止,我们一直在向同一台计算机上的其他进程发送消息。使用命名管道和套接字,我们也可以使用其他机器。这正是这些协议的美丽之处:它们对网络是透明的。然而,如果你确定你只想留在同一台机器上,使用管道或套接字可能会成为一种负担。这些方法并不是最快的通信方式。在这种情况下,你可能更愿意使用 共享内存。
共享内存的设置非常简单。当然,这也伴随着一些缺点。几乎没有任何方法可以保证数据的安全或防止冲突。然而,它的速度非常快;真的,非常快。所以,让我们看看一个示例。
首先,我们来看看如何将数据写入共享内存:
using System.IO.MemoryMappedFiles;
"Ready to write data to share memory.\nPress Enter to do so.".Dump(ConsoleColor.Cyan);
Console.ReadLine();
using var mmf = MemoryMappedFile.CreateNew("SharedData", 1024);
// Create a view accessor to write data
using MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor();
byte[] data = System.Text.Encoding.UTF8.GetBytes("Hello from Process 1");
accessor.WriteArray(0, data, 0, data.Length);
"Data written to shared memory. Press any key to exit.".Dump(ConsoleColor.Cyan);
Console.ReadKey();
共享内存就像有一个只存在于内存中的文件。它是在内存中预留的一块区域。它有一个你可以用来识别它的名称。再次强调,它就像一个文件。在这里,我们创建了一个新的MemoryMappedFile类的实例,给它一个名称和大小。(在我们的例子中,1,024 字节)。如果你想使用那个文件,你必须获取MemoryMappedViewAccessor。你可以通过在MemoryMappedFile实例上调用CreateViewAccessor来获取它。
然后,你可以从这个访问器中读取和写入数据。
从那个共享文件中读取也同样简单。以下是代码:
using System.IO.MemoryMappedFiles;
"Wait for the server to finish. \nPress Enter to read the shared data.".Dump(ConsoleColor.Yellow);
Console.ReadLine();
using var mmf = MemoryMappedFile.OpenExisting("SharedData");
// Create a view accessor to read data
using MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor();
byte[] data = new byte[1024];
accessor.ReadArray(0, data, 0, data.Length);
$"Received message: {System.Text.Encoding.UTF8.GetString(data)}". Dump(ConsoleColor.Yellow);
我们使用几乎与写入者相同的代码。然而,我们不是在内存中创建一个新的文件,而是打开一个现有的文件。我们不需要指定大小,但必须知道名称。
一旦我们有了那个文件,我们就可以使用相同的代码来获取一个访问器。有了它,我们可以读取数据并显示它。简单,不是吗?
再次强调,这是一种在相同机器上的进程之间快速共享数据的方法。然而,也有一些缺点需要注意。例如,任何知道共享内存块名称的进程都可以访问它。没有任何安全性。当然,你可以通过使用加密来规避这一点。
另一个缺点是没有内置的机制来通知进程新的或更改的数据。你必须使用像信号量(semaphores)和互斥锁(mutexes)这样的东西来做这件事。你可以使用实际的文件来设置FileSystemWatcher以获得通知,但这对内存中的这些共享文件不可用。
另一个潜在的缺点是它仅适用于 Windows。这可能会限制你以后部署的选择。
但总的来说,共享内存是快速在相同 Windows 机器上的进程之间共享大量数据的好方法。利用它的优势!
RPC 概述及其在 IPC 中的应用
到目前为止,我们探讨了我们可以共享数据的方法。在大多数情况下,开发者使用这种方法仅仅是为了数据:从一个系统向另一个系统发送有效载荷。然而,有效载荷也可以是其他东西。它们可以是指令,用来指示软件执行某些操作。我们不是在系统中存储、转换和使用数据,而是告诉其他系统执行操作。在这种情况下,我们谈论的是 RPC。
要从外部控制系统,建立通信线路,确保你的安全措施到位,并定义一个协议。
有很多种方法可以做到这一点。在以前,我们曾经使用过 SOAP、DCOM、WCF 和其他技术来做到这一点。
RESTful 服务与 RPC
你可以将 RESTful 服务视为某种 RPC。然而,它们并不相同,我不想在这里深入讨论 RESTful 服务。它们有很多相似之处,但 RESTful 服务背后的基本理念是它们都是关于资源的。调用网络服务通常用于从服务器检索数据。技术上,你可以设置 RESTful 服务仅接受命令,这样它们就是 RPC。这就像把 calzone 当作披萨。技术上,这是正确的,但在实践中存在足够的差异,需要采取不同的方法。因此,我决定不将 RESTful 服务包含在这本书中。如果你选择使用 RESTful 服务与你的系统通信,请随意。
基本上,这非常简单。你想出一个方法来结构和发送命令。只要双方都理解发生了什么,这就可以正常工作。当然,你不必重新发明轮子:存在几个经过良好建立的标准来做这件事。在本章的后面部分,我会向你展示如何使用 gRPC 来做这件事。然而,就像所有标准一样,它们都有代价。有时,你不需要一个已建立框架提供的额外复杂性。有时,你只想向系统发送一个简单的命令。假设你的场景允许一个不太安全和未知的协议。在这种情况下,你可以通过拥有自己的协议来提高速度和内存。
JSON RPC 是实现这一点的最常用方法之一。让我们看看。
JSON RPC
JSON RPC 就是将你的命令封装在 JSON 结构中,通过网络发送它们,在另一端拦截它们,并执行命令告诉系统执行的操作。
让我们从定义我们想要发送的命令开始:
[Serializable]
internal class ShowDateCommand
{
public bool IncludeTime { get; set; }
}
我想让客户端通知服务器它需要打印当前的日期。我可能还想包括当前的时间。所以,这是我们创建的命令:带有 IncludeTime 字段的 ShowDateCommand。
在我的示例中,我将客户端和服务器放在了同一个应用程序中,每个都在不同的任务上运行。我这样做是为了简化。当然,如果你想向同一应用程序的另一个部分发送命令,RPC 就过于冗余了。这甚至是不正确的:它根本不是远程的。然而,对于这个演示,它运行得很好。
对于通信,我选择了一个命名管道。它很容易设置,可以用来在网络中发送消息。除了这些考虑因素,我实际上没有选择这个选项的真正原因,所以你可以做任何你想做的事情。
服务器部分看起来是这样的:
internal class Server(CancellationToken cancellationToken)
{
public async Task StartServer()
{
"Starting the server".Dump(ConsoleColor.Cyan);
await using var server = new NamedPipeServerStream("CommandsPipe");
"Waiting for connection".Dump(ConsoleColor.Cyan);
await server.WaitForConnectionAsync(cancellationToken);
using var reader = new StreamReader(server);
while (!cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync();
if (line == null) break;
$"Received this command: {line}".Dump(ConsoleColor.Cyan);
var command = JsonSerializer. Deserialize<ShowDateCommand>(line);
if (command is { IncludeTime: true })
DateTime.Now.ToString("yyyy-MM-dd
HH:mm:ss").Dump(ConsoleColor.Cyan);
else
DateTime.Now.ToString("yyyy-MM-dd").Dump(ConsoleColor. Cyan);
}
}
}
这个名为 Server 的类有一个名为 StartServer 的方法。它使用 CommandsPipe 名称创建一个 NamePipeServerStream 实例。然后,它等待客户端连接。一旦发生这种情况,我们就读取传入的数据。一旦我们得到一个字符串,我们就将其反序列化为正确的格式并执行它告诉的任务:打印当前的日期,并可选地包括时间。
客户端看起来像这样:
internal class Client(CancellationToken cancellationToken)
{
public async Task StartClient()
{
var newCommand = new ShowDateCommand
{
IncludeTime = true
};
var newCommandAsJson = JsonSerializer.Serialize(newCommand);
"Starting the client".Dump(ConsoleColor.Yellow);
await using var client = new NamedPipeClientStream("CommandsPipe");
await client.ConnectAsync(cancellationToken);
await using var writer = new StreamWriter(client);
$"Sending this command: {newCommandAsJson}".Dump(ConsoleColor. Yellow);
await writer.WriteLineAsync(newCommandAsJson);
await writer.FlushAsync();
}
}
客户端创建了一个ShowDateCommand的实例,并将IncludeTime设置为true。然后,它使用正确的名称创建了NamedPipeClientStream并连接到服务器。最后,它通过电线发送 JSON。这就是全部内容。
为了完整性,我在程序的Main方法中给出了初始化服务器和客户端的代码:
var cancellationTokenSource = new CancellationTokenSource();
"Starting the server".Dump(ConsoleColor.Green);
var server = new Server(cancellationTokenSource.Token);
Task.Run(() => server.StartServer(), cancellationTokenSource.Token);
var client = new Client(cancellationTokenSource.Token);
Task.Run(() => client.StartClient(),
cancellationTokenSource.Token);
"Server and client are running, press a key to stop". Dump(ConsoleColor.Green);
var input = Console.ReadKey();
"Stopping all".Dump(ConsoleColor.Green);
我创建了Server和Client的实例,在Task.Run()中启动它们,并等待用户按下键。在后台,Server和Client执行它们的工作,通过调用Dump()告诉你所有关于它的事情。请注意Dump中的线程 ID——它们对于了解线程(或刷新你的记忆)非常有信息量。
这种技术简单且非常快。然而,它仅在你知道等式的两端:服务器和客户端必须遵循你的专有协议。如果不是这样,你最好使用一个标准。这些标准之一就是 gRPC。让我们看看下一个。
gRPC 概述及其用于 IPC 的使用方法
目前建立进程间直接通信的领先方式之一是 gRPC。缩写gRPC代表Google 远程过程调用或递归命名的gRPC 远程过程调用。你可以选择你喜欢的。谷歌将其开发为一个公开版本和改进其内部框架 Stubby 的版本。
gRPC 使用协议缓冲区(Protobufs)。这是一种描述可用命令、消息和可以传递的参数的格式。Protobufs 被编译成二进制形式,从而实现更快的数据传输。该系统建立在 HTTP/2 之上,因此我们可以使用多路复用(多个请求通过相同的 TCP 连接)。HTTP/2 比旧的 HTTP/1.x 具有更多优势,其中大多数涉及效率。
跨语言和平台支持也是主要的驱动因素之一。因此,你可以确信 gRPC 可以在许多设备上使用。
假设我们想要重新构建一个示例系统,该系统可以远程指示显示当前日期(带或不带时间)。在这种情况下,我们首先必须定义消息结构。然而,在我们这样做之前,我们需要向我们的服务器应用程序添加几个 NuGet 包:
| 包 | 描述 |
|---|---|
Google.Protobuf |
处理 proto 文件 |
Grpc.Core |
gRPC 的核心实现 |
Grpc.Tools |
包含,例如,proto 文件的编译器 |
Grpc.AspNetCore |
需要在我们的应用程序中托管服务器 |
表 6.4:我们的 gRPC 服务器的 NuGet 包
在 C#控制台应用程序中,添加一个名为displayer.proto的新文件。这只是一个文本文件。我喜欢将它们放在一个单独的文件夹中,我称之为Protos。编译器会处理这个文件,为我们生成大量的 C#代码。
文件看起来像这样:
syntax = "proto3";
option csharp_namespace = "_02_GRPC_Server";
service TimeDisplayer {
rpc DisplayTime (DisplayTimeRequest) returns (DisplayTimeReply);
}
message DisplayTimeRequest{
string name = 1;
bool wantsTime = 2;
}
message DisplayTimeReply{
string message = 1;
}
让我们分析一下。
首先,我们告诉系统这是什么格式。我们使用proto3,这是最新也是推荐的版本。
然后,我们告诉系统在生成 C#文件时将它们放在哪个命名空间中。正如你所想象的,这个选项仅限于 C#。这是一个辅助选项,帮助我们保持代码的整洁。
然后,我们定义服务。我们有一个名为TimeDisplayer的服务。它有一个名为DisplayTime的 RPC 方法。它以DisplayTimeRequest作为参数,并返回DisplayTimeReply类型的某个东西。
DisplayTimeRequest和DisplayTimeReply类型定义在下面。它们是消息,并且可以包含参数。我添加了一个名字来展示如何添加一个字符串。对于请求,我还添加了一个布尔值,表示我们是否想要显示时间。
参数需要按顺序和编号。这样,如果消息以某种方式被打乱,两个系统仍然知道数据最初看起来是什么样子。
Visual Studio 通常知道如何处理这个,如果你在你的应用程序中添加了一个.proto文件。然而,如果这没有发生(我偶尔也看到它出错),你必须指导编译器如何处理这个文件。在你的csproj文件中,只需添加以下部分:
<ItemGroup>
<ProtoBuf Include="Protos\displayer.proto" GrpcServices="Server" />
</ItemGroup>
这应该足以让编译器开始工作了。
让我们构建服务器!
我在我的控制台应用程序中添加了服务器的代码。由于编译器会为我们编译所有必要的代码,我们可以使用以下代码:
internal class TimeDisplayerService : TimeDisplayer.TimeDisplayerBase
{
public override Task<DisplayTimeReply> DisplayTime(
DisplayTimeRequest request,
ServerCallContext context)
{
var result = request.WantsTime
? DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
: DateTime.Now.ToString("yyyy-MM-dd");
result.Dump();
return Task.FromResult(new DisplayTimeReply
{
Message = $"I printed {result}"
});
}
}
我们的TimeDisplayerService类是从TimeDisplayer.TimeDisplayerBase基类派生的。这个基类是由我们的.proto文件生成的。正如你所看到的,TimeDisplayer的名字与我们在那个.proto文件中的名字匹配。
我们这里有一个名为DisplayTime的方法。再次,这与我们在.proto文件中的内容匹配。代码很简单;它只接受一个DisplayTimeRequest实例,查看WantsTime参数,并返回结果。
通常,gRPC 服务器运行在一些类型的 web 服务器上,将此代码添加到 ASP.NET 应用程序中是直接的。但当然,你可以在任何你想运行它的地方运行它,这是我们作为系统程序员真正可以利用的事情。所以,如果你打算在控制台应用程序中运行此代码,你可以按照以下方式设置。在你的程序的主要方法中,添加以下内容:
"Starting gRPC server...".Dump();
var port = 50051;
var server = new Server
{
Services = {TimeDisplayer.BindService(new TimeDisplayerService())},
Ports = {new ServerPort("localhost", port, ServerCredentials. Insecure)}
};
server.Start();
Console.WriteLine("Greeter server listening on port " + port);
Console.WriteLine("Press any key to stop the server...");
Console.ReadKey();
await server.ShutdownAsync();
我们创建了一个新的Server类实例。这来自于我们安装的gRPC.Core NuGet 包。我们给它提供了我们想要使用的服务(在我们的例子中,是TimeDisplayerService)并定义了我们决定使用的网络地址和端口。在这里我不关心凭证,但你可以使用 SSL、TLS 和其他安全方式。
我们启动服务器并等待用户按下任意键。然后,我们再次停止服务器。
接下来:客户端。
再次,我们需要向我们的控制台应用程序添加一些 NuGet 包。这些是你需要的:
| 包 | 描述 |
|---|---|
Google.Protobuf |
处理 proto 文件 |
Grpc.Net.Client |
gRPC 的客户端实现 |
Grpc.Tools |
包含编译器,用于处理 proto 文件等 |
表 6.5:我们的 gRPC 客户端所需的 NuGet 包
首先,我们需要一个 .proto 文件。更准确地说,我们需要与服务器上使用的相同的 .proto 文件。因此,最好链接到该文件而不是重新创建它。然而,如果你喜欢键入,请随意创建一个新的。只需确保在做出更改时这些文件保持同步即可。
我们不需要特定的客户端类;我们只需在我们的程序 Main 方法中添加以下代码即可:
"Starting gRPC client... Press ENTER to connect.".Dump(ConsoleColor.Yellow);
Console.ReadLine();
var channel = GrpcChannel.ForAddress("http://localhost:50051");
var client = new
TimeDisplayer.TimeDisplayerClient(channel);
var reply =
await client.DisplayTimeAsync(
new DisplayTimeRequest
{
Name = "World",
WantsTime = false
});
Console.WriteLine("From server: " + reply.Message);
我们从等待用户按下一个键开始。由于我在解决方案中同时启动服务器和客户端,如果客户端在设置连接时比服务器快一点,我可能会遇到时序问题。
然后,我们使用正确的参数调用 GrpcChannel.ForAddress() 来设置连接。有了这个连接,我们使用正确的 DisplayTimeRequest 设置调用 DisplayTimeAsync 方法。结果应该返回并显示服务器执行的操作。
就这些了!我们现在有一个完全功能的服务器和客户端应用程序,它们通过 gRPC 进行通信。
JSON RPC 和 gRPC 的差异
正如你所见,设置 gRPC 服务器和客户端并不太复杂。但仍然,它会给你的代码增加一些复杂性。如果你不需要 gRPC 的优势,你可以使用 JSON RPC。但你在什么时候选择哪一个呢?
如果你的消息变得很大,gRPC 是更好的选择。记得我之前说过 I/O 很慢吗?嗯,JSON 文件通常比它们的二进制等效文件大得多。gRPC 使用较小的二进制格式,因此使用该格式进行数据传输要快得多。
然而,JSON 更易于阅读,更易于调试,也更易于人类解释。代码也更容易设置。.proto 文件是你必须习惯的东西。除此之外,编译器需要将 .proto 文件转换为 C# 类,这会使你的系统更加复杂。
总的来说,这取决于你的场景。然而,为了便于参考,我在下表中概述了 JSON RPC 和 gRPC 之间的差异:
| 特性 | gRPC | 使用 JSON 的 RPC |
|---|---|---|
| 序列化格式 | Protobufs(二进制格式) | JSON(文本格式) |
| 性能 | 由于二进制序列化,通常更高,初始设置和连接可能较慢 | 低于二进制格式,但设置更快(取决于通信设置) |
| 协议 | HTTP/2 | 通常为 HTTP/1.1 |
| 流式传输 | 支持双向流 | 有限支持,通常是请求-响应 |
| 类型安全 | 强类型合约(Protobuf) | 松散类型,容易在运行时出现错误 |
| 语言互操作性 | 高(原生支持许多语言) | 高(JSON 被普遍支持) |
| 网络效率 | 更高效(更小的负载,HTTP/2 特性) | 更低效(更大的负载,HTTP/1.1) |
| 错误处理 | 丰富的错误处理,具有明确的错误代码 | 通常依赖于 HTTP 状态代码 |
| 截止日期/超时 | 原生支持指定调用截止日期 | 通常在应用层管理 |
| 安全性 | 支持各种身份验证机制 | 通常在应用层添加 |
表 6.6:gRPC 和 JSON RPC 之间的差异
如您所见,尽管 gRPC 和使用 JSON 的 RPC 具有许多共同特性,但每个都有其特定的使用场景。选择最适合您场景的那个。
下一步
每个人都需要有人陪伴。这个真理甚至成为了一首歌的标题。对于系统来说,尤其是那些不是为人类使用而设计的系统,也是如此。它们需要某种东西来告诉它们该做什么,以及使用什么数据来做。它们需要相互通信。您现在已经看到了许多可以用来设置通信的方法。
我们已经探讨了 Windows 消息,这种传统的通信风格(尽管 Windows 仍然用于内部通信)。我们研究了命名管道和无名管道。然后,我们探讨了计算机之间最常用的通信方式:套接字。在这个过程中,我们还对 OSI 模型进行了研究,以了解我们需要在哪里编写代码,以及我们可以将哪些留给他人。
我们还探讨了在相同机器上使用共享内存快速共享数据的方法。
最后,我们研究了如何通过使用 JSON RPC 和 gRPC 来发布命令。
现在,我们应该准备好迈出下一步。毕竟,除了与我们的代码进行交流外,我们还可以使用操作系统来帮助我们。Windows 提供了许多我们可能需要或可以利用的服务,这是下一章的主题。
第七章:操作系统探戈篇
与操作系统服务一起工作
计算机是复杂的机器。它们可以有多种不同的形式,不同的外围设备,以及不同的功能。然而,许多不同的机器可以运行相同的软件。只要硬件符合相当广泛的边界(例如,运行特定的 CPU 架构),你的软件就不关心底层机器的外观。
所有这一切之所以能够工作,是因为我们有抽象。你几乎从不直接处理实际的硬件。总有一层层的软件需要通过,每一层都增加了一层抽象。这听起来很复杂,但这是好事。没有这个,我们就必须为所有可能的硬件组合重写我们的软件。想象一下,用户用一种带有旋转磁盘的老式硬盘驱动器换成了更现代、更快的 SSD。然后,他们必须来找你,以便你可以重新编译系统以适应这种情况。我相信,如果可能的话,你肯定不想在这方面浪费时间。
软件的最底层,即最接近硬件运行的,是基本输入/输出系统(BIOS)。这个系统在实际硬件和其上层级之间进行接口。BIOS 知道如何访问存储介质上的特定区域。它知道如何到达网络卡并获取位和字节传输到其上层的层级。它是实际硬件的看门人。
简而言之,下一层的抽象是操作系统(OS)。如今,操作系统和用户程序之间的区别已经不再非常明确了。例如,Windows 是一个操作系统。然而,它也附带了许多用户程序,例如图片查看器和计算器。然而,操作系统确实附带了许多我们作为系统程序员可以使用的实用程序。这些实用程序或操作系统中的系统帮助我们完成任务,而无需担心细节,同时仍然能够在许多不同的机器上运行。本章将解释 Windows 为我们提供的一些更实用的实用程序。
本章将探讨 Windows 为我们提供的一些服务。以下是本章我们将学习的内容:
-
Windows 注册表
-
工作者服务
-
Windows 管理规范(WMI)
-
注册表和 WMI – 风险及其避免方法
我们还将探讨涉及的风险以及如何最小化它们。毕竟,我们正在深入 Windows,当事情出错时,它们通常出错得很严重。
让我们从注册表开始吧!
技术要求
你可以在 GitHub 仓库中找到我们讨论的所有内容的所有源代码和完整示例:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter07。
如果你想要从头开始构建示例,你需要安装一些 NuGet 包。
对于 WMI 示例,你需要安装System.Management包。
Windows 注册表
几乎所有系统都有设置。这些设置是持久的;系统关闭、重启或任何原因后它们仍然存在。这些设置的内容各不相同;它们可以是系统需要的任何内容。这可能是一个数据库的连接字符串,一个可以存储文件的地点,用于生成报告的字体,等等。任何在编写软件时无法预先知道的内容,或者用户或系统管理员可能想要更改的内容,都应该放在系统之外的一个单独位置。
在过去,Windows 应用程序和系统使用[和]字符。键/值数据是一行,例如mykey=myvalue。每个部分或数据行都在单独的一行上,就是这样。
我们将 INI 文件放置在已知的位置,通常在主应用程序文件所在的同一目录中。
这些天,我们不再那么频繁地使用 INI 文件了。.NET BCL 没有为它们提供类,尽管如果你决定使用 INI 文件,第三方 NuGet 包可以帮助你。
INI 文件最明显的替代品是设置文件。设置文件通常以 JSON 格式存储,这使得它易于处理。你可以在我们曾经放置 INI 文件的地方找到它们:我们通常将它们放置在主应用程序旁边。
JSON 允许在设置中有一个更复杂的结构,具有层次结构。JSON 对于人类来说仍然很容易阅读,就像 INI 文件一样。这可能对系统管理员需要更改设置时很有用。
然而,JSON 文件并不总是存储设置的最好方式。这种方法有一些缺点。基于文件的设置的替代方案之一是使用 Windows 注册表。让我先解释一下这是什么以及如何使用它,然后我将概述两种选项的优缺点。
什么是 Windows 注册表?
注册表是 Windows 中的一个分层数据库,系统可以读取和写入各种数据。数据本身是一系列键/值对。键是字符串;值可以是字符串、数字或二进制。
Windows 注册表中的二进制数据
是的,你可以在注册表中存储二进制数据。然而,这并不意味着这是一个好主意。理论上的限制是每个值 1 MB,但我强烈建议不要这样做。如果你只有几个字节,那么存储和读取二进制数据是一个很好的主意,但如果你想要存储大量数据,你最好使用不同的机制。在注册表中存储大量二进制数据可能会减慢整个机器的速度,而不仅仅是你的应用程序。微软建议每个条目使用最多 1 或 2 KB 的二进制数据。超过这个量,你应该将你的数据移动到另一个位置。
注册表以树状结构组织。每个条目可以是键、子键或条目。条目是注册表中的最低级别:条目不能有子条目。
我可以通过向您展示我机器上的一些注册表内容来澄清这一点。

图 7.1:Windows 注册表
图 7**.1 展示了我机器上 Windows 注册表的一部分。在左侧,您可以看到包含所有键的树结构;在右侧是当前选中键的内容。此图像显示了控制面板中使用的颜色设置。这些是默认设置;您可以为每个用户设置不同的设置。
这就是注册表的一个大优点:您可以按用户存储设置,并且系统会在需要时确定使用哪个设置。您不必处理这个问题。
有 5 个顶级键。以下表格中解释了这些键。
| 键 | 描述 |
|---|---|
HKEY_CLASSES_ROOT |
这主要将文件连接到应用程序:例如,如果您在资源管理器中双击文件,Windows 应该启动哪个应用程序? |
HKEY_CURRENT_CONFIG |
这包含有关本地计算机启动时使用的硬件配置信息。 |
HKEY_CURRENT_USER |
所有关于当前用户及其首选项的信息都存储在这里。如果您更改 Windows 的主题,它最终会在这里结束。 |
HKEY_LOCAL_MACHINE |
包含特定于此计算机的配置信息,由所有用户共享。 |
HKEY_USERS |
在这里,您将找到此计算机上所有已知的用户配置文件。对于每个用户,他们的首选项和信息均存储在此处。 |
表 7.1:顶级注册表键
我在这里并不诚实。我刚才提到的某些顶级键是其他键的便捷快捷方式。例如,HKEY_CURRENT_USER 将当前登录用户映射到 HKEY_USERS 中的 HKEY_CURRENT_USER,而 HKEY_CLASSES_ROOT 是 HKEY_LOCAL_MACHINE\Software 的子键。但这些根键是为了帮助您。例如,您不必查找当前用户的 ID,然后在 HKEY_USERS 中找到该条目,而是可以直接打开 HKEY_CURRENT_USER 键,并确信您会得到正确的数据。
每个顶级键都可以有子键。每个子键都可以有自己的子键。总共可以深入到 512 层。每个顶级和子级键都可以有一个或多个键/值数据对。
此数据有一个数据类型。以下表格显示了可用的数据类型。
| Win 32 类型 | C#类型 | 描述 |
|---|---|---|
REG_NONE |
None |
没有数据类型 |
REG_SZ |
String |
一个以空字符终止的字符串 |
REG_EXPAND_SZ |
ExpandString |
包含未展开的环境变量引用的字符串 |
REG_BINARY |
Binary |
任何形式的二进制数据 |
REG_DWORD |
Dword |
一个 32 位的二进制数 |
REG_MULTI_SZ |
MultiString |
一个以空字符终止的字符串数组,以双空字符终止 |
REG_QWORD |
Qword |
一个 64 位的二进制数 |
- |
Unknown |
一个不受支持的注册表数据类型 |
表 7.2: .NET 注册表数据类型
表 7.2 然而,可能需要一点解释。
Win32 API 支持许多数据类型。然而,CLR 可用的类型较少。我们可以使用的类型是 RegistryValueKind 枚举的一部分。这些枚举定义的是我在 C# 类型列中列出的那些。
提供的类型应该足够你使用。然而,有时你需要使用特定的类型。例如,Win32 API 支持一个名为 REG_RESOURCE_LIST 的数据类型。你使用这种类型来存储资源相关的数据。不幸的是,C# 枚举没有提供等效的类型。在这些情况下,你可以使用 Unknown 类型。
ExpandString 可以非常有价值。如果你想存储有关文件位置的详细信息,你可以使用一个宏,例如 %PATH%。这个宏是系统中的当前路径。然而,路径是以这样的字符串存储的:%PATH%。如果你指定 ExpandString 作为类型,操作系统会在读取数据时将那个字符串转换为实际值。
但说实话,你可能最常使用 String、Binary 和 DWord。其他的虽然也有,但只是以防万一需要它们。
如何使用 Windows 注册表访问和存储数据
当向注册表写入数据时,你必须首先决定在哪里存储那些数据。例如,如果你想为当前用户存储一些特定信息,你可能使用 HKEY_CURRENT_USER 作为你的根键。作为系统程序员,我们更有可能选择 HKEY_LOCAL_MACHINE 或 HKEY_CURRENT_CONFIG 这样的键。这些位置与当前用户独立,这更符合我们的情况。但当然,如果你的用例需要,你可以使用任何你想要的键。
由于注册表是一个分层数据库,你必须指定一个层次结构。换句话说,你必须考虑一个树状结构来存储你的数据。
我在我的机器的根键中看到几个子键:HKEY_LOCAL_MACHINE: HARDWARE、SAM、SECURITY、SOFTWARE 和 SYSTEM。在 SOFTWARE 子键中,我看到很多子子键,其中许多是我机器上软件供应商的名称。
如果你想要向注册表写入数据,你必须考虑这一点:位置并不重要,但对于维护我们软件运行的机器的管理员来说,放置东西的逻辑性很重要。
假设我们想要存储软件在特定机器上第一次运行的时间。如果它之前从未运行过,我们存储当前的日期和时间。如果它之前运行过,我们检索那些数据:我们永远不会更改 first-run 日期。
要存储该信息,我们需要采取以下步骤:
-
找到
HKEY_LOCAL_MACHINE\SOFTWARE键。 -
创建一个名为
SystemProgrammers的子键。 -
创建另一个名为
Usage的子键。 -
在名为
FirstAccess的键中以二进制形式存储日期和时间。
当然,如果那个键还不存在,我们才能采取最后一步。如果它已经存在,那么软件已经运行了。在这种情况下,我们检索属于那个键的值,并展示给用户。
这就是它的样子:
var key = Registry.LocalMachine.CreateSubKey(@"Software\SystemsProgrammers\Usage");
var retrievedKey = key.GetValue("FirstAccess");
if (retrievedKey == null)
{
// create the value
key.SetValue(
name: "FirstAccess",
value: DateTime.UtcNow.ToBinary(),
valueKind: RegistryValueKind.QWord);
"First access recorded now".Dump(ConsoleColor.Cyan);
}
else
{
if (retrievedKey is long firstAccessAsString)
{
var retrievedFirstAccess =
DateTime.FromBinary(firstAccessAsString);
$"Retrieved first access:
{retrievedFirstAccess}".Dump(ConsoleColor.Cyan);
}
}
首先,我们创建子键。如果它已经存在,我们就获取它的引用。我们不需要逐个指定每个子键;我们可以给这个方法整个路径。在我们的例子中,Software\SystemsProgrammers\Usage,我们将其存储在LocalMachine根键中。
然后,我们尝试读取FirstAccess键的值。如果它是null,那么我们还没有创建它。因此,我们通过调用key.SetValue来完成这个操作。我指定类型为QWord,但 API 足够智能,可以自己推断出来:如果你想的话,可以省略它。我喜欢明确我的意图,所以我还是指定了它。
如果键确实存在,我们检索它。在我们得到DateTime之前,我们必须进行一些从long到DateTime的类型转换,但转换之后,我们可以显示结果。
运行这个示例
与注册表一起工作通常意味着你必须以提升的权限运行。这段代码只有在以管理员身份运行 Visual Studio 时才有效。但别担心:如果你忘记了,操作系统会很快通知你。作为普通用户,你无法在这个级别上写入注册表。
如果你想要更小心地处理你在注册表中存储的数据,你可以应用一些安全措施。毕竟,任何人都可以通过注册表编辑器应用程序打开注册表,你可能希望限制对特定键的访问。幸运的是,设计注册表的人也想到了这一点。因此,他们在上面启用了安全功能。
如果我们想让我们的键只对当前用户可访问,我们可以添加一些安全信息。
因此,在我们的示例代码中,在创建键之后,添加以下片段:
var currentUser = Environment.UserName;
var security = new RegistrySecurity();
var rule= new RegistryAccessRule(
currentUser,
RegistryRights.FullControl,
InheritanceFlags.None,
PropagationFlags.None,
AccessControlType.Allow);
security.AddAccessRule(rule);
key.SetAccessControl(security);
首先,我们获取当前用户的名称,并将其存储在名为currentUser的相应变量中。我们需要这个信息来告诉注册表我们想要给哪个用户(或拒绝哪个用户)访问我们的键。
我们创建RegistrySecurity类的新实例。然后,我们创建一个新的RegistryAccessRule,给它指定用户的名称,并决定我们希望这个规则应用于所有内容(完全控制),它不会被子类继承,不会被传播到子类,并且我们希望允许这个用户拥有完全控制权(另一种选择是拒绝访问)。
然后,我们将访问规则添加到安全对象中,该对象应用于键。就这样——一个受保护的关键!
一点建议——限制你在注册表中存储的内容
与注册表一起工作很简单。然而,我想强调一点:如果您不需要,请不要填写注册表。另外,如果您有您服务的安装程序,请确保卸载程序会删除您创建的所有键。一个杂乱的注册表是减慢 Windows 的最佳方式之一。公司之所以能从销售注册表清理应用程序中赚钱,是有原因的。不要成为那些搞乱用户注册表的开发者之一!
当然,注册表并不是我们存储值的唯一地方。有时,开销实在太大,我们确实需要这样做来实现我们的目标。让我们看看使用注册表和使用纯 JSON 设置文件之间的区别。
比较 Windows 注册表和 JSON 设置文件
您可能会惊讶地发现与注册表一起工作是多么容易。只需几行代码,您就可以存储和检索所需的信息。您可以轻松地区分当前用户和其他用户的数据。或者,更可能在我们这个案例中,您可以确保数据对当前机器上的所有服务都是可访问的。
然而,拥有一个带有设置的本地文件并没有什么不妥。毕竟,如果你创建了一个新项目,微软会给你一个 settings.json 文件,这是隔离设置的最佳方式。您在应用程序中使用的设置就紧挨着可执行文件。任何需要更改它们的人都可以进入那个文件夹并在需要时进行更改。
您会选择什么?您在什么时候使用哪一个?
好吧,让我们进行比较。
Windows 注册表
Windows 注册表有一些特定的功能,使其在某些场景下成为不错的选择。以下是它们:
-
集中存储:注册表是集中并由 Windows 控制的。人们通常会在这里寻找设置。
-
用户和机器特定设置:使用注册表,您可以针对当前用户、所有用户、本地机器或所有人设置特定的设置。您可以将设置放置在一个或多个这些位置,让操作系统确定何时使用哪一个。
-
安全功能:添加或撤销权限是内置在注册表中的。您可以在非常细粒度的层面上指定用户可以使用或不能使用您的密钥做什么。
-
性能:从注册表读取可能比读取文件更快,尤其是如果您处理的数据很小。
-
支持复杂类型:注册表可以处理不仅仅是字符串和数字。如果您的用例需要更复杂的数据类型,那么注册表很可能已经为您提供了支持。
本地 JSON 文件
JSON 文件被广泛使用。人们喜欢这种结构有几个原因。以下是一些原因:
-
简洁性和便携性:JSON 文件简单易懂。它们易于写入和读取。另一个优点是这些文件易于在不同系统之间传输。
-
人类可读和可编辑:你可以轻松编辑 JSON 文件:它们只是文本文件,结构易于理解。
-
不依赖 Windows:注册表仅适用于 Windows。JSON 文件无处不在。
-
版本控制友好:由于 JSON 文件是文本,像 Git 这样的系统可以处理和版本控制它们。
-
避免系统损坏:如果你搞乱了注册表,你可能会让 Windows 完全停止运行。或者,在稍微好一点的情况下,会对其他应用程序造成破坏。使用 JSON 文件,最坏的情况就是让你的应用程序变得无用。
因此,如果你的应用程序仅限于 Windows,你需要安全性和想要从集中式、多用户设置中受益,那么选择注册表。
如果你重视简洁性、跨平台兼容性和易于版本控制,本地 JSON 文件是一个更好的选择。
只要根据你的需求做出决定。
总结一下,大多数应用程序都需要访问设置。你可以将它们存储在本地 JSON 文件中,或者选择更灵活但稍微复杂一些的注册表。
我们已经了解了注册表的作用,如何从中读取数据,以及如何向其中写入数据。我们比较了注册表和本地 JSON 文件,现在我们可以决定何时使用哪一个。
工作服务
到目前为止,我给你们提供的所有示例都是控制台应用程序;简单直接,但针对读者你,以便你可以看到发生了什么。然而,在现实生活中,系统程序员不需要控制台来写入输出或读取输入。我们处理的是与其他软件交谈和监听的软件。系统软件通常没有用户界面。控制台窗口是一种用户界面,我们不需要它。
我将继续使用控制台,因为这是展示发生情况的直接方式,在这些应用程序中,我们可以专注于我试图展示的核心内容。
然而,在现实世界中,我们的应用程序主要在幕后工作。实现这一目标的一种方法就是构建服务。
服务是一个没有用户界面的独立应用程序。它在幕后默默工作。它确实会与外界通信,但它是通过前面章节中描述的许多方式之一进行的:通过网络连接、文件、管道等等。
传统上,如果你想获得一项服务,你必须创建一个Windows 服务。在你想说:“嗯,当然啦”之前,让我解释一下 Windows 服务在 Visual Studio 中是一种不同类型的应用程序和项目。就像控制台应用程序与 WPF 应用程序不同一样,Windows 服务是其自己的类型。
Windows 服务是一个没有用户界面的应用程序。启动和操作它们不是用户做的事情。Windows 负责这一点。在 Windows 机器上始终运行着数十个服务,控制着你的系统,并提供你需要的后台服务。
以下图像显示了在我机器上运行的服务列表的一部分。正如您通过滚动条所看到的,这只是总服务量的一小部分。

图 7.2:运行 Windows 服务
如您所见,服务有一个名称、描述、状态、启动类型和特定的用户,该用户控制它们。
名称和描述是显而易见的。状态可以是多种可能性之一,但在大多数情况下,它们要么是正在运行,要么是已停止。还有其他状态,但您几乎很少看到这些状态。
启动类型告诉我们服务是如何启动的。它可以自动完成,在 Windows 启动时立即进行。它也可以自动完成,但会有延迟,因此 Windows 会在启动它们之前等待一段时间。这允许您先让其他服务运行。它也可以是手动启动:Windows 根本不会启动它们。还有一些其他选项。最后,我们看到登录为列。此列定义了服务在哪个安全原则下运行。安全原则定义了服务拥有的权限。
Windows 服务功能强大,并且仍然存在。然而,在 Visual Studio 中,您不能再创建它们了。但这并不完全正确,请稍等片刻。
当前编写类似服务应用程序的方式是使用 Worker 服务模板。
Worker 服务是 Windows 服务的跨平台等效物。如果您在 Windows 上运行 Worker 服务,您仍然可以受益于 Windows 服务的功能。这就是为什么我说您不能再创建它们了并不完全正确。CLR 已经将 Windows 服务纳入了 Worker 服务中。
Worker 服务比 Windows 服务更容易构建和调试。由于 Windows 控制 Windows 服务,您必须进行技巧和魔法才能调试它们。然而,Worker 服务可以像控制台应用程序一样运行,尽管增加了额外的功能。
Docker 支持
如果您在 Visual Studio 中创建一个新的Worker Service 项目,您首先会看到一个标准对话框,询问您项目的名称、项目位置和解决方案名称。这几乎不足为奇;您在每种类型的项目中都会得到这个。然而,如果您输入这些详细信息并点击下一步,您会看到一个不同版本的以下对话框。对于控制台应用程序,Visual Studio 会询问您想要哪个版本的运行时(以及是否想要使用顶级语句)。对于 Worker 服务,Visual Studio 会询问您是否想要一个 Docker 容器。您的屏幕可能看起来像以下图像:

图 7.3:创建 Worker 服务所需额外信息
如果你勾选了 启用 Docker 前面的复选框,你可以选择你想要使用的操作系统。如果你安装了 WSL2,这通常是在 Windows 和 Linux 之间进行选择。Visual Studio 会为你创建一个 Docker 文件,现在你可以在一个容器中运行你的服务了。这不是很酷吗?
在 Docker 上开发和运行你的服务非常强大。Visual Studio 允许你在 Windows 机器上编写源文件,然后将它们部署到 Docker 镜像,并启动一个运行你的代码的容器。调试器甚至允许你在 Docker 容器中运行时从 Visual Studio 调试你的服务。
不幸的是,我将在本书中不涉及这一点。这个主题值得一本自己的书。然而,为了展示你的选项,我们将编写一个裸骨工作服务并在安装 Docker 的情况下运行它。如果你没有安装,那也行:工作服务在 Docker 容器中在 Windows 和 Linux 上运行是一样的。所以,你选择你认为最适合你的策略。
拆解工作服务
在 Visual Studio 中,你可以将工作服务作为模板选择。如果你这样做,你会被问到我们之前讨论过的问题:你想要哪个框架版本,你想要 Docker 支持?如果你想要,它应该运行在哪个操作系统上?
在我的例子中,我启用了 Docker 支持,并选择了 Linux 作为操作系统。无论你决定做什么:无论什么情况下,C# 代码都是相同的。
一个工作服务的基本版本比一个常规控制台应用程序的代码要多一些,但主要文件是 Program.cs 和 Worker.cs。
我的应用程序在 Visual Studio 解决方案窗口中看起来是这样的:

图 7.4:Visual Studio 中工作服务的布局
Program 类甚至没有多么有趣。它除了包含命名空间声明外,还包含以下代码:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
让我概述一下我为了得到这些结果所采取的步骤:
首先,我们创建 HostApplicationBuilder 类的一个实例。我们通过在静态 Host 类上调用 CreateApplicationBuilder 来做到这一点。
builder 实例允许我们注册类。这样,我们可以使用依赖注入。模板为我们添加了一个服务:Worker 类。
接下来,我们构建 host(IHost 类型),最后运行它。
更有趣的是 Worker 类中的代码。正如其名所示,所有的工作都发生在这里。我们不是没有理由称之为工作服务!
让我们来看看那个 Worker 类:
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
public Worker(ILogger<Worker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
await Task.Delay(1000, stoppingToken);
}
}
}
如果你想要重命名这个类,当然可以,只要你也在 Program 类中更改注册的类。此外,如果你想在 Worker 旁边构建多个类,你也可以这样做。再次提醒,不要忘记在 Program 类中将它们添加到 builder 中。
那么,这里发生了什么?
基类是 BackgroundService。这个类负责所有管道。它是一个具有 ExecuteAsync(CancellationToken stoppingToken) 抽象方法的抽象类。因此,你必须自己编写该方法(或者像我们在这里做的那样,让模板来做这件事。)
我们类的构造函数获取一个 logger 的默认实例,这允许我们在运行时将内容写入控制台。这个 logger 通过依赖注入的魔力对我们可用。
在 ExecuteAsync 方法中,我们持续循环,直到 CancellationToken 信号表示我们想要停止。在循环中,我们输出一条消息,然后等待一秒钟再进行下一次迭代。
如果你运行这个,你会看到输出。如果你正在运行 Docker Desktop,你还可以在 Docker 上运行它。只需选择 Docker 作为你想要运行的内容而不是你的应用程序。Visual Studio 构建镜像,部署它,启动一个容器,并将调试器连接起来以允许调试。
在 Visual Studio 和 Docker Desktop 本身的输出窗口中,你可以看到结果:我们的循环输出被打印在那里。
控制服务的生命周期
工作服务旨在永远运行。嗯,也许不是永远,但至少要长到你机器运行的时间。它在后台运行,完成其工作。它可能在进行一些有价值的工作,或者等待通过文件、网络或我们讨论的任何其他方式传入的消息。它完成工作后,然后返回等待下一个任务。
但如果你想在服务完成其目的后停止它怎么办?让我们稍微重写一下代码。在我们的工作服务 WorkerService 中添加一个私有变量 call _counter,其类型为 int。
然后,将 ExecuteAsync 中的循环更改为如下所示:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
await Task.Delay(1000, stoppingToken);
if (_counter++ >= 9)
{
break;
}
}
_logger.LogInformation("Worker stopped at: {time}", DateTimeOffset.Now);
}
等待一秒钟后,我们检查循环是否运行了 10 次。如果是,我们就跳出循环。然后,我们打印一条消息告诉你它已经完成了。
运行它并观察。你会注意到它按预期工作,但 Visual Studio 并没有恢复正常:它继续调试。然而,没有东西可以调试!如果你中断,你会看到 Visual Studio 仍然在 Program 中执行对 Host.Run() 的调用。显然,没有出路!
嗯,当然有。在这种情况下,我们只需要告诉 Host 我们想要它停止工作。我们可以非常容易地做到这一点。
我们再次使用依赖注入。我们可用的服务之一是一个实现了 IHostApplicationLifetime 接口类的实例。让我们将其添加到 Worker 类的构造函数中:
private readonly IHostApplicationLifetime _hostApplicationLifetime;
public Worker(
ILogger<Worker> logger,
IHostApplicationLifetime hostApplicationLifetime)
{
_logger = logger;
_hostApplicationLifetime = hostApplicationLifetime;
}
我们获取该实例并将其存储在局部字段中。
然后,在 ExecuteAsync 方法中,在记录我们完成循环之后,添加以下行:
_hostApplicationLifetime.StopApplication();
就这些了。Host 实例现在收到一条消息,告诉它停止工作并返回到操作系统。
总结工作服务
工作服务非常适合您想要在后台运行代码,没有用户界面,只供其他软件调用的场景。这听起来熟悉吗?这正是系统程序员所追求的。工作服务是您的最佳选择。它们可以做您想要做的任何事情。它们可以在 TCP 连接上打开端口,监视文件夹中的文件,拥有命名管道等待数据并处理它,等待网络连接,等等。
简而言之,它们是完成所有这些工作的比我们迄今为止编写的控制台应用程序更好的地方。您仍然可以将它们注册为 Windows 中的服务,这样它们就会在 Windows 启动时自动启动。
代码足够简单。确保您的代码是从Worker类启动的,这样您就可以开始了。
我们将在本书的其余部分坚持使用控制台应用程序。不是因为它们是做系统编程的更好方式(它们不是),而是因为它们非常简单,在我解释新主题时不会妨碍我。但现在您知道了:您在控制台应用程序中能做的所有事情都可以(并且应该)在工作服务中完成。如果您的代码是跨平台的并且不使用仅限 Windows 的 API,您可以将代码部署到 Linux 机器或 Docker 平台上。一切尽在您的掌握!
WMI
系统程序员比其他更面向用户的程序员更接近操作系统。我们往往需要比其他人更多地了解操作系统的状态。我们可能需要跟踪内存使用情况、硬件状态和其他底层项目。幸运的是,Windows 允许我们做到这一点。我们可以打开一个窗口(无意中用了双关语),就像进入机房一样。
WMI 是您需要使用的工具。它就像管理 Windows 中项目的瑞士军刀。WMI 是 Windows 操作系统的一部分,允许您访问和操作各种系统信息和设置。当然,这仅限于 Windows。在 Linux 中,没有内置的、开箱即用的 WMI 替代品。如果您想在 Linux 机器上做这件事,请使用外部库和工具。
您可以用 WMI 做什么?您最好问,“您不能用 WMI 做什么?”让我向您展示一些 WMI 更常见的用途:
-
监控系统健康:您可以检查 CPU 负载、可用内存、磁盘使用情况等等。
-
管理硬件和软件:您可以获取有关已安装软件的信息,管理打印机,甚至可以玩弄 BIOS 设置。
-
自动化任务:您可以使用 WMI 来自动化任务,例如在必要时监控和重启服务。
-
事件通知:我们已经看到了监视文件夹或文件的可能性,但我们能做的远不止这些。我们可以为系统上发生的几乎所有事情获取通知。
使用 WMI,你可以做很多事情。然而,让我们先关注这些。在我们开始查看一些示例之前,你需要安装一个 NuGet 包:来自 Microsoft 的System.Management。这个包替换了.NET Framework 中的一部分较旧的System.Management程序集。
如何使用 WMI
与 Windows 交互的主要方式是通过查询它。System.Management NuGet 包为我们提供了一个名为ManagementObjectSearcher的类。这个类允许我们创建查询并在 Windows 上运行它们。搜索器通常会返回一个ManagementObject实例的集合,你可以与之交互。这些ManagementObject实例反映了你系统中的某些内容。这个类有一个索引器,因此你可以查询该对象以获取你正在搜索的信息。
ManagementObjectSearcher可以搜索很多不同类型的数据提供者。这意味着你可能需要先给它一个范围来限制搜索。
查询本身是一个以SELECT开头的字符串,就像数据库一样。
虽然要小心行事;我们正在打开引擎盖,在我们通常不应该乱动的地方乱摸。大多数在 WMI 上运行的查询都需要提升的权限。你需要是本地管理员才能进行一些更改或查看一些数据。这意味着所有的安全措施都失效了。你只能靠自己。权力越大,责任越大,对吧?
所有的以下示例都只能在 Windows 上运行。Visual Studio 足够智能,能够看到这一点:如果你跟着做,你会看到很多关于这个的警告。具体来说,你会经常收到CA1416警告。这个警告说:“这个调用在所有平台上都是可到达的。ManagementObjectSearcher 仅在以下平台上受支持:Windows”。
为了消除这一点,请在文件顶部添加一个 pragma:
#pragma warning disable CA1416
这个指令告诉编译器不要管:我们知道我们在做什么。现在,编译器不再妨碍你,让你负责所有可能发生的损害,即使你仍然尝试在 Linux 上运行此代码。
但不要吓唬人。在我们开始就本地管理员角色的利弊进行激烈辩论之前,我们必须稍微测量一下热度。让我们测量一下我们的 CPU 温度!
读取 CPU 温度
大多数 BIOS 实现都允许系统读取当前的 CPU 温度。其他主板供应商可能有其他读取系统温度的方法,其中之一可能是 CPU。这完全取决于供应商。然而,如果你的系统支持它,你可以轻松地读取当前的温度。如果你注意到 CPU 工作得太辛苦,你可以使用这些信息来降低系统中的工作量。但我们是怎样得到这个温度的呢?代码相对简单:
public void ReadTemperaturesUsingMsAcpi()
{
var scope = "root\\WMI";
var query = "SELECT * FROM MSAcpi_ThermalZoneTemperature";
var searcher = new ManagementObjectSearcher(scope, query);
try
{
foreach (var o in searcher.Get())
{
var obj = (ManagementObject)o;
var temperature = Convert. ToDouble(obj["CurrentTemperature"]) / 10 - 273.15;
$"CPU Temperature: {temperature}°C".Dump();
}
}
catch (ManagementException)
{
"Unfortunately, your BIOS does not support this API.".Dump(ConsoleColor.Red);
}
}
首先,我确定了查询的范围。在这个例子中,它是 root\\WMI。然后,我创建了查询字符串,其中我们选择了 MCAcpi_ThermalZoneTemperature 类中的所有内容。正如我之前所说的,这会产生一个 ManagementObject 实例的集合。在这种情况下,集合中只有一个项目。这个项目有一个索引器,如果我们请求 CurrentTemperature 字段,我们将得到 CPU 的当前温度,单位是千分之一开尔文。我们将该结果乘以十以得到实际的千分之一开尔文值,然后将其转换为摄氏度。如果你想转换成华氏度,请随意。
正如我说的,并不是所有供应商都提供这个选项。我经常使用一台 2018 年的老款笔记本电脑,但它没有提供这些信息。当我尝试获取结果时,我在那台机器上看到了 ManagementException。然而,在我的强大台式机上,我如预期地得到了结果。
你还可以使用另一个类来查询温度。那个查询是 SELECT * FROM Win32_TemperatureProbe,而那个查询的范围是 root\\CIMV2。
然而,你的机器中可能有多个探头。一些主板还支持测量其他组件的温度,例如 GPU。坦白说,Win32_TemperatureProbe 的实现甚至比 MCAcpi_ThermalZoneTemperature 更不常见。
读取 BIOS
BIOS 是您机器上抽象层次最低的部分。在这个层次,所有的逻辑都被转换成电压,并输入到硬件中。看到那里发生了什么不是很好吗?嗯,你可以,利用 WMI 的力量!
让我们从 BIOS 中获取一些基本信息并显示出来。下面是:
public void ReadBIOSDetails()
{
// Create a management scope object
ManagementScope scope = new ManagementScope("\\\\.\\ROOT\\cimv2");
scope.Connect();
// Query object for BIOS information
ObjectQuery query = new ObjectQuery("SELECT * FROM Win32_BIOS");
using ManagementObjectSearcher searcher = new ManagementObjectSearcher(scope, query);
foreach (var o in searcher.Get())
{
var queryObj = (ManagementObject)o;
"----------------------------------
".Dump(ConsoleColor.Yellow);
"BIOS Information".Dump(ConsoleColor.Yellow);
"----------------------------------
".Dump(ConsoleColor.Yellow);
$"Manufacturer:
{queryObj["Manufacturer"]}".Dump(ConsoleColor.Yellow);
$"Name:
{queryObj["Name"]}".Dump(ConsoleColor.Yellow);
$"Version:
{queryObj["Version"]}".Dump(ConsoleColor.Yellow);
}
}
对于这个例子,我使用了不同的构造函数。不是将范围和查询作为字符串传递,而是首先使用托管的 ManagementScope 和 ObjectQuery 包装类构建这两个项目。这种方式可以达到相同的结果,但可能对将来在此代码上工作的开发者来说更易于阅读。
结构与上一个例子类似。我们创建了一个 ManagmentObjectSearchers 类的实例,向其提供范围和查询,然后查询结果。在我们的例子中,我们得到了制造商、名称和版本。
你可以从 BIOS 中读取许多其他属性,例如功能。这些描述了在该机器上支持哪些硬件。作为一个系统程序员,你可能可以想象知道这些信息是多么有用。
控制 Windows 更新服务
我们在本章前面讨论了工作服务。然而,如果我们能够编写软件来监控这些服务的状态并在需要时对其采取行动,那不是很好吗?嗯,我们可以用 WMI 来做到这一点。
在以下示例中,我们查看一个通用服务的状态:Windows 更新服务。这个服务是操作系统的一部分,负责处理更新:监控、下载和安装。理想情况下,该服务应该始终处于运行状态。让我们看看 WMI 能做些什么来实现这一点:
public void ControlService()
{
// Define the service. In this case,
// we're using the Windows Update service
string serviceName = "wuauserv";
// Define the query to get the service
string queryString = $"SELECT * FROM Win32_Service WHERE Name = '{serviceName}'";
// Create a query to get the specified service
ManagementObjectSearcher searcher =
new ManagementObjectSearcher(queryString);
// Execute the query
foreach (var o in searcher.Get())
{
var service = (ManagementObject)o;
// Check the service state before trying to stop it
if (service["State"].ToString().ToLower() == "running")
{
// Stop the service
service.InvokeMethod("StopService", null);
// Wait a bit for the service to stop
System.Threading.Thread.Sleep(2000);
// Start the service again
service.InvokeMethod("StartService", null);
$"{serviceName} service restarted successfully.".Dump(ConsoleColor.Cyan);
}
}
}
在这个例子中,我没有指定作用域。Windows 随后假定默认的作用域为root\CIMV2,就像我们之前看到的那样。通常,最好使用作用域。指定作用域限制了 WMI 执行查询的区域,这可以极大地提高速度。我只是想在这里展示这种方法,让你知道这是一个选项。
我们正在寻找一个名为wuauserv的服务,即 Windows 更新服务。如果我们找到它,我们获取当前状态。它应该是“正在运行”。如果是,我们停止它,等待两秒然后重新启动它。
就这样:你现在可以在代码中控制服务了!
观察 USB 设备
有时候,你可能会依赖于特定的硬件。假设你正在从 USB 设备读取数据。当用户移除设备时,你会不会希望得到通知?这可以防止你的代码中出现尴尬的错误,对吧?再次,WMI 来帮助我们!
这是实现这一点的代码:
public void StartListening()
{
string wmiQuery = "SELECT * FROM __InstanceDeletionEvent WITHIN 2 " +
"WHERE TargetInstance ISA'Win32_USBHub'";
ManagementEventWatcher watcher = new ManagementEventWatcher(wmiQuery);
watcher.EventArrived += new EventArrivedEventHandler(USBRemoved);
// Start listening for events
watcher.Start();
"Unplug a USB device to see the event.\nPress ENTER to exit.".Dump(ConsoleColor.Cyan);
Console.ReadLine();
// Stop listening for events
watcher.Stop();
}
查询告诉系统查看一个名为__InstanceDeletionEvent的事件。这是当计算机上删除某个东西时 Windows 引发的事件。在这种情况下,我们在ISA'Win32_USBHub'注册的设备列表中寻找某个东西。换句话说,我们希望当 USB 设备从系统中删除时得到通知。WITHIN 2意味着我们希望每 2 秒检查一次。所以,可能会有延迟。
这次,我们创建了一个新的对象。观察者(watcher)是ManagementEventWatcher类的一个新实例。我们给它一个查询,设置一个回调以备事件发生,然后开始观察。当我们完成时,我们再次停止观察。
事件处理器看起来像这样:
private void USBRemoved(object sender, EventArrivedEventArgs e)
{
// Get the instance of the removed device
ManagementBaseObject instance = (ManagementBaseObject) e.NewEvent["TargetInstance"];
// Extract some properties
string deviceID = (string)instance["DeviceID"];
string pnpDeviceID = (string)instance["PNPDeviceID"];
string description = (string)instance["Description"];
var message =
$"USB device removed:" +
$"\n\t\tDeviceID={deviceID}" +
$"\n\t\tPNPDeviceID={pnpDeviceID}" +
$"\n\t\tDescription={description}";
message.Dump(ConsoleColor.Yellow);
}
一旦事件发生,这段代码就会被调用。EventArrivedEventArgs类型的eventargs包含了很多信息。其中之一是TargetInstance。TargetInstance包含我们可以显示的各种信息。
我们可以使用另一种方法:我们可以查询Win32_DeviceChangedEvent类。那会容易一些,但那给我们提供的信息比我们当前的方法要少。这是 WMI 的典型情况:通常有不止一种方法可以得到期望的结果。
试试看;启动代码,然后从你的机器上拔掉几个设备。看看会发生什么!
关于 WMI 的结束语
WMI 非常强大。你可以做很多通常对普通.NET 应用程序不可用的事情。然而,也有缺点:WMI 非常资源密集。我们在上一个示例中将事件观察者设置为每两秒运行一次,以稍微减轻这一点。
WMI 有些神秘。你必须自己找出查询;关于所有可用选项的信息并不多。当然,微软的文档有很多关于这个主题的内容,但它并不像你习惯的那样直接。如果你想要深入研究,学习曲线相当陡峭。这导致另一个风险:你可能会很快出错。
WMI 允许你与系统的较低部分进行交互,这可能导致灾难性的后果。另一个风险是它无意中显示敏感信息。所以,在使用它时要小心。一如既往,测试一下如果你使用这种技术会发生什么。
然而,如果你小心谨慎,你可以做很多酷的事情!
我们在这里看到了一些真正不错的东西。你可能会被诱惑在你的系统中添加大量的注册表和 WMI 代码。然而,在你这样做之前,让我们看看它的缺点:有一些潜在的风险我们应该讨论!
注册表和 WMI – 风险及如何避免
没有什么是免费的。这也适用于操作系统服务:你必须付出代价。代码的复杂性并不高;我确信你能跟上。不,你必须付出的代价在于:错误可能很难发现,甚至更难修复。风险相当高:一个错误可能导致机器出现不可预测的行为。如果你操作不当,可能会使整个服务器崩溃。
当然,我们都是杰出的开发者。我们不会犯错误,对吧?然而,以防万一我们有一瞬间的不坚定(我们都知道连续 14 小时开发软件并不是最佳选择),我想告诉你一些风险以及如何尽可能地避免它们。
在此之前,我们有一个完整的章节(第十一章,确切地说)是关于调试的。那是我们将深入探讨该主题的细节的地方。但在这里,我想关注的是在处理注册表和 WMI 时可能会出现的问题。
Windows 注册表
如前所述,Windows 注册表是操作系统和大多数在其上运行的应用程序存储和读取其设置的地方。这些可能从关于用户偏好的简单值到关于已安装外围设备的详细信息。
在这里犯错误可能会导致应用程序无法按预期工作。然而,它也可能导致机器完全崩溃。所以,你在注册表周围摸索时最好要小心!
你可以采取几个步骤来减轻风险。让我们逐一来看。
备份
如果你开始对注册表进行实验,我能给出的最好建议是备份你的当前设置。你可以在注册表编辑器工具(Windows 目录中的regedit.exe)中导出和导入键和子键。这意味着当你犯错时,你可以轻松地回滚你的更改。如果你在应用程序中这样做,你可能需要考虑在应用更改之前读取你的应用程序设置并将它们存储起来。当然,当存储简单的设置时,你不需要这样做,但这可能会在你需要时救你一命。
正确的工具
最后,注册表本身是你计算机存储介质上的文件集合。毕竟,数据需要存储在某处。这些文件存储的位置并不保密。例如,你可以在%UserProfile%\Local Settings\Application Data\Microsoft\Windows文件夹中的UsrClass.dat文件中找到HKEY_LOCAL_USER设置。然而,我不建议你自己随意操作这些文件。使用工具。上述的注册表编辑器是读取和更改设置的好方法。如果你想在你的软件中这样做,请使用 BCL 和 Win32 API 提供的工具。如果你对运行时注册表发生的事情感到好奇,SysInternals 提供的免费 Process Monitor 工具是无价的。它可以实时查看所有与注册表协同工作的进程。可能会让你惊讶注册表被使用的频率有多高!
保持最小化
注册表不是为了存储大量数据而设计的。它是用于存储较小的项目,如设置和首选项。明智地使用它:不要在那里存储太多数据。一个好的解决方案是将大量数据存储在文件中,并在注册表中以一个已知的位置存储该文件的存储位置。这样,你可以区分不同的用户(因为注册表跟踪用户并向你展示正确的HKEY_LOCAL_USER实例),但仍然有一个地方可以存储更多数据。
记录日志是你的朋友
记录日志始终是调试代码时的一个好工具,但当你处理注册表时,这一点尤其正确。当出现问题的时候,日志可以救命。除此之外,日志可以帮助你理解你的软件流程,并阐明为什么你的代码中采取了特定的路径。在开发过程中,你永远不会拥有太多的日志文件。然而,当你进入生产环境时,你可能希望减少日志的数量和详细程度。
错误处理
错误处理应该是不言而喻的。尽可能多地使用 try-catch 块。不要捕获通用的Exception类,而要具体。毕竟,只捕获你能处理的异常规则仍然有效。
与注册表协同工作的软件可能会遇到诸如SecurityException、IOException和UnauthorizedAccessException等异常。请注意这些异常。在继续流程之前,捕获它们并使你的软件返回到一个已知的状态。
此外,请记录这些实例!
在隔离环境中进行测试
当处理注册表的更危险区域时,你可能想在不同于日常设备的另一台机器上操作。你不需要切换到另一台机器,但可以使用其他技术。你可以在本地或云中快速部署一个 虚拟机(VM)。使用 Azure,创建 VM、部署和运行你的代码非常简单。如果一切顺利,那就没问题。如果不顺利,你只需要删除 VM 再试一次。
另一个好方法是使用 Docker。如果你将 Docker 从 Linux 容器切换到 Windows 容器,你就可以将你的 Worker 服务部署到 Docker 中,然后在容器中的隔离、本地注册表中工作。如果出了问题,也不会造成伤害。如果你还记录到一个存储在容器外部的持久文件,以便在容器崩溃后它仍然存在,你可以方便地进行事后分析。
处理 WMI 时的潜在风险
WMI 可以非常强大。你可以查询你的系统而无需求助于注册表。你还可以更改设置、启动服务、配置网络设置等等。
然而,WMI 并不容易。文档是有的,但你必须自己寻找并拼凑起来。不过,我可以给你一些提示和技巧,帮助你快速掌握 WMI 并利用它。
从基础知识开始
了解你的 WMI 查询语言(WQL)。它有点像 WMI 系统的 SQL。由于你经常以字符串的形式将查询传递给 WMI,你应该小心不要犯拼写错误。它们很难被发现。了解 WQL 的语法可以帮助你解决这些问题。
常犯的一个错误是在查询系统时没有使用正确的命名空间。尽管大多数查询都是针对 ROOT\CMIV2 命名空间运行的,但并非所有都是。确保你使用正确的命名空间。
使用正确的工具
当你学习关于 WMI 的知识时,你可能想先玩一玩。Windows 中有一个几乎鲜为人知的小工具,叫做 WBEMTest。你可以在 Windows 的 搜索 字段中输入该术语来启动它。
这个工具是快速进入 WMI 的一个途径。用户界面看起来像是直接从 Windows 95 出来的,但它是调查 WMI 的好方法。例如,如果我想了解更多关于我的 BIOS 供应商的信息,我可以使用我们之前看过的代码,或者在 WBEMTest 中输入它。它看起来像这样:

图 7.5:WBEMTest 查询 BIOS
这张图片展示了 WBEMTest 的实际应用。在左上角,我将应用程序连接到了 ROOT\CMIV2 命名空间。然后,我点击了 SELECT * FROM Win32_BIOS 查询。你可以在右下角的窗口中看到结果。
PowerShell 也是在将 WMI 集成到系统之前与 WMI 交互的绝佳方式。你可以使用 Get-WMIObject cmdlets 来查询系统。例如,获取 BIOS 的信息会导致以下结果:

图 7.6:PowerShell 中的 Get-WMIObject
正如你所见,我可以输入Get-WMIObject,然后传递我想查询的对象的名称(Win32_Bios),然后我会得到所有格式良好的结果。
提高你的代码
我给出的处理注册表的技巧也适用于这里:捕获正确的异常并尽可能多地记录。
在使用 WMI 时最常见的异常是ManagementException。当然,我们也看到了在特定平台上不支持查询的情况。注意这些问题,并适当处理。
记录日志也是调试你的 WMI 代码的绝佳方式。在开发过程中尽可能多地记录,以便在事情出错时知道发生了什么。
性能和内存考虑
作为系统程序员,我们非常关注性能和内存使用。WMI 可能会显著减慢你的应用程序。特别是当频繁轮询时,你会看到性能的下降。避免频繁轮询。你根本不需要每毫秒都检查温度。
此外,别忘了妥善处理所有与 WMI 相关的 CLR 类。如果那些类的句柄保持打开时间过长,你可能会耗尽可用资源。这是让你的系统突然停止的绝佳方法。让我们不要这么做!
最后一点:WMI 依赖于 WMI 服务。是的,那是一个 Windows 服务。如果该服务没有运行,WMI 将无法工作。这种情况不太可能发生,但可能会发生。所以,如果事情没有按预期进行,请也检查那个服务。
当然,我在这本书中给出的所有其他技巧和窍门也适用。WMI、Worker Services 和注册表并没有什么神奇之处。只是它们可能需要更多的关注,以避免陷入奇怪的情况。
下一步
Windows 为你提供了许多工具。这些工具与系统深度集成。其中大多数工具用户的应用程序几乎从未使用过。但对我们这些系统程序员来说,情况就不同了。我们更接近底层工作,所以了解底层能提供什么是有好处的。
我建议你玩玩注册表编辑器,看看你能在那里找到什么隐藏的宝藏。在那旁边,学习 WQL。许多工具都提供了对 WMI 的友好界面,但最终,你会在你的应用程序中拥有 WQL 字符串。你不妨开始了解它们。
最后,学习 Docker。Docker 是打包应用程序的绝佳方式,也是一款宝贵的调试工具。你可以使用 Docker 来隔离可能危险的代码。如果出了问题,你只需要删除正在运行的容器并重新开始。当然,我们本章讨论的所有内容都仅在 Windows 上可用,所以你必须使用 Docker 上的 Windows 容器。当你确定你的代码运行正常时,你可以在真实的 Windows 机器上使用它。
在本章中,我们探讨了 Windows 为我们提供的所有工具;我们可以在代码中使用这些工具来完成如果我们必须自己编写一切时难以完成的事情。我们学习了被称为注册表的集中式设置存储机制。
我们还学习了我们可以通过使用 WMI 来查询操作系统甚至底层硬件的方法。我们讨论了如何使用它们,以及如何避免其中的一些风险。
现在,随着我们掌握了这些技能,是时候摆脱单机的限制,进入网络的世界了。现在的系统很少在单个机器上独立运行。它们会进行通信。它们会互相交谈。我们应该开始关注网络。所以,连接你的拨号调制解调器,跟随我们探索网络协议的道路!
第八章:网络导航者
构建高性能 网络应用程序
软件很少孤立存在。对于系统程序来说,这一点更是如此。由于这些程序不直接与用户交互,它们依赖于其他软件来提供输入,读取它们的输出,并被告知要做什么。那个“其他软件”通常位于同一台机器上,但同样经常,那个软件运行在其他地方。
到目前为止,我们已经讨论了如何将数据传输到我们的应用程序以及从我们的应用程序中传输数据,并简要地了解了网络。本章将专注于这个特定主题:网络。准备好深入到互联软件的世界吧!
在本章中,我们将探讨以下主题:
-
基础知识和 OSI 层
-
探索 System.Net 命名空间(包括最常用的协议)
-
使用 System.Net.Sockets 以获得更多控制
-
异步、非阻塞网络
-
如何提高网络性能
-
网络错误和超时,以及如何处理这些问题
我们即将跳出盒子,连接到外部世界。让我们出发吧!
技术要求
本章的所有代码示例都可以在github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter08找到。
基础知识
我们已经讨论了 OSI 模型。但为了快速回顾,OSI 模型定义了构成系统的层,使我们能够与其他系统通信。这些层从最低层开始,描述网络适配器应该能够处理的电压,到最高层,描述使用网络的程序。
OSI 层的漫步
我想再次带你们走过所有层,详细说明每一层发生的事情。为此,我想讨论一个用户使用 FTP 发送数据的情况。FTP,即文件传输协议,是一种较老的技术,几乎不再使用,用于向远程机器发送数据或从远程机器获取数据。
FTP 曾经是实现这一点的最佳方式,但由于缺乏安全功能,人们转向了其他方式。我们稍后会讨论其中的一些,但我们仍然可以使用 FTP 来了解 OSI 模型。这使得事情更容易理解。
一个 FTP 客户端可以简单到只是一个控制台应用程序。实际上,几乎所有的 FTP 客户端都是这样。也有基于 GUI 的客户端,但它们只是 FTP 命令的包装器。
要传输文件,用户启动 FTP 客户端,指定要连接的服务器,并可选地传递凭据。然后,用户使用 GET 和 PUT 等命令来传输文件。另一个命令是 LS,用于获取远程目录的内容。我们还有 MKDIR 命令来创建远程目录和其他类似命令。
因此,让我们假设用户正坐在他们的机器上,并想要登录到远程计算机。为此,用户在命令提示符中输入 ftp username:password@127.0.0.1。这会做几件事情:
-
它启动了 FTP 的命令行版本
-
然后它告诉它连接到地址为 127.0.0.1 的计算机(如您可能记得,这是本地主机)
-
它提供服务器需要的用户名和密码。
几秒钟后,客户端列出请求位置中的所有文件。但是当用户按下 Enter 键时,计算机里会发生什么?
在启动应用程序后,FTP 客户端接管。
命令和数据通过 OSI 层流动。让我向您展示会发生什么:
-
第 7 层:应用程序在 OSI 第 7 层,即应用层运行。应用程序中的 FTP 协议随后建立连接。FTP 创建两个连接:一个用于控制命令,一个用于数据传输。
-
open命令将字符串格式转换为 8 位 ASCII 格式。如果需要加密,这里也会处理。毕竟,第 6 层是关于如何呈现数据的问题。 -
第 5 层:会话层随后接管。这一层是实际连接到远程机器的地方。这一层会监控连接,确保其可靠性和稳定性。当不再需要时,它也会关闭连接。
-
第 4 层:之后,传输层确保包含命令的数据被分成更小的数据包,并按正确顺序发送出去。FTP 使用 TCP,这意味着第 4 层负责在接收数据时重新排列顺序错误的数据包。错误检查也在这里发生。
-
第 3 层:网络层是 互联网协议(IP)所在的地方。第 3 层的此协议负责找到到达远程机器的最佳路由。它还处理数据包转发和重新路由。
-
第 2 层:然后,我们到达数据链路层。这一层向数据包添加数据,例如数据需要到达的下一台机器的 MAC 地址。它负责节点到节点的通信。如果你使用 Wi-Fi,这一层会准备数据以便通过无线电波发送。
-
第 1 层:最后,我们到达物理层。这一层实际上是数据传输的地方。如果你使用 Wi-Fi,这一层会将数据转换为无线电信号。它处理所有硬件问题,例如使用的频率和信号的强度。
幸运的是,大部分工作都是在操作系统或 BIOS 层面上完成的。我们设置网络连接时不必担心频率。我们通常处理 第 7 层 和 第 6 层,有时还会处理 第 5 层。我们编写应用程序(第 7 层)。如果我们有自己的协议,我们会定义表示(第 6 层)。有时我们可能还需要担心实际连接,所以我们偶尔会处理 第 5 层。
小贴士
BCL 和 CLR 有许多类、工具和辅助工具,使我们能够专注于乐趣,而不必担心细节。但有时,作为系统程序员,我们必须担心这些细节。这些细节可能是伟大、快速和稳定的系统与平庸系统之间的区别。但不用担心:我们在这里的章节中涵盖了所有内容!
在我们能够做到这一点之前,让我们看看在网络上传输数据的常用方式。
探索 System.Net 命名空间
很有可能,如果你需要一种传输数据的方法,其他人已经想出了做这件事的最佳方式。
例如,你可以编写所有代码来在机器之间传输文件,或者使用 FTP 并依赖现有软件。
事实上,有很多种传输数据的方式。其中许多方式已经标准化到成为 BCL 的一部分。你可以使用它们而无需处理第三方 NuGet 包。让我们讨论 System.Net 命名空间中的一些提供项,看看我们能用它们做什么。
理解 HTTP/HTTPS
HTTP 是使数百万用户最终能够使用互联网的协议。在 HTTP 之前,交换数据的唯一方式是通过技术复杂的协议,其中大多数都需要通过命令行控制。当蒂姆·伯纳斯-李爵士发布了他关于万维网和伴随的超文本传输协议(HTTP)的想法时,那些技术背景很少或没有的人也可以使用网络。网络浏览器使四处走动和查找信息变得容易。当然,当我说容易时,我的意思是比以前更容易。在 20 世纪 90 年代初,我们没有 Google 或 Bing,所以与今天相比,找到有趣的网站是一种挑战。
HTTP 使互联网民主化。在此之前,它是科学家和军事人员的领域,还有一些为了平衡而加入的极客。是的,我就是那些极客之一:我第一次在 1987 年通过 SMTP、Gopher、FTP 和 Usenet 使用互联网。HTTP 和万维网使这一切变得容易得多。
为其编程并不那么容易。然而,随着当前的框架,从全球任何地方的任意网站获取数据只需要几行代码。让我给你看看:
using var client = new HttpClient();
try
{
string url =
"https://jsonplaceholder.typicode.com/posts";
HttpResponseMessage response =
await client.GetAsync(url);
response.EnsureSuccessStatusCode();
string responseBody =
await response.Content.ReadAsStringAsync();
responseBody.Dump(ConsoleColor.Cyan);
}
catch(HttpRequestException ex)
{
ex.Message.Dump(ConsoleColor.Red);
}
在第一行,我们创建了一个HttpClient类的实例。这个类是一个有用的辅助工具:它减少了旧版HttpWebRequest的许多复杂性。尽管HttpWebRequest在HttpClient之上提供了一些优势(例如,对头部的更多控制、设置超时的选项,以及在需要时使用同步数据传输的能力),但HttpClient无疑是更好的选择。
使用虚拟服务器进行测试
如果你想要玩转 HTTP 和 HTTPS,你需要一个可靠且易于使用的网站来连接。jsonplaceholder.typicode.com/ URL 是这种情况下的一个很好的网站。它提供了几个端点来连接、读取和发送数据。它使用简单,而且是免费的。请查看该网站,看看它提供了什么。
在声明 URL 之后,我们使用该 URL 调用GetAsync方法。这个异步操作返回HttpResponseMessage类的实例。这个类包含了我们读取远程服务器数据所需的所有内容。
下一个调用只是一个简单的错误检查的快捷方式。调用EnsureSuccessStatusCode除了查看服务器的返回代码之外几乎不做任何事情,如果它不在 200 范围内,它会抛出一个错误。正如你可能知道的,HTTP 请求返回一个数字状态码,告诉你调用结果是什么。200 到 299 之间的所有代码都表示你的调用成功了。例如,404 代码表示网站不可达,等等。
这种单一的方法可以使你的代码比if语句更易于阅读。
如果一切正常,我们继续读取实际数据。响应有几个属性,其中一个是Content。其他属性包括状态码、头信息等。
Content,类型为HttpContent,是Stream的包装器,它允许我们从服务器读取数据。在我们的例子中,我们调用ReadAsStringAsync,它接受服务器可以给出的所有数据,并将其作为字符串返回给我们。当然,所有这些都是在异步中发生的。
最后,我们在控制台上显示那个字符串。
这是我能想到的 HTTP 使用最简单的例子。这里展示的所有类都有许多更多的用例、方法和辅助工具,这些都可以为你带来好处。我建议你查看HttpClient、HttpResponseMessage、HttpContent和其他类的文档,看看你还能用它们做什么。同时,让我们看看其他协议。
FTP
我们之前见过 FTP。我使用它来说明通过 OSI 模型的动作流程。但我们从未彻底探索过我们可以用它做什么。
FTP 是一种较老的技术。它现在不再被广泛使用,但它仍然有益。它是一种快速、易于理解的技术,可以在机器之间传输文件和控制远程文件系统,无论底层操作系统是什么。它是快速且可靠的。大多数操作系统都支持 FTP 作为客户端和服务器。
在 Windows 中,你可以通过访问控制面板设置中的程序和功能部分来启用 FTP 服务器,在那里,在互联网信息服务下,你可以看到安装 FTP 服务器的选项。或者,你可以按Win + R,然后输入可选功能。参见图 8.1了解其外观。

图 8.1:在 Windows 上安装 FTP 服务器
然而,请确保你知道你在做什么。我们不再那么频繁地使用 FTP 的一个原因是因为它默认不安全。为了传输文件,最好使用像 SFTP 这样的东西,它是一个安全的版本。
但如果你想在安全的环境中(例如在 Kubernetes 集群中)快速轻松地传输文件,那么古老的 FTP 仍然是你的朋友。
那么,你如何读取远程目录的内容呢?很简单:使用这段代码!
public static void FetchDirectoryContents(string ftpUrl, string username, string password)
{
var request = (FtpWebRequest) WebRequest.Create(ftpUrl);
request.Method = WebRequestMethods.Ftp.ListDirectoryDetails;
request.Credentials = new NetworkCredential(username, password);
try
{
using (var response = (FtpWebResponse) request.GetResponse())
{
using (var streamReader = new StreamReader(response.GetResponseStream()))
{
var line = string.Empty;
while ((line = streamReader.ReadLine()) != null) Console.WriteLine(line);
}
$"Directory List Complete, status {response. StatusDescription}".Dump(ConsoleColor.Cyan);
}
}
catch (WebException ex)
{
var status = ((FtpWebResponse) ex.Response).StatusDescription;
$"Error: {status}".Dump(ConsoleColor.Red);
}
}
如你所见,这里的代码相当直接。我们创建了一个WebRequest的实例并将其转换为子类:FtpWebRequest。我们通过设置方法为ListDirectoryDetails来指定我们想要做什么。如果需要,我们添加一些凭据,并获取包含所需数据的流。当然,我们也会处理异常。
太好了!但是等等…这实际上并不那么好。
如果你在这款编辑器中这样做,你会看到警告:“WebRequest(以及因此FtpWebRequest)已被标记为过时。它们已被更好的HttpClient所取代。不幸的是,它不能用于 FTP 站点:它仅适用于 HTTP 流量。
我认为微软在这里犯了一个错误。但这是他们的框架,所以他们可以随心所欲。好消息是,有很多 NuGet 包可以做到我们想要的事情。一个是FluentFtp,你可以在github.com/robinrodricks/FluentFTP这个 URL 找到。在这里,我想提到的是,我与此书提到的任何 NuGet 包都没有关联;这些只是我使用的包。当然,有很多不同的选项可供选择,所以请选择对你有用的任何一种。
电子邮件协议
HTTP 是公共互联网上使用最广泛的协议,无论是处理它的服务器数量还是处理的数据百分比。但排在第二位的是 SMTP。SMTP,即简单邮件传输协议,用于电子邮件。SMTP 只是与电子邮件相关的协议之一。让我们来看看这些协议中的每一个:
-
SMTP:简单邮件传输协议用于在互联网上发送邮件。它是面向连接的,这意味着它的主要任务是确保发送邮件的客户机和处理邮件的服务器之间的连接。它是可靠的(这意味着如果传输过程中数据丢失,它可以恢复)。
-
POP3:POP3 是Post Operation Protocol的第三版。这个协议处理另一边:SMTP 确保邮件被发送到服务器,而 POP3 允许用户从服务器读取邮件。POP3 允许离线访问电子邮件,但一次只能访问一个邮箱。如果你想读取多个邮箱(或者说是账户),你需要设置多个 POP3 连接。
-
IMAP:IMAP 代表互联网消息访问协议。这个协议也是为了从服务器读取邮件。但这个协议可以一次性读取多个邮箱。IMAP 可以在不下载邮件的情况下访问、搜索、操作和删除您的电子邮件。它可以通过 RPC 方式(我们在第七章中广泛讨论了 RPC)将这些命令发送到服务器。
-
MIME:尽管缩写中没有 P,MIME也是一个协议。它是多用途互联网邮件扩展协议的缩写。正如其名所示,它是一个扩展,允许我们在邮件消息中包含附件、多媒体和非 ASCII 字符。
所有这些协议都使我们能够拥有一个功能齐全、完整的邮件体验。
发送电子邮件
话虽如此,大多数软件发送邮件消息;它们几乎从不读取它们。所以,让我们看看一个简单的代码示例,说明如何发送电子邮件。我提供的示例代码由三部分组成。让我们来看看它们:
using System.Net.Mail;
// Create the mail message
MailMessage mail = new MailMessage();
mail.From = new MailAddress("dennis@vroegop.org");
mail.To.Add("dearreader@thisbook.com");
mail.Subject = "Hi there System Programmer!";
mail.Body =
"This is a test email from the System Programming
book.";
显然,我们需要一个消息。否则,我们为什么要连接到 SMTP 服务器?
消息的类型是MailMessage。它需要一个发送者,并且可以有多个收件人。这些收件人可以在收件人、抄送或密送字段中。收件人、抄送和密送都是列表,因此您可以添加多个收件人。当然,您至少需要提供收件人。
我们可以提供一个主题字段。当然,我非常鼓励您这样做。然后我们有一个正文字段,它包含我们想要发送的消息。
一旦我们有了消息,我们就可以创建SmtpClient类的实例。
当然,您需要能够访问一个真实的 SMTP 服务器。大多数互联网服务提供商都有,所以请查阅他们的文档了解如何连接到它们。您通常需要一个用户名和密码来验证自己。在以前的日子里,有匿名服务器,但如今在垃圾邮件泛滥的时代,这些服务器非常难以找到。
我们必须指定服务器的地址和端口(端口25是旧端口;端口587是新端口,也是推荐使用的安全端口),并且您可以指定是否要使用 SSL。这段代码看起来是这样的:
// Set up the connection to the SMTP server
// And no, this is NOT a valid SMTP server. Use your own :)
SmtpClient client =
new SmtpClient("smtp.vroegop.org");
client.Port = 587;
client.EnableSsl = true;
client.Credentials =
new System.Net.NetworkCredential(
"dennis@vroegop.org",
"MySuperSecretPassword");
最后,我们可以发送消息!
// Send the email!
client.Send(mail);
一旦您设置了客户端,您可以使用相同的客户端实例发送多个消息。您不必担心设置连接。您只需调用Send,一切都会正常工作。
发送 HTML 消息
之前的例子工作得很好,但消息有点平淡。如今,消息要丰富多彩得多,看起来也更令人愉快。要做到这一点,就是发送一个 HTML 消息。您可以通过在正文字段中放入 HTML 并设置MailMessage的IsBodyHtml属性为 true 来实现。但这样做并不是最佳方式,以下有两个原因:
-
并非所有客户端都支持 HTML。如果他们的客户端不支持 HTML,读者必须解析 HTML 以找到正文文本。
-
只包含 HTML 的消息通常会被标记为垃圾邮件。
做这件事的最好方法是将你精心制作的 HTML 正文和更接地气的纯文本正文结合起来。你可以通过使用AlternateView类来实现这一点。创建邮件消息的代码如下:
var multipartMail = new MailMessage();
multipartMail.From = new MailAddress("dennis@vroegop.org");
multipartMail.To.Add("dearreader@thisbook.com");
multipartMail.Subject = "Hi there System Programmer!";
var htmlBody = "<html><body><h1>Hi there System Programmer!</h1></body></html>";
var htmlView =
AlternateView.CreateAlternateViewFromString(
htmlBody,
null,
"text/html");
var plainView =
AlternateView.CreateAlternateViewFromString(
"This is a test email from the System Programming book.",
null,
"text/plain");
multipartMail.AlternateViews.Add(plainView);
multipartMail.AlternateViews.Add(htmlView);
我们创建了一个常规的MailMessage类实例。大多数字段都是相同的。但我们没有指定正文。相反,我们通过调用CreateAlternateViewFromString静态方法创建了两个AlternateView类实例。该方法接受我们想要发送的内容(HTML 或纯文本)以及我们使用的编码(我们将其设置为NULL,因此使用机器的默认设置)。我们确实需要指定内容类型。第一个包含"text/html",第二个包含"text/plain"。
我们然后将这两部分添加到MailMessage实例中,并发送它。
代码的其他部分保持不变。
这涵盖了部分高级类。现在是时候深入探究这个兔子洞了。
在使用System.Net.Sockets命名空间时
默认协议非常出色。它们减少了大量的手动工作。我们不必自己编写 HTTP 协议;我们可以专注于内容。同样适用于 SMTP、POP3 以及所有其他协议。如果你想要使用的协议足够流行,你可以找到一个类或 NuGet 包。
但当然,有时你找不到那个包。有时,你想编写自己的协议。在这种情况下,你必须自己完成所有艰苦的工作。但是,我必须诚实地说,我非常享受这个过程。编写自己的协议,将其部署在我的应用程序中,并看到它们协同工作,这很有趣。即使你不享受这个过程,也有时候你别无选择。
好消息是,编写 BCL 的善良的人们已经做了很多底层工作。
在第六章中,当我们讨论系统如何通信时,我们遇到了Socket类。套接字被提及为一种选项。我们编写了一个简单的聊天应用程序,该程序使用 TCP/IP 进行通信。TCP/IP 是套接字可以连接的方式之一。
在我提到的聊天示例中,我们创建了TcpListener和TcpClient类的实例。这些类是更通用的Socket类的包装器。它们专门用于 TCP/IP 连接,并处理了使这一切工作所需的大部分管道工作。
你当然可以使用套接字。这意味着你必须自己完成大部分工作,这让你对发生的事情有更多的控制。
你可以使用套接字进行 TCP 和 UDP 连接。我们在第六章中探讨了它们之间的差异,所以在这里我们不会再次比较。然而,如果你想使用 UDP,你应该使用Socket类:显然,TCPClient将不起作用。顺便说一下,还有一个UdpClient类,你可以用它达到相同的结果。但是,我想让你了解其内部工作原理。这就是为什么我选择使用Sockets的原因。
使用套接字时的步骤
当与套接字一起工作时,你需要采取一些步骤:
-
选择正确的套接字。你可以使用流套接字。流套接字基于 TCP 协议。它是一个可靠、面向连接的协议。但你也可以选择数据报套接字。这些基于 UDP 协议。它们是无连接的、一次性通信方式。它很快,但你无法保证数据会到达预期的接收者。
-
然后,你创建套接字。你指定你想要使用的地址类型(IPv4 或 IPv6)、套接字类型(流或数据报)和协议(TCP 或 UDP)。
-
是时候连接了。你可以监听传入的连接或连接到某个服务器。当你连接到远程服务器时,你必须指定 IP 地址和端口。如果你在监听,你至少需要端口,如果你有更多的网络连接,你可能还想指定你正在监听的 IP 地址。
-
发送和接收数据。毕竟,我们在这里就是为了这个,对吧?
-
当你完成时,你必须确保关闭连接。你不希望长时间保持连接:你可能会妨碍其他应用程序。
就这些了。如果我这样说,看起来很简单,不是吗?嗯,魔鬼在细节中!
IPv4 和 IPv6
我们需要稍微谈谈 IP 地址。IP 地址,即互联网协议地址,是一个唯一标识网络设备的数字。它在边界内是唯一的,但稍后我们将讨论这一点。我们可以使用两种类型的地址:IPv4 和 IPv6。正如你可能已经猜到的,这些缩写分别是互联网协议版本 4 和版本 6。
第一个公开使用的版本是 IPv4。IPv5 从未见过天日,留下了两个版本。我们一直的想法是完全用 IPv6 替换 IPv4,但看起来 IPv4 还会存在一段时间。
一个 IPv4 地址由 4 个字节组成,因此它有 32 位长。这个大小意味着理论上大约有 43 亿个唯一的地址。实际上,由于许多范围被保留,所以更少。我们已经遇到了其中之一:地址是127.0.0.1。这是设备的地址本身。
尽管不同的系统可以保留不同的端口范围,但我们对于应该避免使用哪些范围或可以使用哪些范围有一个共同的理解。这些范围是这样解释的:
-
端口 0 – 1023:知名端口。这些端口到处都在使用,你不应该自己使用它们。
-
1433,由 SQL Server 使用。然而,这些并不是像0–1023范围那样严格分配的。 -
端口 49152 – 65535:这个范围被称为动态或私有范围。它们通常用于临时或短暂的通信。它们通常由操作系统动态分配。
只确保你选择的端口在你打算使用的系统上尚未被占用!
IPv6 地址由 8 组 2 字节的结构组成,长度为 128 位。你可以在那个地址空间中放入大量地址:大约有 340 个十一万亿个唯一的地址。
大数字
与计算机打交道意味着你有时会遇到大数字。这是一个例子:一个十一万亿等于 10 的 36 次方。这意味着这个数字是 340 后面跟着 36 个零。这有很多地址。
IPv6 地址显示为 8 组 16 位十六进制值的序列。例如,一个有效的地址可能看起来像这样:2001:0db8:85a3:0000:0000:8a2e:0370:7334。
这也是一个有趣的地址:0000:0000:0000:0000:0000:0000:0000:0001。这是 127.0.0.1 的 IPv6 版本。换句话说,这就是 localhost。然而,它相当长:有 7 组0000。在 IPv6 中,我们可以通过两个冒号省略一系列的0000值。因此,我们可以将 localhost 的地址缩短为::1。
在 IPv4 和 IPv6 中,我们都预留了地址范围。例如,192.168.0.0到192.168.255.255范围内的所有地址都用于内部网络。然而,你不能将这些地址分配给面向公共网络的设备。对于地址10.0.0.0到10.255.255.255和172.16.0.0到172.31.255.255也是如此。
使用套接字查找时间
是时候看看如何真正地做这些了。
有些服务器充当时间服务器。这些服务器只有一个目的:等待你的连接,然后响应当前的日期和时间。他们这样做的方式非常有趣:他们计算发送响应所需的时间,并相应地调整时间,从而确保答案尽可能准确。
让我们看看一些代码:
public DateTime GetNetworkTime(string ntpServer = "pool.ntp.org")
{
// NTP message size - 16 bytes (RFC 2030)
var ntpData = new byte[48];
// Setting the Leap Indicator, Version Number and Mode values
ntpData[0] = 0x23; // LI, Version, Mode
var addresses = Dns.GetHostEntry(ntpServer);
var ipEndPoint = new IPEndPoint(addresses.AddressList[0], 123); // NTP uses port 123
using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
{
socket.Connect(ipEndPoint);
socket.Send(ntpData);
socket.Receive(ntpData);
socket.Close();
}
return ConvertNtpTimeToDateTime(ntpData);
}
这种方法从服务器的默认名称值开始。我们使用pool.ntp.org作为我们的服务器,但还有许多其他服务器也能完成这项任务。它们都使用 NTP 协议(NTP意味着网络时间协议,以防你有所疑问)。NTP 是最古老的协议之一。早在 80 年代初,系统就使用这个协议来通过网络同步计算机的时钟!
我们使用的地址pool.ntp.org不是一个单独的计算机,而是一个包含数千个 NTP 服务器的池,确保每个人都能获取到时间。然而,我们可以将其视为一个单独的服务器。哦,有一点需要注意:该 URL 旨在由 NTP 客户端使用。它们使用端口123,正如你在代码中所见。如果你使用浏览器访问该地址,你将自动使用 HTTP,因此端口80(在那个地址上没有 HTTPS 服务器)。这意味着你将看到该池维护者想要放置的内容。不要使用浏览器访问该 URL;使用它应有的端口123!
向 NTP 服务器请求需要一个 48 字节的缓冲区来存储答案。当我们连接到服务器时,我们需要在缓冲区中添加一些数据,告诉它我们想要什么。在我们的情况下,我们给它值 0x23。这个字节由 3 组位组成,每组位告诉服务器我们想要什么。查看以下表格以了解这些位的含义:
| 位 | 名称 | 描述 |
|---|---|---|
| 6-7 | 闰秒指示器 | 表示我们是否想要考虑一个月可能有的闰秒。0 表示没有调整,1 表示该月的最后分钟有 61 秒,2 表示该月的最后分钟有 59 秒,3 表示时钟未同步。 |
| 4-6 | 版本 | 我们想要使用的协议版本。最新版本是 4。 |
| 0-3 | 模式 | 0:保留 1:对称主动 2:对称被动 3:客户端 4:服务器 5:广播 6:NTP 控制消息 7:保留 |
表 8.1:NTP 服务器的设置
我们不想使用闰秒调整。我们感兴趣的是使用协议版本 4。我们在这里是客户端。这意味着我们必须做一些位运算。从最高有效位到最低有效位工作,我们得到位 6 和 7 为 00,位 4、5 和 6 为 100,最后位 0 到 4 为 011。如果我们组合这些,我们得到 0010 0011,或十进制的 23。
我们将这个值放在 48 字节长缓冲区的第一个字节中,我们将把这个缓冲区给服务器。
我们有 NTP 服务器的名称(pool.ntp.org),但我们需要该机器的实际 IP 地址。毕竟,套接字需要一个地址而不是一串文本。var addresses = Dns.GetHostEntry(ntpServer);,我得到了 4 个 IP 地址。
我们取回的第一个地址,并使用该地址和端口123创建一个IPEndPoint类的实例。
然后,我们可以创建一个Socket类的实例。我们给它AddressFamily InterNetwork,这意味着我们想要使用 IPv4 地址。我们还指定我们将使用数据报,因此我们使用 UDP。
混合流、数据报、TCP 和 UDP
您必须指定您想要使用的套接字类型和协议类型。然而,如果您使用SocketType.Stream,您还必须使用ProtocolType.TCP。如果您想使用SocketType.DGram,您也必须使用ProtocolType.UDP。如果您尝试混合这些(例如,您想要 TCP 上的数据报),则在运行时会出现异常。所以,请小心选择。
我们在套接字上调用connect,给它我们创建的端点。之后,我们向服务器发送一个包含关于leap、version和mode信息的 48 字节缓冲区。接下来,我们通过调用Receive来尝试获取答案,使用相同的缓冲区。
当然,当我们得到答案时,我们关闭连接。
一旦收到答案并安全地存储在我们的缓冲区中,我们就可以进行一些计算,将数据转换为我们可以在DateTime结构中使用的格式。我们称之为特定代码片段,它包含不同格式之间的转换、位交换等。它们与从服务器获取数据无关,所以我将其省略。GitHub 上的示例有这段代码,如果你想看看它是什么样子,请查看。
处理套接字的代码并不复杂。但这个代码存在一个问题。这就是我们所说的阻塞代码。它在调用 NTP 服务器期间会阻塞整个线程。让我们来修复它。
异步、非阻塞网络
到现在为止,你应该已经很明显,你必须确保你代码中的所有非即时操作都可能是性能问题。慢速操作可能会阻止进程继续。文件 I/O 是这种操作适用的一个领域。网络甚至比这还要慢。所以,我们可以异步执行的所有操作都应该以这种方式实现。
好消息是,大多数处理网络的类都有它们方法的异步版本。坏消息是,对于Socket来说,这并不像你希望的那样简单直接。但不用担心:我们很快就会解决这个问题!
进行异步调用
在之前的示例中,我们使用了静态的Dns类来获取 NTP 服务器的地址信息。我们调用了GetHostEntry(),这是一个同步阻塞调用。我们可以很容易地修复这个问题:Dns有这些方法的异步版本。我们可以重写调用,使其看起来像这样:
var addresses = await Dns.GetHostEntryAsync(ntpServer);
当然,方法签名也需要改变。而不是有这个方法声明:public DateTime GetNetworkTime(string ntpServer = "``pool.ntp.org")。
我们将其改为如下:
public async Task<DateTime> GetNetworkTimeAsync(string ntpServer = "``pool.ntp.org")
我们将其改为async,将返回类型改为Task<DateTime>而不是DateTime,并将方法重命名为带有Async后缀。
这很简单。我们可以对与Socket一起工作的代码做同样的处理。这是完整的方法:
public async Task<DateTime> GetNetworkTimeAsync(string ntpServer = "pool.ntp.org")
{
// NTP message size - 16 bytes (RFC 2030)
var ntpData = new byte[48];
// Setting the Leap Indicator, Version Number, and Mode values
ntpData[0] = 0x23; // LI, Version, Mode
var addresses = await Dns.GetHostEntryAsync(ntpServer);
var ipEndPoint = new IPEndPoint(addresses.AddressList[0], 123); // NTP uses port 123
using (var socket = new Socket(
AddressFamily.InterNetwork,
SocketType.Dgram,
ProtocolType.Udp))
{
await socket.ConnectAsync(ipEndPoint);
await socket.SendAsync(
new ArraySegment<byte>(ntpData),
SocketFlags.None);
await socket.ReceiveAsync(
new ArraySegment<byte>(ntpData),
SocketFlags.None);
}
return ConvertNtpTimeToDateTime(ntpData);
}
这个版本利用了 async/await 模式,因此对服务器的调用不会阻塞线程。
小贴士
网络代码应始终使用异步方法而不是同步方法。与 CPU 和本地机器的原始速度相比,网络较慢,那么为什么还要浪费时间等待网络适配器缓慢的数据流呢?
然而,当使用网络时,有方法可以改进你系统的性能。让我们接下来看看那些方法。
网络性能
由于网络相对较慢,我们必须在提高数据吞吐量的方法上变得聪明。我们可以控制本地网络,确保我们到处都有光纤和超级快速的路由器,但这并不能解决问题。即使是最快的物理网络也比 CPU 处理的数据要慢得多。当然,拥有快速的硬件有帮助。但这只帮助我们的网络:我们无法控制其他网络上的硬件。我们必须在代码中变得聪明,以充分利用我们的网络。再一次,这都取决于我们,开发者!
连接池
连接代表客户端和服务器之间的开放线路。让我们看看以下代码行:
var client = new TcpClient("my.server.com", 123);
这行代码很简单:它创建了一个连接到名为 my.server.com 的服务器,端口为 123,并返回打开的连接。很好。我们之前见过。但是让我给你看看运行这行代码会发生什么:
-
将
my.server.com字符串转换为我们可以使用的正确 IPv4 或 IPv6 地址。 -
实例化
Socket类,为其分配内存并确保其可用。 -
向服务器发送
SYN。基本上,客户端在问:“嘿,我们可以交谈吗?” -
当接收到
SYN消息时,它会响应SYN-ACK,表示它已准备好进行通信。 -
SYN-ACK,这表明网络工作正常,并且它们可以进行通信。
当这一切都发生后,通信线路就打开了,并准备好使用。我们可以开始发送和接收数据。
如你所见,那简单的一行代码涉及到很多工作。你可以想象客户端和服务器之间的握手过程需要花费很多时间。网络连接是昂贵的!
这一点无法回避。这些步骤必须执行。但是,没有必要做得比你需要的更多。如果你已经连接到服务器,你也可以重用它。我们称之为连接池。我们创建一个连接池,每当我们的系统需要与服务器通信时,我们返回已经创建的连接。
很不幸,BCL 没有为此提供类。但是自己写一个并不太难。你可以这样做。
我们创建了一个名为 TcpClientConnectionPool 的类。其签名如下:
internal class TcpClientConnectionPool : IAsyncDisposable{}
这个类中有三个方法:
public TcpClient? GetConnection(){}
public void ReturnConnection(TcpClient? client) {}
public async ValueTask DisposeAsync(){}
在我们查看这些方法之前,我们需要在类中创建两个私有字段:
private readonly ConcurrentBag<TcpClient?> _availableConnections = new();
private readonly int _maxPoolSize = 10; // Example pool size
当你创建一个用于存储对象的池时,你需要一个地方来存储它们。我们在这里使用 ConcurrentBag<T>。ConcurrentBag 是一个线程安全的集合,具有以下特性:
-
线程安全:你可以添加、访问和删除对象,而无需担心锁或其他线程的干扰。这个类为你处理这些细节。
-
无序:没有特定的顺序。在我们的情况下,这完全没问题。然而,如果你想使用类似 FIFO 的东西,你应该使用内置顺序的类。
-
允许重复:如果你想,可以将相同的对象添加到集合中。
-
性能:这个类针对同一线程添加或删除项目的情况进行了优化,但在混合场景中表现也相当不错。
GetConnection()方法如果池中有可用对象,则从中拉取一个对象。如果没有,它会为您创建一个。下面是这个方法:
public TcpClient? GetConnection()
{
if (_availableConnections.TryTake(out TcpClient? client))
return client;
if (_availableConnections.Count < _maxPoolSize)
{
// Create a new connection if the pool is not full
client = new TcpClient("my.server.com", 443);
}
else
{
// Pool is full; wait for an available connection or throw an // exception
// This strategy depends on your specific requirements
throw new Exception("Connection pool limit reached.");
}
return client;
}
在这个示例中,当池达到最大允许对象数时,我会抛出一个异常。您希望在代码中限制TcpClient实例的数量:它们占用相当多的内存和底层句柄,因此让它们无限期地存在可能不是最好的主意。
如果池中有空间但没有可用项,我们创建一个新的并返回给调用者。这个想法是,在使用后,调用者将对象返回,我们将其存储在集合中,以便其他用户可以取用。在这里我们使用懒加载初始化:只有在需要时才创建TcpClient。
如果您愿意,可以在这个类的构造函数中创建所有 10 个实例。这使得类的初始化变慢,并且占用更多内存,但在对象的整个生命周期中运行得更快。
当连接用户调用此方法时,它会获得一个活跃和开放的连接。当用户不再需要TcpClient时,它需要返回以便存储在池中,并准备好供下一个用户使用。这个方法看起来是这样的:
public void ReturnConnection(TcpClient? client)
{
// Check the state of the connection to ensure it's still valid
if (client is { Connected: true })
{
_availableConnections.Add(client);
}
else
{
// Optionally, handle the case where the connection is no // longer valid
// e.g., reconnect or simply discard this connection
}
}
当我们获取TcpClient时,我们可以进行一些检查。例如,我通常检查它是否仍然连接。这有点像图书馆:当你归还物品时,他们期望它们是完好的。我们在这里也这样做。如果有问题,我们可以修复它,或者甚至不将其放回池中。我将这个决定留给您。
最后,当连接池被释放时,我们进行一些清理工作:
public async ValueTask DisposeAsync()
{
foreach (var client in _availableConnections)
{
if (client is { Connected: true })
{
await client.GetStream().DisposeAsync();
}
client?.Close();
client?.Dispose();
}
}
我们遍历集合中剩余的TcpClient实例,如果需要则关闭它们,释放底层流,并释放实例本身。这确保我们没有留下任何打开的连接。我的母亲在我很小的时候就教了我这一点:总是要清理自己的东西!
为了完成这部分,这是您将如何使用这个类的示例:
await using var connectionPool = new TcpClientConnectionPool();
TcpClient? myConnection = connectionPool.GetConnection();
try
{
var myBuffer = "Hello, World!"u8.ToArray();
// Use the connection
await myConnection.Client.SendAsync(myBuffer);
}
finally
{
connectionPool.ReturnConnection(myConnection);
}
我首先创建了一个connectionPool的实例。显然,您不会在每个需要连接的方法调用中都这样做,但在这个简单的示例中,这样做是可以的。
然后,我尝试通过调用GetConnection()来获取连接;
然后,我通过获取Hello, World字符串,在后面加上u8以确保它是 UTF-8,然后将其转换为字节数组。
我可以使用我的池化连接将这个字符串发送到服务器。最后,我可以将连接放回池中。
这个示例的功能有限,并且在将其投入生产之前缺少很多必要的代码。但我确信它将帮助您走上正轨。
我们正在缓存我们的连接。但缓存还可以以许多其他方式提供帮助。
缓存
缓存将数据存储在附近,以便你可以重用它,而不是每次都去服务器。这听起来很简单:它可以是一个巨大的性能提升器。从您机器上的内存位置获取对象,而不是每次都去远程服务器,这似乎是一个显而易见的选择,对吧?但有一些潜在的陷阱你需要考虑。以下是最重要的几点:
-
过时数据:数据可能会改变。例如,我们的 NTP 示例每毫秒都会改变。话虽如此,您可能从服务器检索一次,然后添加自您获取它以来经过的本地时间。它最终会失去同步(NTP 服务器比您的本地机器更精确),但我确信这不会很快成为一个大问题。但数据会过时。如果您在本地存储数据,您必须考虑这一点。数据会多久改变一次?我拥有最新版本的重要性有多大?
-
内存开销:在您的机器上本地存储项目会占用本地内存。存储大量(大)对象会占用大量数据,这可能会减慢您的整个应用程序。甚至可能导致内存不足异常。你必须决定你经常使用什么,什么可以留在服务器上。
-
缓存失效的复杂性:如果数据过时,你必须更新它。这需要代码来监控数据,并在需要时刷新它。这些代码可能会相当复杂。你可能有一个单独的线程来监控你的本地缓存,或者你可能决定在从缓存中拉取数据时进行。无论如何,你必须编写大量的监控代码。这可能会过度复杂化你的软件。
-
安全顾虑:您机器上的数据并不总是安全的。如果您在本地机器上存储敏感数据,它可能会容易受到窃听,尤其是如果您将缓存数据存储在存储介质上。请确保安全地处理敏感数据。
-
缓存未命中成本:当您的应用程序依赖于从缓存中获取数据,并且只有当您遇到缓存未命中(因此,项目尚未在缓存中)时才从远程服务器读取数据时,您可能引入了一个性能瓶颈。通过缓存进行逻辑操作,如果数据不可用,则转到服务器,这个过程需要时间。如果所需的数据不经常需要,这可能不是缓存的最佳案例。
-
数据不一致:假设您的应用程序使用缓存中的数据,但另一个系统或您的系统的一部分使用服务器中的数据。在这种情况下,数据之间可能会有差异。这不仅是不新鲜的数据,这意味着两个系统使用不同的数据——他们期望相同的数据。如果这可能是一个问题,缓存可能不是一个好主意。
缓存可以提高你的应用程序速度,但要注意其中涉及的风险。在实施之前,你应该考虑潜在的风险和收益。
压缩和序列化
如果通过电线传输数据较慢,传输或请求更少的数据可能会有所帮助。所以,压缩以及你如何序列化数据可能会有所帮助。在早期章节中,我们探讨了压缩和序列化,所以在这里我不会详细说明。但请记住:如果你使用压缩,这里是一个非常有帮助的地方。通过首先压缩来减少负载,你可以加快网络通信速度。当然,选择正确的序列化技术也有帮助。
由于我们已经讨论了如何进行压缩,所以我不会在这里再次展示。你已经知道如何在 System.IO.Compression 命名空间中使用 GZipStream 类(是的,这是一个提示)。
保持连接
创建一个 TcpClient 本身并不昂贵。然而,打开到服务器的连接却是昂贵的。尽量长时间保持连接打开是有帮助的。HTTPClient 类在这方面非常出色:它被设计成你可以长时间保持连接打开而不会妨碍你。如果你使用套接字,你也可以做类似的事情。然而,当你不再需要连接时保持连接打开并不是一个好主意。如果你不需要它,请关闭连接。否则,无论如何,保持它打开。当然,如果你保持连接打开,你也会影响到对方。一个紧紧抓住连接的客户也会限制服务器。你必须深思熟虑并做出正确的决定。
网络错误和超时
当处理网络时,有一条规则你必须牢记在心。那就是:假设对方不会回应你的电话。
服务器会宕机。连接可能会断开。网络不可达。会有很多问题(不是可能会发生的问题!)会发生。
你必须使用防御性编程来确保它不会过多地影响你的代码。当然,如果你依赖于外部机器来获取所需的数据,而这个机器不可用,你就有问题。但也许你可以绕过它。也许你可以缓存旧数据。或者,如果出现问题,你可以重试。
让我帮你提供一些你可以用来处理网络中断的策略。
智慧地使用 HTTPClient
HTTPClient 类有一些巧妙的技巧可以帮助你更稳定地使用它。例如,连接池在这个方便的类中是免费且开箱即用的。而且他们以一种相当巧妙的方式构建了这个连接池。
一般建议是创建一个 HTTPClient 实例并在整个系统中使用它。这个类足够智能,可以池化到服务器的连接。如果你使用同一个 HTTPClient 从另一个服务器获取数据,这个类会创建一个新的池,因此这些连接也会被池化。
当然,要小心你的行为:如果你不需要,不要生成到数百个服务器的连接。它们仍然会占用你的系统内存。
使HTTPClient更具弹性的另一种方法是使用默认配置来设置你的连接。我总是确保我设置了DefaultRequestHeaders,这样我知道我可以处理传入的数据。
我总是确保我的实例上有TimeOut。这样,我知道HTTPClient不会等待太久来从服务器获取数据。
我建议你使用类似Factory的东西来创建你的实例。我使用的一个看起来像这样:
internal static class HttpClientFactory
{
private static HttpClient? _instance;
public static HttpClient? Instance
{
get
{
if (_instance == null) CreateInstance();
return _instance;
}
}
private static void CreateInstance()
{
var handler = new HttpClientHandler()
{
UseCookies = true,
CookieContainer = new CookieContainer(),
UseProxy = false
};
_instance = new HttpClient(handler);
_instance.DefaultRequestHeaders.Clear();
_instance.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_instance.DefaultRequestHeaders.Add("User-Agent", "SystemProgrammersApp");
_instance.Timeout = TimeSpan.FromSeconds(5);
}
}
这个静态类在我需要时为我创建一个HTTPClient实例。它告诉处理程序它需要使用Cookies,并且我不希望在连接上使用代理。我还设置了DefaultRequestHeaders,并要求它接受application/json数据。我还添加了一个友好的用户代理,以便服务器知道它在和谁交谈。最后,我将timeout设置为 5 秒。
如果我需要一个HTTPClient实例,我可以这样获取它:
var client = HttpClientFactory.Instance;
var response = await client.GetAsync(
"https://jsonplaceholder.typicode.com/posts");
if (response.IsSuccessStatusCode)
{
string content = await response.Content.ReadAsStringAsync();
$"Received: {content}".Dump(ConsoleColor.Yellow);
}
第一次我需要那个客户端时,它会构建它。但第二次以及之后,它将从连接池中提取它,这使得它更快,并且对错误更具弹性。
我还确保我不直接从HTTPClient使用GetStringAsync()方法或GetStreamAsync()。我首先获取Response(类型为HttpResponseMessage),以检查结果是否有效。正如我们所看到的,这就是IsSuccessStatusCode属性告诉我们的。
这样,你与 HTTP 服务器的通信将会更快,并且更加稳定。
使用 Polly 实现重试
当然,事情仍然可能会出错。服务器可能很忙,或者网络可能很拥挤。解决这个问题的最好方法是尝试再次尝试,然后再次尝试,直到它工作或者你放弃。
你可以自己编写那个逻辑,但使用标准库会更好。实现这个功能的常用库被称为Polly。
因此,让我们首先在我们的应用程序中安装那个 NuGet 包。你可以在 CLI 中使用以下命令来这样做:
Install-Package Polly
完成这些后,我们可以稍微修改一下我们的HttpClientFactory类。
首先,向那个类添加一个新的private static字段:
private static AsyncRetryPolicy<HttpResponseMessage> _retryPolicy;
这是我们的RetryPolicy,我们将将其应用于请求。
在HttpClientFactory类的CreateInstance方法末尾,添加对新方法的调用:SetupRetryPolicy。该方法看起来像这样:
private static void SetupRetryPolicy()
{
_retryPolicy = Policy
.Handle<HttpRequestException>()
.OrResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
(outcome, timeSpan, retryCount, context) =>
{
$"Request failed with
{outcome.Result.StatusCode}.".Dump(ConsoleColor.Red);
$"Waiting {timeSpan} before next
retry.".Dump(ConsoleColor.Red);
$"Retry attempt
{retryCount}.".Dump(ConsoleColor.Red);
});
}
在静态Policy类中,我们调用Handle()方法。我们给它HttpRequestException类型参数。这样,框架就知道触发重试的条件。我们还告诉它,如果HttpResponseMessage.IsSuccesStatusCode设置为 false,则进行重试。
如果出现这些条件之一,我们告诉策略使用WaitAndRetryAsync。我们要求它在第一次失败后进行三次重试。以下参数告诉Policy等待 2 秒、4 秒或 8 秒(2 的幂次方重试次数)。因此,每次等待的时间是前一次的两倍,以便给服务器时间来整理其数据。
我们还给它一个委托,框架将在开始重试时执行该委托。在这种情况下,我们打印一些消息到控制台,告诉它发生了什么失败,它将在尝试再次之前等待多长时间,以及它已经尝试了多少次。
在此基础上,我们可以重新编写我们请求 HTTPClient 数据的方式。在之前的例子中,我向你展示了如何从工厂中获取 Instance 并直接使用该实例。我想将这段代码移动到 HttpClientFactory 中。但调用服务器的请求必须被我们的新 Policy 包装。方法看起来是这样的:
public static async Task<HttpResponseMessage> GetAsync(string url)
{
return await _retryPolicy.ExecuteAsync(
() => _instance.GetAsync(url));
}
而不是让我们的类用户调用 GetAsync(url),我们使用这个包装方法为他们做这件事。但我们把这个调用包装在 _retryPolicy.ExecuteAsync() 中。
将使用此工厂的原代码更改为如下所示:
var client = HttpClientFactory.Instance;
var response = await HttpClientFactory.GetAsync(
"https://jsonplaceholder.typicode.com/posts2");
if (response.IsSuccessStatusCode)
{
string content = await response.Content.ReadAsStringAsync();
$"Received: {content}".Dump(ConsoleColor.Yellow);
}
我现在不再调用 client.GetAsync(),而是调用 HttpClientFactory.GetAsync()。其余的都没有改变。嗯,这并不完全正确。我还稍微修改了 URL。在那个 URL 中,我没有要求 posts2 而不是 posts。而且那并不存在。这应该会触发我们的重试机制。
运行它并看看会发生什么。就这样——正确地完成了重试!
断路器模式
类似模式的是断路器模式。这种模式检测连接是否处于故障状态,并防止系统在预定义的时间内对服务器进行调用。如果连接引发错误,断路器打开,并暂时停止与该服务器的所有通信。在那段冷却期之后,它会稍微打开一点,以便快速查看服务器。如果看起来工作正常,它将允许全流量。否则,它会放弃,并告诉你事情已经出错。
断路器也是 Polly NuGet 包的一部分。
验证网络可用性
尝试连接一个不存在的服务器会导致错误。但如果你自己的网络有问题会怎样?在这种情况下,它看起来就像全球的所有服务器都宕机了。
那种最后一种情况似乎不太可能,所以在责怪整个互联网之前,验证我们的网络是健康的会很好。
事实证明,这样做并不太难。你只需要一行代码:
bool isHealthy = System.Net.NetworkInformation.NetworkInterface.GetIsNetworkAvailable();
这就是全部了。
你也可以这样查询你机器上的每个网络适配器:
foreach (NetworkInterface ni in NetworkInterface.GetAllNetworkInterfaces())
{
$"Name: {ni.Name}".Dump(ConsoleColor.DarkYellow);
$"Type:{ni.NetworkInterfaceType}".Dump(ConsoleColor.DarkYellow);
$"Status: {ni.OperationalStatus}".Dump(ConsoleColor.DarkYellow);
}
我们可以遍历所有网络适配器并查看它们的状态。这可以帮助我们选择合适的适配器,从而在想要对错误和故障具有弹性时选择正确的 IP 地址。
监控和日志记录
这一点不言而喻:解决问题的最佳方式是记录和监控正在发生的事情。如果你有详细的日志记录,一旦出现问题,你更有可能找到问题。但现在我们不必过于担心这一点。
如果你遵循这些技巧和窍门,你仍然会遇到网络问题。这是不可避免的。但至少网络故障不会让你的系统崩溃。
下一步
在本章中,我们深入研究了网络。我们摆脱了本地机器的限制,并研究了 BCL 在连接到外部世界时为我们提供的所有好东西。
我们探讨了默认协议,如 HTTP、FTP 和 SMTP。我们还探讨了套接字,以防预定义的协议不够好,例如当你想从时间服务器查询当前时间时。我们深入研究了异步网络,并大量讨论了性能以及使我们的网络错误检测和更健壮的方法。
诚实地讲:如今几乎没有任何计算机是独立运行的。大多数机器以及运行在其上的软件,都以某种方式连接到外部世界。特别是我们作为系统程序员感兴趣的东西,并不是由用户使用,而是由其他软件使用。其中一些软件存在于其他机器上。这意味着你必须了解网络。现在你已经做到了!
我们没有讨论安全和日志记录。日志记录是我们简要提及的第十章的内容。安全是第十二章的主题。是的,这些话题如此重要,它们值得拥有自己的章节。
但在我们深入之前,让我们先去其他平台看看。由于系统编程与设备紧密相关,我觉得深入另一个设备并看看我们是否能与一些高级硬件进行通信会很有趣。所以,让我们继续前进吧!
第九章:带有硬件握手的那个
硬件交互 和控制
作为系统程序员,我们从不与用户打交道。我们与其他软件打交道。那其他软件可能在同一台机器上,也可能在另一台机器上。有时,我们还要与硬件打交道。这些硬件可能是我们机器的一部分,也可能是连接到我们机器的硬件,或者是在其他地方的硬件。
在本章中,我们将探讨所有这些选项。我们将查看直接与硬件交互和连接到远程设备,并将深入探讨串行通信的世界。
在本章中,我们将涵盖以下主要主题:
-
在 Windows 上连接到串行端口。
-
设置 Arduino 设备
-
在 Arduino 上编写简单的程序
-
从串行端口获取数据
-
处理外部事件
-
调试依赖于外部设备的代码
-
让这种代码尽可能可靠
-
总的来说,我们还有很多东西要学习。加入我,一起探索这个充满异国情调的硬件新领域!
技术要求
在本章中,我们将深入研究一些外部硬件。我将向您展示如何通过串行连接与Arduino 微控制器进行通信。
如果您无法访问此类设备,请不要担心。我还会讨论如何模拟这些设备,以便您可以在实际设备上部署代码之前对其进行测试。如果您以后遇到这些设备,可以跟随并尝试这些代码。
和往常一样,您可以从 GitHub 仓库下载这些示例的源代码,GitHub 仓库地址为github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter09。
连接到串行端口
是时候享受一些乐趣了。让我们摆脱我们正在工作的机器的限制,进入外围设备的世界。
然而,在我们查看代码之前,我们必须了解软件是如何与硬件通信的。
硬件路径
假设我们有一个连接到某些硬件的应用程序。这无关紧要是什么类型的硬件,但让我们假设我们想要向 USB 端口发送数据。
从我们的应用程序向设备发送数据涉及多个步骤,其中数据被转换。这有点像我们在上一章中讨论的 OSI 层。
一切都从我们的应用程序开始。我们设计了 C#代码来向 USB 设备发送数据。我们已经下载了正确的 NuGet 包,安装了框架,编写了代码,并将其编译成可执行文件。
当那个可执行文件运行时,该代码在您安装运行时或 SDK 时,在机器上安装的.NET 库中被认为是正确的代码。BCL(Base Class Library)中有一个名为SerialPort的类,它接收来自您代码的命令并将它们转换为下一层,在那里.NET 运行时将命令交给操作系统。在我们的例子中,那就是 Windows。Windows 查看数据及其去向,并决定它无法处理这些数据。它是硬件,因此操作系统调用 USB 端口的设备驱动程序。
设备驱动程序确保它为其编写的特定硬件拥有所需的一切。它了解波特率、奇偶校验、停止位等等。一旦所有这些都被确定,设备驱动程序就会将数据发送到 USB/串行控制器。这个控制器是一块硬件,它物理连接到端口。
一旦数据传输到这个阶段,它就会通过一组电线离开我们的系统,离开我们的机器,并前往其他硬件。
发生了很多事情,但我们几乎看不到。在我们的代码中,我们看到的是以下内容:
using var serialPort = new SerialPort(
"COM3",
9600,
Parity.None,
8,
StopBits.One);
serialPort.Open();
try
{
serialPort.Write([42],0, 1);
}
finally
{
serialPort.Close();
}
我们创建SerialPort类的实例。我们给它期望的参数。首先,我们需要指定我们想要与之通信的端口。计算机通常有多个串行端口。在以前,计算机确实有最小数量的物理端口。它们要么是并行端口,能够同时发送多个比特,要么是串行端口,只能同时处理一个比特。串行端口也被称为通信端口,简称COM。在我的例子中,我们连接到第三个端口,因为我碰巧知道该端口连接了可以与之通信的硬件。
我还给出了速度——在我的例子中,是 9,600 波特率。
波特率与每秒比特数
关于描述通信速度的最佳方式,有一个常见的误解。我们使用波特率来表示较老的 COM 端口。术语波特率是以法国科学家让-莫里斯-埃米尔·鲍尔多特(Jean-Maurice-Emile Baudot,1845–1903)的名字命名的,他致力于开发一个系统,允许通过单根电报线进行多次传输。波特率代表每秒的信号变化次数。
每秒比特数的意思就是——我们每秒可以发送多少比特?由于波特率是模拟的,可以组合信号,波特率和比特每秒之间没有直接的关系。
然而,在大多数情况下,它们非常接近。9,600 波特率可以认为是大约 9,600 比特每秒。但不要依赖它!
在相关的问题上,一个字节不必是 8 比特。通过电线传输的字节可以长达 12 比特,这取决于通信设置。
我们还定义奇偶校验为无。我们将数据包设置为 8 比特。我们还添加了 1 个停止位。
我在这里给出的设置(无奇偶校验位、8 位和 1 个停止位)是默认设置,但你也可以省略它们。然而,你必须确保线路另一端的设备使用相同的设置。你可以想象,如果你每个字节发送 10 位,其中一些用于错误检查,而另一端期望每个字节只发送 8 位,那会是一团糟。关于这类事情,最好还是明确一点。
一旦我们有了SerialPort,我们就可以打开连接。然后,我们通过电线发送 1 个字节。在我们的应用程序到实际电线之间的链路中,某个地方会添加或转换奇偶校验位、转换为正确的位数和停止位,但我们对此无能为力。BCL、操作系统和设备驱动程序会处理这些。
当然,我们通过再次关闭端口来最终完成所有操作。
接收数据同样简单,但我们会稍后讨论这一点。
我们为什么关心?
串行通信,尤其是在 COM 端口上,是老式技术。如今,如果我们想连接到其他硬件,我们会使用有线网络、Wi-Fi、蓝牙或 USB。或者,至少,你可能这么认为。
对于大多数软件开发人员来说,这是真的。他们几乎不会遇到像串行端口这样的东西。但我们的系统程序员并不像大多数软件开发人员那样。我们处理硬件。而且,通常,这些硬件很旧。或者至少,这些硬件的设计很旧。
例如,许多工厂都有机器人。其中许多通过串行端口进行通信。医疗设备是另一个例子。认证医疗设备需要非常长的时间,因此制造商通常非常不愿意因为宣布了一种新的电缆类型而更改硬件的一部分。他们倾向于坚持有效的方法。只要串行通信足够好,他们就会继续使用它们。
工业数控机床、条形码扫描仪和 GPS 接收器都是今天仍在广泛使用的依赖串行端口的硬件的例子。我们系统程序员是最有可能遇到这些设备的开发者。
因此,了解串行通信是什么以及它做什么至关重要。但,当然,你怎么为它编程呢?
虽然你不太可能在电脑上看到实际的 D-Port 式串行连接器(除非你特别添加一个),但串行端口仍然存在。这些较老端口和我们现在使用的端口之间的区别在于我们使用的是虚拟 COM 端口。
操作系统和设备驱动程序通过 USB 端口将通信通道传递到外部世界,模仿较老的端口。D 式端口有多个引脚用于电源、地、数据、TX 信号等等。如今,USB 设备负责这些。但如果你想连接到这些较老的机器之一,你可以购买便宜的简单 USB 到串行(或技术上称为 RS232)转换器。
我怀疑我们还会长时间使用串行端口。这就是为什么我在本章花这么多时间讨论它们的原因。
关于奇偶校验、数据大小和停止位的一些说明
在之前的示例中,我们将串行端口设置为不使用奇偶校验、8 个数据位和 1 个停止位。但这究竟意味着什么呢?
通常,你不需要关心实际硬件是如何通信的。如果你想从你的存储介质中加载一个文件,你不会受到内部工作的干扰。你不在乎介质是超级快速的 SSD 还是插入某处的慢速 SD 卡。你选择存储数据的位置,然后就可以继续了。操作系统和设备驱动程序会处理其余的事情。
对于 COM 端口来说,这不是一个选项。你不必担心电线之间的电压,但你必须了解设备想要如何通信的更多细节。哦,如果你想知道的话,对于低速 USB 设备,零的电压在 0.0V 到 0.3V 之间,一的电压在 2.8V 到 3.6V 之间。现在你知道了。
那么,如果我们想要通过串行通信线路进行通信,我们需要知道些什么呢?嗯,我们需要决定四个参数。发送方和接收方都需要达成一致。串行协议并不关心:它只知道如何将一和零放在那条线上。我们需要告诉我们的软件这些数据代表什么。
我们需要设置的参数是速度、我们是否想要使用奇偶校验、数据包的大小以及我们是否想要使用停止位。
速度
速度至关重要。我们指定速度为每秒电压变化的次数。我们不是以每秒比特数来指定它。这种区别很重要,因为比特是一个离散的单位。比特就是比特。没有更多,也没有更少。但在电子的世界里,比特并不存在;我们能处理的是电子的流动,形成电压(我在这里真的过于简化了,但基本想法是有效的)。
如果一根电线在一秒内电压高,紧接着一秒内电压低,我们根本不知道这意味着什么。它就是这样——一秒的高电压,接着一秒的低电压。
但如果我们确定我们每秒可以进行四次变化,我们就可以确定我们进行了八次变化;前四次是高电平,后四次是低电平。然后,我们可以同意我们有四个 1,后面跟着四个 0。因此,在两秒钟内,我们传输了比特 11110000。但如果我们确定我们每秒可以进行八次变化,数据将是 11111111 00000000。这完全是一个不同的数字。
我们用来指定速度的波特率告诉系统在特定时间内传输了多少数据,或者发送一个元素(好吧,这是一个比特)需要多长时间。
这一切都关乎时间,这可以帮助硬件进行一些基本的错误检查。当谈到停止位时,我会解释这一点。
奇偶校验
有时,数据会变得混乱。我们在这里处理的是电气连接,有时可能不可靠。有时,电压会下降或出现尖峰,这会阻碍我们想要发送的数据。有几种高级方法可以处理这种情况,但最古老且最简单的方法是通过使用奇偶校验来做一些基本的检查。
存在三种奇偶校验检查方式——偶数、奇数和无。无是最简单的——我们不想进行任何检查。
另外两种,偶数和奇数,意味着我们为每个数据包添加一个额外的位。这个额外的位要么是 1,要么是 0,所以包括奇偶校验位在内的数据包中 1 的总数是一个偶数或奇数。
假设我们想要传输以下 4 位序列——1011。如果奇偶校验设置为偶数,系统会计算该消息中 1 的数量。它注意到有三个,这是一个奇数。我们需要使其成为偶数,因此系统向包中添加一个 1,并通过电线发送,结果为位 10111。
如果我们选择通过电线发送 1001,1 的数量已经是偶数了,所以不需要额外的 1。系统添加一个 0,并通过电线发送 10010。
在接收端,系统计算包中 1 的数量,并检查它是否确实是偶数。如果不是这样,那么就出了问题。然后系统可以忽略那个包或请求重新发送。
当然,如果我们把奇偶校验设置为奇数,那么只有当数据包中 1 的数量是偶数时,它才会添加一个 1。
如果两个位翻转而不是一个位,系统就会崩溃。在这个简单的设置中,没有办法知道发生了这种情况。还有其他方法可以做到这一点,但你必须自己实现它们。
奇偶校验确实会增加数据包的大小,略微减慢通信速度。
数据大小
一个字节有多重要?我想你可能会倾向于说 8 位。但在计算机的早期,这并不是一个固定的数字。有很多基于 10 位的计算机。当时的数据传输既慢又贵,所以他们决定如果他们想要发送文本,可以只发送 7 个字节。毕竟,大多数 ASCII 字符都可以适应 7 位。那么为什么发送额外的数据呢?我知道现在,人们很难想象会担心额外的位,但请记住,时代在变化。例如,我用来将我的计算机连接到外部世界的第一个调制解调器的传输速度为 1200/75。这意味着它以大约每秒 1200 位的速度接收,或者大约每秒 120 字节。但我只能以每秒 75 波特的速度上传。这大约是每秒 10 字节。在这些情况下,移除一个位可以产生很大的差异!
串行端口允许你选择数据包的大小。这个大小是每个包包含的位数,不包括奇偶校验位或任何停止位。你可以选择 7 位或 8 位。技术上,你可以指定其他大小。实际上,在现实中你永远不会遇到这种情况。
7 位足以表示 ASCII 字符。如果你使用 8 位,你可以在一次传输中加倍传输的信息量,但也会使它稍微慢一些。在串行通信的世界里,这可能是重要的。
默认是 8 位,但如果你想真正充分利用你的系统,7 位可能是个好主意。
停止位
然后,我们有 停止位。停止位被添加到数据包中,以表示该数据包的结束。你可以选择 1、1.5 或 2 个停止位。系统将这些位添加到包的末尾,通常是 1。添加数据实现了三个目的——首先,它表示包的结束。它有助于检测定时问题或错误,并允许硬件赶上。
停止位不是实际的位;它们不是数据。它们不会到达链尾的软件。相反,它们是一段电压高的固定时间。这解释了为什么我们可以有 1.5 个停止位。没有 半个位,但你可以将电压设置为传输一个位所需时间的一半。记得我提到过定时可以帮助检测错误吗?这就是我在谈论的。
如果接收系统认为它已经接收到了约定的 8 个数据位和奇偶校验位,它期望一个停止位(假设我们将停止位设置为 1)。如果线路上的电压低,说明出了问题。结合奇偶校验位,这可以检测简单的错误。
停止位可以是 1、1.5 或 2 个 位 长度(记住,它们不是位,而是发送一个位所需的时间)。在两个数据包之间添加额外的时间意味着接收系统有足够的时间处理它接收到的位,计算奇偶校验,并在下一个数据包到达之前将其传递给系统的其余部分。再次强调,在超快硬件的今天,这似乎有些奇怪,但在串行通信被设计出来的时候,添加 1、1.5 或甚至 2 个 位 的暂停时间可能意味着一个优秀的工作系统或一连串的错误。
使用 Arduino 工作
我附近没有医疗 MRI 机器,所以无法展示如何使用讨论的技术连接到其中之一。然而,我确实有一台其他设备——Arduino Uno。
Arduino 真的是非常便宜的微控制器。虽然一个实际的 Arduino 可能需要花费你 20 到 30 美元;具有相同功能的类似设备大约只需 5 到 7 美元。以这个价格,你可以得到一个性能良好的微控制器,可以连接到你的电脑,编程并用于连接各种硬件。
硬件很简单——一个 CPU、一个 USB 连接器、一点内存和一个可以存储你的程序的 EEPROM。此外,Arduino 有可以用来连接其他硬件的引脚。
你可以使用一个名为 Arduino IDE 的免费工具来编程你的 Arduino。现在,这本书是关于使用 C#和.NET 进行系统编程的,而不是关于 Arduino。但是,如果你们决定购买 Arduino 并跟上进度,我需要简要地谈谈这一点。如果你这样做,太好了!如果你不这样做,继续阅读,直到我们到达关于模拟硬件的部分。
我选择 Arduino,因为它使用串行端口与你的电脑通信。它很便宜,而且很多人家里都有。然后,我们可以构建一个基本的设备,让它与我们交流,并让它对我们说话。如果你对这些设备没有经验,不要担心;我会解释你需要知道的一切,以便跟上进度。
我们需要为 Arduino 编写一些软件。代码很简单,包含在这本书的 GitHub 仓库中。
在我们查看代码之前,让我先解释一下我们将要创建的设备。
该设备
我想让我们的 Windows 机器对外部世界更加敏感。我想让它能够感知声音。我想创建一个设备,当检测到大声噪音时,它会警告 Windows。
我们可以使用麦克风并将其插入正确的端口,但麦克风很复杂。它可以以高保真度录制声音。我不想那样;我只想知道是否有大声噪音,而不是噪音的类型。此外,我们一次只能使用一个麦克风。所以,如果我们使用麦克风,我们就无法使用我们的机器进行 Teams 通话或其他类似操作。
最好将这项工作委托给一个单独的设备。为此,我们需要一些东西:
-
一个 Arduino 或兼容设备
-
一个面包板。这是一块带有电线的塑料板,允许我们插入硬件并将它们连接起来,而无需焊接。
-
一个 KY-037 声音检测器。这个非常简单的设备一旦“听到”噪音就会输出电压。它们的价格在 1.50 美元到 3.00 美元之间。
-
一个 LED 和一个 200 欧姆电阻(可选)。我想当设备听到声音时点亮一个 LED。你不需要这个;Arduino 内置了一个 LED,我们也可以使用它。
-
一根 USB 线将其全部连接到我们的机器上。
-
准备一些电线来连接不同的部件。
这个设备的电路图看起来像这样:

图 9.1:声音检测器电路图
如果你以前从未接触过这种类型的电子设备,不要担心。它并没有你想象的那么可怕。前一个图下面的东西是 Arduino。正如我之前所说的,它有引脚,我们可以连接电线,将其与其他硬件连接起来。我在这里使用了四根电线。从底部到面包板(那块白色塑料)的电线连接到 Arduino 的 5 伏电源。我已经将它连接到面包板的最下面一行。
面包板的工作原理是这样的——最低行上的所有小孔都是电连接的。这意味着如果我在该行的一个孔中插入一个带有 5 伏电压的电线,该行中所有其他孔也将有 5 伏电压。同样,第二行也是这样;它们也是水平连接的。我使用这个来连接地线。我将一个孔与 Arduino 的GND(意味着地)引脚连接起来。我插入第二行的所有电线都连接到地线。
你看到的红色电子部件连接到面包板上。除了面包板最低的两行外,每一列也是连接的。这意味着如果我在第一列的孔中插入东西(在两个底部行上方),它上面的所有孔也将连接。列之间是隔离的。
面包板由两部分组成——底部和顶部。这两部分完全隔离;没有电线从一个部分连接到另一个部分。因此,面包板的顶部是底部的镜像。
我将 KY-037(原理图中的红色部件)插入到面包板上。我将第一行的 5 伏电压连接到正确的列。我为 GND 信号做了同样的操作。然后,我将 KY-037 最左边的引脚直接连接到 Arduino 的 8 号引脚。
我为 LED 做了类似的事情;LED 的正极连接到 Arduino 的 13 号引脚,负极连接到一个 200 欧姆的电阻,该电阻反过来连接到面包板的 GND 行(因此也连接到 Arduino 的 GND)。
到目前为止,你跟得上吗?
这个想法很简单——如果 KY-037 检测到声音,它(如果由 Arduino 的 5 伏电源供电)将在连接到 Arduino 8 号引脚的 D0 线上施加电压。如果发生这种情况,微处理器可以检测到这一点并在 13 号引脚上施加电压。这将点亮 LED。
如果声音消失,8 号引脚上的电压也会变为低电平,我们可以通过从 13 号引脚移除电压来编程 Arduino 停止 LED。当然,这是通过移除 13 号引脚的电压来实现的。
Arduino 软件
我们需要指导 Arduino 如何行为。这意味着我们必须编程它。我们可以使用免费的 Arduino IDE 来编写和部署我们的软件到设备上。设备本身很简单;它只能有一个程序。该程序在设备上电时启动,直到断电才停止。没有真正的操作系统,没有加载,也没有多任务处理。
程序本身也很简单。它由两部分组成。第一部分是一个名为setup()的方法。这个方法在程序启动时(或 Arduino 上电时)被调用。它只调用一次,是一个进行初始化的好地方。
还有一个叫做loop()的方法。正如其名称所暗示的,这是一个循环。Arduino 会遍历loop()中的代码,并在到达末尾时从loop()的开始处重新启动。就是这样。当然,你可以(并且应该)编写自己的方法和函数,但这是让设备运行所必需的。
编程是用 C 语言完成的(技术上也可以是 C++,但让我们不要深入这个话题)。IDE 可以为你编译代码并将其部署到连接的 Arduino 上。当你通过 USB 电缆将 Arduino 连接到你的机器时,IDE 会识别它并知道如何与微控制器通信。
我想要使用的软件看起来是这样的:
#define LedPin 13
#define SoundPin 8
int _prevResult = LOW;
void setup() {
pinMode(LedPin, OUTPUT);
pinMode(SoundPin, INPUT);
Serial.begin(9600);
}
void loop() {
int soundPinData = digitalRead(SoundPin);
if(soundPinData != _prevResult){
_prevResult = soundPinData;
if(soundPinData == HIGH)
{
Serial.write(1);
digitalWrite(LED_BUILTIN, HIGH);
}
else
{
Serial.write("0");
digitalWrite(LED_BUILTIN, LOW);
}
delay(100);
}
}
就这样了。
让我们来看看它。
首先,我定义了一些常量。我创建了LedPin常量并将其设置为 13。这个 13 号引脚是我们连接 LED 以检测声音的引脚编号。我选择 13 号引脚是因为大多数 Arduino 设备都有内置的 LED,连接到 13 号引脚。所以,如果你不想使用外部 LED,你可以查看板子并看到相同的效果。
我还定义了 KY-037 用来将信号发送回我们的引脚,引脚 8,我称之为SoundPin。我选择引脚 8 没有特定的原因;它方便地位于 Arduino 上,所以我可以轻松地将其连接到面包板上。
然后,我们有setup()方法。同样,这是用来初始化系统的。我们在这里做了三件事:
-
我们将引脚 13 的方向设置为
OUTPUT;我们通过调用pinMode(LedPin, OUTPUT)来做这件事。这个方向意味着 Arduino 可以使用这个引脚进行写入。我们需要这样做来打开或关闭 LED。 -
我们通过调用
pinMode(SoundPin, INPUT)将引脚 8 的方向设置为INPUT。现在,Arduino 知道我们想要从这个引脚读取而不是写入。 -
我们打开串行端口。我们通过调用
Serial.begin(9600)来实现这一点。这通过 USB 连接器打开串行连接,连接到任何与之连接的设备。我们告诉它我们的速度是 9600 波特。我们本来可以指定奇偶校验、包大小和停止位的数量,但默认值(无奇偶校验、8 位和 1 个停止位)对我们来说已经足够好了。我们需要记住这些设置,因为我们在接收端也需要它们。
然后,我们可以看看loop()方法。
我们从SoundPin引脚开始读取。我们通过调用digitalRead(SoundPin)来做这件事。记住,KY-037 在听到声音时会向设备添加电压。我们可以读取这个结果;电压水平被转换为一或零。我们将其与之前的读取结果进行比较;如果值与之前不同,我们突然听到了什么(或者停止听到什么)。如果是这样,我们确定是否有声音,并将该信息添加到串行总线中;我们使用Serial.write(1)或Serial.write(0)来发送该值。你也可以通过调用Serial.PrintLn("My data")快速通过串行端口发送一个字符串。然而,在这种情况下我们不需要这样做。
然后,根据条件,我们打开或关闭 LED。就像我们使用 digitalRead() 来读取引脚的状态一样,我们现在可以使用 digitalWrite() 来设置状态。
最后,我们调用 delay(100) 以给声音 100 毫秒的消散时间。
然后它又从头开始;毕竟我们是在一个循环中。
那就是全部了。将这个程序上传到 Arduino 并观察会发生什么。如果你发出声音,你会看到 LED 灯亮起。你还没有看到 serial.print() 的效果,但我们将解决这个问题。
使用 .NET 接收串行数据
我们已经做了很多。但那只是为了到达我们真正想要成为的系统程序员——在我们的 C# 程序中处理代码的地方。
我已经编写了一个示例,它正是这样做的;它打开串行端口,并获取数据。这本身并不太难;我已经向你展示了如何打开 SerialPort 并向其写入数据。从同一个端口读取数据同样简单;例如,SerialPort.ReadLine() 是一种方法。
然而,在处理其他硬件时,还有很多需要考虑的因素,这正是我们将在这里讨论的。
首先,我提供的示例不是一个控制台应用程序。它是一个工作服务。我选择这个模板是因为我希望这段代码在后台安静地运行,并且只有在串行总线上有数据传入时才执行操作。这是我们能够接近在 .NET 中编写设备驱动程序的最接近方式。其次,USB 和串行端口很脆弱。并不是说它们经常失败,但移除设备并重新插上的操作极其容易。你永远不能确定你需要的设备是否连接到你的电脑。
用户很少移除他们的主硬盘。网络适配器通常都留在内部。网络线缆可以移除,但几乎从不这么做。然而,USB 设备却经常被插上和拔出。有时,这是有意为之,有时,你的猫可能会决定和那个带有闪烁灯光和悬挂线缆的东西玩耍(是的,在我写这一章的时候,这种情况就发生在我身上)。
如果我们不能依赖我们想要与之通信的设备的存在,我们需要确保在我们做任何事情之前它在那里。我们还需要处理一个场景,即设备在工作时被拔掉。
幸运的是,我们 already know how to do this. 在前面的章节中,我们探讨了 Windows Management Instrumentation (WMI)。这使我们能够调查连接到我们机器的硬件,我们看到如果有什么变化,它可以引发事件。这听起来就像是我们在这里可以使用的东西。
监视 COM 端口
我创建了一个名为 ComPortWatcher 的类。正如其名所示,它监视 IComPortWatcher 接口,其外观如下:
public interface IComPortWatcher : Idisposable
{
event EventHandler<ComPortChangedEventArgs>? ComportAddedEvent;
event EventHandler<ComPortChangedEventArgs>? ComportDeletedEvent;
void Start();
void Stop();
string FindMatchingComPort(string partialMatch);
}
接口声明了两个事件。当我们将感兴趣的设备插入电脑或从电脑中移除该设备时,这些事件会被调用。其他类可以订阅这些事件并采取行动。
我们有一个名为Start()的方法,它开始监视端口。Stop()方法则相反——它停止监视端口。
我还添加了一个名为FindMatchingComPort(string partialMatch)的方法。所有设备都有一组属性,有时包括标题。这个标题包含有关连接到我们机器的设备的一些信息。在 Arduino 的情况下,Caption包含Arduino字符串和实际的 COM 端口。这个方法试图找到这个字符串并提取正确的 COM 端口,这样我们就可以用它来打开串行连接。
让我们看看实现。我们将从最简单的FindMatchingComPort(string partialMatch)方法开始。它看起来是这样的:
public string FindMatchingComPort(string partialMatch)
{
string comPortName;
var searcher = new ManagementObjectSearcher(
@$"Select * From Win32_PnPEntity Where Caption Like '%{partialMatch}%'");
var devices = searcher.Get();
if ( devices.Count > 0)
{
var firstDevice = devices.Cast<ManagementObject>().First();
comPortName = GetComPortName(firstDevice["Caption"]. ToString());
}
else
{
comPortName = string.Empty;
}
return comPortName;
}
我省略了很多错误检查和安全措施;否则,代码会变得太长而难以阅读。我相信你可以找出我省略的部分,并自己想出如何实现。在这里,我只关注了基本的部分。
首先,我创建了一个ManagementObjectSearcher类的新实例。我给它提供"Select * From Win32_PnPEntity Where Caption Like '%{partialMatch}%'"搜索字符串。这个字符串会搜索所有即插即用设备,并尝试匹配我们传入的设备标题。同样,在我的情况下,我给它Arduino字符串。
如果没有匹配项,我们简单地返回一个空字符串,表示没有找到 Arduino 设备。然而,如果找到了一个(我只检查一个;这是你可以大幅改进的一个区域),我取那个标题并使用一些GetComPortName()方法来提取 COM 端口的名称。
那个正则表达式代码看起来是这样的:
private string GetComPortName(string foundCaption)
{
var regExPattern = @"(COM\d+)";
var match = Regex.Match(foundCaption, regExPattern);
return match.Success? Match.Groups[1].Value : string.Empty;}
这段代码相当简单。我们使用"(COM\d+)"正则表达式模式,这意味着我们寻找以 COM 开头的字符串,后面跟着一个或多个数字。然后,我们返回字符串的这一部分。在我的机器上,端口的标题看起来像Arduino Uno (COM4),所以这种方法返回的字符串是COM4。
这个类的Start()方法设置了监视器。在类中有两个私有成员:
private ManagementEventWatcher? _comPortDeletedWatcher;
private ManagementEventWatcher? _comPortInsertedWatcher;
这些是当发生有趣的事情时可以触发事件的 WMI 监视器。我们定义的“有趣”的事情在Start()方法中指定。下面是它的工作方式:
public void Start()
{
if (_isRunning)
return;
var queryInsert = "SELECT * FROM __InstanceCreationEvent WITHIN 1 " +
"WHERE TargetInstance ISA 'Win32_PnPEntity' " +
"AND TargetInstance.Caption LIKE
'%Arduino%'";
var queryDelete = "SELECT * FROM __InstanceDeletionEvent WITHIN 1 " +
"WHERE TargetInstance ISA
'Win32_PnPEntity' " +
"AND TargetInstance.Caption LIKE
'%Arduino%'";
_comPortInsertedWatcher = new
ManagementEventWatcher(queryInsert);
_comPortInsertedWatcher.EventArrived += HandleInsertEvent;
_comPortInsertedWatcher.Start();
_comPortDeletedWatcher = new ManagementEventWatcher(queryDelete);
_comPortDeletedWatcher.EventArrived += HandleDeleteEvent;
_comPortDeletedWatcher.Start();
_isRunning = true;
}
首先,我检查它是否已经在运行。这样做两次是没有意义的。然后,我定义了一个查询字符串,用于搜索插入和删除设备。
当设备插入时,操作系统的__InstanceCreatedEvent类会获取有关该设备的信息。我们查询这个类,但只有当目标是即插即用设备(Win32_PnpEntity)且Caption包含Arduino时。我对其他任何设备不感兴趣。
我为删除事件创建了一个类似的查询字符串。
然后,我创建了一个Watcher类的实例,给它提供查询,并设置事件处理器。最后,我在监视器上调用Start()方法,以便它们开始执行它们应该执行的操作。
Stop()方法停止监视器并清理它们。那里没有什么特别的地方,但你可以查看 GitHub 仓库中的代码以获取更多细节。
事件处理程序比Stop()方法稍微有趣一些。看看这个:
private void HandleInsertEvent(object sender, EventArrivedEventArgs e)
{
var newInstance = e.NewEvent["TargetInstance"] as ManagementBaseObject;
var comPortName = GetComPortName(newInstance["Caption"]. ToString());
Task.Run(() => ComportAddedEvent?.Invoke(this, new ComPortChangedEventArgs(comPortName)));
}
当监视器在操作系统中看到令人兴奋的事件时,会调用这个方法。我们取EventArgs(类型为EventArrivedEventArgs),取NewEvent属性,并获取TargetInstance成员。我们将它转换为正确的类型,ManagementBaseObject,并移除标题。然后,我们提取 COM 端口名称并调用任何附加的事件处理程序。由于我知道附加的事件处理程序将启动串行通信,所以我将其包装在Task.Run()方法中,使其异步工作,从而停止它阻塞当前线程。记住,所有耗时的事情,比如 I/O,都应该写成异步代码。
删除事件的处理器看起来很相似。
使用这个类,我们可以坐下来放松。我们可以确保在需要时 COM 端口可用,如果它被拔掉,我们可以采取行动。
包装串行端口
在.NET 中的串行端口类存在一个小问题。它不是为这个时代编写的。它是从过去一个更慢的世界遗留下来的。它不是异步的。这可能会成为一个问题。串行通信已经足够慢了,而且所有对它的调用都会阻塞它运行的线程。我们需要把这个类包装成更现代的东西。
我创建了一个接口,展示了如何做到这一点:
public interface IasyncSerial
{
bool IsOpen { get; }
void Open(string portName,
int baudRate = 9600,
Parity parity = Parity.None,
int dataBits = 8,
StopBits stopBits = StopBits.One);
void Close();
Task<byte> ReadByteAsync(CancellationToken stoppingToken);
}
接口有一个IsOpen属性,可以帮助我们防止打开多个连接。我们有Open()方法,我把它写成这样,参数都在那里,但如果用户这个类省略了它们,串行端口就会使用默认设置创建。
我们有一个Close()方法来关闭连接。
我还添加了一个ReadByteAsync()方法,它从设备读取 1 个字节。我不需要更多;我们的声音检测设备一次只发送 1 个字节。
让我们看看实现过程。
首先,我在类中有一个私有成员:
private SerialPort? _serialPort;
我们已经遇到了SerialPort类,所以Open()方法的实现应该是熟悉的:
public void Open(
string portName,
int baudRate = 9600,
Parity parity = Parity.None,
int dataBits = 8,
StopBits stopBits = StopBits.One)
{
if (IsOpen) throw new InvalidOperationException("Serial port is already open");
_serialPort = new SerialPort(
portName,
baudRate,
parity,
dataBits,
stopBits);
_serialPort.Open();
IsOpen = true;
}
这里没有发生什么特别的事情——我们创建了一个SerialPort类的实例,给它正确的参数,然后打开它。就是这样。
Close()甚至更简单——它只调用_serialPort成员上的Close()。好吧,它做了那件事,还有一些清理工作。
ReadByteAsycn()要有趣得多。这是我们编写这个类的原因。下面是它:
public Task<byte> ReadByteAsync(CancellationToken stoppingToken)
{
return Task.Run(() =>
{
if (!IsOpen) throw new InvalidOperationException("Serial port is not open");
var buffer = new byte[1];
try
{
_serialPort?.Read(buffer, 0, 1);
}
catch (OperationCanceledException)
{
// This happens when the device has been unplugged
// We pass it a 0xFF to indicate that the device is no // longer available
buffer[0] = 255;
}
return buffer[0];
}, stoppingToken);
}
再次,我们用Task.Run()包装同步调用,这样整个操作就变成了异步的。我们返回那个Task给调用者。
我们调用_serialPort?.Read(buffer,0,1)。如果有的话,这会产生一个字节的数据。如果没有数据可用,这个调用会阻塞,直到有数据到来。这就是为什么我们使用Task.Run()——我们不希望阻塞整个系统并等待单个字节到来。
然而,如果在等待数据时设备从我们的系统中移除,我们会得到 OperationCanceledException。这是有道理的;我们正在等待来自不再存在的设备的数据。我们捕获这个异常并返回 0xFF 字节。由于我们知道我们只能从 Arduino 板上得到 0 或 1(这就是我们编程的方式),我们可以安全地使用这个神奇数字来表示错误。
让我们看看我们如何使用这两个类。
使它们一起工作
我提到我们在构建一个工作服务。这个服务在后台运行,不会影响其他代码或程序。默认模板给你一个名为 Worker 的类,我们可以在其中进行实际的工作。我们应该将我们的代码添加到这个 Worker 类中。
但在这样做之前,我们需要稍微修改一下 Program 类。工作服务模板的一个优点是它免费提供依赖注入,开箱即用。我们可以使用它来注册我们的 IAsyncSerial 和 IComPortWatcher 接口及其相应的类。这样,我们就不必自己创建实例。
Program 类需要修改成这样:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<IComPortWatcher, ComPortWatcher>();
builder.Services.AddTransient<IAsyncSerial, AsyncSerial>();
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
如您所见,我们已经注册了我们的新接口和类,使它们对任何需要的人可用。在我们的情况下,任何人 是 Worker 类。让我们看看构造函数:
public Worker(ILogger<Worker> logger,
IAsyncSerial serial,
IComPortWatcher comPortWatcher)
{
_logger = logger;
_serial = serial;
_comPortWatcher = comPortWatcher;
_comPortName = _comPortWatcher.FindMatchingComPort("Arduino");
_deviceIsAvailable = !string.IsNullOrWhiteSpace(_comPortName);
_comPortWatcher.ComportAddedEvent += HandleInsertEvent;
_comPortWatcher.ComportDeletedEvent += HandleDeleteEvent;
_comPortWatcher.Start();
if (_deviceIsAvailable) StartSerialConnection();
}
我们设置我们类的传入实例,然后寻找连接到 Arduino 的 COM 端口。如果有,我们可以将 _deviceIsAvailable 变量设置为 true。
我们添加了当设备插入或删除时被调用的事件。然后,如果设备已经可用,我们开始串行连接。
那个方法,StartSerialConnection(),看起来像这样:
private void StartSerialConnection()
{
if (_serial.IsOpen)
return;
_serial.Open(_comPortName);
_deviceIsAvailable = true;
}
由于我们已经在 AsyncSerial 类中完成了艰苦的工作,我们可以简单地调用 _serialOpen(_comPortName)。
ComportAddedEvent 的事件处理器大致做同样的事情:
private void HandleInsertEvent(object? sender, ComPortChangedEventArgs e)
{
_comPortName = e.ComPortName;
_logger.LogInformation($"New COM port detected: {_comPortName}");
if (!string.IsNullOrEmpty(_comPortName))
StartSerialConnection();
}
事件从 ComPortWatcher 类获取 COM 端口的名称。所以,我们在这里要做的只是保存那个名称并开始通信。
实际的工作发生在工作者的 ExecuteAsync 方法中。您可能还记得,运行时调用这个类的一部分来执行实际工作。通常,这个方法包含一个循环,直到 CancellationToken 信号需要停止才会重复。我们的版本看起来像这样:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_deviceIsAvailable)
{
var receivedByte = await _serial?. ReadByteAsync(stoppingToken);
if (receivedByte == 0xFF)
{
StopSerialConnection();
_logger.LogWarning("Device is ejected.");
}
else
{
_logger.LogInformation($"Data received: {receivedByte:X}");
}
}
await Task.Delay(10, stoppingToken);
}
}
首先,我们检查设备是否可用。如果没有设备连接,读取数据有什么意义,对吧?
如果有设备,请调用新的 ReadByteAsync() 方法并检查结果。如果它们返回 0xFF,我们就有问题了——设备被移除了。否则,我们只需显示我们拥有的数据。
就这样,所有的内容都介绍完了!这已经很多了。我们介绍了 Arduino,并从中构建了自己的设备。我们学习了串行端口通信的样子。我们讨论了从串行端口提取数据以及如何使其异步工作。总的来说,我认为你值得休息一下。在这里,我们覆盖了很多内容。
请查看 GitHub 仓库中的完整示例,以了解我这里省略的细节。然而,根据我刚刚提供的信息,你已经拥有了开始与串行设备通信所需的一切!
模拟串行设备
我承诺要和你讨论另一件事——如果你没有设备可用,该怎么办。这就是为什么我使用了IComPortWatcher和IAsyncSerial接口的原因。我们可以在单元测试中模拟它们,并编写模拟设备的伪代码。这是一个很好的主意,因为串行通信很脆弱,经常失败。如果你正在开发软件,你希望有一个可以信赖的环境。使用这些接口可以帮助你。
例如,我可以有IComPortWatcher的另一个实现,它包含一个看起来像这样的Start()方法:
public void Start()
{
_timer = new Timer(2000);
_timer.Elapsed += (sender, args) =>
{
// Trigger the event every second
if (_deviceIsAvailable)
{
ComportDeletedEvent?.Invoke(this, new ComPortChangedEventArgs("COM4"));
}
else
{
ComportAddedEvent?.Invoke(this, new ComPortChangedEventArgs("COM4"));
}
_deviceIsAvailable = !_deviceIsAvailable;
};
_timer.Start();
}
如果我把这个插入到我的Program类中,我可以模拟ComPortWatcher。Program类看起来像这样:
#define FAKESERIAL
using _09_SerialMonitor;
using _09_SerialMonitor.Fakes;
var builder = Host.CreateApplicationBuilder(args);
#if FAKESERIAL
builder.Services.AddTransient<IComPortWatcher, FakeComPortWatcher>();
builder.Services.AddTransient<IAsyncSerial, FakeAsyncSerial>();
#else
builder.Services.AddTransient<IComPortWatcher, ComPortWatcher>();
builder.Services.AddTransient<IAsyncSerial, AsyncSerial>();
#endif
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
如你所见,我可以通过定义FAKESERIAL来轻松地在真实和模拟代码之间切换。当然,你可以通过在构建配置文件中而不是在源代码中定义它来做得更好。这样,你可以选择你想运行哪个版本。
我将把FakeAsyncSerial的实现留给你。
因此,我们已经了解了如何编程你的计算机来发送和接收串口或 COM 端口的数据。与外界通信有许多方式,但 COM 端口仍然存在。系统程序员经常会遇到这种特定的协议,现在你知道如何与之交互了。
我们使用 Arduino 来模拟外部设备。当然,你可能会遇到许多不同种类的设备。它们都有各自不同的用途和通信方式。但通常情况下,如果它们通过电缆连接到你的机器上,它们会使用串行连接。现在,你已经看到了如何设置这样的连接和测试环境来模拟串行连接。但是,我还有一件事要讨论——如何使这种系统更加可靠。
使其更加可靠
我们已经看到,我们可以使用 WMI 来拦截设备的不当移除。USB 线缆很容易从机器上拔掉。它们的卖点之一就是插拔设备非常方便。从用户的角度来看,这很好。但从开发者的角度来看,这并不那么好。
即使用户(或你的猫)没有摆弄电缆,许多事情都可能阻碍可靠的数据流。
事情为什么会变得混乱
如果你的软件失败,你可以轻松地查找发生了什么。嗯,实际上并不容易——调试软件,尤其是低级别的多线程软件,可能具有挑战性。但它是可行的。
处理来自其他硬件的问题要难得多。事情出错的原因有很多。让我们来看看其中的一些。
-
干扰:用于串行通信的电缆很简单——几根铜线传输电信号。所有电缆都对干扰敏感,无论它们的使用情况如何。干扰是当电信号在其周围电磁场中引起变化时产生的效果,这反过来又可能引起其他导线的改变。在实践中,如果你有一根长长的串行电缆,并且将其盘绕在你的设备旁边,你可能会注意到错误。解决办法是使用屏蔽电缆或更短的电缆,或者确保它们与其他电缆隔离。
-
不良电缆:当然,即使你有一根短直电缆,附近没有其他电缆,你仍然可能会遇到错误。电缆可能损坏。连接器可能故障。唯一的方法是使用测量设备检查电缆和硬件。但即使这样也不总是能告诉你所有你需要知道的信息。有时,铜线的张力部分断裂,这意味着有时它们传导信号,有时则不传导。
-
错误的波特率:如前所述,波特率描述了每秒信号变化的次数。通信的两端必须使用相同速度的数据。如果你没有正确设置,可能会觉得一切正常。然而,你可能会得到奇怪的数据流,而不是你预期的有价值的数据。
-
缓冲区溢出:数据流需要被处理。有时,处理过程太长,因此驱动程序必须缓冲传入的数据流。这个想法很简单——设备驱动程序在数据到来时将其放入缓冲区,并在应用程序请求时传递这些数据。然而,如果应用程序不能及时处理这么多数据,缓冲区就会填满。最终,缓冲区满了;它不能使用无限的内存。这将触发非常低级别的错误,并导致缓冲区溢出。
-
驱动程序问题:所有硬件都通过驱动程序进行通信。驱动程序是命令或数据在转换为电压差异之前通过的最后一块软件。驱动程序是为你的机器上的硬件专门编写的软件。它充当操作系统和硬件之间的翻译器。但最终,它是软件。软件可能会出错。因此,驱动程序也可能出错。如果发生这种情况,很难看到为什么事情没有按预期工作。驱动程序出错很难追踪。
-
端口配置错误:正如我们所见,我们需要以某种方式设置连接。我们必须通知系统有关奇偶校验、数据包中的位数以及我们想要的停止位数。如果我们混淆了这些设置,我们将得到无意义的数据。并非所有您想要使用的设备的供应商都擅长指定他们期望的格式。因此,您可能需要尝试多次,直到一切按预期工作。
-
硬件故障:最终,在您的软件和您与之通信的设备之间的一切都是由许多硬件组件组成的。它们都可能因为几个原因而出错。端口可能有问题,或者条形码扫描器可能工作不正确。在处理硬件时,可能会有很多问题发生。
-
错误的数据格式:串行通信非常基础。您得到一串位,它们可以被分组成一组字节。但那之后呢?这意味着什么?数据被转换的格式必须在两端都清楚;否则,您无法相互理解。
-
奇偶校验位错误:奇偶校验是一种检测错误的优秀方法。但如果两个位发生翻转怎么办?奇偶校验在那里帮不上忙;如果发送方通过线路发送了 4 个 1 值,奇偶校验位可能被设置为 0(如果使用奇偶=偶数)。但是,如果这些 1 值中的一个变为 0,而一个 0 值变为 1,你仍然有一个有效的奇偶校验。然而,数据可能完全无意义。
-
宇宙辐射:这个可能性很小,但它确实发生了。宇宙辐射,正如其名所示,是来自太空的辐射。它一直围绕着我们。它不会造成伤害,但有时,偶尔,它会击中一块硬件。当这种情况发生时,0 可能变成 1,反之亦然。这种情况在您的计算机内部发生的可能性甚至更小;您的处理器和内存周围有很多保护措施。但这种情况可能会在廉价的串行电缆中发生得更频繁一些。
如您所见,有很多事情可能会出错,其中大多数在您的代码中很难预防。好吧,我同意宇宙辐射问题不是一个常规事件,至少不是一个足够频繁到需要担心的事件(除非您编写的是那种在任何情况下都不会出错的软件,例如用于医疗设备的软件)。
有方法可以加固您的代码,使其不会过多地受到这些潜在问题的影响。让我们调查我们能做什么。
加固代码
在硬件方面,您能做的事情很少。这只是一个自然发生的事情。但是,您可以确保当不可避免的事情发生时,您的代码不会突然停止。
使用 Try…Catch
使用Try…Catch是确保您的系统保持可预测和管理状态的最佳方法之一。不要尝试捕获Exception;相反,更具体地说明您要捕获的异常类型以及如何处理它们。
例如,您的代码应该看起来像这样:
try
{
// Attempt to read from the serial port
}
catch (TimeoutException)
{
// Handle timeout, possibly retry
}
catch (IOException ex)
{
// Handle I/O errors
// Log or attempt recovery
}
分别捕获各种异常并处理它们。
实现健壮的连接循环
我已经提到过这一点,但值得重复——连接可能会丢失。监控您连接的状态,就像我们之前使用 WMI 对象所做的那样。如果发生了不应该发生的事情,处理它并让连接优雅地死亡。
确保线程安全
如果您从多个线程访问串行连接,请使用锁或信号量等机制使您的代码尽可能线程安全。您必须防止在最初为单线程通信机制设计的系统中并发读取和写入。
使用 CancellationToken
对于长时间运行的操作,如串行通信,确保所有方法都携带CancellationToken。然后,在处理数据流时,密切关注该令牌以查看系统是否想要取消操作。
资源管理
您的系统只有有限数量的虚拟串行端口,甚至物理端口更少。这就是为什么如果您不再需要这些设备,您必须非常小心地释放它们的句柄。最好的做法是在完成工作后确保清理;实现IDisposable模式。尽量限制您使用设备的时间,并始终确保释放它。
记录和监控
就像往常一样,记录和监控是跟踪正在发生的事情的最佳方式。如果没有足够的记录,很难甚至不可能追踪事情出错时发生了什么。记录和监控是帮助您了解与外部硬件交互的具体情况的宝贵工具,尤其是在开发期间。我们在第十章中讨论了监控,但请记住,您需要这样做,尤其是在处理外部硬件时。
总的来说,您可以通过做一些事情来使您的软件尽可能健壮。但没有任何事情是免费的;所有这些都会带来性能开销。但请相信我——这是值得的。如今,软件运行速度极快,尤其是与大多数串行通信的缓慢速度相比。您有足够的时间来检查错误并确保通信顺畅。但不要使事情变得太慢;一旦开始出现缓冲区溢出错误,您就不再帮助系统。一如既往,在采取行动之前进行测试和测量。
下一步
我希望您喜欢我们这次小小的设备之旅,这些设备超出了我们计算机的领域。将其他硬件连接到机器上可以非常有趣。正如我之前所说,处理外部设备是系统程序员很可能遇到的情况。在我们的世界中,遇到较老、基于串行的通信机制的可能性相当高。
我们已经讨论了串行通信是什么以及哪些设备使用它们。我们检查了它们的协议,特别是奇偶校验、数据包大小和停止位。我们查看了一个测量声音并通过串行线接收数据的 Arduino 设备。我们还探讨了如果你手头没有这样的设备时可以做什么。
我们使软件可测试,并讨论了在处理串行设备时可能发生或将要发生的灾难。最后,我们查看了一些你可以使用的提示,以使你的软件更能抵御这些故障。
总的来说,我们做了很多探索。我反复提到记录正在发生的事情是多么重要,你应该监控你的软件,尤其是在处理外部硬件时;日志记录和监控有时是找出出了什么问题的唯一方法。
因此,下一章全部关于日志记录和监控。你准备好了吗?
第十章:带有系统检查的方案
日志记录、监控、和指标
软件时不时地会失败。无论我们是否喜欢,这只是一个生活的事实。我们在开发过程中犯错误。其他人也会犯错误。环境发生变化。网络变得不稳定。这些都是系统可能不会按我们预期行为的原因。
测试可以帮助。一套良好且稳固的测试可以显示出你工作中的错误,并有助于使你的系统更加健壮。然而,有时事情仍然会出错。让我们面对现实:构建软件是一种创造性的艺术形式,因此会受到我们无法控制的影响。所以,当事情出错,我们的系统没有按照我们预期的那样运行时,我们需要一种方法来探究它们的运作。这可以帮助我们弄清楚发生了什么,以及我们可以做些什么来解决问题。
这就是日志和监控发挥作用的地方。日志帮助我们记录重要信息并将其存储在已知的位置。日志是我们代码库的一部分。监控是从外部观察系统以跟踪正在发生的事情。在本章中,我将从系统开发者的角度向你展示如何做所有这些事情。
在本章中,我们将学习以下主题:
-
有哪些日志框架?
-
我该如何设置正确的日志级别?
-
结构化日志是什么?
-
我该如何在我的系统外监控日志?
-
监控是什么?
-
我该如何设置监控?
-
我应该监控或记录什么?
我希望你对这像我对它一样兴奋!
技术要求
在本章中,我们将探讨监控和日志工具。我经常使用的一个工具是Seq。我与他们没有关联;这只是我喜欢使用的工具。你可以在datalust.co/download下载免费的个人版本。你可以下载安装程序或以 Docker 镜像的形式运行该工具。要使用它,你必须在你的环境中安装 Docker。我建议你访问www.docker.com/products/docker-desktop/了解更多关于 Docker 的信息。如果你想尝试,我建议你选择 Docker 版本。你可以在终端中通过调用以下命令在本地运行镜像:
docker run -d
--restart unless-stopped
--name seq
-e ACCEPT_EULA=Y
-v c:\data:/data
-p 80:80
-p 5341:5341
datalust/seq:latest
这个 Docker 命令从datalust/seq下载镜像。它监听80端口,用于5341拦截日志。所有设置都存储在C:\data文件夹中,因此你必须事先创建该文件夹(或者更改 Docker 命令中的-v属性)。
我也将在本章中向你展示如何使用 Prometheus。为了使其运行起来,你可以做同样的事情。要么从他们的网站prometheus.io/下载软件,要么在 Docker 容器中运行它:
docker run -d
--name prom
-p 9090:9090
-v c:\data\prometheus.yml:/etc/prometheus/prometheus.yml
prom/prometheus
你需要一个包含你想要监控的信息的prometheus.yml文件。我将在本章的后面部分向你展示该文件的样子,我还会解释每个部分的作用。
我们还将使用很多 NuGet 包;它们都在我讨论每个包及其使用方法的段落中提到。
你可以从我们的 GitHub 仓库下载本章中提到的所有代码示例:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter10
可用的日志框架
日志一直存在。在计算机的早期阶段,操作员会在机器周围走动,并记录他们看到的所有事情。如果一个灯在不应该闪烁的时候闪烁,或者相反,他们会把它记在某本日记里。后来,系统会将它们能记录的所有内容都记录在纸上和穿孔卡片上。如果系统做了意料之外的事情,操作员可以查看纸上的记录来找出导致事件的原因。之后,人们使用串行监视器,将所有内容记录在单独的设备上。
这些天,我们几乎不再使用穿孔卡片了。然而,我们仍然在记录。有许多框架可以帮助你完成任务。在本章中,我将解释这三个框架。它们都有优点和缺点。我会尽可能突出这些,这样你可以自己决定使用什么以及何时使用。
.NET 中的默认日志记录器
微软提供了一个默认的日志记录器。我们之前见过:如果你创建一个 ASP.Net 应用程序,或者像我们这样创建一个工作进程,你将免费获得一个日志框架。这个框架功能非常全面。这个框架提供了足够的功能来满足大多数开发者的需求。所以,让我们看看它吧!
正如我说的,Visual Studio 中的许多模板已经包含了标准的Logger类。然而,有些模板并没有这个。所以,让我们看看如何添加它。我们将从一个干净、空白的控制台应用程序开始。
我们需要做的第一件事是添加正确的 NuGet 包。在这种情况下,你需要在你的项目中安装Microsoft.Extensions.Logging。一旦完成,你将能够访问日志框架。
在你的主项目中,你可以这样设置日志:
using Microsoft.Extensions.Logging;
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Information);
});
var logger = loggerFactory.CreateLogger<Program>();
logger.LogInformation("This is some information");
这段代码是有效的。如果你运行它,你不会得到任何错误。然而,你也不会得到任何输出,所以坦白说,这几乎没什么用。
这是因为框架非常灵活。它可以处理各种输出到各种目的地。然而,你必须指定你想要的内容。
让我们修复这个问题。安装另一个 NuGet 包;这次,我们需要Microsoft.Extensions.Logging.Console包。一旦安装了它,我们需要将LoggerFactory.Create()方法中的代码修改如下:
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Information);
});
在第二行,我们添加了控制台作为输出方式。
如果这次运行程序,你将得到所需的信息:

图 10.1:日志输出
好的。我们在屏幕上看到了一些内容。让我们看看我故意跳过的一些步骤以来我们做了什么。
LoggerFactory 是一个工厂类,可以创建实现 Ilogger<T> 的类的实例。我们通过连接所需的输出(在我们的情况下,是控制台;我们稍后会添加其他输出)来设置 LoggerFactory。我们还给它设置了我们想要的最低日志级别。
让我们深入探讨。我想讨论日志级别和配置,以及我们拥有的不同工具。
日志级别
并非所有消息都同等重要。如果你刚开始你的项目,你可能希望记录很多。你可以输出任何你想要的内容,你很可能也会这样做。你可以记录变量的内容、循环控制、你在流程中的位置等等。任何有助于你在运行程序时理解程序流程的内容都适合记录。
然而,一旦你编写并测试了你的软件,你可能不再需要所有这些信息。你可能只想记录异常情况和错误,仅此而已。
要实现这一点,你必须删除你不再需要的所有日志代码。或者,你可以用 #IF / #ENDIF 语句包裹代码,从而在重新编译时使用不同的 #DEFINE 有效地删除调用。然而,这意味着改变你的代码。这可能会导致副作用。如果你后来发现了一个错误并决定你需要再次使用那段代码,你将需要重写或重新编译系统。
Loglevels 消除了这个问题。
我们写的每条日志消息都有一个级别。在先前的例子中,我们使用了 Log.LogInformation()。这意味着我们想要记录一些信息。我们还可以使用其他级别。你使用它们的目的完全取决于你。然而,一般来说,每个级别都有其意义。以下是我们可以与 ILogger 一起使用的级别:
| 日志级别 | 描述 |
|---|---|
| 跟踪 | 这指的是最详细的消息。这些消息可能包含敏感的应用程序数据,因此不建议在生产环境中启用,除非它们对于故障排除是必要的,并且仅限于短时间内。 |
| 调试 | 这显示对调试有用的消息。它比 Trace 更简洁,但比信息更多。 |
| 信息 | 这允许系统显示突出显示应用程序一般流程的信息性消息。这对于一般的应用程序洞察力很有用。 |
| 警告 | 这涉及的是在应用程序流程中突出显示异常或意外事件的消息,但不会导致应用程序执行停止。 |
| 错误 | 这些消息突出显示当前执行流程由于失败而停止的情况。这些应该表明当前活动中的失败,而不是应用程序范围内的失败。 |
| Critical | 这涉及描述不可恢复的应用程序、系统崩溃或需要立即注意的灾难性故障的消息。 |
| None | 这会导致没有任何消息被记录。此级别用于关闭日志。 |
表 10.1 Microsoft Logger 中的日志级别
你可以通过两种方式指定消息的级别。你可以使用专用日志方法(例如 LogInformation、LogTrace、LogDebug 等)或通用的 Log() 方法。它看起来是这样的:
logger.Log(LogLevel.Critical, "This is a critical message");
你只需调用 Log() 并给它传递 LogLevel。无论你选择哪种方法,你都可以决定日志应该处于哪个级别。
然而,这只能解决部分问题。我们希望输出到屏幕的内容更加灵活。这就是 ILoggingBuilder 上的 SetMinimumLevel() 方法发挥作用的地方。
该方法确定日志写入所选输出通道的内容。如果你将其设置为 Information,则所有信息级别或更高级别的日志调用都会被处理。换句话说,所有调用 Log.LogTrace()、Log.Debug()、Log.Log(LogLevel.Trace) 和 Log.Log(LogLevel.Debug) 的都会被忽略。因此,你可以在一行中确定你想要和不要出现在日志中的内容。你指定了级别,并且该级别及以上的所有信息都会输出。其余的将被忽略。
在开发过程中,你可能希望将级别设置为 Trace。经过广泛的测试后,在生产过程中你可能希望将其设置为 Critical 或 Error。
使用设置文件
当然,我们还没有完成。如果你想更改日志级别,你仍然需要更改代码并重新编译系统。让我们改变一下,这样我们就可以使用其他东西了。
将一个名为 appsettings.json 的新文件添加到你的程序中。确保将 Copy to output directory 属性更改为 Copy if newer;你需要将此文件放在二进制文件旁边。
文件应该看起来像这样:
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
现在,我们需要添加几个 NuGet 包。安装 Microsoft.Extensions.Configuration.JSon 和 Microsoft.Extensions.Logging.Configuration。
在我们完成这些后,我们将添加以下代码来实际读取配置:
var configurationBuilder = new ConfigurationBuilder()
.AddJsonFile(
path: "appsettings.json",
optional:true,
reloadOnChange:true);
var configuration = configurationBuilder.Build();
var configurationSection=
configuration.GetSection("Logging");
此代码创建了一个 ConfigurationBuilder,然后添加了我们刚刚添加的 JSON 文件。我们将 optional 参数设置为 true;如果人们决定删除文件,我们的应用程序仍然可以工作。我们还指定了 reloadOnChange 参数为 true。正如你可能已经猜到的,当文件更改时,配置将被重新加载。
以下相对简单:我们调用 Build() 来获取 IConfiguration,然后调用 GetSection(Logging)来加载我们 JSON 文件中的特定部分。
我们还需要在我们的 LoggerFactory 上做一些工作。将其修改为如下所示:
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.AddConfiguration(configurationSection);
});
我们不再使用硬编码的日志级别,而是现在将配置部分从 JSON 文件中提供给它。
最后,让我们稍微修改一下实际执行日志记录的代码。我将将其包裹在一个连续循环中:
while (true)
{
logger.LogTrace("This is a trace");
logger.LogDebug("This is debug");
logger.LogInformation("This is information");
logger.LogWarning("This is warning");
logger.LogError("This is an error");
logger.LogCritical("This is a critical message");
await Task.Delay(1000);
}
运行你的程序,看看所有不同的显示消息的方式。打开另一个终端窗口,导航到编译的应用程序文件夹,并在appsettings.json文件中更改日志设置。一旦保存文件,你将看到应用程序中的不同行为。根据你的需求,它将显示更多或更少的日志行。
现在,你可以将所有你想要的日志添加到你的应用程序中,在调试和开发期间使用Trace,然后在系统准备生产时切换到Critical或Error。一旦发生某些事情,你可以迅速返回到更详细的调试级别。所有这些都不需要重新编译!
使用 EventId
有不同的调试级别很好,但如果你有大量消息,这不足以对信息进行结构化。为了帮助你创建一些日志混乱中的秩序,你可以使用EventId。
所有日志方法都有一个重载,允许你添加一个EventId。EventId是一个包含整数形式的 ID 和字符串形式的名称的类。这些是什么完全取决于你。名称甚至不在日志中使用,但它是在开发期间为你提供的便利。我们可以创建一个EventId,或者多个,如下所示:
var initEventId = new EventId(1, "Initialization");
var shutdownEventId = new EventId(2, "Shutdown");
var fileReadingEventId = new EventId(3, "File Reading");
我只是随意创建了一些类别:Initialization(初始化)、Shutdown(关闭)和File Reading(文件读取)。这只是一个例子;我相信你可以想出更好的名字。
当你记录某些信息时,可以使用一个EventId来指示你记录的消息与系统的某个部分有关。这看起来是这样的:
logger.LogInformation(initializationEventId, "Application started");
logger.LogInformation(shutdownEventId, "Application shutting down");
logger.LogError(fileReadingEventId, "File not found");
输出现在看起来有些不同:

图 10.2:使用 EventId(或多个)的日志输出
在Log类型和Program旁边,你可以看到括号中的数字。这是EventId类型的编号。在我们的例子中,1代表初始化,2代表关闭,3代表文件读取。再次强调,这些字符串永远不会被使用,并且不幸的是,它们也不会显示在屏幕上。然而,这些数字的存在可以帮助你找到你感兴趣的区域。
使用类型信息
最后,你可以使用它来组织你的日志。我之前没有解释这一点,但你一定注意到了,当我们创建logger的实例时,我们给了它一个Program类型的参数:
var logger = loggerFactory.CreateLogger<Program>();
由于我们使用Program类型调用了CreateLogger,所以我们看到日志中的Program字符串。
你可以为ILogger接口创建几个实例,每个实例都附加了自己的类型。这样,你可以为每个应用程序部分创建不同的记录器。如果你有一个处理打印的系统部分,而主类被命名为Printer,你可以创建一个Printer类型的记录器,如下所示:
var printLogger = loggerFactory.CreateLogger<Printer>();
所有写入printLogger实例的日志现在将在它们的日志行中显示Printer,而不是Program。当然,你传递给那个参数的内容实际上并不重要。如果你想的话,可以在主程序中使用Printer日志记录器。这只是装饰,有助于你组织日志输出。就是这样。背后没有逻辑。
智能使用类别
我建议你使用这些类别,但不要过度使用;太多的类别只会让你的日志变得杂乱。我通常只为日志创建器创建空类。这样,我就可以得到一组漂亮的日志实例,而不必依赖于外部人士不应该看到的内部代码。然而,我将完全由你来决定。
现在我们已经处理了基本的日志记录,是时候看看一些流行的替代方案了,它们提供了一些我们能够使用的巧妙技巧。让我们从 NLog 开始!
NLog
微软并不是唯一提供日志框架的公司。还有其他公司,每个都有自己的优势和劣势。其中之一是更受欢迎的NLog。
NLog 是由 Jared Kowalski 于 2006 年创建的,作为流行的 log4net 解决方案的替代品,而 log4net 是广泛流行的 log4j Java 日志解决方案的移植版本。Kowalski 的目标是构建一个性能高且设置灵活的日志记录解决方案。
设置 NLog
要使用 NLog,你需要安装相应的 NuGet 包。包的名称很简单,就是NLog。
安装了那个包之后,我们必须创建一个配置文件。为此,在你的项目中添加一个新的 XML 文件(不要忘记将属性设置为Copy if newer,这样项目在运行时可以找到该文件)。按照惯例,这个文件叫做NLog.config,但你可以选择任何名称。文件应该看起来像这样:
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns=http://www.nlog-project.org/schemas/NLog.xsd
xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance
>
<targets>
<target
name="logfile"
xsi:type="File"
fileName="${basedir}/logs/logfile.txt"
layout="${date:format=HH\:mm\:ss} ${logger} ${uppercase:${level}} ${message}" />
<target
name="logconsole"
xsi:type="Console" />
</targets>
<rules>
<logger name="*"
minlevel="Info"
writeTo="logfile,logconsole" />
</rules>
</nlog>
你可以通过这个配置文件控制几乎所有的 NLog。你可以在代码中设置所有参数,但这似乎违背了 NLog 的目的。我建议你使用configuration文件,并避免在代码中设置东西。当然,除非你真的有很好的理由这样做,毕竟,这还是你的代码,不是我的。
现在,是时候开始日志记录了。在你的程序中添加以下代码:
using NLog;
LogManager.Configuration =
new XmlLoggingConfiguration(
"NLog.config"
);
try
{
Logger logger = LogManager.GetCurrentClassLogger();
logger.Trace("This is a trace message");
logger.Debug("This is a debug message");
logger.Info("Application started");
logger.Warn("This is a warning message");
logger.Error("This is an error message");
logger.Fatal("This is a fatal error message");
}finally{
LogManager.Shutdown();
}
首先,我们将在LogManager中加载配置。你通常在整个应用程序中为所有日志需求设置一个配置,所以你不妨先做这件事。
然后,我们将调用GetCurrentClassLogger()。这个调用等同于在 Microsoft 框架中调用CreateLogger<T>。它将当前类名绑定到日志记录器,以便你可以对日志进行分类。
如果你想要将其他日志记录器与不同的类关联,你可以通过调用类似以下内容的方式来实现:
var otherLogger = LogManager.GetLogger("OtherLogger");
这个调用将创建另一个具有相同配置的日志记录器,但这次输出将显示"OtherLogger"。
代码的其他部分都是不言自明的,除了说LogManager.Shutdown()的那一行。这一行是必要的,用于清除代码中的所有日志并确保没有消息被留下。
NLog 日志记录的日志级别
与 Microsoft 框架一样,您可以在日志文件中指定您想要看到哪个级别。NLog 的级别是可比的,但有一些细微的差异。以下表格显示了可用的选项:
| NLog 级别 | 描述 |
|---|---|
| 跟踪 | 这提供了最详细的信息。用于最底层的调试信息。 |
| 调试 | 这提供了粗粒度的调试信息。它比跟踪更不详细。 |
| 信息 | 此级别包含强调应用程序一般流程的信息性消息。 |
| 警告 | 在此级别标记对最终用户或系统管理员感兴趣的有潜在危害的情况,这些情况表明可能存在问题。 |
| 错误 | 这里标记了相当重要的错误事件,这些事件将阻止正常程序执行,但可能仍然允许应用程序继续运行。 |
| 致命 | 这个级别关注的是非常严重的错误事件,这些事件可能会使应用程序崩溃。 |
| 关闭 | 这完全不涉及日志记录。 |
表 10.2:NLog 中的日志级别
如您所见,级别几乎相同;只是名称不同。这使得在从一种框架切换到另一种框架时更难以记住,但我们对此无能为力。我猜我们不得不记住这些术语。
NLog 目标
您通过配置文件来控制 NLog。这是推动 NLog 开发的主要原则之一(另一个原则是 NLog 应该具有高性能)。
在我们工作的示例中,我们在控制台和文件中记录了日志。在settings文件中,我们定义了不同的目标,NLog 在这些目标中写入日志。目前,有超过 100 个不同的目标可用,其中一些是核心包的一部分,而另一些则需要 NuGet 包。
让我们看看另一个目标。我们目前使用的是Console,但我们可以将其替换为ColoredConsole。这是默认包的一部分,所以我们不需要添加 NuGet 包。
在配置中,向targets部分添加一个新的目标。它看起来像这样:
<target
name="logcolorconsole"
xsi:type="ColoredConsole"
header="Logfile for run ${longdate)"
footer="-----------"
layout="${date:format=HH\:mm\:ss} ${logger}
${uppercase:${level}} ${message}" />
这部分告诉 NLog 我们想要使用一个新的ColoredConsole类型的目标。我们可以称它为logcolorconsole。
我们还指定了一个应显示“运行日志文件”文本和当前数据的标题。我还添加了一个由简单行组成的页脚。布局部分与我们在文件中使用的相同:我们显示时间(以HH:mm:ss格式),记录器的名称(根据我们所在的行,可能是“程序”或“其他记录器”),日志级别的大写形式,最后是消息本身。
你可以根据需要调整这些内容,随意添加或删除元素。你还可以根据各种因素(如级别)设置显示规则。
我们还必须将其添加到规则中。为了简单起见,我移除了文件和控制台作为目标,并使用了新的 logcolorconsole:
<rules>
<logger name="*"
minlevel="Info"
writeTo="logcolorconsole" />
</rules>
在对这些更改后运行示例,你会看到一组彩色线条。是的,你可以根据级别更改或修改颜色。选项几乎是无限的。
正如我说的,有超过 100 个目标可用。让我给你一个常用目标的简短列表:
| 目标名称 | 描述 | NuGet 包 |
|---|---|---|
| 文件目标 | 将数据记录到磁盘上的文件中,具有文件名、目录、轮换和存档选项 | NLog |
| 控制台目标 | 将日志消息发送到标准输出或错误流;在开发期间很有用 | NLog |
| 彩色控制台目标 | 根据日志级别对控制台进行着色,将日志消息发送到控制台 | NLog |
| 数据库目标 | 使用参数化 SQL 命令将消息记录到数据库中 | NLog |
| 事件日志目标 | 将日志条目写入 Windows 事件日志;非常适合 Windows 应用程序 | NLog.WindowsEventLog |
| 邮件目标 | 将日志条目作为电子邮件消息发送;适合警报和监控 | NLog.MailKit |
| 网络目标 | 包括用于通过网络进行日志记录的 WebService、TCP 和 UDP 目标 | NLog |
| 跟踪目标 | 将日志消息发送到 .NET 跟踪监听器,与其他诊断工具集成 | NLog |
| 内存目标 | 将消息记录到内存中的字符串列表中,主要用于调试目的 | NLog |
| 空目标 | 一个什么也不做的目标;在特定场景中禁用日志记录很有用 | NLog |
表 10.3:NLog 中的目标
我建议你查看 nlog-project.org/config/ 上的文档,以了解不同的选项以及每个选项的设置。内容相当丰富!
NLog 中的规则
除了目标之外,你还可以在 NLog 中设置规则。规则定义了在什么情况下使用哪个目标。
在我们的示例中,我们使用了一个规则:所有日志都应该发送到控制台和 file 目标或我们命名为 logcolorconsole 的 ColoredConsole 目标。
让我们稍微改变一下;我想让它变得更智能。将规则部分修改如下:
<rules>
<logger name="*"
minlevel="Trace"
writeTo="logfile" />
<logger name="Program"
minLevel="Warn"
writeTo="logcolorconsole" />
<logger name="OtherLogger"
minLevel="Info"
writeTo="logconsole" />
</rules>
现在我们有三个规则:
-
第一个是通配符。通过写入
name="*",我们告诉系统接收所有日志记录器。我们想要的最低级别是 Trace,即最低级别,因此我们想要所有消息(是的,你也可以定义一个最大级别)。我们将目标定义为 logfile。这个目标是将日志写入文件的。 -
第二条规则仅适用于名为
Program的日志记录器。因此,所有日志记录器都是通过调用GetCurrentClassLogger()使用我们的Main方法创建的。我们将最低级别提高到 Warn;我们对低于这个级别的任何内容都不感兴趣。文件会捕获这些。我们希望看到它们以漂亮的颜色显示,所以我们指定writeTo参数为logcolorconsole。 -
发送到名为
OtherLogger的日志记录器的所有消息都是第三条规则的主体。我们希望所有 Info 级别或以上的消息,并且我们希望看到它们被我们无色的默认控制台日志记录器处理。
运行示例。看看不同日志记录器的消息是如何被发送到正确的地方的。
异步日志记录
记得我之前说过,任何需要超过几个时钟周期才能完成的事情都应该异步处理吗?嗯,NLog 允许你将日志记录到数据库或网络连接中。它们确实有长时间运行的操作。不幸的是,NLog 中没有名为 LogAsync() 的方法。然而,对此还有一个解决方案。
有一个名为 AsyncWrapper 的目标。正如其名所示,这是一个围绕其他目标进行包装的包装器,使它们能够异步工作。你只需要像这样将其添加到配置中:
<target
name="asyncWrapper"
xsi:type="AsyncWrapper">
<target
name="logfile"
xsi:type="File"
fileName="${basedir}/logs/logfile.txt"
layout="${date:format=HH\:mm\:ss} ${logger} ${uppercase:${level}} ${message}" />
</target>
尽管方法仍然是同步的,但 NLog 将所有日志消息放入一个单独线程上的队列中,并在该线程上而不是在调用线程上将其写入目标。你可以设置几个变量来确定延迟必须多长时间,队列可以变得多长,等等。然而,我们在写入文件、数据库或网络连接时已经消除了延迟。我强烈建议你除了控制台之外,使用那个包装器来处理任何其他事情!
两个有用但常被忽视的附加设置
在配置文件中,我还要展示两个更多的事情。
根元素 NLog 可以有一个名为 autoReload=true 的属性。如果你设置它,NLog 就可以在应用程序运行时拾取日志文件中的更改。我们之前在 Microsoft 日志记录器中看到了类似的选项;了解 NLog 也支持这一点是很好的。
在配置文件中可以设置的所有可用规则、目标、变量和其他事情,你可能会想知道如果出了问题该怎么办。
NLog 背后的团队也想到了这一点。你可以打开 NLog 自身的日志记录。你只需要将根条目更改如下:
<nlog xmlns=http://www.nlog-project.org/schemas/NLog.xsd
xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance
autoReload="true"
internalLogFile="${basedir}/logs/internallog.txt"
internalLogLevel="Trace"
>
我添加了 internalLogFile 和 internalLogLevel 属性。添加这些属性会导致 NLog 将其内部日志记录到指定的文件中。这样做可能有助于你找到日志中的问题。这有点形而上学,但通过记录日志的工作原理,你可以记录得更好。试试看吧!
Serilog
我还想与你分享另一个框架。Serilog 是一个流行的日志记录框架,它首次在 2013 年亮相。
Serilog 背后的理念是它允许结构化日志记录。到目前为止,我们所看到的日志都是一些只有一行文本的日志。Serilog 是围绕结构可以带来清晰这一理念构建的。
让我通过一个示例来展示我的意思。让我们构建一个示例。
尽管 Serilog 可以通过配置文件中的设置进行控制(并且应该如此),但我会通过代码来控制这个最终示例。我想展示如何做到这一点,这样你至少见过一次。
然而,再次强调,由于你希望根据系统的状态来更改日志记录,你最好有一个可以更改而不需要重新编译的配置文件。
当然,我们将从创建一个新的控制台应用程序并添加一些 NuGet 包开始。
使用 Serilog 进行标准日志记录
NLog 有目标,而 Serilog 有 sinks。你必须从不同的包中安装你需要的所有 sinks。在我的示例中,我将只使用控制台和文件,但还有其他:SQL Server、HTTP、AWS 等。
您需要安装 Serilog、Serilog.Sinks.Console 和 Serilog.Sinks.File NuGet 包。
让我们编写代码:
using Serilog;
var logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.WriteTo.File(path:
"logs\\log.txt",
rollingInterval: RollingInterval.Day)
.CreateLogger();
try
{
logger.Verbose("This is verbose");
logger.Debug("This is debug");
logger.Information("This is information");
logger.Warning("This is warning");
logger.Error("This is error");
logger.Fatal("This is fatal");
}
finally
{
await Log.CloseAndFlushAsync();
}
这段代码应该看起来很熟悉。我们创建了一个配置,这次全部在代码中完成;我们创建了一个日志记录器并记录了我们的消息。我们以 CloseAndFlushAsync() 结尾,以确保缓冲区中没有留下任何内容。
这段代码没有什么特别之处。好吧,这里的新东西是 RollingInterval。这个属性决定了系统应该在何时创建一个新的文件。你可以将其设置为从一分钟到一年之间的任何时间。如果你在任何时候都不想创建新文件,你也可以将其设置为 Infinite。这样,系统就会创建一个文件,然后永远不再创建(当然,除非你删除它)。
除了这些,Serilog 没有什么特别之处。然而,让我们改变这一点。更改对 WriteTo.File() 的调用中的参数,使其看起来如下:
var logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console(new JsonFormatter())
.WriteTo.File(
new JsonFormatter(),
"logs\\log.txt",
rollingInterval: RollingInterval.Day)
.CreateLogger();
在这个代码示例中,我向控制台和文件的输出中添加了一个 JsonFormatter。当你添加一个格式化器时,你告诉 Serilog 以某种方式输出日志。JsonFormatter 格式化器强制输出为(好吧,你已经猜到了)JSON 格式。
要真正使用结构化日志,我们必须改变我们记录消息的方式。让我们在写入日志的部分添加一行:
logger.Information(
"The user with userId {userId} logged in at {loggedInTime}",
42,
DateTime.UtcNow.TimeOfDay);
如你所见,我们记录了一行文本,但我们不是在事先构建那个字符串,而是在消息中完成。在这种情况下,我们提供了命名参数 userId 和 loggedInTime,然后传递我们想要显示的值。
如果你现在运行它,最后一行,在格式化后,结果如下:
{
"Timestamp": "2024-04-20T11:47:31.5139218+02:00",
"Level": "Information",
"MessageTemplate": "The user with userId {userId} logged in at {loggedInTime}",
"Properties": {
"userId": 42,
"loggedInTime": "09:47:31.5125828"
}
}
如你所见,突然出现了更多信息。日志行的结构是这样的,如果我们将其存储在某个系统中的话,我们可以轻松地查询这些行。在本章的后面部分,我将向你展示如何做到这一点。
因此,直到您使用众多格式化工具之一,Serilog 与其他两个框架是可比的。将日志信息存储起来以便轻松查询的能力使其成为工具箱中一个非常强大的工具!
Serilog 日志记录中的日志级别
如您现在可能预料到的,Serilog 也具有级别。这些级别应该对您来说非常熟悉。下表显示了 Serilog 提供的级别以及它们的目的。
| Serilog 级别 | 描述 |
|---|---|
| 详细 | 这包含最详细的信息。这些消息可能包含敏感的应用程序数据,因此不建议在生产环境中使用,除非隐藏。 |
| 调试 | 此级别包含在开发和调试中有用的信息。 |
| 信息 | 此级别包含强调应用程序一般流程的信息性消息。它对一般应用程序洞察力很有用。 |
| 警告 | 在此级别包含可能的问题或服务和功能退化的指示。 |
| 错误 | 包含无法处理或意外的错误和异常。 |
| 致命 | 此级别关注导致应用程序完全失败的关键错误,需要立即关注。 |
| 静默 | 这是完全不记录日志的级别(Serilog 并没有明确定义静默级别,但日志可以被有效关闭)。 |
表 10.4:Serilog 日志级别
再次,这里没有惊喜。与其他框架一样,您可以随意使用它:没有人能阻止您在错误级别添加大量调试信息。但这并不是一个好主意。
比较日志框架
在看过所有这些框架之后,您可能会想知道:我应该选择哪一个?答案是简单的:选择您感觉最舒适的一个。
所有框架都有优点和缺点;没有一个是坏的或极其好的。它们有不同的用例和关注领域。下表突出了一些这些:
| 功能 | .NET 日志记录器 | NLog | Serilog |
|---|---|---|---|
| 概述 | 可靠且与 .NET 无缝集成 | 功能丰富,适用于广泛的用途 | 在结构化日志方面表现卓越,使数据有意义且可搜索 |
| 集成 | 深度集成于 .NET Core,支持依赖注入和配置设置 | 灵活,可用于各种 .NET 应用程序,支持多个目标 | 与 .NET 应用程序配合良好,特别是对于结构化数据存储如 Seq 或 Elasticsearch |
| 优点 | 最小化设置,支持结构化日志 | 高级日志路由和过滤;同时将日志发送到多个目标 | 在结构化日志方面表现卓越;支持 enrichers 以提供更多上下文 |
| 缺点 | 没有第三方提供者功能较少 | 配置可能变得复杂 | 对于简单需求可能过于复杂;最佳功能需要兼容的日志目标 |
| 最佳用途 | 需要简单日志记录且设置最少的工程 | 需要详细控制日志记录或需要在多个地方记录日志的应用程序 | 需要将结构化日志记录和数据查询作为优先事项的工程 |
表 10.5:日志框架之间的比较
你只需要看看自己的需求,确定最适合你的场景和工作方式。选择那个工具。我的建议是尝试其他工具。你可能会发现一个新的最喜欢的日志记录框架!
因此,我们现在已经了解了日志记录。我们看到了最常用的框架以及如何使用它们。我们研究了默认的微软日志记录;我们深入研究了 NLog 及其强大的目标和规则集合。最后,我们探讨了 Serilog 的结构化日志方法。
从现在开始,你应该能够使用日志记录。然而,日志记录是应用程序的一部分。如果你从日志记录中没有获取到所有需要的信息怎么办?这就是监控发挥作用的地方。让我们看看下一个话题!
一点注意事项
日志记录非常有用。事实上,我建议如果你没有广泛的日志记录,你无法在没有用户界面的系统上进行严肃的开发。然而,你必须小心:泄露系统敏感信息太容易了。考虑一下连接字符串、凭证和其他敏感信息。此外,你有时可能会无意中泄露关于系统内部工作或运行此系统的组织的详细信息。要小心。不要假设人们不会尝试将日志级别移动到跟踪以查看发生了什么。尽可能多地记录日志,但要注意危险!
日志记录是解决开发和生产问题的最佳方法之一。然而,我们还可以做更多。我们需要深入了解这些日志,但我们也必须监控内存使用、CPU 使用等更多事情。让我们接下来谈谈监控!
监控你的应用程序
我们需要在软件运行时密切关注事情。在开发过程中,我们可以将详细的日志记录到控制台或文件中,这有助于我们跟踪错误和问题。然而,一旦我们的代码在最终机器上运行,它需要运行,查看所有这些日志文件可能有点困难。
使用 Seq 进行监控
监控系统的状态对于保持系统健康至关重要。我们用来做这件事的伟大工具之一是 Seq。Seq 和 Serilog 是天作之合!
Serilog 之所以最近受到如此多的关注,其中一个原因就是它能够以结构化的方式记录日志。我们在上一节中提到了这一点,但没有深入探讨我们可以做什么。现在是时候改变这一点了。
由于 Serilog 生成的日志以特定方式格式化,我们也可以以特定方式存储它们。允许我们这样做的一个工具是 Seq。Seq 是 Datalust 公司的一个工具。你可以从他们那里获得一个免费的个人许可证来玩转你的日志。你可以选择在你的机器上安装 Seq,或者你可以选择下载一个包含你所需所有内容的 Docker 镜像。我更喜欢后者,但选择哪个选项都无关紧要。Datalust 网站清楚地解释了如何获取这些资源。你可以在docs.datalust.co/docs/an-overview-of-seq找到文档。在本章的技术要求部分,我向你展示了你需要执行的 Docker 命令来运行本地的 Seq 版本。
完成这些操作后,你实际上可以开始使用 Seq。我们需要稍微修改一下我们的代码。除了我们之前安装的用于将日志记录到Console和File的包之外,我们还需要一个新的包:Serilog.Sinks.Seq。
安装完成后,我们必须稍微修改一下日志的设置。看起来是这样的:
var logger = new LoggerConfiguration()
.MinimumLevel.Verbose()
.WriteTo.Console(new JsonFormatter())
.WriteTo.File(
new JsonFormatter(),
"logs\\log.json",
rollingInterval: RollingInterval.Day)
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
正如你所见,我们在配置中添加了一个新的Sink,这次我们正在将数据写入 Seq。我使用默认端口5341,因为这是 Seq 监听的端口。
如果我运行应用程序并访问我机器上的 Seq 仪表板,我也可以在那里看到日志。看起来是这样的:

图 10.3:Seq 捕获的 Serilog
你可以清楚地看到所有的日志消息。它们被很好地着色。我还打开了最后一条消息,其中我们添加了一些结构化信息。Seq 捕获这些信息并显示正在发生的事情。
你也可以通过在顶部编辑框中输入类似 SQL 的语句来查询日志。看起来是这样的:

图 10.4:带有用户 ID 过滤器的 Seq 仪表板
我在编辑框中添加了userId = 42查询。这导致 Seq 只显示关于userId为42的用户的全部消息。
查询语言非常丰富,你可以编写复杂的查询。这意味着即使你记录了很多消息,你总能找到你需要的东西。
Seq 非常强大,同时设置起来也很简单。我强烈推荐您去了解一下!
性能计数器
Windows 提供了许多工具来监控系统,例如EventViewer。我们也可以在我们的系统中使用这些工具。例如,有很多性能计数器可供访问,你可以在代码内外访问它们。
让我们先看看如何在代码中实现这一点。
我启动了一个新的控制台应用程序,添加了System.Diagnostic NuGet 包,然后编写了以下代码:
using System.Diagnostics;
using ExtensionLibrary;
#pragma warning disable CA1416
var counter = 0;
var cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
while (true)
{
if (counter++ == 10)
// Start a method on a background thread
Task.Run(() =>
{
Parallel.For(0, Environment.ProcessorCount, j =>
{
for (var i = 0; i < 100000000; i++)
{
var result = Math.Exp(Math.Sqrt(Math.PI));
}
});
counter = 0;
});
var cpuUsage = cpuCounter.NextValue();
var message = $"CPU Usage: {cpuUsage}%";
var color = cpuUsage > 10 ? ConsoleColor.Red : ConsoleColor.Green;
message.Dump(color);
await Task.Delay(200);
}
在文件顶部附近,我创建了一个PerformanceCounter类的实例。这个类让我们能够访问我们提到过的所有性能计数器,因此我们也可以在代码中使用它们。我们需要指定我们想要监控的类别和该类别中的项目。在我的情况下,我选择了Processor和% Processor Time,这些都是 CPU 负载的指标。
然后,我启动一个永无止境的循环,在这个循环中我增加一个计数器。一旦这个计数器达到10,我就会在我机器上所有可用的 CPU 上进行一些愚蠢的计算。这些计算除了让 CPU 忙碌之外没有任何实际作用。所有这些都在后台线程上发生,所以主循环会持续显示系统的忙碌程度。
为了提高可读性,如果 CPU 使用率超过 10%,我还会更改输出颜色的。如果你使用的机器比我慢或快,你可能需要更改这个阈值。
如果你运行这个,你将能够看到系统的忙碌程度。你应该看到一个漂亮的绿色输出,但系统每几秒钟就会变得更忙,如红色输出所示。
你可以测量许多项目,跟踪计算机正在做什么。假设你想找出你可以监控的内容。在这种情况下,你只需要打开 Windows 机器上的性能监视器应用程序(搜索perfmon.exe)。你可以在主屏幕上添加计数器;显示它们的对话框是一个很好的信息来源。确保你检查屏幕底部的显示描述框,以查看所有计数器的功能。为了给你一个概念,这是当你搜索我们刚刚使用的计数器时屏幕看起来像什么:

图 10.5:带有% Processor Time 的 Perfmon.exe 示例
我建议你浏览这个列表,看看你能找到什么可能有用的东西。甚至有专门针对.NET CLR 的类别,所以你可以看到垃圾收集器运行得多频繁,或者每秒钟抛出异常的频率是多少!
Prometheus
关注你系统的重要指标可以帮助你定位问题。如果你的系统突然开始使用更多的内存,或者在某些时候 CPU 使用率突然上升,你可能有一个需要修复的问题。因此,跟踪这些指标非常重要。正如我们刚刚学到的,我们可以使用PerformanceCounter类来获取必要的信息并对其进行处理,例如将其写入 Seq 使用 Serilog。
然而,这并不是唯一的方法。Serilog 和 Seq 的组合并没有什么问题。然而,它们的主要目标是记录事件。你可以使用像 Prometheus 这样的工具来跟踪趋势,比如 CPU 使用率或内存压力。
Prometheus 与 Serilog 和 Seq 类似:它们允许你在代码中写入一些内容到外部系统,你可以在你的网页浏览器中查看这些内容。然而,Prometheus 主要用于广告监控和时间序列数据库。它被设计成以可扩展的方式记录实时指标。它在监控应用程序和基础设施的状态方面表现出色。
让我们看看这一切是如何工作的。
你想要监控的应用程序需要一个 NuGet 包。所以,让我们安装它。它被称为 prometheus-net。然而,这仅仅是方程的一部分。正如我所说的,你可以使用你的浏览器来查看你感兴趣的事件,因此我们还需要安装服务器。
你可以从 prometheus.io 下载 Prometheus 并在你的机器上运行它。然而,如果你只是想了解它是如何工作的,并看看这个工具是否适合你,我建议下载 Docker 镜像并运行它。
Prometheus 需要一个配置文件。这是一个简单的 YAML 文件,告诉它如何行为。当我们启动 Docker 镜像时,我们需要链接到这个配置文件,所以让我们先写这个文件。
打开你喜欢的代码编辑器,在某个地方创建一个名为 prometheus.yml 的文件。我把我的放在了一个名为 c:\data 的文件夹中。前面的 Docker 命令将确保它在运行的容器内部被读取和使用。文件看起来是这样的:
global:
scrape_interval: 5s
evaluation_interval: 5s
scrape_configs:
- job_name: 'c# worker'
static_configs:
- targets: ['host.docker.internal:1234']
让我们看看这里发生了什么。
第一行定义了 scrape_interval。这是 Prometheus 查看指标的间隔。由于指标通常在较长时间内才有意义,你不需要系统持续测量它们。在我们的例子中,我将其设置为每五秒进行一次抓取。
以下行定义了 evaluation_interval。Prometheus 可以有规则和警报,当某个特定指标超过或低于特定指标时,这些规则和警报会被触发。这个间隔决定了它检查是否需要触发警报的频率。再次强调,我已经将其设置为五秒。
这两个设置是全局的;它们适用于我们监控的所有应用程序的所有指标。如果我们想的话,以后我们可以为每个特定的指标或应用程序进行更改。
以下部分,称为 scrape_configs,定义了我们想要收集的特定指标。在我的情况下,我给它起了一个名字:C# worker。然后,我们将告诉它哪个服务器提供指标。同样,在我的情况下,它是 host.docker.internal:1234。这意味着服务器在该 URL 上运行,并使用特定的端口。
“等一下,”你可能会说,“我并不是在运行服务器;我正在运行一个控制台应用程序!”不用担心;Prometheus 会处理这个问题的。
Prometheus 的服务器应用程序通过 HTTP 连接调用它需要监控的系统。因此,它监控的客户端需要一个提供该信息的网络服务器。我们不必担心这一点;Prometheus 会为我们处理。
Docker 中的 IP 地址
你可能会想知道为什么我使用host.docker.internal主机名作为服务器地址。毕竟,Docker 和我们的控制台应用程序都在同一个系统上运行。它们都在localhost上可用,对吧?这是不正确的;Docker 容器都在它们自己的网络中运行(我在这里简化了一些事情,但这个想法仍然有效)。这意味着如果 Prometheus 服务器监听localhost:1234上的任何内容,它只会监听镜像中的虚拟网络。我们需要提供运行我们应用程序的机器的实际 IP 地址。然而,如果你不想硬编码它,使用host.docker.internal DNS 名称。Docker 中的 DNS 系统知道这个名称。它解析主机机的实际 IP 地址,以便 Docker 容器可以找到正确的机器。
让我们来看看我们的代码。我已经启动了一个新的控制台应用程序并添加了 NuGet 包。代码本身看起来是这样的。我首先设置我们的指标:
Gauge memoryGauge =
Metrics.CreateGauge(
"app_memory_usage_bytes",
"Memory Usage of the application in bytes.");
var server =
new MetricServer(
hostname:"127.0.0.1",
port: 1234);
server.Start();
首先,我创建了一个Gauge。这就像一个温度计,你可以用它来测量指标。在这种情况下,我将创建一个测量app_memory_usage_bytes的,这是我们可用的许多指标之一。
然后,我们将创建一个MetricServer的实例。你必须指定应用程序运行的主机和广播指标所用的端口。记得我之前说过 Prometheus 会监听服务器来收集其指标吗?好吧,这就是我们设置那个服务器的地方。
IP 地址和 Docker,再次
我在这里使用了127.0.0.1主机名。如果我使用localhost,由于某种原因我会得到错误。如果我使用机器的实际主机名,我也会得到错误。要么应用程序无法启动,要么 Prometheus 服务器找不到我的应用程序。然而,如果我在这里指定 IP 地址(我的实际 IP 地址也有效),系统就可以正常工作。所以,如果你在使事情正常工作时有问题,只需在这里尝试使用127.0.0.1。
然后,我将启动服务器。
现在,你已经准备好将指标发送到服务器了。我创建了一个简单的用于此目的的方法:
static void UpdateMemoryGauge(Gauge memoryGauge)
{
var memoryUsage = GC.GetTotalMemory(forceFullCollection: false);
memoryGauge.Set(memoryUsage);
}
这基本上就是你必须做的所有事情。
然而,让我们看看当我们实际做些什么会发生什么。在我的代码的主体中,我有一个简单的循环,每五秒钟添加一个内存块,然后在 20 秒后清除所有这些,之后整个过程重新开始。
代码看起来是这样的:
var counter = 0;
List<byte[]> buffer = [];
Random rnd = new Random();
while (true)
{
if (counter++ % 5 == 0)
AllocateMemoryBlock(rnd, buffer);
if (counter == 20)
{
ClearMemory(buffer);
counter = 0;
}
UpdateMemoryGauge(memoryGauge);
await Task.Delay(1000);
}
我定义了一些变量,例如counter、buffer和rnd。在循环中,我将向系统添加内存、清除内存或什么都不做。最后,我确保调用UpdateMemoryGauge()方法。然后,应用程序休眠一秒钟。
AllocateMemoryBlock()看起来是这样的:
static void AllocateMemoryBlock(Random random, List<byte[]> bytesList)
{
var memoryToAllocate =
random.Next(50000000, 200000000);
var dummyBlock =
new byte[memoryToAllocate];
bytesList.Add(dummyBlock);
"Memory block added".Dump(ConsoleColor.Blue);
}
再次强调,这段代码很愚蠢;我希望你永远不会在实际的生产代码中编写这样的代码。然而,它在这里是有效的;我们想要测量我们应用程序的内存使用情况,所以我们不妨分配很多内存。我使用了一个随机化器来使系统更不可预测,因为我更喜欢这样图表的外观。
ClearMemory() 函数甚至更简单:
static void ClearMemory(List<byte[]> list)
{
list.Clear();
GC.Collect();
"Memory block cleared".Dump(ConsoleColor.Green);
}
我们随后通过调用 GC.Collect() 清除列表并清理内存,并将此信息记录到屏幕上。
就这些!如果你运行一段时间,然后在浏览器中打开默认的 Prometheus URL localhost:9090,你可以搜索 app_memory_usage_bytes 指标。如果你运行应用程序一段时间,你将得到一个像这样的图表:

图 10.6:Prometheus 采样我们的内存使用
图表显示了我们的应用程序在运行时的状态,描述了它使用了多少内存。你可能也能看出我为什么使用随机化器;图表看起来稍微有趣一些。然而,这只是我个人的偏好。
你可以在屏幕顶部搜索指标,或者搜索作业。如果你指定了 {job = "c# worker"} 搜索字符串,你将获得超过 30 个关于你应用程序的指标。你可以点击它们中的每一个来将它们添加到图表中。那里有大量的信息!
其他监控平台
我们已经研究了 Seq 来收集日志。我们查看了性能计数器,也查看了 Prometheus。这些都是非常好的工具,我相信它们是我们作为系统程序员最适合的工具。然而,还有许多其他系统可能更适合你和你特定的用例。我不会详细描述它们;那足以写一本书。但是,这里是一些最常用的工具的概述。如果你对它们感兴趣,我建议你进行研究,找出它们如何帮助你!
| 工具 | 描述 | 用例 |
|---|---|---|
| Application Insights | 作为 Azure Monitor 的一部分,它提供 APM 功能和遥测数据 | 基于云的监控 |
| New Relic | 提供全栈可观察性,包括应用程序监控 | 性能洞察 |
| Dynatrace | 利用 AI 进行自动监控和问题解决 | 全栈监控 |
| Datadog | 为云应用程序提供监控、故障排除和安全 | 云原生环境 |
| ELK Stack | Elasticsearch 用于数据处理,Logstash 用于数据收集,Kibana 用于可视化 | 日志管理 |
| Nagios | 为服务器、交换机、应用程序和服务提供监控和警报服务 | 基础设施监控 |
| AppDynamics | 应用性能管理和 IT 运营分析 | 业务性能监控 |
表 10.6:监控工具
如你所见,有很多选项可供选择,所以肯定有适合你特定需求的东西可以使用!
现在你已经知道了如何使用 Seq、性能计数器和 Prometheus 进行监控,我们应该看看我们在记录和监控什么。
你应该监控或记录的内容
我们已经看到了许多可以记录和监控系统的方式。然而,问题仍然存在:你应该记录和监控什么?答案是简单的:无论你需要什么来保持系统健康。
好吧,那个答案可能是个简单的方法。让我们更具体一点。
基本健康监控
您应该监控系统的整体健康状况。您的应用程序并不存在于真空中,因此您应该关注整个系统的状态以及您如何与之交互。以下是一些您可能想要关注的项目:
-
CPU 使用率:跟踪 CPU 使用率,以确定您的应用程序是否导致高 CPU 负载
-
内存使用:监控内存消耗以检测内存泄漏或过度使用,这在垃圾回收发生的.NET 等托管环境中至关重要
-
磁盘 I/O:监控读写操作和磁盘使用情况,以确保磁盘 I/O 不是瓶颈
-
网络 I/O:关注入站和出站网络流量,尤其是如果您的系统与其他服务进行通信时
当然,您可能对许多其他指标感兴趣,但这些通常是人们最关心的。
应用程序特定指标
当然,您自己的系统本身也是您应该关注的事情。以下是我建议您添加到监控工具中的指标:
-
线程计数和线程池健康:了解您的线程是否被饿死或线程池是否过度工作是有帮助的
-
垃圾回收指标:跟踪垃圾回收事件的频率和持续时间,以更有效地管理内存并优化应用程序性能
-
队列长度:如果您的应用程序使用消息队列或类似结构,监控它们的长度可以帮助您了解吞吐量和积压情况
这些指标更多地针对您的应用程序而不是整个系统,所以我强烈建议您使用这些。
错误和异常
异常会发生。这就是生活的现实。因此,您可能还想跟踪这些异常。监控工具可以捕获这些异常,但我不会仅仅依赖它们。始终记录异常处理代码块中发生的事情。您应该考虑以下事项:
-
未处理的异常:记录所有未处理的异常及其完整的堆栈跟踪,以便进行调试
-
已处理的异常:有时,了解已处理的异常可以提供对潜在问题的洞察,这些问题目前可能不是关键的,但可能成为问题
在我看来,监控错误和异常是理所当然的。您真的想了解这些事件!
应用程序日志
围绕您的应用程序发生了一些可能值得跟踪的事情。以下是一些:
-
启动/停止事件:记录服务或组件的启动和停止,以了解应用程序的生命周期事件
-
重要状态变化:任何可能影响应用程序行为的状态变化都应该被记录
-
安全相关事件:这些事件包括身份验证尝试、访问违规和其他安全检查
依赖项健康
应用程序很少独立工作。通常还有其他系统依赖于它们。您应该跟踪这些依赖关系:
-
数据库连接:定期检查以确保您的应用程序可以连接到数据库或其他存储系统
-
外部服务:监控您的应用程序所依赖的任何 API 或外部服务的可用性和响应时间
定制业务逻辑监控
当然,应用程序会做一些特定的事情,这些事情仅适用于您的环境。这些也可能是监控的目标。考虑以下这些事情:
-
关键操作的性能,例如算法或对应用程序功能至关重要的进程
-
数据处理速率,尤其是在处理大量数据或流数据的系统中
这些只是您可能添加到工具包中的一小部分内容。
使用正确级别!
记住:在记录日志时,为每个事件分配正确的级别是至关重要的。并非所有内容都应该是信息;你必须区分错误和调试信息。再次提醒,请确保您没有通过日志泄露敏感信息。
下一步
希望您已经注意到了所有这些项目,并记录了我们一直在讨论的内容!记录和监控非常重要,尤其是在您没有用户界面时。在本章中,我们介绍了作为系统程序员可用的日志记录框架:良好的默认微软日志,以及 NLog 和 Serilog 的结构化日志。
我们还检查了您的系统和应用程序的健康状况。我们探讨了如何使用性能计数器,并深入研究了 Prometheus 的监控。
我们还讨论了您应该记录和监控的内容以及为什么您应该这样做。
总的来说,从现在开始,当意外事情发生时,您将不再处于黑暗中。因为它们将会发生,您最好确保自己已经做好准备。良好的日志记录和监控策略可以拯救您的生命。好吧,也许不是您的生命,但它可以帮助您的系统。这就是这一切都值得的原因。毕竟,一个好的日志可以帮助您在开始调试系统时走上正确的道路。顺便说一句,这正是下一章的主题!
第十一章:调试舞蹈篇
调试与性能分析 系统应用程序
调试是寻找代码中的错误并确保你拥有所有知识来修复它们的艺术。这听起来很简单,不是吗?好吧,再想想。调试可能会很快变得复杂,你需要良好的策略来恢复。幸运的是,我在这里帮助你!在本章中,我们将涵盖以下主要主题:
-
调试是什么?什么是性能分析?
-
我们如何使用断点?
-
我们在 Visual Studio 中还有哪些其他调试工具?
-
我们如何处理多线程和异步系统?
-
我们如何分析和基准测试我们的代码以确保它尽可能快地运行?
调试可能会非常耗时。所以,我们不要浪费时间,开始吧。
技术要求
和往常一样,你可以在这个章节的 GitHub 仓库中找到所有示例的源代码,网址为github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter11。
在这一章中,我只使用 Visual Studio;我并不是在提及任何可能做同样工作的第三方工具。然而,我将在本章末尾提供一系列替代工具的列表。
调试介绍
真事:我曾经为一位声称想要裁减我团队测试人员的管理者工作。他说:“如果你的团队能够表现得更好,他们就不会产生错误,因此我们可以节省测试人员的开支。”显然,他是错的。在这件事发生后不久,我就离开了那家公司。
开发软件是一项创造性的工作。人们认为软件开发是一项接近数学和物理学的精确科学,但并非如此。当然,根源看起来像是数学的,但我们作为软件开发者所做的事情是不同的。我们有一个想法,想到一些还不存在的东西,然后把这些想法变成可以帮助他人的东西。我们通过想象和独创力从无到有地创造东西。
然而,创造性思维是随意的。我们在流畅的状态下会走捷径。我们在试图实现我们的愿景时犯错误。测试人员和 QA 专业人员是我们的安全网;他们在那里捕捉我们忘记的事情。但是,拥有安全网并不意味着你可以随心所欲地做任何事情。一旦你的代码初稿准备好,就是时候从创造性开发者转变为深思熟虑的分析性开发者——那个会查看他们的代码并注意到改进区域的人;即便如此,你仍然会错过一些东西。所以,你测试自己。那时你会发现问题。或者,你可能发现系统运行得不如你预期的流畅。也许你会发现结果不是预期的。那时调试舞会就开始了:你运行系统,尝试定位出错的地方,修复问题,然后重复整个循环。
小贴士
调试可能是一次愉快的旅程,也可能是一次极其令人沮丧的经历。我在这里帮助你将调试体验转变为更加愉快的一种。如果调试意味着修复错误,那么开发意味着创建错误。这没有错,只要你意识到这一点,并且你可以在发货前解决这些问题。让我来帮助你吧!
调试和性能分析——概述
我记得他们在大学主机的 cobol 代码编写教学。说实话,那是一个挑战。主机是一台昂贵的机器,连接了许多终端。如果你不知道那是什么意思,想象一下你有一台电脑,连接了多个键盘和显示器,每个用户都可以使用他们的会话来做自己的工作,与其他用户隔离。
当你需要做一些简单的事情时,比如处理文档或电子表格,这工作得很好;主机可以很好地处理多个会话。然而,编译代码是另一回事:那需要大量的 CPU 功率。他们通过让学生将代码提交给编译器来解决,编译器会在夜间依次运行。你可以在第二天回来时看到你做错了什么。想象一下在某个地方忘记了一个分号,这意味着你必须再等 24 小时才能看到你修复的结果。这种方式让我学会了非常仔细地思考我的代码。
现在,当我输入 C#代码时,我可以看到编译器一直在为我工作。Visual Studio 会立即告诉我我犯了错误。
调试
调试是不可能的。我们所能做的就是往代码里填充日志消息,运行程序,然后查看输出。然后,我们可以尝试从日志文件中推断出我们代码中的错误。
现在,这要容易得多:你可以逐步执行你的代码,看到它们是如何执行的,并检查变量、内存、线程等等。
当然,软件的要求也变得更加复杂,所以编写软件本身并没有变得更容易。
但现代调试工具帮助很大。
调试是识别、隔离和修复软件中问题或“错误”的过程。这些错误可能从简单的语法错误到更难以捉摸的逻辑错误,这些错误会产生错误的输出或流程。
编译器帮助我们修复最明显的错误:一个语句中的拼写错误会被立即捕捉到。然而,编译通过的代码并不意味着程序完美无瑕。调试可以帮助解决这个问题。
性能分析
性能分析是调试的孪生兄弟。虽然调试的目的是找到逻辑错误,但性能分析旨在帮助你找到性能错误。性能错误可能表明系统运行得太慢,使用太多的内存,或者其他阻止软件尽可能高效运行的事情。
性能分析可以帮助你提高软件的效率。它显示了瓶颈所在。性能分析可以帮助你确定内存使用增加的地方以及逻辑在遇到性能问题时失败的地方。
性能分析可能只是记录一些时间信息,也可能像收集所有线程 24 小时的活动并对此数据进行统计分析那样复杂。这完全取决于你的需求。
调试和性能分析是相辅相成的。在性能分析会话中,你收集了某些事情没有按预期进行的证据。然后,你使用调试技术来查找和修复代码中的错误。
当然,这个过程更像是一个循环。你调试,然后性能分析,然后调试以发现问题,修复它们,然后调试修复,然后性能分析修复,如此循环。这是一个永无止境的舞蹈。然而,这可以非常令人满意:最终,你有了更好的代码和性能更好的系统,这肯定使这一切都值得了!
那么,让我们调查一下我们拥有的所有这些魔法工具!
调试 101
Visual Studio是一个伟大的工具。它有许多在开发和调试过程中帮助你的功能。因此,首先查看 Visual Studio 是很自然的。我不会在 Visual Studio 中的调试基础知识上花费太多时间。然而,我认为回顾我们最明显的工具是非常有说明性的。
调试构建与发布构建
让我们谈谈 Visual Studio 顶部那个下拉菜单,你可以在这里选择调试和发布。我确信你对这有什么感觉。当你还在编写代码并想要调试你的软件时,你选择调试。当你准备好发布你的产品时,你选择发布。
然而,还有一些关于这些选项的额外信息你应该知道。让我先说,即使代码是在发布模式下构建的,你仍然可以调试你的代码。只是稍微有点困难。
让我比较一下调试设置和发布设置的结果。以下表格显示了主要差异:
| 调试 | 发布 | |
|---|---|---|
| 目的 | 主要用于开发。 | 主要用于生产。 |
| 优化 | 最小或无优化。 | 高度优化以提升性能和效率。编译器会移除未使用的代码并应用各种优化。 |
| 符号 | 包含调试符号(.pdb 文件),它们提供了有关代码的详细信息(例如,变量名、行号等)。 |
没有或有限的符号。你仍然可以得到一个 .pdb 文件,但它将包含较少的信息。 |
| 断言 | 调试断言被启用。 | 调试断言被禁用。 |
| 性能 | 通常较慢,因为没有优化。 | 通常更快且更高效。 |
| 大小 | 由于额外的调试信息,文件更大。 | 由于优化和调试信息的移除,文件更小。 |
表 11.1:比较调试和发布构建
我建议你在调试时使用调试构建。这就是它的用途。
断点
Visual Studio 提供的最佳工具是强大的 断点。这是一个简单的结构,但它在我们试图理解应用程序内部发生的事情时非常有帮助。
在最简单的情况下,断点是一个代码点,当应用程序到达与之关联的代码语句时,程序会停止。只要它们是语句,你就可以将断点添加到各种事物上。你不能在代码注释上添加断点。
你不能在方法声明上设置断点,但你可以在标记方法开始的第一个 { 上设置断点。
此外,变量的声明不是有效的断点目标,除非你同时进行赋值操作。
例如,看看以下两行:
int x; // Cannot add a breakpoint
int j = 0; // Can add a breakpoint
我们不能在声明 i 的行上设置断点。我们可以在第二行上设置断点。技术上,该行由两部分组成:声明和赋值;断点设置在赋值部分。
命名空间声明和 using 语句也是无效的目标。接口不能有断点,就像属性声明被排除一样。
然而,除了这些明显的例子之外,你可以将它们放置在任何你想放置的地方。
当遇到断点时会发生什么?
我们有一些软件,放置了断点,并运行了软件。在某个时刻,执行点达到我们的断点。问题是:然后会发生什么?
首先,执行停止。程序被冻结在时间点上。在 Visual Studio 中,一些额外的工具开始活跃:
-
局部变量:此窗口打开或更新,显示当前作用域中可访问的所有变量
-
自动监视:此窗口显示当前行及其周围上下文中使用的变量
-
监视器:此窗口显示你可能添加到监视器的任何变量
-
调用堆栈:此窗口显示一系列导致当前断点的方法调用
-
即时:此窗口允许你即时输入命令、评估表达式或更改变量值
当程序暂停时,如果需要,你可以检查或修改变量值。
这有助于你理解程序中发生的情况。然而,如果你不小心,这可能会导致奇怪的情况。
让我们看看我的意思。想象你在这段代码的某个地方:
int sum = 0;
for (int i = 1; i <= 10; i++)
{
sum += i;
}
$"The sum of the numbers from 0 to 9 is {sum}".Dump(ConsoleColor.Cyan);
这段代码遍历i变量,增加它的值并将其添加到sum变量中。如果你运行这个,你会得到55的结果。现在,在循环内部放置一个断点。再次运行代码,但在第九次迭代后,你决定你想再次查看该循环中发生的情况。所以,你将i的值从9改回0。sum变量将不再有任何意义:结果是截然不同的值。
这个示例很简单,但这些副作用可能会很快发生。更改变量可能会有意外的后果。所以,要注意这一点。
线程和断点
在本章的后面部分,我们将讨论调试多线程应用程序,但我想在这里讨论一个项目。我说当代码遇到断点时,调试器会停止执行。
看看这段代码:
ThreadPool.QueueUserWorkItem(_ =>
{
int inThreadCounter = 0;
while (true)
{
$"In the thread with counter {inThreadCounter++}".Dump(ConsoleColor.Yellow);
Thread.Sleep(100);
}
});
int outThreadCounter = 0;
while (true)
{
$"In the main thread with counter {outThreadCounter++}".Dump(ConsoleColor.Cyan);
await Task.Delay(200);
}
代码足够简单。首先,我们从ThreadPool获取一个thread。一个无限循环在thread中记录一条消息,增加一个counter,并等待 100 毫秒。
在代码的主要部分,我们做类似的事情,但时间不同。运行这个程序显示,对于外层线程的每条消息,我们都会从内层线程得到两条消息。现在,在最后一个Task.Delay()语句上放置一个断点。运行代码,让调试器遇到断点,等待几秒钟,然后继续运行。
假设你这样做几次。在这种情况下,你会注意到尽管发送到控制台的消息序列略有不同,但我们仍然从内层线程得到两倍多的消息。换句话说,如果我们暂停外层线程,内层线程也会暂停。
当然,这是好的。你不想其他线程继续,破坏程序流程。但让我们稍微改变一下:将创建线程的代码替换为以下内容:
var inThreadCounter = 0;
var timer = new Timer(100);
timer.Elapsed +=
(_, _) =>
{
$"In the timer call with counter {inThreadCounter++}".Dump(ConsoleColor.Yellow);
};
timer.Start();
我们现在有一个timer而不是thread。这段代码实现了我们之前代码相同的效果:当时间过去时,timer在单独的thread上工作。如果发生这种情况,我们将记录消息并增加计数器。
然而,如果我们重复我们在上一个循环中对代码设置的断点的小技巧,你会注意到完全不同的行为。计时器发出的消息数量不再是来自主循环的两倍;它要多得多。
断点不会停止计时器。它也不会停止像Stopwatch这样的类。基于时间的事件仍然会发生,所以你得到的结果与预期不同。使用计时器时要小心这一点!
断点的特性
断点不仅仅是向调试器显示停止执行位置的标记。它们有一些属性,如果您正确使用,可能会很有帮助。大多数这些设置都是通过在断点窗口中单击断点并选择设置来访问的。该窗口看起来像这样:

图 11.1:Visual Studio 中的断点设置窗口
您也可以通过在代码编辑器中右键单击断点项目并选择其中一个设置来获取此窗口。
激活和未激活断点
默认情况下,断点是激活的,这意味着如果调试器到达包含断点的语句,则执行停止。但您也可以禁用断点:这意味着断点仍然存在,但它不会做任何事情。如果您正在调试某些代码但想跳过当前特定断点但又不想删除它,这个选项可能很有用。
条件断点
条件断点仅在满足特定条件时才会中断。条件可以是一个条件或一组条件,所有这些条件都必须为真。条件还可以包括代码中的变量。让我们想象一下,我想在之前的代码示例中设置一个断点。我想断点在包含 Task.Delay() 代码的行上。然而,我只希望当 outThreadCounter 变量大于 5 且该断点已被触发 6 次时,该断点才处于活动状态。在我们的代码中,这应该是相同的(每次我们通过该循环时,outThreadCounter 都会增加),但如果这种情况没有发生,您可以使用此技术来验证。
您可以通过放置断点、右键单击它,然后选择条件来指定此设置。
操作断点或跟踪点
操作断点可以是真正的断点或不会中断的断点。但除了中断(或不中断)之外,您还可以指定调试器应在输出窗口中写入某些内容。换句话说,这是一个非常轻量级且临时的日志系统。您可以输出静态文本或变量的内容。在指定输出的选项下方,您可以在表示继续代码执行的框中放置勾选标记。如果您勾选该框,调试器不会在此断点处停止,而只会在输出窗口中显示所需信息。当您不停止执行代码而只显示一些信息时,我们称这些断点为跟踪点。
单次断点
单次断点只工作一次。当断点被触发时,它会停止代码执行并禁用自己。如果您想再次使用它,必须手动启用它。您可以通过选择一旦触发禁用断点来创建此断点。
依赖断点
依赖断点只有在另一个断点被触发后才会启用。如果你有一个在代码中从不同地方调用的方法,这尤其有用。尽管如此,你可能只想调试特定的路径。在这种情况下,你可以在你感兴趣的流程中创建一个断点(你甚至可以将其设置为不可中断,使其仅作为触发器)然后,将你感兴趣的该方法中的断点连接到第一个断点。
结果是,断点在第一个断点被触发之前是禁用的。
要看到这个效果,请参考我们的最后一个示例。将 1 秒(1000 毫秒)的时间增加。然后,在写入消息到控制台的行上添加一个断点。勾选此断点的操作属性中的动作框,但不要在消息对话框中添加任何内容。然而,请确保勾选继续代码执行框。设置应该看起来像这样:

图 11.2:触发断点
然后,在写入 outThreadCounter 控制台值的行上添加另一个断点。这次,将设置更改为启用仅在以下断点被触发时启用选项,并在相应的下拉菜单中选择其他断点。它应该看起来像这样:

图 11.3:依赖断点
如果你运行程序,调试器在前一秒钟会忽略最后一个断点。然后,由于我们的第一个断点被触发,执行停止。
当然,你可以随意组合这些设置。
快速添加其他断点
你可能知道,你可以通过点击源代码左侧的所谓空白区域来添加断点到你的代码。如果你这样做,空白区域会出现一个红色项目符号,表示你已在那个位置添加了断点。但你是否知道你还可以在空白区域右键点击?如果你这样做,你会得到一个弹出菜单,可以快速添加之前提到的断点。从长远来看,这可能会节省你一些鼠标点击!
一些其他功能
断点还有一些其他可能很有用的功能。你通常通过在 Visual Studio 的断点窗口中右键点击选定的断点来访问这些功能。以下是一些例子:
-
断点可以有标签:这样,你可以给断点起更有意义的名字。
-
你可以分组断点:如果你创建了一个断点组,你可以将断点添加到其中。这样,你可以快速打开或关闭大量断点,而无需逐个处理。
-
你可以搜索断点:在断点窗口中,你可以搜索类名、行号、输出、标签等。如果你有一大批断点,这个功能可能很有帮助。
-
你可以按名称、条件、命中次数、标签等对断点进行排序:如果你仍然找不到你需要的东西,你可能需要重新考虑你的断点策略!
我遇到的大多数开发者从未接近所有这些选项:他们所做的只是在一行代码上切换断点以停止执行。但我希望你能开始欣赏这些工具能为你带来的力量。
调试窗口
Visual Studio 有很多窗口可以帮助你在调试时了解发生了什么。当编辑代码时,这些窗口大多数都是无用的,但一旦开始调试,它们就会变得活跃。让我们看看我们有什么!
断点
我们已经讨论了断点,但我想要指出断点窗口。这个窗口是你查看应用程序中所有断点的地方。它还显示了有关这些断点的附加信息。如果你需要更多信息,你可以向窗口添加列。以下是一个示例:

图 11.4:断点窗口
你可以自定义这个窗口以满足你的需求。
局部变量、自动变量和监视
在调试时,你可能想看到代码中变量的值。要查看值,你可以在代码编辑器窗口中悬停鼠标在变量上。然而,Visual Studio 中有一些窗口专门用于提供访问这些数据的方式。让我们来探索这些窗口。
局部变量窗口显示了当前作用域中的所有变量。这非常有用:你可以在不受到其他变量的干扰的情况下看到当前块中的所有变量。
自动变量窗口甚至更好:它试图猜测当你中断代码时哪些变量对你感兴趣,并显示它们及其值。
让我们来看看这个。我们有以下类:
internal class MyClass
{
public int Counter { get; set; }
}
我们在以下代码中使用它(我添加了行号,这样我可以在以后引用这些行):
1: MyClass myClass = new MyClass();
2: int myNumber = 0;
3: while (true)
4: {
5: myClass.Counter++;
6: Console.WriteLine($"Counter {myClass.Counter++}");
7: }
现在,在第 3 行设置一个断点。运行代码,看看你的输出是否与我的匹配。我将逐步执行从第 3 行到第 7 行的所有行,并展示自动变量窗口告诉我什么。
第一个断点在第3 行,所以调试器在那里停止。它在第 3 行中断,自动变量窗口中的以下结果是:
| 名称 | 值 | 类型 |
|---|---|---|
myNumber |
0 |
Int |
表 11.2
现在,转到下一行。如果我们停在第 4 行,我们会得到以下结果:
| 名称 | 值 | 类型 |
|---|---|---|
表 11.3
如你所见,我们没有得到任何结果。我们停在{上,现在没有变量可以影响代码的路径。所以,没有东西可以显示。让我们继续并逐步到下一行,第 5 行。
| 名称 | 值 | 类型 |
|---|---|---|
myClass |
{``myClass} |
MyClass |
Counter |
0 |
Int |
myClass.Counter |
0 |
int |
表 11.4
如果你单步执行到那一行,你会看到两个条目。上面的一个,myClass,可以展开,以便你可以看到可能对你感兴趣的性质。在我们的例子中,这是 myClass.Counter。我们还单独看到了 myClass.Counter 变量,因为编译器足够聪明,能够看到这在我们的代码中很重要。
让我们转到下一行,第 6 行。
| 名称 | 值 | 类型 |
|---|---|---|
MyClass.Counter.get returned |
0 |
int |
myClass |
{``myClass} |
MyClass |
Counter |
1 |
Int |
myClass.Counter |
1 |
int |
表 11.5
这很有趣:我们调用了 MyClass.Counter.get) 并得到了一个结果。还有一个图标来显示这确实是一个返回值。get 返回了零,但随后我们应用了 ++ 运算符来改变局部值。
下一个行,第 7 行,产生了这个:
| 名称 | 值 | 类型 |
|---|---|---|
MyClass.Counter.get returned |
1 |
int |
System.Runtime.CompilerServices.DefaultInterpolatedStringHandler.ToStringAndClear returned |
Counter 1 |
string |
表 11.6
我们将文本行打印到控制台,并使用字符串前的 $ 插值命令来完成。现在,你可以看到这样做触发了 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler.ToStringAndClear() 方法,返回了结果字符串 Counter 1。哦,而且我们失去了 myClass(嗯,它并没有丢失;只是由于我们在这个作用域内不再使用它,所以不再显示)。正如你所见,局部变量窗口擅长显示局部变量的值,并帮助你找出隐式方法调用,例如字符串插值和属性获取器!
局部变量 窗口非常擅长确定你需要看到什么。当然,如果你不同意,总有 监视 窗口。
监视 窗口与前面的两个调试窗口做的是同样的事情,但它只显示你要求它显示的内容。一旦调试器停止了程序的流程,你可以在一个变量上右键单击并选择 添加到监视。然后变量将出现在 监视 窗口中,你可以像在 局部变量 和 自动 中一样执行相同的操作:检查变量并在需要时更改值。
然而,这次,变量会一直保留,直到你将其移除。假设它们超出作用域或不可达。在这种情况下,你会在 监视 窗口中得到一个错误,告诉你变量在当前上下文中不存在。但这并不会损害你的体验:它会一直保留,直到你需要它,如果变量再次进入上下文(在这个或随后的调试会话中),它将再次出现。
诊断工具
诊断工具 窗口几乎值得拥有它自己的书籍。它为我们做了很多事情!让我们深入了解一下。
与 Visual Studio 中的大多数其他调试工具一样,你不能在调试代码时使用诊断工具。我们将使用一个非常愚蠢的程序来展示一些诊断工具的可能性。它是一个控制台应用程序,代码看起来像这样:
var memoryBlock = new Dictionary<int, byte[]>();
var passCounter = 0;
while (true)
{
passCounter++;
var newBlock = new byte[1024 * 1024];
memoryBlock.Add(passCounter, newBlock);
}
我还在passCounter++的行上放置了一个条件断点(因此,第一个语句是在 while 循环中)。这个条件看起来是这样的:
passCounter % 100 == 0
换句话说,断点每 100 次迭代就会停止。
如果我们运行这个,应用程序将在第一次迭代时中断。这是有道理的:100%的 0 等于 0。然后,你可以打开诊断工具窗口(如果它没有自动显示,你可以通过转到调试菜单,然后选择窗口,接着选择显示诊断工具来打开它)。我建议你将窗口放大,以便可以看到它给我们提供的所有好东西。我的看起来像这样:

图 11.5:诊断工具窗口
在窗口顶部有一些图表。由于我们的程序还没有发生任何变化,这些图表并不很有趣。但那将会改变!在图表下方有一些标签页。最初,你看到的是摘要标签页,它总结了其他标签页的内容。
在摘要标签页中,点击内存使用标题下的捕获快照。你也可以在内存使用标签页本身这样做。这样做会保存当前的内存使用情况,并允许它在未来的某个时间点进行比较。由于我们的应用程序没有做很多事情,这可以给我们提供一个基线。所以,点击捕获快照。然后,继续运行程序。
如果你点击捕获快照,窗口应该显示内存使用标签页,它显示了快照。由于我们继续了程序,我们不在第 100 次迭代,所以我们可以再次捕获快照。这样做。这导致我的系统在这个视图下:

图 11.6:诊断工具窗口的第二遍
这变得越来越有趣。我们可以从进程内存(MB)图表中看到,我们已经开始分配更多的内存。但真正令人兴奋的事情发生在下面的内存使用标签页中。这里有很多内容:在第二个快照中,我们可以看到我们分配了更多的对象和内存。
你可以点击大多数值,例如Count Diff.列来按该列排序。)

图 11.7:内存快照
我们在内存中有 99 个更多的Byte[]对象,导致内存增加了 104,858,955 字节。
你可以在这里做各种事情。你可以点击你想了解更多信息的行,然后钻入该对象的源代码。这样,你可能会发现为什么你的内存使用在增加。
在诊断工具中有很多事情在进行。我建议你尝试一下,看看它能告诉你关于你系统的一些什么信息!
调试多线程和异步代码
让我们加入超级调试员的行列。我们即将开始一段探索多线程系统深处的旅程,以及它们出错的地方。
多线程代码调试起来特别困难。想象一下,你有两个相互交互的线程,然后出了问题。然而,如果你在 Visual Studio 中逐步执行方法,一切都会正常工作,这是有道理的:一些错误只有在特定的时机条件下才会出现。
并行监视
那么,如果你有多个线程,并且出了问题。你想要检查那个线程中发生的事情。但如果你设置了一个断点,你怎么知道你是在正确的线程中呢?
不要害怕:Visual Studio 可以帮助你解决这个问题。让我们从以下代码开始:
var rnd = new Random();
for (int i = 0; i < 10; i++)
{
int threadNumber = i;
ThreadPool.QueueUserWorkItem(_ =>
{
var counter = 0;
while (true)
{
$"Thread {threadNumber} with counter {counter++}".Dump(ConsoleColor.Yellow);
Task.Delay(rnd.Next(1000)).Wait();
}
});
}
这段代码创建了 10 个线程。每个线程都有一个无限循环,显示一些文本并计数。然而,每个线程以不同的速度执行这些操作:它们都在每次迭代之间等待随机的时间。
在那个循环的某个地方放置一个条件断点,条件是它应该满足这个条件:counter % 10 == 0。现在,运行程序。
你可以在Autos或Locals窗口中看到counter值。这可能很有帮助;这个变量是你当前线程的局部变量。Visual Studio 为我们暂停了所有其他线程,但我们不知道那些线程中数据的状态。我们该如何找出答案呢?
这个问题的答案是:打开并行监视窗口。同样,你可以在调试 | 窗口菜单中找到它。在我的系统中,在断点中断后,它看起来像这样:

图 11.8:并行监视窗口
在这个特定的情况下,我显然是在线程 14628 处停止了执行。这并没有告诉我太多。
添加一个并行监视
但正如你所见,在窗口的顶部,它写着counter变量。当我这样做的时候,监视窗口就显示了这个变量的值,但它为每个线程都这样做:

图 11.9:添加了计数器的并行监视
如截图所示,所有线程都有自己的counter版本,每个都有不同的值。这很有帮助!
跳转到帧
虽然这个窗口主要是监视窗口,意味着它显示你感兴趣的变量及其值,但在这里你还可以做其他事情。
由于我们在循环的某个地方停止了,你可以将鼠标悬停在代码中的变量上以查看其值。然而,正如我们所发现的,这些值仅适用于该线程。你可以将所有你感兴趣的变量添加到并行监视窗口中,但如果你只想查看一个变量一次怎么办?嗯,并行监视窗口可以帮助你。在窗口中选择其他线程,然后右键单击该行,你将看到一个上下文菜单。其中一个选项是切换到帧。如果你这样做,调试器将所选线程设置为当前线程,允许你调查该特定线程作用域内所有变量的值。
这样,你可以在所有活动线程之间跳转,并检查每个线程作用域内所有变量的值。
冻结和解冻线程
检查不同线程中变量的能力是一个强大的工具。你可能可以想象某些变量会影响其他线程。发现问题通常需要大量的日志记录和对这些日志的检查,以确定不希望的行为的结果。能够中断代码并查看正在发生的事情可以让你避免很多这样的工作。
但有时,所有这些同时运行的线程可能会造成干扰。在这种情况下,你可能想要单独关注一个或一些线程。冻结和解冻选项可以帮助你处理这种情况。
冻结线程不过是调试过程中暂停它。你暂时停止一个或多个线程的执行,以便你能专注于对你来说重要的事情。当你收集到所有需要的信息后,你可以解冻被冻结的线程,让它们恢复常规工作。你可以使用线程窗口,但也可以在并行监视窗口中这样做。你只需右键单击你想冻结的线程,并在上下文菜单中选择冻结即可。如果你继续程序,你选择的冻结线程将不再执行任何操作。
要查看这种行为,将我们代码中的线程数从 2 改为 2。重新运行程序,并查看断点触发时哪个线程是活动的。显然,其中一个线程将导致断点条件(该线程中的counter变量必须是 10 的倍数)得到满足。如果你然后继续程序,其他线程很可能是下一个停止的线程:它可能也接近满足条件(我说“可能”,因为Wait()语句的随机行为在理论上可能使其有可能采取其他行动)。
重新启动程序,等待断点第一次变为活动状态。这次,右键单击其他线程并选择冻结。继续程序。

图 11.10:在并行监视中冻结线程
暂停符号应该位于所选线程之前。继续程序。当程序再次中断时,它将位于第一次执行此操作时的同一线程。如果你继续,第三次也将是在那个线程上。这很有道理:其他线程什么都没做,因此永远不会满足断点条件。
现在,你可以专注于那个正在运行的线程,以确保你了解发生了什么。当你准备好让线程加入程序的其他部分时,等待断点再次发生。然后,你可以右键单击冻结的线程并“解冻”它。继续程序,看看是否一切恢复正常:调试器将在任何线程满足条件时立即中断。
冻结和解冻:一句警告
如你所见,在解冻线程而没有调整任何东西之后,程序继续运行。通常,两个线程中的counter变量值应该相近。然而,在冻结一个线程后,它落后了,并且它无法弥补那个滞后。冻结和解冻线程可能会有不可预测的副作用:如果你的代码的其他部分以某种方式依赖于那个线程的运行,你可能会无意中改变了逻辑流程。所以,请注意这一点!
冻结和解冻可以成为你工具箱中的一项美好补充。所以,如果需要,请使用它们,但请明智地使用!
使用并行堆栈和线程窗口调试死锁
死锁相当讨厌。简单来说,死锁是两个线程相互等待,因此无法继续的情况。这就像你在狭窄的道路上开车,看到对面有人过来。你们其中一个人必须退后,否则你们将永远无法离开那条路。死锁就像这样,但你的应用程序因为涉及的线程都不愿意退后而冻结。我认为在代码中你肯定不希望这样。
然而,尽管问题听起来很简单,但调试和修复它可能具有挑战性。但 Visual Studio 在这里可以帮助你!
让我们从一个小程序开始。这是代码:
"Starting the threads".Dump(ConsoleColor.Cyan);
var lockA = new object();
var lockB = new object();
ThreadPool.QueueUserWorkItem(_ =>
{
lock (lockA)
{
"Thread 1 acquired lock A".Dump(ConsoleColor.Yellow);
Thread.Sleep(1000);
lock (lockB)
{
"Thread 1 acquired lock B".Dump(ConsoleColor.Yellow);
}
}
});
ThreadPool.QueueUserWorkItem(_ =>
{
lock (lockB)
{
"Thread 2 acquired lock B".Dump(ConsoleColor.Blue);
Thread.Sleep(1000);
lock (lockA)
{
"Thread 2 acquired lock A".Dump(ConsoleColor.Blue);
}
}
});
"Waiting for all threads to finish".Dump(ConsoleColor.Cyan);
Console.ReadLine();
我们在这里做什么?简单来说,我们创建了两个线程。它们各自使用一个lock语句。这意味着在没有拥有lock语句的线程完成之前,其他线程不能进入该作用域。在这段代码中这不是问题:两个线程使用不同的lock。然而,我们也试图在另一个线程中使用那个lock对象。因为我们每个线程都有一个Thread.Sleep(1000),所以两个线程在访问另一个lock之前都有足够的时间获取lock。但这从未发生。没有线程可以释放lock,因为它在等待另一个线程——反之亦然。
运行它。你会看到两个线程打印出它们关于获取它们的locks 的初始语句。然后:什么都没有。程序完全冻结了。它不再做任何事情。我们遇到了死锁。
在这种情况下,发生的事情很明显。尽管如此,我相信您可以想象在典型程序中找到这些情况可能会很棘手。好消息是 Visual Studio 通常知道发生了什么,并且可以告诉我们。
通过转到 调试 菜单并单击 全部中断 来停止程序执行。当 Visual Studio 获得焦点时,您也可以按 Ctrl + Alt + Break。
以这种方式中断会停止所有线程,就像调试器遇到了断点一样。Visual Studio 停在三个线程之一(主线程或行为不良的线程),您会收到如下警告:

图 11.11:Visual Studio 检测到的死锁
因此,至少要知道导致冻结的原因:我们遇到了死锁。现在是时候找出发生了什么。
并行堆栈
在 图 11.12 中,您可以看到该对话框中的 显示并行堆栈 选项。您也可以通过 调试 | 窗口 菜单选项获取 并行堆栈 窗口。这样做会为您提供一个所有当前已知线程的直观表示。在我的机器上,它看起来像这样:

图 11.12:并行堆栈在行动中
由于我们运行的线程很少,找到问题很简单。有问题的线程用红色圆圈和白色线条标记:这是全球公认的停止标志符号。此符号表示当前处于死锁状态的线程。为了使其更加明显,下面的信息框中写着 [死锁,双击或按 Enter 查看详细信息]。您可以双击 等待锁 行以跳转到该线程的源代码。
此窗口帮助您非常快速地识别线程问题。您可以看到哪些线程正在运行,是否存在任何问题,以及这些线程的来源。
但如果这还不够,您可以通过查看 线程 窗口来深入了解。
线程窗口
如您从名称中猜测到的,线程 窗口显示了您可能感兴趣的线程。让我们继续我们的死锁示例。您已经查看了 并行堆栈,但找不到发生了什么。
因此,您打开 线程 窗口。在我的机器上,它看起来像这样:

图 11.13:线程窗口
这些都是我应用程序中目前已知的所有线程。它们都在运行,当前线程的 ID 为 95264(或托管线程 ID 10)。这是一个线程池中的线程,因为其名称为 .NET TP Worker。您还可以看到位置:它在我的应用程序中。
如果您单击名称旁边的向下箭头,您会获得更多详细信息:

图 11.14:带有更多详细信息的线程窗口
如截图所示,这为我提供了更多信息,包括此线程已死锁并正在等待由线程 14840 拥有的锁。线程窗口也显示了有关该特定线程的信息,因此如果您想查看,可以打开它。双击位置将带您到源代码,在那里您可以调查在所有事情崩溃之前您做了什么。
调试线程问题并不容易。但如果没有这些工具,它们比以往任何时候都更容易被发现。当然,最好的做法是首先不犯错误,但正如我多年前向我的经理解释的那样,我们并不生活在一个那样的世界。
性能分析应用程序性能
到目前为止,我们已经确定系统程序员关心速度。应用程序需要尽可能高效和快速。但如果你认为你的应用程序可以更快,但不知道在哪里或如何改进,那么性能分析和基准测试就可以帮助。
性能分析是衡量和分析您应用程序的性能,包括 CPU 使用率、内存压力、网络性能等因素。这就像把您的应用程序放在显微镜下。在性能分析期间,我们关注的因素包括以下内容:
-
CPU 使用率:确定您的应用程序中哪些部分使用了最多的处理能力
-
内存使用:追踪使用了多少内存以及寻找内存泄漏或过度分配
-
函数调用频率:查看哪些方法被调用得最多以及它们耗时多久
-
性能热点:确定代码中比预期慢的区域
基准测试与之相关,但它不同。基准测试是在不同情况下衡量您的代码性能或比较不同方法。这个过程涉及运行预定义的测试并捕获指标。以下是一些指标:
-
执行时间:测量一段代码运行所需的时间
-
吞吐量:评估在给定时间内可以处理多少操作或事务
-
延迟:确定任务启动和执行之间的延迟
性能分析和基准测试是相辅相成的,通常一起使用来改进您的应用程序。
主要应用程序
为了调查我们如何做到这一点,让我们从一个我们想要提高性能的程序开始。这是一个简单的程序,它计算 0 – 100,000 范围内的所有素数并将它们相加。它没有什么花哨的或有用的,但它需要 CPU 做很多工作。我们还想看看我们是否可以使事情变得更好。所以,让我们先看看代码。首先,我们创建一个名为 PrimeCalculator 的类。这很简单。这个类的主要方法是 Run 方法。它看起来像这样:
public void Run()
{
var limit = 100000;
var stopwatch = Stopwatch.StartNew();
var sum = SumOfPrimes(limit);
stopwatch.Stop();
$"Sum of primes up to {limit}: {sum}".Dump();
$"Time taken: {stopwatch.ElapsedMilliseconds} ms".Dump();
}
这里没有什么特别的事情发生。我们创建一个Stopwatch来计时持续时间,然后调用执行所有实际工作的SumOfPrimes()方法。最后,我们显示结果和持续时间。
让我们看看SumOfPrimes():
private long SumOfPrimes(int limit)
{
long sum = 0;
for (var i = 2; i <= limit; i++)
if (IsPrime(i))
sum += i;
return sum;
}
这段代码也很基础。我们循环遍历从2到给定限制(2,因为从技术上讲1不是一个质数)之间的所有值,并检查该数字是否为质数。如果是,我们就将其加到总和中。让我们转到IsPrime():
private bool IsPrime(int number)
{
if (number < 2) return false;
for (var i = 2; i <= Math.Sqrt(number); i++)
if (number % i == 0)
return false;
return true;
}
这个方法是一个糟糕的实现,用于判断一个数字是否为质数,但它足够简单,易于理解。我们通过检查我们给出的数字是否可以被小于该数字平方根的任何数字整除来实现这一点。如果它可以被整除,那么它就不是质数。
例如,如果我在我写这段文字的机器上运行这个程序,我得到的结果是 454,396,537,总耗时为 21 毫秒。我不知道这个总和是否正确;我无意在我的手机上的计算器应用程序中手动计算它。这无关紧要:我们在这里是为了看看我们是否可以找到瓶颈。
21 毫秒听起来像是一段很短的时间,但实际上相当长。毕竟,现在的计算机速度很快,所以我确信我可以改进它。我们可以使用 Visual Studio 的剖析工具来查看瓶颈在哪里。
Visual Studio 中的分析
在 Visual Studio 中,在主调试菜单下,您会找到性能分析选项。该选项的默认快捷键是Alt + F2,如果您经常运行此操作(您会的!)可能会很有帮助。
如果您选择该选项,您将看到以下屏幕:

图 11.15:分析会话的开始
分析可以在许多不同的层面上进行。然而,最重要的选择是您想要分析的内容。默认情况下,这个工具选择当前的应用程序。正如您所看到的,在我的情况下,那就是11_Profiling项目。您可以选择其他项目或正在运行的过程,浏览应用程序,等等。如果需要,点击那个大型的更改目标按钮进行更改。按钮下面还有一个警告:我们可能想要从调试配置文件切换到发布配置文件。发布模式与您在生产环境中运行的内容更为紧密相关,因此您得到的数字更像是您在部署应用程序时预期看到的数字。然而,发布模式优化了您的代码,使得查找编程错误变得更加困难。所以,我倾向于在开发期间将其保留为调试模式。
然后,您必须决定您想查看什么。这里有许多选项:您可能想查看异步/等待,或者您可能对数据库通信感兴趣。在我的情况下,我想了解CPU 使用率。我还勾选了.NET 计数器和内存使用率;它们可能很有帮助。
如果您点击开始按钮,您的程序将构建并运行。在后台,Visual Studio 开始收集信息。
在我们的例子中,程序运行并结束,向 Visual Studio 发出停止收集的信号。如果你的应用程序继续运行,你必须手动停止程序或点击 Visual Studio 中的停止收集按钮。
一旦你这样做,Visual Studio 就会向你展示它收集的概览。

图 11.16:分析会话的第一批结果
由于我们的程序很简单,所以结果并不那么令人印象深刻。然而,你可以看到在_11_Profiling.PrimeCalculator.IsPrime(int)方法中花费了很多时间:10 微秒,占总时间的 10.64%。
这很好,但我们想看看是否可以获得更多信息。点击那一行,你会得到另一个视图。你可以在视图的顶部选择你想看到的内容。默认情况下,你看到的是按函数分组的所有数据,但我想看到调用路径。如果你这样做,你会得到这个结果:

图 11.17:导致最慢函数的热路径
你可以点击显示热路径和展开热路径来查看过程是如何导致最慢函数的。
最后,你可以双击一行来查看源代码。所以,如果你双击IsPrime()方法,你会得到这个:

图 11.18:突出显示的代码慢行
现在,很明显,循环是IsPrime()函数中最慢的部分。这很有道理:为了让这个循环工作,CPU 每次都必须计算Math.Sqrt(number)。这需要时间。如何改进这一点是显而易见的:预先计算这个平方根,并在for语句中使用这个变量。这应该能加快速度!
如你所见,有了适当的工具,你可以识别出应用程序中的瓶颈。一旦找到它们,你可以重构代码或用更快的部分替换。但你怎么知道该使用哪种算法来加速呢?答案是:基准测试它们!
对不同解决方案进行基准测试
我知道number % i == 0这一行不是查看一个数是否能被另一个数整除的最快方式。然而,我并不确定其他方法能快多少。为了找出答案,我可以使用一些基准测试来解决这个问题。
你有几种方式可以从基准测试开始,但在像这种情况一样,我有几个特定算法的选项时,我喜欢使用Benchmarkdotnet NuGet 包。这个免费包使基准测试变得简单。
要做到这一点,请启动一个新的控制台应用程序。将Benchmarkdotnet包添加到项目中。然后,创建一个新的类。我称这个类为ModuloTesters,因为我想要测试Module运算符的性能以及我能找到的任何替代方案。
我添加了一个名为TestModulo的方法。这个方法看起来像这样:
[Benchmark]
public void TestModulo()
{
var numberOfMatches = 0;
for (var i = 3; i < numberOfLoopCount; i++)
if (testNumber % i == 0)
numberOfMatches++;
}
如您所见,这很简单。我只是进行几次迭代(numberOfLoopCount在我的类中定义为常量,我将其设置为 100,000)并计算模数(testNumber再次是一个常量;它实际上并不重要,但我将其设置为 400)。使这个方法区别于典型方法的唯一因素是[Benchmark]属性。这告诉基准测试工具需要测量这个方法。
在主程序文件中,我们需要启动基准测试。这非常简单:只需添加以下代码行:
var summary = BenchmarkRunner.Run<ModuloTesters>();
将构建模式设置为发布,并运行不带调试。Benchmark工具将运行标记为Benchmark的方法几次(好吧,不止几次)并展示结果。
但在我们查看这些结果之前,我们需要添加一些内容。基准测试的目的是比较解决问题的方案。目前,我们只有一个解决方案:取模运算符。所以,没有什么可以比较的。让我们来解决这个问题。向ModuloTesters类添加一个新方法,如下所示:
Benchmark]
public void TestMultiplicationAndDivision()
{
var numberOfMatches = 0;
for (var i = 3; i < numberOfLoopCount; i++)
if (testNumber - i * (testNumber / i) == 0)
numberOfMatches++;
}
这是一种计算模数的新方法。但它更快吗?唯一找到答案的方法是运行基准测试!如果您这样做,您会看到结果。在我的机器上,它看起来像这样:

图 11.19:基准测试结果
因此,新的算法更快:它只需要 316.4 微秒,而不是 316.7 微秒。好吧,我承认这并没有快多少。也许我们可以做得更好。你知道吗?我们可以。让我们添加第三个基准测试:
[Benchmark]
public void TestMultiplicationAndDivisionInParallel()
{
var numberOfMatches = 0;
var localNumberOfLoopCount = numberOfLoopCount;
var localTestNumber = testNumber;
var lockObj = new object();
Parallel.For(3, localNumberOfLoopCount, i =>
{
var div = localTestNumber / i;
if (localTestNumber == i * div)
lock (lockObj)
{
numberOfMatches++;
}
});
}
由于所有计算都可以独立完成,我们可能可以并行地完成它们。所以,这就是我在这里所做的事情:我使用Parallel.For()语句将工作分成同时运行的作业。我需要一个锁来更新numberOfMatches,这可能会减慢循环的速度。但这只是一个猜测:让我们来测试一下。运行基准测试。这是我得到的结果:

图 11.20:新的基准测试结果
现在,这很有趣。添加了Parallel.For()之后,该方法所花费的时间有了巨大的变化。
如果您认为这可以改善您的代码,您可以将这些发现应用到您正在工作的实际应用程序中。当然,我会在进行更改之前先进行性能分析,然后再进行性能分析以查看是否没有添加新的瓶颈。但总的来说,我认为我们已经使我们的主要计算器变得更快了!
其他工具
Visual Studio 是一个用于调试和性能分析系统的优秀工具。然而,它并非唯一的选择。还有许多其他解决方案可以帮助您调试和性能分析代码。其中一些是付费的,其他是免费的。有些容易使用,有些则相当难以掌握。我不会讨论其他工具,但我想给您提供一个小的列表,以便您可以自己调查它们。
请先看看 Visual Studio 给您提供了什么。很可能您需要的东西已经在那里了!
调试工具
现在有许多调试工具。这只是一个你可以尝试的样本。
| 工具名称 | 描述 | 公司 |
|---|---|---|
| Visual Studio 调试器 | 集成到 Visual Studio 中,支持 .NET、C++ 和其他语言,具有断点、监视变量等功能。 | 微软 |
| WinDbg | Windows 的多用途调试器,适用于调试用户模式和内核模式代码,以及分析崩溃转储。 | 微软 |
| Visual Studio Code 调试器 | 集成到 Visual Studio Code 中,通过扩展支持各种语言和平台,具有断点和变量检查功能。 | 微软 |
| 托管调试器 (MDbg) | .NET 应用程序的简单命令行调试器,为托管代码提供基本的调试功能。 | 微软 |
| 调试诊断工具 (DebugDiag) | 帮助诊断应用程序崩溃、挂起、内存泄漏和用户模式进程的性能问题。 | 微软 |
| ProcDump | 命令行实用程序,用于监视应用程序的 CPU 峰值并生成用于分析的崩溃转储。 | 微软 |
| Microsoft 性能工具 (PerfView) | 用于收集和分析 ETW 数据的性能分析工具,对于 .NET 应用程序的性能和内存问题非常有价值。 | 微软 |
| Son of Strike (SOS) 调试扩展 | WinDbg 的扩展,提供对 .NET 运行时内部结构的洞察,有助于深入调试 .NET 应用程序。 | 微软 |
| Windows 性能记录器 (WPR) | 用于记录和分析 Windows 系统性能数据的工具,捕获详细的系统和应用程序行为。 | 微软 |
| 远程调试工具 | 用于调试在不同机器或环境中运行的应用程序的工具,支持托管和本地代码。 | 微软 |
| GNU 调试器 (GDB) | 用于各种编程语言的强大调试器,特别是 C 和 C++,可以查看程序内部发生的情况。 | GNU 项目 |
| LLVM 调试器 (LLDB) | LLVM 项目的现代、高性能调试器,支持 C、C++ 和 Objective-C 等语言。 | LLVM 项目 |
| Valgrind | 内存调试、内存泄漏检测和性能分析编程工具,包括 Memcheck 等工具。 | Valgrind 开发者 |
| Strace | Linux 的诊断、调试和教学工具,用于跟踪系统调用和信号。 | 开源 |
表 11.7:调试工具
我不推荐任何这些产品;我仅仅在这里列出它们以供您方便参考。
性能分析工具
性能分析工具也容易找到。许多公司都在微软旁边提供解决方案。它们各自都有其优势和劣势。因此,请参考以下表格作为指南,以帮助您找到最适合您的工具。
| 工具名称 | 描述 | 公司 |
|---|---|---|
| Visual Studio 分析器 | 集成到 Visual Studio 中,为 .NET 和 C++ 应用程序提供详细的性能和内存使用数据。 | 微软 |
| WPR | 捕获 Windows 系统上的详细性能数据,以便进行深入分析。 | 微软公司 |
| Windows 性能分析器 (WPA) | 分析 WPR 收集的性能数据,帮助识别性能问题。 | 微软 |
| PerfView | 收集和分析 ETW 数据,对于调查 .NET 应用程序中的性能和内存问题很有用。 | 微软 |
| .NET 内存分析器 | 用于在 .NET 应用程序中查找内存泄漏和优化内存使用的强大工具。 | SciTech 软件 |
| ANTS 性能分析器 | 提供 .NET 代码分析,以查找性能瓶颈,包括内存使用和执行时间分析。 | Redgate |
| JetBrains dotTrace | 针对 .NET 的性能、内存和覆盖率分析分析器,与 Visual Studio 集成。 | JetBrains |
| VTune 分析器 | 针对 C、C++ 和 Fortran 应用程序的性能分析工具,提供对 CPU 和 GPU 性能的深入洞察。 | 英特尔 |
| Valgrind | 包含一系列工具,如 Cachegrind 用于缓存分析,Massif 用于堆分析,主要用于 C 和 C++ 程序。 | Valgrind 开发者 |
| Google 性能工具 (gperftools) | 一套用于性能分析和堆分析的实用程序,提供对 CPU 和内存使用的洞察。 | 谷歌 |
| YourKit 分析器 | 针对 Java 和 .NET 应用程序的分析器,提供全面的 CPU 和内存分析功能。 | YourKit |
| Perf | Linux 中的性能分析工具,提供有关 CPU 性能的详细信息,有助于识别瓶颈。 | Linux 社区 |
| GlowCode | 针对 Windows 的性能和内存分析器,专注于 C++ 和 .NET 应用程序。 | 电软公司 |
| AQtime | 针对各种编程语言的先进性能和内存分析工具,与 Visual Studio 集成。 | SmartBear |
| Perfino | 针对生产环境的 Java 分析器,专注于性能监控和问题解决。 | EJ 技术公司 |
表 11.8:分析工具
这些表格并不包含所有可用的工具。新工具会定期添加,而其他工具则会被淘汰。我建议你尝试一些,并坚持使用最适合你的工具。也许你更喜欢 CLI 解决方案。也许你想要使用图形工具。无论你的偏好如何,总有适合你需求的工具。
下一步
编写代码不可避免地意味着会犯错误。我认为这是工作乐趣的一部分。提出新想法,从无到有,然后让它工作并改进是一个伟大的过程。然而,你只能在你拥有正确的工具并且知道如何使用它们的时候做到这一点。
在本章中,我们探讨了 Visual Studio 提供的调试工具。我们了解了调试和性能分析究竟是什么,发现了断点的可能性,并查看了一些其他有用的调试窗口。
我们还研究了如何处理多线程应用程序及其带来的调试挑战。我们查看了一些可以帮助我们的窗口,并研究了死锁问题。
更重要的是,我们讨论了性能分析和基准测试,以揭示性能瓶颈及其解决方法。
因此,我们现在知道了如何应对代码中的大多数问题。然而,我们还有另一个重要的话题要讨论:我们如何确保代码的安全性?这究竟意味着什么?这是一个很大的话题。它如此之大,以至于我为此专门写了一整章,这就是接下来的内容。请继续关注!
第十二章:具有安全防护措施的人
系统编程的安全要素
安全性在当今比以往任何时候都更加关键。软件永远不会独立存在;它总是与硬件和其他软件包一起工作。攻击者会尽其所能找到链条中最薄弱的环节。作为开发者,我们必须确保我们的软件不是最薄弱的环节。
安全不是一个“东西”,而是一种心态和过程。它是一个永无止境的寻找最佳解决方案的过程,同时考虑到可维护性和可用性。作为系统程序员,我们必须在安全性与性能和内存使用之间进行权衡。
这使得构建安全的软件成为一个挑战。但让我们说实话——难道这种挑战不是我们选择这个职业的原因吗?
在本章中,我们将涵盖以下主题:
-
为什么作为系统程序员我们需要关心安全性?
-
如何安全地处理字符串
-
如何在你的系统中处理密钥
-
关于凭证和权限的要求是什么?
-
你如何安全地在网络上传输数据?
安全是一个重要但复杂的话题。我不会涵盖关于安全性的所有内容。然而,作为一个系统程序员,我会涉及到你应该知道的最重要的事情。但让我们不要太过大声——我们必须保守我们的秘密!所以,确保没有人正在监听,然后跟我来。
技术要求
你可以在这个 URL 中找到本章中所有的代码:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter12。
如果你想要在我讨论 Azure Key Vault 时跟上来,你需要一个 Azure 订阅。你可以在这里创建一个:azure.microsoft.com/en-us/free。
系统程序员的网络安全
当我开始编程时,安全性并不是一个问题。想象一下——我的电脑只有一个连接,那就是墙上的电源插座。当然,电脑还连接到电视上,以显示它需要显示的内容。是的,一台电视。我没有显示器;当时我买不起。我启动了这台机器,它会从 ROM 中加载操作系统和基本的编程环境。这就是全部。这种设置极其安全——没有任何东西可以干扰我的机器和数据。我把电脑放在我的卧室里,因此物理安全也得到了保障(没有人会自愿进入一个青少年的卧室;那混乱是难以置信的)。
快进到今天。我的台式电脑总是开启并始终连接到互联网。我编写的一些系统在云提供商的虚拟机上运行;其他的是无服务器系统,等待连接。
我的防火墙和应用网关告诉我,其他系统不断尝试连接到所有这些环境和机器。我有一种感觉,所有这些机器都处于持续的威胁之下。
安全是软件行业中的每个人都必须时刻关注的事情。只在开发周期结束时添加安全措施是确保攻击者无法访问你的系统的最佳方式。你肯定会忘记一些事情。安全必须在每个步骤中考虑,从最初的设计到维护运行中的系统。
正如我在本章第二段所说,安全是一种心态。你需要不断地问自己,“有人可以利用我正在做的事情吗?”
如果我们有一个漏洞会发生什么?
我几乎能听到你这么说,“嘿,我编写的是底层代码,不是一些花哨的客户网站。我为什么要关心所有这些?”这是一个合理的回应,但作为系统程序员,你应该非常清楚这些风险。如果你不这样做,结果可能是灾难性的。让我概述一下可能会发生的一些事情:
-
权限提升:我们编写的许多代码都带有提升的权限。利用漏洞的黑客可以从普通用户提升到管理员权限,从而获得对系统的广泛控制。
-
数据盗窃:获得系统访问权限的黑客可以使用这些信息窃取敏感信息,如下所示:
-
用户数据,例如个人信息和信用卡信息
-
商业机密数据,包括商业机密、知识产权和内部通讯
-
系统日志和配置,可能包含有关其他系统的信息,因此它们也可以被针对
-
-
代码注入:黑客可能会利用诸如缓冲区溢出或输入验证不足等漏洞,将恶意代码注入进程。这一行为可能使他们能够执行以下操作:
-
执行任意命令:它们可以运行任何代码,可能安装恶意软件、勒索软件或其他恶意软件
-
改变系统行为:改变系统的行为,导致系统不稳定或隐藏他们的活动
-
-
拒绝服务或分布式拒绝服务:攻击者可以通过以下方式干扰系统的正常运行:
-
过载进程:发送过多的请求或数据,导致系统崩溃或无响应。
-
资源耗尽。消耗系统资源,如 CPU、内存或磁盘空间,导致性能下降或系统崩溃
-
-
后门和持续访问:一旦他们控制了后台进程,黑客可以执行以下操作:
-
安装后门:创建隐藏的入口点,即使在初始漏洞被修补后也能重新访问系统
-
建立持续性:修改进程以在重启或重启后重新启动或保持其在系统上的存在
-
-
间谍活动和监控:黑客可以使用受损害的系统在较长时间内监控和收集数据。
-
键盘记录:捕获用户输入的内容,可能窃取密码和其他敏感信息。
-
屏幕截图:定期截图以监控用户的活动。
-
网络流量监控:捕获发送到和接收自其他系统的数据。
-
-
传播恶意软件:受损害的系统可以用作进一步攻击的跳板。
-
横向移动:使用受损害的系统在网络内的其他系统之间移动。
-
传播:将恶意软件传播到其他设备或进程,创建更大的攻击面。
-
-
操纵数据:黑客可以更改后台进程处理的数据。
-
数据损坏:向数据引入错误或恶意修改。
-
篡改日志:修改或删除日志条目以掩盖他们的行踪,使得检测到入侵变得更加困难。
-
正如你所见,如果我们让我们的系统存在漏洞,会有很多事情可能出错。为了强调这一点,想象一个后台进程,它监控串行端口并处理外部设备的数据。该进程全天候运行,因为它处理的是低级的 Win32,所以我们以管理员身份运行它。但我们在某个地方犯了错误,黑客访问了我们的进程。以下是一个可能发生的潜在场景:
-
利用漏洞:黑客发现并利用了进程中的缓冲区溢出漏洞。
-
权限提升:他们提升权限以获得管理权限。
-
数据盗窃:他们提取整个用户凭证和个人信息数据库。
-
安装后门:他们安装后门以保持访问并监控用户活动。
-
数据操纵:他们更改账户余额和应付账款账户的银行信息。
-
破坏:最后,他们发起 DDoS 攻击,使整个公司陷入瘫痪。
如果你认为这被夸大了,我建议你上网查找有关安全黑客的文章。如果你足够仔细,你会找到很多例子。大多数公司都不愿意分享他们的经验,但数据是存在的。
如何保护自己
如果我让你有点害怕,那很好。这可能会很可怕。但不要过于担心——遵循一些良好的安全实践可以避免许多这些风险。实际上,本章的其余部分都是关于作为开发人员,你应该做什么来保护你的系统。然而,除了安全方面的编码,还有几件事情你应该做:
-
定期进行安全审计:持续审查和审计你的代码和系统以发现漏洞。我真心建议为此聘请外部机构。他们有更多的经验,并且不太可能像开发系统的人那样有相同的盲点。
-
输入验证:确保所有输入都经过适当的验证和清理。绝对不要相信来自外部源的内容。
-
最小权限原则:以最低必要的权限运行进程,以限制潜在的损害。
-
采用现代安全实践:使用加密、安全的编码实践和最新的第三方库。
-
监控和记录活动:保持详细的日志并监控可疑活动,以便快速检测和应对违规行为。
所以,现在你知道为什么安全很重要。现在,让我们调查如何在我们的代码中实现这一点。
与字符串一起工作
你的应用程序很可能有字符串。其中大部分与外界无关;如果你向控制台写入“Hello World”,攻击者可能根本不在乎这一点。但其他字符串对这些人的吸引力要大得多。例如,考虑数据库的连接字符串。它们可能对黑客来说是一个极好的资源。然后,还有其他数据,例如用户信息、密码和信用卡信息。
我们可以区分两种类型的字符串:
-
作为你代码一部分的字符串,因此被编译到二进制文件中
-
在你的代码中处理并由外部进程产生或发送到外部进程的字符串
让我们看看我们是否可以保护这些敏感数据。
保护设置
首先,我们处理你应用程序中作为代码库一部分的字符串。想想密码和连接字符串等事情。在理想的世界里,你将此信息存储在外部文件中。这样做的原因是,由于它们不在源代码中,你可以更改它们而无需重新编译代码。
假设你的组织内部某个地方检测到了安全漏洞。安全部门告诉每个人更新他们的密码。在你的情况下,这意味着打开 Visual Studio,加载解决方案,更改数据库服务器的密码,重新编译,最后重新部署系统。或者,再想想,你可以在配置文件中更改密码。我知道我会选择做什么!
然而,在配置文件中保留密码是一个相当糟糕的主意。如果你将密码作为代码的一部分,攻击者必须反编译你的汇编代码来找到它。如果我们把密码存储在文本文件中,攻击者只需打开该文件并读取密码即可。为了应对这种情况,我们加密密码。
我们之前已经多次讨论过加密,所以我确信你能想出如何做这件事。但到目前为止,我们所查看的所有技术都需要密码作为源代码的一部分,而我们刚刚确定这是一个坏主意。将密码存储在配置文件中以启用解密其余文件听起来更糟糕。一定有更好的方法。确实有。
让我们调查这个问题。
我有一个包含一些敏感信息的示例应用程序。我将这些信息放在一个名为appsettings.json的文件中。你知道的——一个典型的基于.NET 的配置文件。它看起来像这样:
{
"MyPublicSettings": {
"Setting1": "Value1",
"Setting2": "Value2",
"Setting3": "Value3"
},
"MySecretSettings": {
"MySecretSetting1": "SecretValue1",
"MySecretSetting2": "SecretValue2"
}
}
我们有两个部分——不敏感数据和我们不希望其他人读取的数据。我们需要保护后者。现在,我们处理这个问题有点不方便。我们必须在编写使用此文件的代码之前编写一个单独的程序来加密数据。
启动一个新的控制台应用程序并添加以下 NuGet 包:
Microsoft.Extensions.Configuration
Microsoft.Extensions.Configuration.FileExtensions
Microsoft.Extensions.Configuration.Json
Microsoft.Extensions.DependencyInjection
Microsoft.AspNetCore.DataProtection
这些包用于读取和使用配置文件,而Microsoft.AspNetCore.DataProtection用于保护我们的数据。
首先,我们必须设置依赖注入基础设施。数据保护工具使用它;它们在需要时需要注入包。所以,我们代码的前几行看起来是这样的:
var serviceCollection = new ServiceCollection();
serviceCollection.AddDataProtection();
var serviceProvider = serviceCollection.BuildServiceProvider();
var dataProtector = serviceProvider.GetDataProtector("MySecureData");
我们首先创建一个ServiceCollection实例。然后,我们调用AddDataProtection()到该集合,以便所有必需的包都被加载并准备好使用。在获取serviceProvider之后,我们通过调用GetDataProtector()获取一个IDataProtector接口的实例。此方法期望一个参数——一个描述目的的字符串。这个字符串可以是任何你想要的;它作为一个标签,以便你可以分组项目。想想看,就像给你的加密数据贴上标签,这样你就可以稍后跟踪哪些属于哪些。
然后,我们将配置文件读取到配置基础设施中:
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
不要忘记在解决方案资源管理器中将你的appsettings.json文件标记为“当较新时复制”;否则,你的代码将无法加载它。
好的,现在来说说有趣的部分——我们再次读取配置文件,但这次是以文本的形式。我们这样做是因为我们将用加密版本替换敏感字符串。这是读取它的代码:
var json = File.ReadAllText("appsettings.json");
json字符串现在包含了我们的完整设置文件。是时候开始加密了!
首先,我们读取要保护的章节,遍历该章节中的所有项目,加密值,然后更改json变量中的字符串。最后,我们将新的字符串写入配置文件。这看起来是这样的:
var secretSection = configuration.GetSection("MySecretSettings");
foreach (var key in secretSection.GetChildren())
{
var originalValue = key.Value;
var encryptedValue = dataProtector.Protect(originalValue);
var oldValue = $"\"{key.Key}\": \"{originalValue}\"";
var newValue = $"\"{key.Key}\": \"{encryptedValue}\"";
json = json.Replace(oldValue, newValue);
}
File.WriteAllText("appsettings.json", json);
对dataProtector.Protect()的调用为我们做了所有艰苦的工作。它接受一个字符串并将其加密。我们用新值替换旧值,并将其写入文件。
如果你打开appsettings.json文件(调试构建所在的文件夹中的那个,不是原始的那个!),你会看到秘密字符串不再是人类可读的。所以,任何打开该文件的人都无法访问我们的秘密!
读取加密数据
在一个打算使用秘密字符串的应用程序中,你可以简单地从配置文件中读取数据并解密它们。这看起来是这样的:
configuration.Reload();
var encryptedSection = configuration.GetSection("MySecretSettings");
var someSecretValue = encryptedSection["MySecretSetting1"];
var decryptedValue = dataProtector.Unprotect(someSecretValue);
$"Encrypted value was: {someSecretValue}\nDecrypted this becomes: {decryptedValue}".Dump();
首先,我重新加载配置以确保对象具有加密的字符串。然后,我获取部分并读取第一个设置及其值。最后,我使用dataProtector来解密字符串。结果是可爱的、未加密的可读字符串。
当然,你不应该在生产系统中使用与加密和解密相同的程序。你需要将它们分开。当你这样做的时候,记得使用相同的字符串作为目的。如果你不这样做,你会得到一个异常,告诉你解密没有工作。试试这个:
var secondProtector = serviceProvider.GetDataProtector("AnotherSection");
var decryptedValue = secondProtector.Unprotect(someSecretValue);
我使用新的目的字符串调用GetDataProtector(),并使用它来解密字符串。这不会起作用。如果我使用"MySecureString"而不是"AnotherSection",它就会再次起作用,即使我有一个新的DataProtector。
密钥在哪里?
你可能会想知道我为什么从未指定一个密码来加密和解密。答案是框架为我生成了一个。它或多或少地隐藏在"%LocalAppData%\ASP.NET\DataProtection-Keys"文件夹中。这个特殊文件夹是运行时存储和读取密钥的地方。打开这个文件夹,选择一个 XML 文件,然后打开它,看看它包含什么。
你可以指定另一个系统存储密钥的文件夹。更改程序的起始部分,我们将AddDataProtection()调用添加到serviceCollection中,使其看起来像这样:
serviceCollection
.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(myKeyFolder));
这行代码告诉系统使用myKeyFolder中指定的文件夹来存储密钥。
在生产系统中,你可以分发密钥文件并将其存储在已知位置。当然,任何可以访问你的appsettings.json文件的攻击者可能没有问题找到密钥文件。必须找到更好的处理方法。确实有,但我将在下一部分处理。首先,我想谈谈内存中的字符串。这些可能是代码中的硬编码字符串或从设置文件中解密的字符串。这些都是潜在的安全风险吗?让我们找出答案!
处理内存中的字符串
你可能会认为配置文件中的加密字符串是安全的。毕竟,没有人能读取它们。只有你的程序可以访问它们,前提是它能够访问密钥文件。程序可以读取并解密内存中的设置,使一切安全可靠。不幸的是,情况并非如此。在运行程序中找到这类信息并不困难。
在你的应用程序中泄露字符串
假设我们有以下代码:
var myOpenString = "This is my Open String";
Console.ReadLine();
我同意。这不是你见过的最令人兴奋的代码片段,但它确实完成了它需要做的事情。它将字符串加载到内存中,然后等待用户按下一个键来终止程序。
假设我在发布模式下编译此代码并启动方便的WinDbg工具(你可以通过访问 Microsoft Store 并搜索它来安装)。在这种情况下,我可以对运行中的程序进行各种检查。经过一番挖掘,我终于找到了这个结果:
Name: System.String
MethodTable: 00007ffaf832ec08
EEClass: 00007ffaf830a500
Tracked Type: false
Size: 66(0x42) bytes
File: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.5\System.Private.CoreLib.dll
String: This is my Open String
Fields:
MT Field Offset Type VT
Attr Value Name
00007ffaf82b1188 400033b 8 System.Int32 1 instance 22 _stringLength
00007ffaf82bb538 400033c c System.Char 1 instance 54 _firstChar
00007ffaf832ec08 400033a c8 System.String 0 static 000001ee80000008 Empty
WinDbg 给我提供了关于在特定内存位置找到的System.String对象的所有 sorts of 信息。其中一部分信息是那个字符串的内容——“这是我的Open String”。
我的示例程序很简单,所以找到信息并不难。但事实上,我可以通过将其附加到正在运行的程序上来做到这一点,这展示了黑客能做什么。如果你的程序从appsettings.json文件中获取加密数据并将该字符串保留在内存中,那么你甚至可以不加密你的数据。
一定有更好的方法。猜猜看——确实有!
使用 SecureStrings
我们发现内存中的字符串并不安全。BCL 背后的那些人也想到了这个问题,并给了我们一个替代方案——SecureString。
这个想法听起来很美好,但SecureString比“真正的”字符串不太方便。远不止这样。然而,它确实有一个优点——其中的数据是加密的。
创建SecureString很容易:
using var secureString = new SecureString();
但这并没有真正帮助我们。我们想要一些数据在其中。这不难,但你必须逐个字符复制数据:
var sourceString = "This is a big secret";
foreach (var c in sourceString)
{
secureString.AppendChar(c);
}
secureString.MakeReadOnly();
现在,secureString包含了一些数据。好事是数据被加密了,并且不再可读。对MakeReadOnly()的调用很重要。通过使其只读,你确保字符串不再可更改,这有助于性能。
SecureString主要用于存储密码。BCL 中许多需要密码的类都接受SecureString作为它们的参数。例如,以下是可以与SecureString实例一起工作的类的一些例子:
-
ProcessStartInfo:在启动新进程时,你可以使用ProcessStartInfo结构或通过调用接受SecureString的重载版本的Process.Start()来提供密码作为SecureString。 -
NetworkCredential:当你需要用网络资源标识一个资源时,你可以使用NetworkCredential来传递所需的参数,例如用户名、密码和域。密码可以是SecureString类的实例。 -
CspParameters和X509Certificate:如果你在处理证书,这些非常重要,它们还允许SecureString实例。
所以,现在我们有一个安全的字符串。太好了。但我们仍然有一个问题。你能发现吗?在你查看我们刚才讨论的代码的同时,我会给你一分钟时间。
当然,问题是我们在哪里播种安全字符串。我们创建了一个包含"This is a big secret"内容的字符串并将其传输到安全字符串中。但原始字符串仍然在内存中。
如果我们从配置文件中读取一个加密的字符串,将其解密,并将其复制到安全字符串中,我们会有同样的问题。原始的解密字符串仍然在内存中,并且可以从外部读取。
要绕过它的唯一方法是尽快删除那个临时字符串。未加密的字符串应该在内存中尽可能长时间地保留。技术上,它仍然容易受到攻击,但攻击者必须在字符串在内存中时恰好打破运行的应用程序。攻击窗口仍然存在,但非常非常小。
删除字符串与分配新值不同——字符串是不可变的。当你尝试更改字符串时,你会得到一个新的实例,而旧数据仍然可读。要从内存中完全删除它,唯一的方法是删除构成字符串的字符。你可以使用类似以下代码的方式从内存中删除字符串:
void OverwriteAndClearString(ref string str)
{
if (str == null) return;
unsafe
{
fixed (char* ptr = str)
{
for (int i = 0; i < str.Length; i++)
{
ptr[i] = '\0'; // Overwrite with null characters
}
}
}
str = null; // Dereference the string
}
你必须设置 '0'。由于 0 表示字符串的结尾,因此很难看到字符串的原始长度。
我并不是说你需要为每个字符串调用此方法。但假设你正在处理你绝对不希望泄露的字符串。在这种情况下,这可能会解决将数据复制到安全字符串的中间问题。
但我们从哪里获取解密密钥呢?我们可以像之前那样分发它们,但还有其他方法。让我们来讨论那些!然而,在这样做之前,让我们思考一下我们学到了什么。这是一个复杂的话题;处理内存中的字符串不是许多 C# 开发者会考虑的事情。但问题就在这里——由于人们没有考虑它,他们没有意识到任何风险。
相反,现在你已经了解了风险,如果需要这种级别的安全性,你将准备好应对。
使用密钥管理
密钥是应用程序的最佳保密信息。密钥用于加密和解密大量敏感数据。这意味着密钥本身更加敏感;它们拥有解锁所有秘密的权力。将密钥存储在可执行文件旁边的文本文件中可能不是处理这块宝贵数据的最佳方式。
你如何以及在哪里存储密钥取决于你的程序运行的位置。如果你的应用程序运行在云端,你应该使用基于云的密钥管理系统。如果你在可以触摸的机器上运行你的系统,你需要另一个解决方案。
使用 Azure 密钥保管库
Azure 密钥保管库是一个集中式、基于云的秘密和密钥管理系统。它设置简单,使用方便。其主要目的是保护基于 Azure 的应用程序的秘密和密钥。然而,它也可以由本地运行的应用程序使用。
我不会在这里教你如何创建密钥保管库;有很多资源可以帮助你。例如,这是来自微软本身的一个很好的资源:learn.microsoft.com/en-us/azure/key-vault/general/quick-create-portal。
一旦你部署了密钥库并添加了密钥,检索该密钥就很简单了。但在我们查看获取该密钥的代码之前,我们必须确保对资源的访问。这意味着我们需要记录以下项目:
| 项目名称 | 值 | 描述 |
|---|---|---|
| 密钥库名称 | mykeyvault |
创建时指定的密钥库的名称 |
| 密钥名称 | MySecretValue |
密钥的名称 |
表 12.1:查找 Azure 密钥库密钥的值
显然,你应该更改这些值以匹配你的配置。
在 C#应用程序中,我们需要添加几个 NuGet 包:
-
Azure.Identity以启用身份验证 -
Azure.Security.KeyvaultSecrets
安装了这些包之后,从密钥库中获取密钥的代码就非常直接了。例如,你可以使用这个辅助方法:
public async Task<string> GetSecretAsync(string keyVaultUrl, string secretName)
{
var client =
new SecretClient(
new Uri(keyVaultUrl),
new DefaultAzureCredential());
var secret =
await client.GetSecretAsync(secretName);
return secret.Value.Value;
}
这个代码片段展示了如何使用之前安装的包中的SecretClient类来访问密钥库中的密钥。为了验证这个请求,我使用了DefaultAzureCredential类。使用这个类意味着我使用当前用户的凭据对 Azure URL 进行身份验证。
在生产系统中,你不会这样做。相反,你可能需要为你的系统创建一个注册,并使用它进行身份验证。Azure 中的身份验证是一个值得单独一本书来讨论的话题,但以下 URL 应该能帮助你入门:learn.microsoft.com/en-us/dotnet/azure/sdk/authentication/?tabs=command-line。
使用环境变量
即使在使用 Azure(且不使用默认凭据)时,你也需要在能够使用资源之前存储某种访问密钥、密钥 ID 或用户 ID 和密码。当你将数据加密存储在appsettings.json文件中时,这也适用——你需要一个密钥来解密。正如我们在之前的示例中看到的,你可以要求.NET 运行时为你创建一个密钥并将其存储在已知位置。这是解决这个问题的方法之一,但还有一个更简单的方法。我们可以使用环境变量。
警告
环境变量很方便,但它们并不安全——远远不够安全。如果有人能够物理访问这台机器,他们就可以查看到这些变量的值。除非你能够确信虚拟或物理机器是安全的,否则永远不要在环境变量中存储敏感信息。
环境变量简单来说就是存在于 Windows 中的一个键值对。它通常用于包含来自进程外部的设置。这就是为什么它们对于保存我们需要用来识别资源的那些数据很有用;它们可以在不改变或重启我们的应用程序的情况下动态更改。
环境变量作用域
这些变量确切地存储在哪里以及它们持续多久取决于环境变量的类型。这些变量可以有一个影响它们持久性的作用域(以及它们持续多久)。以下是我们的选项:
-
进程作用域:这些变量仅对定义它们的进程或主进程所派生的任何子进程可用。对于可以丢弃的临时值,它们可能很有用。
-
用户作用域:它们是针对当前登录用户的特定变量。它们对所有在该用户凭据下运行的进程都可用。这些变量在登录之间持续存在。
-
机器作用域(或系统作用域):这些变量对所有机器上的用户和进程都可用。它们需要管理员权限来设置和修改,但不用于读取。
-
会话作用域:这些变量的作用域是用户会话。这个范围与用户作用域大致相同,但变量在会话结束时会被丢弃。例如,如果用户注销,就会发生这种情况。
-
易失性环境变量:这是一个主要用于系统的特殊类别。它们旨在是临时的。用户通常不会处理或甚至访问这些变量。一个例子是在启动时设置的设置,一旦登录过程结束就可以删除。
如您所见,有很多不同的作用域,其中一些大多数用户甚至从未听说过。确保你选择正确的一个!
设置环境变量
当然,我们可以使用我们的 C# 代码来设置变量。然而,我们通常不会这样做;在我们的情况下,我们想在应用程序外部设置一些秘密数据,然后在代码中使用它。这意味着我们必须从外部设置数据。通常,设置值是在我们的软件安装期间完成的。然而,在开发过程中,你必须手动完成。
从 PowerShell 会话中设置这些变量非常容易,并且确切的语法取决于你想要实现的作用域。
进程作用域
我只在这里添加这个是为了完整性。毕竟,如果我们设置一个变量以便在我们的应用程序中读取它,使用进程作用域就没有意义了。变量是在 PowerShell 会话的作用域中设置的,因此在我们的应用程序中不可读。但无论如何,下面是如何做到这一点的。在 PowerShell 中,输入以下命令:
$env:MY_SECRET_ID = 12345678
此命令在内存中创建了一个名为 "MY_SECRET_ID" 的新变量,并将其赋值为 12345678。
如果你读取数据,你会惊讶地发现它设置起来几乎和读取一样简单:
Write-Host $env:MY_SECRET_ID
此命令应返回 12345678 字符串。
在设置和读取数据后,你可能想将其擦除。同样,这非常容易做到:
$env:MY_SECRED_ID = $null
注意,最后一个命令在关闭 PowerShell 会话时会自动发生。
用户作用域
用户作用域是我们目的的第一个可用的作用域。在 PowerShell 中设置此变量的方式如下:
setx MY_SECRET_ID 87654321
此命令创建一个新变量并设置数据。该变量存储在 Windows 注册表中的HKEY_CURRENT_USER\Environment键下。Windows 会在重启之间保持此值。由于数据存储在HKEY_CURRENT_USER中,你只能读取属于该用户的进程中的数据。这意味着你可以在Visual Studio(VS)的调试期间读取它,但前提是你必须以相同的凭据运行 VS。
机器范围
最广泛的范围是机器范围。设置数据与使用用户范围一样简单,只需添加一个小的改动:
setx MY_GLOBAL_SECRET_ID 87654321 /m
在末尾使用 /m 使得这个变量成为机器范围的。这意味着它还存储在不同的位置;你现在可以在 Windows 注册表中的HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment键下找到这个变量。这个变量会在重启之间持续存在,并且对该机器上的所有用户和进程都是可访问的。
读取你代码中的变量
如果无法读取,存储数据在某个地方几乎没有什么用处。所以,让我们调查一下我们如何在 C#应用程序中使用这些数据。
要读取数据,你只需要一行代码,就是这一行:
string mySecretdId =
Environment.GetEnvironmentVariable("MY_SECRET_ID");
然而,请记住,MY_SECRET_ID 是使用用户范围设置的。所以,如果你以管理员身份运行你的 PowerShell 命令,你也必须以管理员身份运行 VS。否则,代码将返回一个空字符串。
你想看看如何读取机器范围的变量吗?我想你可能会的。这就是方法:
var mySecretdId =
Environment.GetEnvironmentVariable("MY_GLOBAL_SECRET_ID");
是的,这就是相同的代码,唯一的变化是我们正在寻找的变量名。这本书中的所有代码并不都是难以理解的!
处理键有许多方法,但你现在已经看到了最常用的两种。你现在知道如何使用 Azure Key Vault,并且对环境变量有了很多了解。让我们继续前进!
使用正确的权限级别
大多数系统不需要以管理员身份运行。要求你的应用程序具有管理员权限是一个潜在的安全风险。最好确保你的应用程序在可能的最低安全级别上运行,以避免潜在的安全漏洞。
然而,有时你不得不这样做。在某些情况下,需要管理员级别的权限。坏消息是,在我们这些系统程序员生活的世界里,这种情况经常发生。我们的系统比普通程序需要更多的管理员级别。
管理员级别场景
让我们调查一些需要提升权限的区域,如果我们想让我们的系统完成它需要做的事情:
-
C:\Windows\System32目录是一个很好的保护目录示例。如果你想从该文件夹中读取内容,你需要提升权限。 -
HKEY_LOCAL_MACHINE键。没有适当的权限级别是无法访问该区域的。 -
服务管理:
启动、停止或配置 Windows 服务是另一个需要管理员级别权限的好例子。同样,安装和卸载这些服务也需要这种信任水平。由于我们与后台进程打交道很多,我们可以想象出需要从其他进程控制这些进程的场景。这意味着需要再次提升权限级别。
-
网络配置:
修改网络设置也可能是你需要提升权限的原因之一。这些任务包括更改 IP 地址、配置网络适配器和调整防火墙规则。
-
系统监控 和诊断:
一些性能计数器或诊断工具需要提升权限。此外,在事件或其他日志中读取系统日志也需要管理员访问权限。
这不是一个详尽的列表;还有其他区域。如果你遇到这些区域之一,你很快就会知道——你的系统将无法工作并崩溃,出现一个漂亮的异常。
模拟管理员身份
如果你的系统执行了前面列表中的任何操作,你可能会想用管理员凭据安装你的系统。这样,你可以确保它总是正常工作。但正如我们之前讨论的,这并不一定是好主意。更好的做法是在需要时才提升到管理员级别。完成后,回到常规的、权限较低的账户。
我们如何做到这一点?首先,我们必须在我们的软件将运行的机器上创建一个具有管理员级别权限的账户。我不会使用机器上找到的通用管理员账户;你最好使用一个专用账户。
在我的机器上,我创建了一个名为MySecureAdmin的账户。我给了它一个非常安全的密码P@ssw0rd!。不,这不是我会在现实生活中使用的密码,但在这个演示中,它足够了。这个账户是本地管理员。最后,我的机器名为DennisMachine。如果你想以管理员身份登录,你需要这些信息。
在你的应用程序中临时充当另一个用户的技巧被称为模拟。让我向你展示如何做到这一点。
我创建了一个控制台应用程序,并添加了一个名为ImpersonationHelper的新类。该类从 Win32 API 中导入了两个方法——LogonUser和CloseHandle。这是它们的签名:
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern bool LogonUser(
string lpszUsername,
string lpszDomain,
string lpszPassword,
int dwLogonType,
int dwLogonProvider,
out SafeAccessTokenHandle phToken);
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
public static extern bool CloseHandle(IntPtr handle);
LogonUser API 位于“advapi32.dll”DLL 中,而CloseHandle API 可以在“kernel32.dll”中找到。
接下来,我声明两个我们稍后会用到的常量:
private const int LOGON32_LOGON_BATCH = 4;
private const int LOGON32_PROVIDER_DEFAULT = 0;
这就是我们开始模拟所需的所有内容。这是执行该操作的代码:
public static void RunAsAdmin(
string userName, string domain,
string password, Action action)
{
var returnValue = LogonUser(
userName, domain,
password, LOGON32_LOGON_BATCH,
LOGON32_PROVIDER_DEFAULT,
out var safeAccessTokenHandle);
if (!returnValue)
{
var ret = Marshal.GetLastWin32Error();
throw new Win32Exception(ret);
}
try
{
WindowsIdentity.RunImpersonated(safeAccessTokenHandle, () => { action(); });
}
finally
{
safeAccessTokenHandle.Dispose();
}
}
让我来解释这里发生了什么。
该方法获取登录 Windows 所需的所有信息——用户名、密码和域。我们还以Action的形式给它提供了在这些凭据下运行的代码。
我们调用LogonUser()并给它用户名、域和密码。然后,我们指定登录类型;我们给它LOGON32_LOGON_BATCH。这种类型用于批处理服务器。批处理服务器代表用户执行代码而不需要他们的干预。相比之下,标准登录会使用LOGON32_LOGON_INTERACTIVE。BATCH选项导致性能更高,这对我们来说非常方便。之后,我们给它登录提供者,并指示它使用默认提供者,通过传递LOGON32_PROVIDER_DEFAULT。
如果一切顺利,我们将在SafeAccessTokenHandle中得到一个指针。如果它不起作用,我们会得到一个错误。
使用这个句柄,我们可以调用WindowsIdentity.RunImpersonated(),它反过来调用我们的操作。
不要忘记在句柄上调用Dispose()!
使用此代码很简单:
var userName = Environment.UserName;
$"Current user: {userName}".Dump();
var adminUserName = "MySecureAdmin";
var domain = "dennismachine";
var password = "P@ssw0rd!";
ImpersonationHelper.RunAsAdmin(
adminUserName, domain, password, () =>
{
var otherUserName = Environment.UserName;
$"Username {otherUserName}".Dump();
});
这段代码使用我们新的类临时以另一个用户的身份登录。但在那之前,它显示了当前的用户名。我在Action中也做了同样的事情,但结果会有所不同。我们现在以新用户登录,这应该也会显示在屏幕上。
没有必要注销——对LogonUser()的调用不会改变该用户的登录状态;它只是获取句柄所必需的。当我们销毁句柄时,模拟也会终止。这意味着我们现在正在使用标准凭据进行操作。运行这个示例并看看会发生什么。
模拟是您工具箱中的另一个好工具,但请谨慎使用。只有在绝对需要的情况下才提高您应用程序的信任级别。哦,我相信我肯定不需要提醒你,在您的应用程序中以明文形式存储用户名和密码是可怕的,尤其是如果它们属于管理员级别的用户。对吧?
许多开发者默认认为他们的代码需要管理员级别的权限。在阅读本节之后,你现在应该更清楚。我们讨论了需要管理员级别权限的情况,但请记住,少即是多,尤其是在权限级别方面!如果您需要在代码中拥有管理员级别的权限,您知道如何在返回到正常级别之前临时处理它。
如何安全地传输网络数据
正如我们所见,在您的机器上保持数据敏感是困难的。但一旦我们离开我们控制的机器的安全避风港,进入网络的荒野,事情就变得更加复杂。
我可能不需要提醒你,永远不要使用使用 HTTP 连接而不是 HTTPS 连接的公共网站。毕竟,“S”代表“Secure”(安全)。这正是我们想要的——我们希望我们的数据被加密,并且我们希望确信我们与之交谈的服务器是安全的,并且属于我们认为它属于的那一方。
这同样适用于我们的代码——如果我们与外部系统通信,我们希望确保我们的数据没有被篡改或拦截。这也适用于其他系统当我们连接到它们时——我们希望给那些用户提供相同的安全感。我们如何实现这一点?答案是简单的——我们与那些 HTTPS 服务器做同样的事情。下一个问题是,我们如何实现这一点?这稍微复杂一些。但别担心——我会一步一步地带你走过这个过程。
HTTPS 是如何工作的
让我问你一个问题。你怎么知道你可以信任你访问的网站?仅仅因为地址栏里写着 HTTPS 吗?但这究竟意味着什么?这能作为保证吗?为了回答这个问题,我们需要看看 HTTPS 实际上是什么意思。
HTTPS 代表 超文本传输协议安全。这是常规 HTTP 流量的一个变体——它增加了安全性。让我们看看流程:
-
在你的浏览器中,你输入一个 URL:
www.microsoft.com。 -
浏览器将域名解析为 IP 地址。
-
客户端使用三次握手(SYN、SYN-ACK 和 ACK)与服务器建立 TCP 连接。
-
客户端向服务器发送一个
"ClientHello"消息。此消息包括以下内容:-
支持的 TLS 版本
-
支持的加密套件
-
支持的压缩方法
-
一个随机生成的值(客户端随机)
-
会话 ID 和扩展(可选)
-
-
服务器随后响应一个
"ServerHello"消息,其中包含以下内容:-
选择的 TLS 版本
-
选择的加密套件
-
选择的压缩方法
-
一个随机生成的值(服务器随机)
-
会话 ID(如果支持且需要)
-
-
服务器从受信任的证书机构发送其数字证书,包括其公钥和数字签名。
-
然后,如果需要,服务器可能会发送一个
"ServerKeyExchange"消息。 -
之后,服务器请求客户端证书以进行相互认证。
-
最后,服务器发送一个
"ServerHelloDone"消息,表示握手结束。 -
然后,客户端可以选择发送自己的证书(如果请求)。
-
客户端发送一个
"ClientKeyExchange"消息。内容取决于选择的算法。例如,如果选择 RSA,客户端将使用服务器的公钥加密预主密钥并发送给服务器。 -
客户端发送一个
"CertificateVerify"消息来证明它拥有客户端证书。这涉及到使用客户端的私钥对握手消息的哈希进行签名。 -
双方随后使用预主密钥和之前交换的随机值生成会话密钥(对称密钥)以进行加密和认证。
-
然后,客户端向服务器发送一个
"ChangeCipherSpec"消息,通知服务器从现在开始,所有消息都将使用协商的密钥和算法进行加密。 -
服务器还发送一个
"ChangeCipherSpec"消息。 -
客户端发送一个
"Finished"消息,这是一个使用会话密钥加密的所有握手消息的哈希值。 -
服务器以相同的格式响应其
"Finished"密钥。
从现在起,客户端和服务器可以使用密钥和算法来加密和解密数据流。
如果你认为这听起来很复杂,你是对的。好消息是,我们不必担心这个问题。BCL 中处理 HTTP 的所有类都为我们处理了这些问题。你所要做的就是连接到安全的服务器,指定你想使用 SSL,然后就可以开始了。
证书和证书颁发机构
前面的步骤概述了客户端和服务器如何安全地交换密钥。然而,一个关键问题仍然存在——他们如何知道可以相互信任?
这个问题的答案在于证书的使用。证书是一个包含有关证书所有者信息的数字文档。它包括以下信息:
-
主题:证书代表的实体(例如,网站的域名)
-
颁发者:谁颁发的证书
-
公钥:实体的公钥
-
有效期:证书有效的日期范围
-
序列号:证书的唯一标识符
-
签名:颁发者的数字签名,验证证书的真实性,并确保未被篡改
如果你从一个网站获取证书,你可以使用它来验证你连接到的网站确实是它所声称的那个。如果证书上的信息与预期不符,你最好不要使用该网站。
但你如何确保证书是有效的?这个问题引导我们来到 SSL 基础设施的最后一部分——证书颁发机构。
证书必须从第三方获取。这些公司出售证书,但只有在他们验证请求证书的人确实是他们所声称的人之后才会这样做。我们称这些公司为证书颁发机构(CAs)。这些机构会定期接受审计,以确保它们可以信赖。这启动了一个整个链条——CA 有自己的证书。然而,那个证书是根证书;它是隐含受信任的。没有组织保证 CA 的证书是有效的。但如果我们信任那个根证书,我们可以假设所有使用该根证书签发的证书也都是安全的。然后,我们可以使用二级证书来签发另一个证书。我们可以构建一个整个受信任的证书树,所有这些证书都可以追溯到颁发原始证书的 CA。
Windows 跟踪所有受信任的根证书,并将它们存储在本地计算机上。这样,软件可以将哈希值与从 HTTPS 服务器接收到的数据进行比较,确保证书的安全性。
要查看这些根证书,请在您的机器上运行mmc.exe命令。然后,按CTRL + M添加证书插件。展开左侧的树形结构以查看所有受信任的根证书权威机构。这是我机器上的样子:

图 12.1:Windows 中的根证书
您的列表无疑会与我的不同,但这些都是受信任的根证书。Windows 会定期更新这个列表,以确保其仍然有效。
您必须从这些 CA 机构之一获得证书才能设置 HTTPS 服务器。它们的过程略有不同,所以我建议您调查其中一些,看看它们是否适合您。您使用哪个 CA 都无关紧要;所有证书都适合您的目的。有些比其他更快,有些比其他更便宜。只需选择您认为最适合您的选项即可。
注意免费证书!
我会非常明确地说明这一点——在撰写本文时,没有免费的证书可以获得。一些 CA 机构发行了免费证书,但这已经不再发生了。验证的需求显著增加;CA 需要比以往任何时候都要更加彻底,以对抗网络犯罪。这需要花钱。如果您看到提供免费证书的 CA,请不要上当。记住,如果某件事听起来好得令人难以置信,那么它可能就是真的。一些 CA 组织提供免费证书,但它们有其他要求。您必须在他们的管道上构建您的软件,或者您必须与他们一起托管。最终,您仍然需要为此付费。
因此,现在我们知道了证书是什么以及如何获取一个。但让我们说实话——如果您想玩证书或者还在开发中,您可能还没有准备好购买证书。如果这是您的情况,那么我有好消息。有一个免费的选择——您可以自己创建证书!
创建开发证书
是的,您可以创建自己的证书。但这只是为了实验或开发目的。您不能在生产系统中使用它;验证将失败,因为您的证书没有得到 CA 的背书。
创建证书的工具是随 VS 安装的 SDK 的一部分。让我们来创建一个证书吧!
在开发命令提示符或 PowerShell 终端中,输入以下命令:
MakeCert -r -pe -ss PrivateCertStore -n "CN=localhost" -sv testcer.pvk testcer.cer
MakeCert是 SDK 的一部分,是创建证书的工具。有很多选项,但我们不需要大多数它们。我已经为您提供了我们所需的最小选项。让我们通过查看参数来调查我们做了什么:
| 参数 | 描述 |
|---|---|
-r |
这意味着证书是自签名的,没有由 CA 签名。 |
-pe |
这将私钥标记为可导出。私钥和公钥都是证书的一部分,因此如果您想要私钥的副本,则需要此选项。 |
-ss PrivateCertStore |
这指定了将放置生成的证书的证书存储。在我们的情况下,我们使用PrivateCertStore,这是我们在早期查看的管理控制台中的条目之一。 |
-n "CN=localhost" |
这是localhost(CN表示通用名称),这样客户端就知道这个证书属于哪个域名。 |
-sv testcer.pvk |
我们将私钥标记为可导出;此选项执行导出。私钥存储在testcer.pvk文件中。 |
testcer.cer |
证书的文件名 |
表 12.2:MakeCert 的参数
如果你运行MakeCert命令,你将被要求输入密码。请确保你记住它们并将它们存储在安全的地方!
这个命令会生成两个文件——testcer.cer(证书)和testcer.pvk(私钥)。请确保将这些文件视为机密文件;它们包含你的私钥。
证书现在可以使用了,但并非适用于我们想要使用的所有情况。稍后,我们将使用证书来加密数据流,但这需要不同的格式。这些工具需要pfx格式。幸运的是,将.cer文件转换为.pfx文件足够简单。只需输入以下命令:
pvk2pfx -pvk .\testcer.pvk -spc .\testcer.cer -pfx testcer.pfx -po "password"
pvk2pfx工具将导出的私钥和证书转换为.pfx文件。参数不言自明。
我们创建的证书现在存储在证书存储的PrivateCertStore部分。但我们也需要将新生成的.pfx文件存储在证书存储中,以供以后使用。为此,请输入以下命令:
certutil -importpfx testcer.pfx
在这种情况下,certutil命令调用了另一个有用的工具,将新的testcer.pfx文件存储在正确的位置。
就这么简单。我们现在有了自己的证书,所以让我们保护一些网络流量!
保护 TCP 流
如果你有一个 Web 服务器,例如 IIS,你可以在那里导入.pfx文件。这样,你可以在本地网络上使用 HTTPS。再次强调,这不是 SSL;其他客户端不会接受这个自签名证书。这仅用于开发。
然而,我现在对设置 HTTPS 服务器不感兴趣。我更关心我们之前讨论的其他网络通信类型。例如,我们如何保护简单的直接 TCP 通信?如果我们想使用套接字,我们该如何保护?答案是使用 SSL,就像我们在 HTTPS 中看到的那样。让我们构建一些安全代码!
我创建了两个控制台应用程序。一个是等待传入 TCP 连接的服务器;另一个是连接到该服务器的客户端。
让我们先看看服务器代码。我创建了一个名为SecureServer的新类。这个类有一个构造函数,用于设置服务器所需的信息。它看起来是这样的:
public SecureServer(int port,
string certificatePath,
string certificatePassword)
{
_port = port;
_serverCertificate = new X509Certificate2(
certificatePath,
certificatePassword);
}
我们传递刚刚创建的证书的文件路径和监听 TCP 套接字的端口的密码(我告诉你要记下来,对吧?)。我们将端口号存储在一个局部变量中,并使用其他两个变量来创建 X509Certificate2 类的实例。
接下来是启动服务器的那个方法。我们之前已经调查过这个方法了(在 第八章,网络导航那章),所以这里不应该有任何惊喜。下面是它:
public async Task StartAsync()
{
"Server is starting...".Dump();
var listener = new TcpListener(IPAddress.Any, _port);
listener.Start();
$"Server is listening on port {_port}...".Dump();
while (true)
{
var clientSocket = await listener.AcceptSocketAsync();
_ = HandleClientConnection(clientSocket);
}
}
我们创建了一个 TcpListener 的实例,告诉它使用机器上的任何 IP 地址,并给它正确的端口。然后,我们调用 Start() 来接受传入的连接。在一个永无止境的循环中,我们等待客户端连接。如果发生这种情况,我们通过调用 AcceptSocketAsync() 接受连接,并将连接的处理传递给名为 HandleClientConnection() 的方法。让我们看看下一个方法。
方法的前半部分看起来是这样的:
private async Task HandleClientConnection(Socket clientSocket)
{
try
{
await using var sslStream =
new SslStream(
new NetworkStream(clientSocket),
false);
await sslStream.AuthenticateAsServerAsync(
_serverCertificate,
false,
SslProtocols.Tls12,
true);
$"Client connected: {clientSocket.RemoteEndPoint}".Dump();
我们不是使用普通的流,而是使用一个称为 SslStream 的专用流。这个流接受 NetworkStream 和一个参数,表示我们完成时是否应该保持流打开(我们不想这样,所以我们给它一个 False)。
然后,我们在那个 SslStream 上调用 AuthenticateAsServerAsync(),给它证书,告诉它我们不需要客户端证书,也告诉它我们想使用 TLS 版本 1.2,最后,通知该方法我们想检查证书吊销(因此是 True)。这一行代码确保服务器完成所有必要的步骤来设置安全连接。
方法的其余部分很简单——我们读取传入的数据并显示。这是该方法的其余部分:
var buffer = new byte[1024];
var bytesRead =
await sslStream.ReadAsync(
buffer,
0,
buffer.Length);
var receivedString =
Encoding.UTF8.GetString(
buffer,
0,
bytesRead);
$"Received from client: {receivedString}".Dump();
}
catch (Exception ex)
{
ex.Message.Dump();
}
}
就这些!嗯,几乎是这样——我们还需要使用这个方法。但那甚至更简单。在 Main() 方法中,使用以下代码:
var certificatePath = @"d:\Certificate\testcer.pfx";
var certificatePassword = "password";
var server = new SecureServer(
8081,
certificatePath,
certificatePassword);
await server.StartAsync();
所有这些,我们就有了一个工作且安全的套接字服务器!
接下来是客户端!对于客户端,我做了类似的事情。我添加了一个名为 SecureClient 的新类,其构造函数如下:
public SecureClient(
string server,
int port)
{
_server = server;
_port = port;
}
这个构造函数接受两个参数——服务器的名称和它想要连接的端口。
接下来,我们定义一个名为 ConnectAsync() 的方法,允许客户端连接:
public async Task ConnectAsync()
{
using var clientSocket = new TcpClient(_server, _port);
await using var networkStream = clientSocket.GetStream();
await using var sslStream =
new SslStream(
networkStream,
false,
ValidateServerCertificate);
try
{
await sslStream.AuthenticateAsClientAsync(_server);
"SSL authentication successful".Dump();
var message = $"Hello, server! {DateTime.Now.TimeOfDay}";
var messageBytes = Encoding.UTF8.GetBytes(message);
await sslStream.WriteAsync(messageBytes, 0, messageBytes.Length);
}
catch (Exception ex)
{
ex.Message.Dump(ConsoleColor.Red);
}
}
这个方法从熟悉的代码开始——我们创建了一个 TcpClient 的实例,并给它服务器和端口。然后,我们从那个 TcpClient 打开 NetworkStream。但接下来事情变得更有趣——我们创建了一个新的 SslStream 类的实例,给它 NetworkStream,同样的 False 表示我们完成时不想保持流打开,以及一个名为 ValidateServerCertificate 的回调方法。之后,我们调用 AuthenticateAsClientAsync() 来确保客户端和服务器交换消息,如之前所述。
这个方法的其余部分没有什么特别之处——我们只是将字节写入流。
让我们来看看 ValidateServerCertificate() 回调方法:
private static bool ValidateServerCertificate(
object sender,
X509Certificate certificate, X509Chain chain,
SslPolicyErrors sslPolicyErrors)
{
if (sslPolicyErrors == SslPolicyErrors.None)
{
"Server certificate is valid".Dump();
return true;
}
"Server certificate is invalid".Dump(ConsoleColor.Red);
return false;
}
当我们创建 SslStream 时会调用此方法,它是验证服务器证书的一部分。该方法本身很简单——我们只是检查 SslPolicyErrors 枚举中是否有任何错误。如果有,我们返回 false。这会被 SslStream 类捕获,它将引发异常。
开发者的技巧——简化你的开发
如果你正在开发这样的解决方案但没有有效的证书,你可以使用一个快速黑客技巧。将验证方法更改为始终返回 True。这样,你的客户端将接受所有类型的证书,无论其有效性如何。但请记住,不要在生产代码中使用这种技术!
使用这个类很简单。这是代码:
var secureClient = new SecureClient("localhost", 8081);
await secureClient.ConnectAsync();
只提醒一下——这只是为了开发目的。代码本身适用于任何场景,但我们创建的证书不行。我们亲自签名,所以没有任何真正的客户端应该接受它。接下来,我们指定服务器的名称是 "localhost"。显然,这只能在你的机器上工作,不能在网络中工作。当然,你可以在创建证书时更改它。
由此,你得到了一个使用安全通道工作的 TCP 客户端。你让黑客窃听和监听你的通信变得非常困难,甚至不可能!
下一步
我必须对你坦诚。我们只是简要地触及了安全这个主题。关于这个主题有成百上千,甚至可能成千上万的书籍。但我提供给你的信息应该能帮助你进入正确的思维模式。记住,一个系统只有在其最薄弱的环节上才是安全的。而且,安全是你从一开始就应该考虑的事情,而不是事后才添加的。
最后一个警告——不要试图重新发明轮子,提出你自己的算法。你的解决方案永远不如成百上千的加密和安全专家团队所能提出的那么好。相信他们能做好他们的工作,这样你就可以专注于你的工作了。
话虽如此,我们确实覆盖了很多内容。我们讨论了以下内容:
-
现代应用程序中安全的必要性
-
数据如何在内存中表示以及如何保护它
-
如何在 Azure Key Vault 中处理密钥,以及在简单事物如环境变量中处理密钥
-
如何处理适当的权限级别
-
如何确保你的网络通信安全
然而,我们只是简要提及但未详细说明的一件事——如何安全地从我们的开发机器将凭据传递到生产环境。我们如何确保在部署我们的解决方案时环境变量被设置?这些问题是我们可以使用的一些部署策略的一部分,恰好也是下一章的主题!
第十三章:部署戏剧篇
部署 和分发
让我坦白一下:我喜欢编写代码。从模糊的想法开始,然后编写第一行代码,接着发现问题和调试代码,这个过程让我感到兴奋。从无到有创造东西,并亲眼看到它在我眼前变得生动,这有一种神奇的感觉。
但总有那么一刻,软件变得“足够好”,需要进入生产环境。毕竟,我们编写软件是有目的的:它需要被使用。这通常意味着将软件从你的开发机器转移到生产环境中。
在这个过程中有许多挑战。但不用担心:我们将一一解决它们!我们将讨论以下主题:
-
部署是什么意思?
-
如何使用 Visual Studio 中的发布向导?
-
CI/CD 是什么,我如何在 Azure DevOps 或 GitHub 中使用它?
-
我如何构建安装程序?
-
我如何使用 Docker 进行部署?
所以,如果你准备好让世界看到你劳动的成果,但又不确定如何将其推广出去,那么这一章就是为你准备的。
技术要求
你可以在我们的仓库中找到本章的所有代码:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter13。
如果你想要跟随 Azure 中的 CI/CD 示例,你需要一个 Azure 订阅。你可以在以下链接注册 Azure 的免费试用:azure.microsoft.com/en-us/free/。
要尝试 GitHub Actions,你需要注册一个 GitHub 账户。你可以在以下链接免费注册:github.com/signup。
如果你想在我们讨论设置项目时跟随,你必须安装Visual Studio 扩展 Microsoft Visual Studio Installer Projects 2022。你可以在扩展菜单项中选择管理扩展来找到它。从那里,在在线选项卡中搜索该扩展。
使用这个工具,你可以跟随步骤并构建自己的安装程序。
如果你想要使用 Docker 示例,请安装 Docker Desktop。你可以在以下链接找到它:www.docker.com/products/docker-desktop。
这里提到的所有软件都是免费的或提供免费试用。
从开发到生产
在开发应用程序的过程中,总会有那么一刻,你决定是时候让其他人尝试你劳动的成果了。这意味着将你的应用程序从你的开发机器转移到另一个环境。这可能是一个开发者的机器或是一个生产系统。
根据您系统的复杂性,将位移动开可能涉及从简单的文件复制到构建复杂的安装程序应用程序的任何事情。您还必须考虑从他们的系统中删除您的应用程序的方法以及更新或升级您应用程序的方法。所有这些任务都汇集在术语 部署 之下。
部署应尽可能无缝。用户应该能够轻松地将您的应用程序准备好使用。这意味着所有艰苦的工作都由我们来承担。
创建部署场景需要考虑以下方面:
-
复制您的二进制文件
-
复制您的系统依赖的二进制文件
-
复制附加文件
-
设置用户权限
-
复制设置并修改它们
-
创建和复制密钥
-
修改系统设置,如路径
-
在宿主环境中注册您的应用程序
卸载您的应用程序意味着逆转此过程:在理想世界中,卸载不会在宿主机器上留下您的应用程序和相关文件的痕迹。
升级和更新是这些场景的混合:部署新代码、更改设置以及从最新版本中删除不再需要的项目。
如果您只有一个简单的独立控制台应用程序,部署将变得非常简单:只需复制所需的文件。假设您正在部署一个复杂的系统,例如后台工作者;这需要配置设置以连接到外部系统。在这种情况下,您有更多的工作要做。但有好消息:对于这些和其他场景,都有可遵循的策略。这正是本章剩余部分的内容。因此,进行最终构建您的应用程序,进行快速本地测试,然后让我们部署我们的工作!
发布和文件复制
部署应用程序最简单的方法是使用 Visual Studio 的发布机制。假设我有一个简单的控制台应用程序。我没有在运行生产环境时需要更改的任何配置设置。因此,我只需复制我所拥有的。
假设我们有一个简单的控制台应用程序。您已经测试了它,并准备交付。有两种选择:使用 Visual Studio 或使用 CLI。
使用 Visual Studio 发布
在 Visual Studio 中,在 解决方案资源管理器 区域中,右键单击您的项目并选择 发布。您将看到以下对话框:

图 13.1:通过 Visual Studio 发布
有几个选项可供选择:
-
Azure:这意味着将您的系统部署到 Azure,以便在那里运行。
-
ClickOnce:ClickOnce 是构建简单安装程序的技术。更新和卸载是机制的一部分。然而,ClickOnce 是为用户启动的 Windows 应用程序设计的。因此,这不是我们系统程序员的解决方案。因此,我将在此处不涉及 ClickOnce。
-
Docker 容器注册库:这是一种打包和部署系统的绝佳方式。我们将在稍后讨论这一点。
-
文件夹:这是发布的最简单方式,因为它只是将所有必要的文件复制到一个文件夹中。
-
导入配置文件:如果您已经定义了部署方法,您可以通过导入它们来使用这些设置。
在此情况下,我们将选择文件夹。这样做之后,您将得到一个新的对话框,询问您是否希望使用ClickOnce进行文件夹部署,或者是否希望部署到文件系统。选择文件夹以选择后者。此时,您可以输入您想要发布的路径。目前,请将其保留为默认设置。点击完成。
虽然您点击了FolderProfile.pubxml,并且您可以在项目的解决方案资源管理器区域下的属性 > 发布配置文件中找到它。
Visual Studio 将打开配置文件并显示其外观。从这里,我们可以点击大的发布按钮;然而,我们可能想在这样做之前调整配置文件。点击更多操作并选择编辑。这将导致以下对话框:

图 13.2:配置设置对话框
您可以在这里大量调整配置文件。让我们看看选项:
-
配置:您可以选择在项目中定义的任何配置。默认情况下,这些是调试和发布配置。我建议您使用发布进行部署。
-
目标框架:在这里,您可以为您的应用程序选择任何兼容和已安装的框架。只需将其设置为构建系统时使用的设置即可。
-
部署模式:在这里,您可以选择框架依赖和自包含之间的选项。如果您选择框架依赖,应用程序将假定目标机器上已安装.NET 运行时。然而,如果您选择自包含,所有需要的程序集都将包含在发布中。由于它包含从.NET 运行时所需的所有内容,因此您的包将变得更大。但是,它不依赖于其他人安装.NET 运行时。
-
目标运行时:这是您决定目标架构的地方。如果您知道那台机器的架构是什么,您可以从下拉菜单中选择它。这将导致代码更加优化,但限制了您可以使用它的地方。例如,如果您决定使用 Win-X64,您就不能将代码部署到 Linux 机器上。如果您不想做出这个决定,请将其设置为便携式。
-
目标位置:这是文件将被复制到的位置。
假设您决定选择自包含选项。在这种情况下,您将获得三个额外的选择:生成单个文件、启用 ReadyToRun 编译和删除未使用代码。
第一个选项是显而易见的:你得到一个大的文件,而不是几十个小文件。ReadyToRun 是一种 提前编译(AOT)的形式。这意味着代码是预编译的,因此启动更快。它不是真正的 AOT 编译:生成的文件包含编译后的代码和 IL。尽管如此,它节省了启动时间。删除未使用代码选项从运行时中删除你不需要的所有代码。选择此选项会使最终包的大小大大减小。
保存你的更改,然后点击 保存。之后,点击 发布。当 Visual Studio 完成后,转到你选择的文件夹,检查发生了什么(提示:你可以在 发布配置文件 对话框中点击 目标位置 值来打开 资源管理器 窗口并直接转到该位置)。
剩下的就是将生成的文件复制到目标机器上。然后,你可以在该机器上运行它,看看一切是否正常工作,你准备好了。
恭喜:你刚刚部署了你的应用程序!
使用 CLI 发布
Visual Studio 向导在帮助你构建配置方面非常出色。尽管如此,如果你已经知道自己在做什么或者想要将发布作为管道的一部分,你可以使用 CLI 来完成同样的操作。
基本命令很简单——在你有 .csproj 文件所在的目录中,只需运行以下命令:
dotnet publish
此命令使用对话框中的所有默认设置并将它们用于发布你的应用程序。当然,你可以更改 publish 的行为:你只需要提供正确的参数。以下表格显示了最常见的参数及其可能值。大多数参数有两个变体——一个完整的参数名称(通常以两个连字符开头)和一个缩写(通常以一个连字符开头):
| 参数 | 描述 | 可能值 |
|---|---|---|
-o/--output |
放置已发布工件的目标目录 | 你想要放置已发布应用程序的目录 |
--sc/--self-contained |
将运行时包含在你的应用程序中 | - |
-f/--framework |
你想要部署到的目标框架 | net6.0 net7.0 net8.0 |
-r/--runtime |
要发布的目标运行时 | win-x64 linux-x64 linux-arm |
-c/--configuration |
构建配置 | Release Debug |
表 13.1:dotnet publish 选项
如果你决定构建一个自包含的部署,你可以添加三个额外的参数:
| 参数 | 描述 |
|---|---|
-p:PublishSingleFile=true |
创建单个文件 |
-p:PublishReadyToRun=true |
编译为 Ready To Run AOT 二进制文件 |
-p:PublishTrimmed=true |
从二进制文件中删除所有不必要的代码 |
表 13.2:自包含额外选项
你可以通过将它们设置为 False 来指定你不想使用这些选项,但我建议你省略该参数。
因此,要将您的控制台应用程序发布到特定文件夹,创建一个包含单个可执行文件的独立部署,并删除所有不必要的代码以适应在 net.80 上运行的 win-x64 架构。为此,请执行以下代码(所有内容都在一行上):
dotnet publish
-o d:\temp\publish
--self-contained
-f net8.0
-r win-x64
-c Release
-p:PublishSingleFile=true
-p:PublishReadyToRun=true
-p:PublishTrimmed=true
现在,如果您转到 d:\temp\publish 文件夹,您可以取那里的文件,将其复制到您的生产机器上,并运行它。在这个时候,您可以坐下来,知道您辛勤的工作终于被使用了。
使用 Azure DevOps 和 GitHub
如果您的代码打算在云中使用,例如在 Azure 或 AWS 上,您可以使用 Azure DevOps 和 GitHub。您选择哪一个取决于您当前源代码的位置。DevOps 和 GitHub 都允许进行 持续集成和持续部署(CI/CD)场景。
CI/CD
CI/CD 的想法是,当您更改源代码时,系统会注意到这一点并构建您的软件。然后,它可以选择性地运行测试(在我看来,这不是可选的,而是强制性的)。之后,它会自动将新的二进制文件部署到生产环境。这种工作方式意味着您可以对系统进行许多小的、增量更新,并尽早获得关于您所做工作的反馈。如果这符合您的用例,这是一个伟大的工具!
让我们先看看 Azure DevOps。
部署到 Azure
假设您已经设置了一个 Azure DevOps 项目,定义了工作流程,并且有一个用于托管代码的仓库。
如果您已经将 Visual Studio 连接到该项目,您可以将它连接到该项目。在我的情况下,我创建了一个简单的 Function App。Function App 是在 Azure 中运行的服务。在这种情况下,我决定使用一个简单的基于 HTTP 的触发器。换句话说,该函数响应 REST API 调用并返回一个包含友好问候语的字符串。本章不是关于编写 Azure Functions,而是关于部署,所以我不会深入探讨代码的工作原理。目前,它是一个您可以通过名为 name 的参数调用的 REST API;它返回包含该名称的友好问候。就是这样。
但为了使事情更有趣,我给我的程序命名为 MyFileConverterFunctionApp。相信我:它并不做任何有趣的事情。
如果您已经在本地运行了代码,那么现在是时候为部署准备您的系统了。我们需要执行两个步骤。
-
创建发布配置文件
-
将系统发布到 Azure
让我们开始吧。
为 Azure DevOps 构建发布配置文件
在向您展示如何将应用程序部署到 Azure 之前,让我们回顾一下如果您想跟随操作所需的先决条件。首先,您需要一个要部署的项目。但除了这个明显的先决条件之外,您还需要以下这些:
-
一个 Azure 账户。
-
一个资源组(我的叫做
SystemsProgrammingRg)。 -
一个密钥保管库来存储机密。
-
一个存储账户。我们稍后需要这个账户进行部署。
一旦您有了这些,您就可以开始部署过程了。
在 Visual Studio 中,右键单击您的项目名称,然后选择发布。您将被带到以下屏幕:

图 13.3:默认发布对话框
是的,这是我们之前看到的同一个对话框。然而,这次,选择Azure作为您的目标。
以下对话框将询问您想要部署哪种类型的服务。我选择了Azure Function App (Windows)。您可以选择 Linux 部署。现在不用担心容器选项;我们将在本章后面讨论 Docker 和容器。
然后,我们需要告诉 Visual Studio 我们的应用程序的最终位置。很可能您没有可以使用的函数应用(毕竟,这并不是先决条件的一部分),因此您现在可以创建一个。您将看到一个对话框,询问您关于您的环境和首选项。我的看起来像这样:

图 13.4:在 Visual Studio 中创建新的函数应用
我已经将我的 Azure 账户详情隐藏起来,因为我希望您使用自己的账户。您需要选择最适合您的选项。此对话框也是您必须指定我告诉您创建的存储账户的地方(在我的情况下,它是dvstorageaccountsp)。我还决定添加应用程序洞察。使用应用程序洞察可以帮助我在需要时监控和调试我的应用程序。
当您点击创建时,系统将构建您的环境。这需要一些时间,但完成之后,我们可以转到下一个屏幕。下一个屏幕提供了给定资源组中所有应用程序服务和所有部署槽位的概览。由于我们还没有部署,这个列表是空的。点击下一步:

图 13.5:选择作为发布机制要生成的内容
我们可以在这里选择是否使用发布配置文件或 GitHub Actions。我们很快就会看到 GitHub Actions,所以现在让我们选择发布。Visual Studio 将为我们生成发布配置文件。
完成这些后,我们会看到一个概览,包括一个漂亮的大、吸引人的发布按钮:

图 13.6:发布配置概览
让我们点击那个发布按钮!
再次强调,这需要一点时间,但您的代码发布后,您将获得一个超链接,允许您访问资源。您可以点击它,但不会有什么激动人心的东西。它只是一个网页,说明您的函数应用正在运行。
要查看发生了什么,请转到 Azure 门户,找到您的资源组,并定位到我们创建的函数应用。在那里,您可以直接在 Azure 网页门户中测试函数。或者更好的是,如果您已安装 Visual Studio Code,创建一个名为test.http的新文件,并添加以下代码:
GET https://myfileconverterfunctionapp.azurewebsites.net/api/Function1
Content-Type: application/json
{
"name": "dennis"
}
###
用您的 URL 替换我的 URL,并点击第一行顶部的发送请求链接。这将调用服务器。您会得到一些结果,应该看起来类似于以下内容:
HTTP/1.1 200 OK
Connection: close
Content-Type: text/plain; charset=utf-8
Date: Mon, 17 Jun 2024 07:07:01 GMT
Content-Encoding: gzip
Transfer-Encoding: chunked
Vary: Accept-Encoding
This HTTP triggered function executed successfully.
您的数据可能不同,但重要的是我们得到了HTTP/1.1 200 OK的结果。这表明我们的应用程序工作正常!
在 Azure DevOps 中启用持续集成
直接从您的开发环境将代码推送到 Azure 很方便。一旦您设置了发布配置文件,右键单击您的程序并点击发布,将您的更改移动到 Azure。
虽然有更好的方法来做这件事:您可以通过启用 CI/CD,使得任何您做出的更改都会自动部署。
分支和 CI/CD
在我所有的示例中,我使用单个分支:main。我将机器上的main分支更改直接推送到在线源仓库,并让系统从那里构建。在现实世界的场景中,这是一个糟糕的想法。您应该选择一种分支策略,以便在日常工作与部署之间有良好的分离。您需要像拉取请求和合并策略这样的东西来保持工作的质量。请不要像我这里做的那样,只有一个分支。
那么,我们如何实现这个魔法?我们如何让我们的更改“自动魔法般”地出现在我们的生产环境中?答案是使用管道。
在您的 Azure DevOps 环境中,转到项目。您会在左侧侧边栏中看到一个管道标签。点击它。您会看到一个页面,说明您还没有任何管道。让我们改变这一点。点击创建管道按钮。您将被带到以下屏幕:

图 13.7:创建 Azure DevOps 管道
在此情况下,选择Azure Repos Git。点击它后,会弹出一个对话框要求选择项目。选择包含您想要自动部署的代码的仓库。
一旦您这样做,您就完成了。是的——它就是这么简单。
您现在可以手动运行管道以查看一切是否正常工作。构建您的解决方案需要几分钟,但完成后,您会看到如下内容:

图 13.8:成功的管道运行
要测试您的代码是否已发布,请从 Visual Studio Code(或您用于测试 REST 调用的任何工具)重新运行测试。
现在,是时候做一些酷的事情了。
在 Visual Studio 中,对代码进行更改。您可以做一些简单的事情,比如更改函数返回的文本。
保存您的更改并将它们推送到您的仓库。一旦您这样做,转到 Azure DevOps 并找到管道——您会看到它已经开始运行了!只需等待几分钟,直到它完成并重新运行您的测试。您应该会看到您的结果已经传播到生产环境中。
这就是我所说的简单部署!
如果你有所怀疑,管道会从你的发布配置文件中获取所有必要的信息。记得我之前说过,如果你先手动发布,这样做会更简单吗?现在你知道为什么了!你应该查看生成的 YAML 文件,看看事情是如何工作的。如果你准备好将你的部署技能提升到下一个水平,我建议你在网上搜索。关于这个主题已经写了几十本书,所以我相信你可以找到你想要的东西。
启用 GitHub 的 CI
Azure DevOps 是与同一组织内的人协作的好方法。然而,如果你想与不同组织的人合作,GitHub 可能是一个更好的选择。GitHub 更倾向于开放协作,如开源项目。但这并不意味着你不能像在 Azure DevOps 中那样拥有相同的持续集成:你可以通过 GitHub Actions 实现相同的事情。
我们不是在 Azure DevOps 中保留源代码,而是在 GitHub 上托管它。最初,GitHub 不过是一堆仓库,但自那时起它们已经扩展了很多。他们添加的更令人惊讶的事情之一是 GitHub 动作。
行动相当于我们刚才查看的管道。语法不同,并且它们支持比 Azure 默认管道更多的环境,但理念保持一致。
GitHub 提供向导来帮助你编写动作,但有一个简单的方法可以快速启动我们的第一个动作。
在 Visual Studio 中创建一个新的 Azure Function 项目,但这次将其存储在你的 GitHub 账户中。一旦完成,就在本地测试并发布到 Azure。我总是这样做以确保它工作。
一旦发布完成,使用 Visual Studio Code 或你喜欢的测试工具为你的代码创建一个测试。
现在,让我们从 GitHub 设置 CI/CD!
在 Azure 门户中,导航到你的函数。然后,在左侧边栏中,选择部署中心。然后,在源下,选择GitHub。完成这些后,你可以输入你的详细信息。你必须登录到 GitHub 并选择正确的组织、仓库和你要发布的源分支。
你还需要指定你希望如何进行身份验证。GitHub 动作需要登录到 Azure 以部署你的代码,因此向导会为你创建一个账户。使用用户分配的标识来实现这一点。标识将自动创建。一旦发生这种情况,点击保存。
就这样——你刚刚设置了你的第一个 GitHub 动作!如果你不相信我,去你的 GitHub 账户,选择你的项目,然后转到动作。你应该在那里看到动作,并且它应该显示它已经运行过!

图 13.9:第一个动作
要检查它是否按预期工作,您可以更改代码,将更改提交到存储库,并查看操作是否生效。您可以通过点击运行来查看详细信息。完成后,它将更新您 Azure 环境中的代码。测试它并查看更改!
当然,我们的大部分代码在 Azure 上无法运行。作为系统程序员,我们经常必须部署到本地硬件。在这种情况下,这些技术将不起作用。我们必须找到更好的方法。而且有:使用安装程序!
使用 Visual Studio 构建安装程序
安装程序并不是什么新鲜事物——在很长的一段时间里,它是将应用程序安装到您系统上的唯一方式。安装程序主要用于在用户的机器上安装基于 Windows 的应用程序。由于这已经过时,因此不再经常使用。但如果您希望安装后台工作进程并需要进行一些自定义工作,安装程序是一个非常好且简单的方法来完成这项工作。
安装程序和 Wix
标准的 Microsoft Installer 项目工作得很好。尽管如此,许多开发者已经转向使用 Wix。Wix 是用于构建安装程序的第三方解决方案。它非常灵活,因此,开始使用它相当困难。有许多书籍、文章和教程可以帮助您入门。但就我们而言,我们不需要这种复杂性。标准的安装程序对大多数系统程序员来说就足够了。但如果您想要更多控制权,我强烈建议您深入研究 Wix,看看它能为您做什么。
假设您在 Visual Studio 中安装了Microsoft Visual Studio Installer Projects 2022扩展。在这种情况下,您可以将安装程序项目添加到您的解决方案中。
让我们这样做!
构建简单的安装程序
在新项目对话框中,选择设置向导模板。这将启动一个典型的“下一步,下一步,完成”类型的向导。需要遵循五个步骤。
第一个看起来像这样:

图 13.10:设置向导(第 1 页,共 5 页)
其余的屏幕都是自我解释的。第一个真正的问题是询问您是否想为 Windows 应用程序构建设置程序、为 Web 应用程序构建设置程序,或者您是否想创建一个可分发的包。我们想要第一个选项:为 Windows 应用程序的设置,因为后台工作系统仍然是这个。
然后,向导将询问您想安装什么。从下拉菜单中选择从…发布项目选项。这些都是所有可执行文件和依赖项,因此我们想要这些。
之后,您将被询问是否还有其他文件要包含。没有,所以只需点击下一步。最后一步是之前步骤的总结。查看此页面并点击完成。
就这样!
在我们能够测试它之前,我们需要设置一些属性。在解决方案资源管理器区域中选择你的项目,并查看属性窗口。在这里,你可以填写你认为重要的所有详细信息。我的看起来像这样:

图 13.11:设置项目属性
你至少应该更改C:\Program Files (x86)文件夹。如果你不想要这个,将TargetPlatform属性从默认文件夹C:\Program Files更改。当然,你应该只在你的应用程序确实是 64 位(x64选项)而不是较旧的 32 位(x86)格式时这样做。
为什么 64 位是 X64,而 32 位是 X86?
有时候,人们会被这些名称搞混。人们似乎明白 X64 代表 64 位,但为什么 32 位被称为 X86 呢?答案相当简单:X64 确实只是 64 位,但 X86 指的是很久以前的原始英特尔 8086 处理器,当时机器运行的是 16 位或最多 32 位的软件。这只是一个你现在知道并且可以向朋友吹嘘的奇怪事情!
是时候测试一切了!
右键单击你的安装项目,选择构建,看看是否一切构建成功。如果成功了,你可以再次右键单击项目,但这次选择安装。如果一切顺利,你的系统将被安装!你可以导航到安装过程中选择的文件夹,并看到那里的文件。
为了清理,你只需要在 Visual Studio 中点击卸载。
编写自定义操作
这很好,但还不够。特别是对于我们系统程序员来说,在安装期间或之后还需要做几件事情。例如,工作进程必须注册为 Windows 服务以自动启动。或者让我们假设我们有一个必须在存储到设置文件之前加密的秘密。我们该如何做呢?答案是,我们编写一个自定义操作。
自定义操作是外部程序集中的一个代码片段,它在安装程序部署时在正确的时间被调用。
编写它们并不难:它们都是用 C#编写的。而且我们知道这门语言!
首先,让我们讨论一下我们想要做什么。
在上一章中,我们讨论了秘密。我们发现我们可以使用.NET 系统生成密钥来加密和解密数据。这个密钥只会在这个机器上工作,因为它与特定用户的安装的 Windows 版本相关联。这意味着我们必须加密目标机器上的appsettings文件中的任何秘密。
假设我们在设置文件中部署了一个未加密的秘密。在这种情况下,我们必须确保在安装过程中加密目标机器。
在我的示例中,我只是用一个新的GUID替换了一个占位符来展示如何操作。但原则是相同的。
将一个新的类库添加到解决方案中。然而,有一个注意事项:选择类库的.NET Framework版本。MSI 安装程序使用的是“旧”的.NET 框架,因此任何附加组件都必须使用该技术构建。
将对System.Configuration.Install的引用添加到类库项目中,就像我这里做的那样:

图 13.12:添加 System.Configuration.Install 引用
向类库添加一个新的Installer类型的项目。你可以通过右键单击项目并选择Installer来实现。命名为SecretsInstaller。这可以在下面的屏幕截图中看到:

图 13.13:添加安装类
修改代码,使其看起来像这样:
[RunInstaller(true)]
public partial class SecretsInstaller : Installer
{
public override void Install(IDictionary stateSaver)
{
base.Install(stateSaver);
var secret = Guid.NewGuid().ToString();
var targetDir =
Context.Parameters["targetdir"];
var appSettingsPath =
Path.Combine(targetDir, "appsettings.json");
if (File.Exists(appSettingsPath))
{
var appSettingsContent =
File.ReadAllText(appSettingsPath);
appSettingsContent =
appSettingsContent.Replace(
"SECRET_PLACEHOLDER",
secret);
File.WriteAllText(
appSettingsPath,
appSettingsContent);
}
}
}
这段代码会被Installer调用。在这里,我找到了appsettings.json文件,将其加载到内存中,找到了SECRET_PLACEHOLDER字符串,并将其替换为Guid值。最后,我将它写回文件。
有趣的部分是我获取文件路径的行。我稍后会回到那里,所以请记住这一点。
我们需要使用Installer注册这个类。将一个新的Installer类添加到我们的类库中,ProjectInstaller,并更改构造函数。这段代码甚至比上一个还要简单:
[RunInstaller(true)]
public partial class ProjectInstaller : Installer
{
public ProjectInstaller()
{
InitializeComponent();
var secretsInstaller = new SecretsInstaller();
Installers.Add(secretsInstaller);
}
}
在构造函数中,我们创建了一个SecretsInstaller类的实例并将其添加到我们的Installer中。这是一个安装系统会查看并调用Install方法的类列表。
这就是我们需要编写的所有代码。让我们来使用它!
在设置中集成自定义操作
返回到设置程序。右键单击项目,选择添加…,然后项目输出。选择自定义操作项目的输出。这确保我们的 DLL 是目标机器上正在安装的文件之一。
再次右键单击设置程序,但选择查看,然后自定义操作…。
你应该会看到一个包含四个类别的屏幕。这决定了自定义操作应该在何时被调用。这些选项如下:
-
安装:这是所有文件都被安装的时候
-
提交:这是设置完成所有事情的时候
-
回滚:当设置失败时,这被称为
-
卸载:当用户决定安装时,这些操作会被执行
在我们的情况下,我们需要使用Install。右键单击它并选择添加自定义操作。再次,你将看到一个显示目标机器文件结构的对话框。这些都是我们的文件可能最终到达的位置。由于我们将自定义操作的项目输出添加到了常规安装中,我们可以在应用程序文件夹区域找到它。选择自定义操作的主要输出并点击确定:

图 13.14:添加自定义操作组件
不要离开自定义操作视图。点击安装部分中的新项目,查看属性区域。在这里,你可以添加各种项目,但最重要的是CustomActionData。
这是来自外部传递给我们的自定义操作参数的数据。记得我之前说过我会回到如何获取目标目录路径的方法吗?这就是我做到这一点的地方。将以下行添加到该属性中:
/targetdir="[TARGETDIR]\ "
是的。那一行的末尾有一个“反斜杠,空格,关闭引号”。不要遗漏这些。相信我:我花了好几个小时确定为什么我的操作不起作用。原因是:我忘记了那个额外的斜杠和空格。没有它根本不起作用。
就这么简单!
你现在可以构建并运行安装。
查找安装发生的文件夹,并惊叹于 JSON 文件中的变化!
使用 Docker
开发者当有人抱怨系统不符合预期时最常用的借口是“但是在我的机器上它工作得很好!”当然,唯一的合适回应是,“我们不发货机;我们发货软件。”
Docker 旨在解决那个问题。
Docker 是一个高度复杂的话题。如果你不了解它能做什么,请在熟悉它之前跳过本章的这一部分。简而言之,Docker 可以像完整的虚拟机一样运行。这个原则意味着你可以在那个虚拟机上开发,在那个虚拟机上测试,然后部署那个虚拟机。换句话说,如果它在那个机器上工作,它将在任何地方工作。它之所以能在任何地方工作,是因为有了 Docker,我们发货你的机器。好吧,至少是虚拟的。
Visual Studio 完全拥抱了 Docker。IDE 内置了实用的附加组件和向导,以帮助您使用 Docker。
将 Docker 支持添加到你的后台工作进程
如果你创建了一个新的项目,例如后台工作进程,你可以选择添加 Docker 支持。但如果你已经有一个项目,你必须稍后添加支持。这并不难做:只需右键单击项目,选择添加,然后点击Docker 支持。你可以在Windows和Linux之间选择:

图 13.15:将 Docker 添加到现有项目
Docker – 使用 Windows 还是 Linux?
如果你已经使用 Visual Studio 了一段时间,你可能会选择 Windows 而不是 Linux。毕竟,你可能非常了解那个平台。为什么你要迁移到 Linux 呢?然而,容器化来自 Linux 世界:它被嵌入到操作系统的核心中。Linux 是比 Windows 更好的容器平台。如果你不需要 Windows 功能,我建议你以 Linux 为基础容器。如果你决定使用 Docker,你的应用程序将从中受益。
当你这样做时,会发生很多事情:
-
项目中添加了一个名为
Dockerfile的新文件 -
launchSettings.json文件被更改以添加 Docker -
在后台,安装所有必要的支持镜像
-
默认启动操作设置为容器(Dockerfile)
如果你开始调试,Visual Studio 将会使用你的二进制文件构建 Docker 镜像并启动一个容器。你可以在代码中添加断点,Visual Studio 也会确保调试器被部署在容器中。因此,它知道如何来回隧道调试信息。整个过程都是简化的:你几乎不会注意到你是在 Docker 镜像上运行而不是在主机机器上。
部署你的 Docker 镜像
一旦你完成对代码库的工作并准备部署它,你必须弄清楚在哪里部署它。有三个选项:
-
使用 Docker Hub。这是你可以存储你的镜像的标准存储库。
-
使用 Azure/AWS/Google Cloud 来存储你的镜像。这些存储库要安全得多,因为你控制这些环境。例如,你可以在 Azure 中创建一个容器注册库,然后上传你的镜像。你组织中的每个人都可以拉取那个镜像并在本地运行它。
-
使用你自己的存储库。假设你不想依赖云提供商,但想完全控制你的镜像存储位置。在这种情况下,你可以构建自己的存储库。
第三个选项是我们场景中最常用的一个。当然,你也可以使用 Docker Hub 或 Azure。没有任何东西会阻碍你的道路。只是对于我们构建的东西来说,第三个选项可能是最合适的。
构建实际的存储库是困难的。但好消息是,其他人已经完成了这项工作。他们已经将其放入了一个 Docker 镜像中。所以,我们只需要下载那个镜像并启动它。
但在我们这样做之前,我们需要考虑安全性。有许多方法可以保护存储库,但最简单(也是最不安全)的方法是分配一个用户名/密码。你需要一些代码来生成这些,但不用担心:有一个 Docker 镜像可以做到这一点。
首先,创建一个名为C:\Auth的文件夹。然后,运行以下命令:
docker run --rm --entrypoint htpasswd httpd:2 -Bbn yourusername yourpassword > C:\auth\htpasswd
这个命令下载了http:2镜像并运行它,给它一个用户名为yourusername和密码为yourpassword(我建议你使用其他值作为这些参数),并将结果存储在c:\auth文件夹中的htpasswd文件中。
现在,我们可以启动存储库。运行以下命令,全部在一行中:
docker run -d -p 5000:5000
--name registry
-v c:\auth:/auth
-e "REGISTRY_AUTH=htpasswd"
-e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm"
-e "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd" registry:2
这个命令从 Docker Hub 拉取registry:2镜像并启动它。它将内部文件夹/auth连接到我们的c:\auth目录,并给它一些参数。
就这么简单。
要使用存储库,你必须登录:
docker login localhost:5000
现在,你可以在那里标记和推送你的镜像。在我的例子中,我的从 Visual Studio 来的 C#工作进程镜像被称为image13workerfordocker。
你可以通过运行以下命令来标记它:
docker tag image13workerfordocker:dev localhost:5000/image13workerfordocker:dev
再次强调,这是一整行。现在,我可以将其推送到我的本地存储库,如下所示:
docker push localhost:5000/image13workerfordocker:dev
如果我想重用我的镜像,我可以拉取它:
docker pull localhost:5000/imagework13fordocker:dev
我可以使用这个存储库,就像我可以在 Docker Hub、Azure、AWS 或 Google 上的那些存储库中一样。
生产就绪的 Docker 存储库
我在这里展示的只是让你看到最基础的开始。这个仓库并不安全或不稳定,甚至无法在重启后存活。
如果你想在真实的生产环境中使用它,你需要做几件事情:
-
通过使用 TLS 来加强安全性
-
安装一个卷,以便你可以存储镜像而不是使用容器(提示:将卷映射到
/var/lib/registry) -
使用实际的认证方式,而不是我刚刚展示给你的单个用户名/密码
-
在像 Kubernetes 这样的故障安全环境中部署仓库
但即使有这个设置,你仍然可以拥有自己的仓库。这将确保如果代码在你的机器上工作,它将在任何地方工作!
下一步
在本章中,我们讨论了许多将你的软件从你的机器传输到其他机器的方法。有些很简单,有些则很复杂。说实话,本章更多的是帮助你开始思考部署。每个主题都可以填满数百页。例如,我提到了 Wix。好吧,仅关于 Wix 就有数十本书籍。我们在 Azure 中讨论了 CI/CD,只用了几页。结果发现,人们可以从这个主题中完全建立起自己的职业生涯。我们还研究了 Docker:人们可能需要花费数周甚至数月的时间来掌握这个主题。
你有多种方式可以将你的代码发布出去,而这章只是触及了表面。
我想向你展示最常见的一些适合我们最可能遇到场景的方法。确定哪种最适合你的用例,然后深入探索,这取决于你。
在我让你们离开之前,我需要说一些关于 Docker 对话的内容。对话询问你是否想使用 Linux 或 Windows。我建议你尽可能选择 Linux。如果你认为,“但我对 Linux 了解很少”,请不要担心。下一章将告诉你所有你需要知道的关于那个操作系统的内容。那么,让我们看看,好吗?
第十四章:Linux 飞跃篇
在 Linux 上使用 C#进行系统编程
我记得只有真正酷炫的孩子才会使用 Linux。Windows 是严肃人士的选择。那里才是工作的场所。当然,许多服务器运行 Unix 或 Linux,但那些平台上工作的人被认为有些奇怪。他们通常有胡须,穿凉鞋,说一种其他人从未听过的语言。
好吧,也许我有点夸张。这可能表明了我对 Linux 的感受,或者我有多么被那个操作系统及其用户吓到。Linux 一直被认为是一个更成熟但更复杂的操作系统。它更安全,速度更快,维护性更好。但使用起来也更复杂。大部分工作都是在命令行中完成的,尽管也存在图形用户界面。
这些天,情况不同了。Linux 无处不在。而且有很好的理由——在当前这个在线、互联的世界中,Linux 是一个出色的操作系统来运行你的系统。
随着.NET Core和.NET 5的引入,那些传统上只使用 Windows 的开发者也可以将他们的代码编译在 Linux 上运行。这开辟了一个全新的世界。
当然,也有缺点。Linux 比 Windows 复杂,尤其是如果你长时间在 Windows 上工作。尽管.NET 可以在 Linux 上运行,但并不是你习惯的所有类和工具都可用。
这章旨在帮助你入门,如果你想在 Linux 上运行你的.NET 应用程序。别担心——我不想让你开始穿凉鞋或成为一个典型的 1970 年代类型的开发者。这完全是可选的。所以,让我们释放你内心的企鹅,开始在 Linux 上编程!
在本章中,我们将提出以下问题:
-
什么是 Linux?
-
我如何在 Linux 中做基本的事情?
-
我该如何为 Linux 进行开发?
-
我该如何部署到 Linux?
-
我该如何为 Linux 编写后台服务?
本章包含一些历史,一些理论,以及大量的实用信息和示例。你准备好了吗?
技术要求
你可以在以下 URL 的我们的仓库中找到本章的所有代码:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter14。
如果你想跟上,你需要一台 Linux 机器。但在开发过程中,你实际上并不需要。你只需要 WSL。WSL代表Windows Subsystem for Linux。官方名称是 WSL2,因为我们现在处于版本 2,但在这里我们还是用 WSL。
WSL 是一个轻量级的虚拟机,可以在你的 Windows 机器上运行 Linux 发行版(我稍后会解释这是什么)。你可以快速切换到这台机器,就像使用“真正的”Linux 机器一样。你甚至可以直接从 Visual Studio 部署并调试你的应用程序在 WSL 上。
要安装 WSL,请按照以下步骤操作:
- 在 Powershell 中,使用
wsl --install命令(你必须是一位管理员才能这样做)。请注意,这可能会占用你机器上几吉字节的空间。
就这样。没有 第二步。你现在可以进入 Ubuntu。看起来是这样的:

图 14.1:从 Windows 开始菜单运行 Ubuntu
或者,你可以安装其他版本的 Linux,但在这本书中,我将使用 Ubuntu 20.04。你选择什么并不重要;只要做你感到最舒服的事情即可。
或者,你可以使用 HyperV 创建虚拟机,部署带有 Linux 的 Docker 容器,获取第二台安装 Linux 的机器,或者从可启动的 USB 驱动器运行 Linux。选择权在你。
Linux 概述
在讨论如何为 Linux 编程之前,我们应该讨论它是什么。简短的答案是它是一个操作系统。虽然这是绝对正确的,但它并没有充分解释 Linux 可以做什么。我可以说自行车是一种交通工具,但这同样适用于将宇航员送往月球的土星 5 号火箭。我们需要更多的信息。
Linux 简史
Linux 的历史非常引人入胜。了解其开发的时间和背景可以帮助你欣赏一些设计决策和选择。因此,以下是 Linux 历史的简要时间线:
-
早期开始
-
在 1983 年,Richard Stallman 宣布了 GNU 项目。想法是创建一个免费的类 Unix 操作系统。Unix 是当时领先的操作系统。GNU 项目开发了众多组件,但一个关键部分,内核,是缺失的。
-
在 1987 年,Andrew S. Tanenbaum 创建了 Minix。Minix 是一个类 Unix 系统。它旨在教育目的,并且在学生中非常受欢迎。其中之一是一个名叫 Linus Torvalds 的年轻人。
-
-
Linux 的诞生
-
在 1991 年,芬兰赫尔辛基大学的 Linus Torvalds 学生开始开发他的内核。这只是个爱好;他想要有事情做。1991 年 8 月 25 日,他在一个新闻组帖子中宣布了他的项目,寻求他人的意见。这就是后来成为 Linux 内核的东西。
-
在 1991 年 10 月 5 日,Torvalds 发布了 Linux 的 0.02 版本。它可以运行 Bash(一个终端)和 GCC(一个 C 编译器)。
-
-
发展和增长
-
在 1992 年,Linux 在 GNU 通用公共许可证(GPL)下发布,允许任何人使用、修改和分发该软件。
-
在 1990 年代中期,Linux 的受欢迎程度迅速增长。Slackware 和 Debian 这样的发行版都在 1993 年发布。
-
在 1994 年,Linux 1.0 版本发布。这是一个重要的里程碑;这是第一个稳定版本。
-
-
商业和 社区扩展
-
在 1996 年,Tux 企鹅被选为官方 Linux 图标。
-
1990 年代末,像红帽和 SUSE 这样的公司开始提供商业 Linux 发行版。由于这些发行版还包括支持,这是企业纷纷加入的时刻。
-
1999 年,IBM 宣布支持 Linux。
-
-
21 世纪和 主流采用
-
2001 年,内核的 2.4 版本发布,包括 USB、PC 卡和其他硬件支持。
-
2002-2003 年,惠普和戴尔等大型公司开始在他们的服务器上提供 Linux。
-
2004 年,Canonical 发布了其 Linux 发行版,它更加用户友好。这使得普通公众也能使用它。
-
2005 年,林纳斯·托瓦兹发布了一个名为 Git 的侧项目。是的,就是那个 Git。你可能每天都在使用的工具。Git 是 Linux 开发的一个关键工具。
-
-
现代时代
-
2011 年,Linux 成为服务器市场的主导操作系统。它为包括谷歌、亚马逊和 Facebook 等大型公司的多数网站服务器提供动力。
-
2013 年,谷歌发布了 Android,这是一个基于 Linux 的移动智能手机操作系统。
-
在 2020 年代,Linux 继续主导服务器空间、云基础设施、超级计算和物联网设备。
-
导致 Linux 成功的关键因素之一是其开源性质。每个人都可以查看源代码,下载它们,采用它们,并做他们想做的事情。而且它始终是免费的。
考虑到所有这些,我们作为系统程序员,也需要了解 Linux,这毫不奇怪。
什么是 Linux?
Linux 这个名字有时可能会让人困惑。让我来澄清一下,并帮助你弄清楚。
Linux 内核
Linux 内核是我们所说的 Linux 的核心。它是操作系统的核心。内核管理系统的硬件和资源,如内存和 CPU。内核的一些关键职责如下:
-
进程管理:它决定哪个进程运行以及运行多长时间
-
内存管理:它跟踪系统内存中的每一个字节,并管理内存空间的分配和释放
-
设备管理:它管理与系统连接的所有设备的通信
-
系统调用:它还提供了一个上述系统和希望使用它们的应用程序之间的接口
内核可以与Windows NT 内核相媲美。
其他组件
Linux 通常还附带一系列其他组件。以下是一些最常见的组件:
-
系统库:这些是程序可以用来执行任务(如文件处理和数学计算)的必要函数集合。一个好的例子是 GNU C 库,它是大多数 C 或 C++程序的基础。
-
init程序。这个程序管理系统启动。其他例子包括 Bash,一个 shell 程序,以及各种命令行工具。 -
守护进程:这些是在后台执行各种任务的背景服务,例如处理打印作业、管理网络连接或安排任务。
Bash 是什么?
Linux 中软件或部分的名称与你在 Windows 中找到的名称不同。Windows 在命名上更为严肃,而 Linux 则更为俏皮。例如,Bash 代表 Bourne Again Shell,以其创造者 Stephen Bourne 命名。他希望创造一个比当时最常用的 shell(Thompson shell)更好的 shell。因此,他将“再生”与自己的姓氏合并,得出了这个新名字。当你与 Linux 一起工作时,你会经常发现这类名称。
有很多更多的组件,有时,选择放置组件的位置可能看起来是随意的。但总的来说,这种区分是有效的。
添加的软件
当你安装 Linux 时,你通常会获得很多软件。这些是面向用户的程序以及用户与之交互的软件。有基于命令行的程序和基于图形用户界面的程序。哪些被分发取决于你下载或购买的是哪个软件包。
发行版
在 Windows 商店中找到 Linux Ubuntu 并点击 获取。
有数百个发行版可供选择,大多数是免费的,但有些需要付费。以下表格是按使用类别分组的最常用发行版的列表:
| 类别 | 发行版 | 描述 |
|---|---|---|
| 通用 桌面使用 | Ubuntu | 以其用户友好性、庞大社区和强大的支持而闻名 |
| Linux Mint | 基于 Ubuntu,因其易用性和传统的桌面环境而受欢迎 | |
| Fedora | 以其前沿特性和与 Red Hat 的紧密关系而闻名 | |
| 轻量级 | Lubuntu | Ubuntu 的一个更轻、更快、节能的变体,使用 LXQt |
| Xubuntu | 一种官方的 Ubuntu 变体,使用 XFCE 桌面环境 | |
| Puppy Linux | 极其轻量级,设计用于在较旧的硬件上运行 | |
| 隐私 和安全 | Tails | 以保护隐私和匿名性为目标,基于 Debian |
| Qubes OS | 通过隔离来关注安全性,使用虚拟机 | |
| Kali Linux | 设计用于渗透测试和安全审计 | |
| 服务器 和企业 | CentOS/AlmaLinux/Rocky Linux | 由社区支持的 Red Hat Enterprise Linux (RHEL) 的重建版本 |
| Ubuntu Server | Ubuntu 的服务器版本,以其易用性和广泛的支持而闻名 | |
| Debian | 以其稳定性和健壮性而闻名,常用于服务器 | |
| 开发 | Arch Linux | 开发者因其简洁性和控制性而青睐 |
| Fedora | 提供前沿的软件和技术 | |
| openSUSE | 以其开发者友好的工具和 YaST 配置工具而闻名 | |
| 媒体 制作 | Ubuntu Studio | 专门针对音频、视频和图形设计定制 |
| AV Linux | 为多媒体内容创作者定制构建 | |
| Fedora Design Suite | 随带一系列开源创意应用程序 | |
| 教育 | Edubuntu | 一种为教室和教育环境设计的 Ubuntu 版本 |
| Kano OS | 为 Kano 电脑套件设计,旨在教授孩子们如何编程 | |
| Debian Edu/Skolelinux | 一种定制的 Debian 纯净混合版本,专为教育用途设计 | |
| 游戏 | SteamOS | 由 Valve 公司为游戏机开发 |
| Ubuntu GamePack | 预装了许多游戏和模拟器 | |
| Lakka | 一种轻量级的 Linux 发行版,可以将小型计算机转变为完整的游戏机 | |
| 特殊用途 | Raspberry Pi OS(以前称为 Raspbian) | 优化用于 Raspberry Pi 硬件 |
| Clear Linux | 由英特尔开发,针对英特尔硬件的性能和安全性进行优化 | |
| Tiny Core Linux | 一种极小、高度模块化和灵活的 Linux 发行版 |
表 14.1:一些可用的 Linux 发行版
正如你所见,有一个针对你定制的发行版。然而,请记住,内核对于所有这些可能都是相同的,或者至少非常相似。发行版之间最大的区别是提供的软件和开箱即得的配置。
使用 Linux 的快速入门指南
在我的职业生涯早期,我知道我对电脑非常在行。它们对我来说没有惊喜。我知道如何控制它们;我是机器的主人。这种情况一直持续到我第一次坐在 Linux 机器后面。那时我意识到我只了解 Windows 以及如何使用该平台。我感到迷茫。我甚至无法在屏幕上获取目录的内容。
为了自我辩护,这发生在 20 世纪 90 年代初。Linux 刚刚发布,我们没有今天这样丰富的在线信息。万维网刚刚被发明,搜索引擎不存在,信息很难找到。今天,事情更加简单——有大量的资源可以帮助你快速掌握新事物。
我将帮助你学习一些基础知识,这样你就可以在 Linux 系统上随意操作而不会感到烦恼。我不会讨论任何可用的图形用户界面系统。它们有很多,其中一些非常好。但是使用它们就像使用 Windows 一样简单。说实话,真正的工作是在命令行上完成的。所以,从现在开始,我将专注于这一点。
我还假设你已经知道了如何在你的机器上安装 WSL 并可以打开一个终端。我还有一个建议——从 Windows Store 安装 Windows Terminal。Windows Terminal 是一个打开不同 shell(包括 Linux shell)的出色工具。它看起来是这样的:

图 14.2:一个终端应用程序打开新的 Linux shell
你可以打开多个窗口,每个窗口都有自己的 shell。你可以在 Ubuntu 窗口旁边打开一个 PowerShell 窗口,甚至还可以打开旧式的命令提示符。你需要的一切都随时可用。
大小写——小心!
在我们深入命令之前,有一件事你应该知道——Linux 是大小写敏感的;Windows 不是。相信我——这曾经让很多人陷入困境,将来可能会让更多的人感到困惑。所以,请记住这一点。一个目录可以有两个大小写不同的同名文件。在同一个地方可以有 MyAwesomeApp 和 myAwesomeApp 文件。如果你来自 Windows 背景,你经常会犯这个错误;你找不到你知道存在的文件,我经常看到这种情况。检查你的大小写。
如果你选择了终端,打开 Linux 的 shell。现在,你准备好尝试一些命令了!
基本命令
我想给你一个最常用 Linux 命令及其在 Windows 上的等价命令的列表。但在深入这些列表之前,我想分享最好的命令——man。这个关键字可以打开任何你想要了解更多信息的命令的手册页面。例如,Windows 上 dir 的等价命令是 ls。输入 man ls 并按 Enter 键来了解更多信息。这样做会产生关于命令、参数和示例的页页信息。这几乎适用于所有命令。
基本导航和文件管理
在使用操作系统时,导航文件系统可能是必不可少的。对于 Linux,你应该知道这些命令:
| 任务 | Windows 命令 | Linux 命令 | 描述 |
|---|---|---|---|
| 列出目录内容 | dir |
ls |
列出当前路径中的文件和目录 |
| 更改目录 | cd |
cd |
更改当前目录 |
| 打印 工作目录 | cd |
pwd |
显示当前目录 |
| 复制文件 | copy |
cp |
复制文件 |
| 移动/重命名文件 | move |
mv |
移动或重命名文件 |
| 删除文件 | del 或 erase |
rm |
删除文件 |
| 删除目录 | rmdir 或 rd |
rmdir 或 rm -r |
删除目录 |
| 创建目录 | mkdir |
mkdir |
创建目录 |
表 14.2:导航和文件管理命令
命令的工作方式大致如你所期望。所以,试试它们!
文件查看和编辑
如果你想要了解更多关于文件内容的信息或编辑文件内容,这些命令正是为此而设计的:
| 任务 | Windows 命令 | Linux 命令 | 描述 |
|---|---|---|---|
| 查看文件内容 | type |
cat |
显示文件内容 |
| 编辑文件 | notepad |
nano, vi, 或 vim |
编辑文件 |
| 分页查看文件内容 | more |
less |
分页查看文件内容 |
表 14.3:文件查看和编辑
有一点警告——如果你第一次开始使用 VI 或 VIM,确保你有一个打开的网页,上面有这些工具中要使用的命令。如果你没有这些工具的经验,使用这些工具可能会相当复杂!
系统信息和进程
如果你想了解更多关于你所使用的系统或对正在运行的过程感兴趣,可以尝试以下命令:
| 任务 | Windows 命令 | Linux 命令 | 描述 |
|---|---|---|---|
| 显示 系统信息 | systeminfo |
uname -a |
显示系统信息 |
| 显示 进程信息 | tasklist |
ps |
列出正在运行的进程 |
| 终止进程 | taskkill |
kill |
终止进程 |
| 显示 磁盘使用情况 | dir 或 chkdsk |
df |
显示磁盘空间使用情况 |
| 显示 文件大小 | dir |
du |
显示文件和目录大小 |
表 14.4:系统信息和进程命令
当你开始在 Linux 上编写自己的软件时,这些命令非常有价值。运行这些命令会给你提供很多你可能以后需要的信息!
网络命令
作为系统程序员,我们经常与网络一起工作,或者让我们的软件通过网络进行通信。在这些情况下,了解我们系统上的网络情况是很好的。以下是一些可以帮助你的命令:
| 任务 | Windows 命令 | Linux 命令 | 描述 |
|---|---|---|---|
| Ping | ping |
ping |
检查网络连接 |
| IP 配置 | ipconfig |
ifconfig 或 ip |
显示或配置 IP 网络设置 |
| 跟踪路由 | tracert |
traceroute |
跟踪到网络主机的路径 |
表 14.5:网络命令
大多数这些命令与 Windows 的对应命令类似,所以你应该没有问题记住并使用它们。
包管理
许多发行版都预装了软件,但你的发行版可能缺少一些你可能认为非常有价值的东西。但别担心——Linux 有工具可以安装它们。以下是一些简短的列表:
| 任务 | Windows 命令 | Linux c****ommand | 描述 |
|---|---|---|---|
| 安装软件 | 多种方式(例如,msiexec) |
apt-get install,yum install 或 dnf install |
安装软件包 |
| 更新软件 | Windows Update | apt-get update 或 apt-get upgrade |
更新软件包 |
| 卸载软件 | 多种方式(例如,控制面板) | apt-get remove,yum remove 或 dnf remove |
卸载软件包 |
表 14.6:包管理命令
你将在本章后面遇到这些命令更多。
提升权限
Linux 围绕安全性构建。其中一个影响是,你或多或少被迫以普通用户身份运行所有命令。即使你以管理员身份登录,你也不是管理员。你不能做你想做的所有事情。
你可以轻松地改变这一点。你可以使用su命令给自己赋予 root 权限,意味着超级用户。这里的root意味着你处于所有用户权限的最高级别;你可以做任何你想做的事情。然而,不要这样做。我很少有过成为系统 root 的理由。在 Linux 社区中,成为 root 是不受欢迎的。
如果你需要提升权限来做某事,请使用 sudo 命令。这个命令代表 sudo。如果是这样,你一次就给了命令可能需要的根权限,然后系统立即返回到正常权限。只有那一行的命令可以使用这些提升的权限。
在会话中第一次使用 sudo 时,你必须提供你在安装你的发行版时输入的管理员密码。你的系统会记住这些凭证,直到会话结束,所以你不必每次都这样做。
让我展示一下它是如何工作的。我使用 whoami 命令,该命令提供有关当前登录用户的信息。如果我使用该命令,它会返回我的名字。然而,当我再次这样做时,我在它前面添加了 sudo,它会返回 root。紧接着,它又恢复到返回我的名字。这个截图显示了这一过程:

图 1.4.3:Sudo 在行动
如你所见,它也会要求我输入密码。如果我发出相同的或另一个命令并使用 sudo,它将使用缓存的凭证。但请记住,这仅在当前会话中有效。如果我打开另一个终端窗口并重复相同的练习,系统将再次要求我输入密码。
使用 sudo 是 Linux 如何使事情尽可能安全的另一个例子。
了解更多命令很有用,但这至少给你提供了一个起点。记住,man 是你的好朋友!
现在你可以在你的 Linux 发行版中自信地找到自己的位置,是时候看看我们作为开发者如何与操作系统一起工作了。
为 Linux 开发
为 Linux 编写的第一个软件之一是 GCC,它于 1991 年创建。在终端中输入 python3 命令,你就可以开始了。但我们不做 Python;我们做 .NET。这意味着我们还有另一条路要走。
在 Linux 上安装 .NET
我之前提到 Linux 通常预装了很多开发工具。然而,.NET 不是这些预装环境之一。好消息是安装它并不难。
在我告诉你如何将 .NET 安装到系统之前,我想讨论一下我选择开发机器的选择。
我爱 Visual Studio。我认为它是目前最好的 IDE。当然,还有其他 IDE,我知道很多人更喜欢 Visual Studio 以外的工具,但我不属于他们。
许多人使用的 IDE 之一是 Visual Studio Code。我同意他们的看法,VS Code(使用其简称)是一个伟大的工具。然而,当我处理现实世界系统时,我更喜欢 Visual Studio 完整版的丰富性。
如果你更喜欢 VS Code,当然可以使用它。你可以在许多不同的平台上安装 VS Code,包括 Linux。网上有许多教程告诉你如何做到这一点。
如果你想要继续使用 Visual Studio,我有一个好消息和一个坏消息。
坏消息是您不能在 Linux 机器上安装 Visual Studio。好消息是您不必这样做。您可以在 Windows 机器上安装它,然后直接在 Linux 系统上部署和调试。这正是本章剩余部分我们将要做的。
然而,要在您的 Linux 系统上运行.NET 应用程序,您必须拥有运行时。运行时包含运行您的.NET 应用程序所需的一切。如果您想为您的应用程序准备生产系统,这真是太好了。但如果您想在 Linux 机器上调试和测试您的应用程序,您还需要.NET SDK。SDK 包含运行时,因此您不需要安装两者。
安装.NET 运行时
让我们先讨论安装运行时。同样,您只需要在将运行您的软件的机器上安装运行时。如果您想编译您的代码,您需要 SDK。
在您的 Linux 发行版上打开一个终端(或在 Windows 机器上的 Ubuntu 终端)。
输入以下命令:
sudo apt-get update
sudo apt-get install -y wget apt-transport-https
第一个命令更新了您系统上的所有包。第二个命令如果尚未安装,则安装 HTTPS 传输软件。它可能已经在您的系统上,但这样做确保了这一点。我们需要https来下载软件。
微软确保他们所有的软件都是签名的,因此您可以信任它。但是,为了验证这个签名,您需要拥有他们的公钥。这是我们在系统上获取这个密钥的方法:
wget https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
这些命令获取密钥并将它们安装到我们的系统上。现在,我们可以使用它们来验证从微软的下载。
这一切只是准备工作。现在,我们终于可以安装运行时了。这是我们的做法:
sudo apt-get update
sudo apt-get install -y dotnet-runtime-8.0
首先,我们确保一切都已经更新。然后,我们获取运行时包。
就这些了。如果这个命令执行完毕,我们可以通过运行以下命令来测试它:
dotnet --list-runtimes
您应该在运行时列表中看到.NET 8 运行时。
安装 SDK
如果您想构建和调试您的 Linux 发行版,您需要更多的软件。您必须安装 SDK。幸运的是,这个过程几乎与安装运行时相同。如果您已经安装了运行时,您可以输入以下命令:
sudo apt-get update
sudo apt-get install -y dotnet-sdk-8.0
这组命令首先更新所有包,然后安装 SDK。
如果您还没有安装运行时,您首先必须重复我在运行时安装过程中向您展示的所有步骤,除了最后一个步骤(运行时的安装本身)。您仍然需要更新、获取和安装密钥。
通过调用以下命令来测试 SDK 的安装:
dotnet --list-sdks
您现在应该看到一个 SDK 列表——好吧,我说是列表,但您可能只会看到一个条目。
您可以通过进行快速测试来进一步测试安装,例如:
dotnet new console
这个命令创建了一个新的控制台应用程序。当完成时,执行以下操作:
dotnet build .
这将在当前文件夹中构建 .csproj 文件。结果最终位于 /bin/Debug/net8.0 文件夹中。程序名称与您放置项目的文件夹名称相同。如果您没有创建目录,则名称为您的用户名。在我的情况下,程序名为 dvroegop,所以我可以像这样运行它:
./dvroegop
我可以看到友好的 Hello, World 消息,所以显然一切正常!
在 Linux 上运行 .NET 后台工作进程
我想我们现在已经足够理论了。让我们来点实际的。启动 Visual Studio 并开始一个新的后台工作进程项目。在向导中,接受所有默认设置,直到项目准备就绪。在代码中,我们保留一切原样,包括默认模板每秒打印一条消息。运行它以查看是否一切正常。
如果一切顺利,您将在 Windows 上运行一个新的后台工作进程。太棒了!但我们已经看到了很多这样的例子。让我们将我们的程序移到 Linux。为此,我们必须做一些事情。
在 WSL 中运行您的应用程序
我们可以直接从 Visual Studio 发布到您的 WSL 安装。为此,请执行以下操作。
打开您的项目,转到运行菜单。在下拉菜单中,您应该看到部署到 WSL 的选项。这看起来像这样:

图 14.4:在 Visual Studio 中使用 WSL 作为调试环境
在下拉菜单中选择 WSL 选项。
如果您在 WSL 中安装了多个发行版,您可能会在 Visual Studio 中收到警告,默认的 WSL 没有安装正确的 SDK。如果是这种情况,只需点击 安装 按钮即可修复。
您的项目 Properties 文件夹中的 launchSettings.json 文件也应包含 WSL 选项。这决定了 Visual Studio 将启动哪个发行版。如果没有指定,它将使用默认设置。在我的情况下,我安装了 Ubuntu 20 和 Ubuntu 22,所以我必须做出选择。我可以指示 Visual Studio 使用版本 22,通过将我的 launchSettings.json 修改如下:
"WSL": {
"commandName": "WSL2",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
},
"distributionName": "Ubuntu-22.04"
发行版名称默认为空字符串,导致 Visual Studio 使用默认环境来运行您的系统。由于我已经指定了我的期望发行版名称,它将使用该发行版。
您可以通过打开命令提示符或使用 PowerShell 终端来更改默认的发行版。然后,使用此命令获取已安装的发行版列表。
wsl –-list
您将看到机器上安装的发行版列表。在我的机器上,这看起来像这样:
Windows Subsystem for Linux Distributions:
Ubuntu-22.04 (Default)
docker-desktop-data
docker-desktop
Ubuntu-20.04
然后,您可以通过发出此命令来选择一个作为默认选项:
wsl –-set-default Ubuntu-22.04
当然,您可以指定您想要的任何发行版。我恰好喜欢 Ubuntu-22.04。
发生的事情非常有趣。Visual Studio 做了很多工作以确保我们不必担心部署。以下是您选择 WSL 作为环境后点击 运行 时发生的事情的简化概述:
-
Visual Studio 将您的项目构建为一个跨平台系统。
-
Visual Studio 随后使用 WSL 启动子系统的一个实例。
-
使用 WSL,Visual Studio 随后将所有输出文件复制到子系统。
-
Visual Studio 将远程调试器 VSDBG 复制到 WSL。
-
它设置了 VSDBG,给它适当的权限并启用网络通信。
-
Visual Studio 随后在 WSL 中启动 VSDBG 并附加你的应用程序。
-
最后,Visual Studio 会附加到正在运行的 VSDBG 实例。
结果是你可以像使用习惯的 IDE 一样使用它。你可以设置断点、中断应用程序、检查变量、读取系统信息等等。你在本地运行你的应用程序和在 WSL 上运行它之间几乎看不到任何区别。
将你的应用程序部署到 Linux 环境中
直接从 Visual Studio 运行你的应用程序非常酷。然而,最终,你希望将你的应用程序部署到系统中。这并不难做到。
在 Visual Studio 中,右键单击你的项目,并选择发布。
像你通常做的那样创建一个新的发布配置文件,只有一个细微的差别——将目标运行时设置为linux-x64。设置此框架将确保你的应用程序在 Linux 上运行!
如果你愿意,你也可以将你的应用程序部署到你的 WSL 发行版。你可以在 Windows 资源管理器中使用一个方便的快捷方式——导航到\\wsl.localhost\Ubuntu-22.04\home\[username]\文件夹。确保将[username]替换为你的 WSL 用户名。你可以创建一个新的文件夹作为发布配置文件接收者。
你可以将该文件夹输入配置文件的目标位置——这样,Visual Studio 就会自动将所有工件发送到正确的位置。我的配置设置看起来像这样。

图 14.5:Linux/WSL 目标的发布配置文件
如果你这样做,然后按下发布按钮,你的代码最终会到达它需要的位置。现在,你可以从 WSL 发行版运行程序。当然,如果你有另一个正在运行的 Linux 系统,你可以使用相同的机制。如果你不能创建共享,你总是可以本地发布,然后使用如 SCP 之类的工具复制文件。
我想我们已经足够讨论这个问题了。接下来,让我们讨论 Linux 开发!
使你的代码跨平台
.NET 的美丽之处在于它是跨平台的。IL 几乎在所有地方都可以运行。如果你构建一个应用程序,它将在你的 Windows 和 Linux 机器上运行。
在 Linux 上运行 exe 文件?
不,你不能在 Linux 上运行你的 Windows exe文件。EXE 文件是典型的 Windows 结构。文件布局特定于该平台,而 Linux 系统有另一种处理可执行文件的方式。然而,如果你构建你的系统,编译器也会生成一个 DLL 文件。你可以使用dotnet命令运行该文件。所以,如果你的系统名为MyAwesomeApp.exe,你也会在Build目录中找到一个MyAwesomeApp.dll。在所有支持的平台上,你可以使用dotnet MyAwesomeApp.dll命令运行你的应用程序,这在 Windows 和 Linux 上都有效。
但这并不意味着您可以复制您的二进制文件,运行它们,并期望一切都能正常工作。有一些注意事项您应该知道。但别担心——我们在这里会逐一介绍它们。
Linux 中的权限
这里有一个首要的注意事项——脚本和应用程序默认情况下无法运行。它们没有正确的权限。每个文件都有一组权限,告诉操作系统它可以对它做什么。这些权限按类别不同。有针对用户、组和其它人的权限。权限本身可以是读取、写入或执行。您使用chmod命令(使用man chmod获取所有内部信息,但请记住,为了使您的应用程序可运行,您必须使用chmod +x [yourapplicationname]命令。+x部分告诉 Linux 您可以执行它。
一旦您掌握了它,您会发现切换到 Windows 和 Linux 之间非常容易。但说实话,我知道我曾在我的 Windows 机器上尝试过chmod命令。不要告诉任何人我承认了这一点!
代码如何帮助您
创建.NET 的人已经尽力让那些需要支持多个平台的人尽可能容易。在“旧时代”,您需要很多编译器指令,甚至不同的代码版本,而现在您可以在代码中做很多事情,让系统找出如何处理这些事情。让我们看看其中的一些。
查找您运行的位置
有时,您想知道系统运行在哪个平台上。有一个叫做OperatingSystem的类可以帮助您做到这一点。这是一个非常简单的类,但它可以非常强大。看看下面的代码片段:
if(OperatingSystem.IsWindows())
_logger.LogInformation("Worker running on Windows");
else if(OperatingSystem.IsLinux())
_logger.LogInformation("Worker running on Linux");
OperatingSystem类有更多这样的方法,但我相信您已经明白了这个意思。如果您需要知道软件的特定平台版本,您也可以使用这个类来确定。所以,您需要的信息都在这里。
路径和目录
如果您在本章前面给出的命令在 WSL 中尝试过,您可能会注意到路径看起来与您可能习惯的不同。
例如,当我在 Windows 中浏览时,我的家目录路径看起来是这样的:
C:\Users\dvroe
我的主要驱动器是 C 驱动器,有一个Users文件夹,其中有一个子文件夹dvroe。
在我的 Linux 发行版中,我的home文件夹可以在以下位置找到:
/home/dvroegop
显然,可以通过进入根目录,然后是home子文件夹,最后是dvroegop文件夹来找到它。
没有提到驱动器。此外,所有的斜杠方向都是相反的。
驱动器可用,但它们的位置不同。Linux 有一个根路径叫做/mnt。您可以在该文件夹中找到一个包含您机器上所有驱动器的文件夹。所以,在 Windows 中,驱动器是所有路径的根;在 Linux 中,它是/mnt的子文件夹。
在您的代码中,您永远不需要担心使用哪种斜杠或如何构建路径以包含正确的驱动器。Path类包含了您需要的所有工具。看看下面的代码:
var directorySeparatorChar = Path.DirectorySeparatorChar;
var pathSeparator = Path.PathSeparator;
var currentPath = Directory.GetCurrentDirectory();
var newPath = currentPath + directorySeparatorChar + "newFolder";
var betterWay = Path.Combine(currentPath, "newFolder");
var twoPaths = currentPath + pathSeparator + newPath;
$"DirectorySeparatorChar: {directorySeparatorChar}".Dump(consoleColor);
$"PathSeparator: {pathSeparator}".Dump(consoleColor);
$"Current Path: {currentPath}".Dump(consoleColor);
$"newPath: {newPath}".Dump(consoleColor);
$"betterWay: {betterWay}".Dump(consoleColor);
$"twoPaths: {twoPaths}".Dump(consoleColor);
在这个示例中,我合并了路径的两个部分。你永远不应该这样做。我只是想展示如果你使用DirectorySeparator会发生什么。更好的方法是使用Path.Combine(),就像我在代码中也展示的那样。这样,你可以确保始终得到正确的结果。
路径分隔符与目录分隔符
我在示例中展示了路径分隔符和目录分隔符。许多开发者将路径分隔符和目录分隔符互换使用,但在这个情况下它们是不同的事物。目录分隔符是一个字符,用于分隔整个目录名的不同部分。例如,c:\users\yourname\mydata路径包含三个\目录分隔符。路径分隔符用于当你需要在字符串中包含多个目录时。
一个很好的例子是%PATH%环境变量,它显示了 Windows 用于搜索可执行文件的所有目录。它们以路径分隔符分隔的长列表出现。在 Windows 和 Linux 中,所有这些字符都不同。
行结束符的问题也会发生类似的情况。Windows 使用两个字符——回车和换行('\r\n')。Linux 只使用换行符('\n')。如果你想确保你的代码在所有地方都能工作,请使用以下代码:
$"End of the output: {Environment.NewLine}".Dump(consoleColor);
这就解决了你的问题。
为 Linux 编写服务
这本书已经多次提到了后台进程。我们看到了如何编写它们以及如何部署它们。但在 Linux 上是如何工作的呢?让我们找出答案!
Linux 中的后台服务被称为守护进程。这种软件在后台运行,不会立即与用户交互。这听起来就像是我们作为系统程序员应该认识的东西。
我们可以在 Visual Studio 中创建一个 Worker Service 来编写这样的软件。构建它,然后将其部署到 Linux 发行版上的一个文件夹中。
服务描述
在你做那之前,向项目中添加一个新文件;这是 Linux 需要注册你的服务的系统描述。
我将我的文件命名为crossplatformservice.service。它看起来像这样:
[Unit]
Description=My .NET Core Worker Service
After=network.target
[Service]
WorkingDirectory=/home/dvroegop/service
ExecStart=/usr/bin/dotnet /home/dvroegop/service/14_CrossPlatformService.dll
Restart=always
# Restart service after 10 seconds if the dotnet service crashes:
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=crossplatformservice
User=dvroegop
Environment=ASPNETCORE_ENVIRONMENT=Production
[Install]
WantedBy=multi-user.target
显然,你应该确保这个文件中的路径与你的一致。我怀疑你的机器上没有名为dvroegop的文件夹。让我们调查一下这个文件的作用。
Unit部分包含服务的元数据和依赖项。Description是一个可读的描述。
After指定了服务应该何时启动。在这里,我们声明服务应该在网络初始化后启动。
Service部分配置了服务应该如何运行和管理。本节各部分的解释如下表所示:
| 元素 | 描述 |
|---|---|
WorkingDirectory |
这是应用程序将被执行的位置。可用于相对路径解析。 |
ExecStart |
这是启动服务的命令。 |
Restart |
定义重启策略。always表示在崩溃或意外停止后总是重启。 |
RestartSec |
这是重启服务前的延迟时间。 |
KillSignal |
定义用于终止服务的信号。SIGINT是最常用的一个;我们稍后会探讨这个问题。 |
SyslogIdentifier |
为服务的日志条目设置一个名称。 |
User |
以指定用户运行服务。 |
Environment |
设置服务所需的环境变量。 |
表 14.7:服务描述的服务元素
最后,我们有Install部分。这表明服务应该如何以及何时安装和启动。WantedBy元素指定了此服务应链接到的目标。在我们的例子中,我们使用了multi-user.target,这意味着它在多用户、非图形环境中运行。这对我们这样的服务来说是典型的。
确保将此文件添加到您的部署中。
安装服务
一旦您在 Linux 发行版上有了二进制文件和服务描述文件,我们就可以安装服务。在终端中,执行以下操作。
将服务描述文件移动到正确的目录。如果您在您的发行版的发布目录中,请发出以下命令:
sudo mv crossplatformservice.service /etc/systemd/system
您需要使用sudo;普通用户没有权限访问该文件夹。
现在,我们必须重新启动系统管理器配置。使用以下命令执行此操作:
sudo systemctl daemon-reload
一旦配置重新加载并读取了我们的服务描述文件,我们就可以启用服务:
sudo systemctl enable crossplatformservice
名称crossplatformservice是在描述文件中的SyslogIdentifier设置中使用的。
如果 Linux 系统重启,我们的服务也将启动。但您不必重启——您也可以手动启动服务来查看一切是否正常。使用以下命令执行此操作:
sudo systemctl start crossplatformservice
结果可能令人失望;您什么也看不到。但您可以使用此命令来验证一切是否按预期进行:
sudo systemctl status crossplatformservice
此命令返回状态,确认一切按预期进行。
如果您想了解更多信息,可以查看日志文件。所有日志都由 Linux 收集,使用以下命令获取:
sudo journalctl -u crossplatformservice
此命令显示日志中的最后条目。由于许多应用程序使用日志,我们可以过滤结果,只显示属于我们服务的条目。这正是-u参数的作用。
您应该在屏幕上看到预期的数据以确认服务正常工作!
卸载服务
您可能想从您的开发机器上删除服务。这并不太难;只需逆转我们刚才采取的步骤即可。
-
首先,停止服务:
sudo systemctl stop crossplatformservice -
然后,禁用服务:
sudo systemctl disable crossplatformservice -
删除服务描述文件:
sudo rm /etc/systemd/system/crossplatformservice. Service -
之后,重新加载守护进程配置:
sudo systemctl daemon-reload -
就这样。为了验证服务是否真的被删除,使用此命令:
sudo systemctl status crossplatformservice
最后一条命令应该返回错误,因为我们的服务已经不再存在。
处理信号
在服务描述文件中,我们告诉系统我们的应用程序可以通过SIGINT信号停止。但那其实并不准确,因为我们还没有做任何处理信号的操作。
什么是信号?
信号可以与 Windows 机器上的事件相比较,或者如果你还记得前面的章节,Windows 消息的实例。换句话说,它们是发送到您的应用程序的消息。有些是预定义的,而有些是用户定义的,这意味着您也可以使用它们在程序之间进行通信。在这种情况下,我们正在讨论 Linux 中最常用的两个消息。就是这样。
信号是操作系统向您的应用程序或服务发送消息的方式。最常用的两个信号是SIGINT和SIGTERM。第一个,SIGINT请求中断——操作系统想要停止服务。第二个,SIGTERM旨在立即停止应用程序。我同意很难看出这两个之间的区别,但这里是有逻辑的——SIGINT通常是用户做出某种操作的响应,例如按Ctrl + C。你可以说是用户负责发送SIGINT。如果操作系统或其他服务认为我们的服务需要终止,则SIGTERM信号来自操作系统或其他服务。
我们必须编写代码来处理这些信号,并使我们的应用程序表现得更好。
要做到这一点,我们必须导入一个 NuGet 包。在这种情况下,我们需要Mono.Posix.NETStandard。
一旦你完成了这些,就去Worker类中,并添加以下方法到该类中:
private void RegisterSignalHandlers()
{
// This is the default behavior for SIGTERM
AppDomain.CurrentDomain.ProcessExit +=
(sender, eventArgs) => $"Process exit".Dump();
// Handle the signals
UnixSignal[] signals =
{
new(Signum.SIGINT),
new(Signum.SIGTERM)
};
var signalThread = new Thread(() =>
{
while (true)
{
var index = UnixSignal.WaitAny(signals);
SignalHandler(signals[index].Signum);
}
})
{
IsBackground = true
};
signalThread.Start();
}
这种方法做了两件事。首先,它为“正常”的ProcessExit事件注册了eventhandler。当进程需要终止且是.NET 运行时的一部分时,会调用此事件。在 Linux 中,当使用SIGTERM时,会调用此事件。
接下来,我们告诉系统监听SIGINT和SIGTERM信号。我们创建一个包含这些值的数组并启动一个新的后台线程。线程所做的只是等待这些信号的到来。当它们到来时,它调用SignalHandler()方法。这个方法看起来是这样的:
private void SignalHandler(Signum signal)
{
switch (signal)
{
case Signum.SIGINT:
_logger.LogInformation("Received SIGINT");
break;
case Signum.SIGTERM:
_logger.LogInformation("Received SIGTERM");
break;
default:
_logger.LogInformation($"Received signal {(int)signal}");
break;
}
Environment.Exit(0);
}
这种方法足够简单——我们写入日志表示我们已接收并终止程序。
在Worker类的构造函数中,我们添加了对RegisterSignalHandlers()的调用,然后我们就可以开始了。
在 Linux 上运行程序(不是作为服务,而是作为常规程序),按Ctrl + C,并注意显示的消息,告诉我们我们已经成功捕获了信号。酷吧,不是吗?
总结一下
现在,你应该有了开始编写 Linux 程序所需的所有知识。Linux 是许多服务的首选平台。服务,当然,是我们作为系统程序员经常打交道的东西。虽然是一个伟大的平台,但 Linux 的学习曲线很陡峭。许多事情与你在 Windows 上习惯的相似,但略有不同,而其他事情则是全新的或该平台独有的。
学好它需要时间。但通过本章我们讨论的内容,你已经走在熟悉 Linux 的道路上了。我们探讨了该平台的历史,并讨论了一些最常用的命令。我们讨论了开发,并查看如何为 Linux 编写守护进程。
让我们回顾一下
休息一下。深呼吸。你已经做到了。你已经到达了这本书的结尾。我希望你学到了一些东西。
不要低估我们所做的事情。我们已经回答了这么多问题:
-
系统编程是什么?
-
我们如何使用底层 API?
-
我们如何使用 Win32 API?
-
我们如何让我们的软件运行得更快?
-
我们如何让我们的软件内存高效?
-
I/O 是什么?我们如何使用它?
-
在网络上进行系统通信的最佳方式是什么?
-
我们如何监控和记录系统正在做什么?
-
调试这些底层系统的最终方法是什么?
-
我们该如何部署所有这些内容?
-
我们如何与 Linux 合作?
这里有大量的信息!但你接受了挑战。你可以称自己为擅长编写快速执行、内存高效、网络感知、安全且跨平台的底层系统软件的专家。这是一个很长的头衔,但你当之无愧!
并且不要忘记——这些新获得的技能使你成为了一名更好的开发者。这些技能可以应用于各种项目,而不仅仅是系统编程。基本原理是站得住脚的,并且适用于任何地方。我期待着听到你的消息,了解你打算如何利用你所学的知识。学习新技术的最佳方式是尝试它们。所以,我敦促你尝试这些示例,编写出色的系统软件。我对你有信心!


浙公网安备 33010602011771号