$npx -y skills add Aaronontheweb/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. Avoid locks and manual synchronization unless absolutely necessary.
| 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 | ## Reference Files |
| 13 | |
| 14 | - [advanced-concurrency.md](advanced-concurrency.md): Akka.NET Streams, Reactive Extensions, Akka.NET Actors (entity-per-actor, state machines, cluster sharding), and async local function patterns |
| 15 | |
| 16 | ## The Philosophy |
| 17 | |
| 18 | **Start simple, escalate only when needed.** |
| 19 | |
| 20 | 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. |
| 21 | |
| 22 | **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. |
| 23 | |
| 24 | **Locks should be the exception, not the rule.** When you can't avoid shared mutable state: |
| 25 | 1. **First choice:** Redesign to avoid it (immutability, message passing, actor isolation) |
| 26 | 2. **Second choice:** Use `System.Collections.Concurrent` (ConcurrentDictionary, etc.) |
| 27 | 3. **Third choice:** Use `Channel<T>` to serialize access through message passing |
| 28 | 4. **Last resort:** Use `lock` for simple, short-lived critical sections |
| 29 | |
| 30 | --- |
| 31 | |
| 32 | ## Decision Tree |
| 33 | |
| 34 | ``` |
| 35 | What are you trying to do? |
| 36 | │ |
| 37 | ├─► Wait for I/O (HTTP, database, file)? |
| 38 | │ └─► Use async/await |
| 39 | │ |
| 40 | ├─► Process a collection in parallel (CPU-bound)? |
| 41 | │ └─► Use Parallel.ForEachAsync |
| 42 | │ |
| 43 | ├─► Producer/consumer pattern (work queue)? |
| 44 | │ └─► Use System.Threading.Channels |
| 45 | │ |
| 46 | ├─► UI event handling (debounce, throttle, combine)? |
| 47 | │ └─► Use Reactive Extensions (Rx) |
| 48 | │ |
| 49 | ├─► Server-side stream processing (backpressure, batching)? |
| 50 | │ └─► Use Akka.NET Streams |
| 51 | │ |
| 52 | ├─► State machines with complex transitions? |
| 53 | │ └─► Use Akka.NET Actors (Become pattern) |
| 54 | │ |
| 55 | ├─► Manage state for many independent entities? |
| 56 | │ └─► Use Akka.NET Actors (entity-per-actor) |
| 57 | │ |
| 58 | ├─► Coordinate multiple async operations? |
| 59 | │ └─► Use Task.WhenAll / Task.WhenAny |
| 60 | │ |
| 61 | └─► None of the above fits? |
| 62 | └─► Ask yourself: "Do I really need shared mutable state?" |
| 63 | ├─► Yes → Consider redesigning to avoid it |
| 64 | └─► Truly unavoidable → Use Channels or Actors to serialize access |
| 65 | ``` |
| 66 | |
| 67 | --- |
| 68 | |
| 69 | ## Level 1: async/await (Default Choice) |
| 70 | |
| 71 | **Use for:** I/O-bound operations, non-blocking waits, most everyday concurrency. |
| 72 | |
| 73 | ```csharp |
| 74 | // Simple async I/O |
| 75 | public async Task<Order> GetOrderAsync(string orderId, CancellationToken ct) |
| 76 | { |
| 77 | var order = await _database.GetAsync(orderId, ct); |
| 78 | var customer = await _customerService.GetAsync(order.CustomerId, ct); |
| 79 | return order with { Customer = customer }; |
| 80 | } |
| 81 | |
| 82 | // Parallel async operations (when independent) |
| 83 | public async Task<Dashboard> LoadDashboardAsync(string userId, CancellationToken ct) |
| 84 | { |
| 85 | var ordersTask = _orderService.GetRecentOrdersAsync(userId, ct); |
| 86 | var notificationsTask = _notificationService.GetUnreadAsync(userId, ct); |
| 87 | var statsTask = _statsService.GetUserStatsAsync(userId, ct); |
| 88 | |
| 89 | await Task.WhenAll(ordersTask, notificationsTask, statsTask); |
| 90 | |
| 91 | return new Dashboard( |
| 92 | Orders: await ordersTask, |
| 93 | Notifications: await notificationsTask, |
| 94 | Stats: await statsTask); |
| 95 | } |
| 96 | ``` |
| 97 | |
| 98 | **Key principles:** Always accept `CancellationToken`. Use `ConfigureAwait(false)` in library code. Don't block on async code. |
| 99 | |
| 100 | --- |
| 101 | |
| 102 | ## Level 2: Parallel.ForEachAsync (CPU-Bound Parallelism) |
| 103 | |
| 104 | **Use for:** Processing collections in parallel when work is CPU-bound or you need controlled concurrency. |
| 105 | |
| 106 | ```csharp |
| 107 | public async Task ProcessOrdersAsync( |
| 108 | IEnumerable<Order> orders, |
| 109 | CancellationToken ct) |
| 110 | { |
| 111 | await Parallel.ForEachAsync( |
| 112 | orders, |
| 113 | new ParallelOptions |
| 114 | { |
| 115 | MaxDegreeOfParallelism = Environment.ProcessorCount, |
| 116 | CancellationToken = ct |
| 117 | }, |
| 118 | async (order, token) => |
| 119 | { |
| 120 | await ProcessOrderAsync(order, token); |
| 121 | }); |
| 122 | } |
| 123 | ``` |
| 124 | |
| 125 | **When NOT to use:** Pure I/O operations, when order matters, when you need backpressure. |
| 126 | |
| 127 | --- |
| 128 | |
| 129 | ## Level 3: System.Threading.Channels (Producer/Consumer) |
| 130 | |
| 131 | **Use for:** Work queues, producer/consumer patterns, decoupling producers from consumers. |
| 132 | |
| 133 | ```csharp |
| 134 | public class OrderProcessor |
| 135 | { |
| 136 | private readonly Channel<Order> _channel; |
| 137 | |
| 138 | public OrderProcessor() |
| 139 | { |
| 140 | _channel = Channel.CreateBounded<Order>(new BoundedChannelOptions(100) |
| 141 | { |
| 142 | FullMode = BoundedChannelFullMode.Wait |
| 143 | }); |