适用于 .NET 9 的 .NET 库中的新增功能

本文介绍适用于 .NET 9 的 .NET 库中的新功能。

Base64Url

Base64 是一种编码方案,用于将任意字节转换为由一组特定 64 个字符组成的文本。 这是传输数据的常见方法,很长时间以来一直通过多种方法给予支持,例如使用Convert.ToBase64StringBase64.DecodeFromUtf8(ReadOnlySpan<Byte>, Span<Byte>, Int32, Int32, Boolean)。 但是,它使用的某些字符使其在某些情况下(如查询字符串)并不理想。 具体而言,构成 Base64 表的 64 个字符包括“+”和“/”,这两个字符在 URL 中都具有自己的含义。 这导致了 Base64Url 方案的创建,它类似于 Base64,但使用略有不同的字符集,使它适合在 URL 上下文中使用。 .NET 9 包含了新的 Base64Url 类,该类提供了许多优化和实用的方法,可使用 Base64Url 对多种数据类型进行编码和解码。

以下示例演示如何使用新类。

ReadOnlySpan<byte> bytes = ...;
string encoded = Base64Url.EncodeToString(bytes);

BinaryFormatter

.NET 9 从 .NET 运行时中删除 BinaryFormatter 。 API 仍然存在,但无论项目类型如何,其实现始终都会引发异常。 有关删除和选项(如果受影响)的详细信息,请参阅 BinaryFormatter 迁移指南

收集

.NET 中的集合类型为 .NET 9 获得以下更新:

具有跨度的集合查找

在高性能代码中,跨度通常用于避免不必要的字符串分配,而查找表通常以Dictionary<TKey,TValue>HashSet<T>类型作为缓存使用。 但是,对于具有范围的这些数据集合类型,尚无安全的内置查找机制。 使用 C# 13 中的新功能 allows ref struct 和 .NET 9 中这些集合类型的新功能,现在可以执行此类查找。

以下示例演示了如何使用 Dictionary<TKey,TValue>.GetAlternateLookup

static Dictionary<string, int> CountWords(ReadOnlySpan<char> input)
{
    Dictionary<string, int> wordCounts = new(StringComparer.OrdinalIgnoreCase);
    Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>> spanLookup =
        wordCounts.GetAlternateLookup<ReadOnlySpan<char>>();

    foreach (Range wordRange in Regex.EnumerateSplits(input, @"\b\W+"))
    {
        if (wordRange.Start.Value == wordRange.End.Value)
        {
            continue; // Skip empty ranges.
        }
        ReadOnlySpan<char> word = input[wordRange];
        spanLookup[word] = spanLookup.TryGetValue(word, out int count) ? count + 1 : 1;
    }

    return wordCounts;
}

OrderedDictionary<TKey, TValue>

在许多场景中,你可能希望以既能维护顺序(如键值对列表)又能快速通过键查找(如键值对字典)的方式存储键值对。 自早期的.NET以来,OrderedDictionary类型支持这种情况,但仅以非泛型方式,键和值的类型为object。 .NET 9 引入了长期请求 OrderedDictionary<TKey,TValue> 的集合,该集合提供了一种高效的泛型类型来支持这些方案。

以下代码使用新类。

OrderedDictionary<string, int> d = new()
{
    ["a"] = 1,
    ["b"] = 2,
    ["c"] = 3,
};

d.Add("d", 4);
d.RemoveAt(0);
d.RemoveAt(2);
d.Insert(0, "e", 5);

foreach (KeyValuePair<string, int> entry in d)
{
    Console.WriteLine(entry);
}

// Output:
// [e, 5]
// [b, 2]
// [c, 3]

PriorityQueue.Remove() 方法

.NET 6 引入了 PriorityQueue<TElement,TPriority> 集合,该集合提供简单且快速的数组堆实现。 数组堆通常存在一个问题,即它们 不支持优先级更新,这使得它们禁止在 Dijkstra 算法的变体等算法中使用。

虽然无法在现有集合中实现高效的 $O(\log n)$ 优先级更新,但新的 PriorityQueue<TElement,TPriority>.Remove(TElement, TElement, TPriority, IEqualityComparer<TElement>) 方法可以模拟优先级更新(尽管需要 $O(n)$ 的时间)。

public static void UpdatePriority<TElement, TPriority>(
    this PriorityQueue<TElement, TPriority> queue,
    TElement element,
    TPriority priority
    )
{
    // Scan the heap for entries matching the current element.
    queue.Remove(element, out _, out _);
    // Re-insert the entry with the new priority.
    queue.Enqueue(element, priority);
}

