What's new in .NET libraries for .NET 9

This article describes new features in the .NET libraries for .NET 9. It's been updated for .NET 9 Preview 5.

Collections

The PriorityQueue<TElement,TPriority> collection type in the System.Collections.Generic namespace includes a new Remove(TElement, TElement, TPriority, IEqualityComparer<TElement>) method that you can use to update the priority of an item in the queue.

PriorityQueue.Remove() method

.NET 6 introduced the PriorityQueue<TElement,TPriority> collection, which provides a simple and fast array-heap implementation. One issue with array heaps in general is that they don't support priority updates, which makes them prohibitive for use in algorithms such as variations of Dijkstra's algorithm.

While it's not possible to implement efficient $O(\log n)$ priority updates in the existing collection, the new PriorityQueue<TElement,TPriority>.Remove(TElement, TElement, TPriority, IEqualityComparer<TElement>) method makes it possible to emulate priority updates (albeit at $O(n)$ time):

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

This method unblocks users who want to implement graph algorithms in contexts where asymptotic performance isn't a blocker. (Such contexts include education and prototyping.) For example, here's a toy implementation of Dijkstra's algorithm that uses the new API.

Component model

TypeDescriptor trimming support

System.ComponentModel includes new opt-in trimmer-compatible APIs for describing components. Any application, especially self-contained trimmed applications, can use these new APIs to help support trimming scenarios.

The primary API is the public static void RegisterType<T>() method on the TypeDescriptor class. This method has the DynamicallyAccessedMembersAttribute attribute so that the trimmer preserves members for that type. You should call this method once per type, and typically early on.

The secondary APIs have a FromRegisteredType suffix, such as TypeDescriptor.GetPropertiesFromRegisteredType(Type componentType) . Unlike their counterparts that don't have the FromRegisteredType suffix, these APIs don't have [RequiresUnreferencedCode] or [DynamicallyAccessedMembers] trimmer attributes. The lack of trimmer attributes helps consumers by no longer having to either:

  • Suppress trimming warnings, which can be risky.
  • Propagate a strongly typed Type parameter to other methods, which can be cumbersome or infeasible.
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; }
}

For more information, see the API proposal.

Cryptography

For cryptography, .NET 9 adds a new one-shot hash method on the CryptographicOperations type. It also adds new classes that use the KMAC algorithm.

CryptographicOperations.HashData() method

.NET includes several static "one-shot" implementations of hash functions and related functions. These APIs include SHA256.HashData and HMACSHA256.HashData. One-shot APIs are preferable to use because they can provide the best possible performance and reduce or eliminate allocations.

If a developer wants to provide an API that supports hashing where the caller defines which hash algorithm to use, it's typically done by accepting a HashAlgorithmName argument. However, using that pattern with one-shot APIs would require switching over every possible HashAlgorithmName and then using the appropriate method. To solve that problem, .NET 9 introduces the CryptographicOperations.HashData API. This API lets you produce a hash or HMAC over an input as a one-shot where the algorithm used is determined by a HashAlgorithmName.

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

KMAC algorithm

.NET 9 provides the KMAC algorithm as specified by NIST SP-800-185. KECCAK Message Authentication Code (KMAC) is a pseudorandom function and keyed hash function based on KECCAK.

The following new classes use the KMAC algorithm. Use instances to accumulate data to produce a MAC, or use the static HashData method for a one-shot over a single input.

KMAC is available on Linux with OpenSSL 3.0 or later, and on Windows 11 Build 26016 or later. You can use the static IsSupported property to determine if the platform supports the desired algorithm.

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

Date and time

New TimeSpan.From* overloads

The TimeSpan class offers several From* methods that let you create a TimeSpan object using a double. However, since double is a binary-based floating-point format, inherent imprecision can lead to errors. For instance, TimeSpan.FromSeconds(101.832) might not precisely represent 101 seconds, 832 milliseconds, but rather approximately 101 seconds, 831.9999999999936335370875895023345947265625 milliseconds. This discrepancy has caused frequent confusion, and it's also not the most efficient way to represent such data. To address this, .NET 9 adds new overloads that let you create TimeSpan objects from integers. There are new overloads from FromDays, FromHours, FromMinutes, FromSeconds, FromMilliseconds, and FromMicroseconds.

The following code shows an example of calling the double and one of the new integer overloads.

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

Dependency injection

ActivatorUtilities.CreateInstance constructor

The constructor resolution for ActivatorUtilities.CreateInstance has changed in .NET 9. Previously, a constructor that was explicitly marked using the ActivatorUtilitiesConstructorAttribute attribute might not be called, depending on the ordering of constructors and the number of constructor parameters. The logic has changed in .NET 9 such that a constructor that has the attribute is always called.

