← Back to Articles         
Multithreading & Parallel Programming - Part 2

Multithreading & Parallel Programming - Part 2

Published on 12 Oct 2026     9 min read C#
Multithreading and Asynchronous Programming

C# Thread Synchronization, Thread Safety, and Parallel Programming

1. What is the lock keyword in C#?

What is it?

The lock keyword is used to prevent multiple threads from executing a critical section of code simultaneously using the same lock object.

It helps prevent race conditions when accessing shared data.

Example

int counter = 0;
object lockObj = new object();

Parallel.For(0, 1000, i =>
{
    lock (lockObj)
    {
        counter++;
    }
});

Console.WriteLine(counter);

Output:

1000

Explanation:

  • lockObj is the synchronization object.

  • Only one thread can enter the protected block at a time.

  • Other threads wait until the lock is released.

  • counter++ executes safely.

Flow Diagram

Thread 1 ──> Acquire Lock ──> counter++
                 |
                 v
             Release Lock
                 |
Thread 2 ──> Acquire Lock ──> counter++

Key Points

  • lock prevents simultaneous access to a critical section.

  • It protects shared mutable data.

  • The lock is automatically released when the block exits, even if an exception occurs.

  • Prefer a dedicated private lock object.

  • You cannot use await inside a lock block.

 

2. What is the difference between lock, Monitor, Mutex, and SemaphoreSlim?

What is it?

All four mechanisms control access to shared resources, but they have different capabilities.

Feature

lock

Monitor

Mutex

SemaphoreSlim

Purpose

Mutual exclusion

Mutual exclusion

Mutual exclusion

Limit concurrent access

Scope

Same process

Same process

Same or different processes

Same process

Multiple concurrent entries

No

No

No

Yes, configurable

Timeout support

No direct timeout

Yes

Yes

Yes

Async waiting

No

No

No

Yes

Typical usage

Shared variables

Advanced synchronization

Cross-process locking

Async resource control

Example: SemaphoreSlim

SemaphoreSlim semaphore = new SemaphoreSlim(2);

async Task ProcessAsync(int id)
{
    await semaphore.WaitAsync();

    try
    {
        Console.WriteLine($"Task {id} started");

        await Task.Delay(1000);
    }
    finally
    {
        semaphore.Release();
    }
}

await Task.WhenAll(
    ProcessAsync(1),
    ProcessAsync(2),
    ProcessAsync(3)
);

Explanation:

  • SemaphoreSlim(2) allows a maximum of two concurrent entries.

  • Tasks 1 and 2 can enter immediately.

  • Task 3 waits until a permit is released.

  • WaitAsync() supports asynchronous waiting.

Diagram

SemaphoreSlim(2)
       |
       +---- Task 1 → Running
       |
       +---- Task 2 → Running
       |
       +---- Task 3 → Waiting
                         |
                    Permit Released
                         |
                         v
                       Running

Key Points

  • Use lock for simple synchronous critical sections.

  • Use Monitor when advanced locking features such as TryEnter, Wait, and Pulse are needed.

  • Use Mutex when synchronization between processes is required.

  • Use SemaphoreSlim for asynchronous synchronization or limiting concurrent operations.

  • lock and Monitor are reentrant for the owning thread; SemaphoreSlim is not thread-owner-aware.

 

3. What is Thread Safety?

What is it?

Thread safety means code behaves correctly when multiple threads access it concurrently.

It prevents inconsistent results, corrupted data, and race conditions.

Example

class Counter
{
    private int _count = 0;
    private readonly object _lock = new();

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

    public int GetCount()
    {
        lock (_lock)
        {
            return _count;
        }
    }
}

Explanation:

  • Multiple threads may call Increment().

  • The lock ensures only one thread modifies _count at a time.

  • GetCount() also uses the lock to read the shared value consistently.

Key Points

  • Thread safety is important when data is shared across threads.

  • Local variables are generally independent between method invocations.

  • Shared static fields and singleton services require special attention.

  • Use lock, Interlocked, immutable data, or concurrent collections when appropriate.

  • Thread-safe code does not necessarily execute faster.

 

4. What is the difference between Parallel.ForEach() and a normal foreach?

What is it?

  • foreach: Processes items sequentially.

  • Parallel.ForEach(): Processes items concurrently, potentially on multiple CPU cores.

Example