这种方法可以让在渐近性能不是障碍的情况下想要实现图算法的用户不再受到阻碍。 (此类上下文包括教育和原型制作。例如,下面是使用新 API 的 Dijkstra 算法的玩具实现

ReadOnlySet<T>

通常情况下,我们希望提供只读的集合视图。 ReadOnlyCollection<T> 允许创建任意可变 IList<T>的只读包装器,并允许 ReadOnlyDictionary<TKey,TValue> 在任意可变 IDictionary<TKey,TValue>的周围创建只读包装器。 但是,以前版本的 .NET 没有内置支持用 ISet<T> 做同样的事情。 .NET 9 引入了 ReadOnlySet<T> 解决此问题。

新类启用以下使用模式。

private readonly HashSet<int> _set = [];
private ReadOnlySet<int>? _setWrapper;

public ReadOnlySet<int> Set => _setWrapper ??= new(_set);

组件模型 - TypeDescriptor 剪裁支持

System.ComponentModel 包括用于描述组件的新的选择性剪裁器兼容 API。 任何应用程序,尤其是独立的剪裁应用程序,都可以使用这些新的 API 来帮助支持剪裁场景。

主 API 是 TypeDescriptor.RegisterType 类上的 TypeDescriptor 方法。 此方法具有 DynamicallyAccessedMembersAttribute 属性,以便剪裁器保留该类型的成员。 每种类型应调用一次该方法,通常是在早期调用。

辅助 API 具有 FromRegisteredType 后缀,例如 TypeDescriptor.GetPropertiesFromRegisteredType(Type)。 与没有 FromRegisteredType 后缀的对应项不同,这些 API 没有 [RequiresUnreferencedCode][DynamicallyAccessedMembers] 剪裁器属性。 缺少修剪器属性有助于消费者无需再进行以下任一操作:

  • 抑制可能存在风险的剪裁警告。
  • 将强类型 Type 参数传播到其他方法,这些方法可能比较繁琐或不可行。
public static void RunIt()
{
    // The Type from typeof() is passed to a different method.
    // The trimmer doesn't know about ExampleClass anymore
    // and thus there will be warnings when trimming.
    Test(typeof(ExampleClass));
    Console.ReadLine();
}

private static void Test(Type type)
{
    // When publishing self-contained + trimmed,
    // this line produces warnings IL2026 and IL2067.
    PropertyDescriptorCollection properties = TypeDescriptor.GetProperties(type);

    // When publishing self-contained + trimmed,
    // the property count is 0 here instead of 2.
    Console.WriteLine($"Property count: {properties.Count}");

    // To avoid the warning and ensure reflection
    // can see the properties, register the type:
    TypeDescriptor.RegisterType<ExampleClass>();
    // Get properties from the registered type.
    properties = TypeDescriptor.GetPropertiesFromRegisteredType(type);

    Console.WriteLine($"Property count: {properties.Count}");
}

public class ExampleClass
{
    public string? Property1 { get; set; }
    public int Property2 { get; set; }
}

有关详细信息,请参阅 API 建议

密码学

CryptographicOperations.HashData() 方法

.NET 包括哈希函数和相关函数的多个静态 “一次性” 实现。 这些 API 包括 SHA256.HashDataHMACSHA256.HashData。 最好使用一次性 API,因为它们可以提供最佳性能并减少或消除分配。

如果开发人员想要提供一个 API,该 API 支持调用方在其中定义要使用的哈希算法,则通常通过接受 HashAlgorithmName 参数来完成。 但将该模式与一次性 API 配合使用需要切换每个可能的 HashAlgorithmName,然后使用适当的方法。 若要解决此问题,.NET 9 引入了 CryptographicOperations.HashData API。 通过此 API,可以针对输入生成哈希值或 HMAC,作为一次性操作,其中使用的算法由HashAlgorithmName决定。

static void HashAndProcessData(HashAlgorithmName hashAlgorithmName, byte[] data)
{
    byte[] hash = CryptographicOperations.HashData(hashAlgorithmName, data);
    ProcessHash(hash);
}

KMAC 算法

.NET 9 提供 NIST SP-800-185 指定的 KMAC 算法。 KECCAK 消息身份验证代码(KMAC)是基于 KECCAK 的伪随机函数和键控哈希函数。

以下新类使用 KMAC 算法。 使用实例累积数据以生成 MAC,或使用静态 HashData 方法对单个输入进行 一次性处理

KMAC 在具有 OpenSSL 3.0 或更高版本的 Linux 上,以及 Windows 11 内部版本 26016 或更高版本上可用。 可以使用静态 IsSupported 属性来确定平台是否支持所需的算法。

if (Kmac128.IsSupported)
{
    byte[] key = GetKmacKey();
    byte[] input = GetInputToMac();
    byte[] mac = Kmac128.HashData(key, input, outputLength: 32);
}
else
{
    // Handle scenario where KMAC isn't available.
}

为 iOS/tvOS/MacCatalyst 启用了 AES-GCM 和 ChaChaPoly1305 算法

IsSupportedChaChaPoly1305.IsSupported 现在在 iOS 13+、tvOS 13+ 和 Mac Catalyst 上运行时返回 true。

AesGcm 仅在 Apple作系统上支持 16 字节(128 位)标记值。

X.509 证书加载

自 .NET Framework 2.0 以来,加载证书的方法一直是 new X509Certificate2(bytes)。 还有其他模式,例如new X509Certificate2(bytes, password, flags)new X509Certificate2(path)new X509Certificate2(path, password, flags)X509Certificate2Collection.Import(bytes, password, flags)(及其重载)。

这些方法都使用内容探查来确定输入是否是它可以处理的内容,然后加载它(如果可以)。 对于一些呼叫者来说,这种策略非常方便。 但它也有一些问题:

  • 并非每个文件格式都适用于每个 OS。
  • 这是协议偏差。
  • 这是安全问题的来源。

.NET 9 引入了一个新 X509CertificateLoader 类,该类具有“一种方法,一个用途”设计。 在其初始版本中,它仅支持X509Certificate2 构造函数支持的五种格式中的两种。 这些是在所有操作系统上运行的两种格式。

OpenSSL 供应商支持

.NET 8 引入了特定于 OpenSSL 的 API OpenPrivateKeyFromEngine(String, String)OpenPublicKeyFromEngine(String, String)。 例如,它们支持与 OpenSSL ENGINE 组件 进行交互并使用硬件安全模块(HSM)。

.NET 9 引入了SafeEvpPKeyHandle.OpenKeyFromProvider(String, String),它允许使用 OpenSSL 提供程序,以及与诸如tpm2pkcs11这样的提供程序交互。

一些发行版已经取消了 ENGINE 支持,因为它现在已被弃用。

以下代码片段显示了基本用法:

byte[] data = [ /* example data */ ];

// Refer to your provider documentation, for example, https://github.com/tpm2-software/tpm2-openssl/tree/master.
using (SafeEvpPKeyHandle priKeyHandle = SafeEvpPKeyHandle.OpenKeyFromProvider("tpm2", "handle:0x81000007"))
using (ECDsa ecdsaPri = new ECDsaOpenSsl(priKeyHandle))
{
    byte[] signature = ecdsaPri.SignData(data, HashAlgorithmName.SHA256);
    // Do stuff with signature created by TPM.
}

在 TLS 握手过程中,有一些性能提升,同时还改进了与使用 ENGINE 组件的 RSA 私钥的交互方式。

基于 Windows CNG 虚拟化的安全性

Windows 11 添加了新的 API,以帮助使用 基于虚拟化的安全性(VBS)保护 Windows 密钥。 借助这项新功能,密钥可以受到管理级密钥盗窃攻击的保护,对性能、可靠性或规模的影响微乎其微。

.NET 9 添加了匹配 CngKeyCreationOptions 标志。 添加了以下三个标志:

  • CngKeyCreationOptions.PreferVbs 匹配 NCRYPT_PREFER_VBS_FLAG
  • CngKeyCreationOptions.RequireVbs 匹配 NCRYPT_REQUIRE_VBS_FLAG
  • CngKeyCreationOptions.UsePerBootKey 匹配 NCRYPT_USE_PER_BOOT_KEY_FLAG

以下代码片段演示如何使用其中一个标志:

using System.Security.Cryptography;

CngKeyCreationParameters cngCreationParams = new()
{
    Provider = CngProvider.MicrosoftSoftwareKeyStorageProvider,
    KeyCreationOptions = CngKeyCreationOptions.RequireVbs | CngKeyCreationOptions.OverwriteExistingKey,
};

using (CngKey key = CngKey.Create(CngAlgorithm.ECDsaP256, "myKey", cngCreationParams))
using (ECDsaCng ecdsa = new ECDsaCng(key))
{
    // Do stuff with the key.
}

日期和时间 - 新的 TimeSpan.From* 重载

TimeSpan 类提供了多种 From* 方法,可以使用 TimeSpan 来创建 double 对象。 但是,由于 double 是基于二进制的浮点格式, 固有的不精确可能会导致错误。 例如,TimeSpan.FromSeconds(101.832)可能不精确表示101 seconds, 832 milliseconds,而是大致表示101 seconds, 831.9999999999936335370875895023345947265625 milliseconds。 这种差异造成了频繁的混淆,也不是表示此类数据的最有效方法。 为了解决此问题,.NET 9 添加了新的重载,使你能够从整数创建 TimeSpan 对象。 有新的重载,来自FromDaysFromHoursFromMinutesFromSeconds、和FromMillisecondsFromMicroseconds

下面的代码演示了调用 double 和一个新的整数重载的示例。

TimeSpan timeSpan1 = TimeSpan.FromSeconds(value: 101.832);
Console.WriteLine($"timeSpan1 = {timeSpan1}");
// timeSpan1 = 00:01:41.8319999

TimeSpan timeSpan2 = TimeSpan.FromSeconds(seconds: 101, milliseconds: 832);
Console.WriteLine($"timeSpan2 = {timeSpan2}");
// timeSpan2 = 00:01:41.8320000

依赖项注入 - ActivatorUtilities.CreateInstance 构造函数

.NET 9 中的 ActivatorUtilities.CreateInstance 构造函数解析已发生变化。 以前,根据构造函数的顺序和构造函数参数的数目,可能无法调用使用 ActivatorUtilitiesConstructorAttribute 特性显式标记的构造函数。 .NET 9 中的逻辑已更改,因此始终调用具有该属性的构造函数。

诊断

默认情况下,Debug.Assert 报告断言条件

Debug.Assert 通常用于帮助验证预期始终为 true 的条件。 一般来说,失败指示代码中的错误。 Debug.Assert 有许多重载,其中最简单的重载只接受一个条件:

Debug.Assert(a > 0 && b > 0);

如果条件为 False,则断言失败。 从历史上看,这种断言通常不包含关于故障原因的任何信息。 从 .NET 9 开始,如果用户未显式提供任何消息,断言将包含条件的文本表示形式。 例如,在前面的断言示例中,我们不会收到类似的信息:

Process terminated. Assertion failed.
   at Program.SomeMethod(Int32 a, Int32 b)

消息现在将为:

Process terminated. Assertion failed.
a > 0 && b > 0
   at Program.SomeMethod(Int32 a, Int32 b)

以前,只有在Activity时才能将该跟踪 Activity 链接到其他跟踪上下文。 在 .NET 9 中新增功能, AddLink(ActivityLink) API 允许在创建对象后将对象 Activity 链接到其他跟踪上下文。 此更改也与 OpenTelemetry 规范 保持一致。

ActivityContext activityContext = new(ActivityTraceId.CreateRandom(), ActivitySpanId.CreateRandom(), ActivityTraceFlags.None);
ActivityLink activityLink = new(activityContext);

Activity activity = new("LinkTest");
activity.AddLink(activityLink);

Metrics.Gauge 工具

System.Diagnostics.Metrics 现在根据 OpenTelemetry 规范提供 Gauge<T> 工具。 该 Gauge 仪器旨在记录发生更改时的非累加值。 例如,它可以测量背景噪音级别,而将多个房间的值相加则是不合理的。 仪器 Gauge 是一种泛型类型,可以记录任何值类型,例如 intdouble,或 decimal

以下示例演示了如何使用 Gauge 工具。

Meter soundMeter = new("MeasurementLibrary.Sound");
Gauge<int> gauge = soundMeter.CreateGauge<int>(
    name: "NoiseLevel",
    unit: "dB", // Decibels.
    description: "Background Noise Level"
    );
gauge.Record(10, new TagList() { { "Room1", "dB" } });

进程外计量通配符侦听

已经可以使用System.Diagnostics.Metrics事件源提供程序在进程外侦听度量,但在.NET 9之前,必须指定完整的度量名称。 在 .NET 9 中,可以使用通配符 *来侦听所有计量,这样就可以从进程中的每一个计量中捕获指标。 此外,它还增加了通过仪表名称前缀进行侦听的支持,因此你可以侦听名称以指定前缀开头的所有仪表。 例如,指定 MyMeter* 允许监听名称以 MyMeter 开头的所有计量。

// The complete meter name is "MyCompany.MyMeter".
var meter = new Meter("MyCompany.MyMeter");
// Create a counter and allow publishing values.
meter.CreateObservableCounter("MyCounter", () => 1);

// Create the listener to use the wildcard character
// to listen to all meters using prefix names.
MyEventListener listener = new MyEventListener();

MyEventListener 类的定义如下。

internal class MyEventListener : EventListener
{
    protected override void OnEventSourceCreated(EventSource eventSource)
    {
        Console.WriteLine(eventSource.Name);
        if (eventSource.Name == "System.Diagnostics.Metrics")
        {
            // Listen to all meters with names starting with "MyCompany".
            // If using "*", allow listening to all meters.
            EnableEvents(
                eventSource,
                EventLevel.Informational,
                (EventKeywords)0x3,
                new Dictionary<string, string?>() { { "Metrics", "MyCompany*" } }
                );
        }
    }

    protected override void OnEventWritten(EventWrittenEventArgs eventData)
    {
        // Ignore other events.
        if (eventData.EventSource.Name != "System.Diagnostics.Metrics" ||
            eventData.EventName == "CollectionStart" ||
            eventData.EventName == "CollectionStop" ||
            eventData.EventName == "InstrumentPublished"
            )
            return;

        Console.WriteLine(eventData.EventName);

        if (eventData.Payload is not null)
        {
            for (int i = 0; i < eventData.Payload.Count; i++)
                Console.WriteLine($"\t{eventData.PayloadNames![i]}: {eventData.Payload[i]}");
        }
    }
}

执行代码时,输出如下所示:

CounterRateValuePublished
        sessionId: 7cd94a65-0d0d-460e-9141-016bf390d522
        meterName: MyCompany.MyMeter
        meterVersion:
        instrumentName: MyCounter
        unit:
        tags:
        rate: 0
        value: 1
        instrumentId: 1
CounterRateValuePublished
        sessionId: 7cd94a65-0d0d-460e-9141-016bf390d522
        meterName: MyCompany.MyMeter
        meterVersion:
        instrumentName: MyCounter
        unit:
        tags:
        rate: 0
        value: 1
        instrumentId: 1

还可以使用通配符通过监控工具(如 dotnet-counters)侦听指标。

LINQ

新方法 CountByAggregateBy 已引入。 借助这些方法,可以按键聚合状态,而无需通过 GroupBy 分配中间分组。

CountBy 可让你快速计算每个键的频率。 以下示例查找文本字符串中最常出现的单词。

string sourceText = """
    Lorem ipsum dolor sit amet, consectetur adipiscing elit.
    Sed non risus. Suspendisse lectus tortor, dignissim sit amet, 
    adipiscing nec, ultricies sed, dolor. Cras elementum ultrices amet diam.
""";

// Find the most frequent word in the text.
KeyValuePair<string, int> mostFrequentWord = sourceText
    .Split(new char[] { ' ', '.', ',' }, StringSplitOptions.RemoveEmptyEntries)
    .Select(word => word.ToLowerInvariant())
    .CountBy(word => word)
    .MaxBy(pair => pair.Value);

Console.WriteLine(mostFrequentWord.Key); // amet

AggregateBy 允许实现更常规用途的工作流。 以下示例演示如何计算与给定密钥关联的分数。

(string id, int score)[] data =
    [
        ("0", 42),
        ("1", 5),
        ("2", 4),
        ("1", 10),
        ("0", 25),
    ];

var aggregatedData =
    data.AggregateBy(
        keySelector: entry => entry.id,
        seed: 0,
        (totalScore, curr) => totalScore + curr.score
        );

foreach (var item in aggregatedData)
{
    Console.WriteLine(item);
}
//(0, 67)
//(1, 15)
//(2, 4)

Index<TSource>(IEnumerable<TSource>) 使得可以快速提取可枚举对象的隐含索引。 现在可以编写代码(如以下代码片段)来自动为集合中的项编制索引。

IEnumerable<string> lines2 = File.ReadAllLines("output.txt");
foreach ((int index, string line) in lines2.Index())
{
    Console.WriteLine($"Line number: {index + 1}, Line: {line}");
}

日志记录源生成器

C# 12 引入了 主构造函数,它允许直接在类声明上定义构造函数。 日志记录源生成器现在支持使用具有主构造函数的类进行日志记录。

public partial class ClassWithPrimaryConstructor(ILogger logger)
{
    [LoggerMessage(0, LogLevel.Debug, "Test.")]
    public partial void Test();
}

其他

在本部分中,查找有关以下内容的信息:

allows ref struct 在图书馆中使用

C# 13 引入了限制泛型参数 allows ref struct的功能,这告知编译器和运行时 ref struct 可用于该泛型参数。 许多与此兼容的 API 现已批注。 例如,String.Create 方法有一个重载,它可以让你直接写入其内存(以 span 表示)来创建 string。 此方法有一个 TState 参数,由调用方传递给执行实际写入操作的委托。

TState 中,String.Create 类型参数现在已使用 allows ref struct 进行注释:

public static string Create<TState>(int length, TState state, SpanAction<char, TState> action)
    where TState : allows ref struct;

通过此注释,可以将 span(或任何其他 ref struct)作为输入传递给此方法。

以下示例展示了使用这种功能的新 String.ToLowerInvariant() 重载。

public static string ToLowerInvariant(ReadOnlySpan<char> input) =>
    string.Create(span.Length, input, static (stringBuffer, input) => span.ToLowerInvariant(stringBuffer));

SearchValues 扩展

.NET 8 引入了该 SearchValues<T> 类型,该类型提供了一个优化的解决方案,用于搜索跨度内的特定字符集或字节集。 在 .NET 9 中, SearchValues 已扩展为支持在较大的字符串中搜索子字符串。

以下示例在字符串值中搜索多个动物名称,并将索引返回到找到的第一个动物名称。

private static readonly SearchValues<string> s_animals =
    SearchValues.Create(["cat", "mouse", "dog", "dolphin"], StringComparison.OrdinalIgnoreCase);

public static int IndexOfAnimal(string text) =>
    text.AsSpan().IndexOfAny(s_animals);

此新功能具有优化实现,利用基础平台中的 SIMD 支持。 它还允许优化更高级别的类型。 例如, Regex 现在利用此功能作为其实现的一部分。

网络

SocketsHttpHandler 默认在 HttpClientFactory 中

HttpClientFactory 默认情况下,创建由HttpClient支持的HttpClientHandler对象。 HttpClientHandler 本身由 SocketsHttpHandler 提供支持,后者在可配置性方面更加强大,包括在连接生存期管理上提供更多配置选项。 HttpClientFactory 现在默认使用 SocketsHttpHandler,并配置它以设置连接生存期的限制,使其与工厂中指定的轮换生存期相匹配。

System.Net.ServerSentEvents

服务器发送的事件(SSE)是一种简单且常用的协议,用于将数据从服务器流式传输到客户端。 例如,OpenAI 就将其用作 AI 服务流式生成文本的一部分。 为了简化 SSE 的使用,新 System.Net.ServerSentEvents 库提供了一个分析器,用于轻松引入服务器发送的事件。

以下代码演示如何使用新类。

Stream responseStream = new MemoryStream();
await foreach (SseItem<string> e in SseParser.Create(responseStream).EnumerateAsync())
{
    Console.WriteLine(e.Data);
}

使用 Linux 上的客户端证书恢复 TLS

TLS 恢复 是 TLS 协议的一项功能,允许将以前建立的会话恢复到服务器。 这样做可以避免几次往返,并在 TLS 握手期间节省计算资源。

Linux 上已支持 TLS 恢复,无需客户端证书即可进行 SslStream 连接。 .NET 9 添加了对相互身份验证的 TLS 连接进行会话恢复的支持,这在服务器之间的场景中很常见。 此功能会自动启用。

WebSocket 保持连接 ping 和超时

ClientWebSocketOptionsWebSocketCreationOptions 上的新 API 可让你选择发送 WebSocket ping,并在对等方未及时响应时中止连接。

到目前为止,可以指定一个 KeepAliveInterval 来防止连接处于空闲状态,但没有内置机制来确保对等方做出响应。

以下示例每隔 5 秒对服务器执行一次 ping作,并在一秒内未响应时中止连接。

using var cws = new ClientWebSocket();
cws.Options.HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
cws.Options.KeepAliveInterval = TimeSpan.FromSeconds(5);
cws.Options.KeepAliveTimeout = TimeSpan.FromSeconds(1);

await cws.ConnectAsync(uri, httpClient, cancellationToken);

默认情况下,HttpClientFactory 不再记录标头值

LogLevel.Trace 默认情况下,记录的事件 HttpClientFactory 不再包含标头值。 可以通过 RedactLoggedHeaders 帮助程序方法来选择记录特定标头的值。

以下示例会对除用户代理以外的所有标头进行隐去。

services.AddHttpClient("myClient")
    .RedactLoggedHeaders(name => name != "User-Agent");

有关详细信息,请参阅 HttpClientFactory 的日志记录默认会隐藏标头值

反射

持久化程序集

在 .NET Core 版本和 .NET 5-8 中,对于为动态创建的类型生成程序集和发出反射元数据的支持仅限于可运行的 AssemblyBuilder。 对于从 .NET Framework 迁移到 .NET 的客户来说,缺少对 保存 程序集的支持通常是一个障碍因素。 .NET 9 添加了一个新类型, PersistedAssemblyBuilder可用于保存发出的程序集。

若要创建 PersistedAssemblyBuilder 实例,请调用其构造函数并传递程序集名称、核心程序集, System.Private.CoreLib以引用基运行时类型和可选的自定义属性。 向程序集发出所有成员后,调用 PersistedAssemblyBuilder.Save(String) 该方法以创建具有默认设置的程序集。 如果要设置入口点或其他选项,可以调用 PersistedAssemblyBuilder.GenerateMetadata 并使用它返回的元数据来保存程序集。 下面的代码演示了创建持久程序集和设置入口点的示例。

public void CreateAndSaveAssembly(string assemblyPath)
{
    PersistedAssemblyBuilder ab = new PersistedAssemblyBuilder(
        new AssemblyName("MyAssembly"),
        typeof(object).Assembly
        );
    TypeBuilder tb = ab.DefineDynamicModule("MyModule")
        .DefineType("MyType", TypeAttributes.Public | TypeAttributes.Class);

    MethodBuilder entryPoint = tb.DefineMethod(
        "Main",
        MethodAttributes.HideBySig | MethodAttributes.Public | MethodAttributes.Static
        );
    ILGenerator il = entryPoint.GetILGenerator();
    // ...
    il.Emit(OpCodes.Ret);

    tb.CreateType();

    MetadataBuilder metadataBuilder = ab.GenerateMetadata(
        out BlobBuilder ilStream,
        out BlobBuilder fieldData
        );
    PEHeaderBuilder peHeaderBuilder = new PEHeaderBuilder(
                    imageCharacteristics: Characteristics.ExecutableImage);

    ManagedPEBuilder peBuilder = new ManagedPEBuilder(
                    header: peHeaderBuilder,
                    metadataRootBuilder: new MetadataRootBuilder(metadataBuilder),
                    ilStream: ilStream,
                    mappedFieldData: fieldData,
                    entryPoint: MetadataTokens.MethodDefinitionHandle(entryPoint.MetadataToken)
                    );

    BlobBuilder peBlob = new BlobBuilder();
    peBuilder.Serialize(peBlob);

    using var fileStream = new FileStream("MyAssembly.exe", FileMode.Create, FileAccess.Write);
    peBlob.WriteContentTo(fileStream);
}

public static void UseAssembly(string assemblyPath)
{
    Assembly assembly = Assembly.LoadFrom(assemblyPath);
    Type? type = assembly.GetType("MyType");
    MethodInfo? method = type?.GetMethod("SumMethod");
    Console.WriteLine(method?.Invoke(null, [5, 10]));
}

PersistedAssemblyBuilder 类包括 PDB 支持。 可以发出符号信息,并使用它来调试生成的程序集。 API 具有与 .NET Framework 实现类似的形状。 有关详细信息,请参阅发出符号并生成 PDB

类型名称解析

TypeName 是 ECMA-335 类型名称分析器,提供与 System.Type 大致相同的功能,但与运行时环境是分离的。 序列化程序和编译器等组件需要分析和处理类型名称。 例如,本地 AOT 编译器已改用 TypeName

TypeName 类提供:

  • 用于解析输入(表示为Parse)的静态TryParseReadOnlySpan<char>方法。 这两种方法都接受类(选项包)的 TypeNameParseOptions 实例,可用于自定义分析。

  • NameFullNameAssemblyQualifiedName 属性的工作方式与它们的对应项 System.Type完全相同。

  • 多个属性和方法提供有关名称本身的其他信息:

    • IsArrayIsSZArraySZ 表示单维、零索引数组), IsVariableBoundArrayType以及 GetArrayRank 用于处理数组。
    • IsConstructedGenericTypeGetGenericTypeDefinition以及 GetGenericArguments 用于处理泛型类型名称。
    • IsByRef 以及 IsPointer 用于处理指针和托管引用。
    • GetElementType() 用于处理指针、引用和数组。
    • IsNestedDeclaringType 用于处理嵌套类型。
    • AssemblyName,它通过新 AssemblyNameInfo 类公开程序集名称信息。 相比之下AssemblyName,新类型是不可变的,解析文化名称不会创建CultureInfo的实例。

