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:
// 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.