Технологическая платформа 1С:Предприятие – это большой программный продукт (только на С++ - более 10 миллионов строк кода, а есть ещё Java и JavaScript). Подробнее про устройство платформы можно прочитать тут.
В процессе эксплуатации решений, созданных на платформе 1С:Предприятие, мы иногда сталкивались с тем, что в определенных сценариях потребление памяти процессами платформы казалось нам избыточным. К сожалению, простых способов выяснить, так ли это в действительности, для столь большого приложения у нас не было. Поэтому мы начали искать специализированные инструменты, которые могли бы помочь нам в анализе использования памяти, потребляемой нашими приложениями.
О том, какие инструменты мы пробовали использовать, почему они нам не подошли и как мы в итоге решили задачу анализа использования памяти – ниже.
Как видно из схемы, самая ресурсоемкая в работе часть платформы – это кластер серверов 1С, где исполняется основная бизнес-логика. Это нативные приложения, написанные на С++ и работающие в ОС Windows и Linux. Значит, постановка задачи звучит так: нужен инструмент анализа потребления памяти нативными приложениями, работающими в Windows и Linux.
Почему память «течёт» и как с этим бороться
Типичный источник избыточного потребления памяти — это утечки памяти, а также неоптимальные алгоритмы, приводящие к фрагментации памяти и/или избыточной аллокации.
С такими проблемами сталкиваются многие разработчики на С++, поэтому инструментов существует достаточно много, например:
Сами инструменты имеют кардинально различные принципы работы. Например, санитайзеры, к которым относится AddressSanitizerLeakSanitizer, могут добавлять дополнительные метки вокруг аллокаций памяти и таким образом ловить выходы за границы выделенной памяти и прочие проблемы. Плюс такого подхода в том, что он оказывает незначительное воздействие на быстродействие приложения. Главный минус – этот подход интрузивный (вставляет дополнительные инструкции в исполняемый код) и он неприемлем для релизных сборок. Но он хорошо подходит на этапе разработки.
Другим известным и уникальным с инженерной точки зрения инструментом является Valgrind. Фактически он выполняет код проверяемого приложения в песочнице, на виртуальном процессоре, собирая при этом множество метрик. Вместе с тем работа приложения под Valgrind существенно медленнее, чем без него (иногда – на порядки), и поэтому его использование невозможно не только в продуктиве, но и зачастую даже в процессе разработки и отладки.
Существуют инструменты, которые были вдохновлены Valgrind-ом, но разрабатывались с идеей минимального влияния на быстродействие профилируемого приложения. К ним относится HeapTrack из состава KDE. Минусом HeapTrack является то, что он привязан к определенной операционной системе. Более того, некоторые инструменты профилирования работы с памятью привязаны не только к конкретной ОС, но и к конкретному аллокатору, как, например, инструмент pprof, входящий в набор инструментов gperftools, разработанный компанией Google и предназначенный в первую очередь для работы с аллокатором tcmalloc.
На просторах github можно найти много решений с открытым кодом, но, к сожалению, они нам не подошли по ряду требований, которые мы предъявляли.
Одно из главных наших требований, обусловленное спецификой эксплуатации бизнес-приложений – это простота сбора информации в условиях реальной эксплуатации у конечного пользователя. Если пользователь (или его администратор) считают, что приложение потребляет слишком много памяти – должен быть простой способ собрать нужную нам, разработчикам платформы, информацию.
Итак, наши требования к инструменту:
Простой «Post mortem» анализ. Пользователь приложения не имеет у себя аналитического инструмента и не знает о нем. Нам пользователь присылает только дамп, который мы анализируем с помощью инструмента. Получение дампа памяти – задача достаточно тривиальная, и мы вполне можем возложить её на системного администратора на стороне наших клиентов.
Инструмент не оказывает какого-либо воздействия на приложения (не линкует к приложению ничего дополнительного в релизных версиях).
Не увеличивает расход ресурсов приложением.
Инструмент может анализировать уже существующие версии приложений (которые собирались до внедрения инструмента).
Инструмент показывает, по какому адресу в памяти дампа находятся объекты (с возможностью получить тип объекта).
Инструмент работает на Windows и Linux.
Убедившись, что ни одно решение, представленное на рынке, нам не подходит, мы начали делать свой инструмент анализа потребления памяти.
Подходя к вопросу разработки анализатора памяти мы разбили данную задачу на 4 этапа:
Определение адресов и размеров аллокаций
Определение типов объектов
Поиск связей между аллокациями
Анализ собранных данных
Определение адресов и размеров аллокаций
Первым делом необходимо определить адреса и размеры аллокаций внутри дампа. Что мы делаем для этого: берем максимально простое приложение:
Собираем его без оптимизации, чтобы компилятор гарантированно создал новый объект в heap-е. Далее мы останавливаемся непосредственно перед возвратом, снимаем дамп, а также запоминаем адрес, по которому была размещена наша структура, сохраненная в переменной foo. Теперь попробуем понять, где лежит наша единственная аллокация. Для этого мы открываем дамп дебаггером (в случае Windows это WinDbg) и выполняем команду «!heap». Эта команда покажет нам существующие сегменты кучи (heap). Пока мы не будем подробно останавливаться на деталях этой информации, но попросим более детально вывести информацию о том сегменте кучи, в котором находится адрес нашей переменной foo.
По факту мы получим вывод на несколько экранов, но среди этой информации мы увидим, что существует некий LFH(Low-Fragmentation Heap)-сегмент с данным адресом:
В целом этого небольшого эксперимента достаточно, чтобы понять, что необходимая нам информация в принципе может быть извлечена из дампа памяти.
Следующий вопрос, который предстоит решить – это как извлечь такую информацию программно, минуя использование WinDbg.
Когда речь заходит о поиске аллокаций в Windows, то первое, что нам необходимо изучить – это книга “Windows Internals”. Ключевые моменты из книги, которые важно понять для решения нашей задачи, изложены ниже.
В актуальных редакциях ОС Windows существуют два системных аллокатора:
NT Heap
Segment Heap
В случае с платформой 1С:Предприятие все наши аллокации будут происходить в рамках NT Heap. Segment Heap используется для приложений, созданных на платформе Universal Windows Platform (UWP).
Система аллокаций является многоуровневой. Когда мы запрашиваем память у ядра ОС, то память выделяется постранично, размер страницы 64 кб. Этот размер существенно больше, чем нужен для тех сравнительно простых структур, которые мы чаще всего используем. Поэтому существуют user space аллокатор, который также разделен на несколько уровней:
LFH (front-end)
User space backend
VirtualAlloc
Первый – это front-end, он же Low Fragmentation Heap (LFH). Этот уровень предназначен для небольших аллокаций вплоть до 16 Кб и в первую очередь он позволяет оптимизировать выделение памяти в тех случаях, когда мы активно используем память и освобождаем её, чтобы избежать фрагментации.
Если размер аллокации превышает 16 кб, но укладывается в некоторую верхнюю границу (около 2 Мб), то такие аллокации также обслуживаются user space аллокатором, но уже вторым его уровнем. Если мы попросим аллокацию большего размера, то произойдет обращение к ядру ОС и вызов VirtualAlloc.
Т.к. на прошлом шаге мы выяснили, что дебаггер имеет доступ к нужным нам данным, то у нас остался один вопрос: как получить эти данные программно. Для этого существует Debug Interface Access SDK, а также библиотека dbghelp. Для того, чтобы получить данные аллокатора, нам нужно проанализировать структуру heap-а. Отладочные символы ntdll, которые поставляются компанией Microsoft, содержат информацию об этой структуре. Увы, книга “Windows Internals” не дает внятного описания – что именно из ntdll нужно получить.
Но к счастью для нас, анализ работы аллокатора – это распространенная тема конференций и форумов, посвященных задачам защиты информации, т.к. атаки на аллокаторы – это довольно частый сценарий хакерских атак.
Мы воспользовались материалами исследований конференции Black Hat, которые достаточно детально описывают информацию, хранимую в структурах аллокатора. На основании этого для ОС Windows мы смогли реализовать программный алгоритм извлечения информации о том, какие аллокации, какого размера и по каким адресам находятся в дампе.
Задача поиска аллокаций в дампах на ОС Linux с одной стороны решалась проще, потому что для Linux есть в открытом доступе вся необходимая документация и исходный код ОС. С другой стороны, под Linux может существовать целый зоопарк аллокаторов.
Конечно, если вы возьмете приложение, использующее glibc, то с большой вероятностью там будет использоваться аллокатор ptmalloc. При этом без пересборки приложения аллокатор можно легко заменить на другой, используя переменную окружения LD_PRELOAD.
В рамках технологической платформы 1С:Предприятие мы достаточно давно рекомендовали использование аллокатора tcmalloc, разработанного компанией Google. При этом до последних версий платформы 1С:Предприятие мы не включали этот аллокатор в состав платформы и он подключался через LD_PRELOAD.
При разработке анализатора использования памяти мы сразу ориентировались на использование именно этого аллокатора, поэтому наш инструмент не является универсальным.
Определение типов объектов
Определив адреса и размеры аллокаций, мы можем перейти к задаче определения типов объектов, которые находятся в этих аллокациях.
В отличие от таких языков, как Java или C#, в C++ у нас отсутствует метаинформация. Но есть оператор typeid.
Предположим, что мы написали простейшую программу:
Здесь мы пробуем вывести имя типа, хранящегося в переменной foo. И это сработает!
Казалось бы – это ключ к решению нашей задачи. Но дело в том, что структура Foo является неполиморфным типом, а вся информация была получена на этапе компиляции и жестко внесена в программу. Поэтому применить подобный трюк для анализа дампа мы не сможем.
Если говорить о полиморфных объектах, то с ними ситуация несколько лучше. По умолчанию в таких аллокациях в начале будет лежать указатель на информацию в виде type_info. Казалось бы – оттуда можно считать нужные нам данные.
Но, к сожалению, платформа 1С:Предприятие исторически собирается без Run-Time Type Information (RTTI), т.е. в нашем случае информация о типах в дампах будут отсутствовать. Но это не единственный способ, которым можно воспользоваться.
После указателя на type_info в памяти хранится указатель на таблицу виртуальных методов. Если эти данные сопоставить с информацией, имеющейся в отладочных символах, то мы сможем точно понять, какой тип данных хранится в этой аллокации.
Более того, в силу специфики платформы 1С:Предприятие мы используем довольно большое количество объектов с динамическим полиморфизмом в качестве корневых объектов, которые называются SCOM-классами. Этот подход схож по идеологии с ATL, подробнее нем тут, в разделе «SCOM».
Как написано выше, для определения типа объекта по значению указателя на таблицу виртуальных методов нам необходима отладочная информация. Для чтения отладочной информации в различных ОС существуют свои механизмы. В Windows это Debug Interface Access (DIA), в Linux – libdwarf.
Минусом использования этих решений является скорость работы – оба они крайне медленные. Поэтому для нашей утилиты мы реализовали отдельный этап анализа, который берет отладочные символы и извлекает из них необходимую информацию. Далее она сохраняется в некий промежуточный формат, универсальный как для Windows, так и для Linux.
Поиск связей между аллокациями
Следующий этап работы анализатора – это поиск связей между аллокациями. Предположим, мы обнаружили утечку памяти при создании объекта определенного типа. Если этот проблемный объект является полем (свойством) другого класса – для полного расследования проблемы надо понять место, где был создан этот родительский класс.
Часть аллокаций (назовем их корневыми) мы идентифицировали. Но это далеко не все аллокации. Для того, чтобы проанализировать оставшуюся в дампе память, мы можем пройти по свойствам известных нам объектов и в случае, если они содержат ссылки или указатели, использовать эту информацию для расширения наших знаний о содержимом дампа. Конечно, внутри нам будут встречаться поля, которые содержат стандартные коллекции. Для них можно использовать оптимизированный подход получения указателей на элементы, ориентируясь на декларативное описание для дебаггера (NatVis).
В платформе 1С:Предприятие мы часто используем собственные реализации коллекций и контейнеров (например, нашу собственную реализацию строк – в этой статье есть подробное описание нашей реализации строки). Для того, чтобы избежать жесткой привязки алгоритмов обхода связей к конкретному приложению мы реализовали этот этап анализа в виде скриптуемого механизма. В качестве языка скриптов был выбран Lua.
В результате скрипты обхода дочерних аллокаций выглядят следующим образом (в примере мы разбираем элементы std::map).
Так это реализовано у нас на Lua:
А так выглядит описание аналогичной структуры в NatVis:
Разница здесь в том, что у нас существует достаточно большое количество дополнительных проверок, потому что в отличие от дебаггера, где реализован «приблизительный» взгляд на содержимое коллекции, нам нужно точно определить, что эта аллокация и коллекция в ней – валидны. Поэтому для map мы проверяем значения всех полей, включая «несущественные» с точки зрения дебаггера. Это и является одной из причин, по которой мы выбрали скриптовый подход, а не декларативный, как в NatVis.
Анализ собранных данных
Когда вся информация об аллокации данных внутри дампа идентифицирована, нам необходимо её проанализировать.
Когда перед нами встает задача анализа данных – то, наверное, первое, что приходит в голову – это сложить данные в СУБД и выполнить над СУБД какие-либо аналитические запросы.
Так мы и поступили, сложив данные в СУБД PostgreSQL. Почему именно PostgreSQL, а не специализированная колоночная или графовая СУБД? Дело в том, что после того, как мы обдумали, какая информация нам интересна по итогу расследования дампа, оказалось, что в большинстве случаев она покрывается несколькими относительно простыми запросами. И накладные расходы на помещение данных в специализированное хранилище превышают время выполнения этих простых запросов в PostgreSQL. Поэтому здесь мы не стали придумывать сложные решения.
Под спойлером – пример из личного опыта 20-летней давности одного из авторов статьи, как он искал утечки памяти на мобильном приложении с помощью MS SQL!
История из глубины веков
Лет 20 назад встала задача сделать 2D-движок для только появившихся тогда смартфонов с ОС Symbian.
Решили не изобретать велосипед, взяли opensource движок на С, портировали на Symbian. После пары минут работы движка приложение падало по нехватке памяти. Похоже, не все участники проекта аккуратно освобождали память, модифицируя код других участников. Опенсорс такой опенсорс. Памяти тогда в смартфонах было немного, сильно меньше, чем в тогдашних компьютерах.
Сделали так (интрузивным правда методом).
В С-шном приложении память выделялась через malloc, освобождалась через free.
В malloc передается количество памяти, которое нужно выделить, malloc возвращает указатель на начало выделенной области. Во free передается указатель на область, подлежащую освобождению.
После каждого вызова malloc вставили запись в лог-файл: имя файла С-шного исходника, номер строки в исходнике, и по какому адресу выделена память.
После каждого вызова free в лог-файл писали имя файла С-шного исходника, номер строки в исходнике и адрес, по которому память освободили.
Когда программа на смартфоне падала по нехватке памяти, лог-файл скачивали со смартфона на компьютер и заливали в простую табличку в MS SQL.
В идеальной ситуации на каждый адрес, возвращённый malloc, должен быть вызван free.
Простым SQL-запросом по табличке выбирали сиротливые malloc, у которых не было парных вызовов free, и фиксили проблему в исходниках.
В итоге довольно быстро код движка был доведен до состояния, когда вся выделенная память аккуратно освобождалась по окончании её использования, и программа на смартфоне падать по нехватке памяти перестала.
Happy end!
Помимо этого, существуют ещё несколько механизмов для анализа собранных данных. Так, например, мы умеем строить графы связей между аллокациями, которые помогают разработчику понять – откуда, из каких аллокаций мы пришли в данную проблемную область памяти, и с какими объектами эта область памяти связана.
Также есть механизм извлечения данных. Например, можно извлечь всё содержимое строк внутри дампа памяти. Вот как это выглядит на примере.
У нас есть относительно простая программа.
В программе есть структура Derived, которая наследуется от структуры Base. Структура Base является чисто виртуальной, это необходимо для того, чтобы наша целевая структура Derived использовала динамический полиморфизм и хранила указатель на таблицу виртуальных методов. Реализация метода foo() нас в данном случае не интересует.
Внутри структуры Derived размещён std::vector, содержащий указатели на структуры типа Foo, эти структуры не содержат виртуальных функций. Далее в heap-е выделяется место под структуру типа Derived и внутрь вектора помещаются три элемента – указателя на структуру типа Foo.
После этого мы снимаем дамп и вызываем наш анализатор. Результат анализа:
Что мы здесь видим?
Во-первых, помимо ожидаемых структур Foo и Derived мы видим дополнительную информацию. Это некоторые системные/служебные структуры, которые нам не интересны, в реальных дампах они будут находиться на уровне статистической погрешности.
Вся остальная информация соответствует нашим ожиданиям. Мы видим, что у нас есть один объект типа Derived, который занимает в памяти 48 байт. При этом мы можем узнать его точный адрес (на экране эта информация не приведена). Далее – у нас существуют три объекта типа Foo, что тоже соответствует нашим ожиданиям. Также существует указатель на объект Foo, который фактически является телом вектора и представлен в единичном экземпляре.
Также мы можем построить граф связи между аллокациями. Например, если нам нужна информация – каким образом мы можем прийти к одному из объектов типа Foo, который находится по адресу 0x2a5b3b9e670, то мы получим соответствующий граф (для построения графов мы используем Graphviz):
Читаем граф сверху вниз. По адресу ****fa90 у нас находится структура типа Derived.
Внутри неё по смещению 0x8 находится std::vector указателей на объекты типа Foo. Из него выходят три ссылки, ведущие на одну и ту же область памяти – это тело вектора, где, собственно, и лежат наши указатели. Эта область памяти находится по адресу ****1960, и от неё по первому указателю мы переходи к интересующему нас элементу.
А теперь переходим к реальным задачам!
Анализ дампа реального процесса платформы, например, процесса кластера серверов rphost – довольно ресурсоемкая задача. До оптимизации нашей утилиты прогнозы по анализу 100-гигабайтного дампа составляли несколько дней. После оптимизации время анализа уменьшилось до двух часов. При этом подразумевается, что дамп размером около 100 Гб содержит корректно работающий процесс платформы без каких-либо аномалий. Что мы подразумеваем под аномалиями? Например, мы сталкивались с ситуацией, когда библиотека для работы с электронной почтой последовательно создавала ряд больших аллокаций, в результате 100-гигабайтный дамп на 90% был занят небольшим числом аллокаций и поэтому расследовался очень быстро. Но это – пример нетипичной ситуации.
В типичной ситуации память занята большим количеством сравнительно небольших по размеру аллокаций.
Итак, что нам требуется для быстрого анализа ситуации типичной? Желательно – серверное железо, процесс анализа хорошо параллелится, поэтому чем больше ядер – тем лучше. С точки зрения требований к оперативной памяти желательно иметь её в таком количестве, чтобы всё дерево аллокаций помещалось в оперативную память. Для этого 100 Гб памяти более чем достаточно. Поскольку сам файл дампа может не поместиться в память, излишки данных будут помещаться на диск. Поэтому диск желателен быстрый, SSD, а лучше – NVMe.
Разработанный нами инструмент анализа уже успешно применяется для оптимизации кода платформы 1С:Предприятие.
Так, например, нами была решена задача сокращения потребления памяти рабочими процессами разделенной информационной базы, используемой в облачном решении. Это высоконагруженное решение, используемое тысячами пользователей.
До версии платформы 8.3.20 рабочий процесс в этом решении мог занимать около 60 Гб.
Мы рассмотрели дампы памяти с помощью нашего инструмента. В результате в версии платформы 8.3.20 мы улучшили утилизацию памяти за счет выявления узких мест и оптимизации алгоритмов – рабочий процесс стал занимать около 45 Гб.
Мы планируем улучшить и этот показатель, доведя потребление памяти рабочим процессом до 15 Гб (по нашим оптимистичным прогнозам).
С помощью нашего инструмента мы нашли места, где неоптимально используется память (хотя по исходному коду этого можно и не увидеть), поменяли/улучшили алгоритмы, и в результате потребление памяти уменьшилось на десятки процентов.
Код может быть написан правильно, в соответствии с best practices, но при этом почти всегда существует возможность оптимизации. Важно понять, где оптимизация даст существенный эффект, не привнеся каких-либо проблем.
На этом на сегодня все, до новых встреч в нашем блоге!