0

Apparently, a variable can appear to hold a value that was never assigned to it. I ran the following program on a 64-bit OS, targeting "AnyCPU" and .NET Framework 4.5 (although it turns out it ran in 32-bit mode). One thread assigns a variable to one of two values, and another thread reads the variable:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using T = System.Int64;

class Program
{
    static void Main()
    {
        T zero = 0;
        new Racer(zero, (T)~zero).Run();
        Console.Read();
    }
}

class Racer
{
    readonly T _a;
    readonly T _b;
    T _current;

    public Racer(T a, T b)
    {
        _a = a;
        _b = b;
        _current = a;
    }

    public void Run()
    {
        Dump(nameof(_a), _a);
        Dump(nameof(_b), _b);
        Task.Run((Action)SetForever);
        Task.Run((Action)GetForever);
    }

    void SetForever()
    {
        while (true)
        {
            _current = _a;
            _current = _b;
        }
    }

    void GetForever()
    {
        var encountered = new HashSet<T>();
        while (true)
        {
            var cu = _current;
            if (encountered.Add(cu))
            {
                Dump(nameof(cu), cu);
            }
        }
    }

    static void Dump(string prefix, T value)
    {
        var numBits = sizeof(T) * 8;
        var binaryChars = new char[numBits];
        for (var i = 0; i < numBits; i++)
        {
            binaryChars[i] = (value & ((T)1 << i)) == 0 ? '0' : '1';
        }
        Console.WriteLine($"{prefix} = {new string(binaryChars)} = {value}");
    }
}

Scarily, when I run this program, the console outputs the following:

_a = 0000000000000000000000000000000000000000000000000000000000000000 = 0
_b = 1111111111111111111111111111111111111111111111111111111111111111 = -1
cu = 0000000000000000000000000000000011111111111111111111111111111111 = -4294967296
cu = 1111111111111111111111111111111100000000000000000000000000000000 = 4294967295
cu = 1111111111111111111111111111111111111111111111111111111111111111 = -1
cu = 0000000000000000000000000000000000000000000000000000000000000000 = 0

Even though we only ever assigned the values 0 and -1, the variable sometimes appears to have the values -4294967296 and 4294967295!

If I change T to UInt64, I get similarly scary results. However, if I change T to SByte, Byte, Int16, UInt16, Int32, or UInt32, it behaves "properly", i.e., it only ever reads one of the two values that we wrote.

First of all, what's going on here? It looks like the tear is happening on the boundary between the two 32-bit chunks. Is this scary behavior therefore possible for any struct larger than 32 bits? Or is it possible for any struct? Is it even possible for references to value types? That would be even scarier, since we would get the wrong memory location.

Secondly, what should I do about it? I thought the volatile keyword might help here, but it's not applicable to Int64 or UInt64, although it is applicable to the types that appear to behave "properly" without it. Should I always use a lock when accessing a variable that could be set from another thread? Until now, I have only used lock under more advanced circumstances, e.g., when I need to set two variables at once, or when multiple threads need to increment a variable.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
hypehuman
  • 1,290
  • 2
  • 18
  • 37
  • 1
    I suspect its running as x86, therefore anything larger than 32 bits will be subject to torn reads/writes. If you add `Console.WriteLine(Environment.Is64BitProcess);` what does that print? – Matthew Watson Oct 20 '22 at 08:08
  • @MatthewWatson You're right, it prints `False`. – hypehuman Oct 20 '22 at 08:10
  • So that would explain it. Anything larger than the default register size (32 bits for x86, 64 bits for x64) will be subject to torn read/write. – Matthew Watson Oct 20 '22 at 08:11
  • "That would be even scarier, since we would get the wrong memory location." - since one of the primary goals with the design of .NET is memory safety, why do you immediately jump to the supposition that you'd be stuck in an unsafe situation by default? – Damien_The_Unbeliever Oct 20 '22 at 08:12
  • 1
    x86 in 32-bit mode can still do 64-bit atomic loads/stores,but it takes extra work (e.g. using SSE2 `movq`) so it definitely won't happen for free. In C++ you'd use `std::atomic`. If you can't get C# to do that for you, you might use a SeqLock so readers can detect torn reads and retry; good if writes aren't super frequent, especially only 1 writer thread. [Implementing 64 bit atomic counter with 32 bit atomics](https://stackoverflow.com/q/54611003) / [A readers/writer lock... without having a lock for the readers?](https://stackoverflow.com/q/61237650) – Peter Cordes Oct 20 '22 at 10:39

1 Answers1

1

There are two types of programmers who write lock-free multithreaded code:

  1. Very experienced, knowledgeable and smart engineers, who are also interested about hardware and CPU architectures.
  2. Programmers who just started dipping their toes in multithreading, thinking it should be easy.

Most programmers who write multithreaded code, are between these two extremes. They are aware of the pitfalls of lock-free multithreading, and avoid it like a plague. They identify the state of their program that is shared between threads, and protect it meticulously using the lock statement with a dedicated locker object. They make sure that only one thread at a time can interact with the shared state. They make it physically impossible for one thread to read the state, while another thread is modifying it. This is a relatively easy way to guarantee the correctness of their program, provided that they are careful and disciplined.

My advice is to take a break, and study multithreading systematically for a few days. It will be time well spend. There are some good online resources available, like the Threading in C# by Joseph Albahari, and there are also good books for sale in the bookstores.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104