本文翻译自 What is .NET, and why should you choose it?

原文作者尾注:This post was written by Jan Kotas, Rich Lander, Maoni Stephens, and Stephen Toub, with the insight and review of our colleagues on the .NET team.

声明
  • 翻译会在易读易懂的基础上略微修改原文的分段和用词
  • 出现专用名词或是难以翻译的地方会用括号标注原词
  • 想额外补充的地方会使用橙色卡片标注

前言

自从我们启动这个开源的跨平台项目以来,.NET 已经快速发展并发生了巨大的变化。我们重新构思并打磨了该平台,增加了许多底层功能以提高性能和安全性,同时也引入了很多提升开发效率的高级特性,比如,Span<T>硬件内在函数 (Hardware Intrinsics)可空引用类型等。现在我们推出一个新的博客系列,叫做“.NET Design Point”,以深入探讨当今 .NET 平台的基础概念和设计,以及这些设计如何为你当前编写的代码带来好处。

该系列的第一篇文章概述了 .NET 平台的支柱和设计理念:当你选择 .NET 时,在基础层面上“你能获得什么”。这篇文章旨在为你提供一个足够清晰、基于事实的框架,以便你可以用它向其他人介绍这个平台。后续的文章将深入讨论这些主题,因为仅靠本篇文章还不足以详尽地展示这些特性。同时,本篇不会涉及相关工具,例如 Visual Studio,也不会谈及像 ASP.NET 框架提供的库和应用模型。

后续文章:

在深入探讨之前,值得先聊一聊 .NET 的使用情况。目前,有数百万开发者使用 .NET 来开发运行在多种操作系统和不同芯片架构上的云端、客户端以及其他类型的应用程序。它也运行在一些大家耳熟能详的地方,比如 AzureStackOverflowUnity。尤其是在大型公司中,你会看到 .NET 的广泛应用。在很多地方,掌握 .NET 是一项非常有价值的技能,有助于你找到理想的工作。

.NET 设计要点

.NET 平台致力于在提升生产力、性能、安全性和可靠性的同时平衡这些优势,这正是它如此具有吸引力的原因。

.NET 的设计要点可以归结为:在注重生产力的安全领域和具备强大功能的不安全领域都同样高效且有效。.NET 或许是功能最丰富的托管环境,同时也能做到最低开销且不将就地与外界互操作。同时,许多特性都利用了这一点,在底层操作系统和 CPU 的原生性能之上构建安全的托管 API。