int[] numbers = { 1, 2, 3, 4, 5 };

// Normal foreach
foreach (int number in numbers)
{
    Console.WriteLine(number);
}

// Parallel.ForEach
Parallel.ForEach(numbers, number =>
{
    Console.WriteLine(number);
});

Explanation:

Normal foreach:

1 → 2 → 3 → 4 → 5

Parallel.ForEach():

Thread 1 → 1, 4
Thread 2 → 2, 5
Thread 3 → 3

Execution order is not guaranteed.

Comparison

foreach

Parallel.ForEach()

Sequential execution

Parallel execution

Preserves iteration order

Execution order not guaranteed

Minimal overhead

Scheduling overhead

Suitable for simple operations

Suitable for CPU-intensive independent operations

Easier debugging

Requires concurrency awareness

Key Points

  • Parallel.ForEach() is useful for CPU-bound workloads.

  • It uses ThreadPool threads.

  • Parallel processing does not always improve performance.

  • Avoid modifying shared variables without synchronization.

  • For asynchronous I/O, consider Parallel.ForEachAsync().

 

5. What is Interlocked, and when should it be used?

What is it?

Interlocked provides atomic operations on shared variables without requiring an explicit lock.

Atomic means the operation completes as one indivisible action.

Example

int counter = 0;

Parallel.For(0, 1000, i =>
{
    Interlocked.Increment(ref counter);
});

Console.WriteLine(counter);

Output:

1000

Explanation:

  • Multiple threads increment the same variable.

  • Interlocked.Increment() prevents lost updates.

  • The final value is always 1000 in this example.

Common Methods

Method

Purpose

Increment()

Increase by 1

Decrement()

Decrease by 1

Add()

Add a specified value

Exchange()

Atomically replace a value

CompareExchange()

Replace only if the current value matches an expected value

Key Points

  • Interlocked is useful for counters and simple shared-state updates.

  • It avoids the overhead of an explicit lock.

  • It supports atomic operations on specific data types.

  • Use lock when multiple operations must execute together as one critical section.

 

6. What is the volatile keyword?

What is it?

The volatile keyword tells the compiler and runtime that a field may be accessed by multiple threads.

It provides specific memory-ordering and visibility guarantees, but does not make compound operations atomic.

Example

class Worker
{
    private volatile bool _stopRequested;

    public void Run()
    {
        while (!_stopRequested)
        {
            // Perform work
        }
    }

    public void Stop()
    {
        _stopRequested = true;
    }
}

Explanation:

  • One thread executes Run().

  • Another thread calls Stop().

  • The volatile field ensures the loop checks the shared flag with the required visibility semantics.

Key Points

  • volatile is used for simple shared-state communication.

  • It restricts certain compiler and processor memory reorderings.

  • It does not guarantee atomicity for counter++.

  • It does not replace lock for protecting multiple related operations.

  • Prefer CancellationToken for cancellation in modern asynchronous applications.

 

7. What is a Deadlock, and how can you prevent it?

What is it?

A deadlock occurs when two or more threads wait indefinitely for resources held by each other.

Diagram

Thread 1:
  Acquires Lock A
  Waits for Lock B

Thread 2:
  Acquires Lock B
  Waits for Lock A

Result:
  Thread 1 waits for Thread 2
  Thread 2 waits for Thread 1

  DEADLOCK

Example

object lockA = new();
object lockB = new();

Task task1 = Task.Run(() =>
{
    lock (lockA)
    {
        Thread.Sleep(100);

        lock (lockB)
        {
            Console.WriteLine("Task 1");
        }
    }
});

Task task2 = Task.Run(() =>
{
    lock (lockB)
    {
        Thread.Sleep(100);

        lock (lockA)
        {
            Console.WriteLine("Task 2");
        }
    }
});

await Task.WhenAll(task1, task2);

Explanation:

  • Task 1 acquires lockA, then attempts lockB.

  • Task 2 acquires lockB, then attempts lockA.

  • If both hold their first lock simultaneously, neither can proceed.

  • The program may hang.

How to Prevent Deadlocks

  1. Acquire locks in a consistent order.

  2. Avoid nested locks where possible.

  3. Keep critical sections short.

  4. Use timeouts with Monitor.TryEnter() when appropriate.

  5. Avoid blocking asynchronous code using .Wait() or .Result.

