πŸ“„

Threading Cheatsheet

Advanced 3 min read 400 words

C# Threading & Async Quick Reference Cheatsheet

Thread Creation Methods

Method Use For Thread Source
new Thread() Full control, long-blocking New OS thread
ThreadPool.QueueUserWorkItem Fire-and-forget ThreadPool
Task.Run Modern async code ThreadPool
TaskFactory.StartNew Custom scheduler, LongRunning Configurable
Parallel.For/ForEach Data parallelism ThreadPool

Quick Examples

// Task.Run - preferred for most cases
Task.Run(() => DoWork());
await Task.Run(async () => await DoWorkAsync());

// Long-running (creates dedicated thread)
Task.Factory.StartNew(() => BlockingLoop(), TaskCreationOptions.LongRunning);

// Parallel
Parallel.ForEach(items, item => Process(item));
await Parallel.ForEachAsync(items, async (item, ct) => await ProcessAsync(item, ct));

Synchronization Primitives

Primitive Async Best For
lock No Short sync sections
SemaphoreSlim Yes Async, throttling
SpinLock No Very short locks
ReaderWriterLockSlim No Read-heavy workloads

lock vs SemaphoreSlim

// lock - synchronous only
private readonly object _lock = new();
lock (_lock) { DoWork(); }

// SemaphoreSlim - async support
private readonly SemaphoreSlim _semaphore = new(1, 1);
await _semaphore.WaitAsync();
try { await DoWorkAsync(); }
finally { _semaphore.Release(); }

Async Lock Pattern

public sealed class AsyncLock
{
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    public async Task<IDisposable> LockAsync()
    {
        await _semaphore.WaitAsync();
        return new Releaser(_semaphore);
    }

    private class Releaser : IDisposable
    {
        private readonly SemaphoreSlim _semaphore;
        public Releaser(SemaphoreSlim s) => _semaphore = s;
        public void Dispose() => _semaphore.Release();
    }
}

// Usage
using (await _lock.LockAsync())
{
    await DoWorkAsync();
}

Thread-Safe Collections

Collection Lock-Free Best For
ConcurrentDictionary<K,V> Partial Key-value lookups
ConcurrentQueue<T> Yes FIFO
ConcurrentStack<T> Yes LIFO
ConcurrentBag<T> Per-thread Unordered add/take
Channel<T> Partial Async producer-consumer

ConcurrentDictionary Patterns

// GetOrAdd (factory may run multiple times!)
var value = dict.GetOrAdd(key, k => ComputeValue(k));

// GetOrAdd with Lazy (compute once)
var lazyDict = new ConcurrentDictionary<string, Lazy<int>>();
var value = lazyDict.GetOrAdd(key, k => new Lazy<int>(() => ComputeValue(k))).Value;

// AddOrUpdate
dict.AddOrUpdate(key, 1, (k, old) => old + 1);

Channels (Producer-Consumer)

// Create bounded channel
var channel = Channel.CreateBounded<int>(new BoundedChannelOptions(100)
{
    FullMode = BoundedChannelFullMode.Wait
});

// Producer
await channel.Writer.WriteAsync(item);
channel.Writer.Complete();

// Consumer
await foreach (var item in channel.Reader.ReadAllAsync())
{
    await ProcessAsync(item);
}

Full Modes

Mode Behavior
Wait WriteAsync blocks until space
DropOldest Removes oldest item
DropNewest Drops item being written
DropWrite Returns false, no exception

volatile vs Interlocked

// volatile - visibility only (NOT atomic operations!)
private volatile bool _flag;
_flag = true;  // OK - simple write
// _counter++;  // WRONG - not atomic!

// Interlocked - atomic operations
private int _counter;
Interlocked.Increment(ref _counter);
Interlocked.Add(ref _counter, 10);
Interlocked.CompareExchange(ref _counter, newValue, expected);

When to Use

  • volatile: Simple flags (bool), status indicators
  • Interlocked: Counters, any read-modify-write
  • lock: Multiple related updates

Async Streams

// Produce
public async IAsyncEnumerable<int> GenerateAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    for (int i = 0; i < 100; i++)
    {
        await Task.Delay(100, ct);
        yield return i;
    }
}

// Consume
await foreach (var item in GenerateAsync(cancellationToken))
{
    Process(item);
}

ValueTask vs Task

Aspect ValueTask Task
Allocation None if sync Always
Await count Once only! Multiple OK
Store for later No Yes
Use when Often sync (cache) Always async
// ValueTask - ideal for cached values
public ValueTask<int> GetAsync()
{
    return _hasCache
        ? new ValueTask<int>(_cached)  // No allocation
        : new ValueTask<int>(LoadAsync());  // Falls back to Task
}

// WRONG - never await ValueTask twice!
var vt = GetAsync();
await vt;
await vt;  // UNDEFINED BEHAVIOR!

Parallel Execution

WhenAll/WhenAny

// Wait for all
var results = await Task.WhenAll(task1, task2, task3);

// First to complete
var winner = await Task.WhenAny(task1, task2, task3);
var result = await winner;

// With timeout
var work = DoWorkAsync();
var timeout = Task.Delay(TimeSpan.FromSeconds(30));
if (await Task.WhenAny(work, timeout) == timeout)
    throw new TimeoutException();

Throttled Execution

var semaphore = new SemaphoreSlim(maxConcurrency);

var tasks = items.Select(async item =>
{
    await semaphore.WaitAsync();
    try { return await ProcessAsync(item); }
    finally { semaphore.Release(); }
});

var results = await Task.WhenAll(tasks);

LINQ Quick Tips

Deferred vs Immediate

// Deferred - not executed yet
var query = list.Where(x => x > 10);

// Immediate - executed now
var results = list.Where(x => x > 10).ToList();

ToLookup vs GroupBy

// ToLookup - immediate, cached, missing key = empty
ILookup<int, Item> lookup = items.ToLookup(i => i.Category);
var cat1 = lookup[1];      // Items in category 1
var cat99 = lookup[99];    // Empty (never throws!)

// GroupBy - deferred, re-executes
IEnumerable<IGrouping<int, Item>> groups = items.GroupBy(i => i.Category);

Common Interview Answers

Q: Task.Run vs TaskFactory.StartNew?

  • Task.Run: Always ThreadPool, unwraps async lambdas
  • StartNew: Configurable scheduler, may need Unwrap()

Q: Why can’t use lock with async?

  • lock is thread-affine, await may resume on different thread

Q: volatile vs Interlocked?

  • volatile: Visibility only (simple flags)
  • Interlocked: Atomic operations (counters)

Q: What are Channels?

  • Async-first producer-consumer queues
  • Better than BlockingCollection for async code

Q: Deferred execution gotchas?

  • Multiple enumeration re-executes query
  • Source changes affect results before enumeration

Quick Reference

Parameter Keywords

Keyword Must Init Can Modify Use For
(none) Yes Copy Default
ref Yes Yes Modify caller’s var
out No Must assign Multiple returns
in Yes No Large struct perf

ConfigureAwait

// Library code - don't capture context
await task.ConfigureAwait(false);

// UI code - capture context (default)
await task; // or .ConfigureAwait(true)

CancellationToken Pattern

public async Task DoWorkAsync(CancellationToken ct = default)
{
    while (!ct.IsCancellationRequested)
    {
        ct.ThrowIfCancellationRequested();
        await ProcessAsync(ct);
    }
}