📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

πŸ“š Interview question

What is a race condition? Give examples in .NET, including async and database races, and explain how you find and fix them.

Check-then-act and read-modify-write races, non-thread-safe collections, async interleaving, lost updates in databases, and detection and fixes.

Senior Debugging Advanced concurrency optimistic-concurrency race-condition thread-safety

🎯 Key points

  • A race condition makes results depend on timing
  • Common forms: read-modify-write, check-then-act, unsafe lazy init
  • Non-thread-safe collections can corrupt under concurrent writes
  • Async code races at await points even on one thread
  • Database lost updates need concurrency tokens, atomic updates or constraints
  • ConcurrentDictionary.GetOrAdd factories can run more than once
  • Find races with stress tests, Coyote and reviews of shared mutable state

⏱ 30-second answer

A race condition is a bug where the result depends on the timing of concurrent operations. Classic forms are read-modify-write (count++ from two threads), check-then-act (if (!dict.ContainsKey(k)) dict.Add(k, v)), and unsafe publication. In .NET they also appear with non-thread-safe collections (a shared Dictionary or List corrupted under concurrent writes), in async code that interleaves at await points even on one thread, and in databases as lost updates when two requests read, modify and save the same row. Fixes: atomic operations, locks, concurrent collections, immutability, single-writer designs, and in databases optimistic concurrency tokens, unique constraints and idempotency.

πŸ—£ Full answer (senior)

How I would answer this in an interview

"A race condition is a bug where the outcome depends on the timing of concurrent operations. Usually it's two actors reading and writing shared state without the operations being atomic as a whole. The classic shapes are read-modify-write (count++ from two threads loses updates), check-then-act (if (!dict.ContainsKey(k)) dict.Add(k, v) lets two threads both pass the check), and unsafe initialisation or publication. In .NET I also see three variants in production. Non-thread-safe collections like a shared Dictionary or List get corrupted under concurrent writes. Async interleaving at await points can race even with no extra threads. And database lost updates happen when two requests read the same row, modify it and save. My fixes, in order of preference: design the sharing away (immutability, a single owner), push the invariant into something that guarantees atomicity (Interlocked, a concurrent collection, a unique constraint, an atomic SQL UPDATE, an optimistic concurrency token), and only then use locks. Races rarely show up in unit tests, so I find them with stress tests, tools like Coyote, review of shared mutable state, and production signals like impossible states."


1. Race patterns and fixes

Pattern Example Fix
Read-modify-write _total += x; Interlocked.Add, lock
Check-then-act (memory) if (!cache.ContainsKey(k)) cache[k] = Load(k); ConcurrentDictionary.GetOrAdd (+ Lazy<T>), lock
Check-then-act (DB) if (!db.Bookings.Any(...)) db.Add(booking); Unique constraint; conditional atomic update
Lazy initialisation Two threads create the "singleton" Lazy<T>, LazyInitializer
Unsafe publication Flag set before data is visible Volatile.Write, immutable objects, locks
Shared non-thread-safe collection List<T>.Add from many threads Concurrent/immutable collection, per-thread results merged
Async interleaving Double-click triggers two concurrent saves Disable re-entry, SemaphoreSlim, idempotency
DB lost update Two requests read stock 5, both write 4 Concurrency token, UPDATE ... SET Qty = Qty - 1 WHERE Qty > 0
Duplicate message processing At-least-once delivery Idempotency key with a unique index
Time-of-check vs time-of-use (files) if (File.Exists(p)) File.Delete(p) Just attempt the operation and handle failure

2. Non-thread-safe collections

Collection Concurrent writes can cause
Dictionary<K,V> Lost entries, InvalidOperationException ("concurrent operations…"), corrupted buckets, historically infinite loops at 100% CPU
List<T> Lost items, IndexOutOfRangeException, nulls in the array
HashSet<T>, Queue<T> Same class of corruption

"It's mostly reads" isn't safe: a single concurrent writer is enough.

3. Async races without threads

UI click #1: await SaveAsync()  ── suspended while the HTTP call is in flight
UI click #2: await SaveAsync()  ── runs before #1 finishes  β†’ two orders created

Server side: two requests for the same user interleave around awaits that read and write shared in-memory state (caches, session-like objects). Fixes: guard re-entry, per-key SemaphoreSlim, idempotency keys.

4. Database races

