Skip to content

Synchronization and Shared State

Concurrency becomes harder when multiple execution paths can touch the same mutable data. At that point, the main problem is no longer only "how do I start more work?" It becomes "how do I coordinate access safely?"

This topic matters because many threading bugs are not obvious syntax problems. They are timing problems: race conditions, deadlocks, lost updates, and state that looks correct in one run but breaks in another.

A practical mental model

mermaid
flowchart TD
    A["Multiple operations run concurrently"] --> B["Shared mutable state?"]
    B -->|No| C["Much simpler design"]
    B -->|Yes| D["Need coordination"]
    D --> E["lock / Monitor"]
    D --> F["SemaphoreSlim or other primitives"]
    D --> G["Concurrent or immutable data structures"]

The easiest shared-state bug is the one you never create.

Race conditions

A race condition happens when correctness depends on timing between operations.

For example, two threads reading and updating the same value can interfere with each other:

csharp
int counter = 0;

counter++;

That line looks simple, but increment is not a magical indivisible concept. It involves reading, changing, and writing. If multiple operations do that concurrently without coordination, results can be wrong.

lock protects critical sections

The most common coordination primitive for shared in-process mutable state is lock.

csharp
private readonly object _gate = new();
private int _count;

public void Increment()
{
    lock (_gate)
    {
        _count++;
    }
}

The lock says only one thread at a time may execute that critical section for that shared gate object.

This is simple and useful, but it should be kept tight and deliberate.

Keep critical sections small

When using synchronization primitives, shorter critical sections are usually easier to reason about and less likely to cause contention.

Good practice usually means:

  • protect only the shared state that really needs coordination
  • avoid long-running or blocking work while holding a lock
  • avoid mixing unrelated state under one big lock when clearer boundaries exist

SemaphoreSlim for limited concurrency

SemaphoreSlim is useful when you want to allow a limited number of concurrent operations rather than exactly one.

csharp
SemaphoreSlim gate = new(2);

await gate.WaitAsync();
try
{
    await Task.Delay(500);
}
finally
{
    gate.Release();
}

This is common when throttling access to a scarce resource such as outbound requests or expensive background work.

Prefer design that reduces shared mutation

The cleanest concurrency design often comes from reducing shared mutable state rather than adding more locks.

Common strategies include:

  • passing data inward instead of sharing one mutable object globally
  • using immutable values for published state
  • using message passing or queues instead of direct shared mutation
  • isolating state ownership to one component or worker

This is one reason concurrent collections and immutable collections matter: they can simplify coordination boundaries.

Deadlocks and contention

Synchronization can solve races, but it can introduce its own problems.

  • Deadlock happens when execution paths wait on each other and nothing can proceed.
  • Contention happens when many operations compete for the same protected resource.

That is why synchronization primitives are helpful but not free.

A worked example

csharp
public sealed class Counter
{
    private readonly object _gate = new();
    private int _value;

    public void Increment()
    {
        lock (_gate)
        {
            _value++;
        }
    }

    public int Read()
    {
        lock (_gate)
        {
            return _value;
        }
    }
}

This is intentionally small, but it teaches the essential pattern:

  • one object owns the state
  • one gate protects that state
  • callers do not manipulate the field directly

Common mistakes

  • Assuming Task-based code automatically avoids shared-state problems.
  • Locking on publicly accessible objects or on this.
  • Holding a lock while doing long-running I/O or waiting work.
  • Adding synchronization everywhere instead of redesigning ownership and mutation boundaries.
  • Thinking thread-safe collections remove all concurrency design concerns.

Summary

  • threading problems usually become difficult when mutable state is shared
  • lock protects critical sections for one-at-a-time access
  • SemaphoreSlim is useful for limited-concurrency coordination
  • deadlocks and contention are real costs of synchronization
  • reducing shared mutable state is often better than adding more locking

Practice

Take one mutable shared value in a sample design and explain whether it should be protected by lock, moved behind a queue, or replaced with an immutable publication model.

As a second exercise, explain why counter++ is not safe to assume in a concurrent shared-state scenario.

Built with VitePress and published on GitHub Pages.