Investigating an infinite loop in Release configuration

 
 
  • Gérald Barré

This post is part of the series 'Crash investigations and code reviews'. Be sure to check out the rest of the blog posts of the series!

I recently investigated an infinite loop in an application. Here is a simplified version of the buggy code:

C#
static void Main()
{
    bool isReady = false;

    var thread = new Thread(_ =>
    {
        // ... (initialization)
        isReady = true;
        // ... (code after initialization)
    });
    thread.Start();

    // wait for the other thread to do some initialization
    while (!isReady)
    {
        // code omitted for brevity
    }

    Console.WriteLine("Hello World!");
}

The code looks valid, if not ideal. The main thread starts another thread to perform some initialization in the background. Once the initialization is done, it continues its execution. The developer tests it on their machine and everything works fine.

After publishing and starting the application, the program gets stuck. The while loop never completes. After investigation, it turns out this code doesn't work in Release configuration.

Release configuration enables more optimizations, so you need to examine the generated assembly to understand what happens at runtime.

Source: SharpLab

The interesting part is the loop:

  1. L004d: movzx ecx, byte ptr [esi+4]: Move the value of the isReady variable into the ECX register
  2. L0051: test ecx, ecx: Check if the value in the ECX register is 0 (false)
  3. L0053: je short L0051: If the value is 0, go to step 2

The value is read once before the loop, and then the loop always checks that same cached value. As a result, the update made by the other thread is never observed. The JIT applies this optimization because the current method never writes to the variable. The lambda passed to Thread.Start is compiled as a separate method, so the JIT treats isReady as read-only within Main.

Now that we have found why the program stays in an infinite loop, let's check the possible fixes.

#Fix #1: Volatile.Read

One fix is to use Volatile.Read (documentation). This method returns the latest value written by any processor, regardless of the number of processors or the state of the processor cache. This forces the generated assembly to always read the value from memory.

C#
while (!Volatile.Read(ref isReady))
{
}
; https://sharplab.io/#v2:C4LghgzgtgPgAgJgIwFgBQcAMACOSB0AKgBYBOApmACYCWAdgOYDc66iuSA7OgN7rYCOANlwAWbAFkw9ABR5MAbQC62MKQYQAlP0F80gg9gBGAexMAbbDQgAlSlQCe2ALzYAZmHMRyLNDsPYAG5q2MBk9i7YdOQA7tgkFNQyAPouAHxWtvZOrsCkAK7kmr4BAmGJVPgAysBqwDLFrPqlMcQ05uTYMgCEAGoWYMDt5Ph2SRRumWOOmtrNAXql2AC+TUt4AJwyAEQAEuTm5ibYAOompOZU3duN8yvoq2hAA===
L0050: mov ecx, esi
L0052: cmp byte ptr [ecx], 0 ; Read value from the memory and compare it with 0
L0055: je short L0050

#Fix #2: Synchronization primitives

A preferred approach is to use synchronization primitives to signal the main thread when the other thread has finished its work. For instance, you can use a ManualResetEventSlim to block the main thread until the worker thread is ready.

C#
static void Main()
{
    var resetEvent = new ManualResetEventSlim(false);

    var thread = new Thread(_ =>
    {
        // ...
        resetEvent.Set();
        // ...
    });
    thread.Start();

    // wait for the other thread for doing the job
    resetEvent.Wait();
    Console.WriteLine("Hello World!");
}

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?