Diagnostics

Previously, you could only link a tracing Activity to other tracing contexts when you created the Activity. New in .NET 9, the Activity.AddLink(System.Diagnostics.ActivityLink) API lets you link an Activity object to other tracing contexts after it's created. This change aligns with the OpenTelemetry specifications as well.

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

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

LINQ

New methods CountBy and AggregateBy have been introduced. These methods make it possible to aggregate state by key without needing to allocate intermediate groupings via GroupBy.

CountBy lets you quickly calculate the frequency of each key. The following example finds the word that occurs most frequently in a text string.

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 lets you implement more general-purpose workflows. The following example shows how you can calculate scores that are associated with a given key.

(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>) makes it possible to quickly extract the implicit index of an enumerable. You can now write code such as the following snippet to automatically index items in a collection.

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

Miscellaneous

In this section, find information about:

params ReadOnlySpan<T> overloads

C# has always supported marking array parameters as params. This keyword enables a simplified calling syntax. For example, the String.Join(String, String[]) method's second parameter is marked with params. You can call this overload with an array or by passing the values individually:

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

Prior to .NET 9, when you pass the values individually, the C# compiler emits code identical to the first call by producing an implicit array around the three arguments.

Starting in C# 13, you can use params with any argument that can be constructed via a collection expression, including spans (Span<T> and ReadOnlySpan<T>). That's beneficial for a variety of reasons, including performance. The C# compiler can store the arguments on the stack, wrap a span around them, and pass that off to the method, which avoids the implicit array allocation that would have otherwise resulted.

.NET 9 now includes over 60 methods with a params ReadOnlySpan<T> parameter. Some are brand new overloads, and some are existing methods that already took a ReadOnlySpan<T> but now have that parameter marked with params. The net effect is if you upgrade to .NET 9 and recompile your code, you'll see performance improvements without making any code changes. That's because the compiler prefers to bind to span-based overloads than to the array-based overloads.

For example, String.Join now includes the following overload , which implements the new pattern:

public static string Join(string? separator, params ReadOnlySpan<string?> value)

Now, a call like string.Join(", ", "a", "b", "c") is made without allocating an array to pass in the "a", "b", and "c" arguments.

SearchValues expansion

.NET 8 introduced the SearchValues<T> type, which provides an optimized solution for searching for specific sets of characters or bytes within spans. In .NET 9, SearchValues has been extended to support searching for substrings within a larger string.

The following example searches for multiple animal names within a string value, and returns an index to the first one found.

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

This new capability has an optimized implementation that takes advantage of the SIMD support in the underlying platform. It also enables higher-level types to be optimized. For example, Regex now utilizes this functionality as part of its implementation.

Reflection

Persisted assemblies

In .NET Core versions and .NET 5-8, support for building an assembly and emitting reflection metadata for dynamically created types was limited to a runnable AssemblyBuilder. The lack of support for saving an assembly was often a blocker for customers migrating from .NET Framework to .NET. .NET 9 adds a new type, PersistedAssemblyBuilder, that you can use to save an emitted assembly.

To create a PersistedAssemblyBuilder instance, call its constructor and pass the assembly name, the core assembly, System.Private.CoreLib, to reference base runtime types, and optional custom attributes. After you emit all members to the assembly, call the PersistedAssemblyBuilder.Save(String) method to create an assembly with default settings. If you want to set the entry point or other options, you can call PersistedAssemblyBuilder.GenerateMetadata and use the metadata it returns to save the assembly. The following code shows an example of creating a persisted assembly and setting the entry point.

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

The new PersistedAssemblyBuilder class includes PDB support. You can emit symbol info and use it to debug a generated assembly. The API has a similar shape to the .NET Framework implementation. For more information, see Emit symbols and generate PDB.

Type-name parsing

TypeName is a parser for ECMA-335 type names that provides much the same functionality as System.Type but is decoupled from the runtime environment. Components like serializers and compilers need to parse and process type names. For example, the Native AOT compiler has switched to using TypeName .

The new TypeName class provides:

  • Static Parse and TryParse methods for parsing input represented as ReadOnlySpan<char>. Both methods accept an instance of TypeNameParseOptions class (an option bag) that lets you customize the parsing.

  • Name, FullName, and AssemblyQualifiedName properties that work exactly like their counterparts in System.Type.

  • Multiple properties and methods that provide additional information about the name itself:

    • IsArray, IsSZArray (SZ stands for single-dimension, zero-indexed array), IsVariableBoundArrayType, and GetArrayRank for working with arrays.
    • IsConstructedGenericType, GetGenericTypeDefinition, and GetGenericArguments for working with generic type names.
    • IsByRef and IsPointer for working with pointers and managed references.
    • GetElementType() for working with pointers, references, and arrays.
    • IsNested and DeclaringType for working with nested types.
    • AssemblyName, which exposes the assembly name information via the new AssemblyNameInfo class. In contrast to AssemblyName, the new type is immutable, and parsing culture names doesn't create instances of CultureInfo.

