掌握了C#中的并发编程,就像是为你的应用程序装上了强大的引擎,能充分释放多核处理器的潜力,打造出响应迅捷、吞吐高效的系统。但同时,它也需要你谨慎驾驭,避免“竞态条件”、“死锁”这些陷阱。我会结合经验,为你梳理并发编程的知识体系、适用场景以及如何应对常见挑战。

为了让你能快速建立起整体认知,我先用一个表格来汇总C#中主要的并发编程技术及其核心特点:

技术类型 关键技术/模式 核心思想 主要优势 典型应用场景
异步编程 async/await 避免阻塞调用线程,在I/O操作等等待时释放线程去执行其他工作 高吞吐、高响应度、节省线程 Web API、UI客户端、I/O密集型操作(文件、网络)
任务并行库 (TPL) Task, Task<T>, Parallel, Parallel LINQ (PLINQ) 提供高级抽象,简化并行代码编写,高效利用多核 开发效率高、性能优化、支持取消和异常聚合 CPU密集型计算、循环并行化、数据并行
并发集合 ConcurrentQueue<T>, ConcurrentDictionary<TKey, TValue> 提供线程安全的集合类型,专为多线程并发访问设计 简化共享数据访问、避免低级锁、性能优化 生产者-消费者队列、共享缓存、并行任务间协作
响应式编程 Reactive Extensions (Rx.NET) 将事件和数据流作为中心,进行声明式组合和过滤 处理复杂事件流、支持背压、时间相关操作 事件驱动的UI、传感器数据处理、实时消息系统
数据流编程 TPL Dataflow 构建网格(mesh)管道,数据在其中流动并被处理,连接了生产者与消费者 精细控制数据流和转换、支持并行和缓冲 管道式处理、生产者-消费者复杂变体
Actor模型 Orleans, Akka.NET 通过异步消息传递在独立实体(Actor)间进行通信 高可扩展性、强隔离性、位置透明 分布式系统、高并发游戏服务器、CQRS/Event Sourcing

⚙️ 核心并发编程工具箱详解

1. 异步编程 (Async/Await)

这是处理I/O密集型操作的利器。

  • 工作机制:使用 asyncawait 关键字。当 await 一个耗时操作时,该方法会挂起,将线程返回到线程池,直到耗时操作完成,后续代码才由线程池中的线程(可能是原线程也可能是新线程)继续执行。
  • 为何高效:它不阻塞线程。在等待数据库响应、文件读写或网络请求时,宝贵的线程资源可以被用来处理其他请求,极大提升了应用的吞吐量和响应能力。
  • 示例
    // 在Web API中异步处理HTTP请求
    public class DataController : ControllerBase
    {
        [HttpGet]
        public async Task<IActionResult> GetAsync(int id)
        {
            // 模拟异步数据库查询
            var data = await _repository.GetDataByIdAsync(id);
            return Ok(data);
        }
    }
    
2. 任务并行库 (TPL) 与数据并行

TPL是并行计算的现代化工具,让你能更轻松地使用多核。

  • Task:代表一个异步操作。Task.Run 常用于将CPU密集型工作卸载到线程池线程。
  • 数据并行Parallel.ForParallel.ForEach 可以将一个循环的迭代自动分配到多个线程上并行执行
    // 使用 Parallel.ForEach 进行数据并行计算
    void ProcessImages(List<Image> images)
    {
        Parallel.ForEach(images, image =>
        {
            ComputeIntensiveTransformation(image); // 耗时的图像处理
        });
    }
    
  • PLINQ:并行LINQ,只需在LINQ查询后加 .AsParallel(),即可尝试并行执行查询。
    var source = Enumerable.Range(1, 10000);
    var evenNumbers = source.AsParallel()
                            .Where(x => x % 2 == 0)
                            .ToArray(); // 并行查询偶数
    
3. 并发集合 (Concurrent Collections)