Technique How it prevents the race Trade-off
Atomic conditional update (ExecuteUpdateAsync in EF Core 7+) One statement checks and changes Logic in SQL/predicate
Optimistic concurrency ([Timestamp] / rowversion, IsConcurrencyToken) Save fails with DbUpdateConcurrencyException if the row changed Retry/merge logic
Unique constraint DB rejects the second insert Handle the duplicate-key error
Pessimistic locking (UPDLOCK, SELECT ... FOR UPDATE) Serialises access to the row Contention, deadlock risk
Higher isolation (serializable, snapshot with conflict detection) DB detects conflicts Cost, retries

Read committed (SQL Server's default) does not prevent lost updates.

5. Finding races

Method Notes
Code review for shared mutable state Statics, singletons, caches, fields in services
Stress tests Many concurrent workers, randomised delays, many iterations
Microsoft Coyote Systematic exploration of async interleavings
Load tests on hot entities 100 clients fighting over one seat/account
Production invariants Alerts on impossible states (negative stock, duplicate bookings)
Arm64 testing Exposes memory-ordering races hidden on x64

Production example

During a popular on-sale, our [ticketing site] had [20,000] users online and double-booked [37] seats. The booking code loaded the seat, checked HolderId == null, set the holder and saved, a check-then-act across two round trips, and logs showed two requests passing the check within milliseconds. I moved the invariant into the database: a unique constraint on (EventId, SeatId) in bookings, and an atomic conditional update (UPDATE Seats SET HolderId = @c WHERE Id = @s AND HolderId IS NULL, via ExecuteUpdateAsync), returning 409 "seat taken" when zero rows changed. I added a load test where [100] clients book the same seat at once and assert exactly one success. Double bookings went to zero in the next on-sale.

πŸ” What the interviewer is checking

Checks the ability to recognise and eliminate races across layers.

Green flags

  • Names read-modify-write and check-then-act.
  • Mentions non-thread-safe collections and async interleaving.
  • Handles database lost updates with concurrency tokens or constraints.
  • Describes how to detect races (stress tests, tools).

Red flags

  • Only knows lock.
  • Thinks single-threaded async is race-free.

πŸ“š Learn in depth

1. Why races hide

  • The window between read and write can be nanoseconds; it widens only under load, with more cores, GC pauses or preemption.
  • Debuggers and logging change timing, so the bug disappears when you look (a "Heisenbug").
  • Weaker memory models (Arm64) expose ordering races that x64 hides.
  • Dev and CI machines have fewer cores and less contention than production.

2. Designing races out

Design How it removes races
Immutable data + atomic reference swap Readers never see partial updates; writers replace whole objects
Single owner (actor, one Channel<T> reader per key) Only one piece of code mutates the state
Partition by key Each key handled by one worker; no shared state across keys
Database as the source of truth Constraints and atomic statements enforce invariants for all instances
Idempotency keys Duplicates become no-ops

3. ConcurrentDictionary caveats

// Factory can run more than once under contention; only one result is stored.
var value = cache.GetOrAdd(key, k => ExpensiveLoad(k));

// Run the factory at most once per key:
var lazy = cache2.GetOrAdd(key, k => new Lazy<Data>(() => ExpensiveLoad(k), LazyThreadSafetyMode.ExecutionAndPublication));
var data = lazy.Value;

// Compound operations are still races:
if (cache.TryGetValue(k, out var v)) cache[k] = v + 1;           // RACE
cache.AddOrUpdate(k, 1, (_, old) => old + 1);                     // OK (update delegate may be retried)

4. EF Core optimistic concurrency with retry

for (int attempt = 0; attempt < 3; attempt++)
{
    var product = await db.Products.SingleAsync(p => p.Id == id, ct);
    if (product.Stock < qty) return Results.Conflict("Out of stock");
    product.Stock -= qty;
    try
    {
        await db.SaveChangesAsync(ct);    // WHERE Id = @id AND RowVersion = @original
        return Results.Ok();
    }
    catch (DbUpdateConcurrencyException)
    {
        db.ChangeTracker.Clear();         // reload and retry
    }
}
return Results.Conflict("Please retry");

For simple counters, the atomic update is simpler and has no retries:

int rows = await db.Products
    .Where(p => p.Id == id && p.Stock >= qty)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - qty), ct);
if (rows == 0) return Results.Conflict("Out of stock");

