Что делает большинство программистов, когда узнают, что в их программе течёт память? Ничего, пусть пользователь покупает больше оперативы. Посмею предположить, что берут надёжный проверенный временем инструмент, такой как valgrind или libasan, запускают и смотрят отчёт. Там обычно написано, что объекты созданные на такой-то строчке программы такого-то файла не были освобождены. А почему? Вот этого нигде не написано.
Данный пост посвящён инструменту поиска утечек topleaked, идее статистического анализа, лежащего в его основе, и методам применения этого анализа.
Про topleaked я уже писал на хабре, но всё же повторю основную идею в общих чертах. Если какие-то объекты не освобождаются, то они копятся в памяти. А значит у нас много однородных, похожих друг на друга последовательностей. Если утечёт больше, чем реально используется, то самые частые из них — это части утёкших объектов. Обычно в программах на C++ находятся указатели на vtbl классов. Таким образом можно выяснить, объекты какого типа мы забываем освобождать. Понятное дело, что в топе много мусора, часто встречающихся строк, да и тот же valgrind расскажет нам, что и где утекло гораздо лучше. Но topleaked изначально создавался не для того, чтобы соперничать с отработанными годами технологиями. Он был придуман, как инструмент решения задачи, которая ничем другим не решается — анализ невоспроизводимых утечек. Если в тестовом окружении повторить проблему не удаётся, то любой динамический анализ бесполезен. Если ошибка возникает только "в бою", да ещё и нестабильно, то максимум, что мы можем получить — логи и дамп памяти. Вот этот дамп можно анализировать в topleaked.
Возьмём простую C++ программу с утечкой памяти, которая сама завершится с записью дампа памяти из-за abort()
#include <iostream>
#include <assert.h>
#include <unistd.h>
class A {
size_t val = 12345678910;
virtual ~A(){}
};
int main() {
for (size_t i =0; i < 1000000; i++) {
new A();
}
std::cout << getpid() << std::endl;
abort();
}
Запустим topleaked
./toleaked leak.core
Формат вывода по умолчанию — построчный топ в человекочитаемом виде.
0x0000000000000000 : 1050347
0x0000000000000021 : 1000003
0x00000002dfdc1c3e : 1000000
0x0000558087922d90 : 1000000
0x0000000000000002 : 198
0x0000000000000001 : 180
0x00007f4247c6a000 : 164
0x0000000000000008 : 160
0x00007f4247c5c438 : 153
0xffffffffffffffff : 141
Пользы от него мало, разве что мы можем увидеть число 0x2dfdc1c3e, оно же 12345678910, встречающееся миллион раз. Уже этого могло бы хватить, но хочется большего. Для того, чтобы увидеть имена классов утекших объектов, можно отдать результат в gdb простым перенаправлением стандартного потока вывода на вход gdb с открытым файлом дампа. -ogdb — опция, меняющая формат на понятный gdb.
$ ./topleaked -n10 -ogdb /home/core/leak.1002.core | gdb leak /home/core/leak.1002.core
...<много текста от gdb при запуске>
#0 0x00007f424784e6f4 in __GI___nanosleep (requested_time=requested_time@entry=0x7ffcfffedb50, remaining=remaining@entry=0x7ffcfffedb50) at ../sysdeps/unix/sysv/linux/nanosleep.c:28
28 ../sysdeps/unix/sysv/linux/nanosleep.c: No such file or directory.
(gdb) $1 = 1050347
(gdb) 0x0: Cannot access memory at address 0x0
(gdb) No symbol matches 0x0000000000000000.
(gdb) $2 = 1000003
(gdb) 0x21: Cannot access memory at address 0x21
(gdb) No symbol matches 0x0000000000000021.
(gdb) $3 = 1000000
(gdb) 0x2dfdc1c3e: Cannot access memory at address 0x2dfdc1c3e
(gdb) No symbol matches 0x00000002dfdc1c3e.
(gdb) $4 = 1000000
(gdb) 0x558087922d90 <_ZTV1A+16>: 0x87721bfa
(gdb) vtable for A + 16 in section .data.rel.ro of /home/g.smorkalov/dlang/topleaked/leak
(gdb) $5 = 198
(gdb) 0x2: Cannot access memory at address 0x2
(gdb) No symbol matches 0x0000000000000002.
(gdb) $6 = 180
(gdb) 0x1: Cannot access memory at address 0x1
(gdb) No symbol matches 0x0000000000000001.
(gdb) $7 = 164
(gdb) 0x7f4247c6a000: 0x47ae6000
(gdb) No symbol matches 0x00007f4247c6a000.
(gdb) $8 = 160
(gdb) 0x8: Cannot access memory at address 0x8
(gdb) No symbol matches 0x0000000000000008.
(gdb) $9 = 153
(gdb) 0x7f4247c5c438 <_ZTVN10__cxxabiv120__si_class_type_infoE+16>: 0x47b79660
(gdb) vtable for __cxxabiv1::__si_class_type_info + 16 in section .data.rel.ro of /usr/lib/x86_64-linux-gnu/libstdc++.so.6
(gdb) $10 = 141
(gdb) 0xffffffffffffffff: Cannot access memory at address 0xffffffffffffffff
(gdb) No symbol matches 0xffffffffffffffff.
(gdb) quit
Читать не очень просто, но возможно. Строки вида $4 = 1000000 отражают позицию в топе и количество найденных вхождений. Ниже идут результаты запуска x и info symbol для значения. Тут мы можем видеть, что миллион раз встречается vtable for A, что соответствует миллиону утекших объектов класса A.
Про всё это я уже писал. Как верно отметили в комментариях, идее уже сто лет в обед, ну или как минимум 15. История на этом не заканчивается, она только начинается.
Ясно что, но почему?
До ката был поставлен важный вопрос — почему память утекает? Отладочные утилиты как правило говорят, где был объект или массив создан, но не где он должен быть удалён. И topleaked тут не исключение. Понять, почему тот или иной кусок памяти не был освобождён, может только программист, найдя ошибочный сценарий. Но что если пойти дальше поиска типов? Если мы можем пройтись по всем объектам, которые считаем утёкшими, то мы можем искать общие черты среди них. Приведу реальный пример, историю, ради которой была написана новая фича.
Ближе к делу и… проблемам
Есть сервис. Он существенно нагружен, через него проходят сотни тысяч пользователей. Утечка любой мелочи на каждый запрос или подключение смерти подобна — взорвётся за считанные минуты. Сервис был отлажен и проработал 3 месяца без перезапуска процесса. Не рекордный аптайм, но всё же. И вот через 3 месяца мы выясняем, что всё это время он по чуть-чуть подтекал. Вроде бы мелочь, он превысил своё штатное потребление раза в 2-3 — перезапустил и забыл. Но вместе с памятью текли файловые дескрипторы. Поскольку сервис полностью сетевой, то эти дескрипторы — незакрытые сокеты, а значит у нас проблема в логике. Сервис написан почти полностью на C++ с очень небольшими вкраплениями перла. Это на самом деле мало влияет на последующее повествование, но от конкретики отталкиваться проще. С тем же успехом можно было допустить ту же ошибку на C, D, Rust, Go или NodeJS. И искать её можно точно так же, разве что с js были бы проблемы.
Метод глядения в код нам ничего не дал. Все возможные, как мне тогда казалось, сценарии использования сетевых соединений приводят к потере ссылок на объект, что благодаря умному указателю приводит к деструктору, который безусловно сделает close. Анализ и мониторинг дал оценку, что не закрывается примерно каждый сотый сокет. Сессии долгие (игровые сессии клиентов игры), поэтому для того, чтобы упереться в ограничение открытых fd на процесс (512000 в нашем случае) понадобились месяцы. Найти в логах признаки от этих незакрытых клиентов тоже не удавалось. На первый взгляд всё открытое закрывалось. Смотреть было больше некуда, и я полез читать дамп памяти процесса, снятый незадолго до достижения максимума открытых соединений.
Набираем статистику
Первый запуск topleaked сообщил очевидный факт — утекают объекты клиентских подключений. Спасибо, капитан, это мы уже и так знали по незакрытым сокетам. Нас интересует специфика этих подключений, ведь основная масса исправно помирает, когда положено. И вот тут зародилась идея: что если пройтись по всем этим объектам в дампе и посмотреть их состояние. В данном случае у нас в классе было свойство state — enum, отвечающий за логическое состояние клиента. Условно говоря: не подключен, подключен, прошёл хэндшейк websocket, прошла авторизация. Если знать, из какого состояния объекты утекают, то и искать проще.
Тут есть загвоздка. Topleaked не понимает форматов дампов, он просто открывает файл как бинарный поток, режет по 8 байт и строит топ самых частых 8-байтовых последовательностей. Это не какой-то сложный замысел, так было проще написать первую версию, ну а дальше нет ничего более постоянного, чем что-то временное. Вот только из-за отсутствия структуры невозможно понять, где лежат нужные нам значения. Всё, что у нас есть это значение указателя на vtbl, интересующего нас класса. А ещё мы знаем, что эти указатели, как и все свойства “лежат в объекте”. То есть можно поискать в дампе интересующий указатель на vtbl и по какому-то смещению относительно найденной позиции в файле будет лежать state. Это смещение фиксированное, так как зависит только от лейаута класса. Осталось только найти это смещение.
В случае C++ есть проблема — отсутствие ABI или каких-нибудь внятных правил расположения свойств в объектах. Для POD или trivial типов есть чёткие правила ещё из мира C. А вот расположение указателя на виртуальную таблицу, как и само существование виртуальной таблицы, не стандартизировано. К счастью на практике всё просто. Если не сильно мудрить с множественным наследованием и рассматривать конечный класс в иерархии, то на linux gcc выяснится, что vtbl — первое свойство объекта. А значит offsetof(state) и есть наше смещение. На более простом примере это выглядит так:
struct Base {
virtual void foo() = 0;
};
struct Der : Base {
size_t a = 15;
void foo() override {
}
};
int main()
{
for (size_t i = 0; i < 10000; ++i) {
new Der;
}
auto d = new Der;
cout << offsetof(Der, a) << endl;
abort();
return 0;
}
Здесь мы распечатали offsetof Der::a, “утекли” 10000 объектов и упали. Для начала запустим topleaked в штатном режиме
topleaked my_core.core
0x0000000000000000 : 50124
0x000000000000000f : 10005
0x0000000000000021 : 10004
0x000055697c45cd78 : 10002
0x0000000000000002 : 195
0x0000000000000001 : 182
0x00007fe9cbd6c000 : 167
0x0000000000000008 : 161
0x00007fe9cbd5e438 : 154
0x0000000000001000 : 112
0x000055697c45cd78 это указатель на vtbl класса Der. offsetof равен 8. Значит нужно поискать этот указатель, отступить на 8 и прочитать значение. Для поиска воспользуемся отдельным режимом работы topleaked — поиском. Флаг -f отвечает за то, что будем искать в дампе, --memberOffset — смещение интересующего поля относительно найденного в -f, а --memberType — тип поля. Поддерживаются uint8, uint16, uint32 и uint64.
topleaked my_core.core -f0x55697c45cd78 --memberOffset=8 --memberType=uint64
Получаем:
0x000000000000000f : 10001
0x000055697ccaa080 : 1
Мы видим 10000 значений 0x0f, которые сами и записали, а так же небольшой шум.
Happy End
В реальной ситуации всё работает примерно так же. Сначала в тестовом окружении я убедился, что смещение корректно и поиск находит то, что нужно, а потом запустил на реальном дампе. Полученный вывод сначала удивил, а потом порадовал. Нашлось несколько тысяч авторизованных клиентов, цифры соответствовали количеству онлайн пользователей на момент падения. Но самое главное, что нашлись сотни тысяч не просто неавторизованных, а объектов в самом первом состоянии. Это состояние означает, что клиенты подключились к серверу по TCP, но не послали ни байта — ни websocket upgrade, ни чего-нибудь неожиданного. Они подключились и молчали. Это самое простое место для отладки — нашего кода минимум, значит и ошибаться негде. Оказалось всё просто, автор кода (каюсь, это был я) не понимал гарантий TCP. Если не включать дополнительных опций и не пытаться ничего делать с сокетом, то невозможно никак понять, что он отключился. Нет встроенных пингов или таймаутов неактивности по умолчанию. Есть только расширение, которое все поддерживают, но которое выключено — TCP Keep Alive. Подробнее можно прочитать https://blog.cloudflare.com/when-tcp-sockets-refuse-to-die/
Самое неприятное то, что на самом деле мы про это знали. В игровой протокол добавлена похожая логика с проверкой неактивных сокетов, и она работает. Только включается она после установления websocket соединения. Поэтому и утечке были подвержены только те, кто потерял связь до посылки первого пакета.
Ещё немного про D
Не могу не отметить, как просто было добавить описанный выше функционал. Если посмотреть коммит, то мы увидим, что для каждого поддерживаемого типа данных (uint 8/16/32/64) добавилось по строке:
readFile(name, offset, limit)
.findMember!uint64_t(pattern, memberOffset)
.findMostFrequent(size).printResult(format);
findMember — новая функция, реализующая смещение, а findMostFrequent — та же самая функция, которая строит топ самых частых значений. Благодаря шаблонам и алгоритмам на диапазонах (ranges) не потребовалось ничего менять. При том, что изначально эта функция работала с массивами, а теперь ей отдали весьма своеобразный итератор, который ищет и прыгает по файлу.
Бинарных сборок нет, поэтому так или иначе понадобится собрать проект из исходников. Для этого потребуется компилятор D. Варианта три: dmd — референсный компилятор, ldc — основанный на llvm и gdc, входящий в gcc, начиная с 9-й версии. Так что, возможно, вам не придётся ничего устанавливать, если есть последний gcc. Если же устанавливать, то я рекомендую ldc, так как он лучше оптимизирует. Все три можно найти на официальном сайте.
Вместе с компилятором поставляется пакетный менеджер dub. При помощи него topleaked устанавливается одной командой:
dub fetch topleaked
В дальнейшем для запуска будем использовать команду:
dub run topleaked -brelease-nobounds -- <filename> [<options>...]
Чтобы не повторять dub run и аргумент компилятора brelease-nobounds можно скачать исходники с гитхаба и собрать запускаемый файл:
dub build -brelease-nobounds
В корне папки проекта появится запускаемый topleaked.
P.S. Спасибо Crazy Panda за возможность делать и использовать такие штуки в работе, а также за мотивацию к написанию постов. Иначе бы текст пылился еще год на жёстком диске, как это было с прошлым постом про topleaked.