更进一步地说:

  • 全栈生产力:包括运行时、库、语言和相关工具来共同提升开发者的使用体验。
  • 安全的代码是主要的计算模型,同时允许使用不安全代码手动额外优化。
  • 支持静态和动态代码以满足广泛且多样化的场景需求。
  • Native 代码互操作性和硬件内在函数是低开销和高精度的(即直接访问底层 API 和指令)。
  • 代码具有跨平台(操作系统和芯片架构)的可移植性,同时支持对平台进行定制和针对性优化。
  • 通过通用编程模型的特定实现以达成在不同领域(云端、客户端、游戏)之间的适配
  • 优先使用业界标准解决方案(如 OpenTelemetrygRPC,而不考虑定制。

.NET 技术栈的支柱

运行时、库和语言是 .NET 技术栈的核心支柱。更高层的组件,像一些 .NET 工具和应用栈(如 ASP.NET Core),则构建于这些支柱之上。这些共生的支柱由一个团队(包括微软员工和开源社区)共同设计和开发,团队中的每个人在组件之间协同工作、互相启发。

C# 是一种面向对象的语言,同时 .NET 运行时为其提供了面向对象支持。C# 需要垃圾回收机制,则该运行时提供了跟踪垃圾回收器。事实上,若没有垃圾收集器 (GC, garbage collection),是无法将完整的 C# 移植到其他系统的。库(以及应用栈)则将这些底层的功能塑造为让开发者直观理解的概念和对象模型,从而更自然且高效地编写 C# 算法。

C# 是一门现代、安全、通用的编程语言,涵盖了从记录这样的高级特性到函数指针这样的底层功能。它也提供了静态类型检查,保证类型安全和内存安全,这些基础特性既提高了开发者的生产力,也提升了代码的安全性。此外,C# 编译器还具有可扩展性,支持插件模型,使开发者能够通过额外的诊断和编译时代码生成来扩展系统功能。

与当前的前沿编程语言互相影响产生了许多 C# 特性。例如,C# 是首个引入异步编程(asyncawait)的主流编程语言。同时,C# 也借鉴了其他编程语言中首次引入的一些概念,如采用了函数式编程中的模式匹配主构造函数

核心库中有上千种类型,其中许多类型都与 C# 紧密结合。比如,使用 C# 的 foreach 语法来枚举任意集合;并提供了基于模式的优化(pattern-based optimizations),以能够简洁高效地处理诸如 List<T> 这样的集合。资源管理则可以交由GC完成,而通过 IDisposable 接口和语言层面的 using 语法支持也能实现。

C# 中的字符串插值既富有表现力又高效,这得益于核心库中(如 stringStringBuilderSpan<T>)类型的集成与实现。语言集成查询(LINQ)则通过库中成百上千的序列处理方法来实现,比如 WhereSelectGroupBy;LINQ 同时采用可扩展的设计,支持内存数据处理和远程数据源的操作。

这些例子仅仅触及了 .NET 核心库所提供的功能的冰山一角,比如从压缩到加密、正则表达式等的各类实现。一个全面的网络栈几乎是一个独立的领域,涵盖了从 SocketsHTTP/3 的全部内容。同样,库还支持处理多种格式和语言,如 JSONXMLtar

.NET 运行时最初被称为“通用语言运行时(Common Language Runtime,CLR)”。它一直以来支持多种语言,一些由微软维护(例如 C#、F#、Visual Basic、C++/CLI 和 PowerShell),另一些则由其他组织维护(例如 Cobol、JavaPHPPythonScheme)。对运行时的许多改进都无关语言,这对所有语言都有好处。

接下来,我们一起看看这些平台特性是如何协同工作的。我们当然可以分别详细介绍每个组件,但你很快会发现,它们共同组成了 .NET 设计的核心。我们先从类型系统开始。

类型系统

.NET 的类型系统在安全、可描述性、动态类型和互操作性方面提供了极广的支持,且几乎同等程度地满足了这些需求。

首先,.NET 类型系统支持面向对象的编程范式。它包括类型、(单一基类)继承、接口(包括默认方法实现)以及虚方法,这些机制支持面向对象所有类型层次的合理行为。

泛型是一个普遍存在的特性,它允许类针对一个或多个类型进行特殊处理。例如,List<T> 是一个泛型类,可以实现如 List<string>List<int> 这样的实例化,避免了分别定义 ListOfStringListOfInt 类的需求,也避免了像 ArrayList 那样依赖 object 和类型转换的情况。泛型还能在减少大量代码的情况下支持跨类型创建有用的机制,如泛型数学(Generic Math)

委托(delegates)Lambda 表达式可以将方法当作数据传递,这样就可以轻松地像是把外部代码“粘起来”集成到另一个系统的操作管理中。而且为了通用性,它们的签名通常是泛型的。

1
2
3
4
5
6
7
8
9
app.MapGet("/Product/{id}", async (int id) =>
{
if (await IsProductIdValid(id))
{
return await GetProductDetails(id);
}

return Products.InvalidProduct;
});

这段代码将 Lambda 表达式用于ASP.NET Core Minimal APIs中,它让我们可以直接通过路由系统实现端点。在新版本中,ASP.NET Core 更能得益于这个类型系统。

值类型栈分配的内存块为数据和与本机平台的交互提供了与由 .NET GC 托管的类型相比更直接、更底层的控制。.NET 中的大多数基本类型(如整型)都是值类型,用户也可以定义自己的值类型且具有类似的语义。

值类型完全受 .NET 的泛型系统支持,这意味着像 List<T> 这样的泛型类型是可以在内存里表示为扁平化的、没有额外开销的值类型集合。此外,当泛型类型替换为值类型时,.NET 会编译特化代码来避免昂贵的 GC 开销。

1
2
3
byte magicSequence = 0b1000_0001;
Span<byte> data = stackalloc byte[128];
DuplicateSequence(data[0..4], magicSequence);

这段代码在栈上分配了内存。Span<byte> 是一个相比于传统的指针(byte*)更安全、更多功能的替代方案,它提供了长度(包括边界检查)以及方便的切片操作。

ref 类型变量是一种轻量级的编程模型,它为类型系统中的数据提供了更底层且精简的抽象,Span<T> 就是其中之一。这种编程模型并非通用,而加以许多限制以确保安全性。

1
internal readonly ref T _reference;

这种 ref 的用法指向了对数据的引用,而非拷贝数据本身。值类型默认是“值复制”,而 ref 则能“按引用复制”,这种方式可以显著提升性能。

自动内存管理

.NET 运行时通过垃圾回收机制(GC)实现对内存的自动管理。内存管理模型往往能定义一门语言,对于 .NET 的语言来说也是如此。

堆上出现的bug非常难调试,开发人员可能需要花费数周甚至数月的时间来找问题。很多语言都使用GC,通过确保对象的生命周期正确无误地被管理来做到更友好地消除这些bug。通常,GC会批量释放内存以提高效率,但这样的做法会触发一会暂停,因此GC不适合在对延迟有严格要求的场景出现,且内存占用也会更高。但GC通常能带来更好的局部性内存访问,一些GC还具备堆压缩能力,使其不容易出现内存碎片

.NET 有自适应的追踪型 GC,旨在大多数情况下提供“无需操心”的自动操作,同时为少数极端负载场景提供配置项。多年来的投入和对各种工作负载的经验改进造就了如今的GC。

指针递增分配(Bump Pointer Allocation) —— 对象通过将分配指针按所需大小递增来分配内存,而不是在离散的空闲块中寻找空间。因此,一起分配的对象通常在内存中会挨在一起。这种方法能够提高内存局部性,满足了这些通常会被一起访问的对象的性能要求。

分代收集算法(Generational Collections) —— 对象的生命周期通常遵循分代假设,即对象要么存在很久,要么很快被回收。因此,为了高效,GC大多数时候只需回收短命(ephemeral)对象占用的内存(称为短命GC, ephemeral GCs),而不必每次都回收整个堆内存(称为全堆GC, full GCs)。

堆压缩(Compaction) —— 相比于分散的小块空闲空间,集中且较大的空闲空间更有用。在压缩型(compacting) GC的工作过程中,存活的对象会被再次移动到一起,从而腾出较大且连续的空闲空间。这种方式比非移动型(non-moving)的GC更难实现,因为它需要更新指向这些移动对象的引用。.NET GC会动态调整,只有在认为内存回收的收益足以抵消GC的开销时才会进行压缩。这意味着很多对短命(ephemeral)对象的回收都是压缩型的。

并行回收(Parallel) —— GC可以多线程运行。工作站GC在单线程上进行回收工作,而服务器GC则在多个线程上并行工作以加快回收速度。服务器GC还支持更大的分配速率,因为应用程序可以同时在不止一个堆上进行分配,因此在吞吐量方面表现出色。

有关工作站GC和服务器GC

可以查阅Runtime configuration options for garbage collection - Flavors of garbage collection来查看有关工作站GC (Workstation GC)和服务器GC (Server GC)的介绍和对比

并发回收(Concurrent) —— 如果GC在工作时暂停了用户线程(称为 Stop-The-World),实现会更简单,但这种暂停可能是无法接受的。于是 .NET 提供了并发GC来减轻这个问题。

对象固定(Pinning) —— .NET GC支持固定对象,在保证对GC的影响较小的前提下,允许与native代码进行高性能、高精度的零拷贝互操作。

独立GC(Standalone GC) —— 可以实现独立且不同的GC(只要通过配置指定并满足接口要求)。这使得测试或尝试新特性变得更容易。

诊断工具(Diagnostics) —— GC会提供大量关于内存和回收过程的相关信息,这些数据会被结构化以便与系统其他部分关联。例如,你可以通过捕获GC事件并将其与IO等其他事件对比,以评估GC对尾延迟的影响,从而确定GC相较于其他因素的影响如何,然后将优化工作集中在真正需要的地方。

安全性

编程语言和环境的安全性在过去十年一直是热门话题。同时也是像 .NET 这样托管环境必需做到的事情。

.NET 的安全性体现在:

  • 类型安全 — 不允许使用任意类型替代另一种类型,从而避免未定义行为。
  • 内存安全 — 仅使用已分配的内存,例如变量要么引用一个有效对象,要么为 null
  • 并发与线程安全 — 访问共享数据不会导致未定义行为。
有关“未定义行为”

注:美国联邦政府最近发布了关于内存安全重要性的指导意见

.NET 起初就设计为一个安全的平台。比如,它旨在支持新一代的 Web 服务器,这些服务器需要在互联网这个最具敌意的计算环境中接受不可信的输入。而如今,使用安全的语言编写 Web 程序已成为共识。

类型安全由语言和运行时共同保证。编译器会验证静态不变量(static invariants),包括不同类型的赋值操作(如将 string 赋值给 Stream,会导致编译错误)。运行时则验证动态不变量(dynamic invariants),例如不同类型之间的转换会抛出 InvalidCastException

内存安全主要通过代码生成器(如 JIT)和垃圾回收器的协作实现。变量要么引用有效的对象,要么为 null,或者已经超出作用域。内存默认自动初始化,确保新对象不会使用未经初始化的内存。边界检查会确保访问无效索引的元素不会读取未定义的内存(通常由越界访问引起),而是抛出 IndexOutOfRangeException

null 的处理是内存安全的一个具体表现。C# 的可空引用类型是一个语言和编译器共同实现的特性,它能静态地识别未安全处理 null 的代码。具体来说,如果你解引用一个可能为 null 的变量,编译器会发出警告。你还可以禁止 null 赋值,这样当你可能赋一个 null 值给变量时,编译器也会发出警告。运行时有相应的动态验证功能,防止访问 null 引用,若发生则抛出 NullReferenceException

这一特性依赖于库中的可空属性。它还依赖于这些属性在库和应用栈中全面的应用,以便用户代码可以通过静态分析工具获得准确的结果。

1
2
string? SomeMethod() => null;
string value = SomeMethod() ?? "default string";

由于使用了 ??Null 合并操作符)明确声明和处理了 null,这段代码被 C# 编译器认为是空安全的。变量 value 符合其声明,始终不为 null

.NET 并没有内置的并发安全机制。开发者需要遵循特定的模式和约定来避免未定义行为。此外,.NET 生态系统中还有分析器和其他工具,可以帮助识别并发导致的问题。核心库也包含了许多线程安全的类型和方法,例如支持任意数量的并发读写而不会导致数据结构损坏的并发集合

.NET 允许安全和不安全代码。安全代码是默认保证安全性的,而开发者必须选择性地使用不安全代码。不安全代码通常用于与底层平台、硬件交互,或用于手动性能优化。

沙箱是一种特殊的安全机制,它隔离并限制组件之间的访问。我们依赖标准的隔离技术,如进程(和 CGroups)、虚拟机以及 WebAssembly,它们各自具有不同的特性。

错误处理

.NET 中主要的错误处理模型是异常处理。异常(Exception)好在不需要在每个方法中处理,也不需要在方法签名中体现。

比如:

1
2
3
4
5
6
7
8
9
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.");
}

