原文:zh.annas-archive.org/md5/56ec4473fdab4d33463dddef4fa20d6b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

当人们想到软件时,大多数人会想到图形用户界面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 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不仅限于此,您还可以获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_QR_Free_PDF.jpg

packt.link/free-ebook/978-1-83508-268-3

  1. 提交您的购买证明

  2. 那就足够了!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件

系统编程概述

所以,你想学习.NET 中使用 C#的系统编程。至少,我假设你想学习这个;你很可能读了这个书的标题,并决定这是一个好选择。也许你已经涉足系统编程,并想提高这方面的技能。或者,也许你还没有接触过这个主题,并想开始。或者,也许你选错了书。如果是后一种情况,我希望你还有收据,这样你可以退回这本书并得到其他东西。对于所有人:欢迎!

让我们定义一下系统编程

在我们深入系统编程的细节之前,我们需要做好铺垫。我们需要对一些事情有一个共同的理解。例如,“系统编程”这个术语到底是什么意思?它是用来做什么的?它是为谁准备的?

让我从定义开始。

系统编程是系统的编程。这在技术上可能是正确的,但我认为这并不能帮助我们前进。

让我们分解一下:什么是系统

那个很简单。我们构建系统已经有很多年了,所以我们知道我们所说的系统是什么意思。

让我给你展示一个定义:

系统是一组或排列相关或连接的物品,以便形成一个统一体或有机整体。它是一组相互作用的组件或部分,以实现功能。这个术语在物理学、生物学、计算机科学和商业管理等各个领域都有使用,每个领域都有略微不同的含义。

很好。但这个定义有点宽泛。我们可能想专注于计算机科学或软件开发。没问题;也有几个定义可以选择:

系统是一组相互交互以执行特定功能或一系列功能的软件组件。

这样就更好了。如果我们进一步深入探讨,我们可以区分不同的系统组:

  • 软件系统:这是一个集成的软件组件集合,它们一起执行特定的功能或一系列功能。这些组件可以是数据库服务器、微服务和一个前端。这些组件构成了完整的系统,例如 CRM 系统、源代码控制系统和其他类似系统。

  • 操作系统(OSs):你可能知道什么是操作系统。我认为你看到这个术语太多次了,以至于你甚至没有意识到它是一个系统。但确实是一个包含许多部分和组件的操作系统,如驱动程序、工具、辅助工具和日志。它们共同提供了一个系统,你可以作为用户使用它来运行你的软件,而不依赖于硬件。

  • 分布式系统:我们通常将网络上松散连接的组件称为分布式系统。每个部分都是相互隔离的,但它们必须协作以实现有价值的目标。例如,Azure DevOps 在 Azure 云中的许多不同服务器上运行。所有组件可能运行在不同的服务器和机器上,这些组件甚至可能运行在世界上的不同部分。然而,它们共同工作,为最终用户提供一个完整的解决方案。

  • 嵌入式系统:嵌入式系统通常是由硬件和软件的组合。组件之间紧密耦合。开发者通常编写软件以匹配特定的规格,以便最好地使用硬件。例如,想想你车上的系统。如果你有一辆相当新的车,你无疑在车上有一个娱乐系统。在“娱乐系统”这个词中,“系统”这个词有点提示:它由许多不同的组件组成。很可能有一个设备可以从空气中收集电磁波(我们称之为收音机)。该设备连接到一些软件,这些软件解释这些波并将其转换为电信号以供扬声器使用。旁边,一个组件会向你,作为用户,显示你正在听的内容。我确信你可以在你的车上找到很多其他系统,也许在你的电视、手机或冰箱上也能找到。

有更多的例子,但我希望你能看到,一个系统总是由单独的组件组成,这些组件单独使用时没有用处,但结合在一起,可以解决一个问题。

但等等。我们还没有完成。

根据这些定义和例子,你可能会认为系统编程的艺术就是这些系统的编程,你不会错。但通常情况下,系统编程并不是这个意思。我所说的系统编程绝对不是这个意思。

大概、非常粗略地来说,我们可以将软件分为两种类型:

  • 面向用户的软件:这是一种为人们编写的软件。它有一个用户界面(UI),包括按钮、列表、标签等。人们通过使用各种输入方式与软件进行交互。

  • 面向软件的软件:这是一种为其他软件设计的软件。由于我们没有用户,所以没有用户界面(UI)。我们可以说是其他组件是用户,但当我提到用户时,我指的是人。面向软件的软件通过 API、RPC(远程过程调用)调用、文件传输以及许多其他方式与其他组件交互。在这个过程中不涉及任何人类。