5. A stress test that finds races

[Fact]
public async Task Only_one_booking_wins()
{
    var tasks = Enumerable.Range(0, 100).Select(c => client.PostAsync($"/seats/42/book?customer={c}", null));
    var responses = await Task.WhenAll(tasks);
    Assert.Equal(1, responses.Count(r => r.StatusCode == HttpStatusCode.OK));
    Assert.Equal(99, responses.Count(r => r.StatusCode == HttpStatusCode.Conflict));
}

6. Version notes

Version Change
.NET Framework 4.0 Concurrent collections, Lazy<T>
.NET Core 3.0+ Dictionary throws on detected concurrent modification in more cases instead of looping
EF Core 7 ExecuteUpdateAsync / ExecuteDeleteAsync
.NET 8 Frozen collections for read-only shared data
.NET 10 Same primitives

7. Try it yourself

  1. Run the code example: 10 loads for 10 keys despite 1,000 concurrent calls.
  2. Uncomment the unsafe Dictionary version and run it repeatedly; watch for exceptions or wrong counts.
  3. Add 10,000 items to a shared List<int> from 8 tasks and print Count.
  4. In EF Core, add [Timestamp] and save the same entity from two DbContexts; catch DbUpdateConcurrencyException.
  5. Write the 100-client seat test against a version without the constraint, then with it.
  6. Simulate a double-click by calling an async save twice without awaiting the first, and add a re-entry guard.

πŸ’» Code

using System.Collections.Concurrent;

// 1. Check-then-act race and its fix.
var unsafeCache = new Dictionary<string, int>();
var safeCache = new ConcurrentDictionary<string, Lazy<int>>();
int loads = 0;

Parallel.For(0, 1_000, i =>
{
    string key = $"k{i % 10}";
    // Race: two threads can both miss and both load (and corrupt the Dictionary).
    // if (!unsafeCache.ContainsKey(key)) unsafeCache[key] = Load(key);

    // Safe: one Lazy per key, so Load runs once per key even under contention.
    _ = safeCache.GetOrAdd(key, k => new Lazy<int>(() => Load(k))).Value;
});
Console.WriteLine($"Loads: {loads}"); // 10

int Load(string key)
{
    Interlocked.Increment(ref loads);
    return key.Length;
}

// 2. Database lost update: atomic statement instead of read-modify-write (EF Core 7+).
// int rows = await db.Seats
//     .Where(s => s.Id == seatId && s.HolderId == null)
//     .ExecuteUpdateAsync(s => s.SetProperty(x => x.HolderId, customerId), ct);
// if (rows == 0) return Results.Conflict("Seat already taken");

// 3. Optimistic concurrency token on an entity.
public sealed class Seat
{
    public int Id { get; set; }
    public int? HolderId { get; set; }
    [System.ComponentModel.DataAnnotations.Timestamp]
    public byte[] RowVersion { get; set; } = [];
}

⚠ Common mistakes

1. Sharing Dictionary/List across threads "because it's mostly reads"

What happens: a static dictionary is read by requests and occasionally written. Why it is wrong: a single concurrent write can corrupt it. Do instead: concurrent or immutable collections, or lock every access.

2. Enforcing "only one" rules with application checks

What happens: if (!exists) insert across two round trips. Why it is wrong: two requests pass the check simultaneously. Do instead: unique constraints or atomic conditional updates.

3. Assuming single-threaded async can't race

What happens: UI handlers or per-user server flows run concurrently around awaits. Why it is wrong: code interleaves at every await. Do instead: re-entry guards, per-key SemaphoreSlim, idempotency.

4. Trusting GetOrAdd factories to run once

What happens: an expensive or side-effecting factory runs twice. Why it is wrong: factories can run multiple times under contention. Do instead: store Lazy<T> values.

5. Compound operations on concurrent collections

What happens: TryGetValue then indexer set on ConcurrentDictionary. Why it is wrong: each call is atomic; the combination isn't. Do instead: AddOrUpdate, GetOrAdd, or a lock.

6. Relying on default isolation to prevent lost updates

What happens: "we're in a transaction, so it's safe". Why it is wrong: read committed doesn't stop two transactions overwriting each other. Do instead: concurrency tokens, atomic updates or appropriate locking.

7. Catching DbUpdateConcurrencyException and ignoring it

