Pull to refresh

Детали реализации стека — часть вторая

Reading time5 min
Views24K
Original author: Eric Lippet
image Несколько человек спрашивали меня, в контексте моего предыдущего поста о значимых типах, почему же всё-таки значимые типы располагаются на стеке, а ссылочные нет.

Если коротко, то «потому что могут». И т.к. стек «дёшев» мы располагаем их на стеке, когда только это возможно.

Длинный ответ он … длинный.

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

CLR куча* — это чудо инженерной мысли с огромным количеством деталей. Описание далее это не то, как на самом деле куча работает, но его достаточно, чтобы получить общее представление.

Идея в том, что существуют большие блоки памяти, выделенные под экземпляры ссылочных типов. Эти блоки памяти могут иметь «дырки» потому что некоторые блоки памяти заняты «живыми» объектами, а некоторые готовы для использования под новые объекты. В идеальном случае нам бы хотелось, чтобы вся занятая память располагалась в одном месте непрерывным блоком, а всё остальное адресное пространство было бы свободно.

В такой ситуации при выделении новой порции памяти мы бы перемещали указатель на верхнюю границу занятой память вверх на необходимую величину и «отъедали» бы часть ранее свободной памяти. Эта только что зарезервированная память использовалась бы для вновь созданного объекта. Такая операция чрезвычайно дешева: просто передвигаем указатель и заполняем нулями кусок памяти если это необходимо.

Если же у нас есть «дырки» то мы должны хранить «свободный лист» — список свободных секций. Тогда мы можем искать в этом списке свободное место подходящего размера и заполнять его. Эта операция немного дороже потому что производится поиск по списку. Хотелось бы избегать такой ситуации потому что она не оптимальна.

Сборка мусора происходит в 3 этапа: разметка, сборка и сжатие**. В фазе «разметка» мы предполагаем, что все объекты «мертвы» (недостижимы из рутов прим. пер.). CLR знает какие из объектов гарантированно живы в момент начала сборки и помечает их живыми. Всё объекты на которые они ссылаются также помечается как живые и т.д. пока всё транзитивное замыкание живых объектов не будет помечено. В фазе сборки все «мёртвые» объекты превращаются в «дырки». В фазе сжатия блоки реорганизуются таким образом, чтобы живые объекты составляли непрерывный блок памяти без «дырок».

Описанная модель усложняется тем что таких областей три: CLR коллектор реализует поколения. Сначала объекты находятся в куче с «коротким временем жизни». Если они выживают*** то со временем они переносятся в кучу со средним временем жизни и если там они выживают достаточно долго, то переносятся в кучу с долгим временем жизни. GC очень часто запускается на куче с коротким временем жизни и очень редко на куче с долгим временем жизни; идея в том, чтобы сэкономить на постоянной проверке долгоживущих объектов живы они до сих пор или нет. Но мы также хотим, чтобы короткоживущие объекты быстро освобождали память. У GC есть огромное множество точно выверенных политик обеспечивающих высокую производительность. Они определяют баланс между состоянием, когда память похожа на швейцарский сыр и временем, затраченным на фазу сжатия. Очень большие объекты хранятся в специальной куче с совершенно другой политикой сжатия. И т.д. и т.п. Я не знаю всех деталей и к счастью мне это и не нужно. (И конечно же я не стал усложнять деталями, не относящимися к данной статье такими как «привязка объектов»****, финализация, слабые ссылки и т.п.)

Теперь сравним это со стеком. Стек, как и куча, это большой кусок памяти с указателем на верхнюю границу. Но что по-настоящему делает этот кусок памяти стеком — это то, что память внизу стека всегда живёт дольше чем память наверху стека; стек строго упорядочен. Объект, который должен умереть первым — наверху, объект который должен умереть последним — внизу. Основываясь на этом мы знаем, что стек никогда не будет иметь дырок и ему никогда не нужно будет сжатие. Мы также знаем, что память в стеке всегда освобождается сверху и нам не нужно обслуживать список свободных секторов. Мы знаем, что все что лежит в стеке ниже гарантированно живо и нам не нужно ничего помечать и собирать.

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

Очевидно, что лучше использовать стек нежели кучу если Вы можете. Но когда же Вы можете? Только когда все условия чтобы стек работал выполняются. Локальные переменные и параметры значимых типов — самые «сладкие» случаи, когда все условия соблюдены. Локальные данные вызывающих функции, расположенные внизу стека, живут гарантированно дольше нежели локальные данные, расположенные вверху стека вызываемых функций. Локальные переменные значимых типов передаются по значению, а не по ссылке, это гарантирует что только локальная переменная указывает на данный кусок памяти и не нужно что-либо высчитывать чтобы определить время жизни объекта. И существует только один способ передать ссылку на значимую локальную переменную — это ref или out, которые передаются в функции, расположенные выше на стеке. Локальные переменные, расположенные ниже так и так живы пока функция выше по стеку не вернёт управление, поэтому время жизни переданных по ссылке объектов не изменится.

Немного дополнений:
Абзац выше объясняет почему мы не можем создать поле типа ref int. Если бы Вы могли сохранить ссылку на объект с коротким временем жизни внутри поля долго живущего объекта, если бы вдруг это случилось, то стек потерял бы свои преимущества и значимые типы стали бы просто ещё одной разновидностью ссылочных типов, нуждающихся в сборке мусора.

Замыкания анонимных функций и замыкания блоков операторов, компилятор реализует через поля скрытых классов. Теперь, я думаю, Вы понимаете почему запрещено замыкать ref и out переменные.

Конечно, мы не хотели создавать уродливое и странное правило типа «вы можете использовать в замыкании любую локальную переменную кроме параметров функции, переданных через ref и out». Но т.к. мы хотели использовать оптимизацию, располагая значения на стеки, то мы вынуждены были добавить такое на первый взгляд странно ограничение в язык. Это, впрочем, как и всегда, искусство находить компромиссы.

Кстати, CLR позволяет возвращать ref типы. Теоретически Вы могли бы создать метод «ref int Foo(){…}» который возвращает ссылку на целочисленную переменную. Если бы по какой-то странной причине мы бы решили разрешить такое в C#, то мы вынуждены были бы подкорректировать компилятор и проверять, что возвращаемая ссылка присваивается либо переменной в куче, либо переменной, расположенной в стеке ниже.

Вернёмся к нашим баранам. Локальные переменные расположены на стеке потому что могут. Они могут потому что 1 — «нормальные» локальные переменные имеют строго определённое время жизни и 2 — значимые типы всегда копируются по значению и 3 — хранить ссылку на локальные переменные можно только в каком-либо контейнере, время жизни которого, больше чем время жизни локальной переменной. На контрасте время жизни ссылочных типов определяется количеством живых ссылок, копируются по ссылке и эти ссылки могут храниться где угодно. Эта дополнительная свобода, которую ссылочные типы дают Вам прося в замен время на более сложную и дорогую стратегию сборки мусора.

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

(*) От переводчика: в .net есть ещё куча для внутренних CLR объектов, но обычно её не рассматривают, так что в данном случае имеется в виду именно куча, которая собирается GC и в которой хранятся экземпляры объектов, созданных пользователем.
(**) От переводчика: сжатие в данном контексте эквивалентно дефрагментации
(***) От переводчика: не собраны при сборке мусора
(****) От переводчика: в оригинале pinning — объекты которые GC не перемещает в памяти. Подробнее тут: msdn.microsoft.com/en-us/library/f58wzh21%28VS.80%29.aspx.
Tags:
Hubs:
+25
Comments12

Articles

Change theme settings