这本书中我们最感兴趣的第二种类型——软件是为了被其他软件使用的。

系统何时是面向用户的,何时不是?

并非总是清楚人们是某些代码的主要用户还是其他进程。我们可以非常严谨地说,任何带有 UI 的东西都是面向用户的;其他一切都是面向系统的。如果我们想要一个明确的定义,这将使生活更容易。然而,在现实世界中,边界往往变得模糊。

让我给你举一个例子。看看这个 Visual Studio 解决方案:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_01_01.jpg

图 0.1:带有计算器项目的解决方案资源管理器

在这里我们有一个非常、非常简单的解决方案。它有一个名为MyAwesomeCalculator的主程序,其中包含主代码。这是我们的应用程序的入口点,使用控制台作为 UI。所有逻辑都在MathFunctions类库中。这就是魔法发生的地方。

如果我们回到我们对系统编程的定义,我们可以说编写MathFunctions类库是系统编程的一部分。毕竟,没有用户会与该库中的类和接口交互。真正使用它的代码是MyAwesomeCalculator中的代码。

太棒了!这意味着编写MathFunctions库是系统编程!嗯,但别急。如果我们看看解释流程的序列图,我们可能会得出另一个结论。图 0.2展示了这个序列图。

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_01_02.jpg

图 0.2:我们的计算序列图

正如你在图 0.2中看到的,用户启动了一个操作:他们想要将两个数字相加。他们在Main类的 UI 中输入它。然后Main类实例化Adder类的一个实例。在创建之后,Main类调用AddUp(a,b)方法。结果被传回Main类并展示给用户。在这之后,我们可以丢弃Adder实例。

太棒了。边界在哪里?如果我们这样看,我们可以说Adder中的代码以及因此MathFunctions库中的代码与用户操作直接相关。所以,它是面向用户的代码,而不是面向系统的代码。

我仍然喜欢用谁在使用代码的问题来确定我们正在编写什么类型的软件。但显然,这还不够。我们需要深入一点。

MyAwesomeCalculatorMathFunctions中的代码在单独的组件中。用户与一个组件交互;另一个组件仅通过代码访问。但它们仍然可以被视为一个整体。如果我们运行应用程序,运行时将为我们创建AppDomain

.NET 中的AppDomain与.NET Framework 中的AppDomain不同。后者有更多方式将代码彼此隔离。这很好,但它是一个典型的 Windows 功能。这不太适合其他平台。因此,为了使.NET 应用程序在其他平台上运行,它们需要重新设计这一点。这导致AppDomain比以前不那么限制性。尽管如此,AppDomain仍然是不同进程之间的逻辑边界。代码在一个应用程序域中运行,不能直接访问其他应用程序域。

这里,我们又有另一个线索:我们的MyAwesomeCalculator应用程序和相关的MathFunctions程序集都在同一个AppDomain中运行。对于操作系统来说,它们是同一个。由于我们决定实际的人使用Main方法,所以这也适用于该特定AppDomain中的所有其他代码片段。

让我们稍微重写一下我们的解决方案。请看下面的截图。

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_01_03.jpg

图 0.3:带有工作进程的我们的解决方案

我们移除了包含所有工作的代码的类库。相反,我们创建了一个新的项目。这个项目是一个工作进程。技术上,我应该保留那个类库并引用它,但我希望保持事情简单。

工作进程是一个始终运行的后台进程(在技术上并不完全正确,但就现在而言,这已经足够了)。它只是坐在那里什么也不做。然后,突然,发生了有趣的事情,它活跃起来,完成工作,然后再次进入空闲模式。

图 0**.4所示,在这种情况下,序列图也有所不同。

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_01_04.jpg

图 0.4:新修订架构的序列图

MyAwesomeCalculatorMathFunctionServices工作进程现在是相互独立的。它们各自在自己的AppDomain中运行。当用户想要执行计算时,他们在 UI 中输入,这会调用服务。Worker类获取命令,创建Adder类的实例,调用AddUp方法,然后再次使用结果调用MyAwesomeCalculator

如您所见,所有类之间的调用都是同步的(由实心箭头线表示),除了MainWorker之间的调用。这是异步的(由线和开放箭头表示)。

这是有道理的;计算器无法知道命令是否到达或服务是否正在监听。它只是发射并忘记,交叉着数字手指,希望一切顺利。

