Поговорим за Garbage Collector?

С удивлением я для себя обнаружил, что в сети все еще гуляют мнения, что наличие сборщика мусора у таких языков, как Java, C#, Go, много их — это скорее недостаток языка, нежели достоинство. Я сейчас не буду углубляться в то, что разные языки имеют разную нишу, просто не так давно приходилось сталкиваться с мнением новичков, что ничто не мешает просто так взять и после использования объекта его подчистить. Я сказал «новичков», потому что очень надеюсь, что это не Senior Developer’ы и не Архитекторы обладают подобным менталитетом. Эта статья призвана выразить и, надеюсь, обосновать другой взгляд на эту проблему.

Приступим

Сборщики мусора действительно обладают своими недостатками. Самый очевидный из них — это ситуация Stop The World. Когда она происходит, то выполнение всех потоков приложения останавливается и не продолжается до тех пор, пока работа сборщика не будет завершена. Хотя сборщики мусора научились собирать гигабайты мусора за сотые секунды и паузят приложение ненадолго и это оказывает не сильный аффект на трупут вашего приложения, однако это может создавать сложности для создания интерактивных и высоконагруженных приложений, систем реального времени. Этот спор не велся бы если бы вместе с недостатками, не были бы очевидны и плюсы языков со сборщиками мусора.

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

Значит, чтобы обосновать необходимость сборщика мусора, мне потребуется продемонстрировать такую ситуацию, когда без сборщика мусора продумать подобный протокол взаимодействия с аллоцированными объектами если не невозможно, то по крайней мере сложно. Я еще не буду говорить о банальных примерах, когда программист просто забывает написать delete после new (или free после malloc). В конце концов, должна быть какая-та культура — у опытного программиста на C/C++ я считаю должна быть привычка писать new сразу вместе с delete, а затем уже думать, как взаимодействовать с аллоцированными объектами. Это также как не забывать закрыть файл, когда он становится не нужен.

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

Тем не менее, давайте начнем с простых примеров — я буду выражать их на C++ подобном псевдокоде. Предположим довольно простую ситуацию — мы создали экземпляр некоторого класса, попользовались и потом удалили за собой объект — типичная ситуация.

SomeObj *someObj = new SomeObj();
someObj -> doSomething();
delete someObj;

Вот так просто. Я уже смирился с тем, что у меня отобрали сборщик мусора и я могу так просто управлять памятью, удаляя за собой объекты. Внимание вопрос — коль скоро, я живу в XXI веке, мне хотелось бы, чтобы компилятор языка мог как-то выявить, что здесь необходим delete и либо сам его вставил, либо бросил бы ошибку компиляции, мол так и так, вы не удалили за собой объект. Может ли компилятор как-то доказать, что на каком-то этапе выполнения объект уже не используется, а следовательно во-первых его можно безопасно удалить, а во-вторых его НУЖНО удалить, поскольку в ином случае это гарантированно приведет к утечке, а это ошибка? Тех, кто поспешил ответить на этот вопрос утвердительно, я прошу обратить внимание на метод «doSomething». Метод — суть есть специальный синтаксис для процедуры. Эта процедура принимает указатель на someObj как обычный аргумент и someObj доступна ей через ключевое слово языка this. А значит, doSomething может делать с someObj все что угодно. Например, найти глобальный объект по типу Синглтона и вписать себя туда. Значит, глядя на подобный код компилятор не может доказать, что объект, доступный через указатель someObj больше нигде не используется, а значит ему приходится доверять мнению программиста в этом вопросе. Собственно — это единственная уважительная причина, по которой ключевое слово delete присутствует в языке без сборщика мусора.

Хорошо, давайте сделаем пример еще проще. Мы создали экземпляр некоторого класса, ничего с ним не сделали и сразу удалили. Может ли компилятор доказать, что к моменту выполнения инструкции delete объект уже нигде не используется?

delete new SomeObj();

Осторожный читатель заметит, что у SomeObj есть конструкторы, который в своей сути есть особенный метод, который необходимо вызвать, чтобы создать объект. Он может сделать что угодно, в том числе и вызвать метод doSomething. Вы можете спросить меня, а зачем вообще в конструкторе передавать указатель куда либо в неявном виде — ведь это антипаттерн, и, возможно, окажетесь правы. Я лишь говорю, что язык позволяет стрелять себе в ногу подобным образом. Могу предположить, что, например, написав класс Task — вы можете захотеть, чтобы она автоматически скедулилась в некий глобальный планировщик прямо в конструкторе.