TypeName 种类型 AssemblyNameInfo 都是不可变的,不提供检查相等性的方法(它们不实现 IEquatable)。 比较程序集名称很简单,但不同的方案只需要比较公开信息的子集(NameVersionCultureNamePublicKeyOrToken)。

以下代码片段显示了一些示例用法。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection.Metadata;

internal class RestrictedSerializationBinder
{
    Dictionary<string, Type> AllowList { get; set; }

    RestrictedSerializationBinder(Type[] allowedTypes)
        => AllowList = allowedTypes.ToDictionary(type => type.FullName!);

    Type? GetType(ReadOnlySpan<char> untrustedInput)
    {
        if (!TypeName.TryParse(untrustedInput, out TypeName? parsed))
        {
            throw new InvalidOperationException($"Invalid type name: '{untrustedInput.ToString()}'");
        }

        if (AllowList.TryGetValue(parsed.FullName, out Type? type))
        {
            return type;
        }
        else if (parsed.IsSimple // It's not generic, pointer, reference, or an array.
            && parsed.AssemblyName is not null
            && parsed.AssemblyName.Name == "MyTrustedAssembly"
            )
        {
            return Type.GetType(parsed.AssemblyQualifiedName, throwOnError: true);
        }

        throw new InvalidOperationException($"Not allowed: '{untrustedInput.ToString()}'");
    }
}