Both TypeName and AssemblyNameInfo types are immutable and don't provide a way to check for equality (they don't implement IEquatable). Comparing assembly names is simple, but different scenarios need to compare only a subset of exposed information (Name, Version, CultureName, and PublicKeyOrToken).

The following code snippet shows some example usage.

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()}'");
    }
}

The new APIs are available from the System.Reflection.Metadata NuGet package, which can be used with down-level .NET versions.

Serialization

In System.Text.Json, .NET 9 has new options for serializing JSON and a new singleton that makes it easier to serialize using web defaults.

Indentation options

JsonSerializerOptions includes new properties that let you customize the indentation character and indentation size of written JSON.

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

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

Default web options

If you want to serialize with the default options that ASP.NET Core uses for web apps, use the new JsonSerializerOptions.Web singleton.

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

Tensors for AI

Tensors are the cornerstone data structure of artificial intelligence (AI). They can often be thought of as multidimensional arrays.

Tensors are used to:

  • Represent and encode data such as text sequences (tokens), images, video, and audio.
  • Efficiently manipulate higher-dimensional data.
  • Efficiently apply computations on higher-dimensional data.
  • Store weight information and intermediate computations (in neural networks).

To use the .NET tensor APIs, install the System.Numerics.Tensors NuGet package.

New Tensor<T> type

The new Tensor<T> type expands the AI capabilities of the .NET libraries and runtime. This type:

  • Provides efficient interop with AI libraries like ML.NET, TorchSharp, and ONNX Runtime using zero copies where possible.
  • Builds on top of TensorPrimitives for efficient math operations.
  • Enables easy and efficient data manipulation by providing indexing and slicing operations.
  • Is not a replacement for existing AI and machine learning libraries. Instead, it's intended to provide a common set of APIs to reduce code duplication and dependencies, and to achieve better performance by using the latest runtime features.

The following codes shows some of the APIs included with the new Tensor<T> type.

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

// Reshape tensor (3 x 1).
var t1 = t0.Reshape(3, 1); // [[1], [2], [3]]

// Slice tensor (2 x 1).
var 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(t1, [3, 3]);

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

TensorPrimitives

The System.Numerics.Tensors library includes the TensorPrimitives class, which provides static methods for performing numerical operations on spans of values. In .NET 9, the scope of methods exposed by TensorPrimitives has been significantly expanded, growing from 40 (in .NET 8) to almost 200 overloads. The surface area encompasses familiar numerical operations from types like Math and MathF. It also includes the generic math interfaces like INumber<TSelf>, except instead of processing an individual value, they process a span of values. Many operations have also been accelerated via SIMD-optimized implementations for .NET 9.

TensorPrimitives now exposes generic overloads for any type T that implements a certain interface. (The .NET 8 version only included overloads for manipulating spans of float values.) For example, the new CosineSimilarity<T>(ReadOnlySpan<T>, ReadOnlySpan<T>) overload performs cosine similarity on two vectors of float, double, or Half values, or values of any other type that implements IRootFunctions<TSelf>.

Compare the precision of the cosine similarity operation on two vectors of type float versus double:

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

Threading

The threading APIs include improvements for iterating through tasks, and for prioritized channels, which can order their elements instead of being first-in-first-out (FIFO).

Task.WhenEach

A variety of helpful new APIs have been added for working with Task<TResult> objects. The new Task.WhenEach method lets you iterate through tasks as they complete using an await foreach statement. You no longer need to do things like repeatedly call Task.WaitAny on a set of tasks to pick off the next one that completes.

The following code makes multiple HttpClient calls and operates on their results as they complete.

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

Prioritized unbounded channel

The System.Threading.Channels namespace lets you create first-in-first-out (FIFO) channels using the CreateBounded and CreateUnbounded methods. With FIFO channels, elements are read from the channel in the order they were written to it. In .NET 9, the new CreateUnboundedPrioritized<T> method has been added, which orders the elements such that the next element read from the channel is the one deemed to be most important, according to either Comparer<T>.Default or a custom IComparer<T>.

The following example uses the new method to create a channel that outputs the numbers 1 through 5 in order, even though they're written to the channel in a different order.

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