Защититься от таких проблем довольно легко на уровне языка, если мы будем понимать по публичному контракту метода, может ли объект переданный по тому или иному аргументу быть еще куда-то передан или нет. Давайте попробуем представить, как мы могли бы это сделать. Мы вводим ключевое слово «shared» таким образом, чтобы указатели в языке отныне подчинялись следующим правилам:

  • Есть два типа указателей — shared и unshared.
  • Вы не можете произвести запись значения из unshared указателя в shared указатель, но можете сделать обратную операцию, если по коду возможно доказать, что это значение соответствует адресу реально существующего объекта.
  • Unshared указатели в принципе должны обладать достаточным набором ограничений, чтобы гарантированно не оказаться в какой либо коллекции — они предназначены только для того, чтобы их разыменовывать и вызывать методы, либо читать поля.
  • Unshared указатели не могут быть записаны в структурах, которые могут быть инстанцированы в куче. Язык в принципе не должен иметь механизмов, позволяющих увидеть unshared указатель, полученный из одного потока в другом потоке.
  • Ресивер объекта(this) ВСЕГДА является unshared указателем.
  • Unshared указатели не должны поддерживать арифметику указателей.

Я не стану утруждать себя теоретическими доказательствами корректности подобный ограничений, но смею предположить, что их достаточно, чтобы компилятор научился доказывать, что объект созданный и доступный по не shared указателю больше нигде не используется и бросал бы ошибку компиляции, если инструкции delete нету, либо сам бы вставлял её. Очевидными проблемами данного решения является то, что подобный язык уже будет не совмести с C++ на уровне исходного кода и скорее всего не позволит скомпилироваться любой более-менее крупной программе.

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

Случай сложнее

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

Итак, давайте представим себе, что в наше приложение заходит пользователь и хочет посмотреть страницу. Перед тем, как её нарисуют нам надо получить данные. Репозиторий объектов полностью реализует CRUD API для сущностей в том или ином хранилище. Репозиторий через ORM или другой механизм обращается в базу. Все бы ничего, но базе может быть сложно доставать каждый раз данные. Не важно, хотите ли вы получить плоский объект, строго отображенный в одну строку из базы, либо это сложный документ со вложенными коллекциями, главное что получить новый объект может быть дороже, чем достать готовый из хипа. Для хранения объектов в хипе репозиторий будет использовать некую имплементацию кеша.

Так вот, когда порядок действий с объектом чуть сложнее, чем просто создал-поработал-убил, так еще с этим работают несколько потоков дело становится сложнее. Скажем, есть поток 1, который взял объект из кеша поработать. Также есть поток 2, который принес новый кеш. Так получилось, что емкость кеша исчерпано и он выбрал жертвой тот объект, над которым пока еще работает поток 1. Может ли кеш удалить этот объект из кучи? Очевидно, что данное действие привело бы к некорректному дальнейшему исполнению программы. Как мы могли бы эту проблему решить? От себя я могу предложить несколько вариантов:

  1. Давайте расширим контракт кеша. Однажды, когда мы положили объект в кеш, мы можем его одолжить(borrow), немного поработать с ним, затем вернуть(пусть будет give back). Тогда, кеш будет точно знать, какие объекты взяты в долг и не эвиктить их ни при каких обстоятельствах. По сути мы изобретаем операции, аналогичные new/delete для кеша с похожими побочками типа утечки кеша. Конечно, контроллер не может напрямую использовать это API и вернуть долг. Мы будем вынуждены расширить контракт репозитория и кроме имеющихся CRUD операций добавить методы для возвращения объекта в кеш, чтобы сдать использованный объект, после того как контроллер дал его попользоваться шаблонизатору.
  2. Давайте кеш не будет возвращать оригинальный экземпляр, а вместо этого будет его копировать. Тогда и кеш и контроллер смогут удалить свои экземпляры самостоятельно. Из плохих последствий — мы можем проделывать подобные вещи только с иммутабельными данными. С мутабельными тоже, только тогда мы не можем рассчитывать, что результаты изменения в одном потоке будут видны в другом, поскольку они будут работать каждый со своим экземпляром. Кроме того мы получим дополнительные накладные расходы на потребление памяти и копирование.
  3. Давайте объекты будут доступны и кешу и контроллеру исключительно через умные ссылки со счетчиками. Это все еще не сборщик мусора, но вполне решает эту элементарную задачу. Из недостатков — если умные ссылки поддерживаются не на уровне языка, то у вас все еще есть шанс выстрелить себе в ногу, скажем, скопировав глупую ссылку, к тому же вы получите синтаксическое нагромождение при каждом обращении по этой ссылки.

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

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

