- PVSM.RU - https://www.pvsm.ru -
В последнее время мне встречалось немало статей, в которых не самым удачным для меня образом продвигается свежий сборщик мусора в Go. Некоторые из статей написали разработчики самого языка, и их утверждения намекали на радикальный прорыв в технологии сборки мусора.
Вот первичный анонс [1] о внедрении нового сборщика, датированный августом 2015-го:
В Go создаётся сборщик мусора (GC) не только для 2015 года, но и для 2025-го, и ещё дальше… Сборщик в Go 1.5 возвещает о наступлении будущего, в котором паузы на сборку больше не являются барьером для перехода на безопасный язык. Это будущее, в котором приложения без труда масштабируются вместе с оборудованием, и по мере роста мощности оборудования сборщик мусора больше не является сдерживающим фактором при создании более качественного, масштабируемого ПО. Go — хороший язык для использования как минимум в ближайший десяток лет.
Создатели утверждают, что они не просто решили проблему пауз на сборку мусора, а пошли куда дальше:
Одним из высокоуровневых способов решения проблем с производительностью является добавление GC-настроек (knobs), по одной на каждую проблему. Программист может менять их, подбирая наилучшую комбинацию для своего приложения. Недостатком этого подхода является то, что при внедрении каждый год одной-двух новых настроек через десять лет придётся законодательно регулировать труд людей, которые будут менять эти настройки. Go не пошёл по этому пути. Вместо кучи настроек мы оставили одну и назвали её GOGC.
Более того, освободившись от бремени поддержки десятков настроек, разработчики могут сосредоточиться на улучшении runtime’а приложения.
Не сомневаюсь, что многие пользователи Go были просто счастливы получить новый подход к runtime’у в Go. Но у меня есть претензии к этим заявлениям: они выглядят как недостоверный маркетинговый булшит. А поскольку они раз за разом воспроизводятся в Сети, пришло время подробно с ними разобраться.
Реальность такова, что в сборщике мусора в Go не реализовано никаких новых идей или результатов исследований. Как признаются сами авторы, это просто сборщик с параллельной пометкой и очисткой, основанный на идеях 1970-х. Это примечательно лишь потому, что сборщик был разработан для оптимизации продолжительности пауз за счёт всех остальных важных характеристик сборщика. В технических [2] и маркетинговых материалах Go не упоминается обо всех этих побочных эффектах, поэтому многие программисты не подозревают об их существовании. В результате создаётся впечатление, что конкурентные языки — плохо спроектированный шлак. И авторы Go лишь подогревают эти мысли:
Для создания сборщика на следующее десятилетие мы обратились к алгоритмам из десятилетий прошлых. В нашем сборщике реализован трёхцветный (tri-color) алгоритм параллельной пометки и очистки, предложенный Dijkstra в 1978 году [3]. Это намеренное отличие от большинства современных сборщиков «корпоративного» класса, которое, как мы считаем, лучше всего соответствует особенностям современного оборудования и требованиям по уровню задержки в современном ПО.
Читаешь всё это, и возникает мысль, что за последние 40 лет в сфере «корпоративных» сборщиков мусора ничего лучше предложено не было.
При разработке алгоритма сборки мусора нужно учитывать ряд факторов:
Как видите, при разработке сборщика нужно учитывать много разных факторов, и некоторые из них влияют на архитектуру более широкой экосистемы, связанной с вашей платформой. Причём я не уверен, что перечислил все факторы.
Из-за сложности пространства проектных параметров сборка мусора представляет собой подобласть информатики, богато освещённую в исследовательских работах. Новые алгоритмы предлагаются и внедряются регулярно, как в академической, так и в коммерческой среде. Но, к сожалению, никто ещё не создал алгоритма, подходящего для всех случаев жизни.
Разберёмся с этим подробнее.
Первые алгоритмы сборки мусора разработали для однопроцессорных компьютеров и программ с маленькими кучами. Ресурсы процессора и памяти были дороги, а пользователи — нетребовательны, так что к паузам в работе программ относились лояльно. Создававшиеся в те времена алгоритмы старались поменьше задействовать процессор и минимизировать избыточное потребление памяти в куче. Это означало, что сборщик ничего не делал до тех пор, пока программа могла размещать в памяти данные. Затем она вставала на паузу до полного выполнения пометки и очистки кучи, чтобы как можно скорее освободить часть памяти.
У старых сборщиков есть преимущества: они просты; не замедляют программу, если не выполняют свою работу; не приводят к избыточному потреблению памяти. Консервативные сборщики, например Boehm GC [4], даже не требуют вносить изменения в компилятор или язык программирования! Это делает их подходящими для настольных приложений (обычно их кучи маленького размера), в том числе для видеоигр категории ААА [5]; в них большая часть памяти занята файлами с данными, которые не нужно сканировать.
Алгоритмы, для которых характерны паузы полной остановки (Stop-the-world, STW) для выполнения пометки и очистки, чаще всего изучают на курсах по информатике. Иногда на собеседованиях я прошу кандидатов немного рассказать о сборке мусора. И чаще всего они представляют сборщик как чёрную коробку, внутри которой неизвестно что происходит, либо считают, что в нём используется очень старая технология.
Проблема в том, что такие простые паузы на пометку и очистку крайне плохо масштабируются. Если добавить ядра и увеличить соотношения объёмов куч и размещений в памяти, то алгоритм перестаёт хорошо работать. Но иногда, когда применяются маленькие кучи, даже простые алгоритмы вполне сносно выполняют свою задачу! В подобных ситуациях вы можете воспользоваться подобным сборщиком и минимизировать избыточность потребления памяти.
Другая крайность — использование куч размером в сотни гигабайтов на машинах с десятками ядер. Например, на серверах, обслуживающих биржевые транзакции или поисковые запросы. В подобных ситуациях нужно сделать паузы как можно короче. И тогда предпочтительнее могут быть алгоритмы, в целом замедляющие работу программ за счёт фонового сбора мусора, но зато с очень короткими паузами.
На мощных системах вы также можете выполнять большие пакетные задания. Для них важны не паузы, а только общее время выполнения. В таких случаях лучше использовать алгоритм, который максимизирует пропускную способность (throughput), то есть отношение выполненной полезной работы ко времени, потраченному на сборку мусора.
Увы, нет ни одного алгоритма, полностью идеального. Также runtime ни одного языка не может определить, является ли ваша программа пакетным заданием или интерактивной программой, чувствительной к задержкам. Именно это, а не глупость разработчиков runtime’а, привело к появлению «настроек сборщика мусора». Это следствие фундаментальных ограничений информатики.
В 1984 году было подмечено [6], что большинство размещений в памяти «умирают молодыми», то есть становятся мусором через очень короткое время после размещения. Это наблюдение, названное гипотезой поколений, — одно из сильнейших эмпирических наблюдений в сфере разработки языков программирования. Гипотеза вот уже несколько десятилетий подтверждается для самых разных языков: функциональных, императивных, не имеющих и имеющих типы данных.
Гипотеза поколений принесла пользу в том смысле, что алгоритмы сборки мусора стали использовать её плюсы. Так появились сборщики на основе поколений (generational collectors), которые имели ряд преимуществ по сравнению со старыми алгоритмами «остановить — пометить — очистить»:
Но у таких сборщиков есть и недостатки:
Тем не менее выигрыш от использования сборщиков на основе поколений так велик, что сегодня этот тип абсолютно доминирует. Если вы готовы смириться с недостатками, то вам наверняка понравятся такие сборщики. Эти алгоритмы можно расширять всевозможными функциями, типичные современные сборщики могут быть в одном лице многопоточными, параллельными, уплотняющими (compacting) и использующими поколения.
Go — довольно обыкновенный императивный язык с типами значений. Пожалуй, его шаблоны доступа к памяти можно сравнить с С#, в котором используется гипотеза поколений, следовательно, применяется сборщик .NET.
По факту программы на Go обычно требуют наличия обработчиков запросов/откликов вроде HTTP-серверов. То есть они демонстрируют поведение, сильно завязанное на поколениях. Создатели Go думают, как это можно использовать в будущем с помощью таких вещей, как «сборщик, ориентирующийся на запросы» (request oriented collector [8]). Как уже заметили, это просто переименованный сборщик на основе поколений [9] с настроенной политикой срока владения.
Можно эмулировать такой сборщик в других runtime’ах для обработчиков запросов/откликов. Для этого нужно удостовериться, что молодое поколение достаточно велико, чтобы в него поместился весь мусор, генерируемый при обработке запроса.
Но несмотря на это, используемый сегодня в Go сборщик — это не сборщик на основе поколений. Он просто выполняет в фоне старую добрую процедуру пометки с очисткой.
У такого подхода есть одно преимущество: можно получить очень-очень короткие паузы. Но все остальные параметры ухудшатся. Например:
Вот отрывок из одного поста [11], в котором рассказывается о вышеописанных недостатках:
Сервис 1 размещает больше памяти, чем Сервис 2, поэтому паузы полной остановки у него длиннее. Однако у обоих сервисов абсолютная продолжительность пауз остановки уменьшается на порядок. После включения на обоих сервисах мы наблюдали ~20%-й рост потребления сборщиком времени процессора.
В данном случае продолжительность пауз в Go снизилась на порядок, но за счёт замедления работы сборщика. Можно ли это счесть оправданным компромиссом или длительность пауз и так уже была достаточно низкой? Автор не сказал.
Однако наступает момент, когда больше не имеет смысла наращивать возможности железа для сокращения пауз. Если паузы на сервере снизятся с 10 до 1 мс, заметят ли это пользователи? А если для такого снижения вам потребуется увеличить аппаратные ресурсы вдвое?
Go оптимизирует длительность пауз за счёт пропускной способности, причём настолько, что кажется, будто он хочет на порядок замедлить работу вашей программы, лишь бы сделать паузы чуточку меньше.
Виртуальная машина Java HotSpot имеет несколько алгоритмов сборки мусора. Вы можете выбирать их через командную строку. Ни один из них не старается так сильно снизить продолжительность пауз, как Go, потому что они пытаются поддерживать баланс. Чтобы прочувствовать влияние компромиссов, можно сравнить алгоритмы друг с другом, переключаясь между разными сборщиками. Как? С помощью простого перезапуска программы, потому что компилирование выполняется по мере её исполнения, так что разные барьеры, необходимые для разных алгоритмов, могут по мере необходимости компилироваться и оптимизироваться в коде.
На современных компьютерах по умолчанию используется сборщик на основе поколений. Он создан для пакетных задач и изначально не обращает внимания на длительность паузы (её можно задавать в командной строке). Из-за возможности выбирать сборщик по умолчанию многие считают, что в Java паршивый сборщик мусора: из коробки Java пытается сделать так, чтобы ваше приложение работало как можно быстрее, с наименьшей избыточностью памяти, а на паузы наплевать.
Если вам нужно уменьшить продолжительность пауз, то можете переключиться на сборщик с параллельной пометкой и очисткой (concurrent mark/sweep collector, CMS). Это самое близкое к тому, что используется в Go. Но это алгоритм на основе поколений, поэтому паузы у него длиннее, чем в Go: молодое поколение уплотняется во время пауз, потому что выполняется перемещение объектов. В CMS есть два типа пауз: покороче, около 2—5 мс, и подлиннее, около 20 мс. CMS работает адаптивно: поскольку он выполняется одновременно (concurrent), то должен предполагать, когда ему запуститься (как и в Go). В то время как Go попросит вас сконфигурировать избыточность кучи, CMS самостоятельно адаптируется в ходе runtime, стараясь избежать сбоев режима одновременного выполнения. Поскольку большая часть кучи обрабатывается с помощью обычной пометки и очистки, то можно столкнуться с проблемами и тормозами из-за фрагментации кучи.
Самое свежее поколение сборщика в Java называется G1 (от garbage first). По умолчанию он работает начиная с Java 9. Авторы постарались сделать его как можно более универсальным. По большей части он выполняется одновременно, основан на поколениях (generational) и уплотняет всю кучу. Во многом самонастраиваемый. Но поскольку он не знает, чего вы хотите (как и все сборщики мусора), то позволяет регулировать компромиссы: просто укажите максимальный объём памяти, который вы ему выделяете, и размер пауз в миллисекундах, а всё остальное алгоритм подгонит самостоятельно, чтобы соблюсти ваши требования. По умолчанию длительность пауз около 100 мс, так что, если вы не уменьшите их самостоятельно, не ждите, что это сделает алгоритм: G1 отдаст предпочтение скорости работы приложения.
Паузы не совсем консистентны: большинство очень коротки (менее 1 мс), но есть и несколько длинных (более 50 мс), связанных с уплотнением кучи. G1 прекрасно масштабируется. Известны отзывы людей, которые применяли его на кучах терабайтного размера. Также у G1 есть ряд приятных возможностей вроде дедупликации строк в куче.
Наконец, был разработан ещё один новый алгоритм под названием Shenandoah. Он внесён в OpenJDK, но в Java 9 не появится, пока вы не станете использовать специальные билды Java из Red Hat (спонсора проекта). Алгоритм разработан с целью минимизации продолжительности пауз, невзирая на размер кучи, которая в то же время уплотняется. К недостаткам относятся большая избыточность кучи и ряд барьеров: для перемещения объектов во время выполнения приложения необходимо одновременно считывать указатель и взаимодействовать со сборщиком мусора. В этом смысле алгоритм аналогичен «безостановочному» сборщику из Azul.
Цель этой статьи — не в том, чтобы убедить вас использовать другие языки или инструменты. Сборка мусора — это трудная, действительно трудная проблема, которая десятилетиями изучается армией учёных и программистов. Поэтому относитесь с подозрением к прорывным решениям, которые никто не заметил. Вероятнее всего, это просто странные или необычные замаскированные компромиссы, избегаемые другими по причинам, которые станут ясны позднее.
А если хотите минимизировать продолжительность пауз за счёт всех остальных параметров любой ценой, то обратитесь к сборщику мусора из Go.
Автор: Mail.Ru Group
Источник [12]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/229182
Ссылки в тексте:
[1] первичный анонс: https://blog.golang.org/go15gc
[2] технических: https://talks.golang.org/2015/go-gc.pdf
[3] Dijkstra в 1978 году: http://dl.acm.org/citation.cfm?id=359655
[4] Boehm GC: http://www.hboehm.info/gc/
[5] видеоигр категории ААА: https://wiki.unrealengine.com/Garbage_Collection_Overview
[6] В 1984 году было подмечено: http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.122.4295&rep=rep1&type=pdf
[7] сильно улучшило эффективность использования кеша: http://dl.acm.org/citation.cfm?id=1005693
[8] request oriented collector: https://docs.google.com/document/d/1gCsFxXamW8RRvOe5hECz98Ftk-tcRRJcDFANj2VwCB0/edit
[9] заметили, это просто переименованный сборщик на основе поколений: https://news.ycombinator.com/item?id=11969740
[10] компилятору Go не хватает возможностей, чтобы надёжно и быстро ставить треды на паузу: https://github.com/golang/go/issues/10958
[11] одного поста: https://groups.google.com/forum/#!msg/golang-dev/Ab1sFeoZg_8/pv0Yg7tkAwAJ
[12] Источник: https://habrahabr.ru/post/318504/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.