这更接近了。这确实是编写供其他软件使用的软件(我在这里谈论的是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计数器,我会得到这个图像。

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_01_05.jpg

图 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。有关如何操作的说明在网上到处都是;我不会在这里重复。

一旦你安装了所有你喜欢的工具,你可以在你的终端中选择其中任何一个。我的看起来像这样:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_01_06.jpg

图 0.6:不同壳的 Windows Terminal

如你所见,我已经安装了 PowerShell命令提示符UbuntuAzure 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命名空间,其中包含ObjectStringArray等类。

  • 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:

  1. 找到你想要使用的 API。

  2. 找到 API 所在的 DLL。

  3. 在你的程序集加载该 DLL。

  4. 声明一个存根,告诉你的应用程序如何调用该 API。

  5. 将.NET 数据类型转换为 Win32 API 可以理解的形式。

  6. 使用 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 中。我们知道如何导入它,我们可以在我们的代码的第 1112 行中看到它:

[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr GetStdHandle(int nStdHandle);

没有字符串,所以我们不需要设置 CharSet。然而,我们需要设置 SetLastError

查找地址的方法被称为 GetStdHandle,它接受一个参数:nStdHandle。这告诉此 API 我们正在寻找哪种类型的控制台。有三种类型可供选择:STD_INPUT_HANDLE, STD_OUTPUT_HANDLESTD_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 变量会告诉我们写入了多少个字符(第 2324 行)。

然后,在 第 26 行,我们调用实际的 API:

if (!WriteConsole(
    stdHandle,
    output,
    (uint)output.Length,
    out charsWritten,
    IntPtr.Zero))
{
    Console.WriteLine("Failed to write using Win32 API.");
}

现在应该很清楚了。我们调用 API,给它正确的参数,并检查结果是否为0IntPtr.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 的地方。我们给它传递控制台的句柄——或者更确切地说,我们认为我们这样做。相反,我们传递了一个值为 -10xffffffffffffffff)。这不是 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 ClassIdCOM 对象未找到的内容。不幸的是,你得到的是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 INTLONG 32 位有符号整数
uint UINTDWORD 32 位无符号整数。也用于标志和枚举。
long LONGLONG 64 位有符号整数。
ulong ULONGLONG 64 位无符号整数。
float FLOAT 32 位浮点数。
double DOUBLE 64 位浮点数。
char WCHARTCHAR 在 C# 中为 16 位 Unicode 字符,而在 Win32 中 WCHAR/TCHAR 则不同。
bool BOOL 布尔类型。在 C# 中为 TrueFalse,在 Win32 中通常为 TRUEFALSE。在这里,FALSE 被定义为 0,而 TRUE 被定义为 NOT FALSE,即任何其他值,但通常为 1
IntPtr HANDLEHINSTANCEHWND 表示指针或句柄。类型根据上下文而变化。
UIntPtr 在 Win32 中很少使用 无符号指针或句柄。
T[] T*SAFEARRAY T 类型的数组。其在 Win32 中的表示取决于上下文。
DateTime FILETIMESYSTEMTIME 表示日期和时间。在 Win32 中的表示不同。
Guid GUIDUUID GUID。128 位数字。(GUID 通常与 Windows 平台相关联,而 UUID 在其他平台上找到。尽管如此,它们基本上是相同的。)
TimeSpan 通常由 DWORDs 的组合表示 时间间隔。在 Win32 上不可用。

表 1.2:C# 和 Win32 类型

如你所见,大多数类型可以轻松地在平台之间转换。当我们深入研究更复杂的类型时,事情会变得有点复杂,因为许多类型都依赖于上下文或实现。这使得在平台之间传输类型具有挑战性。

另一个需要考虑的是所谓的调用约定。调用约定定义了在调用函数时如何处理参数。最常见的是 stdcallcdecl。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 来指定它。

其他类型包括 WinAPIStdCall(它们基本上是相同的)、ThisCallFastCall。你几乎不会遇到后两种,但至少你现在已经听说过它们了。

当你调用 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 可能会给你带来所需的额外性能。我认为这一点非常重要,所以我专门写了一章关于性能的内容,恰好是下一章。我们生来就是为了奔跑,所以让我们尽可能快地奔向下一部分!

第二章:速度至上的那一章

为性能而写作

大多数用户都认为应用程序永远不会足够快。每次你与人谈论软件中的烦恼,无论是性能还是缺乏性能,它都是排在最前面的一个问题。

这是有道理的:我们都很忙,我们当然不希望浪费时间等待机器赶上我们。它必须反过来!

但如果你仔细想想,你会意识到计算机在合理的时间内做任何事情都是一个奇迹。如果你觉得自己很忙,看看计算机需要做的一切!你可以进行以下实验:

  1. 重新启动你的电脑。

  2. 登录。

  3. 启动(如果你使用的是 Windows)任务管理器(提示:使用Ctrl + Shift + Esc组合键)。

  4. 看看在“后台进程”部分有多少事情在进行。

所有这些进程都是系统编程的例子。它们都在那里帮助系统完成其工作或帮助面向用户的软件完成任务。而且有很多这样的进程。这些进程都占用一点 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 + KG。这样做会打开反汇编器。

你会得到类似这样的结果(我已经删除了一些对我们来说不是很有用的代码):

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.MaxValueUInt32.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.Int32System.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。乍一看,你可能会期望stuffint类型,因为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()

SortedListSortedDictionaryDictionary 中的键必须是唯一的。如果你的用例允许这样做,这些集合可以发挥神奇的作用,但前提是你选择了正确的一个。以下表格比较了这三种类型在性能方面的差异:

属性 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:基于键的集合

再次,检查你的需求和基准测试,看看什么最适合你。

字典或最后的元组/对象

ListDictionary 是不同的事物,但通过一些重写,你可以使用两者来实现你的目标。

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.ConcatString.Join

我说当你在循环中连接字符串时应该使用StringBuilder。但是如果你不在循环中,只想向字符串添加一次,创建一个StringBuilder对象就有点过度了。在这种情况下,你应该使用String.ConcatString.Join

只为了清楚起见:如果你在循环中,使用StringBuilderStringBuilder对象是连接字符串最快的方式。但是创建一个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;
}

