C# 万字拆解线程间通讯?

文章目录

  • 核心同步基元 (Low-Level Primitives)
    • [1. AutoResetEvent](#1. AutoResetEvent)
    • [2. ManualResetEvent](#2. ManualResetEvent)
    • [3. 互斥锁(Mutex)和监视器(Monitor)](#3. 互斥锁(Mutex)和监视器(Monitor))
      • [3.1. 监视器 (`Monitor` / `lock`)](#3.1. 监视器 (Monitor / lock))
      • [监视器 (`Monitor`) - `Wait` / `Pulse`](#监视器 (Monitor) - Wait / Pulse)
      • [3.2. 互斥锁 (`Mutex`)](#3.2. 互斥锁 (Mutex))
      • [3.3. 总结](#3.3. 总结)
    • [4. 信号量(Semaphore)和信号量Slim(SemaphoreSlim)](#4. 信号量(Semaphore)和信号量Slim(SemaphoreSlim))
    • [5. 线程间消息传递](#5. 线程间消息传递)
    • [6. 事件(Event)和委托(Delegate)](#6. 事件(Event)和委托(Delegate))
      • [6.1. 委托](#6.1. 委托)
      • [6.2. 事件](#6.2. 事件)
  • 消息传递与数据共享 (Data Sharing & Messaging)
    • [1. 并发集合 (Concurrent Collections)](#1. 并发集合 (Concurrent Collections))
      • [1.1. ConcurrentQueue<T> (并发队列)](#1.1. ConcurrentQueue<T> (并发队列))
      • [1.2. ConcurrentDictionary<TKey, TValue> (并发字典)](#1.2. ConcurrentDictionary<TKey, TValue> (并发字典))
      • [1.3. 总结](#1.3. 总结)
    • [2. 阻塞集合 (The BlockingCollection)](#2. 阻塞集合 (The BlockingCollection))
      • [2.1. 什么是阻塞集合?](#2.1. 什么是阻塞集合?)
      • [2.2. 内部结构与机制](#2.2. 内部结构与机制)
      • [2.3. 阻塞原理](#2.3. 阻塞原理)
      • [2.4. 核心方法详解](#2.4. 核心方法详解)
        • [2.4.1. 生产方法(添加数据)](#2.4.1. 生产方法(添加数据))
        • [2.4.2. 消费方法(取出数据)](#2.4.2. 消费方法(取出数据))
      • [2.5. 总结](#2.5. 总结)
      • [2.6. Monitor 与 BlockingCollection<T> 对比](#2.6. Monitor 与 BlockingCollection<T> 对比)
        • [2.6.1. 为什么 `BlockingCollection<T>` 更好?](#2.6.1. 为什么 BlockingCollection<T> 更好?)
        • [2.6.2. 什么时候应该使用 `Monitor`?](#2.6.2. 什么时候应该使用 Monitor?)
    • [3. 任务完成源 (`TaskCompletionSource<T>`)](#3. 任务完成源 (TaskCompletionSource<T>))
  • 异步编程 (Asynchronous Programming)
    • [1. Task 和 async/await](#1. Task 和 async/await)
      • [1.1. Task(任务)](#1.1. Task(任务))
      • [1.2.async 和 await 关键字](#1.2.async 和 await 关键字)
    • [2. 异步编程的优势:非阻塞](#2. 异步编程的优势:非阻塞)
      • [2.1. 传统的阻塞方式(同步)](#2.1. 传统的阻塞方式(同步))
      • [2.2. 异步的非阻塞方式](#2.2. 异步的非阻塞方式)
    • [3. TaskCompletionSource<T> 的作用](#3. TaskCompletionSource<T> 的作用)
    • [4. 示例:异步 I/O 操作](#4. 示例:异步 I/O 操作)
    • 总结

C#中,线程之间的通信是实现多线程应用程序的关键环节。线程通信不仅确保数据的安全性和一致性,还是实现多线程协作和同步的重要手段。

核心同步基元 (Low-Level Primitives)

这个翻译比较生硬,总之这些是操作系统提供的最基础的工具,提供了对并发执行的精细控制。

1. AutoResetEvent

AutoResetEvent 是一种同步原语,它允许一个线程等待另一个线程发出信号。当一个线程调用 WaitOne() 方法时 ,它会被阻塞直到另一个线程调用 Set() 方法为止。每次 Set() 被调用后,AutoResetEvent 将自动重置为非信号状态,这意味着只能唤醒一个等待的线程。

复制代码
using System;
using System.Threading;

class Program
{
    static AutoResetEvent autoResetEvent = new AutoResetEvent(false);

    static void Main()
    {
        Thread workerThread = new Thread(WorkerMethod);
        workerThread.Start();

        Console.WriteLine("主线程:启动工作线程");
        Thread.Sleep(1000); // 模拟一些操作

        Console.WriteLine("主线程:发出信号让工作线程继续");
        autoResetEvent.Set(); // 发出信号
    }

    static void WorkerMethod()
    {
        Console.WriteLine("工作线程:正在等待信号...");
        autoResetEvent.WaitOne(); // 等待信号
        Console.WriteLine("工作线程:收到信号,继续执行");
    }
}

2. ManualResetEvent

与 AutoResetEvent 类似,ManualResetEvent 允许一个或多个线程等待某个事件的发生。不同的是,ManualResetEvent 在 Set() 被调用之后不会自动重置,除非显式地调用了 Reset() 方法。

复制代码
using System;
using System.Threading;

class Program
{
    static ManualResetEvent manualResetEvent = new ManualResetEvent(false);

    static void Main()
    {
        for (int i = 0; i < 3; i++)
        {
            Thread workerThread = new Thread(WorkerMethod);
            workerThread.Start(i + 1);
        }

        Console.WriteLine("主线程:所有工作线程已启动,等待它们准备就绪...");
        Thread.Sleep(1000); // 模拟一些操作

        Console.WriteLine("主线程:发出信号让所有工作线程继续");
        manualResetEvent.Set(); // 发出信号

        // 手动重置ManualResetEvent,与Auto的区别
        manualResetEvent.Reset();
    }

    static void WorkerMethod(object threadId)
    {
        Console.WriteLine($"工作线程 {threadId}:正在等待信号...");
        manualResetEvent.WaitOne(); // 等待信号
        Console.WriteLine($"工作线程 {threadId}:收到信号,继续执行");
    }
}

3. 互斥锁(Mutex)和监视器(Monitor)

互斥锁和监视器是C#中实现线程同步的基本机制。它们可以防止多个线程同时访问共享资源,从而避免数据竞争和不一致。互斥锁(Mutex)和监视器(Monitor,即 C# 中的 lock 关键字),我们必须理解它们的核心区别:作用域

  • Monitor / lock 进程内(Intra-Process)同步。它更快,只保护当前应用程序域中的共享资源。
  • Mutex 跨进程(Inter-Process)同步。它可以保护系统范围内的共享资源,例如确保某个应用程序只有一个实例在运行。

3.1. 监视器 (Monitor / lock)

保护进程内的共享资源

我们使用 lock 语句来保护一个共享的计数器 (_counter)。多个线程同时尝试增加计数器的值 1000 次。如果没有锁的保护,最终结果将小于 100000(数据竞争)。

复制代码
using System.Threading;
using System.Threading.Tasks;

public class MonitorExpert
{
    private static int _counter = 0;
    // 1. 声明一个私有的、静态的、只读的对象作为锁句柄
    private static readonly object _lockObject = new object();
    private const int Iterations = 1000;
    private const int NumThreads = 100;

    public void RunMonitorExample()
    {
        Console.WriteLine($"启动 {NumThreads} 个线程,每个线程操作 {Iterations} 次...");
        _counter = 0; // 重置计数器

        // 启动多个任务/线程
        Task[] tasks = new Task[NumThreads];
        for (int i = 0; i < NumThreads; i++)
        {
            tasks[i] = Task.Run(() => IncrementCounter());
        }

        // 等待所有任务完成
        Task.WaitAll(tasks);

        Console.WriteLine($"\n期望结果: {Iterations * NumThreads}");
        Console.WriteLine($"实际结果: {_counter}");

        // 实际结果应等于期望结果,因为 lock 保证了原子性。
    }

    private void IncrementCounter()
    {
        for (int i = 0; i < Iterations; i++)
        {
            // 2. 使用 lock 语句包裹临界区 (Critical Section)
            lock (_lockObject)
            {
                // 临界区:只有持有 _lockObject 锁的线程才能进入和执行。
                // 确保对 _counter 的读取、增加、写入操作是原子的。
                _counter++;
            }
            // 线程离开临界区,锁被释放
        }
    }
}

原子性(Atomicity): 指一个操作或一系列操作要么完全发生,要么完全不发生。在多线程环境中,_counter++ 实际上包含三个步骤(读取值、增加值、写入值),lock 保证了这三个步骤作为一个不可分割的单元执行。
锁句柄(Lock Object): lock 语句内部使用的是 Monitor.Enter 和 Monitor.Exit。它要求你提供一个引用类型(如 object 或 private readonly 字段)作为锁的标记。切勿 使用 this、typeof(T) 或字符串作为锁句柄。

监视器 (Monitor) - Wait / Pulse

场景:使用 Monitor 实现生产者/消费者

我们将使用一个共享的 Queue<T> 作为缓冲区。生产者向队列中添加数据,消费者从队列中取出数据。Monitor 确保以下两点:

  1. 互斥访问: 任何时刻只有一个线程能操作队列(通过 lock)。

  2. 通讯/等待:

    1. 如果队列满,生产者等待(Wait)。
    2. 如果队列空,消费者等待(Wait)。
    3. 当生产者添加数据后,通知(Pulse)等待的消费者。
    4. 当消费者取出数据后,通知(Pulse)等待的生产者。

    using System;
    using System.Collections.Generic;
    using System.Threading;
    using System.Threading.Tasks;

    public class MonitorProducerConsumer
    {
    // 共享资源:缓冲区
    private readonly Queue<int> _buffer = new Queue<int>();
    // 锁对象
    private readonly object _lock = new object();
    // 最大容量限制
    private const int Capacity = 5;

    复制代码
     // 运行主入口
     public void RunExample()
     {
         // 创建并启动生产者和消费者
         Task producerTask = Task.Run(() => Produce());
         Task consumerTask = Task.Run(() => Consume());
    
         // 运行一段时间后停止
         Thread.Sleep(5000);
    
         // 演示目的,这里没有优雅退出机制,实际应用中应使用 CancellationToken
         Console.WriteLine("\n--- 5秒结束,演示停止 ---");
         // 由于 Task 还在后台运行,这里只是演示,实际退出需要额外的同步机制。
     }
    
     /// <summary>
     /// 生产者逻辑:生成数据并放入缓冲区
     /// </summary>
     private void Produce()
     {
         int itemCounter = 0;
         while (true)
         {
             // 1. 获取锁,进入临界区
             lock (_lock)
             {
                 // 如果缓冲区已满,则等待
                 while (_buffer.Count >= Capacity)
                 {
                     Console.WriteLine("[生产者] 缓冲区满,等待消费者。");
                     // 2. Wait():释放锁,并阻塞当前线程,等待被 Pulse 唤醒。
                     Monitor.Wait(_lock);
                 }
    
                 // 生产数据
                 int newItem = Interlocked.Increment(ref itemCounter); // 使用 Interlocked 保证 itemCounter 线程安全
                 _buffer.Enqueue(newItem);
                 Console.WriteLine($"[生产者] 生产: {newItem}。当前库存: {_buffer.Count}");
    
                 // 3. Pulse():通知一个(如果有)等待在该锁上的线程(即消费者)。
                 Monitor.Pulse(_lock);
             }
             // 4. 释放锁,线程继续执行
             Thread.Sleep(100); // 模拟生产时间
         }
     }
    
     /// <summary>
     /// 消费者逻辑:从缓冲区取出数据并处理
     /// </summary>
     private void Consume()
     {
         while (true)
         {
             // 1. 获取锁,进入临界区
             lock (_lock)
             {
                 // 如果缓冲区为空,则等待
                 while (_buffer.Count == 0)
                 {
                     Console.WriteLine("[消费者] 缓冲区空,等待生产者。");
                     // 2. Wait():释放锁,并阻塞当前线程,等待被 Pulse 唤醒。
                     Monitor.Wait(_lock);
                 }
    
                 // 消费数据
                 int item = _buffer.Dequeue();
                 Console.WriteLine($"[消费者] 消费: {item}。当前库存: {_buffer.Count}");
    
                 // 3. Pulse():通知一个(如果有)等待在该锁上的线程(即生产者)。
                 Monitor.Pulse(_lock);
             }
             // 4. 释放锁,线程继续执行
             Thread.Sleep(500); // 模拟消费时间
         }
     }

    }

Monitor.Wait(object obj):
释放所有权: 调用线程释放它对 obj 对象持有的锁。
进入等待队列: 线程被挂起,并进入与该锁关联的"等待队列"(Waiting Queue)。
等待唤醒: 线程等待另一个线程调用 Pulse 或 PulseAll。
重新获取: 被唤醒的线程会离开等待队列,并重新尝试获取锁(obj 的所有权)。成功获取锁后,Wait 方法才返回。
Monitor.Pulse(object obj):
必须在持有锁的情况下调用。
将等待队列中的一个线程(具体的选择由运行时决定)移动到"就绪队列"(Ready Queue)。
被移动的线程会竞争重新获取锁。
Monitor.PulseAll(object obj):
与 Pulse 类似,但它会将等待队列中所有线程都移动到就绪队列,让它们竞争重新获取锁。

为什么使用 while 循环而不是 if 语句?

在生产者和消费者的 Wait 之前,我们使用了 while (_buffer.Count >= Capacity)while (_buffer.Count == 0)。这至关重要,因为:

  1. 假唤醒(Spurious Wakeups): 线程有时可能会在没有收到 PulsePulseAll 的情况下被操作系统唤醒。
  2. 多线程竞争: 如果使用 PulseAll 唤醒了多个等待线程,但只有一个线程能够获得锁并执行操作(例如,消费者 A 得到锁并清空了队列),当其他被唤醒的线程 B 获得锁后,它必须重新检查条件是否仍然满足。

使用 while 循环确保了线程在获取锁之后重新检查 进入临界区的条件是否仍然有效。这是使用 Monitor.Wait 进行条件同步的 标准且强制 的做法。

3.2. 互斥锁 (Mutex)

场景:实现单例应用程序(跨进程同步)

这是 Mutex 最经典的应用场景。我们使用一个具名(Named)的 Mutex 来确保应用程序在整个操作系统中只有一个实例在运行。

复制代码
using System;
using System.Threading;

public class MutexExpert
{
    // 定义一个唯一的 Mutex 名称,用于跨进程识别
    private const string AppGuid = "Global\\MyUniqueAppMutex-A3C9E1D2";

    // 存储 Mutex 实例
    private Mutex _appMutex;

    // 标记当前实例是否是唯一实例
    private bool _isOnlyInstance = false;

    public bool CheckAndAcquireMutex()
    {
        try
        {
            // 1. 创建或打开 Mutex
            // 最后一个参数 out bool createdNew:
            // 如果 Mutex 是新创建的,则为 true;如果已经存在(表示其他进程已运行),则为 false。
            _appMutex = new Mutex(true, AppGuid, out bool createdNew);
            _isOnlyInstance = createdNew;

            if (_isOnlyInstance)
            {
                // 2. 如果 Mutex 是新创建的,尝试获取它的所有权
                // 如果成功获取,表示当前进程是第一个启动的。
                // 超时时间设置为 0,表示立即返回结果。
                if (_appMutex.WaitOne(0, false))
                {
                    Console.WriteLine("进程 ID: " + Environment.ProcessId + " - Mutex 获取成功,我是唯一实例。");
                    return true;
                }
            }

            // 如果 Mutex 已经存在,或者 WaitOne 失败(不太可能发生在这里),则退出
            Console.WriteLine("进程 ID: " + Environment.ProcessId + " - 发现另一个实例正在运行,程序即将退出。");
            return false;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Mutex 创建或等待时发生错误: {ex.Message}");
            return false;
        }
    }

    public void ReleaseMutex()
    {
        if (_isOnlyInstance && _appMutex != null)
        {
            try
            {
                // 3. 在退出程序前释放 Mutex,允许其他可能的等待进程启动
                _appMutex.ReleaseMutex();
                _appMutex.Dispose();
                Console.WriteLine("进程 ID: " + Environment.ProcessId + " - Mutex 已释放。");
            }
            catch (ApplicationException)
            {
                // 确保只有持有 Mutex 的线程才能释放它
                Console.WriteLine("尝试释放 Mutex 时出错 (不是持有者)。");
            }
        }
    }
}
  1. 运行 CheckAndAcquireMutex()。如果它是第一个实例,它会返回 true 并保持运行。
  2. 再次运行同一个程序。第二个实例运行 CheckAndAcquireMutex() 时,由于 createdNewfalse(或 WaitOne 失败),它会立即返回 false 并退出。
  3. 只有在第一个实例调用 ReleaseMutex() 或进程终止后,第二个实例才能成功获取 Mutex 并启动。

3.3. 总结

特性 Monitor / lock Mutex
作用域 进程内(Intra-Process) 跨进程(Inter-Process)
性能 (用户模式同步) (内核模式同步,涉及系统调用)
异常处理 自动处理释放(finally 块),通常更安全。 需要手动调用 ReleaseMutex(),更容易出错。
用途 保护程序内的静态变量、集合等共享数据。 实现应用程序单例、跨进程共享文件等。

对于绝大多数 C# 应用程序中的线程同步问题,可以优先使用 lock 语句(即 Monitor)。只有当你的需求明确涉及跨越多个独立进程 的协调时,才需要考虑使用 Mutex

4. 信号量(Semaphore)和信号量Slim(SemaphoreSlim)

信号量和信号量Slim用于控制对共享资源的访问数量。它们允许多个线程同时访问资源,但会限制访问的最大数量。

复制代码
// 使用SemaphoreSlim限制并发访问
public class ResourcePool
{
    private SemaphoreSlim _semaphore = new SemaphoreSlim(3); // 限制最大3个线程同时访问

    public async Task AccessResourceAsync()
    {
        await _semaphore.WaitAsync(); // 等待信号量可用
        try
        {
            // 访问共享资源
            Console.WriteLine($"Accessing resource by thread {Thread.CurrentThread.ManagedThreadId}");
            await Task.Delay(1000); // 模拟耗时操作
        }
        finally
        {
            _semaphore.Release(); // 释放信号量
        }
    }
}

// 使用示例
var pool = new ResourcePool();

var tasks = Enumerable.Range(1, 10).Select(i => pool.AccessResourceAsync()).ToArray();
await Task.WhenAll(tasks);

5. 线程间消息传递

C#中,还可以通过多种方式实现线程间的消息传递,如使用QueueUserWorkItem、ThreadPool、BlockingCollection或Channel等。

复制代码
// 使用BlockingCollection进行线程间消息传递
public class MessageProducerConsumer
{
    private BlockingCollection<string> _messages = new BlockingCollection<string>();

    public void StartProducer()
    {
        Task.Run(() =>
        {
            for (int i = 0; i < 10; i++)
            {
                _messages.Add($"Message {i}"); // 生产消息
                Thread.Sleep(500); // 模拟耗时操作
            }
            _messages.CompleteAdding(); // 表示不再添加消息
        });
    }

    public void StartConsumer()
    {
        Task.Run(() =>
        {
            foreach (var message in _messages.GetConsumingEnumerable())
            {
                Console.WriteLine($"Consumed: {message} by thread {Thread.CurrentThread.ManagedThreadId}");
            }
        });
    }
}

// 使用示例
var producerConsumer = new MessageProducerConsumer();
producerConsumer.StartProducer();
producerConsumer.StartConsumer();

6. 事件(Event)和委托(Delegate)

事件和委托是C#中实现线程间解耦通信的有效方式。事件允许一个线程通知其他线程发生了某个特定的动作或状态变化。

6.1. 委托

复制代码
// 示例代码:使用事件和委托进行线程通信
public class DataProvider
{
    public event Action<string> DataAvailable; // 定义一个事件

    public void SimulateDataGeneration()
    {
        for (int i = 0; i < 5; i++)
        {
            string data = $"Data {i}";
            DataAvailable?.Invoke(data); // 触发事件
            Thread.Sleep(1000); // 模拟耗时操作
        }
    }
}

public class DataConsumer
{
    public void ConsumeData(string data)
    {
        Console.WriteLine($"Consumed data: {data} by thread {Thread.CurrentThread.ManagedThreadId}");
    }
}

6.2. 事件

通过事件和委托机制,可以在对象之间发送消息并通知订阅者。这种模式非常适合需要跨多个线程进行通信的情况,尤其是在图形用户界面(GUI)应用中,主线程通常用于UI更新,而子线程则处理后台任务。

复制代码
using System;
using System.Threading;

// 发布者
public class Publisher
{
    public event EventHandler<int> ProgressChanged;

    protected virtual void OnProgressChanged(int progress)
    {
        ProgressChanged?.Invoke(this, progress);
    }

    public void DoWork()
    {
        for (int i = 0; i <= 100; i += 20)
        {
            Thread.Sleep(500); // 模拟工作进度
            OnProgressChanged(i);
        }
    }
}

// 订阅者
public class Subscriber
{
    private readonly Publisher _publisher;

    public Subscriber(Publisher publisher)
    {
        _publisher = publisher;
        _publisher.ProgressChanged += HandleProgressChanged;
    }

    private void HandleProgressChanged(object sender, int progress)
    {
        Console.WriteLine($"Subscriber: 工作进度更新到 {progress}%");
    }
}

class Program
{
    static void Main()
    {
        var publisher = new Publisher();
        var subscriber = new Subscriber(publisher);

        Thread workThread = new Thread(publisher.DoWork);
        workThread.Start();
    }
}

总结

上述三种方法都展示了如何在线程之间安全地传递信息,避免了直接共享资源可能导致的竞争条件。每种方法都有其适用场景,开发者可以根据具体的需求选择最适合的技术2。例如,在需要精确控制哪些线程被释放的情况下,AutoResetEvent 和 ManualResetEvent 都是非常合适的选择;而在复杂的多线程环境中,使用事件和委托机制可以更方便地管理组件之间的交互。

消息传递与数据共享 (Data Sharing & Messaging)

这些方法侧重于数据的安全传递和共享,而不是单纯的线程阻塞。

"消息传递与数据共享"是解决并发问题的另一种核心思路,它与传统的排他性锁(如 Monitor)和事件等待(如 AutoResetEvent)有所不同。

这种模式的重点在于:安全地共享数据结构,或者通过明确定义的通道(Channel)传递数据,从而避免线程间的直接竞争和复杂的锁机制。

1. 并发集合 (Concurrent Collections)

这是 C# 中最常见、最易用的线程间数据共享工具。它们位于 System.Collections.Concurrent 命名空间下。这些集合类在设计时就考虑了多线程环境,通过内部机制(如无锁算法或细粒度锁)来保证数据操作的原子性和线程安全。

1.1. ConcurrentQueue (并发队列)

  • 特性: 先进先出(FIFO)。
  • 用途: 生产者/消费者模式中最常用的组件。生产者线程安全地将数据放入队列尾部,消费者线程安全地从队列头部取出数据。
  • 核心方法:
    • Enqueue(T item):将项目添加到队列尾部(线程安全)。
    • TryDequeue(out T result):尝试从队列头部移除并返回项目。如果队列为空,则返回 false(非阻塞)。

非阻塞(Non-Blocking)设计

与传统的 Queue<T> 相比,ConcurrentQueue<T> 的关键优势在于它的实现策略:

  • 无锁(Lock-Free)或细粒度锁: 它的内部实现主要依赖于底层操作系统提供的原子操作(Atomic Operations),例如 比较并交换 (Compare-and-Swap, CAS) ,而不是使用全局的 Monitor 锁。这大大减少了线程阻塞和上下文切换的开销。
  • 链表结构: 内部通常实现为一个动态增长的链表结构。生产者在链表尾部添加元素,消费者从链表头部取出元素。通过 CAS 操作保证对头尾指针的修改是原子的,即使多个线程同时操作也不会破坏数据结构。
方法 作用 特性
Enqueue(T item) 将元素添加到队列的尾部。 总是成功的 非阻塞 操作。
TryDequeue(out T result) 尝试从队列的头部移除并返回元素。 非阻塞。如果队列为空,立即返回 false,不会阻塞调用线程。
IsEmpty 获取一个值,表示队列是否为空。 考虑到并发性,这个值可能在你检查后立即发生变化,只用于估计。
复制代码
using System.Collections.Concurrent;
using System.Threading.Tasks;

public class ConcurrentQueueExpert
{
    private static ConcurrentQueue<int> _dataQueue = new ConcurrentQueue<int>();
    private const int ItemCount = 10000;

    public void RunQueueExample()
    {
        // 生产者任务:添加元素
        Task producer = Task.Run(() =>
        {
            for (int i = 1; i <= ItemCount; i++)
            {
                _dataQueue.Enqueue(i);
            }
        });

        // 消费者任务:取出元素并计数
        int consumedCount = 0;
        Task consumer = Task.Run(() =>
        {
            int item;
            while (consumedCount < ItemCount)
            {
                // 尝试取出,如果队列为空,则继续循环(非阻塞)
                if (_dataQueue.TryDequeue(out item))
                {
                    Interlocked.Increment(ref consumedCount);
                }
            }
        });

        Task.WaitAll(producer, consumer);
        Console.WriteLine($"生产者添加了 {ItemCount} 个元素。");
        Console.WriteLine($"消费者取出了 {consumedCount} 个元素。");
    }
}

比较并交换 (Compare-and-Swap, CAS): 这是一种原子操作,它接收三个参数:内存位置 V、旧的期望值 A 和新的值 B。CAS 操作将 V 位置的当前值与 A 进行比较;如果它们相等,则将 V 的值原子地更新为 B。如果它们不相等,则不进行任何操作。整个操作是原子的。这是实现无锁数据结构的基础。

1.2. ConcurrentDictionary<TKey, TValue> (并发字典)

  • 特性: ConcurrentDictionary<TKey, TValue> 是一个线程安全的、可变大小的 键值对集合 。它是标准 Dictionary<TKey, TValue> 的线程安全替代品。
  • 用途: 在多线程环境中安全地维护共享的查找表或缓存。例如,多个线程需要根据键获取或更新配置信息。
  • 核心方法:
    • GetOrAdd(TKey key, Func<TKey, TValue> valueFactory):如果键存在,则返回其值;如果不存在,则添加键值对并返回新值。这是一个原子操作,非常强大。
    • TryUpdate(...):尝试更新特定键的值,如果当前值匹配,则执行更新。
  • 实现机制:分段锁定(Partitioning/Striping)

ConcurrentDictionary<TKey, TValue> 避免了对整个字典使用一个全局锁,而是采用了更精妙的策略:

  • 内部数据分段: 字典的内部存储被分成许多小的**段(Segments)**或桶(Buckets)。
  • 细粒度锁定: 每次对字典的操作(如添加、更新、删除)只会锁定与该键相关的特定段,而不是整个字典。这意味着当一个线程正在更新 A 段时,另一个线程可以同时安全地操作 B 段。
  • 原子方法: 对于最常见的操作,如 GetOrAdd,它提供了原子性保证。
方法 作用 线程安全原子性
GetOrAdd(TKey key, TValue value) 如果键存在,返回现有值;如果不存在,添加新的键值对,并返回新值。 。保证键值对的获取或添加作为一个单一的原子步骤完成。
GetOrAdd(TKey key, Func<TKey, TValue> valueFactory) 允许使用工厂函数来创建新值。 。即使多个线程同时调用,valueFactory 也只会被调用一次。
TryUpdate(TKey key, TValue newValue, TValue comparisonValue) 只有当字典中键的当前值等于 comparisonValue 时,才将值更新为 newValue。 。适用于实现乐观并发控制。
TryRemove(TKey key, out TValue value) 尝试移除指定的键值对。
AddOrUpdate(...) 复杂的原子操作,用于实现添加或更新逻辑。
复制代码
using System.Collections.Concurrent;

public class ConcurrentDictionaryExpert
{
    // 用于统计每个用户操作次数
    private static ConcurrentDictionary<string, int> _userCounts = new ConcurrentDictionary<string, int>();

    public void IncrementUserCount(string userId)
    {
        // 场景:原子地增加用户的计数器
        // 1. 如果用户ID不存在,添加键,初始值为 1。
        // 2. 如果用户ID存在,则使用 valueFactory (lambda) 来更新值 (v + 1)。
        _userCounts.AddOrUpdate(
            key: userId,
            // addValueFactory: 如果键不存在,则添加此值
            addValueFactory: (id) => 1,
            // updateValueFactory: 如果键存在,则使用此函数更新值
            updateValueFactory: (id, existingCount) => existingCount + 1
        );
    }

    public int GetUserCount(string userId)
    {
        // 线程安全地获取值
        return _userCounts.GetOrAdd(userId, 0);
    }
}

乐观并发控制(Optimistic Concurrency Control): 一种假设冲突不常发生的并发控制方法。ConcurrentDictionary 的 TryUpdate 就体现了这种思想:它只有在当前值是你预期值的时候才执行更新,否则就失败(或者需要重试)。

1.3. 总结

集合 线程安全实现 适用场景
ConcurrentQueue 无锁/CAS 异步任务队列、消息缓冲、日志记录队列。
ConcurrentDictionary<TKey, TValue> 分段锁定(Striping)/ 原子操作 线程安全的缓存、多线程计数器、共享配置表。

在 C# 中处理多线程共享数据时,应当 优先使用并发集合 ,而不是手动使用 lock 来保护标准集合。使用这些内置的类,不仅代码更简洁、更安全,而且性能通常更高。

2. 阻塞集合 (The BlockingCollection)

BlockingCollection<T> 是对并发集合的进一步封装和功能增强,专门用于简化经典的 生产者/消费者模式

理解了 ConcurrentQueue<T> 的非阻塞特性,那么 BlockingCollection<T> 的出现,就是为了解决它在 生产者/消费者模式 中的一个痛点:当队列为空或满时,你仍需要手动编写循环和等待逻辑。

BlockingCollection<T> 是对并发集合的优雅封装,它的核心价值在于 自动处理阻塞和通知逻辑

2.1. 什么是阻塞集合?

BlockingCollection<T> 是 .NET Framework 4.0 引入的类,位于 System.Collections.Concurrent 命名空间下。它不是一个全新的数据结构,而是一个 适配器 (Adapter)包装器 (Wrapper)

  • 核心功能

它将以下两个功能结合起来,以实现高效且易于编写的生产者/消费者(P/C)模式:

  • 并发性 (Concurrency): 内部使用一个线程安全的集合(默认为 ConcurrentQueue<T>)。
  • 阻塞性 (Blocking): 提供 AddTake 方法,能够在集合状态不满足操作条件时(如集合为空或达到容量上限)阻塞调用线程,直到条件满足。

2.2. 内部结构与机制

你可以指定 BlockingCollection<T> 内部使用的底层集合,但默认情况下,它使用 ConcurrentQueue<T>

复制代码
// 默认构造函数,内部使用 ConcurrentQueue<T>
var defaultCollection = new BlockingCollection<T>();

// 也可以指定使用 ConcurrentStack<T> 或其他实现了 IProducerConsumerCollection<T> 的集合
var stackCollection = new BlockingCollection<T>(new ConcurrentStack<T>());

2.3. 阻塞原理

BlockingCollection<T> 内部使用了像 SemaphoreSlimManualResetEventSlim 这样的同步基元来管理线程的等待和通知。它在生产者调用 Add 和消费者调用 Take 时,自动执行以下流程:

角色 操作 集合状态 行为 内部同步机制
生产者 Add(T item) 达到容量上限 线程被 阻塞,等待有空间。 等待通知(信号量或事件)。
消费者 Take() 集合为空 线程被 阻塞,等待有数据。 等待通知(信号量或事件)。

2.4. 核心方法详解

2.4.1. 生产方法(添加数据)
方法 特性 用途
Add(T item) 阻塞式添加。 当集合达到容量上限时,线程会阻塞,直到其他线程取出元素腾出空间。
TryAdd(T item) 非阻塞式尝试添加。 立即返回一个布尔值,指示是否添加成功。
CompleteAdding() 关键方法 通知集合:生产者已经完成了所有添加操作。此后,任何新的 Add 尝试都会抛出异常。消费者在处理完剩余数据后,可以安全退出循环。
2.4.2. 消费方法(取出数据)
方法 特性 用途
Take() 阻塞式取出。 当集合为空时,线程会阻塞,直到其他线程放入元素。如果集合已被标记为完成添加 (CompleteAdding),且队列为空,则抛出 InvalidOperationException。
TryTake(out T item) 非阻塞式尝试取出。 立即返回一个布尔值,指示是否取出成功。
GetConsumingEnumerable() 推荐用法 提供一个 IEnumerable 接口,可以用于 foreach 循环。它会不断调用 Take(),自动处理阻塞。当集合完成添加且为空时,循环会自动优雅地结束。
复制代码
using System.Collections.Concurrent;
using System.Threading.Tasks;

public class BlockingCollectionDemo
{
    // 限制容量为 5,演示阻塞效果
    private BlockingCollection<int> _queue = new BlockingCollection<int>(5);

    public void RunPcExample()
    {
        // 生产者任务
        Task producer = Task.Run(() => ProducerJob());

        // 消费者任务
        Task consumer = Task.Run(() => ConsumerJob());

        // 等待生产者完成后,再等待消费者完成
        producer.Wait();
        consumer.Wait();

        Console.WriteLine("\n所有数据已处理完毕,程序退出。");
    }

    private void ProducerJob()
    {
        Console.WriteLine("--- 生产者开始 ---");
        for (int i = 1; i <= 20; i++)
        {
            // 当容量达到 5 时,Add 会阻塞,直到消费者 Take 出数据
            _queue.Add(i);
            Console.WriteLine($"[P] 生产数据: {i}。当前队列大小: {_queue.Count}");
            Thread.Sleep(50); // 模拟快速生产
        }

        // 生产完毕,发送"完成"信号
        _queue.CompleteAdding();
        Console.WriteLine("\n--- 生产者完成添加 ---");
    }

    private void ConsumerJob()
    {
        Console.WriteLine("--- 消费者开始 ---");

        // 使用 GetConsumingEnumerable() 自动处理阻塞和退出
        // 1. 如果队列为空,阻塞等待。
        // 2. 当生产者调用 CompleteAdding() 且所有元素都被取出后,循环自动结束。
        foreach (var item in _queue.GetConsumingEnumerable())
        {
            Console.WriteLine($"[C] 消费数据: {item}");
            Thread.Sleep(200); // 模拟耗时消费
        }

        Console.WriteLine("\n--- 消费者完成消费 ---");
    }
}

2.5. 总结

BlockingCollection<T> 是 C# 中实现 有界 P/C 模式 的黄金标准工具。它将复杂的线程同步逻辑封装起来,让开发者能专注于业务逻辑,而不用担心手动管理锁、WaitPulse

如果你只需要在线程间传递数据流并需要阻塞机制,请优先使用 BlockingCollection<T>

MonitorWait/Pulse 方式与 BlockingCollection<T> 进行对比,有助于你理解在不同场景下,如何选择最合适的工具来实现线程间通讯和同步。


2.6. Monitor 与 BlockingCollection 对比

特性 Monitor (lock + Wait/Pulse) BlockingCollection
抽象层级 。底层同步基元,直接操作锁和通知。 。基于并发集合的适配器/包装器。
实现难度 。需要手动管理:lock、Wait()、Pulse()、while 条件检查(防止假唤醒)。 。封装了所有复杂逻辑,只需调用 Add() 和 Take()。
阻塞机制 手动。需要调用 Monitor.Wait() 来释放锁并阻塞线程。 自动。内部机制自动判断是否需要阻塞,并管理线程的等待/唤醒。
退出机制 复杂。需要额外的旗标(Flag)和 PulseAll() 来通知所有等待线程退出循环。 优雅。使用 CompleteAdding() 方法,消费者通过 GetConsumingEnumerable() 即可自动退出。
容量限制 手动。需要手动检查 if (_buffer.Count >= Capacity),并调用 Wait() 阻塞生产者。 内置。可以在构造函数中指定容量,并自动处理阻塞。
性能 理论上可以实现极高性能,但对开发者要求高,容易出错。 经过 .NET 团队高度优化,对于 P/C 场景,兼顾安全性和性能。
适用场景 需要实现复杂的、非标准同步条件(例如多重条件变量)、或者需要手动控制线程等待/唤醒的底层库。 绝大多数标准的 生产者/消费者模式 场景,是首选方案。
2.6.1. 为什么 BlockingCollection<T> 更好?

对于大多数软件工程师而言,使用 BlockingCollection<T> 实现生产者/消费者模式是最佳实践。

  • 安全: 它消除了手动管理锁、处理"假唤醒"和确保正确退出机制的风险,大大减少了并发 Bug 的发生。
  • 简洁: 特别是 GetConsumingEnumerable() 抽象了消费过程中的所有阻塞和退出逻辑,让代码极其清晰。
2.6.2. 什么时候应该使用 Monitor

MonitorWait/Pulse 机制提供了更底层的、更细致的控制权。只有在以下特殊情况下,你才应该考虑使用它:

  • 底层库开发: 你正在编写一个低级别的同步原语,需要精确控制线程的等待和通知。
  • 非标准同步: 你的同步条件非常独特,无法用现有的并发集合或信号量来描述。
  • 性能极限优化: 你的应用对延迟有极高的要求,并且你确信自己手动实现的 Monitor 优化能够超越 .NET 框架的内置实现(这很少见)。

简单来说:如果你的需求是建立一个数据管道(队列),请使用 BlockingCollection<T>。如果你的需求是实现一个复杂的、基于条件的线程等待机制,且无法使用更高级的抽象,那么使用 Monitor

3. 任务完成源 (TaskCompletionSource<T>)

这是一种现代且高级的通讯方式,它将旧的线程通讯模型(基于回调、事件或低级同步)桥接到 C# 的 Task 异步编程模型中。它不是用于共享数据,而是用于 传递结果或信号

TaskCompletionSource<T> (简称 TCS) 本身不代表一个工作,它代表一个控制句柄

  1. 创建任务: 当你创建一个 TCS 实例时,可以立即通过其 Task 属性获取一个 Task<T> 对象。
  2. 线程 A 等待: 线程 A(通常是调用者)可以 await 这个 Task 对象,然后进入非阻塞等待状态。
  3. 线程 B 发信号: 线程 B(通常是工作者)在完成操作后,调用 TCS 的方法来完成任务。
    1. SetResult(T result):成功完成任务,并将结果传递给等待线程。
    2. SetException(Exception ex):以异常方式完成任务。
    3. SetCanceled():以取消方式完成任务。

当线程 B 调用这些方法时,线程 A 上的 await 表达式会立即继续执行,并接收到结果或抛出异常。

复制代码
using System.Threading.Tasks;

public class TcsExample
{
    // 用于协调结果的句柄
    private TaskCompletionSource<string> _tcs = new TaskCompletionSource<string>();

    public async Task<string> WaitForExternalResultAsync()
    {
        // 1. 调用线程 (主线程) 在此等待结果。
        //    注意: 这是非阻塞的 (async/await)
        Console.WriteLine("主线程: 正在等待结果...");
        string result = await _tcs.Task;

        return $"结果已收到: {result}";
    }

    public void SetResultFromWorkerThread(string data)
    {
        // 2. 工作线程调用此方法,发送信号和结果。
        Console.WriteLine($"工作线程: 成功完成任务,发送结果 '{data}'");
        _tcs.SetResult(data);
        // 此时,等待在 await _tcs.Task 上的主线程会被释放并得到 "data"。
    }
}

异步编程(Async/Await): Task 和 TCS 的使用是 C# 异步编程范式的基石。它们解决了传统线程通讯中常见的线程阻塞和资源浪费问题,尤其适用于 I/O 密集型应用。

机制 核心目的 适用场景 优势
并发集合 线程安全地共享数据结构。 缓存、全局状态管理、简单的队列操作。 性能高(内部优化),API 简洁。
阻塞集合 线程安全地传递数据流。 经典生产者/消费者、数据管道(Pipeline)。 自动处理阻塞和完成信号,最易于实现 P/C 模式。
TaskCompletionSource 桥接异步/同步操作,传递信号和结果。 集成非 Task 异步源、复杂的任务流程协调。 与现代 Task 模型完美融合,非阻塞。

异步编程 (Asynchronous Programming)

在 C# 中,推荐使用基于 Taskasync/await 的异步模型来处理 I/O 密集型操作,这虽然不是传统的"线程间通讯",但它通过最小化线程阻塞,优化了并发性能,避免了大量同步问题。

  • TaskValueTaskTask 代表一个可以在未来完成的操作。线程 A 启动一个 Task,然后继续做其他事情,最终可以通过 awaitContinueWith 接收结果。这是现代 C# 中最主要的 非阻塞式 通讯和流程协调机制。

异步编程模型(基于 Taskasync/await)是处理 I/O 密集型CPU 密集型 任务的首选方法,因为它能最大程度地提高应用程序的响应性和资源利用率。

1. Task 和 async/await

异步编程模型的核心思想是:启动一个操作,然后释放当前线程去执行其他工作,直到操作完成时再回来接收结果。

1.1. Task(任务)

Task 是一个类,它代表一个尚未完成的、正在进行中的工作。

  • Task:代表一个不返回任何值的异步操作(类似于 void 方法)。
  • Task<TResult>:代表一个返回类型为 TResult 的异步操作。

Task 充当了未来结果的 承诺(Promise) 。当一个方法返回 Task 时,它承诺在将来的某个时间点完成工作并提供结果。

1.2.async 和 await 关键字

这两个关键字是 C# 语言提供的语法糖,用于编写和使用基于 Task 的异步方法,让异步代码看起来和同步代码一样清晰。

  • async 标记一个方法是异步方法。它允许在该方法内部使用 await 关键字。async 方法必须返回 TaskTask<TResult>void(不推荐)。
  • await 挂起(Suspend)当前的异步方法,并立即将控制权返回给调用者。当被 awaitTask 完成后,该方法从挂起点继续执行。

2. 异步编程的优势:非阻塞

理解异步编程,关键在于理解它如何避免线程阻塞

2.1. 传统的阻塞方式(同步)

当你的线程执行一个 I/O 操作(例如:从网络下载文件、查询数据库、读取大文件)时:

  1. 线程被占用,进入阻塞状态,等待 I/O 完成。
  2. 在这段等待时间内,线程不能做任何其他有用的工作。
  3. 如果这是 Web 服务器上的线程,它浪费了宝贵的计算资源,降低了服务器的并发能力。

2.2. 异步的非阻塞方式

当你使用 await 执行一个异步 I/O 操作时:

  1. 线程启动 I/O 操作(例如,向网卡发送请求)。
  2. 当遇到 await 时,线程立即释放,返回到线程池中,可以去处理其他用户的请求或执行其他 CPU 密集型任务。
  3. 当 I/O 操作在底层(通常由操作系统内核完成)完成后,系统会发送一个通知。
  4. 释放的线程(或线程池中的另一个线程)接收到通知后,继续执行 await 之后的代码。

结果: 应用程序可以在等待外部资源的同时保持响应,极大地提高了吞吐量。

3. TaskCompletionSource 的作用

正如我们在上文中提到的,TaskCompletionSource<T>(简称 TCS)是实现异步编程模型的重要底层工具。

用途:桥接非 Task 异步源

TCS 的主要作用是将基于回调(Callback)或事件的旧式异步代码,或者第三方库的异步操作,桥接到现代 C# 的 Task 模型中。

它可以让你手动控制一个 Task 的状态和结果:

方法 效果
SetResult(T result) 成功完成 Task 并设置结果。
SetException(Exception ex) 以失败状态完成 Task 并抛出异常。
SetCanceled() 以取消状态完成 Task。

4. 示例:异步 I/O 操作

复制代码
using System.Net.Http;
using System.Threading.Tasks;

public class AsyncExpert
{
    private readonly HttpClient _httpClient = new HttpClient();

    // 1. 标记方法为 async,返回 Task<string>
    public async Task<string> DownloadAndProcessContentAsync(string url)
    {
        Console.WriteLine($"[Thread ID: {Thread.CurrentThread.ManagedThreadId}] 任务开始,准备下载...");

        // 2. await 关键字:
        //    - 启动网络请求(I/O 密集型)。
        //    - 线程立即释放回线程池。
        //    - 等待网络I/O完成后,从这里继续执行。
        string content = await _httpClient.GetStringAsync(url);

        // 注意:await 之后的代码可能在另一个线程上执行,但在单个任务上下文中是安全的。
        Console.WriteLine($"[Thread ID: {Thread.CurrentThread.ManagedThreadId}] 下载完成,内容长度: {content.Length}");

        // 3. 这是一个 CPU 密集型操作,它会占用线程池线程
        string processedContent = ProcessData(content);

        return processedContent;
    }

    private string ProcessData(string rawContent)
    {
        // 模拟一些 CPU 密集型计算
        Thread.Sleep(100);
        return rawContent.ToUpper();
    }
}

总结

  • async await 是语法层面的工具,目的是简化异步代码的编写。
  • Task 是数据结构层面的工具,代表异步操作的状态和结果。
  • 异步编程 主要用于处理 I/O 密集型 任务,以实现 非阻塞,从而提高应用程序的响应速度和并发能力。
相关推荐
赵庆明老师2 小时前
NET 10 集成Session
缓存·.net
lljss20203 小时前
C# 定时器类实现1s定时器更新UI
开发语言·c#
白杨攻城狮3 小时前
C# 关于 barierr 心得
开发语言·c#
SEO-狼术3 小时前
Detect Trends with Compact In-Cell Visuals
.net
江沉晚呤时3 小时前
延迟加载(Lazy Loading)详解及在 C# 中的应用
java·开发语言·microsoft·c#
专注VB编程开发20年3 小时前
C#用API添另静态路由表
c#·静态路由
赵庆明老师3 小时前
.NET 日志和监控
.net
我是唐青枫3 小时前
C# Params Collections 详解:比 params T[] 更强大的新语法
c#·.net
IT_陈寒4 小时前
Vue3 性能优化实战:从10秒到1秒的5个关键技巧,让你的应用飞起来!
前端·人工智能·后端