6.6 KiB
6.6 KiB
1.4 多线程与异步规范
本规范旨在指导 .NET 中的多线程与异步编程,确保代码的高效性、稳定性和可维护性,避免常见的死锁、线程饥饿和竞态条件问题。
1. 异步编程 (Async/Await)
1.1 避免 async void
- 规则:严禁使用
async void,唯一的例外是事件处理程序 (Event Handlers)。 - 理由:
async void方法无法被等待 (await)。async void方法中抛出的异常无法被调用方捕获,会导致进程崩溃(除非在 SynchronizationContext 中捕获)。- 难以测试和组合。
- 反例 :
// ❌ [不推荐] 使用 async void,异常无法被外部捕获 private async void ExecuteAsync(object obj) { try { await _jobPerformer.Perform(...); } catch (Exception e) { // 必须在内部吞掉所有异常,否则进程崩溃 Console.WriteLine(e); } } - 正例:
// ✅ [推荐] 返回 Task,允许调用方等待和处理异常 private async Task ExecuteAsync(object obj) { try { await _jobPerformer.Perform(...); } catch (Exception e) { _logger.LogError(e, "Error occurred"); } }
1.2 异步全链路 (Async All the Way)
- 规则:一旦开始使用异步,应在整个调用链路中保持异步。
- 理由:避免同步/异步混合导致的死锁(Sync-over-Async)和线程池饥饿。
- 反例:
// ❌ [禁止] 在异步方法中阻塞等待 public void DoSomething() { DoSomethingAsync().Result; // 或者 .Wait() } - 正例:
// ✅ [推荐] 使用 await public async Task DoSomething() { await DoSomethingAsync(); }
1.3 库代码使用 ConfigureAwait(false)
- 规则:在通用类库 (非 UI 层、非 ASP.NET Core Controller 层) 代码中,应使用
.ConfigureAwait(false)。 - 理由:避免在不需要特定
SynchronizationContext(如 UI 线程) 的情况下强制切回原上下文,提高性能并减少死锁风险。 - 注意:在 ASP.NET Core 应用层代码中通常不需要,因为 ASP.NET Core 没有 SynchronizationContext。但在编写底层 SDK (如
Aegis.Caching) 时必须遵守。 - 正例:
// ✅ [推荐] 类库代码 public async Task<string> GetDataAsync() { var result = await _httpClient.GetStringAsync(url).ConfigureAwait(false); return result; }
1.4 传入 CancellationToken
- 规则:异步方法应尽可能支持
CancellationToken,以便在操作不再需要时取消。 - 正例:
// ✅ [推荐] 支持取消 public async Task ProcessAsync(CancellationToken cancellationToken = default) { while (!cancellationToken.IsCancellationRequested) { await Task.Delay(1000, cancellationToken); // ... 业务逻辑 } }
2. 线程与任务 (Threads & Tasks)
2.1 优先使用 Task 而非 Thread
- 规则:使用
Task.Run或Task.Factory.StartNew(带有TaskCreationOptions.LongRunning),尽量避免直接new Thread()。 - 理由:
Task基于线程池,资源利用率更高,且 API 更易于组合和异常处理。 - 例外:需要设置线程优先级、名称或必须是前台线程的特殊场景。
2.2 避免长时间阻塞线程池线程
- 规则:如果任务是 CPU 密集型且运行时间很长,应指定
TaskCreationOptions.LongRunning,或者使用独立的线程,避免耗尽线程池导致吞吐量下降。
3. 线程安全与锁 (Locking)
3.1 锁的选择
-
规则:
- 同步代码:首选
lock(即Monitor)。 - 异步代码:必须使用
SemaphoreSlim,严禁在lock块中使用await。 - 高性能/轻量级:
Interlocked用于简单的计数器或原子交换。 - 自旋锁:慎用
SpinLock或SpinWait,除非非常短的临界区且你非常清楚自己在做什么。 建议:对于大多数业务场景,普通的lock或SemaphoreSlim足够且更安全。除非经过 Benchmark 证明必须使用自旋锁。
- 同步代码:首选
-
正例 (异步锁):
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); public async Task SafeOperationAsync() { await _lock.WaitAsync(); try { // ✅ 可以在锁内 await await DoSomethingAsync(); } finally { _lock.Release(); } }
3.2 线程安全的集合
- 规则:在多线程环境下读写集合,应使用
System.Collections.Concurrent命名空间下的集合 (如ConcurrentDictionary,ConcurrentQueue),而不是手动加锁的List或Dictionary。 - 正例:
private readonly ConcurrentDictionary<string, User> _cache = new(); public void AddUser(User user) { // ✅ 线程安全 _cache.TryAdd(user.Id, user); }
3.3 静态成员的线程安全
- 规则:静态成员 (Static Members) 在多线程环境下是共享的,必须保证其线程安全。
- 建议:
- 尽量设计为不可变 (Immutable)。
- 如果必须可变,使用
lock或Concurrent类型保护。
4. 并行处理 (Parallel)
4.1 控制并发度
- 规则:使用
Parallel.ForEach或Task.WhenAll时,必须控制并发数量 (MaxDegreeOfParallelism),防止瞬间并发过高压垮下游服务 (数据库、Redis、外部 API)。 - 正例:
var options = new ParallelOptions { MaxDegreeOfParallelism = 5 }; await Parallel.ForEachAsync(items, options, async (item, token) => { await ProcessItemAsync(item, token); });
4.2 避免 Parallel 里的共享状态写入
- 规则:避免在并行循环中写入共享变量,这通常是非线程安全的。应使用线程局部变量或并发集合。
5. 最佳实践总结
- 不要阻塞异步代码:避免
.Result,.Wait()。 - 异常处理:
Task中的异常会被包装在AggregateException中 (如果使用.Result或.Wait()),但在await时会解包抛出第一个异常。建议始终使用await。 - 避免线程饥饿:不要在线程池线程中执行长时间的同步 IO 操作。
- 不可变性:尽可能设计不可变对象,天然线程安全。