NuGet 包中提供了 System.Reflection.Metadata 新的 API,可与较低版本的 .NET 配合使用。

正则表达式

属性上的 [GeneratedRegex]

.NET 7 引入了 Regex 源生成器和相应的 GeneratedRegexAttribute 属性。

以下的部分方法将从源中生成实现 Regex 所需的全部代码。

[GeneratedRegex(@"\b\w{5}\b")]
private static partial Regex FiveCharWord();

C# 13 除了分部方法之外,还支持部分 属性 ,因此从 .NET 9 开始,还可以在 [GeneratedRegex(...)] 属性上使用。

以下分部属性是上一个示例的属性等效属性。

[GeneratedRegex(@"\b\w{5}\b")]
private static partial Regex FiveCharWordProperty { get; }

Regex.EnumerateSplits

Regex 类提供一个 Split 方法,其概念与 String.Split 该方法类似。 使用String.Split时,提供一个或多个charstring分隔符,并且实现对这些分隔符拆分输入文本。 使用 Regex.Split 时,不是将分隔符指定为 charstring,而是指定为正则表达式模式。

下面的示例演示了 Regex.Split.

foreach (string s in Regex.Split("Hello, world! How are you?", "[aeiou]"))
{
    Console.WriteLine($"Split: \"{s}\"");
}

