mi-assessment/docs/开发规范/1-编程规约/1.4-多线程与异步规范.md
2026-02-03 20:50:51 +08:00

6.6 KiB
Raw Blame History

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.RunTask.Factory.StartNew (带有 TaskCreationOptions.LongRunning),尽量避免直接 new Thread()
  • 理由Task 基于线程池,资源利用率更高,且 API 更易于组合和异常处理。
  • 例外:需要设置线程优先级、名称或必须是前台线程的特殊场景。

2.2 避免长时间阻塞线程池线程

  • 规则:如果任务是 CPU 密集型且运行时间很长,应指定 TaskCreationOptions.LongRunning,或者使用独立的线程,避免耗尽线程池导致吞吐量下降。

3. 线程安全与锁 (Locking)

3.1 锁的选择

  • 规则

    • 同步代码:首选 lock (即 Monitor)。
    • 异步代码:必须使用 SemaphoreSlim,严禁在 lock 块中使用 await
    • 高性能/轻量级Interlocked 用于简单的计数器或原子交换。
    • 自旋锁:慎用 SpinLockSpinWait,除非非常短的临界区且你非常清楚自己在做什么。 建议:对于大多数业务场景,普通的 lockSemaphoreSlim 足够且更安全。除非经过 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),而不是手动加锁的 ListDictionary
  • 正例
    private readonly ConcurrentDictionary<string, User> _cache = new();
    
    public void AddUser(User user)
    {
        // ✅ 线程安全
        _cache.TryAdd(user.Id, user);
    }
    

3.3 静态成员的线程安全

  • 规则:静态成员 (Static Members) 在多线程环境下是共享的,必须保证其线程安全。
  • 建议
    • 尽量设计为不可变 (Immutable)。
    • 如果必须可变,使用 lockConcurrent 类型保护。

4. 并行处理 (Parallel)

4.1 控制并发度

  • 规则:使用 Parallel.ForEachTask.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. 最佳实践总结

  1. 不要阻塞异步代码:避免 .Result, .Wait()
  2. 异常处理Task 中的异常会被包装在 AggregateException 中 (如果使用 .Result.Wait()),但在 await 时会解包抛出第一个异常。建议始终使用 await
  3. 避免线程饥饿:不要在线程池线程中执行长时间的同步 IO 操作。
  4. 不可变性:尽可能设计不可变对象,天然线程安全。