views:

485

answers:

2

I have an application that logs messages to the screen using a TextBox. The update function uses some Win32 functions to ensure that the box automatically scrolls to the end unless the user is viewing another line. Here is the update function:

private bool logToScreen = true;

// Constants for extern calls to various scrollbar functions
private const int SB_HORZ = 0x0;
private const int SB_VERT = 0x1;
private const int WM_HSCROLL = 0x114;
private const int WM_VSCROLL = 0x115;
private const int SB_THUMBPOSITION = 4;
private const int SB_BOTTOM = 7;
private const int SB_OFFSET = 13;

[DllImport("user32.dll")]
static extern int SetScrollPos(IntPtr hWnd, int nBar, int nPos, bool bRedraw);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern int GetScrollPos(IntPtr hWnd, int nBar);
[DllImport("user32.dll")]
private static extern bool PostMessageA(IntPtr hWnd, int nBar, int wParam, int lParam);
[DllImport("user32.dll")]
static extern bool GetScrollRange(IntPtr hWnd, int nBar, out int lpMinPos, out int lpMaxPos);

private void LogMessages(string text)
{
    if (this.logToScreen)
    {
        bool bottomFlag = false;
        int VSmin;
        int VSmax;
        int sbOffset;
        int savedVpos;
        // Make sure this is done in the UI thread
        if (this.txtBoxLogging.InvokeRequired)
        {
            this.txtBoxLogging.Invoke(new TextBoxLoggerDelegate(LogMessages), new object[] { text });
        }
        else
        {
            // Win32 magic to keep the textbox scrolling to the newest append to the textbox unless
            // the user has moved the scrollbox up
            sbOffset = (int)((this.txtBoxLogging.ClientSize.Height - SystemInformation.HorizontalScrollBarHeight) / (this.txtBoxLogging.Font.Height));
            savedVpos = GetScrollPos(this.txtBoxLogging.Handle, SB_VERT);
            GetScrollRange(this.txtBoxLogging.Handle, SB_VERT, out VSmin, out VSmax);
            if (savedVpos >= (VSmax - sbOffset - 1))
                bottomFlag = true;
            this.txtBoxLogging.AppendText(text + Environment.NewLine);
            if (bottomFlag)
            {
                GetScrollRange(this.txtBoxLogging.Handle, SB_VERT, out VSmin, out VSmax);
                savedVpos = VSmax - sbOffset;
                bottomFlag = false;
            }
            SetScrollPos(this.txtBoxLogging.Handle, SB_VERT, savedVpos, true);
            PostMessageA(this.txtBoxLogging.Handle, WM_VSCROLL, SB_THUMBPOSITION + 0x10000 * savedVpos, 0);
        }
    }
}

Now the strange thing is that the text box consumes at least double the memory that I would expect it to. For example, when there are ~1MB of messages in the TextBox, the application can consume up to 6MB of memory (in addition to what it uses when the logToScreen is set to false). The increase is always at least double what I expect, and (as in my example) sometimes more.

What is more strange is that using:

this.txtBoxLogging.Clear();
for (int i = 0; i < 3; i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
}

Does not free the memory (in fact, it increases slightly).

Any idea where the memory is going as I'm logging these messages? I don't believe it has anything to do with the Win32 calls, but I've included it to be thorough.

EDIT:

The first couple of responses I got were related to how to track a memory leak, so I thought I should share my methodology. I used a combination of WinDbg and perfmon to track the memory use over time (from a couple hours to days). The total number of bytes on all CLR heaps does not increase by more than I expect, but the total number of private bytes steadily increases as more messages are logged. This makes WinDbg less useful, as it's tools (sos) and commands (dumpheap, gcroot, etc.) are based on .NET's managed memory.

This is likely why GC.Collect() can not help me, as it is only looking for free memory on the CLR heap. My leak appears to be in un-managed memory.

+2  A: 

How are you determining the memory usage? You'd have to watch the CLR memory usage for your application, not the memory used by the system for the whole application (you can use Perfmon for that). Perhaps you are already using the correct method of monitoring.

It seems to me that you are using StringBuilder internally. If so, that would explain the doubling of the memory, because that's the way StringBuilder works internally.

The GC.Collect() may not do anything if the references to your objects are still in scope, or if any of your code uses static variables.


EDIT:
I'll leave the above, because it may still be true, but I looked up AppendText's internals. It does not append (i.e., to a StringBuilder), instead, it sets the SelectedText property, which does not set a string but sends a Win32 message (string is cached on retrieval).

Because strings are immutable, this means, for every string, there will be three copies: one in the calling application, one in the "cache" of the base Control and one in the actual Win32 textbox control. Each character is two bytes wide. This means that any 1MB of text, will consume 6MB of memory (I know, this is a bit simplistic, but that's basically what seems happening).

EDIT 2: not sure if it'll make any change, but you can consider calling SendMessage yourself. But it sure starts to look like you'll need your own scrolling algorithm and your own owner-drawn textbox to get the memory down.

Abel
Memory debugging was using a combination of permon and WinDbg. The memory does not appear on the CLR Heap, it's somewhere in unmanaged memory. This is the offending code, though, for certain. If I make logToScreen false, the memory use doesn't increase over time (days).
cgyDeveloper
Thanks for the update, that sheds some light on the issue. Indeed, P/Invoke SendMessage is used to send the string (it is not stored internally on setting it!). Then, marshalling and win32 api are the left-over possible memory eaters. Iirc, marshalling doesn't copy strings, but I can be mistaken.
Abel
Interesting, where did you find information on AppendText's internals? I've been on MSDN, but didn't find anything in-depth. This could very well be exactly what I'm looking for.
cgyDeveloper
Abel
I think it might just be easier to use a ListBox, so I'll check and see what the memory use looks like for that. Thanks for the help so far.
cgyDeveloper
ListBox is no better...
cgyDeveloper
If memory is really important, make your own. Draw directly to the screen / window, don't buffer, keep one copy of the text, store text as ANSI (i.e., use extended ASCII: 1 byte per char) and read the file in chunks but again, don't buffer. This may slow performance, but will reduce memory by a large factor.
Abel
+1  A: 

Tracking the amount of memory used by an application, especially a garbage collected language, is a tricky undertaking. People often used the overall memory count for an application to determine the objects still in use (for instance via task manager). This is questionably effective for native application but will give very misleading results for managed applications.

In order to correctly determine the amount of memory used by your CLR objects, you need to use a tool that is specifically geared to measuring that. For instance, I find the best way is to use a combination of WinDbg and sos.dll to measure the current rooted objects. This will both tell you the size of your managed objects and point out what objects are actually taking up the extra memory.

Here's a nice article on the subject

JaredPar
Yes, that's a very good article. Using a combination of WinDbg and perfmon, I determined that the additional memory is not in the CLR managed memory (making WinDbg much less useful), but in unmanaged memory somewhere.
cgyDeveloper
On *"I find the best way is to use a combination of WinDbg and sos.dll"* : interesting, but isn't it just as easy to use Perfmon with the applicable CLR counters? Many (not all!) CLR + GC related mem issues can be tracked down that way.
Abel
Perfmon is the best place to start, and then WinDbg can tell you exactly why a certain object is not eligible for garbage collection (using sos.dll's gcroot command).
cgyDeveloper