Жила-была одна программа, которая при старте загружала кучу данных, делала некоторую первичную обработку этих данных, а затем переходила в режим ожидания запросов от пользователей. Особенностью первичной обработки было то, что на это время под всякие промежуточные объекты выделялся значительный объем динамической памяти. И после завершения обработки практически вся память должна была быть освобождена.
Что, собственно, до некоторых пор и происходило: по показаниям htop было видно, как сперва потребление памяти растет, затем резко падает.
Но в какой-то момент поведение изменилось: потребление памяти росло во время первичной обработки, потом уменьшалось, но уменьшалось не настолько, насколько ожидалось. Грубо говоря, раньше в пике съедали 1Gb, затем падали до 400Mb. Теперь же в пике съедаем 1Gb, но затем падаем всего до 600Mb.
Естественно, возникло ощущение, что где-то есть утечка. Но никакой утечки не нашли. Зато нашли явные следы фрагментации хипа.
Сразу извинения: я не уверен на 100%, что термин "фрагментация" здесь является точным. Но лучшего, увы, не придумал. Поэтому прошу гнилыми помидорами не бросаться.
В проекте в качестве дефолтного аллокатора используется mimalloc. Хвала его создателям, в API mimalloc-а есть средства для интроспекции содержимого хипа, что и позволило обнаружить эффект фрагментации.
Нужно сказать, что mimalloc спроектирован так, чтобы максимально избавиться от вероятности фрагментации (насколько это в принципе возможно для языков без compacting GC): он использует арены (areas) под объекты фиксированного размера. Так, объекты размером в 16 байт создаются в своей арене, объекты размером в 32 байта -- в своей, размером 48 байт -- в своей и т.д. Тем самым mimalloc частично избавляется от эффекта "дырок в сыре" -- когда суммарно свободной памяти много, но вся она рассредоточена в мелких фрагментах, чередующихся с занятыми фрагментами.
Выяснилось, что под контролем mimalloc-а оказываются буквально сотни арен для объектов размером 16 и 48 байта, на которых живых всего пара-тройка объектов, а все остальное пространство пустое. Но т.к. на этих аренах есть живые объекты, то mimalloc не может отдать выделенную под арены память обратно ОС. Поэтому ОС показывает, что приложение держит больше памяти, чем мы рассчитывали.
Почему же так произошло?
Первичная обработка данных при старте создает в динамической памяти множество объектов. Среди них огромный процент объектов как раз имеют размер 16 и 48 байт. Под них mimalloc динамически выделяет все новые и новые арены, запрашивая память у ОС.
При завершении обработки эти вспомогательные объекту удаляются. И ранее их удаление приводило к очистке тех арен mimalloc-а, в которых объекты размещались. По мере освобождения арен mimalloc возвращал память ОС. И по показаниям htop было видно, что потребление памяти приложением снижается.
Но после того, как кусочек приложения был переведен на использование нового, написанного мной под особенности задачи, контейнера с деревом внутри, возник эффект, при котором в этих самых аренах оставались единицы живых объектов. Поэтому-то арены полностью не освобождались и не возвращались ОС.
Происходило это потому, что в процессе обработки начали создаваться не только "старые" объекты размером 16 и 48 байт, но и "новые" -- внутри этого самого нового контейнера. Только вот содержимое нового контейнера должно было остаться до конца работы программы, поэтому эти "новые" объекты не удалялись. В отличии от "старых" объектов, нужных только на время первичной обработки.
Получалось, что сплошным потоком создаются объекты размером, например, 48 байт. Львиная их часть относится к "старым" объектам, но иногда создаются и "новые". Пропорция приблизительно 500 "старых" объектов к одному "новому". Т.е. создали 500 "старых" объектов, затем один "новый", затем еще 500 "старых", затем еще один "новый" и т.д.
Физически это выглядит так, что на очередной арене mimalloc-а подряд размещается 500 "старых" объектов, за ними один "новый", затем еще 500 "старых", следом еще один "новый", затем арена заканчивается, начинается новая, на которой сперва идет 500 "старых" объектов, затем один "новый" и т.д.
В итоге "старые" и "новые" объекты короткое время живут вместе внутри одних и тех же арен mimalloc-а. И когда после завершения первичной обработки "старые" объекты удаляются, то "новые" остаются. Именно они и являются той парой-тройкой все еще живых объектов внутри практически пустых арен.
В результате вроде как и утечек нет, все объекты корректно удалились. Но и сотни мегабайт неиспользуемой приложением памяти не были возвращены ОС.
К счастью выяснилось, что в реализации моего нового контейнера есть ошибка -- это самое дерево внутри содержало раз в 10 больше узлов, чем требуется. Тупо ошибся с расчетом размера одного узла дерева, поэтому узлы создавались слишком маленькими, а чтобы компенсировать уменьшившийся размер пришлось создавать больше узлов.
Отсюда и возникала пропорция одного "нового" объекта (тот самый узел дерева) к пятистам "старым" объектам.
После исправления найденной ошибки количество узлов резко сократилось и эффект, при котором есть множество практически пустых арен с парой-тройкой живых объектов внутри, исчез. Теперь количество таких арен измеряется буквально единицами.
Для меня главный практический вывод -- это то, насколько языки с GC (особенно с продвинутыми compacting GC) могут облегчить жизнь программиста. Да, там возникают другие проблемы. Но в данном случае compacting GC был бы очень в тему, на мой взгляд.
Ну а так-то конкретно повезло, что первопричиной была моя глупая ошибка. Было бы гораздо хуже, если бы такой эффект возникал бы при полностью корректной работе.
Т.е. сценарий, когда одним потоком в хипе аллоцируются коротко- и длинноживущие объекты, потенциально возможен. А значит и возможна ситуация, когда после удаления короткоживущих объектов приложение продолжит потреблять в разы больше памяти, чем нужно.
Если бы события развивались бы по такому худшему сценарию нам бы пришлось пересматривать политику создания "старых" объектов, которые нужны только на время первичной обработки данных.