// Output, split by all English vowels:
// Split: "H"
// Split: "ll"
// Split: ", w"
// Split: "rld! H"
// Split: "w "
// Split: "r"
// Split: " y"
// Split: ""
// Split: "?"

但是,Regex.Split 仅接受 string 作为输入,不支持将 ReadOnlySpan<char> 作为输入。 此外,它将输出完整的拆分集作为一个string[],这需要为保存结果分配一个string数组,并为每个拆分分配一个string。 在 .NET 9 中,新 EnumerateSplits 方法可以执行相同的操作,但使用基于 span 的输入,而无需为结果分配任何内存。 它接受一个 ReadOnlySpan<char> 并返回由表示结果的 Range 对象组成的可枚举集合。

以下示例演示了一个以 Regex.EnumerateSplits 为输入的 ReadOnlySpan<char>

ReadOnlySpan<char> input = "Hello, world! How are you?";
foreach (Range r in Regex.EnumerateSplits(input, "[aeiou]"))
{
    Console.WriteLine($"Split: \"{input[r]}\"");
}

序列化 (System.Text.Json)

缩进选项

JsonSerializerOptions 包括新的属性,用于自定义写入 JSON 的缩进字符和缩进大小。

var options = new JsonSerializerOptions
{
    WriteIndented = true,
    IndentCharacter = '\t',
    IndentSize = 2,
};

string json = JsonSerializer.Serialize(
    new { Value = 1 },
    options
    );
Console.WriteLine(json);
//{
//                "Value": 1
//}

默认 Web 选项单一实例

如果要使用 ASP.NET Core 为 Web 应用提供的默认选项进行序列化,请使用新的JsonSerializerOptions.Web 单例。

