✍️ Статья Сборка мусора. Разбираем мифы об автоматическом управлении памятью

The 146X Project Dublikat Web Studio Avram Lincoln AL Service Navigator Knyaz

BlackPope

Команда форума
Модератор DeepWeb ✔️
PR-Group DeepWeb 🔷
Регистрация
27.04.2020
Сообщения
230
Когда читаешь дискуссии между сторонниками и противниками автоматического управления памятью, может сложиться впечатление, будто это какая‑то единая технология, одинаково реализованная во всех языках программирования. В этой статье мы поговорим о сборке мусора — наиболее распространенном механизме управления памятью.

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

ОШИБКИ УПРАВЛЕНИЯ ПАМЯТЬЮ​


Для начала вспомним, от чего нас спасает автоматическое управление памятью.

Первая и самая известная, но при этом не самая опасная — утечка памяти (memory leak). Утечка происходит, если запросить у ядра ОС память и забыть ее вернуть. В терминах языка С — вызвать malloc() и забыть free(). Программа с этой проблемой будет занимать все больше и больше памяти, пока ее не остановит пользователь или сама ОС. Поведение программы при этом остается корректным, и проблем с безопасностью утечки не вызывают.

Вторая проблема — висячие указатели (dangling pointers). Суть проблемы в том, что в программе остается указатель на участок памяти, который уже был освобожден. Для повторного обращения к такой памяти есть отдельный термин — use after free. Такие ошибки гораздо опаснее, и последствия могут быть самыми разными: от сложных в отладке глюков до возможности выполнить произвольный код — база CVE не даст соврать.

Более редкий вариант проблемы с висячим указателем — повторное освобождение (double free), которое уничтожает полезные данные.

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

ЧТО ДЕЛАЕТ СБОРЩИК МУСОРА?​


Упрощенно можно сказать, что при запуске у программы есть непрерывный диапазон адресов, куда она может поместить свои данные. Программа с автоматическим управлением памятью сразу при запуске запрашивает у ОС область памяти под «кучу» (heap). Начальный размер кучи часто (но не всегда) можно настроить во время компиляции или выполнения. При выполнении программы размер кучи может расти.

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

СБОРЩИК МУСОРА — ЧАСТЬ ЯЗЫКА?​


Часто можно услышать утверждения вроде «Ruby — язык со сборкой мусора» или «С — язык с ручным управлением памятью». Первое утверждение верно в том смысле, что ни одна реализация Ruby не предоставляет возможность управлять памятью вручную.

Со вторым утверждением сложнее. Сборка мусора не входит в спецификацию языка С. Тем не менее спецификация ее и не запрещает. Спецификация языка ада также не навязывает авторам компиляторов какую‑то конкретную модель управления памятью, но некоторые компиляторы при этом предоставляют опциальный сборщик мусора.

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

Для примера мы возьмем Boehm GC. Это весьма зрелый и функциональный продукт, который использовали или поныне используют множество проектов: как приложений (например, векторный графический редактор Inkscape), так и реализаций языков программирования.

Используем Boehm GC​


Многие дистрибутивы Linux предоставляют пакет с Boehm GC в репозиториях, чаще всего под именем libgc. В Fedora его можно поставить командой sudo dnf install libgc-devel, в Debian — sudo apt-get install libgc-dev.

Для демонстрации мы напишем программу, которая непрерывно запрашивает память под массив из тысячи целых чисел, но никогда ее не освобождает. Если бы мы использовали для выделения памяти классический malloc(), это была бы хрестоматийная утечка памяти. Но мы обратимся не напрямую к ОС, а к менеджеру памяти Boehm GC с помощью функции GC_MALLOC() и посмотрим, что будет.

Сохраним следующий код в файл gctest.c.

#include "gc.h"

#include <stdio.h>

int main() {

GC_INIT();

while(1) {

printf("Allocating memoryn");

int *p = (int*)GC_MALLOC(sizeof(int) * 1000);

printf("There are %d free bytes in the heap nown", GC_get_free_bytes());

printf("Making the object unreachablen");

p = NULL;

printf("There are %d free bytes in the heap nown", GC_get_free_bytes());

}

}


Вызовом int *p = (int*)GC_MALLOC(sizeof(int) * 1000) мы запрашиваем память на массив из тысячи 32-битных целых чисел (4069 байт) и сохраняем указатель на этот участок памяти в переменной p. Далее мы делаем эту память недоступной, заменив значение p нулевым указателем.

Теперь скомпилируем его командой gcc -lgc ./gctest.c -o gctest.

