Почему GC не работает и можно ли без него обойтись

15 декабря 2014

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

Автоматическая у вас сборка мусора или нет, память в ваших программах все равно течет.

Java. Рассмотрим замечательный пример со стеком из книги Effective Java. Можете ли вы обнаружить в нем утечку памяти? Не спешите читать дальше, посмотрите на пример и подумайте… Я серьезно, это очень интересное упражнение. Ладно, рассказываю. Поскольку ссылки в массиве elements не обнуляются в методе pop, объекты, однажды помещенные в стек, не будут освобождены до тех пор, пока стек не будет уничтожен. Что намного хуже, объекты, на которые ссылаются объекты, однажды помещенные в стек, также будут висеть в памяти, пока существует стек. Или пока ссылки в стеке не будут перезаписаны другими ссылками.

Go и другие. Течет по той же причине, по которой течет и Java. Вообще, пример со стеком применим к любому языку с автоматической сборкой мусора, если в нем есть изменяемые переменные.

Python. Создадим два объекта, имеющих деструкторы. Сошлемся из первого объекта на второй, а из второго на первый. Перестанем использовать объекты. Вопрос — какой объект будет уничтожен первым? Допустим, первый. Но что, если второй объект использует первый в своем деструкторе? Все сломается! Поэтому в такой ситуации Python не освобождает память. Оба объекта будут висеть в памяти до тех пор, пока работает программа. А если таких цепочек объектов создается много, программа быстро упадет с out of memory.

Erlang. Известен своей способностью создавать мемори лики при работе с большими кусками бинарных данных. Допустим, есть бинарь размером 1 Мб. Нам нужны первые 3 байта из него. Мы можем легко получить их при помощи сопоставления с образцом. Но физически это те же первые три байта из 1 Мб данных. Даже если весь бинарь целиком не используется, выделенные 1 Мб памяти не могут быть освобождены. Решить эту проблему можно, используя фукнцию binary:copy(). Однако бездумно копировать все и вся не только дорого, но и может приводить к еще большим мемори ликам, если оригинальные 1 Мб все-таки используются! А еще в Erlang постоянно переполняются очереди.

Haskell. Все бояться хаскеля из-за ленивых вычислений. И, возможно, не зря. Допустим, мы написали рекурсивную функцию с аргументом-аккумулятором. Будучи ленивым языком, Haskell будет постоянно откладывать вычисление этого аргумента. Вместо вычисления, язык будет выделять все больше и больше памяти, содержащей информацию о том, как произвести эти вычисления. Анализ строгости (strictness analysis) отчасти решает эту проблему. Но факт остается фактом, при использовании ленивых вычислений приходится дополнительно думать о том, чтобы память не утекла.

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

Мы сразу лишаемся такого количества проблем! Никаких накладных расходов на GC, никаких stop the world! Программа работает быстро, словно если бы мы писали на C++, память расходуется почти так же экономно, как если бы мы выделяли ее вручную! Кроме того, в нашем арсенале появляются такие приемы, как «а давайте уместим наши данные в L1 кэш».

Чем за это приходится платить? Если у нас появятся циклические ссылки, память утечет. Но так она может утечь и при использовании полноценного GC! Так что тут нет особой разницы. Вот проблема фрагментации памяти куда актуальнее. Но давайте подумаем, а многие ли из нас сталкивались с этой проблемой на практике? Лично я, кажется, еще ни разу. А ведь мы ежедневно используем кучу программ, написанных на C/C++. Ну и потом, хоть я и не специалист в этом вопросе, сдается мне, что проблема фрагментации памяти сравнительно легко решается при помощи повторного использования объектов, приемов вроде slab allocator’ов и так далее. Если вдруг вы с ней столкнетесь.

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

Дополнение: Некто Index Int подсказал в комментариях ссылку на отличную статью по теме. В частности, в ней объяснено, почему молодые объекты выгоднее собирать с помощью GC, а старые — при помощи счетчиков ссылок. Следует однако отметить, что в Rust молодые объекты размещаются на стеке и быстро освобождаются.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.