Дебаггинг памяти .NET Core приложения в Linux

Большую часть прошлой недели мне пришлось экспериментировать с виндовым .NET проектом под Linux и в Kubernetes. Это на самом деле не такая уж и дурка, как кажется поначалу. Проект мы заранее перевели на кросс-платформенный .NET Core, я отловил практически все проблемы, которые вплыли в никсе под контейнерами, и по итогу в K8s проект действительно заработал. Почти.

На практике всё равно остались мелкие неприятности. Эпизодически выскакивали всякие StackOverflow (хорошо хоть не segmentation faults), да и мой дебагерский виндовый опыт оказался практически бесполезным на никсах.

Например, практически сразу мы заметили, что, запускаясь под контейнером, проект с ходу отъедал 300 мегабайт физической памяти и около 2-х гигов виртуальной. В виндовом продакшене, конечно, под нагрузкой бывало и на порядки больше, но вот тут, на Linux, в демо-режиме, это много, мало, или вообще как? Как это проверить в принципе? На Винде я бы сделал дамп процесса, запустил Visual Studio или WinDBG и гуглил, что теперь делать дальше. А что тут?

Как выяснилось, гугл под Linux тоже работает, так что через пару часов медитации на монитор я выучил несколько интересных штук, о которых и хотел бы рассказать сегодня.

Песочница (дебаггинг пойдёт потом)

Естественно, коммерческий проект в качестве примера я притянуть сюда не могу, но  это и не нужно — практически любой «Hello world» на .NET Core тоже подойдёт. Я просто создам виртуальную Ubuntu 16.04 (при помощи Vagrant и VirtualBox), положу туда тестовый проект, и будем экспериментировать уже там.

Виртуальная машина с Ubuntu

…запросто сделается вот этим Vagrantfile:

В нём нет ничего особенного: обычная виртуалка с 3-мя гигами памяти и установленными .NET Core 2.0.2 SDK, vim, gdb и lldb-3.6. Впоследствии станет понятно, зачем они нужны.

Теперь создаём машину при помощи vagrant up , заходим внутрь через vagrant ssh и начинаем делать тестовый проект.

Тестовый проект

Как я уже сказал, любой hello-world на .NET Core подойдёт, но в идеале было бы здорово, если бы у него в памяти был какой-нибудь шлак для исследования, да и сам процесс не завершался мгновенно, дабы успеть сделать его дамп.

dotnet new console -o memApp сделает практически всё, что нужно. Я только добавил туда массив с текстовым мусором и дописал ReadLine в конце, чтобы процесс не завершался, пока не скажут:

Теперь пример можно сбилдить, запустить и приступать к экспериментам:

Делаем дамп процесса (core dump)

Для начала посмотрим, сколько же памяти процесс уже умудрился отожрать:

Весьма неплохо: ~2.6 гига виртуальной памяти и примерно 238 мегабайт физической. Конечно, размер виртуальной памяти не означает, что вся эта память теперь недоступна другим, или мы её хоть как-то используем, но это точно повлияет на размер дампа и он скорее всего займёт те же 2.6 гига.

Самый простой способ сделать дамп на никсе — воспользоваться утилитой gcore. Она идёт в комплекте с gdb дебаггером и это единственная причина, почему я его устанавливал.

Но запустить gcore без sudo , скорее всего, не получится, а в Kubernetes даже sudo было недостаточно — приходилось лезть в sysctl.conf и играть с настройками:

Но в Ubuntu всё хорошо, и sudo gcore с айдишкой процесса делает всё, как нужно:

И, как я и говорил раньше, размер дампа оказался равен размеру виртуальной памяти:

Это, кстати, было проблемой в нашем тестовом Kubernetes кластере, с .NET’ом, сборщик мусора которого работал в серверном режиме. У сервера было аж 208 гигов RAM, и при таких настройках процесс со старта обозначил себе 49 гигов виртуальной памяти и, соответственно, его дампы занимали столько же. Зато, выключив серверный режим (gcServer - false), аппетиты процесса упали в десять раз — до более вменяемых 5-ти гигов.

Но я отвлёкся. У нас есть дамп, будем препарировать.

LLDB и поддержка .NET

С дампом могут работать как gdb, так и  lldb дебаггеры, но только lldb можно научить любить .NET, так что выбор простой. «Любовь» обеспечивается специальным SOS плагином — libsosplugin.so. Проблема в том, что этот плагин, так же как и дотнетовский CoreCLR, собирается под конкретный LLDB, и поэтому если нет желания перекомпилировать ещё и рантайм (не так уж и сложно), то придётся брать не самый свежий  lldb-3.6.

