.NET Core становится всё более и более зрелой платформой. На нём уже достаточно комфортно можно вести разработку, используя тот же Rider или VS Code.
Однако, и там не всё гладко. Например, отладка кода на .NET Core 2 заработала только в Rider 2017.2, который вышел, буквально на днях (были ещё EAP сборки). Приходилось пользоваться VS Code. В нём работает отладка, однако, чтобы заработал запуск тестов надо руками ставить beta-версию расширения для C#.
Я думаю, суть ясна, что инструментальная поддержка пока сильно далека от аналогичной при разработке под Windows.
Для некоторых вещей пока нету готовых средств. Например, для профилирования.
Из источников, которые доступны в сети, самыми содержательными, по моему мнению, на текущий момент являются статьи Саши Гольдштейна:
- Analyzing a .NET Core Core Dump on Linux
- Profiling a .NET Core Application on Linux
- Tracing Runtime Events in .NET Core on Linux
- Tracing .NET Core on Linux with USDT and BCC
Однако, готового рецепта по поиску утечки памяти мне найти не удалось. Поэтому я решил описать найденный мной способ.
Как бы мы действовали под Windows
Лично я бы, не долго думая, подключился к работающему приложению с помощью dotMemory, снял бы 2 снапшота через некоторый промежуток времени и сравнил бы их, используя, красивый GUI.
Как здорово было бы, если бы под Linux мы могли бы сделать что-нибудь похожее.
Давайте попробуем.
Пример, который будем рассматривать
Тут ничего сложного. Конечно, не нужно прибегать ни к каким инструментам, чтобы найти утечку в следующем коде. Но для обучающих целей сгодится.
using System;
namespace leak_example
{
class Program
{
static void Main(string[] args)
{
Function1();
}
private static void Function1()
{
var leakClass = new LeakClass();
leakClass.DoWork();
}
}
}
using System.Collections.Concurrent;
using System.Threading.Tasks;
namespace leak_example
{
public class LeakClass
{
private BlockingCollection<string> _collection;
public LeakClass()
{
_collection = new BlockingCollection<string>(new ConcurrentQueue<string>());
}
public void DoWork()
{
while(true) {
_collection.Add(System.Guid.NewGuid().ToString());
Thread.Sleep(20);
}
}
}
}
Как сделать снапшот работающего приложения под Linux
Сделать подобный снапшот (core dump) под Linux для работающего приложения достаточно легко. Это делают следующие 2 команды:
$ ulimit -c unlimited
$ sudo gcore -o dump1 $(pidof dotnet)
И через некоторое время делаем второй снапшот
$ sudo gcore -o dump2 $(pidof dotnet)
Мы получили 2 дампа нашего приложения:
$ ls -lah dump*
-rw-r--r-- 1 root root 5,7G окт 18 17:01 dump1.13486
-rw-r--r-- 1 root root 6,2G окт 18 17:03 dump2.13486
которые теперь можем попытаться сравнить.
Как в теории заглянуть в снапшот .NET Core приложения
Microsoft предоставляет плагин для LLDB, который может нам с этим помочь. Это портированное расширение SOS из WinDBG с аналогичным набором команд.
В теории, чтобы посмотреть аллокации памяти, имея полученный выше снапшот мы должны были бы выполнить следующие команды:
$ lldb $(which dotnet) --core <dump>
(lldb) plugin load libsosplugin.so
(lldb) sos DumpHeap -stat
В статьях у Саши Гольдштейна ещё устанавливается путь к CLR командой
(lldb) setclrpath /usr/share/dotnet/shared/Microsoft.NETCore.App/2.0.0
Но для исследования проблем с Debug-сборкой моего тестового приложения мне это не понадобилось.
Суровая реальность
Microsoft поставляет
libsoplugin.so
вместе с .NET Core. Так что, скорее всего он у вас есть в системе.
Как писал Саша в своей статье, к сожалению, этот плагин линкуется с конкретной версией LLVM. Соответственно, потребуется конкретная версия LLDB, чтобы им воспользоваться.
Посмотреть конкретную версию, уже не получится как раньше, с помощью команды
ldd
:
$ ldd /usr/share/dotnet/shared/Microsoft.NETCore.App/2.0.0/libsosplugin.so linux-vdso.so.1 => (0x00007ffc31dcb000) libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f65b93bb000) libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f65b90b2000) libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f65b8e9c000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f65b8ad2000) /lib64/ld-linux-x86-64.so.2 (0x00007f65b994e000)
Дело в том, что
liblldd.so
в разных дистрибутивах назывался по-разному и его убрали из явных зависимостей.
В каком-то из issues на GitHub есть информация, что в .NET Core 2.0 этот плагин собран с lldb-3.8, а версия в .NET Core 2.0.1 уже будет собрана с lldb-3.9.
Казалось бы, теперь мы знаем версию и можем просто поставить в систему lldb нужной версии. Но нет. Дело в том, что lldb до версии 4.0 не мог грузить on-demand core dump. Как раз такие, какие мы и сделали (в процессе работы программы).
- Вот и получается, что пути у нас 2, либо собрать плагин для lldb-4.0, либо пропатчить и собрать самим lldb-3.8. Я пошёл первым путём.
Собираем libsoplugin с lldb-4.0
К счастью, нам понадобится только плагин. Не придётся использовать кастомный .NET Core и т.д. Плагин будем использовать прямо из папки, в которой мы его соберём, так что система не замусорится.
Собственно, в репозитории CoreCLR есть инструкции по сборке под Linux. Мы только немного подправим их, чтобы воспользоваться lldb-4.0.
Я использую Ubuntu 16.04. Для других дистрибутивов набор команд может несколько отличаться.
Ставим необходимые инструменты для сборки:
$ sudo apt install cmake llvm-4.0 clang-4.0 lldb-4.0 liblldb-4.0-dev libunwind8 libunwind8-dev gettext libicu-dev liblttng-ust-dev libcurl4-openssl-dev libssl-dev uuid-dev libnuma-dev libkrb5-dev
Клонируем репозиторий:
$ git clone https://github.com/dotnet/coreclr.git $ git checkout release/2.0.0
Применяем следующий патч:
diff --git a/src/ToolBox/SOS/lldbplugin/CMakeLists.txt b/src/ToolBox/SOS/lldbplugin/CMakeLists.txt index fe816ab..ef9846d 100644 --- a/src/ToolBox/SOS/lldbplugin/CMakeLists.txt +++ b/src/ToolBox/SOS/lldbplugin/CMakeLists.txt @@ -76,6 +76,7 @@ endif() find_path(LLDB_H "lldb/API/LLDB.h" PATHS "${WITH_LLDB_INCLUDES}" NO_DEFAULT_PATH) find_path(LLDB_H "lldb/API/LLDB.h") +find_path(LLDB_H "lldb/API/LLDB.h" PATHS "/usr/lib/llvm-4.0/include") find_path(LLDB_H "lldb/API/LLDB.h" PATHS "/usr/lib/llvm-3.9/include") find_path(LLDB_H "lldb/API/LLDB.h" PATHS "/usr/lib/llvm-3.8/include") find_path(LLDB_H "lldb/API/LLDB.h" PATHS "/usr/lib/llvm-3.7/include")
Собираем CoreCLR:
$ ./build.sh clang4.0
Ура, мы получили работающий плагин для lldb-4.0.
Применяем теорию на практике
Открываем наш снапшот в lldb:
sudo lldb-4.0 $(which dotnet) --core ./dump1.13486
Загружаем собранный плагин:
(lldb) plugin load /home/user/works/coreclr/bin/Product/Linux.x64.Debug/libsosplugin.so
Дампим статистику по использованию кучи:
(lldb) sos DumpHeap -stat Statistics: MT Count TotalSize Class Name 00007fe36870b4c8 1 24 System.Collections.Generic.GenericEqualityComparer`1[[System.Int32, System.Private.CoreLib]] 00007fe3686efea8 1 24 System.Threading.AsyncLocalValueMap+EmptyAsyncLocalValueMap ... 00007fe367cf7038 1 131096 System.Collections.Concurrent.ConcurrentQueue`1+Segment+Slot[[System.Threading.IThreadPoolWorkItem, System.Private.CoreLib]][] 00007fe3686dcec8 19898 477552 System.Threading.TimerHolder 00007fe3686bfc70 19898 477552 System.Threading.Timer 00007fe3686dcd18 16261 650440 System.Threading.QueueUserWorkItemCallback 00007fe3686c4430 19898 1751024 System.Threading.TimerQueueTimer 00007fe3686bfbb8 19898 2228576 System.Threading.Tasks.Task+DelayPromise 00007fe367d08498 19 268435400 UNKNOWN 0000000001b465a0 3069079 449192802 Free 00007f53bc74b460 13903273 1362548770 System.String Total 17068427 objects
Первая колонка — это адрес method table для объектов данного класса, вторая — количество аллоцированных объектов данного класса, третья — количество аллоцированных байт, четрвёртая — имя класса.
По выводу не сложно догадаться, кто же течёт.
Получаем стек, в котором создан объект (команды могут выполняться ооочень долго):
(lldb) sos DumpHeap -mt 00007f53bc74b460 ... 00007f539d0712c0 00007f53bc74b460 98 00007f539d071420 00007f53bc74b460 98 00007f539d071580 00007f53bc74b460 98 00007f539d0716e0 00007f53bc74b460 98 00007f539d071840 00007f53bc74b460 98 00007f539d0719a0 00007f53bc74b460 98 00007f539d071b00 00007f53bc74b460 98 00007f539d071c60 00007f53bc74b460 98 00007f539d071dc0 00007f53bc74b460 98 00007f539d071f20 00007f53bc74b460 98 00007f539d072080 00007f53bc74b460 98 00007f539d0721e0 00007f53bc74b460 98 00007f539d072340 00007f53bc74b460 98 00007f539d0724a0 00007f53bc74b460 98 00007f539d072600 00007f53bc74b460 98 00007f539d072760 00007f53bc74b460 98 00007f539d0728c0 00007f53bc74b460 98 00007f539d072a20 00007f53bc74b460 98 00007f539d072b80 00007f53bc74b460 98 ...
Получаем огромную таблицу в которой первая колонка — адрес инстанса этого класса, вторая — адрес method table, третья — размер инстанса в байтах.
Выбираем какой-нибудь инстанс и выполняем команду:
(lldb) sos GCRoot 00007f539d072b80 Thread 4303: 00007FFC92921910 00007F53BC9C0E30 leak_example.LeakClass.DoWork() [/home/ilya/works/trading/leak-example/LeakClass.cs @ 18] rbp-48: 00007ffc92921918 -> 00007F539D072B80 System.String 00007FFC92921910 00007F53BC9C0E30 leak_example.LeakClass.DoWork() [/home/ilya/works/trading/leak-example/LeakClass.cs @ 18] rbp-40: 00007ffc92921920 -> 00007F5394014878 <error> -> 00007F5394014530 <error> -> 00007F53984B4A10 <error> -> 00007F53A47E35F0 <error> -> 00007F539D072B80 System.String Found 2 unique roots (run '!GCRoot -all' to see all roots).
И как видим, нам указывают как раз на строку
_collection.Add(System.Guid.NewGuid().ToString());
в исходном файле.
Сравнение снапшотов
Раз уж я начал с разговора о сравнении снапшотов, придётся хоть как-то их сравнить.
Сохранив в файлы dump1.txt
и dump2.txt
выводы команд sos DumpHeap -stat
для обоих снапшотов (я просто скопировал из консоли), я обработал их вот таким простым скриптиком (на самом деле я писал прямо в консоли iPython, поэтому на скрипт это не очень похоже):
dump1 = open('dump1.txt')
lines1 = dump1.readlines()
methodTables = {}
for s in lines1:
if s.startswith('000'):
(mt, cnt, sz, name) = s.split(maxsplit=3)
if not mt in methodTables:
methodTables[mt] = {'cnt1': cnt, 'sz1': sz, 'name': name}
dump2 = open('dump2.txt')
lines2 = dump2.readlines()
for s in lines2:
if s.startswith('000'):
(mt, cnt, sz, name) = s.split(maxsplit=3)
if not mt in methodTables:
methodTables[mt] = {'cnt2': cnt, 'sz2': sz, 'name': name}
else:
methodTables[mt]['cnt2'] = cnt
methodTables[mt]['sz2'] = sz
for mt in methodTables.keys():
if 'cnt1' in methodTables[mt] and 'cnt2' in methodTables[mt]:
cnt1 = int(methodTables[mt]['cnt1'])
sz1 = int(methodTables[mt]['sz1'])
cnt2 = int(methodTables[mt]['cnt2'])
sz2 = int(methodTables[mt]['sz2'])
if (cnt2 > cnt1 and cnt2 > 100 and sz2 > 1024 * 1024):
print(mt, cnt1, cnt2, methodTables[mt]['name'])
Получив в результате, список "горячих точек" на которые стоит обратить внимание:
Заключение
Надеюсь, такой вот, импровизированный, способ поиска утечек памяти будет кому-нибудь полезен.
Внимательный читатель, конечно же, заметит, что было бы неплохо, автоматизировать весь процесс, с помощью скриптов lldb. Но пока у меня нету времени на это. Не удалось сходу решить проблему с тем, что python-lldb-4.0
установленный из репозитория отказывается грузить libsoplugin.so
. Возможно, кто-то ещё продвинется дальше.
Спасибо за внимание!
stalkerg
Ещё одна причина почему source based дистрибутивы хороши — всё консистентно в системе.
amarao
В binary distro все компоненты должны быть пересобираемы (при необходимости), плюс они все собираются правильными версиями компиляторов.
Тут претензия не к дистрибутиву, а к тому, что libsoplugin.so отгружается ms'ом как blob, без прилагающихся исходных текстов.
stalkerg
Это нормально если в бинармном дистрибутиве нету какойто нужной версии библиотеки.
т.е. невозможно собрать приложение без какойто сторонней библиотеки которой нету в нужной версии в конкретном дистре, а так как их много то лучше не мучится.
В Gentoo в любом случае пришлось бы собирать из исходников (на самом деле нет) и как минимум всегда можно было бы указать точно все зависимости и уже на плечи юзера ложится бремя поддержаня консистенции. Как то так… dependency hell он такой.
ЗЫ а может тут что то с лицензией?