当多个线程需要安全地读写共享数据时,并发集合是你的首选,它避免了在简单场景下手动使用锁的复杂性。

  • 为何需要:传统的 List<T>Dictionary<TKey, TValue> 在多线程同时读写时会很快崩溃。并发集合内部使用了高效的精巧同步机制,保证了线程安全。
  • 常见类型
    • ConcurrentQueue<T>:先进先出队列,适用于生产者-消费者模式
    • ConcurrentDictionary<TKey, TValue>:线程安全的字典,适用于共享缓存。
    • ConcurrentBag<T>:无序集合,适用于对象池等场景。
  • 示例
    // 使用 ConcurrentQueue 实现生产者-消费者
    private readonly ConcurrentQueue<WorkItem> _workQueue = new ConcurrentQueue<WorkItem>();
    
    // 生产者
    public void EnqueueWork(WorkItem item)
    {
        _workQueue.Enqueue(item);
    }
    
    // 消费者(通常运行在后台线程)
    public async Task ProcessWorkAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            if (_workQueue.TryDequeue(out WorkItem item))
            {
                await ProcessItemAsync(item);
            }
            else
            {
                await Task.Delay(100, ct); // 短暂休息
            }
        }
    }
    

🧰 实战:应对并发挑战的策略

并发编程总会伴随一些挑战,下面是一些常见问题及应对方法:

  1. 竞态条件 (Race Conditions)

    • 问题:多个线程以不确定的顺序访问和操作同一数据,导致结果依赖于执行的时序。
    • 解决方案
      • 锁 (lock):最直接的方式,但要注意粒度,锁得太死会影响性能。
      • 并发集合:如前所述,是共享数据访问的优选。
      • 不可变类型:使用 System.Collections.Immutable 命名空间下的集合,每次“修改”都返回一个新集合,从根本上避免竞态。
  2. 死锁 (Deadlocks)

    • 问题:两个或多个线程相互等待对方占用的资源,导致所有线程都无法继续执行。
    • 解决方案
      • 按固定顺序获取锁:确保所有线程请求锁的顺序是一致的。
      • 设置超时:使用 Monitor.TryEnterSemaphoreSlim.WaitAsync(TimeSpan) 并指定超时时间,避免无限期等待。
      • 避免嵌套锁:尽量减少锁的嵌套层次。
  3. 资源管理与性能

    • 问题:过度创建线程或任务、不当的阻塞操作(如在异步代码中用 ResultWait())会导致线程池饥饿,系统性能急剧下降。
    • 解决方案
      • 使用 SemaphoreSlim 控制并发度:特别是在处理大量突发任务时,限制同时执行的任务数量,保护系统免遭洪水般的请求冲垮。
        // 使用 SemaphoreSlim 限制最大并发数
        private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(10); // 限制10并发
        
        public async Task ProcessManyItemsAsync(List<Item> items)
        {
            var tasks = items.Select(async item =>
            {
                await _semaphore.WaitAsync();
                try
                {
                    await ProcessItemAsync(item);
                }
                finally
                {
                    _semaphore.Release();
                }
            });
            await Task.WhenAll(tasks);
        }
        
      • 区分CPU密集和I/O密集:CPU密集型工作适合用 ParallelTask.Run 分配到多核;I/O密集型工作则应用 async/await,在等待时释放线程。
      • 始终处理任务异常:使用 awaitTask.WaitAll() 来传播和处理异常,防止异常被默默吞掉。

📊 并发编程技术选型指南

面对不同的场景,如何选择合适的技术?下面的流程图可以帮你做出决策:

flowchart TD
    A[开始: 需要并发吗?] --> B{任务类型是什么?}

    B --> C[I/O密集型<br>如: Web请求、数据库操作、文件读写]
    B --> D[CPU密集型<br>如: 图像处理、复杂计算、大数据分析]
    B --> E[多个独立且耗时操作<br>需要简单触发并行]
    B --> F[多线程共享数据<br>如: 缓存、队列、状态字典]

    C --> G[使用 async/await<br>高吞吐, 避免阻塞]
    D --> H{数据并行?}
    H --> I[是, 处理集合<br>使用 Parallel.For/ForEach 或 PLINQ]
    H --> J[否, 异构任务<br>使用 Task.Run 启动多个任务]
    E --> K[使用 Task.WhenAll<br>等待所有独立任务完成]
    F --> L[使用并发集合<br>如: ConcurrentDictionary, ConcurrentQueue]

    G --> M[完成]
    I --> M
    J --> M
    K --> M
    L --> M

💎 总结

C# 提供了丰富且强大的并发编程工具集。关键在于理解不同技术的适用场景

  • 追求响应和吞吐,async/await 是你的基石。
  • 榨干CPU性能,TPL (Parallel, PLINQ, Task) 是你的利器。
  • 安全共享数据,并发集合 是你的护甲。
  • 控制并发洪流,SemaphoreSlim 是你的闸门。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