可以特意处理预期内的异常以避免应用程序崩溃。相比于出现未定义行为,一个崩溃的应用程序更能被诊断且可靠。

异常会在错误发生的位置被抛出,并自动收集关于程序状态的额外诊断信息,这些信息可用于交互式调试、应用程序可观测性 (application observability) 以及事后调试。这些诊断方法都依赖于收集到的丰富的错误信息和应用状态。

异常处理应少用,一部分原因是处理异常的性能开销相对较高。尽管有时会将其用于控制流,但这并非其设计初衷。

异常也可用于取消操作(Cancellation)。它们能够在收到取消请求后,高效地停止执行并展开调用栈,从而中止正在进行的工作。

1
2
3
4
5
6
7
8
try 
{
await source.CopyToAsync(destination, cancellationToken);
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation was canceled");
}

由于异常处理的性能开销过高,.NET 提供了另一种错误处理的设计模式。例如,int.TryParse 返回一个 bool,并通过 out 参数返回解析成功的整数值。同样对于 Dictionary<TKey, TValue>.TryGetValue,它会在成功的情况下通过 out 参数返回有效的 TValue 类型的值。

错误处理以及更广泛的诊断功能由底层的运行时 API、高级库工具来实现。这些功能已被设计用于支持如容器等新型部署选项。例如,dotnet-monitor可以通过内置的面向诊断的 Web 服务器,将运行时数据从应用程序导出到监听器。

并发

并发处理在几乎所有场景中都是很必要的:无论是应用程序在后台处理任务的同时保持 UI 的响应、处理成千上万的并发请求服务、同时响应大量设备,还是高性能机器并行处理计算密集型操作。操作系统提供了线程并将这些线程调度在机器的可用 CPU 核心上执行,这也赋予了机器并发地独立处理多个指令流的能力。操作系统还支持 I/O 操作,也提供了可扩展地执行大量 I/O 操作的机制,使得在任意时刻都“在进行”多个 I/O 操作。编程语言和框架则在这一核心支持之上提供了各种层次的抽象。

.NET 在多个抽象层上提供了并发和并行处理的支持,这些支持既通过库实现,又深度集成在 C# 语言中。Thread 类位于最底层,即代表一个线程,允许开发者创建新线程并在该线程上运行代码。ThreadPool 则基于线程,使开发者能够以工作项的形式异步调度任务到线程池中,并由运行时负责线程的管理(包括线程池中线程的增加和移除,以及工作项的分配)。

可以通过多种方式创建 Task 来表示异步操作。例如,Task.Run 允许将一个委托调度到 ThreadPool 中运行,并返回一个 Task 来表示该操作的最终完成情况;而 Socket.ReceiveAsync 则返回一个 Task<int>(或 ValueTask<int>),表示从 Socket 异步 I/O 操作读取的待处理数据的最终完成情况。