我相信你同意这并不是一个令人兴奋的方法。然而,调用它却需要花费很多时间:调用方法必须存储返回地址,将所有参数(整数值,ab)移动到正确的位置,跳转到方法,检索参数,执行实际工作,将返回值存储在正确的位置,检索返回地址,跳转到那个返回地址,并将结果赋值给调用方法中的变量。

编译器知道这一点。所以,在这种情况下,它可能会优化它并“内联”它。但如果你认为编译器不知道这一点,你可以指示它更仔细地查看代码,并对此更加积极。你可以这样做:

[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文件中将它设置为选项。这样做最好的方式是将它添加到项目属性中:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_03_01.jpg

图 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++的背景。

以下图将帮助您理解在运行上述代码片段时发生的情况:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_04_01.jpg

图 3.1:空的、已分配的堆

在程序启动期间,CLR 分配了一个连续的内存块。这个块不是很大,但足够容纳所有启动对象,以及它确定需要的任何其他东西。到那时,创建了一个指针,指向项目可用的第一个区域。

在第 1 行,我们开始一个代码块。然后,在第 2 行,我们创建一个Object类型的实例并将其存储在a变量中。属于该对象的所有数据内存都位于堆上。运行时初始化,计算a应该占用多少内存,并将分配指针移动到块中下一个可用的内存块。在栈上创建了一个指针(我们称之为a),该指针指向堆上存储其数据内存块:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_04_02.jpg

图 3.2:创建对象 a 后的堆

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

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_04_03.jpg

图 3.3:变量 a 超出作用域后的堆

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

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_04_04.jpg

图 3.4:分配对象 b 时的堆

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

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sys-prog-csp-dn/img/B20924_04_05.jpg

图 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
};

我用来分配值的表示法让我想起了我在序列中的位置:通过左移,我可以轻松地对项目进行编号(012)。

位移动

在系统编程中,我们经常处理位和字节。因此,你应该了解这种表示法。

<< 运算符将一个字节的全部位向左移动一位,实际上是将值乘以 2。所以,1 << 0 不移动任何位,1 << 1 将所有位移动一位,结果为值 2,而 1 << 2 将位移动两位,结果为 4。在二进制中,结果是 000000010000001000000100

我们可以这样设置一个变量:

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; }
}

MessagePackObjectKey 属性来自 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 工作得不够好的情况,这时这里讨论的主题就能帮到你。但请务必非常小心。管理内存如果出错可能会导致奇怪甚至灾难性的后果。

在调整内存使用之前,你应该测试和基准测试你的代码。但如果你遵循我的建议和忠告,你可以得到非凡的结果!然而,一旦你的系统中有多线程,事情就会变得复杂得多。我们需要讨论线程。很多。这正是我们将在下一章中要做的!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