Key Points

  • Deadlocks are caused by circular waiting.

  • Consistent lock ordering is an important prevention technique.

  • Deadlocks can occur in synchronous and asynchronous code.

  • A timeout can prevent indefinite waiting but does not automatically fix the underlying design.

 

8. What is the difference between Thread.Sleep() and Task.Delay()?

What is it?

  • Thread.Sleep(): Blocks the current thread for a specified duration.

  • Task.Delay(): Creates an awaitable delay without blocking a thread during the wait.

Example

// Blocking
Thread.Sleep(2000);

Console.WriteLine("Sleep completed");

// Non-blocking asynchronous delay
await Task.Delay(2000);

Console.WriteLine("Delay completed");

Diagram

Thread.Sleep(2000)
------------------
Thread → Blocked for 2 seconds
       → Continue execution


await Task.Delay(2000)
----------------------
Thread → Start delay
       → Released during wait
       → Delay completes
       → Continuation executes

Comparison

Thread.Sleep()

Task.Delay()

Blocks current thread

Does not block while awaited

Synchronous

Asynchronous

Does not support cancellation tokens

Supports CancellationToken

Useful for specialized synchronous scenarios

Preferred for async methods

Wastes a thread during the wait

Improves scalability during waits

Key Points

  • Avoid Thread.Sleep() in ASP.NET Core request handling.

  • Prefer await Task.Delay() for asynchronous delays.

  • Task.Delay() does not automatically create a new thread.

  • Task.Delay() is commonly used for retry intervals and polling.

 

9. What are Channel<T> and Producer-Consumer Patterns?

What is it?

Producer-Consumer Pattern: A design pattern where producers create work items and consumers process them independently.

Channel<T>: A thread-safe mechanism in .NET for passing data asynchronously between producers and consumers.

Diagram

Producer 1 ──┐
             |
Producer 2 ──┼──> Channel<T> ──> Consumer
             |      Queue           |
Producer 3 ──┘                      |
                                    v
                              Process Items

Example

using System.Threading.Channels;

var channel = Channel.CreateUnbounded<int>();

// Producer
async Task ProducerAsync()
{
    for (int i = 1; i <= 5; i++)
    {
        await channel.Writer.WriteAsync(i);
    }

    channel.Writer.Complete();
}

// Consumer
async Task ConsumerAsync()
{
    await foreach (int item in channel.Reader.ReadAllAsync())
    {
        Console.WriteLine($"Processing: {item}");
    }
}

await Task.WhenAll(
    ProducerAsync(),
    ConsumerAsync()
);

Output:

Processing: 1
Processing: 2
Processing: 3
Processing: 4
Processing: 5

Explanation:

  • The producer writes numbers into the channel.

  • The consumer reads and processes the numbers.

  • ReadAllAsync() waits asynchronously when no items are available.

  • Complete() signals that no more items will be written.

Real-World ASP.NET Core Example

Web API Request
      |
      v
Add Email Job to Channel
      |
      v
BackgroundService
      |
      v
Read Email Job
      |
      v
Send Email

Key Points

  • Channel<T> supports asynchronous producer-consumer communication.

  • It is thread-safe for multiple producers and consumers.

  • Bounded channels can provide backpressure when consumers are slower than producers.

  • It is useful for background jobs, message processing, and work queues.

  • An in-memory channel is not durable; queued work can be lost if the application stops.

 

Quick Interview Revision

Concept

Important Point

lock

Allows one thread at a time into a critical section

Monitor

Advanced locking with timeout and signaling

Mutex

Can synchronize across processes

SemaphoreSlim

Controls concurrent access; supports async

Thread Safety

Correct behavior under concurrent access

Parallel.ForEach()

Parallel processing for CPU-bound operations

Interlocked

Atomic operations without explicit locking

volatile

Memory visibility and ordering, not compound atomicity

Deadlock

Circular waiting between threads or tasks

Thread.Sleep()

Blocks the current thread

Task.Delay()

Asynchronous, non-blocking delay

Channel<T>

Thread-safe asynchronous producer-consumer queue

Interview focus for .NET Tech Lead: Prioritize lock, thread safety, deadlock prevention, SemaphoreSlim, Interlocked, and Task.Delay() versus Thread.Sleep(). These concepts are especially relevant when designing concurrent ASP.NET Core services.

← Back to Articles