文章目录
- 核心同步基元 (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. 总结)
- [3.1. 监视器 (`Monitor` / `lock`)](#3.1. 监视器 (
- [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?)
- [2.6.1. 为什么 `BlockingCollection<T>` 更好?](#2.6.1. 为什么
- [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 确保以下两点:
-
互斥访问: 任何时刻只有一个线程能操作队列(通过
lock)。 -
通讯/等待:
- 如果队列满,生产者等待(
Wait)。 - 如果队列空,消费者等待(
Wait)。 - 当生产者添加数据后,通知(
Pulse)等待的消费者。 - 当消费者取出数据后,通知(
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)。这至关重要,因为:
- 假唤醒(Spurious Wakeups): 线程有时可能会在没有收到
Pulse或PulseAll的情况下被操作系统唤醒。 - 多线程竞争: 如果使用
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 时出错 (不是持有者)。");
}
}
}
}
- 运行
CheckAndAcquireMutex()。如果它是第一个实例,它会返回true并保持运行。 - 再次运行同一个程序。第二个实例运行
CheckAndAcquireMutex()时,由于createdNew为false(或WaitOne失败),它会立即返回false并退出。 - 只有在第一个实例调用
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): 提供
Add和Take方法,能够在集合状态不满足操作条件时(如集合为空或达到容量上限)阻塞调用线程,直到条件满足。
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> 内部使用了像 SemaphoreSlim 或 ManualResetEventSlim 这样的同步基元来管理线程的等待和通知。它在生产者调用 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 模式 的黄金标准工具。它将复杂的线程同步逻辑封装起来,让开发者能专注于业务逻辑,而不用担心手动管理锁、Wait 和 Pulse。
如果你只需要在线程间传递数据流并需要阻塞机制,请优先使用 BlockingCollection<T>。
将 Monitor 的 Wait/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?
Monitor 的 Wait/Pulse 机制提供了更底层的、更细致的控制权。只有在以下特殊情况下,你才应该考虑使用它:
- 底层库开发: 你正在编写一个低级别的同步原语,需要精确控制线程的等待和通知。
- 非标准同步: 你的同步条件非常独特,无法用现有的并发集合或信号量来描述。
- 性能极限优化: 你的应用对延迟有极高的要求,并且你确信自己手动实现的
Monitor优化能够超越 .NET 框架的内置实现(这很少见)。
简单来说:如果你的需求是建立一个数据管道(队列),请使用 BlockingCollection<T>。如果你的需求是实现一个复杂的、基于条件的线程等待机制,且无法使用更高级的抽象,那么使用 Monitor。
3. 任务完成源 (TaskCompletionSource<T>)
这是一种现代且高级的通讯方式,它将旧的线程通讯模型(基于回调、事件或低级同步)桥接到 C# 的 Task 异步编程模型中。它不是用于共享数据,而是用于 传递结果或信号。
TaskCompletionSource<T> (简称 TCS) 本身不代表一个工作,它代表一个控制句柄:
- 创建任务: 当你创建一个
TCS实例时,可以立即通过其Task属性获取一个Task<T>对象。 - 线程 A 等待: 线程 A(通常是调用者)可以
await这个Task对象,然后进入非阻塞等待状态。 - 线程 B 发信号: 线程 B(通常是工作者)在完成操作后,调用 TCS 的方法来完成任务。
SetResult(T result):成功完成任务,并将结果传递给等待线程。SetException(Exception ex):以异常方式完成任务。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# 中,推荐使用基于 Task 和 async/await 的异步模型来处理 I/O 密集型操作,这虽然不是传统的"线程间通讯",但它通过最小化线程阻塞,优化了并发性能,避免了大量同步问题。
Task和ValueTask:Task代表一个可以在未来完成的操作。线程 A 启动一个Task,然后继续做其他事情,最终可以通过await或ContinueWith接收结果。这是现代 C# 中最主要的 非阻塞式 通讯和流程协调机制。
异步编程模型(基于 Task 和 async/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方法必须返回Task、Task<TResult>或void(不推荐)。await: 挂起(Suspend)当前的异步方法,并立即将控制权返回给调用者。当被await的Task完成后,该方法从挂起点继续执行。
2. 异步编程的优势:非阻塞
理解异步编程,关键在于理解它如何避免线程阻塞。
2.1. 传统的阻塞方式(同步)
当你的线程执行一个 I/O 操作(例如:从网络下载文件、查询数据库、读取大文件)时:
- 线程被占用,进入阻塞状态,等待 I/O 完成。
- 在这段等待时间内,线程不能做任何其他有用的工作。
- 如果这是 Web 服务器上的线程,它浪费了宝贵的计算资源,降低了服务器的并发能力。
2.2. 异步的非阻塞方式
当你使用 await 执行一个异步 I/O 操作时:
- 线程启动 I/O 操作(例如,向网卡发送请求)。
- 当遇到
await时,线程立即释放,返回到线程池中,可以去处理其他用户的请求或执行其他 CPU 密集型任务。 - 当 I/O 操作在底层(通常由操作系统内核完成)完成后,系统会发送一个通知。
- 释放的线程(或线程池中的另一个线程)接收到通知后,继续执行
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 密集型 任务,以实现 非阻塞,从而提高应用程序的响应速度和并发能力。