diff --git a/book/ru/Memory/01-00-MemoryManagement-Intro.md b/book/ru/Memory/01-00-MemoryManagement-Intro.md index 942c0d3..ac9af58 100644 --- a/book/ru/Memory/01-00-MemoryManagement-Intro.md +++ b/book/ru/Memory/01-00-MemoryManagement-Intro.md @@ -2,7 +2,7 @@ Когда вы думаете о разработке любого .NET приложения до недавних пор можно было себе позволить считать, что приложение, которое вы делаете, будет всегда работать на одной и той же платформе: это операционная система Windows, запущенная поверх технологического стека Intel. Сейчас же с каждым прожитым днем мы входим в новую эпоху: платформа .NET стала поистине кроссплатформенной, пустив новые корни в сторону всех доступных настольных операционных систем. Это -- прекрасное время и наш долг сейчас не потерять нить и остаться востребованными специалистами. Ведь когда toolset становится кроссплатформенным, это означает что мы обязаны начать смотреть внутрь. Изучать, как работает двигатель нашей платформы. Чтобы понимать, почему тот ведет себя, так или иначе, на различных системах. -Вторая мысль, которую мне хотелось бы передать через страницы данной книги: даже если ваши сервера будут сверхпроизводительными. Даже если они будут настолько быстрыми, что будет казаться, что оптимизации -- последнее дело, о котором стоит думать... Вы всё равно начнёте оптимизировать код. Вспомните: когда-то всем известный системный программист Билл Гейтс сказал, что 640 Кб памяти должно хватить любому. И на тот момент это было правдой. И на тот момент если скажи любому, что через каких-то 20 лет один компьютер сможет заменить несколько тысяч серверов того времени, вам бы сказали: в ваше время производительность настолько высокая, что над оптимизацией наверняка не стоит даже задумываться. Но что мы видим? Всё большее количество людей уходит в онлайн. Всё большее количество людей начинает пользоваться смартфонами и как следствие -- онлайн торговлей. А этим системам чтобы выдерживать всё нарастающие нагрузки хочется сэкономить: арендовывать меньшее количество серверов при всё нарастающих потребностях. И поверьте: эпоха сети Интернет только взяла старт: в прошлое уйдёт население, не принимающее современных технологий, а на смену ему придёт поколение, не знающее другого пути. И даже если серверные системы будущего будут выдерживать нагрузки в 1,000 раз большие.. Конечно же сайты "попроще" могут быть написаны как угодно плохо... Но если ваша цель -- работать в компаниях топового уровня, оптимизацией вы будете заниматься всегда. +Вторая мысль, которую мне хотелось бы передать через страницы данной книги: даже если ваши сервера будут сверхпроизводительными. Даже если они будут настолько быстрыми, что будет казаться, что оптимизации -- последнее дело, о котором стоит думать... Вы всё равно начнёте оптимизировать код. Вспомните: когда-то всем известный системный программист Билл Гейтс сказал, что 640 Кб памяти должно хватить любому. И на тот момент это было правдой. И на тот момент если скажи любому, что через каких-то 20 лет один компьютер сможет заменить несколько тысяч серверов того времени, вам бы сказали: в ваше время производительность настолько высокая, что над оптимизацией наверняка не стоит даже задумываться. Но что мы видим? Всё большее количество людей уходит в онлайн. Всё большее количество людей начинает пользоваться смартфонами и как следствие -- онлайн торговлей. А этим системам чтобы выдерживать всё нарастающие нагрузки хочется сэкономить: арендовать меньшее количество серверов при всё нарастающих потребностях. И поверьте: эпоха сети Интернет только взяла старт: в прошлое уйдёт население, не принимающее современных технологий, а на смену ему придёт поколение, не знающее другого пути. И даже если серверные системы будущего будут выдерживать нагрузки в 1,000 раз большие.. Конечно же сайты "попроще" могут быть написаны как угодно плохо... Но если ваша цель -- работать в компаниях топового уровня, оптимизацией вы будете заниматься всегда. [>]: Касательно этого вопроса, конечно же, может существовать множество мнений. Однако, как опыт так и статистика по зарплатам на различных порталах говорит нам о том, что вектор развития индустрии именно такой. @@ -16,6 +16,6 @@ Первым делом мы обоснуем алгоритмистику управления памятью внутри кучи .NET. Т.е. докажем, что она построена правильно. Это будет как часть введения, тренировки ума и программа принятия того, до чего у многих не доходили руки. Далее мы проследуем в описание особенностей устройства и организации работы с памятью. Зачем? Слово "Организация" имеет цену. Выделить память - время. Освободить память - время. На всё нужно время: и прежде всего время нужно нам самим. Жаль, у профилировщиков нет метрики: сколько времени работал Garbage Collector. Я думаю, эта цифра бы заставила многих задуматься. И, нет. Вовсе не о смене языка или платформы. Тут у нас как раз-таки всё просто замечательно. А о том, как можно работать иначе. Чтобы это время стало нашим. -Язык не даёт производительности. Производительность даёт компилятор. Знаете.. Не испротить можно, пожалуй, только язык `asm`. Потому что это - язык процессора. А вот всё что выше можно написать как аккуранто относясь к деталям, так и в качестве студенческого проекта "чтобы не влепили неуд". Второе, что даёт производительность - наш ум. Если вы влепим в цикл `LINQ` выражение, создающее, например, по 4 аллокации благодаря использованию замыкания, то нечего удивляться, что на 100,000 итераций цикла создаётся около 4-6 Мб траффика из ниоткуда + 1-2 GC. Если переписать под `for()`, то картина в корне поменяется. Другими словами, профессионал знает инструмент, которым он работает. Проблематику некоторых аспектов языка программирования, платформы, операционной системы и оборудования. +Язык не даёт производительности. Производительность даёт компилятор. Знаете.. Не испортить можно, пожалуй, только язык `asm`. Потому что это - язык процессора. А вот всё что выше можно написать как аккуратно относясь к деталям, так и в качестве студенческого проекта "чтобы не влепили неуд". Второе, что даёт производительность - наш ум. Если вы влепим в цикл `LINQ` выражение, создающее, например, по 4 аллокации благодаря использованию замыкания, то нечего удивляться, что на 100,000 итераций цикла создаётся около 4-6 Мб траффика из ниоткуда + 1-2 GC. Если переписать под `for()`, то картина в корне поменяется. Другими словами, профессионал знает инструмент, которым он работает. Проблематику некоторых аспектов языка программирования, платформы, операционной системы и оборудования. Далее мы поговорим про практику. В разделе практики мы будем учиться писать максимально производительный код. Будем выжимать всё возможное из тех инструментов, которые у нас есть и учиться применять их тогда, когда это делать своевременно. diff --git a/book/ru/Memory/01-02-MemoryManagement-Basics.md b/book/ru/Memory/01-02-MemoryManagement-Basics.md index 18feb34..6704c6a 100644 --- a/book/ru/Memory/01-02-MemoryManagement-Basics.md +++ b/book/ru/Memory/01-02-MemoryManagement-Basics.md @@ -68,7 +68,7 @@ while(current < memory_end) > Отсюда рождается идея алгоритма сжатия кучи Compacting. -Но, подождите, скажите вы. Ведь эта операция может быть очень тяжёлой. Представьте только, что вы освободили объект в самом начале кучи. И что, скажете вы, надо двигать вообще всё?? Ну конечно, можно пофантазировать на тему векторных инструкций CPU, которыми можно воспользоваться для копирования огромного занятого участка памяти. Но это ведь только начало работы. Надо ещё исправить все указатели с полей объектов на объекты, которые подверглись передвижениям. Эта операция может занять дичайше длительное время. Нет, надо исходить из чего-то другого. Например, разделив весь отрезок памяти кучи на сектора и работать с ними по отдельности. Если работать отдельно в каждом секторе (для предсказуемости времени работы алгоритмов и масштабирования этой предсказмуемости -- желательно, фиксированных размеров), идея сжатия уже не кажется такой уж тяжёлой: достаточно сжать отдельно взятый сектор и тогда можно даже начать рассуждать о времени, которое необходимо для сжатия одного такого сектора. +Но, подождите, скажите вы. Ведь эта операция может быть очень тяжёлой. Представьте только, что вы освободили объект в самом начале кучи. И что, скажете вы, надо двигать вообще всё?? Ну конечно, можно пофантазировать на тему векторных инструкций CPU, которыми можно воспользоваться для копирования огромного занятого участка памяти. Но это ведь только начало работы. Надо ещё исправить все указатели с полей объектов на объекты, которые подверглись передвижениям. Эта операция может занять дичайше длительное время. Нет, надо исходить из чего-то другого. Например, разделив весь отрезок памяти кучи на сектора и работать с ними по отдельности. Если работать отдельно в каждом секторе (для предсказуемости времени работы алгоритмов и масштабирования этой предсказуемости -- желательно, фиксированных размеров), идея сжатия уже не кажется такой уж тяжёлой: достаточно сжать отдельно взятый сектор и тогда можно даже начать рассуждать о времени, которое необходимо для сжатия одного такого сектора. Теперь осталось понять, на основании чего делить на сектора. Тут надо обратиться ко второй классификации, которая введена на платформе: разделение памяти, исходя из времени жизни отдельных её элементов. @@ -97,7 +97,7 @@ while(current < memory_end) ## Как это работает у нас -Теперь мы зайдём с другой стороны. Я буду выполировывать факты так, что даже если у вас плохая память на вычитке текста, вы всё равно запомните, как работает менеджмент памяти в .NET. +Теперь мы зайдём с другой стороны. Я буду выполировать факты так, что даже если у вас плохая память на вычитке текста, вы всё равно запомните, как работает менеджмент памяти в .NET. Итак, мы знаем, что у нас существует два способа классифицировать память: исходя из времени жизни сущностей и исходя из их размера. Подумаем, что мы имеем, исходя из размеров объектов. Если у нас объекты имеют большие размеры, то нам не выгодно часто делать `Сompact`. Потому что в этом случае мы все объекты перетаскиваем на освободившиеся участки. То есть копируем их. А если объект огромен, то копировать дорого и каждый раз прибегая к сжатию кучи можно сильно просесть по производительности. В данной ситуации удобен только `Sweep`. Об этом способе мы подробно поговорим позже, но если совсем коротко, то память освобождается и свободный кусок сохраняется в список свободных участков и дальше переиспользуется при выделении объектов. Вызов `Compact` может быть выгоден в редких случаях: когда *есть понимание*, что из-за `Sweep` и траффика буферов большого размера существует некоторая фрагментация в зоне крупных объектов (Large Objects Heap. Давайте уже начнём называть вещи своими именами). И ручной вызов GC с указанием метода сбора мусора `Compact` в этом случае может нам помочь. Однако, давайте не будем углубляться: у нас для этого есть очень много времени. @@ -152,7 +152,7 @@ var arr2 = new double[1000]; // -> Gen 2 Если учесть, что каждый пустой объект, максимально маленький (например, new Object) занимает четыре машинных слова в среднем, то получается, что один бит карточного стола перекрывает десять объектов. И если хотя бы с одного из них есть ссылка в младшее поколение, то GC должен при обходе в фазе маркировки зайти по этому адресу и просмотреть все десять объектов и найти те, которые ссылаются на младшее поколение. Один байт перекрывает уже 1,2 КБ оперативной памяти. Или 80 объектов. Четыре байта -- 320 объектов. Это x86 архитектура. Получается жирновато, если ссылка появилась. -Как это работает. Можно посмотреть код, который будет вызван при присваивании (см. слайд 43:24) по этому адресу на github. Там ассемблеровский код, он достаточно простой, разобраться в нем легко. Есть много комментариев, гораздо больше, чем кода. +Как это работает. Можно посмотреть код, который будет вызван при присваивании (см. слайд 43:24) по этому адресу на github. Там ассемблерный код, он достаточно простой, разобраться в нем легко. Есть много комментариев, гораздо больше, чем кода. Реализация сильно зависит от особенностей. У нас есть Workstation GC, есть серверный GC. У Workstation есть две версии: до и после роста кучи. Как следствие, перемещается gen_1 gen_0. И Server GC: есть несколько хипов для SOH и несколько для LOH. Там свои реализации этих методов присваивания, потому что придется параметризовать для какой кучи идет вызов. А так он просто генерирует ставку для новой кучи и все хорошо. Плюс две реализации под x64. Если смотреть базовую, то будет примерно так (см. слайд 44:36). Регистр RCX -- это адрес filed. Адрес таргета, куда мы присваиваем. RDX -- это ссылка на объект. Когда мы присваивали, должна быть составлена эта инструкция и больше ничего. Но на самом деле нет. Присвоили и дальше этим кодом мы проверяем, находится ли правая часть присваивания внутри эфемерного сегмента (gen_0, gen_1). И если находится, то мы берем карточный стол, делим на 2048, получаем адрес ячейки и если флаг не выставлен, то выставить. diff --git a/book/ru/Memory/01-04-MemoryManagement-ThreadStack.md b/book/ru/Memory/01-04-MemoryManagement-ThreadStack.md index 921887d..e11673f 100644 --- a/book/ru/Memory/01-04-MemoryManagement-ThreadStack.md +++ b/book/ru/Memory/01-04-MemoryManagement-ThreadStack.md @@ -187,7 +187,7 @@ ThreadPool -> MakeFork ![](./imgs/ThreadStack/step5.png) -Последним шагом, очень аккуратно, задействуя минимальное количество регистров, копируем наш массив в конец стека дочернего потока, после чего сдвигаем регистры ESP и EBP на новые места. С точки зрения стека мы сымитировали вызов всех этих методов: +Последним шагом, очень аккуратно, задействуем минимальное количество регистров, копируем наш массив в конец стека дочернего потока, после чего сдвигаем регистры ESP и EBP на новые места. С точки зрения стека мы сымитировали вызов всех этих методов: ![](./imgs/ThreadStack/step6.png) @@ -288,7 +288,7 @@ int AdvancedThreading_Unmanaged::ForkImpl() StackInfo* info; ``` -Первым делом, до того как произойдёт хоть какая-то операция, чтобы не получить запорченные регистры, мы их копируем локально. Также дополнительно необходимо сохранить адрес кода, куда будет сделан `goto`, когда в дочернем потоке стек будет сымитирован, и необходимо будет произвести процеруду выхода из `CloneThread` из дочернего потока. В качестве "точки выхода" мы выбираем `JmpPointOnMethodsChainCallEmulation` и не просто так: после операции сохранения этого адреса "на будущее" мы дополнительно закладываем в стек число 0. +Первым делом, до того как произойдёт хоть какая-то операция, чтобы не получить запорченные регистры, мы их копируем локально. Также дополнительно необходимо сохранить адрес кода, куда будет сделан `goto`, когда в дочернем потоке стек будет сымитирован, и необходимо будет произвести процедуру выхода из `CloneThread` из дочернего потока. В качестве "точки выхода" мы выбираем `JmpPointOnMethodsChainCallEmulation` и не просто так: после операции сохранения этого адреса "на будущее" мы дополнительно закладываем в стек число 0. ```csharp // Save ALL registers @@ -359,7 +359,7 @@ NonClonned: memcpy(arr, (void*)localsStart, localsEnd - localsStart); ``` -Ещё одна задача, которую надо будет решить, -- это исправление адресов переменных, которые попали на стек и при этом указывающих на стек. Для решения этой проблемы мы получаем диапазон адресов, которые нам выделила операционная система под стек потока. Сохраняем полученную информацию и запукаем вторую часть процесса клонирования, запланировав делегат в пул потоков: +Ещё одна задача, которую надо будет решить, -- это исправление адресов переменных, которые попали на стек и при этом указывающих на стек. Для решения этой проблемы мы получаем диапазон адресов, которые нам выделила операционная система под стек потока. Сохраняем полученную информацию и запускаем вторую часть процесса клонирования, запланировав делегат в пул потоков: ```csharp // Get information about stack pages @@ -390,7 +390,7 @@ void AdvancedThreading_Unmanaged::InForkedThread(StackInfo * stackCopy) StackInfo copy; ``` -Первым делом мы сохраняем значения рабочих регистров, чтобы, когда `MakeFork` завершит свою работу, мы смогли их безболезненно восставновить. Чтобы в дальнейшем минимально влиять на регистры, мы выгружаем переданные нам параметры к себе на стек. Доступ к ним будет идти только через `SS:ESP`, что для нас будет предсказуемым. +Первым делом мы сохраняем значения рабочих регистров, чтобы, когда `MakeFork` завершит свою работу, мы смогли их безболезненно восстановить. Чтобы в дальнейшем минимально влиять на регистры, мы выгружаем переданные нам параметры к себе на стек. Доступ к ним будет идти только через `SS:ESP`, что для нас будет предсказуемым. ```csharp short CS_EIP[3]; @@ -495,7 +495,7 @@ void AdvancedThreading_Unmanaged::InForkedThread(StackInfo * stackCopy) pop EAX ``` -А вот теперь самое время вспомнить тот странный код с закладыванием `0` в стек и проверки на `0`. В этом потоке мы закладываем `1` и делаем дальний jmp в код метода `ForkImpl`. Ведь по стеку мы находимся именно там, а реально все ещё тут. Когда мы туда попадём, то `ForkImpl` распознает смену потока и осуществит выход в метод `MakeFork`, который, завершив работу, попадёт в точку `RestorePointAfterClonnedExited`, т.к. немного ранее мы сымтировали вызов `MakeFork` из этой точки. Восстановив регистры до состояния "только что вызваны из TheadPool", мы завершаем работу, отдавая поток в пул потоков. +А вот теперь самое время вспомнить тот странный код с закладыванием `0` в стек и проверки на `0`. В этом потоке мы закладываем `1` и делаем дальний jmp в код метода `ForkImpl`. Ведь по стеку мы находимся именно там, а реально все ещё тут. Когда мы туда попадём, то `ForkImpl` распознает смену потока и осуществит выход в метод `MakeFork`, который, завершив работу, попадёт в точку `RestorePointAfterClonnedExited`, т.к. немного ранее мы сымитировали вызов `MakeFork` из этой точки. Восстановив регистры до состояния "только что вызваны из TheadPool", мы завершаем работу, отдавая поток в пул потоков. ```csharp push 1 @@ -524,7 +524,7 @@ RestorePointAfterClonnedExited: Если мы заглянем краем глаза на ещё более низкий уровень, то узнаем или же вспомним, что память на самом деле является виртуальной и что она поделена на страницы объёмом 8 или 4 Кб. Каждая такая страница может физически существовать или же нет. А если она существует, то может быть отображена на файл или же реальную оперативную память. Именно этот механизм виртуализации позволяет приложениям иметь раздельную друг от друга память и обеспечивает уровни безопасности между приложением и операционной системой. При чем же здесь стек потока? Как и любая другая оперативная память приложения стек потока является её частью и также состоит из страниц объёмом 4 или 8 Кб. По краям от выделенного для стека пространства находятся две страницы, доступ к которым приводит к системному исключению, нотифицирующему операционную систему о том, что приложение пытается обратиться в невыделенный участок памяти. Внутри этого региона реально выделенными участками являются только те страницы, к которым обратилось приложение: т.е. если приложение резервирует под поток 2Мб памяти, это не значит, что они будут выделены сразу же. Отнюдь, они будут выделены по требованию: если стек потока вырастет до 1 Мб, это будет означать, что приложение получило именно 1 Мб оперативной памяти под стек. -Когда приложение резервирует память под локальные переменные, то происходят две вещи: наращивается значение регистра ESP и зануляется память под сами переменные. Поэтому, когда вы напишете рекурсивный метод, который уходит в бесконечную рекурсию, вы получите StackOverflowException: заняв всю выделенную под стек память (весь доступный регион), вы напоритесь на специальную страницу, Guard Page, доступ к которой вызовет нотификацию операционной системы, которая инициирует StackOverflow уровня ОС, которое уйдёт в .NET, будет перехвачено и выбросется исключение StackOverflowException для .NET приложения. +Когда приложение резервирует память под локальные переменные, то происходят две вещи: наращивается значение регистра ESP и зануляется память под сами переменные. Поэтому, когда вы напишете рекурсивный метод, который уходит в бесконечную рекурсию, вы получите StackOverflowException: заняв всю выделенную под стек память (весь доступный регион), вы напоритесь на специальную страницу, Guard Page, доступ к которой вызовет нотификацию операционной системы, которая инициирует StackOverflow уровня ОС, которое уйдёт в .NET, будет перехвачено и выброситься исключение StackOverflowException для .NET приложения. ## Выделение памяти на стеке: stackalloc diff --git a/book/ru/Memory/01-06-MemoryManagement-EntitiesLifetime.md b/book/ru/Memory/01-06-MemoryManagement-EntitiesLifetime.md index 0192859..5e1a250 100644 --- a/book/ru/Memory/01-06-MemoryManagement-EntitiesLifetime.md +++ b/book/ru/Memory/01-06-MemoryManagement-EntitiesLifetime.md @@ -22,7 +22,7 @@ - поэтому с одной стороны это значит, что операция освобождения последней ссылки на объект превращается в детерминированную операцию "удаления" объекта из зоны видимости приложения. Объект ещё существует, но недосягаем для всего остального приложения; - однако, с другой стороны мы далеко не всегда в курсе, какое именно обнуление ссылки будет последним, что лишает нас свойства детерминированности в обнулении последней ссылки. -Еще одним очень важным свойством является наличие виртуального метода финализации объекта. Этот метод вызывается во время срабатывания сборщика мусора: т.е. в некотором неопределенном будущем. И необходим данный метод для одного: корректного закрытия неуправляемых ресурсов, которыми владеет объект в тех и только тех случаях, когда что-то пошло не так (например, было выброшено необработанное исключение, сломавшее возможность дойти до вызова `Dispose()`) и программа более не сможет самостоятено это сделать (код, который отвечает за освобождение данных ресурсов никогда более не вызовется вследствие срабатывания исключительной ситуации). И, поскольку время вызова данного метода ровно как и освобождение памяти из под объекта от нас не зависят, его вызов также не является детерминированным. Мало того, он является асинхронным, т.к. осуществяется в отдельном потоке во время исполнения приложения. Это важно помнить, т.к. если, например, ваше приложение имеет логику повторной попытки работы с ресурсом и если произошла какая-то ошибка (например, `ThreadAbortException`), в результате которой по результатам предыдущей попытки не был вызван `Dispose()` и ресурсы "повисли" в очереди на финализацию, то это значит, что вы не сможете открыть этот ресурс (например, файл), пока не отработает очередь на финализацию, в которой этот ресурс будет освобождён. +Еще одним очень важным свойством является наличие виртуального метода финализации объекта. Этот метод вызывается во время срабатывания сборщика мусора: т.е. в некотором неопределенном будущем. И необходим данный метод для одного: корректного закрытия неуправляемых ресурсов, которыми владеет объект в тех и только тех случаях, когда что-то пошло не так (например, было выброшено необработанное исключение, сломавшее возможность дойти до вызова `Dispose()`) и программа более не сможет самостоятельно это сделать (код, который отвечает за освобождение данных ресурсов никогда более не вызовется вследствие срабатывания исключительной ситуации). И, поскольку время вызова данного метода ровно как и освобождение памяти из под объекта от нас не зависят, его вызов также не является детерминированным. Мало того, он является асинхронным, т.к. осуществляется в отдельном потоке во время исполнения приложения. Это важно помнить, т.к. если, например, ваше приложение имеет логику повторной попытки работы с ресурсом и если произошла какая-то ошибка (например, `ThreadAbortException`), в результате которой по результатам предыдущей попытки не был вызван `Dispose()` и ресурсы "повисли" в очереди на финализацию, то это значит, что вы не сможете открыть этот ресурс (например, файл), пока не отработает очередь на финализацию, в которой этот ресурс будет освобождён. > Однако, там где есть неопределенность, программисту всегда хочется внести определенность и как результат, возник интерфейс `IDisposable`, речь о котором пойдет чуть позже, в следующей главе. Я сейчас могу сказать лишь одно: он реализуется если необходимо, чтобы внешний код мог самостоятельно отдать команду на освобождение ресурсов объекта. Т.е. детерминированно сообщить объекту, что он более не нужен. @@ -30,7 +30,7 @@ Однако, если заглянуть с другой стороны: что такое начало и конец времени жизни сущности? Мир программного обеспечения - мир виртуального, который обусловлен законами, созданными нами самими. Т.е. другими словами, время начало жизни сущности -- это время, которое сообщили нам, что сущностью можно пользоваться. Правильно? Кто нам мешает начать управлять этим поведением? Давайте введем перерождение сущности: когда, закончив своё существование, сущность возродится для переиспользования кем-то другим. -Начало времени жизни - по идее - это когда под объект выделена память и когда он переведен в корректное состояние. Заходя с конца процесса инициализации, как с наиболее понятного нам с вами, переводом в корректное состояние объекта занимается конструктор объекта. Именно он инициализирует все пустые поля осмысленными значениями, которые наделяют его жизнью. Но что такое "выделение памяти"? В терминологии оператора `new` языка C# - это запрос адреса оперативной памяти, который размечен под объект определенного типа. Т.е. выделение в некотором внешнем массиве (не более того) памяти под поля объекта + его системные поля (`SyncBlockIndex`, VMT) и отдача адреса этого участка коду вашей программы. Другими словами... это чисто программная вещь - выделить вам память под что-то. Резальтат точно таких же алгоритмов, которые мы с вами пишем ежедневно. +Начало времени жизни - по идее - это когда под объект выделена память и когда он переведен в корректное состояние. Заходя с конца процесса инициализации, как с наиболее понятного нам с вами, переводом в корректное состояние объекта занимается конструктор объекта. Именно он инициализирует все пустые поля осмысленными значениями, которые наделяют его жизнью. Но что такое "выделение памяти"? В терминологии оператора `new` языка C# - это запрос адреса оперативной памяти, который размечен под объект определенного типа. Т.е. выделение в некотором внешнем массиве (не более того) памяти под поля объекта + его системные поля (`SyncBlockIndex`, VMT) и отдача адреса этого участка коду вашей программы. Другими словами... это чисто программная вещь - выделить вам память под что-то. Результат точно таких же алгоритмов, которые мы с вами пишем ежедневно. Мы можем сделать новый слой управления памятью. Где не будет сборок мусора, а окончание жизни объекта станет детерминированным - для нас. Этот способ называется по-разному. Чаще всего - pooling. Реже - кэшированием. Т.е. использование коллекций объектов, их выдача по запросу и забор обратно в коллекцию, когда объект более не нужен. @@ -60,7 +60,7 @@ public static class ObjectsPool where T : class, new() } ``` -Этот пул - максимально простой пул "без обязательств". Т.е. объекты, выданные им вовсе не обязательно возвращать в него. Если не вернуть, ничего страшного не произойдёт: они просто будут собраны GC. Минус у него один: `ObjectsPool.Get()` вернёт "грязный" объект. Т.е. уже кем-то проинициализированный. А для того чтобы он был очищен, придётся делать реализацию `IDisposable`. Но поскольку мы пока что рассматриваме теорию, останавливаться на этом пока не будем. +Этот пул - максимально простой пул "без обязательств". Т.е. объекты, выданные им вовсе не обязательно возвращать в него. Если не вернуть, ничего страшного не произойдёт: они просто будут собраны GC. Минус у него один: `ObjectsPool.Get()` вернёт "грязный" объект. Т.е. уже кем-то проинициализированный. А для того чтобы он был очищен, придётся делать реализацию `IDisposable`. Но поскольку мы пока что рассматриваем теорию, останавливаться на этом пока не будем. Зато он демонстрирует новую возможность: детерминированное время начала жизни сущности (когда надо - запросили из пула), детерминированное окончание (когда больше не нужен - вернули обратно в пул) и отсутствие сборки мусора (ссылка есть либо из пула либо у вас - пока объект в работе). Помимо всего прочего если вы забыли вернуть объект в пул ничего страшного не случится: он будет собран GC. @@ -90,7 +90,7 @@ var token = default(CancellationToken); Здесь `token` просто проинициализирован нулями. А значит, на `m_source == null` можно опираться как на токен, который никогда не перейдёт в состояние `IsCancellationRequested == true`. -Уход структуры из жизни происходит вместе с уходом из жизни хранящей её сущности: фрейма вызова метода, когда проиходит выход из метода, или же класса, когда тот теряет свою последнюю ссылку. +Уход структуры из жизни происходит вместе с уходом из жизни хранящей её сущности: фрейма вызова метода, когда происходит выход из метода, или же класса, когда тот теряет свою последнюю ссылку. ### В защиту текущего подхода платформы .NET @@ -106,7 +106,7 @@ var token = default(CancellationToken); Из всего сказанного можно увидеть, что у любого объекта есть некоторое время его существования. Это может показаться тривиальной мыслью, которая лежит на поверхности, но не все так однозначно: - - важно понимать, как, когда и при каких иных условиях *объект создается*. Ведь его создание занимает некоторое не всегда короткое время. И вы не можете заранее угадать, по какому алгоритму он будет создан: простым переносом указателя в случае наличия места в allocation context, вследcтвии необходимости расширения или переноса allocation context в памяти, необходимости сжатия эфимерного сегмента или же необходимости создания нового эфимерного сегмента с его полным структурированием. Возможно, при большом траффике таких объектов стоит задуматься над пуллингом таких объектов. Тогда время их создания станет *предсказуемым*, а GC не будет обращать на них слишком много внимания; + - важно понимать, как, когда и при каких иных условиях *объект создается*. Ведь его создание занимает некоторое не всегда короткое время. И вы не можете заранее угадать, по какому алгоритму он будет создан: простым переносом указателя в случае наличия места в allocation context, вследствие необходимости расширения или переноса allocation context в памяти, необходимости сжатия эфимерного сегмента или же необходимости создания нового эфимерного сегмента с его полным структурированием. Возможно, при большом траффике таких объектов стоит задуматься над пуллингом таких объектов. Тогда время их создания станет *предсказуемым*, а GC не будет обращать на них слишком много внимания; - также стоит понимать, насколько "популярным" будет объект *во время его жизни* и как долго он будет существовать: какое количество иных объектов будет на него ссылаться и как долго. Этот фактор влияет как на сборку мусора, фрагментацию кучи, время создания других объектов и что самое интересное -- на время обхода графа объектов в фазе маркировки достижимых объектов сборщиком мусора. Например, при том же пуллинге, объекты, которые там долго находятся, уходят во второе поколение на веки вечные. Но при инициализации они могут получить ссылки на младшее поколение и тогда включается механизм карт при сборке мусора. Если пул заполняется по требованию (как в нашем простом примере чуть выше по тексту), его объекты будут перемешаны с объектами, созданными другим кодом. Что замедлит анализ ссылок в младшие поколения. - а также, что логично и очень важно: как объект будет *достигать* состояния освобождения (состояние выброшенности звучит грустно). Это значит, будет ли осуществляться детерминированное его разрушение или нет. Например, при помощи `IDisposable.Dispose` - и освобождаться -- быть подхваченным Garbage Collector'ом с дальнейшей возможностью вызова финализатора. diff --git a/book/ru/Memory/03-02-MemoryManagement-Allocation.md b/book/ru/Memory/03-02-MemoryManagement-Allocation.md index 3c86cfc..db65e5b 100644 --- a/book/ru/Memory/03-02-MemoryManagement-Allocation.md +++ b/book/ru/Memory/03-02-MemoryManagement-Allocation.md @@ -6,15 +6,15 @@ Управление памятью .NET разработчика интересует со стороны двух основных процессов: первое - это выделить память, второе - освободить. Про выделение памяти мы сейчас и поговорим. -С точки зрения процессора память выглядит несколько сложнее. Если говорит про архитектуру управления памяти, я бы сказал, что она разделена на слои. Первый слой, который видим мы, слой .NET достаточно сложный, но верхоуровневый. То есть он в любом случае опирается на что-то другое, а именно на слои управления памятью Windows, который в свою очередь запущен на каком-то процессоре. Процессор по-своему интерпретирует эту память. Таким образом, у нас фактически существует три слоя управления памятью. +С точки зрения процессора память выглядит несколько сложнее. Если говорит про архитектуру управления памяти, я бы сказал, что она разделена на слои. Первый слой, который видим мы, слой .NET достаточно сложный, но верхнеуровневый. То есть он в любом случае опирается на что-то другое, а именно на слои управления памятью Windows, который в свою очередь запущен на каком-то процессоре. Процессор по-своему интерпретирует эту память. Таким образом, у нас фактически существует три слоя управления памятью. -Память с точки зрения процессора - это планка, физическая память, RAM. Сколько планок мы поставили, столько он и видит. Но у нас есть знания, что каждый запущенный в системе Windows процесс изолирован. И кроме себя он больше ничего не видит. Есть сам процесс, winapi и больше ничего вообще. Word не видит Exel. Exel не видит калькулятор. Все они изолированы полностью. Потому что на архитектуре Intel сделана виртуализация памяти. +Память с точки зрения процессора - это планка, физическая память, RAM. Сколько планок мы поставили, столько он и видит. Но у нас есть знания, что каждый запущенный в системе Windows процесс изолирован. И кроме себя он больше ничего не видит. Есть сам процесс, winapi и больше ничего вообще. Word не видит Excel. Excel не видит калькулятор. Все они изолированы полностью. Потому что на архитектуре Intel сделана виртуализация памяти. Как она работает. Есть таблицы глобальных дескрипторов, которые мы не видим, есть таблицы локальных дескрипторов, которые куда-то отсылаются. И все вместе это ссылается на Page Frame. -У любого процесса есть некий диапазон памяти от нуля и до, например, 4Гб на x32 системе и до условной бесконечности на x64. Это диапазон виртуальной памяти. Процессор создает для конкретной программы некий виртуальный кусок, где есть только она. Эта область поделена на страницы: на кусочки по 4Кб. Каждый их них означает, что память в этом месте либо существует, либо ее там не существует, т.е. она физически в этом месте отсутствует полностью. Адрес есть, а памяти там нет. Это возникает в ситуациях, когда планка, например на 4Гб, вставлена в материнскую плату, а процессов у нас запущена сотня и Windows 32х разрядный. Значит, что у каждой программы 4Гб памяти, но при этом они друг от друга изолированы. +У любого процесса есть некий диапазон памяти от нуля и до, например, 4Гб на x32 системе и до условной бесконечности на x64. Это диапазон виртуальной памяти. Процессор создает для конкретной программы некий виртуальный кусок, где есть только она. Эта область поделена на страницы: на кусочки по 4Кб. Каждый их них означает, что память в этом месте либо существует, либо ее там не существует, т.е. она физически в этом месте отсутствует полностью. Адрес есть, а памяти там нет. Это возникает в ситуациях, когда планка, например на 4Гб, вставлена в материнскую плату, а процессов у нас запущена сотня и 32x разрядная Windows. Значит, что у каждой программы 4Гб памяти, но при этом они друг от друга изолированы. -Как 4 Гб поделить на сто и получить 4 каждому? Никак. Это значит, что у каждой программы на четырехгигабайтном участке есть места, где памяти не вообще, там только выделены кусочки адресов. Но где-то память есть и она замаплена на планку физическую. А если не замаплена - памяти нет. Если туда обратиться, что-то попытаться считать, вы получите не ноль, а AccessViolationException. Исключение, которое говорит о том, что вы пытаетесь работать с куском, который не существует, либо на который у вас нет прав. На данном уровне исключение различий не делает. +Как 4 Гб поделить на сто и получить 4 каждому? Никак. Это значит, что у каждой программы на четырехгигабайтном участке есть места, где памяти нет вообще, там только выделены кусочки адресов. Но где-то память есть и она замаплена на планку физическую. А если не замаплена - памяти нет. Если туда обратиться, что-то попытаться считать, вы получите не ноль, а AccessViolationException. Исключение, которое говорит о том, что вы пытаетесь работать с куском, который не существует, либо на который у вас нет прав. На данном уровне исключение различий не делает. Точно также работать с Wap. (см. слайд 06:30). На изображении выделены процессы в виде столбиков. Там, где белый шум - память выделена и заполнена. А там, где пропуски - памяти нет. Выделенные области могут быть замаплены как на физическую память, так и на жесткий диск. @@ -50,7 +50,7 @@ Когда мы используем best-fit, нам нужно найти наилучший участок. Мы пробегаем по всем участкам, и запоминаем то, что мы прошли. Выполняется линейный поиск и наш участок может оказаться в конце. Но зато на выходе мы получаем минимальную фрагментацию - это здорово. Второй алгоритм - это first-fit. Это альтернатива. Мы идем вперед по списку и берем первый же участок, который нам подошел. Он может быть таким же по размеру, либо больше или существенно больше требуемого. Работает это быстро, но может сильно фрагментировать память. В .NET используется смесь этих подходов, организуется список свободных участков через корзины - бакеты. У каждого поколения есть список бакетов. Они группируют память по размеру от малого к большому. Бакет ссылается на односвязаный список свободных участков. Если посмотреть вниз, то от Head на свободный участок встали и дальше по односвязному списку можем идти дальше, перечисляя все свободные участки, которые относятся к этому бакету. Напомню, они организованы по размеру. И в данном бакете находятся участки с определенным диапазоном размеров. В следующем бакете будут свои диапазоны. -Бакет - это first-fit, среди бакетов выбираем первый, который подходит, уходим внутрь этой корзины и там, по однозвязному списку мы идем по best-fit. Таким образом мы делаем очень хорошую оптимизацию. +Бакет - это first-fit, среди бакетов выбираем первый, который подходит, уходим внутрь этой корзины и там, по одноcвязному списку мы идем по best-fit. Таким образом мы делаем очень хорошую оптимизацию. Что такое бакеты? Эта табличка (см. слайд 22:05) нам раскрывает глаза. Тут вспомним разницу между SOH и LOH: LOH организован только по принципу sweep collection, сжатие кучи там происходит только по запросу. Само по себе сжатие кучи там не запустится никогда. В SOH идет смесь. @@ -82,7 +82,7 @@ ## Выделение памяти в LOH -В хипе больших объектов мы пытаемся найти неиспользуемую память, повторяя для каждого эфемерного сегмента LOH, потому что тут их может быть несколько, в зависимости от того, в каком режиме работает платоформа .NET. Поскольку в LOH по умолчанию у нас нет сжатия кучи, то управление памятью тут упрощено. Сжатие кучи - это очень жирная операция, которая требует больших вычислительных процессов. Но в LOH эта операция проводится только по запросу, а значит программист исходя из знаний о работе алгоритмов собственной программы, решил, что в данное время он готов потратить неизвестно сколько времени на сжатие огромного пространства. А значит, можно сильно упростить алгоритмы по работе с LOH. +В хипе больших объектов мы пытаемся найти неиспользуемую память, повторяя для каждого эфемерного сегмента LOH, потому что тут их может быть несколько, в зависимости от того, в каком режиме работает платформа .NET. Поскольку в LOH по умолчанию у нас нет сжатия кучи, то управление памятью тут упрощено. Сжатие кучи - это очень жирная операция, которая требует больших вычислительных процессов. Но в LOH эта операция проводится только по запросу, а значит программист исходя из знаний о работе алгоритмов собственной программы, решил, что в данное время он готов потратить неизвестно сколько времени на сжатие огромного пространства. А значит, можно сильно упростить алгоритмы по работе с LOH. Для каждого эфемерного сегмента мы ищем участок памяти в списке свободных, пытаемся увеличить, если не получилось найти. Если не получилось увеличить - пытаемся подкоммитить зарезервированную часть. Следующим шагом запускаем GC, возможно несколько раз. И если совсем ничего не выходит - дергаем OutOfMemoryException. В данном подходе нет слова "сжатие", мы идем по простому пути.