.NET 11 и C# 15: что нового и как это использовать на практике
.NET 11 и C# 15: что нового и как это использовать на практике#
10 ноября 2026 года Microsoft выпустила .NET 11 — очередную версию платформы, вместе с которой стали доступны C# 15, ASP.NET Core 11, Entity Framework Core 11 и обновлённые инструменты разработки.
На первый взгляд всё привычно: оптимизации JIT, улучшения производительности, новые API и очередная версия языка. Но в этот раз есть несколько изменений, которые действительно могут повлиять на то, как мы пишем приложения. В C# 15 наконец появились нативные union types, закрытые иерархии классов и управление вложенными циклами через метки. В ASP.NET Core добавили асинхронную валидацию, а Blazor получил кэширование результатов рендеринга отдельных компонентов. Я решил не ограничиваться перечислением возможностей. Давайте разберём, какие проблемы они решают, как мы обходились без них раньше и что изменилось теперь.
1. Union types: проблема, с которой я уже сталкивался#
Начну, пожалуй, с самого интересного нововведения C# 15. В апреле 2025 года я написал статью «Боремся с union types». Тогда мы столкнулись с довольно неприятной особенностью стороннего API. Сервис возвращал JSON, в котором одно и то же поле options могло содержать совершенно разные значения.
Например, массив вариантов ответа:
{
"options": [
{
"is_correct": true,
"text": "Правильный ответ"
},
{
"is_correct": false,
"text": "Неправильный ответ"
}
]
}
Или объект с настройками:
{
"options": {
"is_checkbox": true,
"sample_size": 5
}
}
Или пустой объект:
{
"options": {}
}
А иногда свойство options вообще отсутствовало. Получается, одно поле могло представлять несколько совершенно разных структур данных.
Как решали проблему раньше#
В C# нельзя было объявить свойство примерно так:
// Так в C# 14 написать нельзя:
public List<OptionItem> | OptionSettings Options;
Можно было использовать object или dynamic, но тогда терялась значительная часть преимуществ строгой типизации. В нашем случае решение состояло из класса-обёртки и собственного JsonConverter.
Упрощённая модель выглядела так:
public class OptionsWrapper
{
public List<OptionItem>? OptionsList { get; set; }
public OptionSettings? Settings { get; set; }
public bool IsEmpty =>
OptionsList is null && Settings is null;
}
А в JsonConverter мы определяли тип входящего значения:
if (token.Type == JTokenType.Array)
{
return new OptionsWrapper
{
OptionsList =
token.ToObject<List<OptionItem>>(serializer)
};
}
if (token.Type == JTokenType.Object)
{
// Анализируем свойства объекта
// и определяем нужную модель.
}
Решение работало. Но у него был существенный недостаток. Посмотрите на OptionsWrapper ещё раз.
Ничто не запрещает написать:
var options = new OptionsWrapper
{
OptionsList = [new OptionItem()],
Settings = new OptionSettings()
};
То есть одновременно заполнить оба свойства, хотя исходный JSON такого состояния не предусматривает. Более того, при чтении OptionsWrapper приходится проверять, какое именно свойство заполнено.
Что изменилось в C# 15#
Теперь можно использовать ключевое слово union. Для начала определим варианты данных:
public record OptionItem(
bool IsCorrect,
string Text);
public record OptionSettings(
bool IsCheckbox,
int SampleSize);
public record OptionList(
IReadOnlyList<OptionItem> Items);
public record EmptyOptions;
И объединим их:
public union SourceOptions(
OptionList,
OptionSettings,
EmptyOptions);
Теперь SourceOptions может содержать значение одного из трёх перечисленных типов.
Например:
SourceOptions options = new OptionSettings(
IsCheckbox: true,
SampleSize: 5);
Или:
SourceOptions options = new OptionList(
[
new(true, "Ответ A"),
new(false, "Ответ B")
]);
Обратите внимание: не нужно вручную создавать объект-обёртку и заполнять одно из нескольких nullable-свойств.
Обработка union через switch#
Самое интересное начинается при обработке результата:
string Describe(SourceOptions options) =>
options switch
{
OptionList list =>
$"Вариантов ответа: {list.Items.Count}",
OptionSettings settings =>
$"Размер выборки: {settings.SampleSize}",
EmptyOptions =>
"Настройки отсутствуют"
};
Здесь компилятор понимает, что SourceOptions содержит только перечисленные варианты.
Если мы добавим новый тип:
public record ExternalOptions(string Url);
public union SourceOptions(
OptionList,
OptionSettings,
EmptyOptions,
ExternalOptions);
Компилятор сможет предупредить, что наш switch больше не обрабатывает все возможные состояния. Это называется проверкой полноты сопоставления с образцом (exhaustiveness checking).
А что с JSON?#
Здесь есть важная тонкость. Union types решают проблему представления нескольких возможных типов в C#, но не отменяют необходимость правильно разобрать входящие данные. В нашем примере внешний API не сообщает, какой тип находится в поле options. Приходится определять его по структуре JSON:
StartArray— список вариантов.StartObjectс известными свойствами — настройки.- Пустой объект —
EmptyOptions. - Отсутствующее поле — отдельная ситуация, которую можно представить nullable-свойством или специальным вариантом модели.
В .NET 11 поддержка объединений появилась и в System.Text.Json. Однако это не означает, что сериализатор автоматически распознает любую нестандартную структуру стороннего API. Для нашего случая по-прежнему нужен код определения формы входного значения. Зато после десериализации приложение работает с нормальной типизированной моделью, а не с набором потенциально заполненных свойств.
Где это пригодится#
Union types особенно интересны для:
- Результатов бизнес-операций: успех, ошибка, отсутствие данных.
- Интеграций со сторонними
API. - Моделирования сообщений и событий.
- Описания различных вариантов состояния.
- Разработки библиотек, где важно предоставить потребителю строго определённый набор результатов.
На мой взгляд, это одно из наиболее значимых изменений C# за последние годы.
2. Closed hierarchies: когда наследование должно иметь границы#
Следующее нововведение тесно связано с предыдущим. Представим, что мы разрабатываем систему обработки платежей.
У платежа есть несколько состояний:
public abstract record PaymentState;
public record Pending : PaymentState;
public record Paid : PaymentState;
public record Failed(string Reason) : PaymentState;
Всё выглядит вполне нормально. Но есть проблема: любой другой проект, который ссылается на нашу библиотеку, может добавить собственный тип:
public record Refunded : PaymentState;
И это не всегда желательно. Например, логика обработки платежей может быть рассчитана строго на три состояния. Если кто-то добавит четвёртое, мы не сможем гарантировать, что все обработчики знают о его существовании.
Как обходились раньше#
Один из способов — ограничить создание наследников с помощью приватного конструктора и вложенных типов:
public abstract record PaymentState
{
private PaymentState() { }
public sealed record Pending : PaymentState;
public sealed record Paid : PaymentState;
public sealed record Failed(string Reason)
: PaymentState;
}
Такой подход позволяет контролировать создание наследников, но требует размещать их внутри базового типа. Кроме того, компилятор всё равно не воспринимает эту конструкцию как специальную закрытую иерархию при проверке switch.
Поэтому обработчик обычно содержал ветку по умолчанию:
string Describe(PaymentState state) =>
state switch
{
PaymentState.Pending => "Ожидает оплаты",
PaymentState.Paid => "Оплачен",
PaymentState.Failed f => f.Reason,
_ => throw new NotSupportedException()
};
Решение в C# 15#
Теперь появился модификатор closed:
public closed record class PaymentState;
public sealed record Pending : PaymentState;
public sealed record Paid : PaymentState;
public sealed record Failed(string Reason)
: PaymentState;
Модификатор closed означает, что прямые наследники PaymentState могут объявляться только в той же сборке. Допустим, эти типы находятся в библиотеке Payments.Core. Тогда внутри Payments.Core мы можем объявлять новые состояния.
Но другой проект, например Payments.Web, уже не сможет написать:
// В другой сборке — ошибка компиляции
public record Refunded : PaymentState;
Теперь набор прямых наследников контролируется автором библиотеки.
Что это даёт компилятору#
Главное преимущество — возможность проверить полноту обработки:
string Describe(PaymentState state) =>
state switch
{
Pending => "Ожидает оплаты",
Paid => "Оплачен",
Failed f => $"Ошибка: {f.Reason}"
};
Здесь не нужна ветка _. Компилятор знает все допустимые прямые наследники и может определить, что каждый вариант обработан.
Если мы добавим внутри библиотеки:
public sealed record Refunded : PaymentState;
Существующий switch перестанет быть исчерпывающим.
Closed — не то же самое, что sealed#
Это принципиально разные модификаторы.
sealed запрещает дальнейшее наследование вообще.
closed разрешает наследование, но ограничивает место объявления прямых наследников.
Есть и менее очевидная особенность:
public closed class BaseState;
public class CustomState : BaseState;
CustomState не становится автоматически closed.
Поэтому в другой сборке допустимо:
public class ExtendedState : CustomState;
Ограничение не распространяется автоматически на всю цепочку наследования. Если требуется полностью контролируемая иерархия, промежуточные типы тоже нужно закрывать или объявлять sealed. Ещё одна особенность: closed-класс неявно является абстрактным. Поэтому сочетание abstract closed не допускается.
Когда применять#
Я бы использовал закрытые иерархии для конечных автоматов, состояний заказов, платежей, задач и внутренних моделей библиотек. А вот для типов, которые специально предназначены для расширения сторонними разработчиками, closed скорее помешает.
3. Collection expressions: теперь можно передавать аргументы конструктора#
В C# 12 появились выражения коллекций:
List<int> numbers = [1, 2, 3, 4, 5];
Это оказалось удобнее привычного синтаксиса инициализации. Но была одна проблема: как передать дополнительные параметры конструктору коллекции?
Например, компаратор для HashSet.
Раньше#
var tags = new HashSet<string>(
StringComparer.OrdinalIgnoreCase)
{
"dotnet",
"DotNet",
"CSharp"
};
Теперь#
В C# 15 можно использовать with(...):
HashSet<string> tags =
[
with(StringComparer.OrdinalIgnoreCase),
"dotnet",
"DotNet",
"CSharp"
];
Console.WriteLine(tags.Count); // 2
Компаратор передаётся прямо внутри выражения коллекции. Ещё один пример — предварительное выделение ёмкости List<T>.
Раньше:
var items = new List<string>(100);
items.AddRange(source);
Теперь:
List<string> items =
[
with(capacity: 100),
.. source
];
Это не революция, но полезное развитие уже знакомого синтаксиса. Особенно если коллекция требует компаратора, ёмкости или других параметров конструктора.
4. Labeled break и continue: управление вложенными циклами#
Представим задачу импорта данных. Мы обрабатываем несколько документов. Каждый документ содержит разделы, а каждый раздел — записи. Если одна из записей содержит критическую ошибку, нужно прекратить обработку текущего документа и перейти к следующему. Получается три уровня вложенности.
Как это выглядело раньше#
Один из вариантов — использовать флаг:
foreach (var document in documents)
{
bool hasError = false;
foreach (var section in document.Sections)
{
foreach (var record in section.Records)
{
if (!IsValid(record))
{
hasError = true;
break;
}
Import(record);
}
if (hasError)
break;
}
if (hasError)
LogFailedDocument(document);
}
Проблема в том, что обычный break завершает только ближайший цикл. Поэтому приходится протаскивать состояние ошибки через несколько уровней вложенности. Чем больше уровней, тем больше проверок.
Как это выглядит в C# 15#
Теперь можно пометить внешний цикл и обратиться к нему напрямую:
foreach (var document in documents)
{
bool hasError = false;
sections: foreach (var section in document.Sections)
{
foreach (var record in section.Records)
{
if (!IsValid(record))
{
hasError = true;
break sections;
}
Import(record);
}
}
if (hasError)
LogFailedDocument(document);
}
break sections завершает именно цикл по разделам, независимо от глубины вложенности. Обратите внимание: флаг здесь остался, но уже для передачи результата обработки, а не для управления выходом из вложенных циклов.
А как работает continue?#
Предположим, при обнаружении повреждённой записи мы хотим немедленно перейти к следующему документу.
documentsLoop: foreach (var document in documents)
{
foreach (var section in document.Sections)
{
foreach (var record in section.Records)
{
if (!IsValid(record))
{
LogFailedDocument(document);
continue documentsLoop;
}
Import(record);
}
}
MarkAsImported(document);
}
continue documentsLoop начинает следующую итерацию самого внешнего цикла. То есть оставшиеся записи и разделы текущего документа больше не обрабатываются.
Где это полезно#
Подобная логика встречается при обработке файлов, импорте данных, обходе деревьев, поиске по матрицам и анализе вложенных структур. Но есть важное замечание: метки не всегда лучший выбор.
Если логика обработки сложная, иногда гораздо понятнее вынести её в отдельный метод:
foreach (var document in documents)
{
if (!TryImportDocument(document))
LogFailedDocument(document);
}
Labeled break/continue — дополнительный инструмент, а не замена нормальной декомпозиции кода.
5. Extension indexers: индексаторы для чужих типов#
В C# 14 появились новые возможности объявления расширений, а в C# 15 они получили поддержку индексаторов. Представим, что мы работаем с IEnumerable<T>.
Обычно для получения элемента по индексу используем:
var item = sequence.ElementAt(2);
Теперь можно добавить индексатор через extension declaration:
public static class EnumerableExtensions
{
extension<T>(IEnumerable<T> source)
{
public T this[int index] =>
source.ElementAt(index);
}
}
И обращаться к элементам привычным синтаксисом:
IEnumerable<int> numbers =
Enumerable.Range(10, 5);
Console.WriteLine(numbers[2]); // 12
Но здесь важно понимать вот что: такой индексатор — синтаксический сахар, простое удобство, а не оптимизация. Если источник не поддерживает быстрый доступ по индексу, ElementAt может потребовать последовательного обхода элементов. Поэтому добавлять индексаторы имеет смысл прежде всего там, где такой способ обращения действительно соответствует природе коллекции.
6. LINQ: полноценный FullJoin#
В .NET 10 появились LeftJoin и RightJoin. Теперь в .NET 11 добавился FullJoin. Разберём практическую задачу: сравнение двух каталогов товаров.
Есть старый каталог:
record Product(int Id, string Name);
Product[] oldProducts =
[
new(1, "Keyboard"),
new(2, "Mouse")
];
И новый:
Product[] newProducts =
[
new(2, "Mouse"),
new(3, "Monitor")
];
Нам нужно найти все изменения, включая удалённые и добавленные товары.
Раньше#
Один из вариантов — использовать два соединения и объединять результаты. Например, сначала получить все элементы старого каталога с соответствиями из нового, а затем отдельно найти товары, отсутствующие в старом. Код получался довольно громоздким.
Теперь#
var changes = oldProducts.FullJoin(
newProducts,
old => old.Id,
current => current.Id,
(old, current) => new
{
Id = old?.Id ?? current!.Id,
OldName = old?.Name,
NewName = current?.Name
});
Результат:
ID | OldName | NewName
---|----------|---------
1 | Keyboard | null
2 | Mouse | Mouse
3 | null | Monitor
Теперь можно легко определить, какие товары добавлены, удалены или изменены.
Важно, что FullJoin — это возможность стандартного LINQ, а не исключительно Entity Framework Core. Она работает с обычными коллекциями. Для IQueryable<T> возможность выполнения на стороне БД зависит от поддержки соответствующей операции провайдером.
7. ASP.NET Core 11: настоящая асинхронная валидация#
Это изменение мне кажется особенно полезным для веб-разработки. Допустим, мы разрабатываем регистрацию пользователей. При регистрации необходимо проверить, что email ещё не используется.
Обычные атрибуты DataAnnotations прекрасно справляются с простыми проверками:
public class RegisterRequest
{
[Required]
[EmailAddress]
public string Email { get; set; } = "";
}
Но они не могут определить, есть ли такой email в базе данных. Для этого нужен асинхронный запрос.
Как было раньше#
В Minimal API такую проверку часто размещали непосредственно в обработчике:
app.MapPost("/register", async (
RegisterRequest request,
AppDbContext db,
CancellationToken ct) =>
{
bool exists = await db.Users
.AnyAsync(u => u.Email == request.Email, ct);
if (exists)
{
return Results.ValidationProblem(
new Dictionary<string, string[]>
{
["Email"] = ["Email уже занят"]
});
}
// Создание пользователя
return Results.Ok();
});
Решение рабочее, но у него есть недостатки. Валидация оказывается смешана с логикой регистрации. Если проверка понадобится в другом endpoint, придётся повторять код или выносить его в отдельный сервис.
Что появилось в .NET 11#
Теперь в Microsoft.Extensions.Validation есть AsyncValidationAttribute.
Он позволяет выполнять асинхронные проверки непосредственно в механизме валидации.
Например:
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
public sealed class UniqueEmailAttribute
: AsyncValidationAttribute
{
public UniqueEmailAttribute()
{
ErrorMessage = "Email уже зарегистрирован";
}
protected override async Task<ValidationResult?>
IsValidAsync(
object? value,
ValidationContext context,
CancellationToken ct)
{
if (value is not string email ||
string.IsNullOrWhiteSpace(email))
{
return ValidationResult.Success;
}
var users =
context.GetRequiredService<IUserService>();
bool exists =
await users.IsEmailExistsAsync(email, ct);
return exists
? new ValidationResult(
ErrorMessage,
[context.MemberName!])
: ValidationResult.Success;
}
protected override ValidationResult? IsValid(
object? value,
ValidationContext context)
{
throw new InvalidOperationException(
"Требуется асинхронная валидация");
}
}
IUserService здесь — интерфейс нашего сервиса пользователей, который выполняет запрос к базе данных.
Теперь модель выглядит так:
public class RegisterRequest
{
[Required]
[EmailAddress]
[UniqueEmail]
public string Email { get; set; } = "";
}
А в Program.cs регистрируем валидацию:
builder.Services.AddValidation();
После этого Minimal API может автоматически проверить модель до выполнения endpoint:
app.MapPost("/register", (
RegisterRequest request) =>
{
// Проверки уже выполнены.
return Results.Ok();
});
Если email занят, запрос будет отклонён механизмом валидации.
А что с Blazor?#
Здесь ситуация тоже улучшилась. В Blazor асинхронная валидация интегрирована с EditForm. При отправке формы используется асинхронный путь проверки. Для валидации отдельных полей также поддерживаются асинхронные правила. Это позволяет обращаться к БД или внешним сервисам без блокировки потока. Кроме атрибутов появился интерфейс IAsyncValidatableObject, позволяющий выполнять проверки на уровне всей модели. Например, когда нужно проверить не одно поле, а комбинацию значений.
Важные особенности#
Асинхронные валидаторы могут выполняться параллельно, поэтому нельзя рассчитывать на определённый порядок их запуска. Поддержка автоматической асинхронной валидации через Microsoft.Extensions.Validation относится к Minimal APIs и Blazor. Для MVC и Razor Pages этот механизм не является заменой существующей системы валидации.
И ещё один важный момент: проверка уникальности email не заменяет уникальный индекс в базе данных. Между проверкой и сохранением другой запрос может зарегистрировать тот же адрес. Поэтому ограничение уникальности на уровне БД всё равно необходимо.
8. Blazor 11: CacheView и кэширование разметки#
А вот это нововведение мне особенно интересно, потому что мой блог algmironov.com тоже написан на Blazor. Публичная часть сайта работает через Static SSR, а административная панель использует InteractiveServer. Для такого сочетания новый компонент CacheView выглядит весьма полезным.
Как это работало раньше#
Представим, что на главной странице блога отображается список последних публикаций.
Компонент получает данные из сервиса:
@inject IPostQueryService Posts
<ul>
@foreach (var post in _posts)
{
<li>
<a href="/posts/@post.Slug">
@post.Title
</a>
</li>
}
</ul>
@code {
private IReadOnlyList<PostSummary> _posts = [];
protected override async Task OnInitializedAsync()
{
_posts = await Posts.GetRecentAsync(10);
}
}
При каждом рендеринге компонент загружает данные и формирует HTML. Конечно, можно кэшировать результаты запроса через IMemoryCache. Можно использовать и OutputCache, но он работает на уровне HTTP-ответа. А если нам нужно закэшировать только один блок страницы?
Новый CacheView#
В .NET 11 появился компонент CacheView:
<CacheView ExpiresAfter="TimeSpan.FromMinutes(5)">
<RecentPosts />
</CacheView>
При первом запросе RecentPosts выполняет обычный рендеринг. Полученная разметка сохраняется в кэше. При последующих запросах, пока запись действительна, CacheView возвращает готовый HTML, не создавая заново вложенный компонент и не вызывая его жизненный цикл. Таким образом, мы экономим не только обращения к БД, но и работу по построению разметки.
Кэширование с учётом параметров#
Представим страницу со списком статей, фильтруемых по категории:
@page "/articles"
<CacheView
VaryByQuery="category"
ExpiresAfter="TimeSpan.FromMinutes(5)">
<ArticleList Category="@Category" />
</CacheView>
@code {
[SupplyParameterFromQuery]
public string? Category { get; set; }
}
Здесь VaryByQuery="category" указывает, что кэш должен учитывать значение параметра category.
Поэтому страницы:
/articles?category=dotnet
/articles?category=blazor
не будут использовать одну и ту же запись кэша.
Где это пригодится в реальном блоге#
Я бы рассмотрел (и сделаю это чуть позже) CacheView для списка последних публикаций, блоков со статьями определённой серии и других относительно статичных элементов публичной части. Но нужно учитывать несколько особенностей.
Во-первых, CacheView работает при Static SSR и не предназначен для кэширования интерактивных компонентов.
Во-вторых, для персонализированных блоков необходимо разделять записи кэша по соответствующим данным пользователя либо не использовать общий кэш.
В-третьих, время жизни кэша нужно согласовать с процессом публикации. Если статья опубликована прямо сейчас, а список последних публикаций закэширован на пять минут, пользователь может увидеть обновление с задержкой.
Для административной панели с InteractiveServer такой подход не подходит. Там остаются привычные механизмы управления состоянием, кэширования данных и интерактивного рендеринга. Но для публичной части Blazor-сайта это действительно полезный инструмент.
9. Другие изменения Blazor и ASP.NET Core#
Помимо CacheView, в .NET 11 появились и другие интересные возможности.
Обновление авторизации SignalR#
Раньше длительное соединение SignalR могло продолжать работать с устаревшим состоянием аутентификации до переподключения. Теперь поддерживается обновление аутентификации существующего соединения. Для Blazor InteractiveServer это особенно полезно: компоненты, использующие AuthenticationStateProvider и AuthorizeView, могут получать обновлённые данные пользователя без полного переподключения circuit.
Относительная навигация#
У NavigationManager.NavigateTo и NavLink появилась поддержка навигации относительно текущего пути.
Например:
<NavLink
href="settings"
RelativeToCurrentUri="true">
Настройки
</NavLink>
Это удобно для приложений с вложенной структурой страниц.
OpenAPI 3.2#
ASP.NET Core 11 обновил поддержку OpenAPI до версии 3.2. Среди изменений — более точное описание потоковых ответов, включая Server-Sent Events. Для разработчиков API это означает возможность формировать более выразительные спецификации, особенно для современных потоковых сценариев.
Экспериментальные AI-компоненты#
Появился пакет Microsoft.AspNetCore.Components.AI. Он предлагает компоненты для построения интерфейсов AI-агентов: потоковый чат, отображение результатов вызова инструментов, подтверждение действий пользователем и другие элементы. Пока это экспериментальное направление, но оно показывает, что Blazor развивается не только как инструмент создания обычных веб-интерфейсов.
10. EF Core 11: оптимизация запросов и новые возможности#
Entity Framework Core 11 продолжает развитие механизмов генерации SQL и работы со сложными моделями. Среди изменений — улучшения трансляции GroupBy, оптимизации соединений, расширение поддержки JSON и новые возможности для работы с типами моделей. Отдельного внимания заслуживает поддержка FullJoin на уровне запросов к базе данных.
Например, при наличии соответствующей поддержки у провайдера можно написать:
var query = db.OldProducts.FullJoin(
db.NewProducts,
old => old.Id,
current => current.Id,
(old, current) => new
{
OldName = old == null ? null : old.Name,
NewName = current == null ? null : current.Name
});
Вместо того чтобы загружать обе таблицы в память и сравнивать их средствами LINQ to Objects, операция может быть преобразована в SQL. Это важно для больших наборов данных. Впрочем, здесь следует различать возможности самого EF Core и конкретного провайдера базы данных. Поддержка FullJoin на уровне LINQ не означает автоматическую поддержку трансляции в SQL каждым провайдером.
11. Runtime Async: новое направление развития async/await#
Ещё одна интересная возможность .NET 11 — Runtime Async. Чтобы понять её значение, вспомним, как работает привычный async/await.
Когда мы пишем:
public async Task<string> LoadAsync()
{
await Task.Delay(100);
return "Готово";
}
Компилятор традиционно преобразует асинхронный метод в машину состояний.
Это позволяет приостанавливать выполнение на await, а затем продолжать его после завершения ожидаемой операции.
Но у такого механизма есть накладные расходы и особенности отладки.
Runtime Async предлагает другую архитектуру: часть управления приостановкой и возобновлением выполнения переносится в среду выполнения.
Это позволяет уменьшить объём сгенерированного кода и улучшить представление асинхронных стеков вызовов.
Как включить#
В .NET 11 функция остаётся в статусе Preview. Для её включения используется настройка проекта:
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Features>runtime-async=on</Features>
</PropertyGroup>
В отличие от ранних предварительных версий, для проекта net11.0 не требуется отдельно устанавливать EnablePreviewFeatures. При этом стандартные библиотеки .NET 11 уже используют новую модель внутри runtime. Для обычного приложения я бы пока рассматривал Runtime Async как возможность для экспериментов и измерений, а не как обязательную настройку после обновления.
12. Производительность и инструменты#
В .NET 11 продолжилась работа над производительностью JIT, стандартных библиотек и инструментов разработки.
Native AOT CLI#
Командная строка dotnet получила новый Native AOT-хост, который используется по умолчанию. Для некоторых команд это позволяет избежать запуска управляемой части CLI.
Например:
dotnet --version
dotnet --info
dotnet --help
Такие операции могут выполняться быстрее благодаря нативной точке входа.
MSBuild Server#
Сервер MSBuild теперь включён по умолчанию. Он позволяет повторно использовать уже запущенный процесс и сокращать накладные расходы на инициализацию между сборками. Это особенно полезно при частых локальных сборках во время разработки.
Новые возможности LINQ и библиотек#
Помимо FullJoin, появились дополнительные перегрузки Join и GroupJoin, возвращающие кортежи. В стандартных библиотеках также появились новые возможности работы со сжатием Zstandard, числовыми типами, криптографией и низкоуровневыми операциями ввода-вывода.
Оптимизации JIT#
Microsoft продолжила улучшать устранение лишних проверок границ массивов, девиртуализацию вызовов, оптимизацию сравнений последовательностей и генерацию машинного кода. Для многих приложений это означает потенциальное повышение производительности без изменений исходного кода. Но здесь не стоит ожидать универсального прироста на определённый процент. Результат зависит от характера нагрузки, используемых библиотек и аппаратной платформы.
13. Стоит ли переходить на .NET 11?#
Теперь о практической стороне обновления.
.NET 11 — это STS-релиз с поддержкой до ноября 2028 года. При этом .NET 10 остаётся LTS-релизом, поддержка которого также заканчивается в ноябре 2028 года. Поэтому привычный аргумент «LTS поддерживается значительно дольше» в данном случае не работает. Если мы начинаем новый проект, я бы серьёзно рассматривал .NET 11. Особенно если предполагается использовать C# 15, новые возможности ASP.NET Core или обновлённые библиотеки. Но для уже работающего production-приложения ситуация другая. Если проект стабилен, все зависимости поддерживаются, а новые функции не дают существенного преимущества, срочная миграция с .NET 10 не требуется.
Отдельный вопрос — приложения на .NET 8 и .NET 9.
Поддержка обеих версий завершилась 10 ноября 2026 года. Поэтому для таких проектов переход на поддерживаемую версию уже связан не столько с новыми возможностями, сколько с безопасностью и сопровождением.
Заключение#
На мой взгляд, .NET 11 получился интересным прежде всего благодаря развитию C#. Union types и closed hierarchies позволяют гораздо точнее описывать допустимые состояния приложения и находить некоторые ошибки ещё на этапе компиляции. Асинхронная валидация избавляет от необходимости вручную размещать часть проверок в обработчиках запросов. CacheView даёт разработчикам Blazor удобный способ кэшировать отдельные части серверной разметки. А Runtime Async показывает, в каком направлении может развиваться одна из фундаментальных возможностей платформы.
Конечно, далеко не каждое нововведение пригодится в повседневной работе. Но отрадно, что C# продолжает получать не только синтаксические улучшения, но и механизмы, которые позволяют писать более надёжный и выразительный код.