.NET Inside Out Part 22 – Your application is always multithreaded and it’s not easy to exit properly

This is the twentieth second part of the .NET Inside Out series. For your convenience you can find other parts in the table of contents in Part 1 – Virtual and non-virtual calls in C#

We know that a .NET application is always multithreaded. GC creates threads for running finalizers and (depending on the settings) to run collection. This means that each app has multiple threads running and we may get a race condition when cleaning resources.

Let’s take the following code:

We run application recursively in two modes. In first mode (bootstraper) we enter the loop in line 18 and then constantly run the application. In other mode we start writing to file in line 16. We create two threads: first of them is writing the content, second is exiting the application with Environment.Exit.

Then, bootstraper checks the file length in line 21. If you run this for long enough (few seconds on my machine) you should see that the file is longer than 3 characters. Actually, instead of being abc it is abcabc. How is that possible?

The reason is in line 39 where we terminate the application. What’s happening? Let’s grab dnspy and decompile the code writing to the file. If we go through the using construct we can see that it calls FileStream.WriteFileNative method under the hood through the Dispose method. So let’s see, what is exactly going on (for .NET Framework):

We want to hijack .NET method and log the stacktraces when we call it multiple times (as we suspect that’s the reason why the file has incorrect content). Based on this great explanation we figure out how to hijack method. So we start that in line 22, in line 99 we get the offset and calculate target address, then do the normal jump instruction to end in line 79.

There, we want to store information that this method was already called. We don’t care about memory consistency here so we don’t use any volatiles, in worst case we’ll just need to wait a little longer. We store the stacktrace of first call in line 87 and then we explicitly yield. However, we need to maintain the logic of the .NET method, so we set the HRESULT in line 90 and return 3 because that’s the length of the content to write.

How do we do this for .NET Core 3.1.101? First, WriteFileNative is different:

Second, we need to disable ReadyToRun before running the child

Finally, machine code address is 12 bytes from the beginning, not 8.

If we run this for long enough (~20 seconds on my box), we should get the race condition. In that case lines 81-84 print out the stracktraces which are handled in line 45. And that’s the output:

We can see that first stacktrace comes from finalizer and the second one from normal using construct.

So what’s going on exactly? First thread writes to the file and then cleans it up with Dispose method which causes the buffer to be flushed to the disk. But in the meantime we terminate the application which causes finalizer to run which in turn goes through the same path and writes to the file again.

Is this something we should rely on? No. Finalizers are tricky, as shown by Eric Lippert here and here.

Okay, how do we fix it? We need to change the logic to not call the finalizer:

Thanks to this we don’t call the finalizer but we still flush with Dispose.

After this change I couldn’t reproduce the issue for few minutes so I presume it’s fixed.

Takeaway? Finalizers can cause race conditions and exiting the application is not simple.