string webJson = JsonSerializer.Serialize(
    new { SomeValue = 42 },
    JsonSerializerOptions.Web // Defaults to camelCase naming policy.
    );
Console.WriteLine(webJson);
// {"someValue":42}

JsonSchemaExporter

JSON 通常用于将方法签名中的类型表示为远程过程调用方案的一部分。 例如,它用作 OpenAPI 规范的一部分,或者用作通过 OPENAI 等 AI 服务调用的工具的一部分。 开发人员可以使用 System.Text.Json 将 .NET 类型序列化和反序列化为 JSON。 但是,它们还需要能够获取一个 JSON 架构来描述 .NET 类型的形状(也就是说,描述要序列化的内容和可反序列化的内容的形状)。 System.Text.Json 现在提供类型 JsonSchemaExporter ,它支持生成表示 .NET 类型的 JSON 架构。

有关详细信息,请参阅 JSON 架构导出程序

遵循可以为 Null 批注

System.Text.Json 现在可以识别属性的可为 Null 性注解,并可使用 RespectNullableAnnotations 标志来进行配置,以便在序列化和反序列化过程中强制执行这些注解。

以下代码演示如何设置选项:

public static void RunIt()
{
    JsonSerializerOptions options = new() { RespectNullableAnnotations = true };

    // Throws exception: System.Text.Json.JsonException: The property or field
    // 'Title' on type 'Serialization+Book' doesn't allow getting null values.
    // Consider updating its nullability annotation.
    JsonSerializer.Serialize(new Book { Title = null! }, options);

    // Throws exception: System.Text.Json.JsonException: The property or field
    // 'Title' on type 'Serialization+Book' doesn't allow setting null values.
    // Consider updating its nullability annotation.
    JsonSerializer.Deserialize<Book>("""{ "Title" : null }""", options);
}

public class Book
{
    public required string Title { get; set; }
    public string? Author { get; set; }
    public int PublishYear { get; set; }
}

有关详细信息,请参阅遵循可为 null 的注释

需要非可选构造函数参数

从历史上看,在使用基于构造函数的反序列化时, System.Text.Json 非可选构造函数参数被视为可选参数。 可以使用新 RespectRequiredConstructorParameters 标志更改该行为。

以下代码演示如何设置选项:

JsonSerializerOptions options = new() { RespectRequiredConstructorParameters = true };

// Throws exception: System.Text.Json.JsonException: JSON deserialization
// for type 'Serialization+MyPoco' was missing required properties including: 'Value'.
JsonSerializer.Deserialize<MyPoco>("""{}""", options);

MyPoco 类型定义如下:

record MyPoco(string Value);

有关详细信息,请参阅 非可选构造函数参数

排序 JsonObject 属性

JsonObject 类型现在提供类似于有序字典的 API,允许显式地操作属性顺序。

JsonObject jObj = new()
{
    ["key1"] = true,
    ["key3"] = 3
};

Console.WriteLine(jObj is IList<KeyValuePair<string, JsonNode?>>); // True.

// Insert a new key-value pair at the correct position.
int key3Pos = jObj.IndexOf("key3") is int i and >= 0 ? i : 0;
jObj.Insert(key3Pos, "key2", "two");

foreach (KeyValuePair<string, JsonNode?> item in jObj)
{
    Console.WriteLine($"{item.Key}: {item.Value}");
}

// Output:
// key1: true
// key2: two
// key3: 3

有关详细信息,请参阅 属性顺序操作

自定义枚举成员名称

System.Text.Json.Serialization.JsonStringEnumMemberNameAttribute 属性可用于为序列化为字符串的类型自定义单个枚举成员的名称:

JsonSerializer.Serialize(MyEnum.Value1 | MyEnum.Value2); // "Value1, Custom enum value"

[Flags, JsonConverter(typeof(JsonStringEnumConverter))]
enum MyEnum
{
    Value1 = 1,
    [JsonStringEnumMemberName("Custom enum value")]
    Value2 = 2,
}

有关详细信息,请参阅 自定义枚举成员名称

流式传输多个 JSON 文档

System.Text.Json.Utf8JsonReader 现在支持从单个缓冲区或流读取多个空格分隔的 JSON 文档。 默认情况下,如果读取器检测到任何尾随第一个顶级文档的非空格字符,则会引发异常。 可以使用 AllowMultipleValues 标志更改此行为。

有关详细信息,请参阅 读取多个 JSON 文档

跨度

在高性能代码中,span 通常用于避免不必要地分配字符串。 Span<T>ReadOnlySpan<T> 将继续彻底改变 .NET 代码的编写方式,而且每个版本都会添加越来越多的 span 操作方法。 .NET 9 包含以下与 span 相关的更新:

文件助手

File类现在具有新的帮助程序,可以轻松地直接将ReadOnlySpan<char>/ReadOnlySpan<byte>ReadOnlyMemory<char>/ReadOnlyMemory<byte>写入文件。

以下代码可有效地将文件 ReadOnlySpan<char> 写入文件。

ReadOnlySpan<char> text = ...;
File.WriteAllText(filePath, text);

此外,还添加了新的 StartsWith<T>(ReadOnlySpan<T>, T)EndsWith<T>(ReadOnlySpan<T>, T) 扩展方法用于跨度,因此可以轻松测试 ReadOnlySpan<T> 开始还是以特定 T 值结尾。

以下代码使用这些新的便利 API。

ReadOnlySpan<char> text = "some arbitrary text";
return text.StartsWith('"') && text.EndsWith('"'); // false

params ReadOnlySpan<T> 重载

C# 始终支持将数组参数 params标记为 . 此关键字启用简化的调用语法。 例如,方法 String.Join(String, String[]) 的第二个参数标记有 params。 可以使用数组或单独传递值来调用此重载:

string result = string.Join(", ", new string[3] { "a", "b", "c" });
string result = string.Join(", ", "a", "b", "c");

在 .NET 9 之前,当单独传递值时,C# 编译器通过围绕三个参数生成隐式数组来发出与第一次调用相同的代码。

从 C# 13 开始,你可以使用params与任何可以通过集合表达式构造的参数一起使用,包括 spans(Span<T>ReadOnlySpan<T>)。 这有利于可用性和性能。 C# 编译器可以将参数存储在堆栈上,环绕这些参数,并将该范围传递给该方法,从而避免产生其他结果的隐式数组分配。

.NET 9 包含 60 多种具有 params ReadOnlySpan<T> 参数的方法。 有些是全新的重载,有些是已经使用 ReadOnlySpan<T> 的现有方法,但现在该参数已标记为 params。 净效果是,如果升级到 .NET 9 并重新编译代码,你将看到性能改进,而无需进行任何代码更改。 这是因为编译器更喜欢绑定到基于范围的重载,而不是绑定到基于数组的重载。

例如,String.Join 现在包括以下重载,它实现了新模式:String.Join(String, ReadOnlySpan<String>)

现在,类似string.Join(", ", "a", "b", "c")的调用在传入"a""b""c"参数时不需要分配数组。

枚举 ReadOnlySpan<char>.Split() 段