В выводе программы мы будем периодически наблюдать следующую картину: объем доступной памяти будет падать сперва до 8192 байт, затем до 4096 и, наконец, до нуля. Когда он достигнет нуля, следующая попытка выделения памяти скачком увеличит доступный объем.

$ ./gctest

...

There are 4096 free bytes in the heap now

Making the object unreachable

There are 4096 free bytes in the heap now

Allocating memory

There are 0 free bytes in the heap now

Making the object unreachable

There are 0 free bytes in the heap now

Allocating memory

There are 258048 free bytes in the heap now


По умолчанию Boehm GC выполняет сборку мусора только при острой необходимости — когда новую память взять уже негде. Если добавить в начало функции main() вызов GC_enable_incremental(), сборка мусора будет производиться чаще и объем свободной памяти не станет падать до нуля.

Вообще, у Boehm GC множество опций, которые можно менять как изнутри программы, так и извне с помощью переменных окружения.

СБОРЩИК МУСОРА — НЕУПРАВЛЯЕМЫЙ ЧЕРНЫЙ ЯЩИК?​


Другое распространенное мнение: сборка мусора — это всегда «магический», скрытый от пользователя и неуправляемый процесс.

Как мы уже увидели на примере Boehm GC, это не совсем верно. Автоматическое управление памятью — это всегда компромисс, и разные приложения требуют разных тактик сборки мусора для лучшей производительности. Авторы средств разработки это прекрасно понимают.

Авторы Boehm GC открыто признают, что GC_enable_incremental() может ухудшить общее время выполнения программы, но повысить ее отзывчивость. Что лучше для каждой конкретной программы, могут решить только ее автор и пользователи.

JVM предоставляет огромное количество опций для выбора стратегии сборки мусора и ее параметров. Glasgow Haskell Compiler тоже содержит ряд опций, несмотря на репутацию академического языка.

Некоторые реализации языков также позволяют управлять сборкой мусора и получать данные об использовании памяти изнутри программы: например, Python, Ruby, OCaml. Авторы программ иногда предпочитают запускать сборку мусора вручную, когда связанная с этим кратковременная потеря производительности меньше всего заметна для пользователя.

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

ВСЕ РЕАЛИЗАЦИИ СБОРКИ МУСОРА ОДИНАКОВЫ?​


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

Иногда реализации языков продолжают придерживаться старых методов по историческим или практическим причинам. К примеру, Perl использует самую простую стратегию из возможных — подсчет ссылок.

Подсчет ссылок и его проблемы​


Когда программист на Perl пишет $msg = "hello world", интерпретатор помещает значение "hello world" в память и сохраняет в $msg указатель на него. Если присвоить его другой переменной ($hello = $msg) или поместить в массив (@msgs = ($msg)), счетчик ссылок увеличивается на единицу. Как только переменные $hello или @msgs выйдут из области видимости, счетчик уменьшается. Когда счетчик достигнет нуля, память со строкой "hello world" считается недостижимой и освобождается.

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

Авторы Perl открыто признают эту проблему и советуют вручную использовать слабые ссылки (weak references). Почему они не перешли на более совершенные методы? Perl чаще всего используют для небольших скриптов или во всяком случае не для программ со сложными алгоритмами и структурами данных, поэтому для типичного использования это не создает проблем. Однако об этом нужно помнить, чтобы случайно не посчитать Perl хорошо пригодным для работы с такими структурами данных.

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

Tracing garbage collectors​


Некоторые думают, что все сборщики мусора используют подсчет ссылок, и совершенно зря. Многие языки и их компиляторы используют схему с отслеживанием (tracing garbage collector). Такие алгоритмы начинают работу от «заведомо доступных» объектов (например, глобальных переменных) и отмечают все объекты, на которые те ссылаются, — для отметки доступности служат зарезервированные биты. Затем объекты, которые не помечены как используемые, освобождаются.

Алгоритмы этого семейства объединяются термином mark-and-sweep. Среди их пользователей — JVM, .Net, Go, OCaml и многие другие языки и их библиотеки времени выполнения.

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

ЗАКЛЮЧЕНИЕ​


Знаешь ли ты, как твой любимый язык и его реализация управляют памятью? Нередко именно это знание отличает новичка от эксперта, особенно если речь идет о приложениях с высокой нагрузкой, поскольку верный выбор опций сборки мусора может сильно ускорить работу программы.

Возможно ли автоматическое управление памятью без сборки мусора и связанных с ней потерь производительности? Да, но об этом — в следующий раз.
 

📌 Золотая реклама

AnonPaste

Верх