.NET 提供了丰富的同步原语(Synchronization primitives),用于同步和协调线程以及异步操作。此外,还有许多高级 API 简化了常见的并发模式实现。如Parallel.ForEachParallel.ForEachAsync 使得并行处理数据序列中的所有元素变得更加容易。

异步编程也是 C# 语言的一等(first-class)特性,提供了 asyncawait 关键字,使编写和组合异步操作变得简便,同时仍然能够充分利用语言提供的各种控制流结构的优势。这些关键字让开发者能够以同步代码的方式编写异步逻辑,提高了代码的可读性和可维护性。

反射

反射是一种“将程序视为数据”的编程范式,允许程序的一部分动态地查询和/或调用另一部分,包括程序集、类型和成员。它在延迟绑定(late binding)的编程模型和工具中尤其有用。

以下代码使用了反射来查找并调用类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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<IStory>。这种延迟绑定的方法更适用于如加载来自任意程序集的插件的场景。反射常常被用到当程序集和类型事先未知时的情况。

反射可能是 .NET 中最动态的机制。它旨在让开发者创建自定义的二进制代码加载器和方法调度器,其语义可以与(由运行时定义的)静态代码相匹配或有所不同。反射提供了一个丰富的对象模型,对于特殊使用场景来说易于使用,但当场景变得更复杂时,则需要更深入地理解 .NET 的类型系统。

此外,反射还支持一种独立的模式,即生成的 IL 字节码可以在运行时被即时编译(JIT),有时用于编写特定算法以替代通用算法。在序列化器或对象关系映射器中,反射常被用来在对象模型和其他细节确定后执行相关操作。