string.Split 是一种方便的方法,用于快速对包含一个或多个提供的分隔符的字符串进行分区。 但是,对于专注于性能的代码,string.Split 的分配配置文件可能成为阻碍,因为它为每个解析的组件分配一个字符串,并且用 string[] 来存储所有这些组件。 它也不能与 span 一起使用,因此,如果你有一个 ReadOnlySpan<char>,当你将其转换为字符串时,你将被迫分配另一个字符串,以便在其上调用 string.Split

在 .NET 8 中,为Split引入了一组SplitAnyReadOnlySpan<char>方法。 这些方法不是返回一个新的string[],而是接受一个目标Span<Range>,写入每个组件的边界索引。 这使得操作完全不需要分配。 这些方法适合在范围数量已知且较少的情况下使用。

在 .NET 9 中,添加了 Split and SplitAny 的新重载,允许对具有 ReadOnlySpan<T> 未知段数的 进行增量解析。 新方法能够枚举每个段,这些段表示成与Range类似的格式,可以用于切割原始跨度。

public static bool ListContainsItem(ReadOnlySpan<char> span, string item)
{
    foreach (Range segment in span.Split(','))
    {
        if (span[segment].SequenceEquals(item))
        {
            return true;
        }
    }

    return false;
}

System.Formats

对象封闭流 TarEntry 中数据的位置或偏移量现在是公共属性。 TarEntry.DataOffset 返回条目的存档流中条目的第一个数据字节所在的位置。 条目的数据封装在一个子流中,您可以通过 TarEntry.DataStream 访问该子流,这样可以隐藏相对于存档流的数据的实际位置。 对于大多数用户来说,这已经足够了,但是,如果需要更大的灵活性,并且想要了解存档流中数据的真实起始位置,则新 TarEntry.DataOffset API 可以轻松支持使用非常大的 TAR 文件进行并发访问等功能。

// Create stream for tar ball data in Azure Blob Storage.
BlobClient blobClient = new(connectionString, blobContainerName, blobName);
Stream blobClientStream = await blobClient.OpenReadAsync(options, cancellationToken);

// Create TarReader for the stream and get a TarEntry.
TarReader tarReader = new(blobClientStream);
System.Formats.Tar.TarEntry? tarEntry = await tarReader.GetNextEntryAsync();

if (tarEntry is null)
    return;

// Get position of TarEntry data in blob stream.
long entryOffsetInBlobStream = tarEntry.DataOffset;
long entryLength = tarEntry.Length;

// Create a separate stream.
Stream newBlobClientStream = await blobClient.OpenReadAsync(options, cancellationToken);
newBlobClientStream.Seek(entryOffsetInBlobStream, SeekOrigin.Begin);

// Read tar ball content from separate BlobClient stream.
byte[] bytes = new byte[entryLength];
await newBlobClientStream.ReadExactlyAsync(bytes, 0, (int)entryLength);

System.Guid

NewGuid() 按照 RFC 9562 中的 UUID 第 4 版规范,创建一个主要由Guid填充的 。 同一份 RFC 还定义了其他版本,包括第 7 版,该版本“具有一个按时间排序的值域,该值域源自广泛使用的著名 Unix Epoch 时间戳源”。 换句话说,大部分数据仍然是随机的,但其中有一部分是基于时间戳的,这使这些值具有自然顺序。 在 .NET 9 中,可以通过新的 GuidGuid.CreateVersion7() 方法按照第 7 版创建 Guid.CreateVersion7(DateTimeOffset)。 还可以使用新 Version 属性检索 Guid 对象的版本字段。

System.IO

使用 zlib-ng 进行压缩

System.IO.Compression功能(如ZipArchiveDeflateStreamGZipStreamZLibStream)都主要基于 zlib 库。 从 .NET 9 开始,这些功能都使用 zlib-ng,该库可在更广泛的作系统和硬件中生成更一致且更高效的处理。

ZLib 和 Brotli 压缩选项

ZLibCompressionOptions并且BrotliCompressionOptions是用于设置特定于算法的压缩级别和策略(Default、、FilteredHuffmanOnlyRunLengthEncodingFixed)的新类型。 这些类型针对的是想要比唯一现有的选项 <System.IO.Compression.CompressionLevel> 更精细的设置的用户。

将来可能会扩展新的压缩选项类型。

以下代码片段显示了一些示例用法:

private MemoryStream CompressStream(Stream uncompressedStream)
{
    MemoryStream compressorOutput = new();
    using ZLibStream compressionStream = new(
        compressorOutput,
        new ZLibCompressionOptions()
        {
            CompressionLevel = 6,
            CompressionStrategy = ZLibCompressionStrategy.HuffmanOnly
        }
        );
    uncompressedStream.CopyTo(compressionStream);
    compressionStream.Flush();

    return compressorOutput;
}

来自 XPS 虚拟打印机的 XPS 文档

由于不支持处理 System.IO.Packaging 文件,以前无法使用库打开来自 V4 XPS 虚拟打印机的 XPS 文档。 .NET 9 中解决了这一差距。

System.Numerics

BigInteger 上限

BigInteger 支持表示本质上任意长度的整数值。 但是,实际上,长度受基础计算机的限制(例如可用内存或计算给定表达式所需的时间)的限制。 此外,存在一些 API,在给定输入导致值过大的情况下会失败。 由于这些限制,.NET 9 强制实施最大长度 BigInteger,即它不能包含不超过 (2^31) - 1 (约 21.4 亿)位。 此类数字表示近 256 MB 的分配,包含大约 6.465 亿位数。 此新的限制可确保所有公开的 API 都表现良好并保持一致,同时仍允许使用远远超出大多数使用场景的数字。

BigMul 应用程序接口

BigMul 是一种产生两个数字完整乘积的操作。 .NET 9 在 BigMulintlonguint 上添加了专用的 ulong API,其返回类型为比参数类型更大的下一个整数类型。

新 API 包括:

矢量转换 API

.NET 9 添加了专用扩展 API,用于在 Vector2Vector3Vector4QuaternionPlane 之间转换。

新 API 如下所示:

对于相同大小的转换,例如在Vector4QuaternionPlane之间的转换,这些转换是零成本的。 缩小转换范围也是如此,例如从 Vector4Vector2Vector3。 为了类型拓宽转换(如从 Vector2Vector3Vector4),有一个普通 API,它将新元素初始化为 0,和一个以 Unsafe 为后缀的 API,这个 API 保持新元素未定义,因此可能实现零成本。

矢量创建 API

有新的Create API 被公开,它们与命名空间Vector中公开的硬件向量类型的等效 API 是一致的,涵盖了Vector2Vector3Vector4System.Runtime.Intrinsics

有关新 API 的详细信息,请参阅:

这些 API 主要用于便捷和保持 .NET 的 SIMD 加速类型的整体一致性。

额外加速

System.Numerics命名空间中的许多类型进行了性能改进,包括BigIntegerVector2Vector3Vector4QuaternionPlane

在某些情况下,这导致核心 API 的速度提高了 2 到 5 倍,包括 Matrix4x4 乘法、从一系列顶点创建 PlaneQuaternion 串联,以及计算 Vector3 的叉积。

