Хабрахабр

Заблуждения начинающих C# разработчиков. Пытаемся ответить на стандартные вопросы

Недавно мне выпала возможность пообщаться с довольно большим количеством начинающих специалистов по разработке на языке C#. Многие из них интересуются языком и платформой, и это очень круто. Среди зеленых юниоров распространено мракобесие по поводу очевидных (только что прочитавшему книгу про память) вещей. И это же натолкнуло меня на мысль о создании этой статьи. Статья в первую очередь нацелена на начинающих разработчиков, но думаю, что многие факты будут полезны и практикующим инженерам. Ну и самые очевидные и неинтересные заблуждения, разумеется, опущены. Здесь собраны наиболее интересные и значимые, особенно с точки зрения прохождения интервью.

#1. Мантра про 3 поколения в любой ситуации

Это скорее неточность, чем заблуждение. Вопрос про «сборщик мусора в C#» для разработчика стал классикой и уже мало кто не начнет на него бойко отвечать про концепцию поколений. Однако, почему-то, мало кто обращает внимание на то, что великий и ужасный сборщик мусора — часть рантайма. Соответственно, я бы дал понять, что не пальцем пихан, и спросил бы про какую среду исполнения идет речь. По запросу «сборщик мусора в c#» в интернетах можно найти более, чем много похожих сведений. Однако мало кто упоминает, что данная информация относится к CLR/CoreCLR (как правило). Но не стоит забывать и про Mono, легковесный, гибкий и встраиваемый рантайм, который занял свою нишу в мобильной разработке (Unity, Xamarin) и используется в Blazor. И для соответствующих разработчиков я бы посоветовал поинтересоваться подробностями устройства сборщика в Mono. Например, по запросу «mono garbage collector generations», можно увидеть, что поколения всего два — nursery и old generation (в новеньком и модном сборщике мусора — SGen).

#2. Мантра про 2 стадии сборки мусора в любой ситуации

Еще не так давно исходники сборщика мусора были скрыты от всех. Однако интерес к внутреннему устройству платформы был всегда. Поэтому информация извлекалась разными путями. И некоторые неточности при реверс-инжиниринге сборщика привели к мифу о том, что сборщик работает в 2 стадии: маркировка и чистка. Или и того хуже, 3 стадии — маркировка, чистка, сжатие.

Код сборщика для CoreCLR был целиком взят из CLR версии. Однако все изменилось, когда народ огня развязал войну с появлением CoreCLR и исходников сборщика. Теперь, чтобы понять, как что-то работает, достаточно зайти на github и найти это в исходном коде или прочитать readme. Никто с нуля его не писал, соответственно, почти все, что можно узнать из исходников CoreCLR, будет верно и для CLR. Но формально это можно разделить на 3 этапа — маркировка, планирование, чистка. Там же можно увидеть, что существует 5 фаз: маркировки, планирования, обновления ссылок, компактинга (удаление с перемещением) и удаление без перемещений(перевести это дело сложно).

Благодаря информации, полученной на этом этапе, выносится решение о необходимости компактинга (дефрагментации), там же просчитывается, на сколько необходимо двигать объекты и т.д. На этапе маркировки выясняется, какие объекты не стоит собирать сборщику.
На этапе планирования производятся расчет различных показателей текущего состояния памяти и сбор данных, необходимых на этапе чистки.

И на этапе чистки в зависимости от необходимости компактинга может производится обновление ссылок и компактинг или удаление без перемещений.

#3. Выделение памяти на куче так же быстро, как и на стеке

Опять же, скорее неточность, чем абсолютная неправда. В общем случае, конечно, разница в скорости выделения памяти минимальна. Действительно, в лучшем случае, при bump pointer allocation, выделение памяти — лишь сдвиг указателя, как и на стеке. Однако на выделение памяти в куче могут повлиять такие факторы, как присваивание нового объекта в поле старого (что затронет write barrier, обновляющий card table — механизм, позволяющий отслеживать ссылки из старшего поколения в младшее), наличие финализатора(необходимо добавить тип в соответствующую очередь) и др. Так же, возможно, объект будет записан в одну из свободных дыр в куче (после сборки без дефрагментации). А нахождение такой дыры происходить хоть и быстро, но, очевидно, медленнее, чем простой сдвиг указателя. Ну и разумеется каждый созданный объект приближает очередную сборку мусора. И на очередной процедуре выделения памяти она может случится. Что, естественно, займет некоторое время.

#4. Определение ссылочного, значимого типов и упаковки через понятия стека и кучи

Прямо классика, которая, к счастью, уже не так часто встречается.

Значимый на стеке. Ссылочный тип располагается в куче. Но мало того, что это лишь частичная правда, так и определять понятия через протекшую абстракцию — не лучшая идея. Наверняка многие слышали эти определения очень часто. Для начала стоит уточнить, что типы описывают значения. За всеми определениями предлагаю обращаться к стандарту CLI — ECMA 335. Для значимого типа значение им описываемое является автономным(самосодержащим). Так, ссылочный тип определяется следующим образом — значение, описываемое ссылочным типом (ссылка) указывает на расположение другого значения. Это является протекшей абстракцией, которую все же следует знать. Про то, где располагаются те или иные типы ни слова.

