Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.
The runtime contract
Interlocked provides atomic operations over the locations and operations its API specifies. It does not turn a sequence of separate reads, writes, or updates to other fields into one transaction. The portable sample performs actual increments and a compare-and-exchange on one integer so the individual contracts are visible without OS concurrency. The external project adds a controlled split read-modify-write experiment and a real multithreaded atomic counter. A barrier forces both split readers to observe the same prior value, avoiding an unreliable test that merely hopes an unsynchronized increment loses an update during one short run.
Trace the portable program
Starting at zero, the first and second Interlocked.Increment calls return one and two. CompareExchange sees the expected two, stores five, and returns the prior value two. Output is first:1, second:2, observed:2, and current:5. In the external experiment, two deliberately separated volatile reads and writes produce one retained increment after a coordinated race, while four workers using Interlocked each add one hundred and finish at four hundred. Volatile access to a value does not make that split operation atomic; the distinction is between the entire operation and its individually visible pieces.
Execute the actual desktop lab
The desktop split-increment fixture uses two workers and a barrier between each read and write. Both read zero before either can store its increment, so both later write one. The separate atomic-counter fixture uses four owned workers and actual Interlocked increments, then verifies four hundred only after joining them. This provides a reproducible comparison of operation boundaries rather than a timing-sensitive stress test. It does not assert that an ordinary unsynchronized increment will lose the same number of updates on every machine. The controlled fixture deliberately arranges the missing atomicity instead of extrapolating from a lucky run.
Pitfalls and boundary limits
Updating two independent fields with Interlocked does not automatically preserve a relationship visible to other readers. A reader can observe an intermediate combination unless the design publishes one coherent state or uses an appropriate lock. Likewise, an atomic counter does not make the work it counts durable, ordered, or exactly once. A successful increment may precede a later exception in the operation. Keep the protected invariant and effect boundary explicit. Volatile reads and writes address particular visibility and ordering concerns, but wrapping a split read-plus-write in those calls does not change it into the atomic increment that the dedicated API specifies.
Practice one: isolate behavior
First change the portable CompareExchange expected value from two to three. Predict that it returns the observed two and leaves current at two instead of storing five. Hint: the returned value is the old observation, not a success boolean or automatically the new value. Keep both output checks. Then perform a separate successful exchange with the correct expected value. These cases distinguish successful replacement from merely reading a matching-looking number, and they prevent an implementation that always overwrites from passing only the baseline happy path.
Practice two: design and verify
For another exercise, define a two-field invariant such as accepted plus remaining equals capacity, and explain why two individually atomic updates do not publish one coherent snapshot. Choose a lock or immutable-state CAS design and state the reader's protocol as well. Hint: correctness needs the readers to participate; changing only the writer's increment primitive is insufficient. Test a deliberately arranged intermediate state with controlled gates rather than hoping a stress run catches it. Keep any production transaction or external side-effect guarantee separate from the local in-memory atomicity being demonstrated.
Review the evidence
Review atomic code by naming the exact memory location, operation, return contract, and larger invariant. The portable lines establish the real Increment and CompareExchange behavior on one integer. The desktop fixtures establish a controlled lost update and a bounded atomic total, without claiming throughput or lock-free progress for arbitrary surrounding work. Use a dedicated operation where it matches the invariant, and compose stronger synchronization deliberately when the invariant spans more state. A primitive's atomicity is valuable precisely because its boundary is defined; extending that guarantee to every adjacent statement hides the part that still needs design and testing.
Interlocked covers one operation at a time — desktop runtime lab
Set up the SDK project
- Save both files at their exact paths in a fresh owned directory.
- Use a .NET 10 SDK, then run dotnet run --project RuntimeLab.csproj. This project intentionally uses C# 13 syntax.
- Workers and waits are bounded; a timeout or a violated invariant fails the process. No benchmark or NativeAOT publication is implied by an ordinary run.
Download complete SDK project ↗
Project files
Program.cs
Download file ↗using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
int split = 0;
using var barrier = new Barrier(2);
void SplitIncrement() { int before = Volatile.Read(ref split); if (!barrier.SignalAndWait(TimeSpan.FromSeconds(5))) throw new TimeoutException(); Volatile.Write(ref split, before + 1); }
OwnedThreads.Run(SplitIncrement, SplitIncrement);
int atomic = 0;
OwnedThreads.Run(Enumerable.Range(0, 4).Select(_ => (Action)(() => { for (int i = 0; i < 100; i++) Interlocked.Increment(ref atomic); })).ToArray());
Console.WriteLine($"separate-read-write:{split}");
Console.WriteLine($"atomic:{atomic}");
public static class OwnedThreads
{
public static void Run(params Action[] actions)
{
var errors = new ConcurrentQueue<Exception>();
var threads = actions.Select(action => new Thread(() => { try { action(); } catch (Exception error) { errors.Enqueue(error); } }) { IsBackground = true }).ToArray();
foreach (var thread in threads) thread.Start();
foreach (var thread in threads) if (!thread.Join(TimeSpan.FromSeconds(8))) throw new TimeoutException("Owned worker did not finish");
if (!errors.IsEmpty) throw new AggregateException(errors);
}
}
RuntimeLab.csproj
Download file ↗<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>13.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>false</PublishAot>
</PropertyGroup>
</Project>
Expected result
Exact behavioral stdout on the stated desktop lab: separate-read-write:1 atomic:400 Variable diagnostics, where emitted, go to stderr and must not be treated as portable timing or allocation guarantees.
Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.
C# companion practice
using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
int count = 0;
Console.WriteLine($"first:{Interlocked.Increment(ref count)}");
Console.WriteLine($"second:{Interlocked.Increment(ref count)}");
int observed = Interlocked.CompareExchange(ref count, 5, 2);
Console.WriteLine($"observed:{observed}");
Console.WriteLine($"current:{count}");
C# practice output
first:1 second:2 observed:2 current:5
An atomic increment is not a transaction over several fields. The desktop lost-update experiment uses a barrier instead of hoping that a race happens.
Further reading
Interview questions for this lesson
- Explain: Why is an Interlocked increment stronger than separate volatile reads and writes but weaker than a multi-field transaction?
- Trace: Trace both increments and the successful integer CompareExchange return value.
- Design: How would you maintain a capacity invariant spanning accepted and remaining state?