编译后的二进制格式

应用程序和库被编译成采用PE/COFF 格式标准化跨平台字节码。二进制分发使得应用程序能够扩展到越来越多的项目。每个库都包含一个导入和导出类型的数据库,称为元数据,这在开发和运行应用程序时都起着重要作用。

编译后的二进制文件包括两个主要部分:

  • 二进制字节码 — 这种紧凑且规范的格式无需在经过语言编译器(如 C#)抽象后编译并解析源代码。
  • 元数据 — 描述导入和导出的类型,包括特定方法在字节码中的位置。

在开发过程中,工具可以高效地读取元数据,以确定在给定库中公开的类型集合,以及哪些类型实现了特定接口等。这一过程使编译速度更快,并使 IDE 和其他工具能够在特定上下文中准确地显示类型和成员列表。

在运行时,元数据允许库和方法体被延迟加载。反射(稍后讨论)是用于元数据和 IL 的运行时 API。但对于工具来说,还有其他更合适的 API。

IL 的格式依旧保持向后兼容。最新版本的 .NET 仍然可以加载和执行由 .NET Framework 1.0 编译器生成的二进制文件。

共享库通常用 NuGet 进行分发。NuGet 包默认情况下可以在任何操作系统和架构上运行,但也可以指定环境以提供特定行为。

代码生成

.NET 的字节码并不是能让机器直接执行的格式,而需要通过某种形式的代码生成器将其转化为可执行代码。这可以通过提前编译(ahead-of-time, AOT)、即时编译(just-in-time, JIT)、解释执行或转译来实现,这些方法在各种场景中都在使用。

.NET 最为人熟知的是即时编译(JIT)。JIT 得名“即时”是因为它在应用程序运行时按需地将方法(及成员)编译成 native 代码。例如,一个程序在运行时可能只会调用某个类型的几个方法中的一个。JIT 也可以利用运行时获得的信息来优化,比如已初始化的只读静态变量的值或程序运行时机器的 CPU 型号,并且可以多次编译同一个方法,以便借鉴之前编译的经验在每次编译时针对不同的目标优化。

有关“按配置优化”

按配置优化 (PGO) 是指 JIT 编译器根据最常使用的类型和代码路径生成优化后的代码。 动态 PGO 与分层编译携手合作,根据第 0 层期间实施的其他检测进一步优化代码。

来源:用于编译的运行时配置选项

JIT 会根据特定的操作系统和芯片架构生成代码。 .NET 具有支持 Arm64 和 x64 指令集以及 Linux、macOS 和 Windows 操作系统的 JIT 实现。作为 .NET 开发者,你无需担心 CPU 指令集和操作系统调用之间的差异。JIT 会负责生成 CPU 所需的代码。它还知道如何为各种 CPU 生成高效的代码,操作系统和 CPU 厂商通常会协助我们实现这一点。

AOT 与 JIT 类似,但会在程序运行前生成 native 代码。开发者选择这种方式是因为它可以显著提高程序启动速度,省去了 JIT 所需的启动工作。AOT 的应用程序本质上是构建在特定的操作系统和架构的,这意味着需要额外的步骤才能使应用程序在多种环境中运行。例如,如果你希望支持 Linux 和 Windows 以及 Arm64 和 x64,那么你需要构建四个版本才能做到。AOT 生成的代码也可以提供有价值的优化,但通常不如 JIT 那么多。

有关“本机 AOT 部署”

Native AOT 应用启动更快且内存占用更小,且可以在没有安装 .NET 运行时的机器上运行。

来源:Native AOT deployment

我们将在后续的文章中讨论解释执行和转译,它们在我们的生态系统中也扮演着关键角色。

代码生成器优化之一是内在函数(intrinsics)。硬件内在函数可以将 .NET API 直接转换为 CPU 指令。这在 .NET 库中广泛用于执行 SIMD

互操作性

.NET 设计了与本机库进行低开销互操作的特性。.NET 程序和库可以无缝调用底层操作系统的 API 或庞大的 C/C++ 生态。现代的 .NET 运行时专注于提供底层的互操作构建,例如通过函数指针调用本机库的方法、将托管方法暴露为非托管回调自定义的接口转换。.NET 也在这一领域不断发展。.NET 7 发布了 AOT 友好的源生成解决方案,进一步降低了开销。

以下代码展示了 .NET 7 引入的 LibraryImport 源生成器与高效的 C# 函数指针(该源生成器是在 .NET 诞生以来就存在的 DllImport 之上提供的支持)。

1
2
3
4
5
6
7
8
9
10
// 使用函数指针避免分配委托
// 相当于 C 语言中的 `void (*fptr)(int) = &RegisterCallback;`
delegate* unmanaged<int, void> fptr = &RegisterCallback;
RegisterCallback(fptr);

[UnmanagedCallersOnly]
static void Callback(int a) => Console.WriteLine($"Callback: {a}");

[LibraryImport("...", EntryPoint = "RegisterCallback")]
static partial void RegisterCallback(delegate* unmanaged<int, void> fptr);

独立的包通过这些底层构建模块,提供更高抽象层的特定领域互操作方案,例如 ClangSharpXamarin.iOS & Xamarin.MacCsWinRTCsWin32 以及 DNNE

这些新特性的出现并不意味着内置的互操作方案,如内置的运行时托管/非托管封送(marshalling)或 Windows COM 互操作不再有用 —— 我们知道这些功能仍然非常有用,且人们已经依赖于它们。这些历史上内置于运行时的功能将继续在 .NET 运行时中得到支持。然而,这些仅停留于向后兼容,未来没有进一步的发展计划。以后将集中在互操作构建模块以及它们所支持的特定领域方案上。

二进制发行版

微软的 .NET 团队维护了多个二进制发行版,最近又支持了 Android、iOS 和 Web Assembly。团队采用多种技术针对每个平台进行代码库的优化。平台的大部分使用 C# 编写,这使得移植工作可以集中在相对较少的组件上。

社区主要聚焦于 Linux,继而维护了另一组发行版。例如,.NET 已被包含在 Alpine LinuxFedoraRed Hat Enterprise LinuxUbuntu 中。

社区还将 .NET 扩展到了其他平台。比如三星为其基于 Arm 的 Tizen 平台移植了 .NETRed HatIBM 将 .NET 移植到了 LinuxONE/s390x龙芯科技(Loongson Technology).NET 移植到了 LoongArch。我们期待有新的合作伙伴将 .NET 移植到更多环境中。

Unity Technologies 已启动一项为期多年的计划以迁移至现代的 .NET 运行时。

.NET 开源项目的维护和结构设计旨在让个人、公司及其他组织能够在传统的上游模式(upstream model)中协同合作。微软作为平台的管理者,负责治理项目并提供基础设施(如 CI pipelines)。微软团队与各组织合作,帮助他们成功使用和/或移植 .NET。该项目拥有广泛的上游政策,包括接受针对特定发行版的更改。

一个重点是 从源码构建的项目多个组织使用它根据典型的发行版规则(例如 Canonical (Ubuntu))构建 .NET。最近,这一点随着虚拟单仓库(Virtual Mono Repo,VMR)的加入而扩展:由于 .NET 项目由许多仓库组成,这有助于提高 .NET 开发者的效率,但也使得构建完整的运行时变得更加困难。

总结

加上最近发布的 .NET 7,我们已经体验了多个现代的 .NET 版本。我们认为,总结自 .NET Core 1.0 以来在底层所做的努力是非常有用的。我们铭记 .NET 的初心,同时向着一个全新的平台走出一条新路,为开发者提供了更新更多的价值。

让我们回到最初的地方。 .NET 的价值体现在:生产力、性能、安全性和可靠性。我们坚信,当不同的语言平台提供不同的路子时,开发者会获得最大的收益。作为一个团队,我们致力于为 .NET 开发者提供高生产力,同时构建在性能、安全性和可靠性方面领先的平台。