Значимый тип может располагаться:

  1. В динамической памяти (куче), если он является частью объекта, расположенного в куче, или в случае упаковки;
  2. На стеке, если он является локальной переменной/аргументом/возвращаемым значением метода;
  3. В регистрах, если то позволяет размер значимого типа и другие условия.

Ссылочный тип, а именно, значение на которое указывает ссылка, на текущий момент располагается в куче.

Сама же ссылка может располагаться там же, где и значимый тип.

Рассмотрим краткий пример. Упаковка также не определяется через места хранения.

Код C#

public struct MyStruct public class MyClass { public MyStruct justStruct; } public static void Main() { MyClass instance = new MyClass(); object boxed = instance.justStruct; }

И соответственный IL код для метода Main

Код IL

1: nop 2: newobj instance void C/MyClass::.ctor() 3: stloc.0 4: ldloc.0 5: ldfld valuetype C/MyStruct C/MyClass::justStruct 6: box C/MyStruct 7: stloc.1 8: ret

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

Так, упаковка — операция над значимым типом, создающая значение соответствующего упакованного типа, содержащего побитовую копию оригинального значения. Чтобы определить, что есть упаковка, для начала стоит сказать, что для каждого значимого типа CTS(common type system) определяет ссылочный тип, который называется упакованным типом.

#4. События — отдельный механизм

События существуют с первой версии языка и вопросы о них встречаются гораздо чаще, чем сами события. Однако понимать и знать, что это такое, стоит, ведь этот механизм позволяет писать очень слабо связанный код, что иногда бывает полезно.

Особенно этому способствует тип из BCL EventHandler, имя которого наталкивает на мысли о том, что это что-то отдельное. К сожалению, зачастую под событием понимается некий отдельный инструмент, тип, механизм.

Я уже давно провел для себя такую аналогию, а недавно увидел, что она проведена и в спецификации CLI. Определение события стоит начать с определения свойств.

Звучит довольно очевидно. Свойство определяет именованное значение и методы, которые обращаются к нему. CTS поддерживает события так же, как и свойства, НО методы для доступа отличаются и включают методы для подписки и отписки от события. Переходим к событиям. Тип этого объявления должен быть типом делегата. Из спецификации языка C# — класс определяет событие… что напоминает объявление поля с добавлением ключевого слова event. Спасибо стандарту CLI за определения.

Внутри же класса событие ничем не отличается от простого поля типа делегата. Итак, это означает, что событие — ничто иное, как делегат, который выставляет наружу лишь часть функциональности делегатов — добавление очередного делегата в список для выполнения, удаление из этого списка.

#5. Управляемые и неуправляемые ресурсы. Финализаторы и IDisposable

Абсолютная путаница существует при работе с этими ресурсами. Этому во многом способствует интернет с тысячей статей о правильной реализации паттерна Dispose. Собственно, в этом паттерне ничего криминального нет — модифицированный шаблонный метод под конкретный случай. Но вопрос в другом — нужен ли он вообще. Почему-то, у части людей возникает непреодолимое желание реализовать финализатор на каждый чих. Скорее всего причиной этого является не полное понимание, что есть «неуправляемый ресурс». И строчки про то, что в финализаторах, как правило, освобождаются неуправляемые ресурсы из-за этого неполного понимания проходят мимо и не остаются в голове.

А управляемый ресурс, в свою очередь, тот, который выделяется и высвобождается CLI автоматически через процесс который называется сборка мусора. Неуправляемый ресурс — ресурс, который не является управляемым (как бы это странно ни было). Но если попытаться объяснить проще, то неуправляемые ресурсы — те, про которые не знает сборщик мусора. Это определение я нагло содрал из стандарта CLI. AddMemoryPressure и GC. (Строго говоря мы можем давать сборщику немного информации про такие ресурсы с помощью GC. Соответственно, он не сможет сам позаботиться об их освобождении и поэтому мы должны сделать это за него. RemoveMemoryPressure, это может оказать влияние на внутренние самотьюнинги сборщика). И чтобы код не пестрил от разнообразия воображения разработчиков, используются 2 общепринятых подхода. И подходов к этому может быть много.

  1. Интерфейс IDisposable (и его асинхронная версия IAsyncDisposable). Мониторится всеми анализаторами кода, так что забыть про его вызов сложно. Предоставляет единственный метод — Dispose. И поддержку компилятора — оператор using. Отличный кандидат на тело метода Dispose — вызов аналогичного метода одного из полей класса или освобождение неуправляемого ресурса. Вызывается явно пользователем класса. Наличие данного интерфейса у класса подразумевает, что по окончании работы с экземпляром, нужно вызвать этот метод.
  2. Финализатор. По своей сути является страховкой. Вызывается неявно, в неопределенное время, во время сборки мусора. Замедляет выделение памяти, работу сборщика мусора, продлевает время жизни объектов минимум до следующей сборки, а то и дольше, но зато вызывается сам, даже если его никто не вызывал. Из-за своей недетерминированной природы, в нем должны освобождаться только неуправляемые ресурсы. Также можно встретить примеры, в которых финализатор применялся для воскрешения(resurrection) объекта и организации пула объектов таким образом. Однако такая имплементация пула объектов — однозначно плохая идея. Как и пытаться логировать, кидать исключения, обращаться к базе и тысячи подобных действий.

