$npx -y skills add wshaddix/dotnet-skills --skill csharp-concurrency-patternsChoosing the right concurrency abstraction in .NET - from async/await for I/O to Channels for producer/consumer to Akka.NET for stateful entity management. Covers both high-level abstractions and low-level synchronization primitives. Use when deciding how to handle concurrent ope
| 1 | # .NET Concurrency: Choosing the Right Tool |
| 2 | |
| 3 | ## When to Use This Skill |
| 4 | |
| 5 | Use this skill when: |
| 6 | - Deciding how to handle concurrent operations in .NET |
| 7 | - Evaluating whether to use async/await, Channels, Akka.NET, or other abstractions |
| 8 | - Tempted to use locks, semaphores, or other synchronization primitives |
| 9 | - Need to process streams of data with backpressure, batching, or debouncing |
| 10 | - Managing state across multiple concurrent entities |
| 11 | |
| 12 | ## The Philosophy |
| 13 | |
| 14 | **Start simple, escalate only when needed.** |
| 15 | |
| 16 | Most concurrency problems can be solved with `async/await`. Only reach for more sophisticated tools when you have a specific need that async/await can't address cleanly. |
| 17 | |
| 18 | **Try to avoid shared mutable state.** The best way to handle concurrency is to design it away. Immutable data, message passing, and isolated state (like actors) eliminate entire categories of bugs. |
| 19 | |
| 20 | **Locks should be the exception, not the rule.** When you can't avoid shared mutable state, using a lock occasionally isn't the end of the world. But if you find yourself reaching for `lock`, `SemaphoreSlim`, or other synchronization primitives regularly, step back and reconsider your design. |
| 21 | |
| 22 | When you truly need shared mutable state: |
| 23 | 1. **First choice:** Redesign to avoid it (immutability, message passing, actor isolation) |
| 24 | 2. **Second choice:** Use `System.Collections.Concurrent` (ConcurrentDictionary, ConcurrentQueue, etc.) |
| 25 | 3. **Third choice:** Use `Channel<T>` to serialize access through message passing |
| 26 | 4. **Last resort:** Use `lock` for simple, short-lived critical sections |
| 27 | |
| 28 | --- |
| 29 | |
| 30 | ## Decision Tree |
| 31 | |
| 32 | ``` |
| 33 | What are you trying to do? |
| 34 | │ |
| 35 | ├─► Wait for I/O (HTTP, database, file)? |
| 36 | │ └─► Use async/await |
| 37 | │ |
| 38 | ├─► Process a collection in parallel (CPU-bound)? |
| 39 | │ └─► Use Parallel.ForEachAsync |
| 40 | │ |
| 41 | ├─► Producer/consumer pattern (work queue)? |
| 42 | │ └─► Use System.Threading.Channels |
| 43 | │ |
| 44 | ├─► UI event handling (debounce, throttle, combine)? |
| 45 | │ └─► Use Reactive Extensions (Rx) |
| 46 | │ |
| 47 | ├─► Server-side stream processing (backpressure, batching)? |
| 48 | │ └─► Use Akka.NET Streams |
| 49 | │ |
| 50 | ├─► State machines with complex transitions? |
| 51 | │ └─► Use Akka.NET Actors (Become pattern) |
| 52 | │ |
| 53 | ├─► Manage state for many independent entities? |
| 54 | │ └─► Use Akka.NET Actors (entity-per-actor) |
| 55 | │ |
| 56 | ├─► Coordinate multiple async operations? |
| 57 | │ └─► Use Task.WhenAll / Task.WhenAny |
| 58 | │ |
| 59 | ├─► Need to protect shared mutable state with synchronization? |
| 60 | │ └─► Is the shared state a single scalar (int, long, reference)? |
| 61 | │ YES -> Use Interlocked (lock-free, lowest overhead) |
| 62 | │ |
| 63 | │ Is the shared state a key-value lookup or queue? |
| 64 | │ YES -> Use ConcurrentDictionary / ConcurrentQueue (thread-safe by design) |
| 65 | │ |
| 66 | │ Does the critical section contain `await`? |
| 67 | │ YES -> Use SemaphoreSlim (async-compatible via WaitAsync) |
| 68 | │ NO -> Does the critical section need many readers, few writers? |
| 69 | │ YES -> Use ReaderWriterLockSlim (only if profiling shows lock contention) |
| 70 | │ NO -> Use lock (simplest, lowest cognitive overhead) |
| 71 | │ |
| 72 | │ Is the critical section extremely short (< 100 ns) with high contention? |
| 73 | │ YES -> Consider SpinLock (advanced, measure first) |
| 74 | │ |
| 75 | └─► None of the above fits? |
| 76 | └─► Ask yourself: "Do I really need shared mutable state?" |
| 77 | ├─► Yes -> Consider redesigning to avoid it |
| 78 | └─► Truly unavoidable -> Use Channels or Actors to serialize access |
| 79 | ``` |
| 80 | |
| 81 | --- |
| 82 | |
| 83 | ## Level 1: async/await (Default Choice) |
| 84 | |
| 85 | **Use for:** I/O-bound operations, non-blocking waits, most everyday concurrency. |
| 86 | |
| 87 | ```csharp |
| 88 | public async Task<Order> GetOrderAsync(string orderId, CancellationToken ct) |
| 89 | { |
| 90 | var order = await _database.GetAsync(orderId, ct); |
| 91 | var customer = await _customerService.GetAsync(order.CustomerId, ct); |
| 92 | return order with { Customer = customer }; |
| 93 | } |
| 94 | |
| 95 | public async Task<Dashboard> LoadDashboardAsync(string userId, CancellationToken ct) |
| 96 | { |
| 97 | var ordersTask = _orderService.GetRecentOrdersAsync(userId, ct); |
| 98 | var notificationsTask = _notificationService.GetUnreadAsync(userId, ct); |
| 99 | var statsTask = _statsService.GetUserStatsAsync(userId, ct); |
| 100 | |
| 101 | await Task.WhenAll(ordersTask, notificationsTask, statsTask); |
| 102 | |
| 103 | return new Dashboard( |
| 104 | Orders: await ordersTask, |
| 105 | Notifications: await notificationsTask, |
| 106 | Stats: await statsTask); |
| 107 | } |
| 108 | ``` |
| 109 | |
| 110 | **Key principles:** |
| 111 | - Always accept `CancellationToken` |
| 112 | - Use `ConfigureAwait(false)` in library code |
| 113 | - Don't block on async code (no `.Result` or `.Wait()`) |
| 114 | |
| 115 | --- |
| 116 | |
| 117 | ## Level 2: Parallel.ForEachAsync (CPU-Bound Parallelism) |
| 118 | |
| 119 | **Use for:** Processing collection |