对于 SinCos 的 API 也提供了常量折叠支持,该 API 能在一次调用中同时计算 Sin(x)Cos(x),从而提高效率。

用于 AI 的张量

张量是人工智能 (AI) 的基础数据结构。 它们通常被视为多维数组。

张量用于:

  • 表示和编码文本序列(标记)、图像、视频和音频等数据。
  • 有效地处理高维数据。
  • 有效地对更高维数据应用计算。
  • 存储权重信息和中间计算(在神经网络中)。

若要使用 .NET 张量 API,请安装 System.Numerics.Tensors NuGet 包。

新的 Tensor<T> 类型

Tensor<T> 类型扩展了 .NET 库和运行时的 AI 功能。 此类型:

  • 尽可能使用零副本与 ML.NET、TorchSharp 和 ONNX 运行时等 AI 库进行高效互操作。
  • 基于 TensorPrimitives 构建,实现高效的数学运算。
  • 通过提供索引和切片操作,实现轻松高效的数据操作。
  • 不能替代现有的 AI 和机器学习库。 相反,它旨在提供一组常见的 API 来减少代码重复和依赖项,并使用最新的运行时功能实现更好的性能。

以下代码显示了新 Tensor<T> 类型附带的一些 API。

// Create a tensor (1 x 3).
Tensor<int> t0 = Tensor.Create([1, 2, 3], [1, 3]); // [[1, 2, 3]]

// Reshape tensor (3 x 1).
Tensor<int> t1 = t0.Reshape(3, 1); // [[1], [2], [3]]

// Slice tensor (2 x 1).
Tensor<int> t2 = t1.Slice(1.., ..); // [[2], [3]]

// Broadcast tensor (3 x 1) -> (3 x 3).
// [
//  [ 1, 1, 1],
//  [ 2, 2, 2],
//  [ 3, 3, 3]
// ]
var t3 = Tensor.Broadcast<int>(t1, [3, 3]);

// Math operations.
var t4 = Tensor.Add(t0, 1); // [[2, 3, 4]]
var t5 = Tensor.Add(t0.AsReadOnlyTensorSpan(), t0); // [[2, 4, 6]]
var t6 = Tensor.Subtract(t0, 1); // [[0, 1, 2]]
var t7 = Tensor.Subtract(t0.AsReadOnlyTensorSpan(), t0); // [[0, 0, 0]]
var t8 = Tensor.Multiply(t0, 2); // [[2, 4, 6]]
var t9 = Tensor.Multiply(t0.AsReadOnlyTensorSpan(), t0); // [[1, 4, 9]]
var t10 = Tensor.Divide(t0, 2); // [[0.5, 1, 1.5]]
var t11 = Tensor.Divide(t0.AsReadOnlyTensorSpan(), t0); // [[1, 1, 1]]

注释

此 API 在 .NET 9 中被标记为实验性

TensorPrimitives

System.Numerics.Tensors 库包括该 TensorPrimitives 类,该类提供用于对值跨度执行数值运算的静态方法。 在 .NET 9 中,TensorPrimitives 公开的方法范围已显著扩展,从 40 个(在 .NET 8 中)增加到近 200 个重载。 表面积包含 MathMathF 等类型的熟悉的数值运算。 它还包括泛型数学接口,例如 INumber<TSelf>,除了处理单个值外,它们还处理值的范围。 许多操作通过针对 .NET 9 的 SIMD 优化实现也得到了加速。

TensorPrimitives 现在为任何实现了特定接口的 T 类型提供了泛型重载。 (.NET 8 版本仅包含用于操纵float值跨度的重载)。例如,新CosineSimilarity<T>(ReadOnlySpan<T>, ReadOnlySpan<T>)重载对两个floatdouble、或Half向量值,或任何其他实现IRootFunctions<TSelf>的类型的值执行余弦相似性。

比较两个类型为 floatdouble 的向量上余弦相似性运算的精度:

ReadOnlySpan<float> vector1 = [1, 2, 3];
ReadOnlySpan<float> vector2 = [4, 5, 6];
Console.WriteLine(TensorPrimitives.CosineSimilarity(vector1, vector2));
// Prints 0.9746318

ReadOnlySpan<double> vector3 = [1, 2, 3];
ReadOnlySpan<double> vector4 = [4, 5, 6];
Console.WriteLine(TensorPrimitives.CosineSimilarity(vector3, vector4));
// Prints 0.9746318461970762

线程

线程 API 包括对任务迭代的改进、对通道优先级的改进(通道可以对其元素进行排序,而不是先入先出 (FIFO)),以及对更多类型的 Interlocked.CompareExchange 的改进。

Task.WhenEach

添加了各种有用的新 API,用于处理 Task<TResult> 对象。 通过新的 Task.WhenEach 方法,可以使用 await foreach 语句在任务完成时对其进行迭代。 你不再需要执行诸如在一组任务上反复调用 Task.WaitAny 来挑选下一个完成的任务之类的操作。

以下代码多次调用 HttpClient,并在完成时对调用结果进行处理。

using HttpClient http = new();

Task<string> dotnet = http.GetStringAsync("http://dot.net");
Task<string> bing = http.GetStringAsync("http://www.bing.com");
Task<string> ms = http.GetStringAsync("http://microsoft.com");

await foreach (Task<string> t in Task.WhenEach(bing, dotnet, ms))
{
    Console.WriteLine(t.Result);
}

优先的无限通道

命名空间 System.Threading.Channels 允许你使用 CreateBoundedCreateUnbounded 方法创建先入先出(FIFO)通道。 使用 FIFO 通道时,元素按写入通道的顺序从通道中读取。 在 .NET 9 中,添加了新CreateUnboundedPrioritized方法,该方法会根据或自定义Comparer<T>.Default顺序对元素进行排序,以便从通道中读取的下一个元素被认为是最重要的IComparer<T>元素。

以下示例使用新方法创建一个按顺序输出数字 1 到 5 的通道,即使它们按不同的顺序写入通道。

Channel<int> c = Channel.CreateUnboundedPrioritized<int>();

await c.Writer.WriteAsync(1);
await c.Writer.WriteAsync(5);
await c.Writer.WriteAsync(2);
await c.Writer.WriteAsync(4);
await c.Writer.WriteAsync(3);
c.Writer.Complete();

while (await c.Reader.WaitToReadAsync())
{
    while (c.Reader.TryRead(out int item))
    {
        Console.Write($"{item} ");
    }
}

// Output: 1 2 3 4 5

用于更多类型的 Interlocked.CompareExchange

在早期版本的 .NET 中,Interlocked.ExchangeInterlocked.CompareExchange有用于处理 intuintlongulongnintnuintfloatdoubleobject 的重载,以及用于处理任何引用类型的T 泛型重载。 在 .NET 9 中,有新的重载用于原子处理 bytesbyteshortushort。 此外,已删除对泛型Interlocked.Exchange<T>Interlocked.CompareExchange<T>重载的泛型约束,因此这些方法不再局限于仅适用于引用类型。 它们现在可以处理任何基元类型,其中包括上述所有类型和boolchar任何enum类型。