Almost every bug in concurrent code has the same cause: shared mutable state. Two threads read and write the same variable, the same field, the same collection, and their steps interleave in such a way that the result is wrong. This lesson shows what that looks like, how you make it visible with a test, and which four tools .NET has against it: lock, SemaphoreSlim, Interlocked and data that does not change at all.

Why count++ does not count

count++ looks like one step, but it is three: read, add one, write back. If two threads read the same value, both write back the same value plus one, and a count is lost (lost update). Eight tasks count a million each:

C#
// counter.cs: eight tasks count to a million each, without and with synchronization
#:property PublishAot=false
using System.Diagnostics;
using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
const int Tasks = 8;
const int PerTask = 1_000_000;

int plain = 0;
Measure("count++ unprotected", () => plain++, () => plain);

int interlocked = 0;
Measure("Interlocked.Increment", () => Interlocked.Increment(ref interlocked), () => interlocked);

int locked = 0;
var gate = new Lock();
Measure("lock (Lock)", () =>
{
    lock (gate)
    {
        locked++;
    }
}, () => locked);

void Measure(string name, Action increment, Func<int> result)
{
    var watch = Stopwatch.StartNew();
    Task.WaitAll(Enumerable.Range(0, Tasks).Select(_ => Task.Run(() =>
    {
        for (int i = 0; i < PerTask; i++)
        {
            increment();
        }
    })));
    Console.WriteLine($"{name,-22} {result(),10:N0} of {Tasks * PerTask:N0}, {watch.Elapsed.TotalMilliseconds,5:N0} ms");
}

On a test machine with 32 logical cores, two runs; your numbers are different:

count++ unprotected     1,605,433 of 8,000,000,    76 ms
Interlocked.Increment   8,000,000 of 8,000,000,   204 ms
lock (Lock)             8,000,000 of 8,000,000,   520 ms
count++ unprotected     1,660,527 of 8,000,000,    80 ms
Interlocked.Increment   8,000,000 of 8,000,000,   204 ms
lock (Lock)             8,000,000 of 8,000,000,   535 ms

Without synchronization, four out of five counts are lost, and every run loses different ones. That is the treacherous thing about these bugs: they are not wrong, they are wrong sometimes, sometimes a lot, sometimes a little, sometimes not at all. A test with a single call never finds them.