И можно легко представить себе ситуацию при написании критической к производительности библиотеки, которая внутри использует неуправляемые ресурсы, что она обходится просто грамотным обращением с этим ресурсом, освобождая память аккуратно вручную. При написании таких высокопроизводительных библиотек, ООП, поддерживаемость и иже с ним уходит на второй план.

При работе с неуправляемыми ресурсами, надо быть готовым, что они никем, кроме вас, не управляются. И вопреки утверждению, что Dispose нарушает концепцию, в которой CLR сделает все за нас, заставляет делать что-то самим, о чем-то помнить и тд, скажу следующее. И в большинстве случаев можно обойтись замечательными классами обертками, вроде SafeHandle, который обеспечивает критическую финализацию ресурсов, предотвращая их преждевременную сборку. И вообще, ситуации, при которых данные ресурсы будут использоваться в ентерпрайсе, почти не встречаются.

Но не стоит его применять при виде первого же IDisposable объекта. Если же в вашем приложении по тем или иным причинам много ресурсов, требующих дополнительных действий для освобождения, то стоит взглянуть на отличный паттер компании JetBrains — Lifetime.

#6. Стек потока, стек вызовов, вычислительный стек и

Stack <T>

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

Стек вызовов — понятие больше логическое. Стек вызовов — структура данных, а именно стек, для хранения адресов возврата, для возвращения из функций. Получается, что стек вызовов — самый обычный и родной нам стек [т.е. Оно не регламентирует где и как должна храниться информация для возврата.

Stack<T>

:trollface:]. В нем же хранятся локальные переменные, через него передаются параметры и в нем же сохраняются адреса возврата при вызове инструкции CALL и прерываний, которые впоследствии используются инструкцией RET для возврата из функции/прерывания. Идем дальше. Одним из основных приколов потока является указатель на инструкцию, которая выполняется далее. Поток поочереди выполняет инструкции, объединяющиеся в функции. Соответственно у каждого потока есть стек вызовов. Таким образом получается, что стек потока и есть стек вызовов. То есть стек вызовов данного потока. Вообще, он также упоминается и под другими именами: программный стек, машинный стек.

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

Как известно, код C# компилируется в IL код, который входит в состав результирующих DLL (в самом общем случае). Вычислительный стек (evaluation stack). Почти все IL инструкции оперируют неким стеком. И как раз в основе среды выполнения, поглощающей наши DLL и выполняющей IL код лежит стек-машина. Здесь под стеком понимается некий виртуальный стек, ведь в итоге эта переменная может с высокой вероятностью оказаться и в регистрах. Например, ldloc загружает локальную переменную под определенным индексом на стек. То бишь вычисления производятся через этот стек. Арифметические, логические и др IL инструкции оперируют с переменными со стека и кладут результат туда же. К слову, многие виртуальные машины основаны на стек-машине. Таким образом получается, что вычислительных стек — абстракция в рантайме.

#7. Больше потоков — быстрее код

Интуитивно кажется, что обрабатывать данные параллельно будет быстрее, чем поочередно. Поэтому вооружившись знаниями о работе с потоками, многие пытаются запараллелить любой цикл и вычисление. Почти все уже знают про оверхед, который вносит создание потока, поэтому лихо используют потоки из ThreadPool и Task. Но оверхед создания потока — далеко не конец. Здесь мы имеем дело с еще одной протекшей абстракцией, механизмом, который используется процессором для повышения производительности — кеш. И как часто это бывает, кеш является обоюдоострым клинком. С одной стороны он значительно ускоряет работу при последовательном доступе к данным из одного потока. Но с другой стороны, при работе нескольких потоков, даже без необходимости их синхронизации, кеш не только не помогает, но еще и замедляет работу. Дополнительное время тратится на инвалидацию кэша, т.е. поддержание актуальных данных. И не стоит недооценивать эту проблему, которая по началу кажется пустяком. Эффективный с точки зрения кэша алгоритм будет выполняться одним потоком быстрее, чем многопоточный, в котором кэш используется неэффективно.

Диск и без того является тормозящим фактором многих программ, которые с ним работают. Также пытаться работать с диском из многих потоков — самоубийство. Если пытаться работать с ним из многих потоков, то надо забыть о быстродействии.

За всеми определениями рекомендую обращаться сюда:

NET Memory Management
CLI specification — ECMA-335
CoreCLR developers about runtime — Book Of The Runtime
От Станислава Сидристого про финализацию и прочее — . C# Language Specification — ECMA-334
Просто хорошие источники:
Konrad Kokosa — Pro . NET Platform Architecture

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть