Поиск утечек памяти в приложениях на .NET Core под Linux

в 12:58, , рубрики: .net, dotnet, linux, memory leaks, отладка

.NET Core становится всё более и более зрелой платформой. На нём уже достаточно комфортно можно вести разработку, используя тот же Rider или VS Code.

Однако, и там не всё гладко. Например, отладка кода на .NET Core 2 заработала только в Rider 2017.2, который вышел, буквально на днях (были ещё EAP сборки). Приходилось пользоваться VS Code. В нём работает отладка, однако, чтобы заработал запуск тестов надо руками ставить beta-версию расширения для C#.

Я думаю, суть ясна, что инструментальная поддержка пока сильно далека от аналогичной при разработке под Windows.

Для некоторых вещей пока нету готовых средств. Например, для профилирования.

Из источников, которые доступны в сети, самыми содержательными, по моему мнению, на текущий момент являются статьи Саши Гольдштейна:

Однако, готового рецепта по поиску утечки памяти мне найти не удалось. Поэтому я решил описать найденный мной способ.

Как бы мы действовали под Windows

Лично я бы, не долго думая, подключился к работающему приложению с помощью dotMemory, снял бы 2 снапшота через некоторый промежуток времени и сравнил бы их, используя, красивый GUI.

image

Как здорово было бы, если бы под 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());
                Task.Delay(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-сборкой моего тестового приложения мне это не понадобилось.

Суровая реальность

  1. Microsoft поставляет libsoplugin.so вместе с .NET Core. Так что, скорее всего он у вас есть в системе.

  2. Как писал Саша в своей статье, к сожалению, этот плагин линкуется с конкретной версией LLVM. Соответственно, потребуется конкретная версия LLDB, чтобы им воспользоваться.

  3. Посмотреть конкретную версию, уже не получится как раньше, с помощью команды 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 в разных дистрибутивах назывался по-разному и его убрали из явных зависимостей.

  4. В каком-то из issues на GitHub есть информация, что в .NET Core 2.0 этот плагин собран с lldb-3.8, а версия в .NET Core 2.0.1 уже будет собрана с lldb-3.9.

  5. Казалось бы, теперь мы знаем версию и можем просто поставить в систему lldb нужной версии. Но нет. Дело в том, что lldb до версии 4.0 не мог грузить on-demand core dump. Как раз такие, какие мы и сделали (в процессе работы программы).

  6. Вот и получается, что пути у нас 2, либо собрать плагин для lldb-4.0, либо пропатчить и собрать самим lldb-3.8. Я пошёл первым путём.

Собираем libsoplugin с lldb-4.0

К счастью, нам понадобится только плагин. Не придётся использовать кастомный .NET Core и т.д. Плагин будем использовать прямо из папки, в которой мы его соберём, так что система не замусорится.

Собственно, в репозитории CoreCLR есть инструкции по сборке под Linux. Мы только немного подправим их, чтобы воспользоваться lldb-4.0.

Я использую Ubuntu 16.04. Для других дистрибутивов набор команд может несколько отличаться.

  1. Ставим необходимые инструменты для сборки:

    $ 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

  2. Клонируем репозиторий:

    $ git clone https://github.com/dotnet/coreclr.git
    $ git checkout release/2.0.0

  3. Применяем следующий патч:

    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")

  4. Собираем CoreCLR:

    $ ./build.sh clang4.0

Ура, мы получили работающий плагин для lldb-4.0.

Применяем теорию на практике

  1. Открываем наш снапшот в lldb:

    sudo lldb-4.0 $(which dotnet) --core ./dump1.13486

  2. Загружаем собранный плагин:

    (lldb) plugin load /home/user/works/coreclr/bin/Product/Linux.x64.Debug/libsosplugin.so

  3. Дампим статистику по использованию кучи:

    (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 для объектов данного класса, вторая — количество аллоцированных объектов данного класса, третья — количество аллоцированных байт, четрвёртая — имя класса.

    По выводу не сложно догадаться, кто же течёт.

  4. Получаем стек, в котором создан объект (команды могут выполняться ооочень долго):

    (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'])                

Получив в результате, список "горячих точек" на которые стоит обратить внимание:

Поиск утечек памяти в приложениях на .NET Core под Linux - 2

Заключение

Надеюсь, такой вот, импровизированный, способ поиска утечек памяти будет кому-нибудь полезен.

Внимательный читатель, конечно же, заметит, что было бы неплохо, автоматизировать весь процесс, с помощью скриптов lldb. Но пока у меня нету времени на это. Не удалось сходу решить проблему с тем, что python-lldb-4.0 установленный из репозитория отказывается грузить libsoplugin.so. Возможно, кто-то ещё продвинется дальше.

Спасибо за внимание!

Автор: iloginov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js