zoukankan      html  css  js  c++  java
  • Analyzing .NET Core memory on Linux with LLDB

     
    Most of the last week I’ve been experimenting with our .NET Windows project running on Linux in Kubernetes. It’s not as crazy as it sounds. We already migrated from .NET Framework to .NET Core, I fixed whatever was incompatible with Linux, tweaked here and there so it can run in k8s and it really does now. In theory.
    In practice, there’re still occasional StackOverflow exceptions (zero segfaults, however) and most of troubleshooting experience I had on Windows is useless here on Linux. For instance, very quickly we noticed that memory consumption of our executable is higher than we’d expect. Physical memory varied between 300 MiB and 2 GiB and virtual memory was tens and tens of gigabytes. I know in production we could use much higher than that, but here, in container on Linux, is that OK? How do I even analyze that?
    On Windows I’d took a process dump, feed it to Visual Studio or WinDBG and tried to google what’s to do next. Apparently, googling works for Linux as well, so after a few hours I managed learn several things about debugging on Linux and I’d like to share some of them today.

    The playground (debugging starts later)

    Obviously, I can’t use our product as an example, but in reality any .NET Core “Hello world” project would do. I’ll create Ubuntu 16.04 VM with the help of Vagrant and VirtualBox, put the project in it and we can experiment in there.

    Ubuntu VM

    This is the Vagrantfile that will prepare the VM:
     
     
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    Vagrant.configure("2") do |config|
      config.vm.box = "ubuntu/xenial64"
     
      config.vm.provider "virtualbox" do |vb|
         vb.memory = "3072"
      end
      
      config.vm.provision "shell", inline: <<-SHELL
        # Install .net core SDK
        curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg
        mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg
        sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-xenial-prod xenial main" > /etc/apt/sources.list.d/dotnetdev.list'
        apt-get update && apt-get install -y dotnet-sdk-2.0.2
     
        # Dev tools
        apt-get install -y vim gdb lldb-3.6
      SHELL
    end
     
    There’s nothing fancy. It’s 3 GiB RAM VM with .NET Core 2.0.2 SDK, vim, gdb and lldb-3.6 installed (more on them later).
    Now, vagrant up will bring that VM to life and we can get into it with vagrant ssh command. The next stop is the demo project.

    Demo project

    As I said, any hello-world .NET Core app would do, but ideally it should have something in the memory to analyze. It also shouldn’t exit immediately – we need some time to take a process dump.
    dotnet new console -o memApp creates almost sufficient project template, which I improved very slightly by adding a static array full of dummy strings:
     
     
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    using System;
    using System.Linq;
    using System.Text;
     
    namespace memApp
    {
        class Program
        {
            static Random random = new Random((int)DateTime.Now.Ticks);
     
            static char RandomChar()
                => Convert.ToChar(random.Next(65, 90));
     
            static string RandomString(int length)
                => String.Concat(Enumerable.Range(0, length).Select(_ => RandomChar()));
     
            static void Main(string[] args)
            {
                var dummyStringsCollection = Enumerable.Range(0, 10000)
                    .Select(_ => "Random string: " + RandomString(10000)).ToArray();
                Console.WriteLine("Hello World!");
                Console.ReadLine();
            }
        }
    }
     
    Now, let’s build the app, launch it and begin with experiments:
     
     
    1
    2
    3
    4
    5
    6
    7
    8
    9
    dotnet build
    #...
    #Build succeeded.
    #    0 Warning(s)
    #    0 Error(s)
    #
    #Time Elapsed 00:00:02.06
    dotnet bin/Debug/netcoreapp2.0/memApp.dll
    # Hello World!
     

    Creating a core dump

    First, let’s check what’s initial memory stats look like:
     
     
    1
    2
    3
    4
    ps u
    #USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
    #ubuntu    4058  7.9  7.9 2752512 243908 pts/0  SLl+ 04:10   0:06 dotnet bin/Debug/netcoreapp2.0/memApp.dll
    #...
     
    That’s actually quite a lot: ~2.6 GiB of virtual memory and ~238 MiB of physical. Even though virtual memory doesn’t mean we’re ever going to use all of it, process dump (‘core dump’ in linux terminology) will take at least the same amount of space.
    The simplest way to create a core dump is to use gcore utility. It comes along with gdb debugger and that’s the only reason I had to install it.
    Using gcore, however, in most cases requires elevated permissions. On local Ubuntu I was able to get away with sudo gcore, but inside of Kubernetes pod even that wasn’t enough and I had to go to underlying node and add the following option to sysctl.conf:
     
     
    1
    2
    echo "kernel.yama.ptrace_scope=0" | sudo tee -a /etc/sysctl.conf # Append config line
    sudo sysctl -p # Apply changes
     
    But here in Ubuntu VM sudo gcore works just fine and I can create a core dump just by providing target process id (PID):
     
     
    1
    2
    3
    sudo gcore 4058
    # ...
    # Saved corefile core.4058
     
    As I mentioned before, dump file size is the same as the amount of virtual memory:
     
     
    1
    2
    3
    ls -lh
    #total 2.6G
    #-rw-r--r-- 1 root root 2.6G Dec 12 04:25 core.4058
     
    This actually was a problem for us in Kubernetes, with .NET garbage collector switched to server mode and the server itself having 208 GiB of RAM. With such specs and GC settings virtual memory and core dump file were just above 49 GiB. Disabling gcServer option in .NET, however, reduced default address space and therefore core file size down to more manageable 5 GiB.
    But I digressed. We have a dump file to analyze.

    Debugger and .NET support

    We can use either gdb or lldb debuggers to works with core files, but only lldb has .NET debugging support via SOS plugin called libsosplugin.so. Moreover, the plugin itself is built against specific version of lldb, so if you don’t want to recompile CoreCLR and libsosplugin.so locally (not that hard), the safest lldb version to use at the moment is 3.6.
    As a side note, I was wondering what SOS exactly means and found this wonderful SO answer. Apparently, SOS has nothing to do with ABBA or save-our-souls Morse code distress signal. It means “Son of Strike”. Who is Strike, you might ask? Strike was a name of debugger for .NET 1.0, codename Lightning. Strike of Lightning, you know. And SOS is his proud descendant. Whenever I doubt if I should still love my profession, I find a story like this and give it another year. Few years ago a story behind a userAgent browser property did the same trick.
    OK, we have a debugger, an executable and a core dump. Where do we get SOS plugin? Fortunately, it comes along with .NET Core SDK which I already installed:
     
     
    1
    2
    find /usr -name libsosplugin.so
    #/usr/share/dotnet/shared/Microsoft.NETCore.App/2.0.0/libsosplugin.so
     
    Finally, we can start lldb, point it to dotnet executable, which started our application, it’s core dump and then load the plugin:
     
     
    1
    2
    3
    4
    5
    $ lldb-3.6 `which dotnet` -c core.4058
    # (lldb) target create "/usr/bin/dotnet" --core "core.4058"
    # Core file '/home/ubuntu/core.4058' (x86_64) was loaded.
    # (lldb) plugin load /usr/share/dotnet/shared/Microsoft.NETCore.App/2.0.0/libsosplugin.so
    # (lldb)
     
    This is where the real black magic begins.

    Analyzing managed memory

    SOS plugin added a set of commands which are aware of .NET managed nature, so we can not just see what bits and bytes are stored at given location, but what is their .NET type (e.g. System.String).
    soshelp command prints out all .NET commands it added to lldb and soshelp commandname will explain how to use a particular one. Well, except when it won’t.
    For instance, DumpHeap command, which is basically the entry point for memory analysis, has no help at all. Fortunately for me, I was able to find the missing info next to the plugin’s source code.
     
     
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    (lldb) soshelp
    #...
    #Object Inspection                  Examining code and stacks
    #-----------------------------      -----------------------------
    #DumpObj (dumpobj)                  Threads (clrthreads)
    #DumpArray                          ThreadState
    #..
    (lldb) soshelp DumpHeap
    -------------------------------------------------------------------------------
    (lldb)
     

    Memory summary

    We have a working debugger, we have a DumpHeap command – let’s take a look at managed memory statistics:
     
     
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    (lldb) sos DumpHeap -stat
    #Statistics:
    #              MT    Count    TotalSize Class Name
    #00007f6d32992aa8        1           24 UNKNOWN
    #00007f6d329911d8        1           24 UNKNOWN
    #....
    #00007f6d323defd8        4        17528 System.Object[]
    #00007f6d323e08a8       25        40644 System.Int32[]
    #00007f6d323e0168       29        82664 System.String[]
    #00007f6d323e3440      335       952398 System.Char[]
    #000000000223b860    10092      6083604 Free
    #00007f6d3242b460   150846    204845172 System.String
    #Total 161886 objects
    (lldb)
     
    Not surprisingly, System.String objects use the most of the memory. Btw, if you summarize total sizes of all managed objects (like I did), resulting memory count comes very close to physical memory count reported by ps u. 202 MiB of managed objects vs 238 MiB of physical memory. The delta, I suppose, goes to the code itself and executing environment.

    Memory details

    But we can go further. We know that System.String uses the most of the memory. Can we take a closer look at those strings? Sure thing:
    Drill down into the memory
     
     
     
     
     
    Shell
     
     
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    (lldb) sos DumpHeap -type System.String
    #        Address               MT     Size
    #00007f6d0bfff3f0 00007f6d3242b460       26    
    #00007f6d0bfff4c0 00007f6d3242b460       42
    #...
    #00007f6d0c099ab0 00007f6d3242b460    20056    
    #00007f6d0c09e920 00007f6d3242b460    20056
    #...
    #00007f6d323e0168       29        82664 System.String[]
    #00007f6d3242b460   150846    204845172 System.String
    #Total 150895 objects
     
    -type works as a mask, so the output also contains System.String[] and a few Dictionaries. Also strings vary in size, whereas I’m actually interested in large ones, at least 1000 bytes:
     
     
    1
    2
    3
    4
    5
    6
    sos DumpHeap -type System.String -min 1000
    # ...
    # 00007f6d0e8810f0 00007f6d3242b460    20056    
    # 00007f6d0e885f60 00007f6d3242b460    20056    
    # 00007f6d0e88add0 00007f6d3242b460    20056    
    # ...
     
    Having the list of suspicious objects we can drill down even more: examine the objects one by one.

    DumpObj

    DumpObj can look into the managed object details at given memory address. We have a whole first column of addresses and I just picked one of them:
     
     
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    (lldb) sos DumpObj 00007f6d0e8810f0
    #Name:        System.String
    #MethodTable: 00007f6d3242b460
    #EEClass:     00007f6d31c49eb8
    #Size:        20056(0x4e58) bytes
    #File:        /usr/share/dotnet/shared/Microsoft.NETCore.App/2.0.0/System.Private.CoreLib.dll
    #String:      
    #Fields:
    #              MT    Field   Offset                 Type VT     Attr            Value Name
    #00007f6d3244b020  40001c9        8         System.Int32  1 instance            10015 m_stringLength
    #00007f6d3242f420  40001ca        c          System.Char  1 instance               52 m_firstChar
    #00007f6d3242b460  40001cb       38        System.String  0   shared           static Empty
    #                                 >> Domain:Value  00000000022ab050:NotInit  <<
     
    It’s actually pretty cool. We immediately can see the type name (System.String) and what fields it is made of. I also noticed that for small strings we’d see the value right away (line 7), but not for the large ones.
    I was puzzled at first about how to get the value for those. There’s m_firstChar field, but is it like a linked list or what? Where’s a pointer to the next item? Only after checking out the source code for System.String I realized that m_firstChar can be used as a pointer itself and the whole string is stored somewhere as continuous block of memory. This means I can use lldb’s native memory read command to get the whole string back!
    For that I just need to take object’s address (00007f6d0e8810f0), add m_firstChar‘s field offset (c, third column in fields table) and then do something like this:
     
     
    1
    2
    3
    (lldb) memory read 00007f6d0e8810f0+0xc
    #0x7f6d0e8810fc: 52 00 61 00 6e 00 64 00 6f 00 6d 00 20 00 73 00  R.a.n.d.o.m. .s.
    #0x7f6d0e88110c: 74 00 72 00 69 00 6e 00 67 00 3a 00 20 00 43 00  t.r.i.n.g.:. .C.
     
    Does it look familiar? “R.a.n.d.o.m. .s.t.r.i.n.g.”. C# char defaults to UTF16 encoding and therefore it takes two bytes. Even though one of them is always zero for ASCII characters.
    We also can experiment with memory read formatting, but even with default settings we can get the idea what’s inside.
     
     
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    (lldb) memory read 00007f6d0e8810f0+0xc -f s -c 13
    #0x7f6d0e8810fc: "R"
    #0x7f6d0e8810fe: "a"
    #0x7f6d0e881100: "n"
    #0x7f6d0e881102: "d"
    #0x7f6d0e881104: "o"
    #0x7f6d0e881106: "m"
    #0x7f6d0e881108: " "
    #0x7f6d0e88110a: "s"
    #0x7f6d0e88110c: "t"
    #0x7f6d0e88110e: "r"
    #0x7f6d0e881110: "i"
    #0x7f6d0e881112: "n"
    #0x7f6d0e881114: "g"
     

    Conclusion

    I’m just scratching the surface, but I love what I find. I’ve been a .NET programmer for quite a while, but it’s the first time in years when I started to think what’s happening that deep under the hood. What’s inside of a System.String? What fields does have? How those fields are aligned in the memory? The first field has an offset 8. What’s in those eight bytes? A type id? .NET strings are interned, does it mean that m_firstChar of identical strings will point to the same block of memory? Can I check that?
    I also wonder how debugging .NET code with lldb looks like. Many years ago I used to debug a C++ pet project with gdb, so I kind of know the feeling. But .NET applications compile Just-In-Time, so it’s interesting to see how SOS plugin deals with that.
     
     

    Analyzing .NET Core memory on Linux with LLDB - Dots and Brackets: Code Blog
    https://codeblog.dotsandbrackets.com/net-core-memory-linux/

  • 相关阅读:
    巩固基础前台
    super 、static、final关键字加深记忆哦!还有父子类构造函数调用问题
    java异常了解
    spring粗略整体认识
    java枚举新认识
    泛型集合注意事项
    java反射基础
    对java集合类的认识——基础很重要
    多线程(C++ And POSIX)
    v2代理原理,应用
  • 原文地址:https://www.cnblogs.com/wang-xiaohui/p/13300888.html
Copyright © 2011-2022 走看看