diff --git a/book/ru/Memory/1-Introduction/1-MemoryManagement-Intro.md b/book/ru/Memory/1-Introduction/1-MemoryManagement-Intro.md index 59e94ee..cc5e2ab 100644 --- a/book/ru/Memory/1-Introduction/1-MemoryManagement-Intro.md +++ b/book/ru/Memory/1-Introduction/1-MemoryManagement-Intro.md @@ -4,7 +4,7 @@ Когда я разговаривал с различными людьми и рассказывал, как работает Garbage Collector (для меня по началу это было большим и странным увлечением), то весь рассказ умещался максимум минут на 15. После чего, мне задавали один вопрос: «а зачем это знать? Ведь работает как-то и работает». После чего в голове начиналась путаница: с одной стороны я понимал, что они в большинстве случаев правы. И рассказывал про то самое меньшинство случаев, где эти знания прекрасно себя чувствуют и используются. Но поскольу таких случаев было всё-таки меньшинство, в глазах собеседника оставалось некоторое чувство недоверия. -На уровне тех знаний, которые нам давали раньше в немногочисленных источниках, люди, которых собеседуют на позицию разработчика обычно говорят: есть три поколения, пара хипов больших и малых объектов. Еще максимум можно услышать про наличие неких сегментов и таблицы карт. Но обычно дальше поколений и хипов люди не уходят. И все почему? Ведь **вовсе не потому**, что чего-то не знают, а потому, что действительно **не понятно, зачем это знать**. Ведь та информация, которая нам давалась, выглядела как рекламный буклет к чему-то большому, закрытому. Ну знаем мы про три поколения, ну и что?.. Всё это, согласитесь, какое-то эфемерное. +На уровне тех знаний, которые нам давали раньше в немногочисленных источниках, люди, которых собеседуют на позицию разработчика обычно говорят: есть три поколения, пара хипов больших и малых объектов. Еще максимум можно услышать - про наличие неких сегментов и таблицы карт. Но обычно дальше поколений и хипов люди не уходят. И все почему? Ведь **вовсе не потому**, что чего-то не знают, а потому, что действительно **не понятно, зачем это знать**. Ведь та информация, которая нам давалась, выглядела как рекламный буклет к чему-то большому, закрытому. Ну знаем мы про три поколения, ну и что?.. Всё это, согласитесь, какое-то эфемерное. Сейчас же, когда Microsoft открыли исходники, я ожидал нескольких бенефитов от этого. Первый бенефит - это то, что сообщество накинется и начнет какие-то баги исправлять. И оно накинулось! И исправило все грамматические ошибки в комментариях. Много запятых исправлено и опечаток. Иногда даже написано, что, например, свойство `IsEnabled` возвращает признак того, что что-то включено. Можно даже подать на членство в .NET Foundation, опираясь на множесто вот таких вот комментариев (и, понятное дело, не получить). Сейчас же можно: дорога открыта для граммар-наци. Второй бенефит, который ожидался - это предложение нового и полезного функционала в готовом виде. Этот бенефит, насколько я знаю, время от времени также срабатывает, но это очень редкие кейсы. Например, один разработчик очень ускорил получение символа по индексу в строке. Как выяснилось, ранее это работало не очень эффективно. @@ -20,13 +20,13 @@ ## Возможные классификации памяти исходя из логики -Как можно классифицировать память? Чисто интуитивно можно разделять выделяемые участки памяти исходя из размеров объекта, который выделяется. Например, понятно, что если мы говорим о больших структурах данных, то управлять ими надо совершенно по-другому, нежели маленькими: потому что они тяжелые и их трудно перемещать при надобности. А маленькие, соотвественно, занимают мало места и они вместе группируются. Поэтому, их перемещать легко, однако из-за того что их намного больше, ими тяжелее управлять. А значит, для них без всякой статистики и так понятно, что должен быть другой подход. +Как можно классифицировать память? Чисто интуитивно можно разделять выделяемые участки памяти исходя из размеров объекта, который выделяется. Например, понятно, что если мы говорим о больших структурах данных, то управлять ими надо совершенно по-другому, нежели маленькими: потому что они тяжелые и их трудно перемещать при надобности. А маленькие, соотвественно, занимают мало места и из-за того, что они образуют группы, перемещать легко. Однако из-за того что их намного больше, ими тяжелее управлять: знать о положении в памяти каждого из них. А значит, для них без всякой статистики и так понятно, что должен быть другой подход. -Если разделять по времени жизни, то тут тоже возникают идеи. Например, если объекты короткоживущие, то, возможно, к ним надо чаще присматриваться, чтобы побыстрее от них избавляться (желательно, сразу, как только они стали не нужны). Если объекты долгоживущие, то можно уже посмотреть на статистику. Например, можно пофантазировать и решить, что эту область памяти анализировать на предмет ненужных объектов можно и пореже: ведь большие объекты редко создают траффик в памяти. А если смотреть редко, это сокращает время на собрку мусора. +Если разделять по времени жизни, то тут тоже возникают идеи. Например, если объекты короткоживущие, то, возможно, к ним надо чаще присматриваться, чтобы побыстрее от них избавляться (желательно, сразу, как только они стали не нужны). Если объекты долгоживущие, то можно уже посмотреть на статистику. Например, можно пофантазировать и решить, что эту область памяти анализировать на предмет ненужных объектов можно и пореже: ведь большие объекты редко создают траффик в памяти. А если смотреть редко, это сокращает время на собрку мусора сумме, но увеличивает - каждый вызов GC. -Или по типу данных. Можно легко предположить, что все типы, которые отнаследованы от типа `Attribute`, будут жить вечно. Или строки, которые представляют собой массив символов. К ним тоже может быть свой подход. +Или же по типу данных. Можно легко предположить, что все типы, которые отнаследованы от типа `Attribute` или в зоне `Reflection`, будут жить почти всегда вечно. Или строки, которые представляют собой массив символов: к ним тоже может быть свой подход. -Видов может быть сколько угодно и в зависимости от классификаций может оказаться, что управление памятью для конкретной классификации может быть более эффективно, если учитывать её особенности. +Видов может быть сколько угодно много и в зависимости от классификаций может оказаться, что управление памятью для конкретной классификации может быть более эффективно, если учитывать её особенности. Когда создавали архитектуру нашего GC, то выбрали первые два вида классификаций: размер и время жизни (хотя, если присмотреться к делению типов на классы и структуры, то можно подумать, что классификации на самом деле три. Однако, различие свойств классов и структур можно свести к размеру и времени жизни). @@ -36,33 +36,50 @@ > Я выбрал формат рассуждения чтобы вы почувствовали себя архитекторами платформы и сами пришли к тем же самым выводам, к каким пришли реальные архитекторы в штаб-квартире Microsoft в Рэдмонде. -Исходя из классификации выделяемых объектов на основании их размера можно разделить места под выделение памяти на два больших раздела: на место с объектами размером ниже определенного порога и на место с размером выше этого порога и посмотреть, какую разницу можно внести в управление этими группами (исходя из их размера) и что из этого выйдет. +Определимся с терминологией: менеджмент памяти - это структура данных и ряд алгоритмов, которые позволяют "выделять" память и отдавать её внешнему потребителю и освобождать её, регистрируя как свободный участок. Т.е. если взять, например, какой-то массив байт (линейный кусок памяти), написать алгоритмы разметки массива на объекты .NET (запросили новый объект: мы подсчитали его размер, пометили у себя что этот вот кусок и есть новый объект, отдали указатель на объект внешней стороне) и алгоритмы освобождения памяти (когда нам говорят, что объект более не нужен, а потому память из-под него можно выдать кому-то другому). -Если рассматривать вопросы управления условно "*маленьких*" объектов, то можно заметить, что если придерживаться идеи сохранения информации о каждом объекте, нам будет очень дорого поддерживать структуры данных управления памятью, которые будут хранить в себе ссылки на каждый такой объект. В конечном счёте может оказаться, что для того, чтобы хранить информацию об одном объекте понадобится столько же памяти, сколько занимает сам объект. Вместо этого стоит подумать: если при сборке мусора мы пляшем от корней, уходя вглубь графа через исходящие поля объекта, а линейный проход по куче нам понадобится только для идентификации мусорных объектов, так ли нам необходимо в алгоритмах менеджмента памяти хранить информацию о каждом объекте? Ответ очевиден: надобности в этом нет никакой. А значит, можно попробовать исходить из того, что такую информацию мы хранить не должны: пройти кучу линейно мы можем, зная размер каждого объекта и смещая указатель каждый раз на размер очередного объекта. +Исходя из классификации выделяемых объектов на основании их размера можно разделить места под выделение памяти на два больших раздела: на место с объектами размером ниже определенного порога и на место с размером выше этого порога и посмотреть, какую разницу можно внести в управление этими группами (исходя из их размера) и что из этого выйдет. Рассмотрим каждую категорию в отдельности. + +Если рассматривать вопросы **управления условно** "*маленьких*" объектов, то можно заметить, что если придерживаться идеи сохранения информации о каждом объекте, нам будет очень дорого поддерживать структуры данных управления памятью, которые будут хранить в себе ссылки на каждый такой объект. В конечном счёте может оказаться, что для того, чтобы хранить информацию об одном объекте понадобится столько же памяти, сколько занимает сам объект. Вместо этого стоит подумать: если при сборке мусора мы будем помечать достижимые объекты обходом графа объектов (понять это легко, зная, откуда начинать обход графа), а линейный проход по куче нам понадобится *только* для идентификации всех остальных, т.е. мусорных объектов, так ли нам необходимо в алгоритмах менеджмента памяти хранить информацию о каждом объекте? Ответ очевиден: надобности в этом нет никакой. Ведь если мы будем размещать объекты друг за другом и при этом сделать возможным узнать размер каждого из них, сделать итератор кучи очень просто: + +```csharp +var current = memory_start; + +while(current < memory_end) +{ + var size = current.typeInfo.size; + current += size; +} +``` + + А значит, можно попробовать исходить из того, что такую информацию мы хранить не должны: пройти кучу мы можем линейно, зная размер каждого объекта и смещая указатель каждый раз на размер очередного объекта. > В куче нет дополнительных структур данных, которые хранят указатели на каждый объект, которым управляет куча. Однако, тем не менее, когда память нам более не нужна, мы должны её освобождать. А при освобождении памяти нам становится трудно полагаться на линейное прохождение кучи: это долго и не эффективно. Как следствие, мы приходим к мысли, что надо как-то хранить информацию о свободных участках памяти. -> В куче есть списки свободных участков памяти. +> В куче есть списки свободных участков памяти: набор указателей на их начала + размер. -Если, как мы решили, хранить информацию о свободных участках, и при этом при освобождении памяти участки эти оказались слишком малы, то во-первых мы приходим к той-же проблеме хранения информации о свободных участках, с которой столкнулись при рассмотрении занятых (если по бокам от занятых освободился один объект, то чтобы хранить о нём информацию, надо в худшем случае 2/3 его размера. Указатель + размер против SyncBlockIndex + VMT + какое-либо поле - в случае объекта). Это снова звучит расточительно, согласитесь: не всегда выпадает удача освобождения группы объектов, следующих друг за другом. Обычно, они освобождаются в хаотичном порядке. Но в отличии от занятых участков, которые нам нет надобности линейно искать, искать свободные участки нам необходимо потому что при выделении памяти они нам снова могут понадобиться. А потому возникает вполне естественное желание уменьшить фрагментацию и сжать кучу, переместив все занятые участки на места свободных, образовав тем самым большую зону свободного участка, где можно выделять память. +Если, как мы решили, хранить информацию о свободных участках, и при этом при освобождении памяти из под объектов эти участки оказались слишком малы для размещения в них чего-либо полезного, то во-первых мы приходим к той-же проблеме хранения информации о свободных участках, с которой столкнулись при рассмотрении занятых: хранить информацию о таких малышах может оказаться слишком дорого. Это снова звучит расточительно, согласитесь: не всегда выпадает удача освобождения группы объектов, следующих друг за другом. Обычно они освобождаются в хаотичном порядке, образуя небольшие просветы свободной памяти, где сложно выделить что-либо ещё. Но всё-таки в отличии от занятых участков, которые нам нет надобности линейно искать, искать свободные участки нам необходимо потому что при выделении памяти они нам снова могут понадобиться. А потому возникает вполне естественное желание уменьшить фрагментацию и сжать кучу, переместив все занятые участки на места свободных, образовав тем самым большую зону свободного участка, где можно совершенно спокойно выделять память. -> Отсюда рождается идея алгоритма Compacting. +> Отсюда рождается идея алгоритма сжатия кучи Compacting. -Но, подождите, скажите вы. Ведь эта операция может быть очень тяжёлой. Представьте только, что вы освободили объект в самом начале кучи. И что, скажете вы, надо двигать вообще всё?? Ну конечно, можно пофантазировать на тему векторных инструкций CPU, которыми можно воспользоваться для копирования огромного занятого участка памяти. Но это ведь только начало работы. Надо ещё исправить все указатели с полей объектов на объекты, которые подверглись передвижениям. Эта операция может занять дичайше длительное время. Нет, надо исходить из чего-то другого. Например, разделив весь отрезок памяти кучи на сектора и работать с ними по отдельности. Если работать отдельно в каждом секторе (для предсказуемости и масштабирования этой предсказмуемости - желательно, фиксированных размеров), идея сжатия уже не кажется такой уж тяжёлой: достаточно сжать отдельно взятый сектор и тогда можно даже начать рассуждать о времени, которое необходимо для сжатия одного такого сектора. +Но, подождите, скажите вы. Ведь эта операция может быть очень тяжёлой. Представьте только, что вы освободили объект в самом начале кучи. И что, скажете вы, надо двигать вообще всё?? Ну конечно, можно пофантазировать на тему векторных инструкций CPU, которыми можно воспользоваться для копирования огромного занятого участка памяти. Но это ведь только начало работы. Надо ещё исправить все указатели с полей объектов на объекты, которые подверглись передвижениям. Эта операция может занять дичайше длительное время. Нет, надо исходить из чего-то другого. Например, разделив весь отрезок памяти кучи на сектора и работать с ними по отдельности. Если работать отдельно в каждом секторе (для предсказуемости времени работы алгоритмов и масштабирования этой предсказмуемости - желательно, фиксированных размеров), идея сжатия уже не кажется такой уж тяжёлой: достаточно сжать отдельно взятый сектор и тогда можно даже начать рассуждать о времени, которое необходимо для сжатия одного такого сектора. Теперь осталось понять, на основании чего делить на сектора. Тут надо обратиться ко второй классификации, которая введена на платформе: разделение памяти, исходя из времени жизни отдельных её элементов. -Деление простое: если учесть, что выделять память мы будем по мере возрастания адресов, то первые выделенные объекты становятся самыми старыми, а те, что находятся в старших адресах - самыми молодыми. Далее, проявив смекалку, можно прийти к выводам, что в приложениях объекты делятся на две группы: те, что создали для долгой жизни и те, которые были созданы жить очень мало. Например, для временного хранения указателей на другие объекты в виде коллекции. Или те же DTO объекты. Соответственно, время от времени сжимая кучу мы получаем ряд долгоживущих объектов - в младших адресах и ряд короткоживущих - в старших. +Деление простое: если учесть, что выделять память мы будем по мере возрастания адресов, то первые выделенные объекты (в младших адресах) становятся самыми старыми, а те, что находятся в старших адресах - самыми молодыми. Далее, проявив смекалку, можно прийти к выводам, что в приложениях объекты делятся на две группы: те, что создали для долгой жизни и те, которые были созданы жить очень мало. Например, для временного хранения указателей на другие объекты в виде коллекции. Или те же DTO объекты. Соответственно, время от времени сжимая кучу мы получаем ряд долгоживущих объектов - в младших адресах и ряд короткоживущих - в старших. > Таким образом мы получили *поколения*. -Разделив память на поколения, мы получаем возможность реже заглядывать в объекты старшего поколения, которых становится всё больше и больше. +Разделив память на поколения, мы получаем возможность реже заглядывать за сборкой мусора в объекты старшего поколения, которых становится всё больше и больше. + +Но возникает еще один вопрос: если мы будем иметь всего два поколения, мы получим проблемы: -Но возникает еще один вопрос: если мы будем иметь всего два поколения, мы получим проблемы. Либо мы будем стараться, чтобы GC отрабатывал маскимально быстро: тогда размер младшего поколения мы будем стараться делать минимальных размеров. Как результат - объекты будут случайно проваливаться в старшее поколение (если GC сработал "прям вот сейчас, во время яростного выделения памяти под множество объектов"). Либо, чтобы минимизировать случайное проваливание, мы увеличим размер младшего поколения. Тогда GC на младшем поколении будет работать достаточно долго, замедляя и подтормаживая приложение. + - Либо мы будем стараться, чтобы GC отрабатывал маскимально быстро: тогда размер *младшего поколения мы будем стараться делать минимальных размеров*. Как результат - недавно созданные объекты при вызове GC будут случайно уходить в старшее поколение (если GC сработал "прям вот сейчас, во время яростного выделения памяти под множество объектов"), хотя если бы он сработал чуть позже, они бы остались в младшем, где были бы собраны за короткие сроки. + - Либо, чтобы минимизировать такое случайное "проваливание", мы *увеличим размер младшего поколения*. Однако, в этом случае GC на младшем поколении будет работать достаточно долго, замедляя и подтормаживая тем самым всё приложение. -Выход - введение "среднего" поколения. Подросткового. Другими словами, если дожили до подросткового возраста, велика вероятность дожить до старости. Суть его введения сводится к получению баланса между *получением минимального по размеру младшего поколения* и *максимально-стабильного старшего поколения*, где лучше ничего не трогать. Это - зона, где судьба объектов еще не решена. Первое (не забываем, что мы считаем с нуля) поколение создается также небольшим и GC туда заглядывает реже. GC тем самым дает возможность объектам, которые находятся во временном, первом поколении, не уйти в старшее поколение, которое собирать крайне тяжело. +Выход - введение "среднего" поколения. Подросткового. Суть его введения сводится к получению баланса между *получением минимального по размеру младшего поколения* и *максимально-стабильного старшего поколения*, где лучше ничего не трогать. Это - зона, где судьба объектов еще не решена. Первое (не забываем, что мы считаем с нуля) поколение создается также небольшим, но чуть крупнее, чем младшее и потому GC туда заглядывает реже. Он тем самым дает возможность объектам, которые находятся во временном, "подростковом" поколении, не уйти в старшее поколение, которое собирать крайне тяжело. > Так мы получили идею трёх поколений.