C# продвинутый уровень

Курс для разработчиков, уверенно владеющих базовым C#, которые хотят углубить знания языка и экосистемы .NET. Разберём продвинутые возможности языка, архитектурные подходы, асинхронность, производительность, тестирование и практики разработки промышленного уровня.

1. Глубокое понимание CLR, память и сборка мусора

Глубокое понимание CLR, память и сборка мусора

Зачем продвинутому разработчику понимать CLR

Большая часть производительности, надёжности и предсказуемости C#-приложения определяется не самим языком, а средой выполнения CLR (Common Language Runtime). На продвинутом уровне важно понимать:

  • где и как размещаются объекты в памяти
  • почему одни аллокации «дешёвые», а другие резко ухудшают задержки
  • что именно делает сборщик мусора и какие есть режимы
  • как финализация, закрепление (pinning) и большие объекты меняют поведение GC
  • как писать код, который не только «быстрый в среднем», но и стабилен по задержкам
  • Официальная документация по теме:

  • Автоматическое управление памятью
  • Сборка мусора (GC)
  • IDisposable и финализация
  • Что такое CLR и как исполняется C#-код

    Когда вы компилируете проект на C#, компилятор создаёт:

  • IL (Intermediate Language, иногда говорят CIL/MSIL)
  • метаданные (описание типов, методов, атрибутов)
  • Затем во время выполнения подключается CLR и делает несколько ключевых вещей:

  • загрузка сборок и разрешение зависимостей
  • проверка типов и безопасность исполнения (в рамках модели .NET)
  • JIT-компиляция IL в машинный код для текущей архитектуры
  • управление памятью (GC), исключениями, потоками и др.
  • Подробнее:

  • Обзор CLR
  • JIT: почему «первый вызов медленнее»

    JIT (Just-In-Time compiler) обычно компилирует метод при первом обращении к нему (механика может отличаться в деталях, но практический эффект тот же). Следствия:

  • первый вызов может включать стоимость компиляции
  • JIT может оптимизировать код, зная реальную платформу и некоторые детали исполнения
  • производительность может отличаться между Debug/Release, потому что меняются оптимизации
  • Полезно понимать, что микро-бенчмарки «на один запуск» часто меряют не то, что вы думаете (например, JIT и прогрев кэшей).

    !Блок-схема пути от C# к машинному коду и роли CLR

    Базовая модель памяти: стек, управляемая куча и ссылки

    В .NET удобно мыслить тремя сущностями:

  • значимые типы (value types, например int, DateTime, struct)
  • ссылочные типы (reference types, например class, массивы, строки)
  • ссылка как значение, указывающее на объект в управляемой куче
  • Стек и куча: упрощённая, но полезная картина

  • Стек чаще всего хранит:
  • - локальные переменные методов - параметры - адрес возврата и служебные данные вызовов
  • Управляемая куча чаще всего хранит:
  • - объекты ссылочных типов - «упакованные» (boxed) значимые типы - массивы, строки

    Важно: значимый тип может оказаться в куче (например, как поле класса или элемент массива), а ссылочный тип всегда представлен ссылкой в местах, где вы его используете.

    Стоимость аллокации в управляемой куче

    Аллокация объекта в .NET обычно очень быстрая, потому что часто сводится к «сдвигу указателя» в текущем сегменте кучи. Но массовые аллокации:

  • увеличивают частоту сборок мусора
  • повышают вероятность продвижения объектов в старшие поколения
  • могут приводить к фрагментации (особенно при больших объектах)
  • Внутреннее устройство объекта (почему это важно)

    Каждый объект в куче несёт служебные данные CLR (упрощённо):

  • указатель на информацию о типе (метаданные)
  • заголовок для нужд синхронизации/GC
  • затем поля объекта
  • Практические следствия:

  • даже «пустой» объект имеет накладные расходы
  • множество мелких объектов может быть хуже одного агрегированного
  • поля, особенно ссылочные, увеличивают «граф объектов», который должен обходить GC
  • Boxing и unboxing: скрытые аллокации

    Boxing — упаковка значимого типа в объект (ссылочный тип), то есть аллокация в куче.

    Типичные триггеры boxing:

  • приведение struct к object
  • использование не-обобщённых коллекций
  • интерфейсы, когда struct приводится к интерфейсному типу (в ряде случаев)
  • форматирование/логирование, если вызвало преобразование к object
  • Пример:

    Как это влияет на GC:

  • каждое boxing создаёт новый объект
  • такие объекты часто краткоживущие, но при большом объёме создают давление на Gen 0
  • GC в .NET: концепция поколений и почему она работает

    Сборщик мусора в .NET — поколенческий (generational). Идея:

  • большинство объектов живёт недолго
  • поэтому выгодно часто собирать «молодую» часть кучи
  • Обычно выделяют:

  • Gen 0 — новые объекты, самая частая и быстрая сборка
  • Gen 1 — промежуточное поколение
  • Gen 2 — долго живущие объекты, сборка реже и дороже
  • LOH (Large Object Heap) — куча больших объектов (обычно массивы и объекты размером от ~85 000 байт)
  • Официально:

  • Основы сборки мусора
  • Что делает GC во время сборки

    В упрощённом виде GC выполняет:

  • Root scanning — поиск корней (GC roots): ссылки из стеков потоков, статические поля, handles и т.д.
  • Mark — пометка достижимых объектов (обход графа ссылок)
  • Sweep/Compact — освобождение недостижимого и, часто, уплотнение памяти (перемещение живых объектов для уменьшения фрагментации)
  • Ключевая деталь: GC перемещает объекты в памяти (компактификация) — поэтому обычные ссылки безопасны, но «внешние» указатели на управляемые объекты требуют особого обращения.

    !Схема поколений GC и продвижения объектов

    LOH и большие объекты

    LOH предназначена для больших объектов, чаще всего — массивов. Особенности:

  • аллокации больших объектов дороже из-за запросов крупных сегментов
  • большие объекты создают риск фрагментации
  • исторически LOH долгое время не компактифицировалась так же активно, как «маленькая куча»; в современных .NET есть режимы, позволяющие компактифицировать LOH по запросу
  • Документация:

  • Large Object Heap
  • Практический вывод: если вы часто создаёте/уничтожаете большие массивы, задержки и потребление памяти могут стать нестабильными.

    Режимы GC и влияние на задержки

    GC в .NET имеет разные режимы, которые важны для серверных приложений и UI:

  • Workstation GC — ориентирован на клиентские сценарии
  • Server GC — обычно лучше для высоконагруженных серверов (использует несколько куч/потоков GC)
  • Background GC — уменьшает паузы, выполняя часть работы параллельно с приложением
  • Официальная точка входа в тему:

  • Настройка сборки мусора
  • Практический вывод:

  • в low-latency сценариях (UI, торговые системы, игры) важно контролировать аллокации и избегать «внезапных» Gen 2
  • в серверных сценариях важны пропускная способность и масштабирование по ядрам
  • Финализация: почему это «страховка», а не стратегия

    Финализатор (~TypeName()) — метод, который CLR может вызвать перед освобождением объекта, если объект оказался недостижим. Проблема в том, что финализация:

  • недетерминирована по времени
  • требует как минимум двух циклов GC, чтобы память была реально освобождена (объект попадает в очередь финализации, затем после финализации может быть собран)
  • создаёт дополнительную нагрузку и усложняет предсказуемость
  • Документация:

  • Финализация
  • IDisposable и паттерн Dispose

    Если объект владеет неуправляемыми ресурсами (хэндлы файлов, сокеты, дескрипторы ОС), нужно освобождать их детерминированно через IDisposable и Dispose().

    Ключевые практики:

  • используйте using/using var
  • финализатор нужен редко: обычно только если вы напрямую держите неуправляемый ресурс и хотите «страховку» на случай забывчивости пользователя
  • Документация и рекомендуемый паттерн:

  • Implementing Dispose
  • Пример:

    Pinning: закрепление объектов и почему оно опасно

    Pinning — это закрепление объекта, чтобы GC не мог перемещать его. Это бывает нужно, когда вы передаёте адрес управляемого буфера в нативный код.

    Чем плохо закрепление:

  • GC теряет возможность уплотнять часть кучи
  • растёт фрагментация
  • паузы сборок могут увеличиваться
  • Где встречается:

  • fixed в unsafe-коде
  • GCHandle.Alloc(obj, GCHandleType.Pinned)
  • interop/маршалинг
  • По возможности:

  • закрепляйте на минимальное время
  • используйте подходы с Span<T>/Memory<T> и API, которые избегают длительного pinning
  • WeakReference: ссылки, которые не удерживают объект

    WeakReference полезна для кешей и таблиц, где объект можно «сдать» GC при нехватке памяти.

    Поведение:

  • наличие WeakReference не делает объект достижимым
  • объект может быть собран, и TryGetTarget вернёт false
  • Документация:

  • WeakReference
  • Как писать код, дружественный к GC

    Ниже — практические подходы, которые дают эффект в реальных системах (особенно под нагрузкой).

    Уменьшайте количество аллокаций

  • избегайте лишних временных объектов в горячих участках
  • используйте StringBuilder, когда реально есть много конкатенаций в цикле
  • будьте внимательны к LINQ в горячем пути: он удобен, но может создавать итераторы/замыкания
  • Используйте пулы там, где это оправдано

  • для буферов: ArrayPool<T>.Shared
  • для повторно используемых объектов: собственные пулы (осторожно с сложностью и утечками)
  • ArrayPool<T>:

  • ArrayPool<T>
  • Пример:

    Следите за жизненным циклом больших массивов

  • по возможности переиспользуйте большие буферы
  • избегайте частой смены размера больших массивов (это создаёт новые объекты в LOH)
  • Избегайте удержания ссылок дольше нужного

  • долгоживущие коллекции, в которые попадают ссылки на «молодые» объекты, повышают вероятность продвижения объектов в Gen 2
  • события могут удерживать подписчиков и предотвращать сборку (если не отписаться)
  • Диагностика: как увидеть проблемы памяти и GC

    Для продвинутого уровня важно уметь доказательно находить источники аллокаций и причины Gen 2/LOH.

    Инструменты и точки входа:

  • dotnet-counters (счётчики GC, выделения памяти)
  • dotnet-trace (трассировка событий)
  • Visual Studio Diagnostic Tools
  • PerfView
  • Официально (набор инструментов .NET):

  • dotnet-counters
  • dotnet-trace
  • Итог

    CLR — это не «чёрный ящик», а набор понятных механизмов:

  • C# компилируется в IL и исполняется под управлением CLR с JIT
  • объекты размещаются в управляемой куче, а GC управляет временем их жизни
  • поколения (Gen 0/1/2) и LOH объясняют, почему одни аллокации почти бесплатны, а другие создают паузы
  • IDisposable решает освобождение ресурсов, а финализация — лишь запасной механизм
  • pinning и большие объекты требуют дисциплины, иначе возникают фрагментация и нестабильные задержки
  • В следующих темах курса эти знания станут основой для разговоров о высокопроизводительных структурах данных, interop, потоках, async/await и профилировании.

    2. Продвинутые возможности языка: generics, LINQ, выражения

    Продвинутые возможности языка: generics, LINQ, выражения

    Зачем это нужно на продвинутом уровне

    На уровне просто писать код generics, LINQ и лямбды выглядят как синтаксический сахар. На продвинутом уровне они становятся инструментами управления:

  • производительностью (аллоцируется ли что-то в куче, будет ли boxing, сколько объектов создастся в процессе запроса)
  • предсказуемостью (когда именно выполнится LINQ-запрос и сколько раз)
  • архитектурой (обобщённые абстракции без потери типобезопасности, построение динамических запросов)
  • Эта тема напрямую связана с предыдущей статьёй про CLR и GC: generics помогают избегать boxing и лишних аллокаций, LINQ и замыкания могут создавать скрытые объекты и увеличивать давление на GC.

    Официальные точки входа:

  • Generics в .NET
  • LINQ (C#)
  • Лямбда-выражения (C#)
  • Expression trees
  • Generics: не только типобезопасность

    Что такое generics в .NET с точки зрения выполнения

    Обобщения в C# компилируются в IL как обобщённые типы/методы, а конкретные типовые аргументы подставляются во время выполнения.

    Практически важные следствия:

  • для значимых типов (struct) JIT обычно создаёт специализированный машинный код под конкретный T (это позволяет избегать boxing и даёт оптимизации)
  • для ссылочных типов (class) код чаще разделяется (одна версия на группу ссылочных типов), а различия обрабатываются через работу со ссылками
  • Это одна из причин, почему List<int> почти всегда предпочтительнее ArrayList: нет упаковки int в object.

    Boxing как цена отсутствия generics

    Сравните:

    В терминах предыдущей статьи:

  • boxing создаёт новый объект в управляемой куче
  • много boxing в горячем пути увеличивает давление на Gen 0 и частоту сборок мусора
  • Ограничения (constraints): контракт для JIT и читателя

    Constraints задаются через where и делают две вещи:

  • ограничивают допустимые типы, которые можно подставить вместо T
  • позволяют компилятору и JIT безопасно использовать некоторые операции
  • Примеры ограничений:

    Коротко про смысл популярных ограничений:

  • where T : class означает, что T — ссылочный тип (можно сравнивать с null)
  • where T : struct означает, что T — значимый тип (не null, но может быть Nullable<T>)
  • where T : new() гарантирует публичный конструктор без параметров
  • where T : unmanaged означает, что T не содержит управляемых ссылок и может безопасно использоваться в unsafe/interop сценариях
  • where T : SomeBase или where T : ISomeInterface задаёт базовый тип или интерфейс
  • Документация:

  • Ограничения параметров типа (C#)
  • Вариантность: out и in в обобщённых интерфейсах и делегатах

    Вариантность — это правила, когда можно подставлять производные или базовые типы в обобщённых конструкциях.

  • out T (ковариантность) позволяет использовать более конкретный тип там, где ожидается более общий
  • in T (контравариантность) позволяет использовать более общий тип там, где ожидается более конкретный
  • Это работает только для интерфейсов и делегатов, и только когда T используется безопасным образом.

    Пример ковариантности:

    Пример контравариантности на делегатах:

    Документация:

  • Ковариантность и контравариантность (C#)
  • LINQ: выразительность, отложенное выполнение и цена абстракций

    LINQ — это прежде всего набор расширений над IEnumerable<T> (и отдельный мир IQueryable<T>). Продвинутый уровень начинается там, где вы управляете:

  • когда выполняется запрос
  • сколько раз он выполняется
  • какие аллокации и обходы коллекций происходят
  • IEnumerable<T>: итераторы и отложенное выполнение

    Большинство операторов LINQ возвращают последовательность, которая будет выполнена только при перечислении.

    Это называется deferred execution.

    !Конвейер LINQ и точка, где начинается выполнение

    Терминальные операции: когда выполнение становится немедленным

    Операторы вроде ToList(), ToArray(), Count(), First() обычно перечисляют последовательность прямо сейчас.

  • ToList() и ToArray() создают новую коллекцию (и, значит, аллокации)
  • Count() для IEnumerable<T> без быстрого пути обязан пройтись по всем элементам
  • Пример типичной ошибки:

    Если перечисление дорогое или имеет побочные эффекты, лучше материализовать один раз:

    Streaming vs buffering: важная характеристика операторов

    Некоторые операторы LINQ работают потоково (streaming): они могут отдавать элементы по мере чтения источника.

    Другие вынуждены буферизовать (buffering): им нужно увидеть все элементы, чтобы выдать результат.

    Таблица-интуиция:

    | Оператор | Поведение | Почему важно | |---|---|---| | Where, Select | чаще streaming | минимум памяти, элементы идут по одному | | Take | streaming | может завершить перечисление рано | | OrderBy | buffering | нужно собрать всё, отсортировать, это память и время | | ToList/ToArray | buffering | создают коллекцию и аллоцируют память | | GroupBy | buffering | строит группы, часто существенная память |

    LINQ и аллокации: где прячутся объекты

    В зависимости от кода и версии .NET возможны аллокации:

  • объектов-итераторов, которые представляют цепочку операторов
  • замыканий (если лямбда захватывает переменные)
  • промежуточных коллекций при ToList, ToArray, OrderBy, GroupBy
  • Связь с предыдущей темой про GC:

  • много краткоживущих объектов увеличивает частоту сборок Gen 0
  • материализация больших коллекций может приводить к существенному потреблению памяти и, иногда, к попаданию массивов в LOH
  • Если LINQ используется в горячем пути, полезно измерять, а не угадывать:

  • dotnet-counters
  • IQueryable<T>: LINQ как построитель запросов

    IQueryable<T> отличается от IEnumerable<T> принципиально:

  • IEnumerable<T> обычно выполняет код в памяти процесса (LINQ to Objects)
  • IQueryable<T> обычно строит дерево выражения и отдаёт провайдеру (например, Entity Framework), который превращает его в SQL или другой запрос
  • Важно помнить:

  • не любой C#-код внутри лямбды переводим провайдером
  • часть операций может быть выполнена на стороне базы, часть — случайно “переехать” в память, если вы рано вызвали AsEnumerable() или материализовали данные
  • Выражения: лямбды, делегаты, замыкания и Expression trees

    Слово выражения в контексте C# часто означает два разных, но связанных инструмента:

  • лямбда-выражения как краткая запись делегатов
  • деревья выражений (Expression<TDelegate>) как структура данных, описывающая код
  • Лямбда как делегат

    Лямбда может быть преобразована в делегат (Func<>, Action<> или пользовательский делегат):

    Это “обычный” исполняемый код.

    Замыкания: скрытые объекты и удержание памяти

    Если лямбда использует переменную из внешней области видимости, возникает замыкание.

    Что важно на продвинутом уровне:

  • компилятор создаёт скрытый класс “контейнер”, в поле которого хранится threshold
  • этот объект контейнера обычно аллоцируется в куче
  • время жизни захваченных данных может стать больше ожидаемого, потому что делегат удерживает ссылку на контейнер
  • Это напрямую связано с темой GC: замыкания могут неожиданно продлить жизнь объектам.

    Практика:

  • по возможности используйте статические лямбды (там, где они применимы), чтобы запретить захват
  • Документация:

  • Лямбда-выражения (C#)
  • Expression trees: код как данные

    Expression<Func<...>> — это не исполняемый делегат, а дерево, описывающее выражение.

    Зачем это нужно:

  • построение динамических запросов (например, фильтров)
  • анализ выражений (логирование, построение SQL, генерация UI)
  • компиляция выражения в делегат (с затратами на компиляцию)
  • !Пример дерева выражения

    Ключевое отличие от делегата:

  • делегат исполняется сразу
  • expression tree можно разобрать, трансформировать, перевести в другой язык запроса, либо скомпилировать
  • Документация:

  • Expression trees
  • Компиляция expression tree и кэширование

    Если вы делаете так:

    то:

  • Compile() имеет ощутимую стоимость
  • если это происходит много раз в горячем пути, лучше кэшировать результат (например, словарь по ключу конфигурации)
  • Практические рекомендации: как совместить выразительность и предсказуемость

  • используйте generics вместо object-ориентированных API, чтобы избегать boxing и лишних аллокаций
  • будьте особенно осторожны с LINQ в горячих циклах и высоконагруженных обработчиках запросов
  • помните про отложенное выполнение: если нужна стабильность и однократный проход, материализуйте один раз
  • избегайте лишних замыканий: они создают скрытые объекты и могут удерживать память
  • применяйте expression trees там, где нужен анализ/построение запросов, и кэшируйте результаты компиляции
  • Итог

  • Generics дают типобезопасность и часто заметно улучшают производительность за счёт отсутствия boxing и более эффективного кода для struct.
  • LINQ даёт мощную декларативность, но требует понимания отложенного выполнения, повторного перечисления, буферизации и скрытых аллокаций.
  • Лямбды и выражения — это либо исполняемые делегаты, либо деревья выражений для построения и анализа кода; замыкания могут влиять на память и поведение GC.
  • Дальше по курсу эти знания станут базой для тем про асинхронность, высокопроизводительные структуры и профилирование: именно там ошибки в LINQ/замыканиях/генериках чаще всего превращаются в реальные паузы GC и нестабильные задержки.

    3. Асинхронность и параллелизм: async/await, TPL, многопоточность

    Асинхронность и параллелизм: async/await, TPL, многопоточность

    Как эта тема связана с предыдущими

    В прошлых статьях курса мы разобрали:

  • как CLR управляет памятью и почему аллокации, финализация и GC-паузы влияют на задержки
  • как generics, LINQ, лямбды и замыкания создают как удобство, так и скрытую стоимость (аллокации, повторные перечисления, удержание объектов)
  • Асинхронность и параллелизм продолжают ту же линию, но уже на уровне исполнения:

  • асинхронность помогает не блокировать поток во время ожидания I/O, но может порождать дополнительные объекты состояния и ошибки синхронизации
  • параллелизм повышает пропускную способность на CPU-задачах, но требует потокобезопасности и понимания планировщика, thread pool и контекстов
  • Официальные точки входа:

  • async/await (C#)
  • Task Parallel Library (TPL)
  • ThreadPool
  • CancellationToken
  • ConfigureAwait
  • Термины и базовые различия

    Важно не смешивать понятия.

  • Многопоточность: выполнение кода в нескольких потоках (threads). Это про конкуренцию и разделяемые данные.
  • Параллелизм: частный случай многопоточности, когда работа реально выполняется одновременно на разных ядрах CPU. Обычно полезен для CPU-bound задач.
  • Асинхронность: выполнение без блокировки потока ожиданием. Чаще всего про I/O-bound задачи (сеть, файл, база данных).
  • Практическая формулировка:

  • если задача ждёт внешнее событие, выгодна асинхронность (поток возвращается в пул и обслуживает другие запросы)
  • если задача занимает CPU вычислениями, выгоден параллелизм (но только при наличии достаточного объёма работы)
  • !Сравнение асинхронности и параллелизма по временной шкале

    Что такое Task и почему он не равен Thread

    Task в .NET представляет обещание результата в будущем и механизм композиции.

  • Thread это конкретный поток ОС с собственным стеком и жизненным циклом
  • Task это единица работы, которую планировщик может выполнить на thread pool, а может завершить без выделения потока, если речь об I/O
  • Ключевые последствия:

  • тысячи Task в сервере нормальны; тысячи Thread почти всегда проблема
  • Task удобно комбинировать: WhenAll, WhenAny, ContinueWith (редко нужен в современном коде), await
  • Справка:

  • Task
  • ThreadPool: почему блокировки опасны в асинхронном коде

    Большая часть прикладного .NET кода использует ThreadPool.

  • thread pool содержит ограниченное число потоков
  • если потоки thread pool блокируются (например, на Wait(), Result, Thread.Sleep, синхронном I/O), то новые задачи начинают ждать свободного потока
  • Типичный эффект под нагрузкой:

  • растёт очередь работ
  • растут задержки
  • приложение выглядит как «подвисшее», хотя CPU может быть не загружен
  • Полезный счётчик: ThreadPool Thread Count в dotnet-counters.

    async/await: модель выполнения без блокировки

    Что делает компилятор

    async и await это не просто ключевые слова, а трансформация кода компилятором в машину состояний.

    Идея:

  • до первого await метод выполняется синхронно
  • на await метод может "вернуть управление" вызывающему, а продолжение выполнится позже, когда ожидаемая операция завершится
  • Это связывает тему с CLR/GC:

  • машина состояний обычно является объектом в куче (часто, но детали зависят от метода)
  • лишние await в горячих местах могут повышать аллокации
  • Документация:

  • Async return types
  • Правило: не блокируйте ожидание

    Неправильно:

    Правильно:

    В серверном коде это особенно важно: блокировки на Result и Wait() отнимают потоки у thread pool.

    Синхронизационный контекст и причина «странных» дедлоков

    В некоторых окружениях (UI-приложения, старые ASP.NET) есть синхронизационный контекст (SynchronizationContext), который требует, чтобы продолжения после await вернулись на определённый поток (например, UI поток).

    Сценарий дедлока:

  • UI поток вызывает async-метод, но ждёт его синхронно через .Result
  • async-метод делает await и пытается продолжиться на UI потоке
  • UI поток заблокирован на .Result, продолжение не может выполниться
  • Это один из главных аргументов против синхронного ожидания async-кода.

    Справка:

  • SynchronizationContext
  • ConfigureAwait: когда он уместен

    ConfigureAwait(false) говорит: не пытайся вернуться в исходный контекст.

    Рекомендация по применению:

  • в библиотечном коде ConfigureAwait(false) часто снижает риск дедлоков и уменьшает накладные расходы на маршалинг контекста
  • в UI-коде обычно нельзя ставить false, если после await вы обновляете UI
  • Документация:

  • ConfigureAwait
  • Исключения в async-коде

    Исключения сохраняются в Task и будут выброшены при await.

    Если вы используете Task.WhenAll, то при await вы получите одно исключение, но оно может агрегировать несколько ошибок.

    Документация:

  • Task.WhenAll
  • Отмена: CancellationToken как контракт

    Отмена в .NET кооперативная: код должен сам проверять токен.

    Ключевые правила:

  • принимайте CancellationToken в публичных async API
  • передавайте токен глубже в вызовы, которые его поддерживают
  • если вы делаете CPU-bound цикл, проверяйте ct.ThrowIfCancellationRequested()
  • Документация:

  • CancellationToken
  • CPU-bound работа: Task.Run, TPL и Parallel

    Когда нужен Task.Run

    Task.Run используется, чтобы вынести CPU-bound вычисление на thread pool и не блокировать текущий поток (часто UI поток).

    Важно:

  • Task.Run не делает код «асинхронным» по сути, он делает его параллельным/конкурентным за счёт другого потока
  • в ASP.NET Core обычно не нужно оборачивать CPU-bound работу в Task.Run, если вы и так уже на потоке thread pool, а цель только «сделать async»
  • Parallel.ForEach и PLINQ

    Для чисто вычислительных задач TPL даёт высокоуровневые инструменты.

    Parallel.ForEach:

    PLINQ:

    Когда это полезно:

  • работа тяжёлая и однородная
  • нет блокировок на общий ресурс
  • стоимость распараллеливания меньше выигрыша от параллельного выполнения
  • Документация:

  • Parallel class
  • PLINQ
  • Ограничение параллелизма

    Параллелизм по умолчанию старается быть «разумным», но иногда нужно ограничивать степень параллелизма, чтобы:

  • не перегрузить CPU
  • не устроить DDoS внешней системе (например, базе)
  • Подходы:

  • ParallelOptions.MaxDegreeOfParallelism
  • SemaphoreSlim для ограничения конкурентного доступа
  • SemaphoreSlim в async-коде:

    Документация:

  • SemaphoreSlim
  • Потокобезопасность: общие данные, lock и конкурентные коллекции

    Гонка данных и атомарность

    Если несколько потоков читают и пишут общие данные без синхронизации, возможны:

  • потеря обновлений
  • чтение частично обновлённых данных
  • ошибки, которые проявляются редко и плохо воспроизводятся
  • Пример гонки:

    Исправление через Interlocked:

    Документация:

  • Interlocked
  • lock и его цена

    lock гарантирует взаимное исключение.

    Риски:

  • дедлоки при неправильном порядке захвата нескольких блокировок
  • падение производительности при высокой конкуренции
  • Практика:

  • минимизируйте код внутри lock
  • избегайте вызовов внешних методов внутри lock, если они могут блокироваться или брать другие блокировки
  • Документация:

  • lock statement
  • Конкурентные коллекции

    Для многих сценариев лучше использовать готовые структуры из System.Collections.Concurrent.

    Примеры:

  • ConcurrentDictionary<TKey, TValue>
  • ConcurrentQueue<T>
  • BlockingCollection<T>
  • Документация:

  • System.Collections.Concurrent
  • Асинхронные потоки: IAsyncEnumerable

    IAsyncEnumerable<T> позволяет получать элементы асинхронно, по мере готовности.

    Где полезно:

  • чтение данных порциями (стриминг)
  • обработка событий
  • пайплайны, где не хочется буферизовать всё в память
  • Документация:

  • IAsyncEnumerable<T>
  • Частые ошибки и устойчивые практики

    Ошибка: fire-and-forget без наблюдения

    Если вы запускаете задачу и не ждёте её, исключение может остаться необработанным или потеряться логически.

    Плохо:

    Когда это допустимо:

  • если вы явно проектируете фоновые задачи и у вас есть централизованная обработка ошибок и отмена
  • Ошибка: смешивание sync и async

    Типичные «антипаттерны»:

  • Task.Result и Task.Wait() вместо await
  • Thread.Sleep в async-методе вместо await Task.Delay
  • Практика: выбирайте правильные типы возвращаемых значений

  • async Task для операций без результата
  • async Task<T> для операций с результатом
  • async void только для обработчиков событий UI (это особый контракт)
  • Документация:

  • Async return types
  • Практика: измеряйте, а не угадывайте

    Асинхронность и параллелизм часто дают неожиданные эффекты на GC и задержки.

    Инструменты из темы про CLR/GC пригодятся напрямую:

  • dotnet-counters
  • dotnet-trace
  • Смотрите:

  • частоту сборок Gen 0/1/2
  • рост выделений памяти
  • рост числа потоков thread pool
  • Итог

  • Асинхронность (async/await) нужна, чтобы не блокировать поток во время ожидания I/O, и требует дисциплины: не смешивать sync и async, правильно работать с исключениями и отменой.
  • Параллелизм (TPL, Parallel, PLINQ) нужен, чтобы ускорять CPU-bound вычисления, но требует потокобезопасности и контроля степени параллелизма.
  • ThreadPool и контексты объясняют большинство «магических» зависаний: блокировки потоков, дедлоки на .Result, лишние ожидания.
  • Связь с темами CLR/GC и LINQ прямая: неправильные абстракции, замыкания и лишние аллокации в асинхронных пайплайнах превращаются в GC-паузы и нестабильные задержки.
  • 4. Проектирование и архитектура: SOLID, паттерны, DDD-основы

    Проектирование и архитектура: SOLID, паттерны, DDD-основы

    Зачем это нужно на продвинутом уровне

    После тем про CLR/GC, generics/LINQ и async/await обычно приходит понимание: быстрый и корректный фрагмент кода ещё не гарантирует устойчивую систему. Архитектура влияет на то, насколько ваш код:

  • поддерживаем (легко ли менять требования без каскада правок)
  • тестируем (можно ли проверять бизнес-логику без базы, сети и UI)
  • предсказуем по производительности (нет ли случайного LINQ в горячем пути и лишних аллокаций на каждый запрос)
  • безопасен в многопоточности (понятны ли границы конкурентного доступа)
  • Эта статья даёт практическую связку:

  • SOLID как набор принципов проектирования модулей
  • паттерны как повторяемые решения типовых проблем
  • DDD-основы как способ моделировать предметную область и отделять бизнес-правила от инфраструктуры
  • Архитектурная рамка: слои и зависимости

    Прежде чем обсуждать принципы, полезно зафиксировать простую и рабочую модель: отделять бизнес-логику от деталей.

    Типичная структура (обобщённо):

  • Domain — бизнес-правила, модель предметной области
  • Application — сценарии использования (use cases), оркестрация
  • Infrastructure — базы данных, очереди, файловая система, внешние API
  • Presentation — Web API, UI, обработчики сообщений
  • Ключевое правило зависимостей:

  • Domain не зависит ни от чего прикладного и инфраструктурного
  • Application может зависеть от Domain
  • Infrastructure зависит от Application и Domain (реализует порты)
  • !Схема слоёв и направления зависимостей

    Связь с предыдущими темами курса:

  • если Domain чистый, вы можете тестировать правила без I/O и без async (меньше случайных блокировок и проблем с thread pool)
  • если границы слоёв ясны, проще локализовать горячие участки и измерять аллокации (например, LINQ использовать на краях, а не в ядре)
  • Полезная практика из платформы .NET — встроенный DI:

  • Dependency injection в .NET
  • SOLID: принципы, которые реально помогают

    Single Responsibility Principle

    Идея: у модуля должна быть одна причина для изменения.

    Практический критерий: если класс одновременно содержит бизнес-правила, работу с БД и логирование/метрики, то любое изменение одной части повышает риск сломать другую.

    Плохой сигнал:

  • метод одновременно валидирует, пишет в БД, отправляет HTTP-запрос и форматирует ответ
  • Хороший сигнал:

  • бизнес-решение выражено отдельно, а I/O вынесено за границу (в Application/Infrastructure)
  • Open/Closed Principle

    Идея: расширяйте поведение без изменения существующего кода.

    В C# чаще всего достигается через:

  • интерфейсы и внедрение зависимостей
  • композицию вместо наследования
  • стратегии (паттерн Strategy)
  • Пример: добавили новый способ расчёта цены, не переписывая существующую логику оформления заказа.

    Liskov Substitution Principle

    Идея: подтип должен быть взаимозаменяем с базовым типом без неожиданных эффектов.

    Типичные нарушения в C#:

  • переопределение метода так, что он начинает бросать исключения там, где базовый контракт этого не предполагал
  • ослабление или усиление предусловий/постусловий
  • Практический совет:

  • если вы ловите себя на проверках if (x is SpecialType) — возможно, иерархия выбрана неправильно
  • Interface Segregation Principle

    Идея: много маленьких интерфейсов лучше одного большого.

    Почему это важно:

  • проще мокать в тестах
  • меньше случайных зависимостей
  • меньше причин менять интерфейс (и всё, что от него зависит)
  • Dependency Inversion Principle

    Идея: высокоуровневая политика не должна зависеть от низкоуровневых деталей.

    В практическом C# это означает:

  • Domain/Application определяют интерфейсы (порты)
  • Infrastructure реализует интерфейсы (адаптеры)
  • внедрение через DI
  • Минимальный пример:

    Здесь Application зависит от абстракции, а не от конкретной БД.

    Паттерны: когда они уместны, а когда вредны

    Паттерн — это не цель, а словарь решений. На продвинутом уровне важнее всего:

  • понимать какую проблему решает паттерн
  • видеть стоимость абстракции (сложность, аллокации, косвенность вызовов)
  • Ниже — набор паттернов, которые часто встречаются в C#-системах.

    Factory и Abstract Factory

    Когда полезно:

  • сложная логика создания объектов
  • разные реализации в зависимости от конфигурации/окружения
  • Когда вредно:

  • можно обойтись DI-контейнером и обычными конструкторами, а фабрика добавляет лишний слой
  • Справка:

  • Factory method
  • Strategy

    Подходит для замены больших switch по типам/режимам.

    Плюсы:

  • расширяемость (OCP)
  • тестируемость
  • Справка:

  • Strategy
  • Decorator

    Очень распространён в .NET (в том числе в middleware и при оборачивании сервисов).

    Сценарии:

  • кэширование
  • логирование
  • ретраи
  • метрики
  • Важно: декораторы удобны, но создают цепочки вызовов. В горячем пути следите за накладными расходами (это перекликается с темой про производительность и GC).

    Справка:

  • Decorator
  • Adapter

    Классический мост между вашим интерфейсом и внешним SDK.

    Плюсы:

  • вы изолируете внешний контракт
  • тесты не зависят от сторонней библиотеки
  • Справка:

  • Adapter
  • Repository

    В DDD-ориентированной архитектуре репозиторий — это абстракция над хранением агрегатов.

    Важно не перепутать:

  • репозиторий агрегата
  • универсальный CRUD-репозиторий на все случаи
  • Антипаттерн:

  • IRepository<T> с десятками методов, который течёт везде и превращает доменную модель в набор таблиц
  • Справка:

  • Repository
  • Unit of Work

    Паттерн координации сохранения изменений как единой операции.

    В .NET часто роль Unit of Work уже выполняет ORM-контекст (например, DbContext). Важно не городить второй слой только ради названия.

    Справка:

  • Unit of Work
  • DDD-основы: как отделять бизнес от инфраструктуры

    DDD (Domain-Driven Design) полезен, когда у системы:

  • сложные бизнес-правила
  • много терминов и нюансов предметной области
  • высокая цена ошибки в логике
  • Если же система — тонкий CRUD, DDD может быть избыточным.

    Официальный (для .NET-архитектуры) материал по DDD и микросервисам:

  • DDD-oriented microservice
  • Ubiquitous Language

    Главный смысл: команда использует один и тот же язык в обсуждениях, коде и документации.

    Практика:

  • сущности и методы называются терминами бизнеса (PlaceOrder, ReserveStock, Cancel) вместо технических (Process, Handle, DoWork)
  • Entity и Value Object

    Entity:

  • имеет идентичность (например, OrderId)
  • может меняться со временем
  • Value Object:

  • идентичности не имеет
  • обычно неизменяемый
  • сравнивается по значению
  • Пример value object:

    Связь с темой CLR/GC:

  • record struct как value type может уменьшать аллокации, но важно не злоупотреблять копированием больших структур
  • Aggregates и инварианты

    Aggregate — кластер объектов, который изменяется как единое целое.

  • у агрегата есть корень (aggregate root), например Order
  • внешнему миру разрешено ссылаться только на корень
  • правила целостности (инварианты) должны соблюдаться внутри агрегата
  • Пример инварианта: нельзя оплатить отменённый заказ.

    Ключевая идея: бизнес-правило живёт в доменной модели, а не в контроллере и не в SQL.

    !Карта основных элементов DDD и их связи

    Domain Services

    Иногда бизнес-операция не принадлежит одной сущности (или затрагивает несколько агрегатов). Тогда используют доменный сервис.

    Признак доменного сервиса:

  • он выражает бизнес-операцию
  • он не является технической обвязкой (не про I/O и не про инфраструктуру)
  • Domain Events

    Domain Event — факт, который произошёл в домене: OrderPlaced, PaymentReceived.

    Зачем:

  • уменьшить связанность между частями системы
  • строить реакцию на события без прямых вызовов
  • Важное уточнение:

  • доменное событие не обязано быть сообщением в брокере
  • но может быть опубликовано наружу на границе (outbox-паттерн), если архитектура событийная
  • Связь с темой async/await:

  • обработчики событий часто выполняются асинхронно
  • нужно контролировать параллелизм и отмену (CancellationToken), чтобы не перегружать thread pool
  • Как это собрать вместе в реальном C#-приложении

    Ниже — практический чеклист, который связывает SOLID, паттерны и DDD с темами производительности и асинхронности.

  • Держите Domain максимально чистым: без HttpClient, DbContext, DateTime.UtcNow напрямую
  • Выражайте инварианты методами доменных объектов, а не внешними if в контроллерах
  • В Application размещайте use cases, транзакционные границы и оркестрацию
  • Инфраструктуру прячьте за адаптерами и репозиториями, внедряйте через DI
  • LINQ и материализацию коллекций контролируйте на границах, не делайте случайный ToList() внутри доменных правил
  • Для фоновых обработчиков и событийных пайплайнов:
  • - не блокируйте Task через .Result - ограничивайте параллелизм (SemaphoreSlim) - проектируйте идемпотентность (повторы из очереди или при ретраях)

    Итог

  • SOLID помогает держать модули маленькими, предсказуемыми и тестируемыми, а зависимости направлять от политики к абстракциям.
  • Паттерны дают язык решений (Strategy, Decorator, Adapter, Repository), но требуют дисциплины: абстракции имеют стоимость.
  • DDD-основы помогают моделировать бизнес так, чтобы правила жили в домене, а инфраструктура была деталями, которые можно заменить.
  • В связке с предыдущими темами курса это даёт главное: контроль над сложностью, предсказуемые задержки и меньше неожиданных проблем в продакшене.
  • 5. Производительность: профилирование, оптимизация, Span и pipelines

    Производительность: профилирование, оптимизация, Span и pipelines

    Зачем эта тема в продвинутом C#-курсе

    В предыдущих статьях курса мы уже заложили фундамент производительности:

  • из темы про CLR/GC вы знаете, почему аллокации, LOH и поколения GC влияют на задержки
  • из темы про generics/LINQ/выражения вы видите, где появляются скрытые итераторы, замыкания и материализация
  • из темы про async/await и TPL вы понимаете, как блокировки потоков и перегрузка thread pool превращаются в латентность
  • из темы про архитектуру вы видите, как границы слоёв помогают локализовать горячие участки
  • Эта статья связывает всё вместе в практический цикл:

  • измерить (профилирование и диагностика)
  • объяснить (почему именно так, с учётом CLR/GC и планировщиков)
  • исправить (оптимизации, работа с памятью, уменьшение копирований)
  • проверить регрессии (повторные измерения и бенчмарки)
  • Цель продвинутого уровня не в том, чтобы писать “самый быстрый код”, а в том, чтобы получать предсказуемую производительность и уметь доказательно улучшать её.

    Модель мышления: что именно мы оптимизируем

    Производительность обычно разлагают на несколько измеримых целей:

  • время ответа (latency) — как быстро один запрос/операция завершается
  • пропускная способность (throughput) — сколько операций в секунду система выдерживает
  • выделения памяти (allocations) — сколько байт/объектов создаётся на операцию
  • паузы GC — как часто и насколько надолго сборщик мусора останавливает выполнение
  • нагрузка CPU — сколько времени уходит на вычисления
  • I/O ожидание — насколько система упирается в сеть/диск
  • Ключевой принцип:

    > Сначала меняйте алгоритм и структуру данных, потом микро-оптимизации.

    Если вы ускорите “неправильный” алгоритм на 20%, но можно заменить на , вы оптимизируете не то.

    !Цикл оптимизации: измерение → причина → изменение → проверка

    Инструменты профилирования и диагностики в .NET

    Ниже — практичный набор инструментов, который покрывает большую часть задач.

    BenchmarkDotNet для микробенчмарков

    Микробенчмарк — измерение небольшой функции/операции, чтобы сравнить варианты реализации.

  • BenchmarkDotNet
  • Почему он нужен:

  • прогревает JIT и снижает шум
  • делает многократные прогоны
  • умеет показывать статистику и выделения памяти
  • Пример минимального бенчмарка:

    Что важно понимать:

  • микробенчмарк не гарантирует выигрыш в реальной системе
  • но он отлично ловит аллокации и “дорогие” операции на горячем пути
  • dotnet-counters для живых метрик процесса

    dotnet-counters показывает счётчики исполнения: GC, thread pool, исключения, время CPU.

  • dotnet-counters
  • Что полезно смотреть:

  • частоту Gen 0/1/2
  • % времени в GC
  • объём выделений
  • число потоков thread pool
  • Типичный сценарий:

  • “всё тормозит” под нагрузкой
  • CPU невысокий
  • thread pool threads растут
  • значит, вероятны блокировки потоков (например, синхронные ожидания .Result или блокирующий I/O)
  • dotnet-trace и PerfView для глубокого разбора

    Если нужно понять “почему”, переходят к трассировкам событий.

  • dotnet-trace
  • PerfView
  • С помощью трассировки обычно ищут:

  • горячие методы по CPU
  • причины сборок мусора
  • конкуренцию на блокировках
  • паузы и контеншн
  • Профилировщик Visual Studio

    Если вы в Windows-окружении, удобно использовать встроенные инструменты профилирования.

  • Visual Studio Profiler
  • Практическая ценность:

  • быстрый вход для CPU sampling
  • анализ выделений памяти
  • диагностика “кто держит объекты” (memory snapshots)
  • Типичные ловушки измерений

    Debug vs Release

    В Debug отключены многие оптимизации JIT, меняется инлайнинг и поведение.

    Правило:

  • измеряйте производительность в Release
  • по возможности без отладчика
  • Холодный старт, прогрев и кэширование

    Первый запуск отличается из-за:

  • JIT-компиляции
  • прогрева кэшей
  • ленивой инициализации
  • Рекомендация:

  • разделяйте “первый запрос” и “steady state” метрики
  • Шум и “случайные” эффекты

    Производительность зависит от:

  • фоновой активности ОС
  • частоты CPU
  • конкуренции потоков
  • размеров входных данных
  • Поэтому:

  • сравнивайте варианты на одном и том же стенде
  • фиксируйте входные данные
  • используйте статистику, а не единичный прогон
  • Практический приоритет оптимизаций

    Ниже — порядок, который обычно даёт лучший ROI.

    Алгоритмы и структуры данных

    Примеры “дешёвых побед”:

  • заменить линейный поиск на словарь Dictionary<TKey, TValue>
  • убрать лишнюю сортировку OrderBy
  • заменить повторные проходы по IEnumerable<T> на один проход с накоплением
  • Связь с темой LINQ:

  • OrderBy, GroupBy, ToList часто буферизуют и аллоцируют
  • в горячем пути иногда стоит перейти от “красиво” к “прозрачно”
  • Снижение аллокаций

    Это напрямую уменьшает давление на GC и стабилизирует задержки.

    На практике чаще всего аллоцируют:

  • строки и конкатенации
  • ToList() и ToArray() в циклах
  • замыкания в лямбдах
  • “удобные” парсеры, которые создают много временных объектов
  • Полезные инструменты:

  • ArrayPool<T> для временных буферов
  • Span<T> и ReadOnlySpan<T> для работы с срезами без копий
  • Уменьшение копирований

    Особенно критично для:

  • сетевых протоколов
  • сериализации
  • обработки больших текстов
  • Ключевые техники:

  • работать с срезами (Span<T>) вместо Substring/Split
  • использовать streaming вместо полной буферизации
  • применять System.IO.Pipelines для I/O пайплайнов
  • Микро-оптимизации

    Имеют смысл, когда:

  • горячий участок уже найден профилировщиком
  • архитектурные изменения либо невозможны, либо уже сделаны
  • Примеры:

  • убрать лишние виртуальные вызовы в tight loop
  • заменить LINQ на цикл
  • кэшировать результат Regex или Expression.Compile()
  • Span и Memory: эффективная работа с буферами

    Что такое Span

    Span<T> — это представление непрерывного участка памяти (например, массива, stackalloc-буфера, части строки) без копирования.

  • Span<T> — изменяемый
  • ReadOnlySpan<T> — только для чтения
  • Документация:

  • Span<T>
  • ReadOnlySpan<T>
  • Ключевые свойства:

  • часто позволяет обойтись без выделений памяти
  • даёт безопасную работу с “указателями” без unsafe
  • ограничен временем жизни: Span<T> нельзя хранить в полях классов и нельзя “увести” в async-границу
  • Span и строки: парсинг без Substring

    Substring в современных .NET создаёт новую строку, то есть аллокацию.

    Вместо этого можно “резать” строку как ReadOnlySpan<char>:

    Что вы получаете:

  • нет новых строк
  • меньше давления на GC
  • возможность парсить большие входные данные потоково
  • stackalloc для маленьких временных буферов

    Если буфер маленький и живёт внутри метода, иногда выгодно разместить его на стеке:

    Ограничения:

  • не делайте огромные stackalloc (можно переполнить стек)
  • не возвращайте Span<T> на внешний уровень
  • Memory<T> и async-границы

    Span<T> нельзя безопасно удерживать между await, потому что он может ссылаться на стек.

    Для асинхронных сценариев есть Memory<T> и ReadOnlyMemory<T>:

  • Memory<T>
  • Memory<T> можно хранить в полях и передавать дальше, а при необходимости получать Span<T>:

    System.IO.Pipelines: высокопроизводительный I/O без лишних копий

    Зачем нужны pipelines

    Классический подход “прочитал в массив, скопировал, распарсил” часто приводит к:

  • лишним аллокациям
  • лишним копированиям
  • сложной ручной работе с буферами
  • System.IO.Pipelines даёт модель:

  • PipeReader читает последовательность байт как ReadOnlySequence<byte>
  • парсер потребляет данные порциями, не требуя “всё сразу”
  • буферы переиспользуются, снижая давление на GC
  • Документация:

  • System.IO.Pipelines
  • !Как данные проходят через PipeWriter/PipeReader без лишних копирований

    Основные понятия: ReadOnlySequence и backpressure

    ReadOnlySequence<byte> — это последовательность, которая может быть:

  • одним непрерывным сегментом
  • набором сегментов (например, если данные приходили кусками)
  • Это важно: ваш парсер должен уметь работать и с “несмежными” данными.

    Backpressure — механизм, который не даёт производителю бесконечно заливать данные, если потребитель не успевает. Это помогает стабилизировать память под нагрузкой.

    Минимальный пример чтения и парсинга

    Ниже — упрощённый пример “читать строки по \n” через PipeReader. Он показывает идею: вы не обязаны копировать весь буфер, вы ищете разделитель и продвигаете курсоры.

    Практические замечания:

  • как только вы начинаете декодировать в string, вы возвращаетесь к аллокациям, поэтому часто выгодно парсить протокол на байтах
  • если нужно декодировать, делайте это точечно и по возможности переиспользуйте буферы
  • Pipelines и async: устойчивость под нагрузкой

    Связь с предыдущей темой про асинхронность:

  • pipelines хорошо сочетаются с async/await, потому что чтение данных — I/O-bound
  • вы уменьшаете риск блокировки потоков thread pool
  • вы получаете более стабильное потребление памяти из-за порционной обработки и backpressure
  • Короткий чеклист: как улучшать производительность безопасно

  • измеряйте до и после, фиксируйте метрику
  • оптимизируйте горячие точки, найденные профилировщиком
  • уменьшайте аллокации в циклах и обработчиках запросов
  • избегайте случайной материализации LINQ в горячем пути
  • используйте Span<T> для срезов и парсинга без копий
  • используйте Memory<T> там, где есть await
  • применяйте System.IO.Pipelines для высоконагруженного I/O и протоколов
  • после оптимизации добавляйте бенчмарк или нагрузочный тест, чтобы не потерять выигрыш
  • Итог

  • Профилирование — это не “опция”, а основной способ принимать решения о производительности. Для микросценариев используйте BenchmarkDotNet, для живой диагностики — dotnet-counters, для глубокого анализа — dotnet-trace и PerfView.
  • Главные выигрыши обычно дают алгоритмы, снижение аллокаций и уменьшение копирований, а не микро-оптимизации.
  • Span<T> и ReadOnlySpan<T> позволяют работать со срезами памяти без выделений, но требуют понимания времени жизни данных. Для async-сценариев используйте Memory<T>.
  • System.IO.Pipelines помогает строить высокопроизводительные I/O пайплайны: порционная обработка, минимум копий и backpressure дают устойчивость под нагрузкой.
  • 6. Надёжность кода: тестирование, DI, логирование, обработка ошибок

    Надёжность кода: тестирование, DI, логирование, обработка ошибок

    Что такое надёжность в контексте C#-систем

    Надёжность кода на продвинутом уровне это не только отсутствие багов, но и способность системы:

  • предсказуемо работать под нагрузкой
  • корректно деградировать при отказах внешних зависимостей
  • быть диагностируемой в продакшене
  • оставаться поддерживаемой при росте требований
  • Эта тема связывает предыдущие части курса в один практический контур:

  • понимание CLR/GC объясняет, почему некоторые подходы к логированию и обработке ошибок создают лишние аллокации и паузы
  • знание LINQ/замыканий помогает избегать скрытой стоимости в тестах и в логировании
  • понимание async/await и TPL критично для корректной отмены, таймаутов и обработки исключений в конкурентном коде
  • архитектура и DDD задают границы, где должны жить бизнес-правила, где инфраструктурные ошибки, и где должны быть перехвачены исключения
  • тема производительности напоминает: надёжность требует измеримости, а измеримость начинается с качественных логов и трассировок
  • !Карта, где обычно размещают DI, логирование, обработку ошибок и устойчивость относительно слоёв

    Тестирование: как сделать поведение проверяемым

    Виды тестов и где они дают максимум пользы

    Полезная рабочая классификация:

  • Unit-тесты проверяют бизнес-логику в изоляции от I/O
  • Интеграционные тесты проверяют взаимодействие с реальными или близкими к реальным зависимостями (БД, HTTP, очереди)
  • End-to-end тесты проверяют систему как чёрный ящик через публичный контракт (например, HTTP API)
  • Практическое правило для надёжности:

  • доменные инварианты (DDD) должны быть максимально покрыты unit-тестами
  • контракты с инфраструктурой должны быть покрыты интеграционными тестами
  • критические пользовательские сценарии должны иметь несколько e2e тестов, но не тысячи
  • Официальные точки входа:

  • Unit testing in .NET
  • xUnit.net
  • Детерминированность: как не получить флейки

    Флейки тесты это тесты, которые то проходят, то падают без изменения кода. Главные источники:

  • время (DateTime.UtcNow, таймеры)
  • случайность (Random без фиксированного seed)
  • многопоточность и гонки
  • ожидания через Thread.Sleep
  • реальные внешние сервисы без контроля окружения
  • Рабочие техники:

  • инвертируйте время через TimeProvider (актуально в современных версиях .NET)
  • делайте генераторы случайных данных воспроизводимыми (фиксируйте seed)
  • избегайте Thread.Sleep в тестах, используйте ожидания по условию и таймауту
  • в асинхронных тестах всегда await, не используйте .Result и .Wait()
  • Справка:

  • TimeProvider
  • Unit-тест доменной логики: пример

    Ниже пример, где проверяется инвариант доменной сущности. Важный момент: тест не зависит от БД, HTTP, DI и времени.

    Моки и подмены: осторожно с “тестированием реализации”

    Mock-фреймворки полезны, но могут сделать тесты хрупкими.

    Проблемный стиль:

  • вы проверяете, что был вызван “правильный” метод “правильное” число раз
  • при рефакторинге тесты ломаются, хотя поведение системы не изменилось
  • Более надёжный стиль:

  • по возможности проверяйте наблюдаемое поведение и результат
  • моки используйте для границ I/O и редких побочных эффектов
  • Популярные инструменты:

  • Moq
  • FluentAssertions
  • Dependency Injection: как управлять зависимостями предсказуемо

    DI на продвинутом уровне это не “магия контейнера”, а дисциплина сборки графа объектов.

    Официальная документация:

  • Dependency injection in .NET
  • Composition Root: где должен собираться граф

    Ключевая практика:

  • граф зависимостей должен собираться на границе приложения (обычно в Program.cs)
  • внутри домена и application-слоя не должно быть вызовов контейнера
  • Иначе возникает:

  • скрытая связанность
  • сложность тестирования
  • непредсказуемое время жизни объектов
  • Времена жизни: transient, scoped, singleton

    Важно понимать контракт каждого lifetime:

  • Transient создаётся каждый раз при запросе
  • Scoped создаётся один раз на scope (в вебе обычно scope равен HTTP-запросу)
  • Singleton живёт весь процесс
  • Типичная ошибка надёжности: captive dependency, когда Singleton зависит от Scoped. Это приводит к:

  • утечкам ресурсов
  • неожиданному использованию “старого” контекста запроса
  • ошибкам многопоточности
  • IDisposable и ресурсы

    DI-контейнер .NET умеет освобождать IDisposable и IAsyncDisposable, но корректность зависит от lifetime:

  • Singleton освобождается при остановке приложения
  • Scoped освобождается при завершении scope
  • Transient освобождается контейнером только если он создавался контейнером и не “потерян” вне графа
  • Практика:

  • не создавайте вручную HttpClient и не держите его как “вечный singleton” без понятной стратегии
  • используйте IHttpClientFactory для устойчивости и управления соединениями
  • Справка:

  • IHttpClientFactory
  • Open generics и декораторы

    Обобщённые регистрации полезны для кросс-срезных аспектов. Пример: оборачивать репозиторий декоратором, который добавляет логирование или метрики.

    С архитектурной точки зрения это связывает DI с паттернами из предыдущей статьи:

  • Decorator удобен, но помните о стоимости абстракции в горячих местах
  • Логирование: чтобы ошибки были объяснимы, а не мистичны

    Цель логирования в надёжной системе:

  • восстановить контекст проблемы
  • связать события одного запроса (корреляция)
  • получать сигнал о деградации раньше, чем о падении
  • Официальная документация:

  • Logging in .NET
  • Структурированные логи вместо “склеивания строк”

    Вместо:

    предпочтительнее:

    Почему это повышает надёжность:

  • поля UserId и OrderId становятся структурированными данными
  • лог-хранилище может фильтровать и агрегировать по полям
  • меньше случайных аллокаций при выключенном уровне логирования (зависит от провайдера, но шаблонный стиль обычно безопаснее)
  • Уровни логирования как контракт

    Практическое смысловое разделение:

  • Trace и Debug для диагностики, обычно выключены в продакшене
  • Information для ключевых бизнес-событий и жизненного цикла запросов
  • Warning для деградаций и восстановимых проблем
  • Error для ошибок, которые повлияли на результат операции
  • Critical для угрозы работоспособности сервиса
  • Надёжность страдает и от недологирования, и от перелогирования.

  • если логов мало, инцидент невозможно расследовать
  • если логов слишком много, вы теряете сигнал в шуме и можете получить лишнюю нагрузку на CPU, диск и сеть
  • Корреляция: связываем события одного запроса

    Минимальный уровень корреляции:

  • прокидывать TraceId или CorrelationId через границы
  • использовать scopes
  • Для распределённых систем полезно опираться на Activity и трассировку.

    Справка:

  • Activity
  • Высоконагруженные сценарии: source-generated logging

    В горячих путях логирование может стать заметным источником накладных расходов.

    Подход:

  • использовать LoggerMessage и source generators, чтобы снизить стоимость формирования лог-сообщений
  • Справка:

  • High-performance logging in .NET
  • Обработка ошибок: предсказуемость вместо “ловим всё подряд”

    Обработка ошибок это часть контракта. Она должна отвечать на вопросы:

  • где ошибка перехватывается
  • как она превращается в результат (HTTP-ответ, сообщение в очередь, доменное событие)
  • что логируется и с каким уровнем
  • какие ошибки можно повторить, а какие нельзя
  • Официальная документация:

  • Exceptions and Exception Handling
  • Исключения vs “результаты”

    Надёжная стратегия обычно выглядит так:

  • внутри домена нарушения инвариантов оформляются как исключения или доменные ошибки с явным типом
  • на границе приложения (например, HTTP API) вы переводите ошибки в контракт ответа
  • Важная дисциплина:

  • не использовать исключения как обычный механизм ветвления
  • не подавлять исключения без логирования и без перевода в понятный результат
  • Границы try/catch: ловить ближе к границе

    Плохая практика:

  • оборачивать весь код внутри каждого метода в try/catch (Exception) и возвращать “что-нибудь”
  • Хорошая практика:

  • в глубине (domain/application) дать ошибке подняться
  • на границе (Presentation, обработчик сообщений) перехватить и:
  • - залогировать - вернуть корректный контракт (например, Problem Details) - не раскрывать внутренние детали пользователю

    Справка для HTTP API:

  • ProblemDetails
  • Stack trace: как правильно пробрасывать исключения

    Если вы хотите залогировать и пробросить исключение выше, используйте throw;, а не throw ex;.

    throw; сохраняет исходный stack trace, а это напрямую повышает диагностируемость.

    Отмена и таймауты в async-коде

    Отмена в .NET кооперативная. Надёжный код:

  • принимает CancellationToken в публичных async-методах
  • передаёт токен вниз по стеку
  • корректно относится к OperationCanceledException как к ожидаемому сценарию
  • Это связано с темой про async/await:

  • игнорирование отмены приводит к подвисанию операций и перегрузке thread pool
  • Устойчивость к сбоям: ретраи, таймауты, circuit breaker

    Ошибки внешних зависимостей часто временные: сеть, DNS, перегрузка базы.

    Однако ретраи повышают надёжность только при соблюдении условий:

  • операция идемпотентна или вы контролируете повтор
  • вы используете backoff, чтобы не усилить перегрузку
  • вы ограничиваете общее время ожидания
  • Для типовых политик устойчивости часто используют Polly.

  • Polly
  • Важно:

  • не ретраить всё подряд
  • не ретраить доменные ошибки
  • учитывать, что ретраи увеличивают нагрузку и могут ухудшить ситуацию, если применены бездумно
  • Как всё это связывается с производительностью и GC

    Надёжность и производительность в .NET связаны напрямую:

  • лишние аллокации в логировании и ошибках увеличивают давление на GC
  • чрезмерные исключения в горячем пути создают дорогой сценарий по CPU и памяти
  • синхронные блокировки в обработке ошибок и ретраях “съедают” потоки thread pool
  • Практика из предыдущих тем курса:

  • измеряйте влияние логирования и обработчиков ошибок на аллокации через dotnet-counters
  • избегайте LINQ и лишних замыканий в горячих обработчиках запросов
  • применяйте высокопроизводительное логирование там, где объём событий большой
  • Итоговый практический чеклист

  • пишите unit-тесты на доменные правила и инварианты без I/O
  • делайте тесты детерминированными: управляемое время, отсутствие Thread.Sleep, правильный await
  • собирайте зависимости в composition root, а не “дёргайте контейнер” из бизнес-кода
  • корректно выбирайте lifetime и избегайте Singleton -> Scoped
  • используйте структурированное логирование и корреляцию через scopes и trace id
  • ловите ошибки на границе приложения, сохраняйте stack trace через throw;
  • проектируйте отмену и таймауты через CancellationToken
  • добавляйте устойчивость (retry/timeout/circuit breaker) только там, где это оправдано контрактом и идемпотентностью
  • 7. Современный .NET: API, конфигурация, деплой и наблюдаемость

    Современный .NET: API, конфигурация, деплой и наблюдаемость

    Зачем продвинутому разработчику «современный .NET»

    В предыдущих темах курса мы говорили о фундаменте исполнения (CLR/GC), выразительности языка (generics, LINQ, выражения), конкуренции (async/await, TPL), архитектуре (SOLID, паттерны, DDD), производительности (Span, pipelines, профилирование) и надёжности (DI, тестирование, логирование, обработка ошибок).

    Эта статья связывает всё это в практическую картину современного .NET-приложения:

  • как строить HTTP API (ASP.NET Core) так, чтобы оно было тестируемым, быстрым и предсказуемым
  • как управлять конфигурацией и секретами без «магии» и утечек
  • как собирать и деплоить сервис так, чтобы он одинаково работал локально и в продакшене
  • как сделать систему наблюдаемой: логи, метрики, трассировки и health checks
  • Ключевая идея:

    > Код считается «готовым к продакшену», когда его можно настроить без перекомпиляции, задеплоить воспроизводимо и диагностировать по сигналам, не заходя на сервер «посмотреть глазами».

    Современное хостинг-модель и входная точка

    Начиная с .NET 6 в ASP.NET Core широко используется минимальная хостинг-модель: настройка приложения сосредоточена в Program.cs, где вы собираете:

  • контейнер DI
  • middleware-конвейер
  • маршруты (endpoints)
  • конфигурацию и логирование
  • Документация:

  • Обзор ASP.NET Core
  • Минимальные API
  • Типовой каркас:

    Что здесь важно на продвинутом уровне:

  • границы ответственности: доменная логика не должна жить в Program.cs
  • DI как композиция: именно тут должны быть регистрации и декораторы (связь со статьёй про SOLID и DI)
  • середина конвейера: middleware часто определяет наблюдаемость (корреляция, обработка ошибок, метрики)
  • HTTP API в ASP.NET Core: контроллеры и минимальные API

    В ASP.NET Core есть два популярных стиля:

  • контроллеры (MVC) с атрибутами, фильтрами, model binding
  • минимальные API (Minimal APIs) через MapGet/MapPost и делегаты
  • Документация:

  • Создание Web API с контроллерами
  • Минимальные API
  • Когда удобны контроллеры

    Контроллеры обычно выигрывают, когда:

  • много эндпоинтов и нужна единая структура
  • активно используются фильтры (например, авторизация, валидация, обработка ошибок)
  • важны возможности MVC: форматирование, атрибутные маршруты, конвенции
  • Когда удобны минимальные API

    Минимальные API обычно выигрывают, когда:

  • сервис небольшой или вы хотите начать с малого без лишних слоёв
  • endpoints естественно описываются как функции
  • важна низкая церемониальность
  • Продвинутый нюанс: стиль API не заменяет архитектуру. И в контроллерах, и в минимальных API бизнес-правила стоит держать в application/domain слое, а HTTP-слой делать тонким.

    Контракты, валидация и ошибки

    Надёжный API определяется не тем, что «не падает», а тем, что:

  • возвращает предсказуемые коды и форматы ошибок
  • не протекает внутренними исключениями
  • коррелируется в логах и трассировках
  • Для HTTP-ошибок в ASP.NET Core есть стандартный формат:

  • ProblemDetails
  • Пример в минимальном стиле:

    Продвинутая практика из темы про надёжность:

  • доменные нарушения инвариантов должны быть выражены в домене, а перевод в HTTP-ответ должен происходить на границе
  • не стоит «ловить всё подряд» внутри каждого обработчика, лучше иметь централизованную обработку исключений в middleware
  • Конфигурация: источники, приоритеты и типичная дисциплина

    Конфигурация в .NET строится как набор источников (providers): JSON-файлы, переменные окружения, командная строка, секреты.

    Документация:

  • Конфигурация в .NET
  • Переменные окружения как источник конфигурации
  • Правило приоритета

    Практический принцип: последний подключённый источник побеждает.

    В типовом WebApplication-шаблоне порядок примерно такой:

  • appsettings.json
  • appsettings.{Environment}.json
  • User Secrets (обычно только в Development)
  • переменные окружения
  • аргументы командной строки
  • Это позволяет:

  • держать «базу» конфигурации в репозитории
  • переопределять значения под окружение без пересборки
  • передавать секреты и runtime-настройки через окружение или секрет-хранилище
  • !Схема источников конфигурации и принципа переопределения

    Options pattern: конфигурация как типобезопасный контракт

    В продакшен-коде конфигурацию лучше не читать через Configuration["Key"] по всему проекту. В .NET для этого есть Options pattern.

    Документация:

  • Options pattern в .NET
  • Пример:

    Что это даёт:

  • типобезопасность и автодополнение вместо «строковых ключей»
  • единый контракт на настройки
  • раннее обнаружение ошибок конфигурации (fail fast), что повышает надёжность
  • Связь с SOLID и тестированием:

  • ваш бизнес-код зависит от абстракции настроек (тип PaymentsOptions), а не от глобального IConfiguration
  • такой код проще тестировать, подставляя опции напрямую
  • User Secrets, секреты и дисциплина окружений

    Секрет это значение, которое нельзя хранить в репозитории: токены, пароли, ключи.

    Документация:

  • Безопасное хранение секретов в разработке с Secret Manager
  • Практика:

  • локально использовать User Secrets
  • в CI/CD и продакшене использовать переменные окружения или секрет-хранилище платформы деплоя
  • не логировать секреты и не включать их в исключения
  • Деплой: сборка, publish, контейнеры и воспроизводимость

    Деплой в .NET обычно строится вокруг команды dotnet publish.

    Документация:

  • dotnet publish
  • Что означает publish

    publish готовит артефакты для запуска:

  • собирает проект в Release
  • копирует зависимости
  • может упаковать всё в один файл
  • может сделать приложение self-contained (с собственным runtime)
  • Примеры:

    Где:

  • -c Release включает оптимизации JIT и компилятора (важно для темы производительности)
  • -r linux-x64 задаёт целевую платформу (Runtime Identifier)
  • --self-contained true включает runtime в артефакт и уменьшает зависимость от окружения
  • Trimming и осторожность с рефлексией

    В .NET есть режимы уменьшения размера приложения (например, trimming), но они требуют дисциплины: код, который активно использует рефлексию и динамическую загрузку типов, может ломаться.

    Тезис для продвинутого уровня:

  • оптимизация размера и стартового времени должна быть подкреплена тестами и проверками на реальных сценариях
  • если у вас много сериализации, DI и отражения, любые агрессивные режимы оптимизации нужно вводить постепенно
  • Если эта тема актуальна, начинать стоит с официального раздела:

  • Trimming .NET приложений
  • Контейнеризация как стандартная единица деплоя

    Контейнер (обычно Docker) помогает обеспечить воспроизводимость:

  • одинаковая ОС-база и зависимости
  • одинаковый способ запуска
  • простая интеграция с оркестраторами (например, Kubernetes)
  • Официальный вход:

  • Контейнеры и .NET
  • Продвинутые нюансы:

  • контейнер не решает проблем конфигурации, он лишь делает окружение более предсказуемым
  • важно уметь отдавать конфигурацию через переменные окружения и секреты платформы
  • Наблюдаемость: логи, метрики, трассировки и корреляция

    Наблюдаемость это способность понять состояние системы по её сигналам.

    В современном .NET стандартная связка сигналов:

  • логи: события и контекст
  • метрики: численные измерения во времени
  • трассировки: цепочки операций и задержки между компонентами
  • Связанные официальные материалы:

  • Логирование в .NET
  • Встроенная диагностика и Activity
  • OpenTelemetry для .NET
  • !Как логи, метрики и трассировки связываются одним идентификатором

    Логирование: структурированные события и стоимость

    Из темы про надёжность важнейшее правило: структурированные логи лучше строковой конкатенации.

    Документация:

  • Шаблонные сообщения в ILogger
  • Пример:

    Продвинутые аспекты:

  • в горячем пути логирование может влиять на аллокации и GC, поэтому уровни логирования и формат сообщений нужно проектировать
  • для высоконагруженных сценариев полезны подходы с source-generated logging
  • Официальный материал:

  • Высокопроизводительное логирование в .NET
  • Метрики: что измерять в API

    Метрики отвечают на вопрос: что происходит с сервисом в целом.

    Практический минимальный набор для HTTP API:

  • количество запросов
  • распределение времени ответа (latency)
  • доля ошибок по классам (например, 5xx)
  • загрузка ресурсов (CPU, память, GC, thread pool)
  • Связь с темой производительности:

  • без метрик вы оптимизируете «вслепую»
  • метрики помогают подтвердить, что изменение реально улучшило поведение под нагрузкой
  • Трассировки: где именно теряется время

    Трассировка нужна, когда вы знаете, что «медленно», но не понимаете, где именно.

    В .NET трассировки строятся вокруг Activity и стандарта W3C Trace Context (заголовки traceparent и tracestate). Практически это означает:

  • входящий запрос получает trace id
  • каждый внешний вызов (HTTP, БД) становится частью цепочки
  • в распределённой системе можно увидеть полный путь запроса
  • Официальная точка входа:

  • Сбор распределённых трассировок в .NET
  • OpenTelemetry как универсальный транспорт сигналов

    OpenTelemetry (OTel) это набор библиотек и протоколов, который позволяет собирать логи, метрики и трассировки и отправлять их в систему наблюдаемости.

    Официальная документация .NET:

  • Observability with OpenTelemetry in .NET
  • Важная дисциплина для продвинутого уровня:

  • наблюдаемость должна быть встроена в продукт, а не добавляться «в пожарном режиме» после первого инцидента
  • любые добавления в горячий путь должны оцениваться по стоимости (CPU, аллокации), что перекликается с темой профилирования
  • Health checks: живой ли сервис и готов ли он обслуживать запросы

    Health checks это специальные эндпоинты, которые позволяют платформе деплоя и мониторингу понять состояние сервиса.

    Документация:

  • Health checks в ASP.NET Core
  • Часто выделяют два смысла:

  • liveness (жив ли процесс): можно ли считать сервис «не упавшим»
  • readiness (готов ли обслуживать): доступна ли БД, прогрелись ли зависимости, завершилась ли миграция
  • Практика:

  • liveness должен быть быстрым и дешёвым
  • readiness может проверять зависимости, но тоже должен быть ограничен по времени
  • health checks не должны превращаться в «полноценный сценарий», который сам создаёт нагрузку
  • Минимальный пример регистрации:

    Итог: как собрать «production-ready» .NET сервис

    Ниже компактная карта практик, связывающая курс в единое целое:

  • API слой (контроллеры или минимальные API) держите тонким, доменные правила пусть живут в domain/application (DDD, SOLID)
  • конфигурацию делайте типобезопасной через Options, а секреты держите вне репозитория (надёжность)
  • деплойте через dotnet publish, добивайтесь воспроизводимости, постепенно вводите оптимизации размера и старта только при наличии измерений (производительность)
  • включайте наблюдаемость: структурированные логи, метрики и трассировки, связаны корреляцией (диагностика)
  • добавляйте health checks, чтобы платформа деплоя могла принимать правильные решения (надёжность)
  • С этой базой переход к реальным production-практикам становится инженерным процессом: измерили, поняли, поменяли, проверили.