Introduction
The release of .NET 8 has been anticipated by many .NET and Azure developers for a while now, as it marks the latest long-term support (LTS) release since .NET 6. Due to this fact, many organizations have ignored the release of .NET 7, and as such this latest release compounds a lot of the excitement and functionality missed out by the .NET 7 release. Furthermore, the release of .NET 8 brings with it several other exciting announcements, such as the achievement of isolated Azure Functions reaching feature parity with the in-process model, which we will explore later in this two-part blog series.
Are you excited to make the move to .NET 8 to enjoy the new features and language updates? Or, are you excited by the prospect of feature parity that isolated function apps will finally have with the in-process model? Whether you’re eager to explore the innovations introduced in .NET 8 and C# 12 or looking forward to the newfound feature alignment in isolated function apps, this two-part series offers a comprehensive dive into the world of .NET 8, as well as its impact on Azure Functions.
In this article, we’ll cover the biggest changes coming to .NET 8, in terms of performance, features, standard library changes, syntax changes, and so on!
Overview
In the first part of this two-part series, we’ll cover some of the changes that .NET 8 brings to the table, from performance upgrades to language changes and everything in between. For a comprehensive view of everything else that .NET 8 brings to the table, see more here!
At a high level, this update brings the following changes and features to .NET:
- Serialization improvements
- A time abstraction
- UTF8 improvements
- New methods for working with randomness
- New performance-focused types
- System.Numerics and System.Runtime.Intrinsics
- New data validation attributes
- New metrics APIs
- Extended cryptography support
- Support for HTTPS proxy
- New stream-based ZipFile methods
This update also brings C# 12 along with it, which brings forth the following features:
- Primary constructors
- Collection expressions
- ref readonly parameters
- Default lambda parameters
- Alias any type
- Inline arrays
- Experimental attributes
- Interceptors (Preview only)
Examples
There’s a lot to unpack in this update, with a lot of changes that are useful in many niche circumstances: As such, we will explore some examples of the more commonly used features that .NET 8 brings to the table.
Performance
The traditional performance update summary that comes with each .NET release, aptly named “Performance Improvements in .NET 8” this year, is a compilation of hundreds of pages worth of performance changes, ranging from slight to great. Due to the sheer size, we can’t cover everything, but there are some interesting things to note:
- The underlying Enum class has been rewritten to provide increased performance to methods such as
IsDefined
,GetValues
,GetNames
,EnumToString
etc. - Performance improvements to
AddRange
for Lists, whilst also adding Span-related variants toAddRange
,InsertRange
andCopyTo
. - ToString has been made faster for
ints
by caching digits 0 to 299 (a somewhat arbitrary sounding choice, though it does include all the HTTP success codes). This also reduces allocations somewhat. - Dynamic Profile-Guided Optimization, or PGO, has been enabled by default, a change so important that Microsoft partner Stephen Toub who wrote the article on performance improvements touts it as “a one-character PR that might be the most valuable PR in all of .NET 8”. At a high level, it is an optimization technique that is combined with tiering to improve the performance of code by profiling its actual runtime behavior and taking the profiled data to perform smart optimizations.
The rest of the changes are across a large variety of areas, such as the JIT compiler, the GC and VM, threading, reflection, other primitives, I/O, networking, and even more!
Native Ahead of Time Compilation
Native AoT was shipped with .NET 7, however, .NET 8 takes this feature and expands on it greatly. Compiling a .NET application with AoT enabled creates a self-contained executable or library made entirely from native code, requiring no JIT (Just in Time) compilation at runtime. In fact, no JIT-related code is even shipped to the resulting executable or library. As an AoT-compiled application requires no JIT compilation, the issue of cold starts is almost mitigated completely.
To enable this, one can add an additional flag to the appropriate .csproj file:
<PublishAot>true</PublishAot>
Microsoft has presented a comparison between an AoT-compiled app produced in .NET 7 vs .NET 8 to showcase the improvements in file size to the compilation mode:
# .NET 7
# 12.8 MB
dotnet publish -f net7.0 -r linux-x64 -c Release
ls -s --block-size=k
> 12820K /home/stoub/nativeaotexample/bin/Release/net7.0/linux-x64/publish/nativeaotexample
# .NET 8
# 1.5 MB
dotnet publish -f net8.0 -r linux-x64 -c Release
ls -s --block-size=k
> 1536K /home/stoub/nativeaotexample/bin/Release/net8.0/linux-x64/publish/nativeaotexample
Alongside these clear improvements in output size, there have been a lot of performance improvements to parts of the AoT compiler that lack some optimizations that the JIT compiler offers. Most important, however, is the support of ASP.NET web apps. This support is almost fully featured and allows many web apps to make the switch to native AoT. With that said, there are some changes that need to be made to the application code to support this, such as opting into source generators for JSON serialization and so forth.
Primary Constructors
Primary constructors are a feature that, for a while, only `record` style objects have had to enjoy. In .NET 8 however, the syntax is being made available for standard `class` objects also. This provides us a way to clean up a lot of boilerplate code in classes where oftentimes the parameters in the constructor are directly equivalent to backing properties or fields. This means that not only can a lot of POCOs be neatened up, but also classes that are heavy with constructor-based dependency injection. For example:
// Old
public class HttpTrigger
{
private readonly ILogger<HttpTrigger> _logger;
private readonly IPopularBooksService _popularBooksService;
public HttpTrigger(ILogger<HttpTrigger> logger, IPopularBooksService popularBooksService)
{
_logger = logger;
_popularBooksService = popularBooksService;
}
}
// New
public class HttpTrigger(ILogger<HttpTrigger> Logger, IPopularBooksService PopularBooksService)
{
}
Collection Expressions
Collection expressions are another addition in C# 12 that provides syntactic sugar to developers, and they do so by providing terse shorthands for creating and destructuring collections. Using square brackets, `[]`, we can signify the creation of new arrays, lists, and spans, whilst by using `..`, we can signify that the contents of a given collection are to be expanded in place. These provide a convenient syntax to reduce potential boilerplate code. For example:
// Old
string[] updatedReviews = existingBook.Reviews.Concat(new[] { evt.Review }).ToArray();
// New
string[] updatedReviews = [..existingBook.Reviews, evt.Review];
Time Provider
For the longest time, testing functions that were centered around or consumed pieces of time via the DateTime or DateTimeOffset APIs has been a hassle. .NET 8 brings with it an abstraction known as TimeProvider, an abstract class that can be very simply extended and consumed in services where we might need to use the current time at execution.
By depending on the `TimeProvider` abstract class, we can inject our actual implementation when dealing with production code. A contrived example of an implementation might look like the following service:
public class StandardTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
}
When we go to test the consuming service, we can substitute in a testable variant via a new NuGet package that Microsoft exposes, Microsoft.Extensions.TimeProvider.Testing
. With this package, we can deterministically test any classes consuming the abstract TimeProvider
class like so:
namespace Example;
class ServiceThatUsesTime
{
private readonly TimeProvider _timeProvider;
public ServiceThatUsesTime(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public bool IsWeekday()
{
var now = _timeProvider.UtcNow;
return now.DayOfWeek != DayOfWeek.Saturday && now.DayOfWeek != DayOfWeek.Sunday;
}
}
[Test]
public void TimeProviderMockingExample()
{
var mock = new FakeTimeProvider();
// Sets the time provider mock to deterministically return a Monday
mock.SetUtcNow(DateTime.Parse("2023-12-04"));
var svc = new ServiceThatUsesTime(mock);
// Consistently returns true
var result = svc.IsWeekday();
Assert.IsTrue(result);
}
Performance-Focused Types
Some new performance-focused types have been added to the standard library, such as the frozen collections FrozenSet
and FrozenDictionary
. These types are immutable upon creation, which allows for certain optimizations to be made to make reads even faster than they would be with the standard `Set` or `Dictionary` types.
private readonly FrozenSet<string> _bookNames = new HashSet<string>() { "The Hobbit", "The Lord of the Rings", "The Silmarillion" }.ToFrozenSet();
_bookNames.Contains(book.Value.Name);
Another performance-focused type is SearchValues
, which provides an incredibly performant way of finding a char within a string. This is used extensively internally within the .NET compiler, replacing many such standard IndexOfAny
calls, such as in this example for ReplaceLineEndings
:
// Old
internal static int IndexOfNewlineChar(ReadOnlySpan<char> text, out int stride)
{
const string Needles = "\r\n\f\u0085\u2028\u2029";
int idx = text.IndexOfAny(needles);
...
}
// New
internal static class SearchValuesStorage
{
public static readonly SearchValues<char> NewLineChars = SearchValues.Create("\r\n\f\u0085\u2028\u2029");
}
int idx = text.IndexOfAny(SearchValuesStorage.NewLineChars);
Keyed Dependency Injection
For a while, to inject variations of an interface into the dependency injection container and have different consumers target specific implementations, you’d need to either reach for AutoFac, create a provider class, or pull down a list of all implementations and use some discriminator to find the one you need. This was predominantly an issue when dealing with classes that may be configured in multiple ways depending on the consumer, such as caches. ConsumerA might want an instance of IMemoryCache that has a large size limit and a certain compaction rate, whilst ConsumerB might want an instance that has a small limit.
Now, we can use keyed dependency injection to have consumers target which version of the class or interface they’d like to receive:
builder.Services.AddKeyedSingleton<IMemoryCache, BigCache>("big");
builder.Services.AddKeyedSingleton<IMemoryCache, SmallCache>("small");
public class ServiceA([FromKeyedServices("big")] IMemoryCache bigCache)
{
public string Get(string id) => bigCache.Get(id);
}
public class ServiceB([FromKeyedServices("small")] IMemoryCache smallCache)
{
public string Get(string id) => smallCache.Get(id);
}
Up Next
In this part of the two-part series, we investigated a few of the more commonly used features and functionalities unlocked by .NET 8, however, in the next part we’ll set our sights on the latest changes available in Azure Functions!