Десять решений проблемы stop the world при использовании автоматической сборки мусора
24 декабря 2015
Часто от умных, казалось бы, людей можно услышать очень смешные вещи в отношении GC. Например, «Java/Scala/Go тормозит и вообще со stop the world, поэтому все всегда нужно писать только на Erlang». При этом все остальные преимущества Java (Scala) и множественные недостатки Erlang отбрасываются. Или вот еще — «все игры должны писаться на C/C++, потому что из-за GC автоматически все будет лагать». Люди, как всегда, все переупрощают и ругают то, с чем они даже не пытались научиться работать. Та же проблема stop the world действительно является проблемой, но вовсе не относящейся к разряду нерешаемых. И в этой заметке я собрал аж десять известных мне способов борьбы с ней.
Итак, бороться со stop the world, если вы вообще когда-нибудь столкнетесь с ним на практике (об это ниже), можно следующими способами:
- Тюнингом параметров самого GC. В случае с JVM здесь вам поможет книга Java Performance: The Definitive Guide. Сборщик мусора в Java имеет десятки ручек, потянув за которые, можно существенно скорректировать его поведение. То и дело сообщается о случаях, когда все проблемы с GC в том или ином приложении (Cassandra, IntelliJ IDEA, …) решались путем настройки GC в течение одного рабочего дня. После этого приложение просто летало и к этой проблеме никто больше не возвращался;
- Приложение можно разбить на много приложений поменьше, те самые микросервисы, про которые все говорят. У каждого микросервиса своя локальная куча. А следовательно и свой GC, работающий параллельно с остальными GC. Притом, работает он намного быстрее, так как куча меньше. Идея похожа на локальные небольшие кучи в Erlang, только с куда меньшим количеством лишних копирований данных из кучи в кучу;
- Так называемая избыточная работа. Идея в следующем. У вас крутятся два экземпляра одного и того же сервиса. Вы шлете два одинаковых запроса на оба сервиса и смотрите, какой раньше ответит. Его ответ принимаете, а ответ второго сервиса игнорируете. Тем самым, если на одной из копий сервиса сейчас идет тяжелая сборка мусора, это не повлияет на производительность всего приложения в целом. Возможны и модифицированные схемы, при которых, скажем, обычно шлется один запрос, а второй посылается только в случае, если на первый запрос нет ответа дольше заданного количества времени. Описанный подход помогает не только от stop the world, но и, например, от сетевых проблем или внезапных больших нагрузок на одну из копий сервиса. Понятно, что работает это только с запросами на чтение, но в типичном вебе это 90% всех запросов;
- Старые-добрые пулы объектов. Идея заключается в следующем. Создадим объекты, которые будут использоваться повторно. Поскольку современные GC собирают мусор по поколениям, такие объекты быстро станут «старыми» и GC будет редко их проверять. Кроме того, мы будем меньше выделять и освобождать память, а следовательно и собирать мусор придется реже;
- Так называемое размещение данных offheap, или, попросту говоря, ручное управление памятью. Многие современные языки с GC также позволяют размещать данные и offheap. Например, такое возможно как в Go, так и в языках под JVM. Таким образом, современные языки с GC как минимум в плане производительности ничем не хуже языков без GC. В крайнем случае можно просто перейти на ручное управление памятью;
- Тупо использовать меньше памяти. Чем меньше памяти используется, тем быстрее происходит сборка мусора. Профайлер в руки и вперед. Совсем уж в крайнем случае можно хранить часть данных в файликах на диске, Memcached или какой-то СУБД, загружая эти данные только при необходимости;
- Часто помогает делать explicit GC, который в Java
System.gc()
. Это позволяет сделать паузы на сборку мусора короче, хоть и более частыми. Как только наплодили много мусора (например, выгрузили пройденный уровень и загрузили следующий в игре), тут же собираем мусор, чтобы он не копился; - Уменьшение числа ссылок. Там, где это возможно, не ссылаемся на данные, а встраиваем их в структуру по значению. В мире Java это редко используется, так как POJO, содержащие только примитивные типы, встречаются довольно редко. А вот в мире Go это, по всей видимости, распространенный прием. Идея заключается в том, что чем меньше в программе ссылок, тем меньше данных обходить, тем быстрее GC. В том же Go есть еще штука под названием escape analysis, с помощью которой можно найти объекты, которые были размещены в куче, а не в стеке. Аналогично, чем меньше объектов попадает в кучу, тем меньше ссылок, тем быстрее GC;
- Полное отключение GC / сборка мусора по расписанию. Сам так никогда не делал, но слышал, что в некоторых компаниях просто покупают побольше памяти и не собирают никакой мусор. Он копится в течение дня, а ночью, когда нагрузка минимальна, приложение перезапускается. Эту схему можно и адаптировать, чтобы памяти требовалось не так уж много. Настраиваем мониторинг, который следит за количеством свободной памяти на бэкендах. Когда память на бэкенде начинает заканчиваться, он выключается из нагрузки, приложение перезапускается (или дергается ручка, делающая explicit GC), и бэкенд снова включается в нагрузку. Несмотря на радикальность такого решения, имеет право на жизнь, почему бы и нет;
- Наконец, в крайнем случае ничто не мешает переписать кусок приложения, создающий особо много мусора, на язык с управлением памятью вручную или на счетчиках ссылок. Переписанный кусок может представлять собой стороннюю утилиту, отдельный сервис или библиотеку, хождение в которую осуществляется по какому-нибудь JNI;
Следует отметить, что в той же JVM сборка мусора настолько крута, что вы, возможно, этот самый stop the world вообще никогда не увидите. Потому что сборка мусора выполняется параллельно с выполнением самой программы, а тот самый stop the world занимает какие-то миллисекунды. При написании вебчика или каких-то там серверных приложений эти миллисекунды с точки зрения пользователя неотличимы от сетевых задержек. Кроме того, важен не столько факт существования stop the world, сколько его длительность и частота, с которой он происходит.
В тех же играх, если вы хотите показывать пользователю 60 FPS, значит каждый кадр должен рисоваться за 16-17 мс. Если каждую секунду будет происходить stop the world на 1-2 мс, этого может никто и не заметить. Не удивительно, что существуют игровые движки на Java, например jMonkeyEngine. Советую также посмотреть канал господина ThinMatrix на YouTube, где он публикует довольно красочные видео своего игрового движка на Java, а также уроки по LWJGL. Еще из интересных ссылок касательно разработки игр на Java следует отметить игру Caromble, библиотеку libGDX, библиотеку для работы с физикой JBullet, а также движок OpenRTS;
На самом деле, существенная часть работы касаемо самой графики в играх выполняется на GPU, а не CPU, поэтому наличие или отсутствие GC менее существенно, чем принято думать. Более того, почти вся игровая логика в наше время пишется вообще на скриптовых языках. (Впрочем, это не отменяет того факта, что какой-нибудь Skyrim имеет смысл с самого начала писать на C++. Если игра заведомо тяжелая и есть возможность выжимать из железа максимум, почему бы этой возможностью не воспользоваться? Влияние, оказываемое культурой и традициями, также не следует недооценивать.)
Не нужно большого ума для того, чтобы винить технологию во всех бедах. Куда сложнее разобраться в технологии и научиться правильно ею пользоваться. И, само собой разумеется, никакая технология не решает автоматически за вас все проблемы, GC не исключение.
Метки: Разработка, Языки программирования.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.