一文透彻详解.NET框架类型系统设计要点
- 技术交流
- 2024-10-01 09:16:02
目录
- 引言
- .NET 设计要点
- .NET 堆栈的支柱
- 类型系统
- 自动内存管理
- ▌Bump 指针分配
- ▌分代收集
- ▌压缩
- ▌并行
- ▌并发
- ▌固定
- ▌独立 GC
- ▌诊断
- 安全
- 错误处理
- 并发
- 反射
- 编译后的二进制格式
- 代码生成
- 互操作
- 二进制分布
- 概括
引言
自从我们启动快速发展的 .NET 开源和跨平台项目以来,.NET 发生了很大变化。我们重新思考并完善了该平台,添加了专为性能和安全性而设计的新低级功能,以及以生产力为中心的高级功能。Span
本系列的第一篇文章全面概述了平台的支柱和设计要点。当您选择 .NET 时,它在基础级别上描述了"您得到了什么",旨在成为一个充分且以事实为中心的框架,您可以使用它来向其他人描述该平台。后续帖子将更详细地介绍这些相同的主题,因为这篇帖子并没有完全公正地介绍这些功能中的任何一个。这篇文章不描述工具,如 Visual Studio,也不涵盖更高级的库和应用程序模型,如 ASP.NET Core 提供的那些。
我们所说的".NET"是现代的 .NET Core。我们在 GitHub 上作为开源项目于2014年启动了这个项目。它在 Arm64、x64 和其他芯片架构上的 Linux、macOS 和 Windows 上运行。它在一堆 Linux 发行版中可用。它与 .NET Framework 保持了很大的兼容性,但又是一个全新的方向和产品。
.NET 设计要点
.NET 平台代表生产力、性能、安全性和可靠性。.NET 在这些价值之间取得的平衡使其具有吸引力。
.NET 的设计要点可以归结为在安全域(一切都高效)和不安全域(存在大量功能)中都有效和高效。.NET 可能是具有最多内置功能的托管环境,同时还提供最低的与外部世界互操作的成本,并且两者之间没有权衡。事实上,许多功能都利用了这种无缝划分,在底层操作系统和 CPU 的原始能力和功能上构建安全的托管 API。
我们可以进一步扩展设计点:
- 生产力是跨运行时、库、语言和工具的首要设计考虑因素。
- 安全代码是主要的计算模型,而不安全代码支持额外的手动优化。
- 支持静态和动态代码,支持广泛的不同场景。
- 本机代码互操作和硬件内在函数成本低且保真度高(原始 API 和指令访问)。
- 代码可跨平台(操作系统、芯片架构)移植,而平台定位则支持专业化和优化。
- 通过通用编程模型的专门实现,可以实现跨编程域(云、客户端、游戏)的适应性。
- OpenTelemetry 和 gRPC 等行业标准优于定制解决方案。
.NET 堆栈的支柱
运行时、库和语言是 .NET 堆栈的支柱。更高级别的组件,如 .NET 工具和应用程序堆栈,如 ASP.NET Core,构建在这些支柱之上。这些支柱具有共生关系,由一个团队(Microsoft 员工和开源社区)共同设计和构建,致力于这些组件的多个方面并为其提供信息。
C# 是面向对象的,运行时支持面向对象。C# 需要垃圾收集,运行时提供跟踪垃圾收集器。事实上,将 C#(以其完整形式)移植到没有垃圾收集的系统是不可能的。这些库(以及应用程序堆栈)将这些功能塑造成概念和对象模型,使开发人员能够在直观的工作流程中高效地编写算法。
C# 是一种现代的、安全的、通用的编程语言,涵盖了从面向数据的记录等高级功能到函数指针等低级功能。它提供静态类型以及类型和内存安全作为基准功能,同时提高开发人员的工作效率和代码安全性。C# 编译器也是可扩展的,支持插件模型,使开发人员能够通过额外的诊断和编译时代码生成来增强系统。
许多 C# 功能已经影响或受最先进的编程语言的影响。例如,C# 是第一个引入 async and await. 同时,C# 借鉴了其他编程语言中首先引入的概念,例如采用模式匹配和主构造函数等函数式方法。
核心库公开了数千种类型,其中许多类型与 C# 语言集成并为其提供动力。例如,C# 的 foreach 支持枚举任意集合,基于模式的优化使 List
C# 中的字符串插值既富有表现力又高效,与 string 、StringBuilder 和 Span
.NET 运行时最初称为"公共语言运行时 (CLR)"。它继续支持多种语言,一些由 Microsoft 维护(例如 C#、F#、Visual Basic、C++/CLI 和 PowerShell),一些由其他组织维护(例如 Cobol、Java、PHP、Python、Scheme)。许多改进与语言无关,这会引发所有改善。
接下来,我们将看看它们一起提供的各种平台特性。我们可以分别详细说明这些组件中的每一个,但您很快就会看到它们在交付 .NET 设计点方面进行合作。让我们从类型系统开始。
类型系统
.NET 类型系统提供了显著的广度,大致同等地满足了安全性、描述性、动态性和本机互操作性。
首先,类型系统支持面向对象的编程模型。它包括类型、(单个基类)继承、接口(包括默认方法实现)和虚拟方法分派,为面向对象允许的所有类型分层提供合理的行为。
泛型是一种普遍的特性,它允许将类专门化为一种或多种类型。例如,List
Delegates 和 lambdas 允许将方法作为数据传递,这使得将外部代码集成到另一个系统拥有的操作流中变得容易。它们是一种"胶水代码",它们的签名通常是通用的,可以广泛使用。
app.MapGet("/Product/{id}", async (int id) => { if (await IsProductIdValid(id)) { return await GetProductDetails(id); } return Products.InvalidProduct; });
这种对 lambdas 的使用是 ASP.NET Core Minimal APIs 的一部分。它可以直接向路由系统提供端点实现。在更新的版本中,ASP.NET Core 更广泛地使用了类型系统。
与 .NET 的 GC 管理类型相比,值类型和堆栈分配的内存块提供了对数据和本机平台互操作的更直接、低级别的控制。.NET 中的大多数原始类型,如整数类型,都是值类型,用户可以定义自己具有相似语义的类型。
.NET 的泛型系统完全支持值类型,这意味着像 List
byte magicSequence = 0b1000_0001; Spandata = stackalloc byte[128]; DuplicateSequence(data[0..4], magicSequence);
此代码生成堆栈分配的值。Span
Ref 类型和变量是一种小型编程模型,它提供对类型系统数据的较低级别和更轻量级抽象。这包括 Span
internal readonly ref T _reference;
这种使用 ref 导致将指针复制到底层存储,而不是复制该指针引用的数据。默认情况下,值类型是"按值复制"。ref 提供"按引用复制"行为,可以提供显著的性能优势。
自动内存管理
.NET 运行时通过垃圾收集器 (GC) 提供自动内存管理。对于任何语言,其内存管理模型可能是其最具决定性的特征。.NET 语言也是如此。
工程师花费数周甚至数月的时间来追踪这些问题的情况并不少见。许多语言使用垃圾收集器作为消除这些错误的用户友好方式,因为 GC 确保正确的对象生命周期。通常,GC 会分批释放内存以高效运行。这会导致暂停,如果您对延迟要求非常严格,这可能不适合,并且内存使用率会更高。GC 往往具有更好的内存局部性,并且某些 GC 能够压缩堆,使其不易产生内存碎片。
.NET 具有自我调整、跟踪 GC。它旨在一般情况下提供"放手"操作,同时为更极端的工作负载提供配置选项。GC 是多年投资、改进和从多种工作负载中学习的结果。
▌Bump 指针分配
通过指针递增所需的大小分配对象(而不是在分离的空闲块中寻找空间),因此一起分配的对象往往会在一起。由于用户经常一起访问不同对象,这样做可以实现更好的内存局部性 memory locality ,这有利于保证性能。
▌分代收集
对象生命周期遵循分代假设 generational hypothesis 是非常常见的,对象生存周期要么很长,要么很短。因此,对于 GC 来说,如果大部分运行时只收集临时对象占用的内存(称为临时 GC ),而不是每次运行时都必须收集整个堆(称为完整 GC ),那么效率就要高得多。
▌压缩
相同数量的可用空间在面积大而数量少的块中比在面积小和数量多的块中更有用。在压缩 GC 期间,仍然存在的对象会被移动到一起,由此可以形成更大的自由空间。这种行为需要比非移动 GC 更复杂的实现,因为它需要更新对这些移动对象的引用。.NET GC 被动态调整为仅在确定回收的内存高于 GC 成本时才执行压缩。这意味着临时集合通常会被压缩。
▌并行
GC 工作可以在单个线程或多个线程上运行。Workstation flavor 在单个线程上进行 GC,而 Server flavor 在多个 GC 线程上进行,这样可以更快结束作业。服务器 GC 还可以适应更大的分配率,因为有多个堆供应用程序分配,因此它对吞吐量适应性也很好。
▌并发
在用户线程暂停时进行 GC 工作称为 Stop-The-World,这样使实现需求更简单,但这些暂停可能对于 GC 来说是不可接受的。.NET 提供 concurrent flavor 来缓解该问题。
▌固定
.NET GC 支持对象固定,它可以实现与本机代码的零拷贝互操作。此功能可实现高性能和高保真度的本机互操作,同时限制 GC。
▌独立 GC
可以使用具有不同机制的独立 GC(通过配置指定并满足 interface requirements)。这样一来,调查和尝试新功能就更容易了。
▌诊断
GC 提供有关内存和集合的大量信息,这允许您将数据与系统的其余部分相关联。例如,您可以通过捕获 GC 事件并将它们与其他事件(如 IO)相关联来评估 GC impact of your tail latency 尾部延迟对 GC 的影响,以计算 GC 对其他因素的影响程度,这样您就可以将精力集中在正确的组件上。
安全
.NET 编程安全一直是过去十年的热门话题之一。它是 .NET 等托管环境的固有组件。
安全形式:
- Type safety 类型安全 — 不能使用任意类型代替另一个类型,避免未定义的行为。
- Memory safety 内存安全 — 不能使用任意类型代替另一个类型,避免未定义的行为。
- Concurrency or thread safety 并发或线程安全 — 不能使用任意类型代替另一个类型,避免未定义的行为。
.NET 从最初的设计开始就被设计成一个保证安全的平台。特别需要指出的是,它旨在启用新一代 Web 服务器,这些服务器一直需要在世界上复杂的计算环境(Internet)中接受不受信任的输入的考验。现在普遍认为网络程序应该用安全的语言编写。
类型安全由语言和运行时模块同时强制执行。编译器验证静态不变量,例如分配不同的类型——例如,分配 string 给 Stream——这将导致编译器中产生错误。运行时验证动态不变量,例如不同类型之间的转换,就将产生 InvalidCastException。
内存安全主要由代码生成器(如 JIT)和垃圾收集器合作实现。变量引用值要么是活动对象,要么是 null,要么超出范围。默认情况下内存是自动初始化的,这样新对象就不会使用未初始化的内存。边界检查禁止访问数组中无效索引的元素读取未定义的内存——通常由一个单位的错误偏移引起——这会导致 IndexOutOfRangeException。
Cnull 处理是保证内存安全的一种特殊形式。可空引用类型 Nullable reference types 是一种 C# 语言和编译器功能,可静态标识未安全处理的代码 null。特别是,如果您取消引用可能为 null 的变量,编译器会发出警告。您还可以禁止 null 赋值,这样编译器会在您可能给变量赋空值时发出警告。运行时具有匹配的动态验证功能,可通过抛出 NullReferenceException 来防止引用被访问。
C# 功能依赖于库中可为空的属性 nullable attributes 。它还依赖于它们在库和应用程序堆栈(我们已经完成)中的详尽应用,以便为您的代码提供来自静态分析工具的准确结果。
.NET 中没有内置的并发安全。相反,开发人员需要遵循模式和约定来避免未定义的行为。.NET 生态系统中还有分析器和其他工具,可以深入了解并发问题。核心库包括多种可以安全并发使用的类型和方法,例如支持任意数量的并发读取器和写入器而不会冒数据结构损坏风险的 concurrent collections 并发集合。
运行时公开安全和 unsafe code 不安全的代码模型。安全代码的安全性得到保证,这是默认设置,而开发人员必须选择使用不安全代码。不安全代码通常用于与底层平台互操作、与硬件交互或对性能关键路径实施手动优化。
沙箱 sandbox 是一种特殊的安全形式,它提供隔离并限制组件之间的访问。我们依赖标准的隔离技术,如进程(和 CGroups)、虚拟机和 WebAssembly(具有不同的特性)。
错误处理
异常是 .NET 中的主要错误处理模型。异常的好处是错误信息不需要在方法签名中表示或由每个方法处理。
下面的代码演示了一个典型的模式:
try { var lines = await File.ReadAllLinesAsync(file); Console.WriteLine($"The {file} has {lines.Length} lines."); } catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException) { Console.WriteLine($"{file} doesn't exist."); }
正确的异常处理对于应用程序的可靠性至关重要。可以在用户代码中有意处理预期的异常,否则应用程序就会崩溃。崩溃的应用程序比具有未定义行为的应用程序更可靠。当您想找出问题的根本原因时,它也更容易诊断。
异常从错误点抛出,并自动收集有关程序状态的附加诊断信息。这些信息可用于交互式调试、应用程序可观察性和事后调试。这些诊断方法中的每一种都依赖于访问大量的错误信息和应用程序状态来诊断问题。
异常是为罕见的情况而设计的。这在一定程度上是因为它们的性能成本相对较高。它们不打算用于控制流,即使它们有时以这种方式使用。
异常(有一部分)依赖于取消。一旦观察到取消请求,它们就可以有效地停止执行并展开正在进行的调用堆栈。
try { await source.CopyToAsync(destination, cancellationToken); } catch (OperationCanceledException) { Console.WriteLine("Operation was canceled"); }
.NET 设计模式包括替代形式的错误处理,以应对异常的性能成本过高的情况。例如,int.TryParse 返回成功时其参数包含已解析的有效整数,Dictionary
错误处理和更普遍的诊断是通过低级运行时 API、higher-level libraries 和 tools 实现的。这些功能旨在支持更新的部署选项,例如容器。例如,dotnet-monitor 可以通过内置的面向诊断的 Web 服务器将运行时数据从应用导出到侦听器。
并发
支持同时做多件事是几乎所有工作负载的基础,无论是在保持 UI 响应的同时进行后台处理的客户端应用程序、处理成千上万同时请求的服务、响应大量同时刺激的设备,还是高驱动的机器并行处理计算密集型操作。操作系统通过线程为这种并发性提供支持,这使得多个指令流能够独立处理,操作系统管理这些线程在机器中任何可用处理器内核上的执行。操作系统还提供对执行 I/O 的支持,提供的机制使 I/O 能够以可扩展的方式执行,并且在任何特定时间都有许多"运行中"的 I/O 操作。
.NET 通过库和深度集成到 C# 中,在多个抽象级别提供此类并发和并行化支持。线程 Thread 类位于层次结构的底部,代表一个操作系统线程,使开发人员能够创建新线程并随后加入它们。线程池 ThreadPool 位于线程之上,允许开发人员考虑异步安排在线程池上运行的工作项,并这些线程的管理(包括从池中添加和删除线程,以及为这些线程分配工作项)放在运行时。Task 然后为任何异步执行的操作提供统一的表示形式,并且可以通过多种方式创建和连接;例如,Task.Run 允许在 ThreadPool 上运行安排委托并返回 Task 以表示该工作的最终完成,同时 Socket.ReceiveAsync 返回一个Task
异步编程支持也是 C# 编程语言的一流功能,它提供了 async 和 await 关键字,使编写和组合异步操作变得容易,同时仍然享受该语言必须提供的所有控制流结构的全部好处。
反射
反射是一种"程序即数据"范例,它能让程序的一部分根据程序集、类型和成员动态查询和/或调用另一部分。它对于后期绑定编程模型和工具特别有用。
以下代码使用反射来查找和调用 type。
foreach (Type type in typeof(Program).Assembly.DefinedTypes) { if (type.IsAssignableTo(typeof(IStory)) && !type.IsInterface) { IStory? story = (IStory?)Activator.CreateInstance(type); if (story is not null) { var text = story.TellMeAStory(); Console.WriteLine(text); } } } interface IStory { string TellMeAStory(); } class BedTimeStore : IStory { public string TellMeAStory() => "Once upon a time, there was an orphan learning magic ..."; } class HorrorStory : IStory { public string TellMeAStory() => "On a dark and stormy night, I heard a strange voice in the cellar ..."; }
此代码动态枚举实现特定接口的所有程序集类型,实例化每个类型的实例,并通过该接口调用对象的方法。代码本来可以静态编写的,因为它只查询它所引用的程序集中的类型,但要这样做,需要将所有实例的集合(也许是作为一个 List
反射可能是 .NET 中提供的最动态的系统。它旨在使开发人员能够创建自己的二进制代码加载器和方法分派器,其语义可以与静态代码策略(由运行时定义)相匹配或有所区别。反射公开了一个丰富的对象模型,它可以直接用于简单的用例,但随着场景变得更加复杂,您就需要更深入地了解 .NET 类型系统。
反射还启用了一种单独的模式,其中生成的 IL 字节代码可以在运行时进行 JIT 编译,有时用于以专用算法替换通用算法。有了对象模型和其他细节,它通常会被用于序列化器或对象关系映射器中。
编译后的二进制格式
应用程序和库被编译为 PE/COFF 格式的标准化跨平台字节码。二进制分发最重要的是性能特征。它使应用程序能够扩展到越来越多的项目。每个库都包含一个导入和导出类型的数据库,称为元数据,它对开发操作和运行应用程序都起着重要作用。
编译的二进制文件包括两个主要方面:
- 二进制字节码——简洁而规则的格式,无需在高级语言编译器(如 C#)编译后解析文本源。
- 元数据——描述导入和导出的类型,包括给定方法的字节代码的位置。
例如,对于开发,工具可以有效地读取元数据以确定给定库公开的类型集以及哪些类型实现了某些接口。此过程可加快编译速度,并使 IDE 和其他工具能够准确呈现给定上下文的类型和成员列表。
对于运行时,元数据使库能够延迟加载,方法体更是如此。上文讨论过的反射是元数据和 IL 的运行时 API。还有其他更适合工具的 API。
随着时间的推移,IL 格式一直保持向后兼容。最新的 .NET 版本仍然可以加载和执行由 .NET Framework 1.0 编译器生成的二进制文件。
共享库通常通过 NuGet 包分发。默认情况下,带有单个二进制文件的 NuGet 包可以在任何操作系统和体系结构上运行,但也可以专门用于在特定环境中提供特定行为。
代码生成
.NET 字节码不是机器可执行的格式,它需要通过某种形式的代码生成器使其可执行。这可以通过提前 (AOT) 编译、即时 (JIT) 编译、解释或转译来实现。事实上,这些都是今天在各种场景中使用的。
.NET 以 JIT 编译而闻名。JIT 在应用程序运行时将方法(和其他成员)编译为本机代码,并且仅在需要时才将其编译,因此得名"及时(just in time,缩写为 JIT)"。例如,一个程序在运行时可能只调用一种类型中几种方法中的一种。JIT 还可以利用仅在运行时可用的信息,如初始化的只读静态变量的值或程序运行的确切 CPU 模型,并且可以多次编译相同的方法,以便每次针对不同的目标进行优化,并从以前的编译中吸取教训。
JIT 为给定的操作系统和芯片架构生成代码。.NET 具有支持 Arm64 和 x64 指令集以及 Linux、macOS 和 Windows 操作系统等的 JIT 实现。作为 .NET 开发人员,您不必担心 CPU 指令集和操作系统调用约定之间的差异。JIT 负责生成 CPU 需要的代码。它还知道如何为每个 CPU 生成快速代码,操作系统和 CPU 供应商经常帮助我们做到这一点。
AOT 类似,只是代码是在程序运行之前生成的。开发人员选择 AOT 是因为它可以通过消除 JIT 完成的工作来显著缩短启动时间。AOT 构建的应用程序本质上是特定于操作系统和体系结构的,这意味着需要额外的步骤才能使应用程序在多个环境中运行。例如,如果您想支持 Linux 和 Windows 以及 Arm64 和 x64,那么您需要构建四个变体(以支持所有组合)。AOT 代码也可以提供有价值的优化,但总体不如 JIT 多。
代码生成器优化之一是内在函数。硬件内在函数就是 .NET API 直接转换为 CPU 指令的例子。这已在整个 .NET 库中普遍用于 SIMD 指令。
互操作
.NET 被特意设计用于与本机库的低成本互操作。.NET 程序和库可以无缝调用低级操作系统 API 或利用 C/C++ 库的庞大生态系统。现代 .NET 运行时专注于提供低级互操作构建块,例如通过函数指针调用本机方法的能力,将托管方法公开为非托管回调或自定义接口转换。.NET 也在这个领域不断发展,在 .NET 7 中发布了源代码生成的解决方案,进一步减少了开销并且便于使用 AOT。
下面的代码演示了 C# 函数指针的效率。
// Using a function pointer avoids a delegate allocation. // Equivalent to `void (*fptr)(int) = &Callback;` in C delegate* unmanagedfptr = &Callback; RegisterCallback(fptr); [UnmanagedCallersOnly] static void Callback(int a) => Console.WriteLine($"Callback: {a}"); [LibraryImport("...", EntryPoint = "RegisterCallback")] static partial void RegisterCallback(delegate* unmanaged fptr);
此示例使用 .NET 7 中引入的 LibraryImport 源代码生成器。它位于现有 DllImport 或 P/Invoke 功能之上。
独立包通过利用这些低级构建块(例如 ClangSharp、Xamarin.iOS 和 Xamarin.Mac、CsWinRT、CsWin32 和 DNNE )提供更高级别的特定于域的互操作解决方案。
这些新功能并不意味着内置运行时托管/非托管编组或 Windows COM 互操作等内置互操作解决方案没有用——我们知道它们有用,而且人们已经开始依赖它们。那些之前内置到运行时中的功能将继续按原样提供支持,只是为了向后兼容,我们没有进一步发展它们的计划。所有未来的投资都将集中在互操作构建块以及它们支持的特定领域和更高性能的解决方案上。
二进制分布
Microsoft 的 .NET 团队维护着多个二进制发行版,最近开始支持 Android、iOS 和 WebAssembly。该团队使用多种技术为这些环境中的每一个环境定制代码库。大多数平台是用 C# 编写的,这使得移植可以集中在相对较小的组件集上。
社区维护着另一套发行版,主要集中于 Linux 。例如,.NET 已包含在 Alpine Linux、Fedora、Red Hat Enterprise Linux 和 Ubuntu中。
概括
我们有几个版本进入现代 .NET 时代,最近发布了 .NET 7。我们认为,如果我们总结自 .NET Core 1.0 以来我们一直在平台的最低级别构建的内容,将会很有用。我们明确保留了原始 .NET 的精神,结果是一个新平台开辟了一条新道路,并为开发人员提供了新的和更多的价值。
让我们用最开始的话题结束本篇文章。.NET 代表四个值:生产力、性能、安全性和可靠性。我们坚信,当不同的语言平台提供不同的方法时,开发人员会得到最好的服务。作为一个团队,我们寻求为 .NET 开发人员提供高生产力,同时提供在性能、安全性和可靠性方面处于领先地位的平台。
这篇文章由 Jan Kotas、Rich Lander、Maoni Stephens 和 Stephen Toub 撰写,囊括了 .NET 团队同事的深刻见解和审阅。
以上就是一文透彻详解.NET框架类型系统设计要点的详细内容,更多关于.NET 框架类型系统的资料请关注讯客其它相关文章!
一文透彻详解.NET框架类型系统设计要点由讯客互联技术交流栏目发布,感谢您对讯客互联的认可,以及对我们原创作品以及文章的青睐,非常欢迎各位朋友分享到个人网站或者朋友圈,但转载请说明文章出处“一文透彻详解.NET框架类型系统设计要点”