Кстати, аббревиатуру SOS я видел ещё на винде, и всё гадал, что же она значит. Оказалось, что SOS не имеет никакого отношения ни к ABBA, ни к азбуке Морзе и сигналу «спасите наши души». Нашёлся интереснейший ответ на StackOverflow, который раскрыл, что SOS означает Son of Strike. Кто такой Страйк? О, это ещё более классная история. Кодовое имя для .NET 1.0 было Lightning — молния. А его дебаггер звали Strike — типа Strike of Lightning, удар молнии. Ну а SOS для последующий версий дотнэта уже создавался на базе Страйка. Всё просто.

Именно такие истории мне помогают оставаться в профессии в моменты, когда хочется всё бросить и уйти в геологи или хотя бы в судмедэксперты. История про браузеровский userAgent когда-то тоже помогла. Но я снова отвлёкся.

Итак, у нас есть lldb, сам dotnet и дамп приложения. Где искать SOS плагин? А он идёт в комплекте с .NET Core SDK, и его просто надо поискать среди файлов:

Всё, теперь можно запускать lldb, показать ему, где находится dotnet (он же был точкой входа в тестовый проект), дамп, и грузим плагин:

А вот теперь начнётся самое интересное.

Смотрим на managed объекты

SOS добавил несколько новых комманд, которые в курсе, что .NET работает с управляемой памятью и объектами. Благодаря этому мы будем не просто таращиться в биты и байты, а сможем посмотреть их .NET тип (например, System.String).

Команда soshelp выводит вполне полезную справку по .NET командам и даже может сказать что-то более подробное про каждую из них — soshelp commandname. Ну кроме тех моментов, когда она это не делает.

Например, команда DumpHeap — самая первая команда для анализа памяти, идёт вообще без справки. К счастью, справка нашлась прямо возле исходников на гитхабе.

Статистика объектов

Итак, всё работает. Посмотрим, что вообще в памяти творится — для этого есть команда DumpHeap:

Как и ожидалось, больше всего памяти ушло на System.String объекты. Зря что ли создавал их. Что интересно, если просуммировать размер всех объектов, то получится примерно 202 MiB, что не намного меньше размера памяти, о которой отрапортовала ps u — 238 MiB. Я так полагаю, что дельта ушла на сам код и, собственно, среду выполнения.

Более детальный взгляд на объекты

Но мы можем пойти ещё дальше. Строки занимают практически всю память — значит будем работать только с ними:

Параметр -type работает как маска, поэтому кроме System.String в результат попал и System.String[]. Но строк много, а мне интересны только большие. Например, от тысячи байт:

Имея на руках список подозрительно одинаковых строк, можно посмотреть на них ещё ближе.

DumpObj

DumpObj может заглянуть в детали объекта по конкретному адресу памяти. Адресов у нас целый первый столбец из предыдущей команды, так что я взял первый попавшийся:

Вообще-то, это реально круто. Команда тут же выводит, что за объект находится по этому адресу, его размер, список полей и их смещения по адресам. Ещё я заметил, что для маленьких строк их содержимое выводится тут же, седьмой строкой. Но в нашем случае нужны дополнительные телодвижения.

Какие именно телодвижения нужны, мне пришлось некоторое время подумать. Например, есть поле m_firstChar (строка 11). А где m_secondChar? Это связанный список, или как? А где указатель вообще на всё? Но, слазив в исходники System.String, оказалось, что m_firstChar можно и использовать как указатель на непрерывный блок памяти, и вся строка будет там.

Чтобы посмотреть, что лежит в памяти по случайному адресу, можно взять родную lldb’шную команду memory read, а чтобы найти адрес начала строки, мы просто берём адрес самого объекта, (00007f6d0e8810f0), и добавляем к нему смещение m_firstChar (0xC) поля, и всё:

Если проскроллить вправо, то выглядит знакомо, правда? «R.a.n.d.o.m. .s.t.r.i.n.g.». Шарповый тип char — по-умолчанию двух-байтовый юникодовый (UTF16), и по этому для ASCII диапазона старший байт будет нулём. Что, собственно, мы и видим.

С memory read можно поиграться немного дольше — добавить форматирование, количество выводимых байт,  но, в принципе, и так уже всё ясно.

Мораль

Я только-только начал экспериментировать со всем этим, но уже в восторге. Всё-таки я работал с .NET достаточно долго, и только сейчас начал задумываться, а что же происходит внутри него. Из чего сделана System.String? Что там за поля? Как они выровнены в памяти? Смещение первого поля в String — 8 байтов. А на что эти 8 байт используются? На идентификатор типа?

А ещё, дотнетовские строки же интернированы. Значит ли это, что m_firstChar будет указывать на тот же участок памяти для одинаковых строк? Или это только на этапе компиляции? А если проверю?

Ещё интересно (я туда пока не полез), как вообще выглядит сам дебаггинг .NET в lldb. Как оно выглядит для C++, я примерно представляю, когда-то сталкивался. Но .NET ведь компилируется на лету, Just-in-time. Как SOS справляется с этим? В общем, куча интересных вопросов.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *