8

Here is some simple C# .NET 7 code that causes a memory leak:

using System.Threading;

while (true)
{
    ThreadLocal<object> tl = new();
    tl.Value = tl;
}

If you run this code, the memory usage of the process increases very rapidly (on my computer, a gigabyte every couple seconds) until it runs out of memory and crashes.

Setting tl.Value = new object(); instead fixes the memory leak. So does adding a call to tl.Dispose(); at the end of the loop, but since ThreadLocal has a finalizer that itself calls Dispose, I would have expected Dispose to (eventually) get called regardless.

The above code is of course a contrived example, but the memory leak also occurs if the ThreadLocal only references itself indirectly through whatever object it directly references. As long as there is a reference chain that reaches back to the ThreadLocal, the memory leak occurs. (This is how I originally discovered the leak.)

Is this expected/documented behavior? Why does it happen?

EDIT: Here is a version of the above program that manually invokes the garbage collector every 100000 loop iterations. Note that even with the manually GC of all generations, it still leaks memory. (Albeit somewhat less quickly, presumably due to the amount of time spent GC'ing.)

using System;
using System.Threading;

int countUntilGc = 100000;
while (true)
{
    ThreadLocal<object> tl = new();
    tl.Value = tl;

    countUntilGc--;
    if (countUntilGc <= 0)
    {
        GC.Collect();
        countUntilGc = 100000;
    }
}
Walt D
  • 4,491
  • 6
  • 33
  • 43
  • Why would to make a circular reference in the first place and also trying to involve threads ? – Franck Apr 04 '23 at 19:42
  • In my real-world code where I found this, the circular reference was not deliberate (was a chain of many references). But also I don't worry about circular references much because they don't ordinarily cause memory leaks. ThreadLocal seems to be special in that regard. – Walt D Apr 04 '23 at 19:45
  • ThreadLocal implements IDisposable: does the memory leak go away if you dispose `tl` when it goes out of use? – StriplingWarrior Apr 04 '23 at 20:10
  • @StriplingWarrior It does go away yes. Though as I mentioned in my question, ThreadLocal has a finalizer which should (eventually) call Dispose for me anyway. – Walt D Apr 04 '23 at 20:21
  • 1
    Finalizers aren't guaranteed to be called as soon as the object is available for destruction. https://stackoverflow.com/questions/17270312/when-is-destructor-called-for-c-sharp-classes-in-net – Hank Apr 04 '23 at 20:46
  • @Hank That's true, but they do generally get run *eventually*, especially in scenarios such as this one where the GC is running a lot due to high demand for more memory. – Walt D Apr 04 '23 at 20:58
  • 2
    thread locals have a lot of magic under the covers that may circumvent GC unless handled properly. – Daniel A. White Apr 04 '23 at 21:00
  • 1
    Does this answer your question? [Memory leak when ThreadLocal is used in cyclic graph](https://stackoverflow.com/questions/33172615/memory-leak-when-threadlocalt-is-used-in-cyclic-graph) – GSerg Apr 04 '23 at 21:34
  • @GSerg I think that's the same basic question/issue, but Andrew's answer below does a far better job of actually answering it (and has more upvotes) than the answer in that question. – Walt D Apr 05 '23 at 00:18

1 Answers1

11

I think you'll see the same behaviour in most versions of .Net

A value stored into a ThreadLocal is cleared when the ThreadLocal instance is disposed/finalized OR when the corresponding thread exits, whichever happens first. - New in .NET 4.5: ThreadLocal.Values

When the local goes out of scope (at the end of each iteration of the while loop) the ThreadLocal is still referenced by itself. According to this answer ThreadLocals are considered to be GC roots. The garbage collector considers tl.Value to still be reachable, which means the ThreadLocal is still reachable, so the self-reference prevents the finalizer from running.

When you call Dispose() on the ThreadLocal it releases the reference to the ThreadLocal held by tl.Value. The ThreadLocal is no longer accessible so it can be garbage collected.

If you want a circular reference to a GC root, you'll need to wrap it in a WeakReference.

Andrew Williamson
  • 8,299
  • 3
  • 34
  • 62
  • Thanks for the clear explanation! ThreadLocal being a GC root makes a lot of sense as the cause of this issue. Thanks again! – Walt D Apr 04 '23 at 21:04