Немного за синтаксис

Я отдаю себе отчет в том, что следующий аргумент будет неубедительно звучать для фанатов низкоуровневого программирования. Однако, для меня важно, чтобы язык позволял писать красивые, лаконичные и выразительные конструкции. Это одна из причин, по которой я люблю Kotlin.

Я не буду приводить конкретные примеры, но вы сами можете найти, сколь мощные DSL можно создать с использованием цепочек, функций высшего порядка и лямбда выражений с ресиверами на языках вроде Kotlin, Scala или Groovy. Самые разные, начиная от тех, которые позволяют обрабатывать коллекции в функциональном стиле и декларировать асинхронные потоки данных, заканчивая возможностью писать рулы в декларативном стиле и шаблоны HTML кода прямо на языке программирования.

Будет обидно, если такие богатые возможности совмещать различные парадигмы будет осложнена тем, что ваши декларативные конструкции будут нагромождены синтаксическим шумом, вроде переменных хранящих указатели и операций delete. Это то, что по определению в DSL быть не должно.

Немного про арифметику указателей

Этот аргумент не столько против языков с ручным управлением памятью, сколько против языков с арифметикой указателей.

Человеку с опытом программирования на C/C++ представлять, что такое арифметика указателей не нужно. Для остальных читателей объясню. Основная цель указателей — возможность использовать участок памяти, на которые те, как ни странно, указывают. Для этого в языке реализованы некоторые основные операции — оператор new возвращает указатель на новый объект, оператор delete удаляет объект, операторы разыменовывания позволяют тем или иным способом взаимодействовать с этим объектом. Кроме этого, для указателей в языках C/C++ реализованы дополнительные операции, которые не важны для этой задачи, но вскрывают суть того, чем указатели являются. Во-первых указатель можно привести к числу и узнать адрес виртуальной памяти процесса, по которой можно обратиться к объекту. Все бы ничего, но кроме этого, зная адрес в числовом виде можно проделать обратную операцию — получить указатель из адреса. Ну и кроме того, определены операции сложения указателей с числами — они позволяют сдвигать указатель на смещение, кратное размеру типа указателя. Совокупность всех этих побочных операторов я называю арифметикой указателей.

Язык, который дает программисту арифметику указателей, предоставляет ему очень мощный инструмент. Так на C/C++ вы по сути можете реализовать свой собственный алгоритм выделения памяти. Однако, когда язык не ограничивает вас в подобных unsafe операциях, он ограничивает свою среду выполнения в реализации некоторых фичей. Предположим, мы аллоцировали сотню тысяч объектов, половину из них удалили в случайном порядке. Ожидаемым результатом такого действия будет сильная дефрагментация памяти. В хипе вашего приложения будет много маленьких «дырочек», куда можно разместить маленький объект, но для объекта побольше придется искать другое место. Это создает некоторые накладные расходы в плане потребления памяти и долгого перебора списка областей аллокатором памяти. Хотелось, чтобы в среде выполнения была бы возможность дефрагментировать хип. Сборщики мусора в языках с управляемой кучей подобные штуки умеют делать. Что насчет C? Понятное дело, чтобы в C реализовать нечто подобное, пришлось бы отказаться от указателей в том виде, в котором они существуют. Как можно так просто взять и перенести кусок памяти с одного виртуального адреса на другой, если мы не управляем указателями, которые на этот кусок ссылаются? Ввиду существования операций, которые позволяют получать указатели из произвольных адресов — это принципиально невозможно сделать безопасным способом.

Краткий итог

Я бы ни в коем случае не хочу, чтобы у вас сложилось впечатление, будто я пытаюсь вас убедить в том, что языки без сборщиков мусора не нужны. Однако, как видите ручное управление памятью повышают ваши шансы написать код не правильно, кроме того ограничивает вас в использовании некоторых архитектурных шаблонов и мешает писать лаконичный и выразительный код. Я лишь, хочу показать, что не стоит воспринимать сборщик мусора исключительно как костыль, нивелирующий неумение программистов писать корректный код. Я вспоминаю известную в программисткой среде цитату «Компьютер — это конечный автомат. Потоки предназначены для людей, которые не могут программировать конечные автоматы». Однако я вижу, что потоки по прежнему признают и их не оспаривают программисты на C. Я думаю, что это все по той же причине — потоки дают удобные примитивы, позволяющие нам писать многозадачные программы, и чтобы отказаться от потоков их пришлось бы серьезно переписать. Так и отказавшись от сборщика мусора, нам бы пришлось многие приложения по-сути перепроектировать с нуля.