What happens: the conflict is logged and the request returns success. Why it is wrong: the user's change silently didn't happen. Do instead: retry with fresh data, merge, or return 409.

8. Using locks to fix multi-instance races

What happens: lock around booking code in a service running 4 replicas. Why it is wrong: locks are per process; other instances still race. Do instead: database guarantees or distributed coordination.

9. Trying to reproduce races with a debugger

What happens: stepping through code to find the race. Why it is wrong: the debugger changes timing and hides it. Do instead: stress tests, logging with correlation ids, Coyote.

10. No invariant monitoring

What happens: races are only discovered by customers. Why it is wrong: data damage accumulates silently. Do instead: alerts and periodic checks for impossible states (negative balances, duplicates).

πŸ› Senior insight: trade-offs

I look for races wherever two actors share mutable state, and the database is the most common place in web systems. The strongest fixes move the invariant to something that guarantees it atomically: a constraint, an atomic statement, or a single owner.

Shared state Strongest fix
In-memory counter Interlocked
In-memory cache ConcurrentDictionary with Lazy<T>
Row updated by many requests Concurrency token or atomic UPDATE
"Only one" business rule Unique constraint
Message processing Idempotency key with a unique index

πŸ”— Follow-ups the interviewer may ask

  1. When: Candidate proposes atomic operations Why: Checks Interlocked and CAS
    How to answer

    Interlocked provides atomic operations on shared variables: Increment, Decrement, Add, Exchange, CompareExchange, Read (for 64-bit values on 32-bit platforms), and And/Or (.NET 5+). Each is a single indivisible hardware operation and a full memory fence. CompareExchange(ref x, newValue, expected) writes only if x still equals expected and returns the original value, which is the building block for lock-free algorithms: read, compute, try to swap, and retry if another thread changed it. Use it for counters, flags and reference swaps; use a lock when several values must change together.

  2. When: Candidate suggests concurrent collections Why: Checks concurrent collection behaviour
    How to answer

    System.Collections.Concurrent types are safe for many threads at once. ConcurrentDictionary uses lock-free reads and fine-grained (striped) locks for writes. Each single method is atomic, but check-then-act across two calls is not, so use GetOrAdd, AddOrUpdate, TryUpdate or TryRemove. Caveat: the value factory in GetOrAdd/AddOrUpdate can run more than once under contention (only one result is stored), so wrap expensive or side-effecting work in Lazy<T>. Count, IsEmpty and ToArray take every lock, so avoid them in hot paths. ConcurrentQueue/ConcurrentStack are lock-free FIFO/LIFO. ConcurrentBag is unordered, optimised for the same thread adding and taking. BlockingCollection adds bounding and blocking Take but blocks threads; for async producer/consumer use Channel<T>.

  3. When: Candidate uses a lock to fix it Why: Checks lock design
    How to answer

    lock (x) { ... } gives mutual exclusion: only one thread at a time runs the block for the same lock object, and entering and leaving act as memory barriers. For a normal object the compiler emits Monitor.Enter(x, ref lockTaken) in a try/finally with Monitor.Exit. Since .NET 9 and C# 13, if x is a System.Threading.Lock, the compiler instead uses x.EnterScope() and disposes the scope, which is a dedicated, slightly faster lock type. Locks are re-entrant on the same thread, you cannot await inside one (CS1996), and you should lock on a private, readonly object and keep the block short.

πŸ›  Real-world problems: how you handle them

Overselling seats during a big on-sale
Situation
A [ticketing site] sold seats for a [sold-out concert] with [20,000] users online at once.
Signals
Customer complaints of double-booked seats; [37] seats had two confirmed orders.
Investigation
The booking code read the seat, checked it was free, then saved the holder. Logs showed two requests passing the check within milliseconds.
Resolution
Added a unique constraint on (EventId, SeatId) in bookings and an atomic conditional update on the seat, returning 409 when it failed.
Prevention
Added a load test that books the same seat from [100] clients at once and expects exactly one success.

How to tell it (STAR)

During a [big on-sale] we double-booked [37] seats. The code checked a seat was free and then saved, so two requests could both pass the check. I moved the rule into the database with a unique constraint and an atomic update, and added a load test that fights over one seat. Double bookings went to zero.

More in Concurrency Production Problems

Last updated 31 Oct 2026.

🌐 Sangam's Webring

βͺ Previous 🏠 Main Next ⏩
πŸ’¬ Chat with me