【.Net技术栈梳理】04-核心框架与运行时(线程处理)

发布于:2025-09-09 ⋅ 阅读:(23) ⋅ 点赞:(0)

1. 线程管理

.NET 的线程管理建立在操作系统线程之上,但提供了更高级别的抽象和更丰富的功能来简化并发编程。

1.1 线程的核心概念:System.Threading.Thread

这是最基础的线程类,直接包装了操作系统线程。

using System.Threading;

// 创建并启动一个新线程
Thread newThread = new Thread(WorkerMethod);
newThread.Start(); // 开始执行 WorkerMethod

void WorkerMethod()
{
    Console.WriteLine($"我在另一个线程上运行!线程ID: {Thread.CurrentThread.ManagedThreadId}");
}
  • 前台线程 vs. 后台线程:
    • 前台线程:默认创建的线程是前台线程。只要有一个前台线程还在运行,应用程序进程就不会终止。
    • 后台线程:通过 IsBackground = true 设置。当所有前台线程结束时,CLR 会强制终止所有后台线程,无论其是否执行完毕。适用于非关键任务(如心跳检测、日志刷新)。
Thread bgThread = new Thread(WorkerMethod);
bgThread.IsBackground = true;
bgThread.Start();
  • 线程状态:通过 ThreadState 枚举表示(Unstarted, Running, WaitSleepJoin, Stopped 等)。
  • 线程池:直接创建和销毁线程开销很大。.NET 提供了一个线程池来管理一组重用的工作线程。

1.2 现代线程管理:System.Threading.Tasks.Task 和 Task Parallel Library (TPL)

从 .NET 4.0 开始,Task 成为了推荐的多线程和异步编程模型。它是对 Thread 的高级封装,极大地简化了复杂操作。

  • 什么是 Task: 表示一个异步操作。它不一定映射到独占的操作系统线程。它可能在线程池线程上运行,也可能使用 I/O 完成端口等机制,效率更高。
  • 线程池: Task 默认使用线程池中的线程。线程池会智能地管理线程数量,根据系统负载创建和销毁线程,避免了频繁创建新线程的开销。
  • 创建和启动 Task
// 方式一:Task.Run (最常用,用于将工作排到线程池)
Task task = Task.Run(() =>
{
    Console.WriteLine($"Task 在线程池线程上运行。线程ID: {Thread.CurrentThread.ManagedThreadId}");
    // 模拟工作
    Thread.Sleep(1000);
});

// 方式二:Task.Factory.StartNew (提供更多选项)
Task task2 = Task.Factory.StartNew(() => { /* ... */ }, TaskCreationOptions.LongRunning); // 提示线程池这可能是个长任务

// 等待任务完成
task.Wait(); // 阻塞当前线程,直到 task 完成

1.3 状态管理和异常处理

  • 状态查询: Task.Status 属性(Created, WaitingToRun, Running, RanToCompletion, Faulted, Canceled)。
  • 返回值: Task 可以返回值。
Task<int> calculationTask = Task.Run(() => CalculateSomething());
int result = calculationTask.Result; // 获取结果(如果任务未完成,会阻塞当前线程)
  • 异常处理: Task 中的异常会被捕获并存储在 Task.Exception 属性中(一个 AggregateException)。当你调用 .Wait(), .Result, 或 .WaitAll() 时,这些异常会被重新抛出
try
{
    task.Wait(); // 或者访问 task.Result
}
catch (AggregateException ae)
{
    foreach (var e in ae.InnerExceptions)
    {
        Console.WriteLine($"Exception: {e.Message}");
    }
}

1.4 协调任务:async/await 模式

这是现代 .NET 异步编程的基石,它让异步代码看起来像同步代码一样直观。

  • async: 修饰方法,表明该方法包含异步操作。
  • await: 用于等待一个 Task 完成。在 await 时,当前线程会被释放回线程池,而不是被阻塞。当 Task 完成后,该方法会在线程池线程上恢复执行。
public async Task<int> GetWebsiteLengthAsync(string url)
{
    // 注意:不要在生产环境使用 HttpClient 这种方式,这里仅为示例。
    using (var httpClient = new HttpClient())
    {
        // await 会释放当前线程(如UI线程),去处理其他工作(如响应用户点击)
        string content = await httpClient.GetStringAsync(url);
        // 当下载完成后,执行会在这里恢复(可能在另一个线程池线程上)
        return content.Length;
    }
}

// 调用异步方法
async void Button_Click(object sender, EventArgs e)
{
    int length = await GetWebsiteLengthAsync("https://example.com");
    MessageBox.Show($"Length is: {length}");
}

优势

  • 非阻塞: 在等待 I/O 操作(如网络请求、文件读写)时,不占用任何线程, scalability(可扩展性)极高。
  • 清晰的代码流: 避免了复杂的回调地狱(Callback Hell)。

2. 线程间通信

当多个线程需要访问共享数据或协调行动时,就需要线程间通信。核心挑战是线程安全。

2.1 共享内存与竞态条件

最简单的通信方式是共享变量,但这会导致竞态条件。

private int _counter = 0;

void UnsafeIncrement()
{
    _counter++; // 这不是原子操作,可能被线程切换打断
}

2.2 同步原语:确保线程安全

.NET 提供了丰富的同步原语来控制对共享资源的访问。

  • lock 语句(Monitor 类): 最常用的机制,确保代码块在任何时候只被一个线程执行。
private readonly object _lockObject = new object();
private int _safeCounter = 0;

void SafeIncrement()
{
    lock (_lockObject) // 一次只允许一个线程进入
    {
        _safeCounter++;
    }
}

注意: 锁定对象应为 private readonly 的引用类型。

  • Interlocked 类: 提供简单的原子操作,性能比 lock 更高。
Interlocked.Increment(ref _safeCounter); // 原子性地 +1
Interlocked.Exchange(ref _value, newValue); // 原子性地交换值
  • Mutex 和 Semaphore:
    • Mutex: 类似于 lock,但可以跨进程使用(系统级锁)。
    • Semaphore / SemaphoreSlim: 允许指定数量的线程同时访问一个资源池。例如,限制只有 5 个线程可以同时访问数据库。
  • ManualResetEvent / AutoResetEvent: 用于线程间的信号通知。一个线程可以 WaitOne() 等待信号,另一个线程可以 Set() 发出信号。
  • Barrier / CountdownEvent: 用于协调多个线程,让它们在某个点同步。

2.3 线程安全的数据结构

.NET 在 System.Collections.Concurrent 命名空间中提供了一系列线程安全的集合。

  • ConcurrentDictionary<TKey, TValue>
  • ConcurrentQueue< T>
  • ConcurrentStack< T>
  • ConcurrentBag< T>
  • BlockingCollection< T>

这些集合内部实现了高效的同步机制,可以在大多数情况下避免手动加锁。

private ConcurrentDictionary<string, int> _userScores = new ConcurrentDictionary<string, int>();

void UpdateScore(string userId, int points)
{
    // 无需手动加锁!
    _userScores.AddOrUpdate(userId, points, (key, oldValue) => oldValue + points);
}

2. 多线程编程模式与最佳实践

2.1 模式

  • 生产者/消费者模式: 一个或多个线程(生产者)生成数据并放入共享队列,一个或多个线程(消费者)从队列中取出并处理数据。可以使用 BlockingCollection 轻松实现。
  • Fork/Join 模式: 将一个大任务拆分成多个小任务(Fork),并行执行,最后等待所有结果并合并(Join)。Parallel.For/ForEach 和 Task.WhenAll 是实现此模式的利器。

2.1 最佳实践与常见陷阱

  1. 避免死锁
    • 原因: 两个或更多线程互相等待对方释放锁。
    • 预防: 按固定的全局顺序获取锁;使用 Monitor.TryEnter 并设置超时;尽量减少锁的持有时间。
  2. 警惕线程池的过度订阅: 不要创建成千上万的短时 Task,这会导致线程池创建大量线程,上下文切换开销巨大。对于 CPU 密集型任务,任务数量不应大幅超过 CPU 核心数。
  3. 不要阻塞线程池线程: 在线程池线程上执行同步的 I/O 操作或长时间 CPU 计算会耗尽线程池,影响整个应用程序的响应能力。对于 I/O 操作,始终使用 async/await。
    4.** 使用 Cancellation Tokens**: 提供一种标准机制来取消异步操作。
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

Task longRunningTask = Task.Run(() =>
{
    while (true)
    {
        token.ThrowIfCancellationRequested(); // 如果取消请求了,则抛出 OperationCanceledException
        // ... 做一点工作
    }
}, token);

// 在某个地方取消操作
cts.CancelAfter(5000); // 5秒后取消
  1. 访问 UI 控件: 在 WPF/WinForms 中,只有 UI 线程才能更新 UI 控件。从非 UI 线程更新 UI 会引发异常。必须使用 Dispatcher.Invoke (WPF) 或 Control.Invoke (WinForms) 来封送调用回 UI 线程。
// 在 WPF 中
await Task.Run(() => DoHeavyWork());
// 现在回到 UI 线程了,可以安全更新 UI
textBox.Text = "Done!";

// 如果在另一个上下文中,需要显式调用 Dispatcher
Dispatcher.Invoke(() => { textBox.Text = "Done!"; });

总结

  • 基础: 理解 Thread 类。
  • 现代方式: 优先使用 Task 和 TPL,默认使用线程池,效率更高。
  • 异步 I/O: 对于 I/O 密集型操作,始终使用 async/await,以释放线程,获得极高的可扩展性。
  • 线程安全: 使用 lock、Interlocked 或并发集合来保护共享数据。
  • 协调与通信: 使用同步原语(如 Event、Barrier)和模式(生产者/消费者)来协调多线程工作。
  • 避免陷阱: 警惕死锁、过度订阅和阻塞线程池线程。

2.2 补充


线程间通信的本质是什么?
多个线程在同一个进程内运行,共享进程的整个内存空间。因此,从广义上讲,任何一个线程写入内存的数据,理论上都可以被其他线程读取到。

所以,线程间通信的“通信”二字,其本质是:

  1. 数据传递:一个线程生产/计算出的数据,如何安全地交给另一个线程处理。
  2. 状态同步:一个线程如何知道另一个线程已经完成了某项工作或进入了某种状态。
  3. 协调行动:多个线程如何步调一致地协作,避免“混乱”(如竞态条件)和“死等”(如死锁)。

核心挑战:由于操作系统线程调度的不确定性,你永远不知道一个线程在执行到哪条指令时会被挂起,另一个线程会开始执行。这种交错执行如果处理不当,就会导致数据损坏、结果错误等线程安全问题。

如何进行线程间通信?
.NET 提供了多种机制来实现安全高效的线程间通信,主要分为三大类:

  1. 共享内存(最常用,但最危险)
    这是最直观的方式:多个线程读写同一个变量或数据结构。
  • 如何进行:简单地创建一个所有线程都能访问的字段、属性或静态变量。
  • 巨大风险:直接共享内存会引发竞态条件。
// 危险的共享内存示例
public class UnsafeExample
{
    private int _counter = 0; // 共享内存

    public void Increment()
    {
        _counter++; // 这不是原子操作!
                    // 它可能被分解为:读取 -> 加1 -> 写入
                    // 线程A可能在“读取”后被打断,线程B也完成了“读取”,然后两者都写入,导致只加了一次。
    }
}
  • 如何安全地使用:必须使用同步原语来保护对共享内存的访问,确保某一时刻只有一个线程能操作它。

    • lock 语句:最常用的工具。
    private readonly object _lockObj = new object();
    private int _safeCounter = 0;
    
    public void SafeIncrement()
    {
     lock (_lockObj) // 一次只允许一个线程进入此代码块
     {
     	   _safeCounter++;
     }
    }
    
    • Interlocked 类:为简单的数学操作提供原子性,性能更高。
    Interlocked.Increment(ref _safeCounter); // 原子性地完成整个“读取-修改-写入”操作
    
    • Monitor 类:lock 语句的底层实现。
    • Mutex:类似于锁,但可以跨进程使用。
  1. 信号机制(用于协调和通知)
    当一个线程需要“等待”另一个线程完成某项工作后才能继续时,就需要信号机制。它不直接传递数据,而是传递“事件已发生”的信号。
  • 如何进行:一个线程等待一个信号,另一个线程发出信号。
  • 常见类型:
    • EventWaitHandle 及其子类:
      • AutoResetEvent:像一个旋转门,一次只允许一个线程通过。Set() 一次只释放一个等待的线程,然后自动重置为无信号状态。
      • ManualResetEvent:像一个大门,Set() 打开大门,释放所有等待的线程;直到调用 Reset() 才会关上大门。
        // 使用 AutoResetEvent 进行线程协调
        AutoResetEvent _waitHandle = new AutoResetEvent(false); // 初始状态为无信号
        
        void ThreadA()
        {
            // 做一些准备工作...
            _waitHandle.Set(); // 发出信号:“我的工作完成了,你可以继续了”
        }
        
        void ThreadB()
        {
            // 等待 ThreadA 的准备信号
            _waitHandle.WaitOne(); // 阻塞在此,直到收到信号
            // 收到信号,继续执行...
        }
        
    • Semaphore / SemaphoreSlim:类似于一个计数器,用于控制同时访问某一资源的线程数量。例如,只允许 3 个线程同时访问数据库连接池。
    • Barrier:用于让多个线程在某个时间点同步,所有线程都到达这个点后,才一起继续执行。适合分阶段计算的场景。
    • CountdownEvent:初始化一个计数,每次有线程完成工作时计数减一,当计数为 0 时,释放所有等待的线程。
  1. 消息传递(更高级、更安全的模式)
    这种模式解耦了线程,线程之间不直接共享内存,而是通过一个“中间人”(通常是队列)来传递数据“消息”。生产者线程放入消息,消费者线程取出消息。
  • 如何进行:使用生产者/消费者模式。
  • .NET 提供的强大工具:System.Collections.Concurrent 命名空间下的线程安全集合。
    • BlockingCollection:一个提供了阻塞和边界功能的线程安全集合。它是实现生产者/消费者模式的最佳工具。

      // 创建一个最多容纳10个项目的阻塞集合
      BlockingCollection<string> _messageQueue = new BlockingCollection<string>(10);
      
      // 生产者线程
      void Producer()
      {
          while (true)
          {
              string message = GenerateMessage();
              _messageQueue.Add(message); // 如果队列满了,Add 会阻塞生产者
          }
          _messageQueue.CompleteAdding(); // 通知消费者不会再生产了
      }
      
      // 消费者线程
      void Consumer()
      {
          // GetConsumingEnumerable() 会在没有数据时阻塞消费者,并在 CompleteAdding() 且队列空后自动结束
          foreach (var message in _messageQueue.GetConsumingEnumerable())
          {
              ProcessMessage(message);
          }
      }
      
    • ConcurrentQueue, ConcurrentStack, ConcurrentBag, ConcurrentDictionary<TKey, TValue>:这些是线程安全的集合,可以在不加锁的情况下被多个线程同时读写,但它们本身不提供阻塞功能。

总结与最佳实践

通信机制 如何实现 适用场景 优点 缺点
共享内存 共享变量 + 锁/同步原语 高频、简单的数据共享 性能高、直观 容易死锁、难以编写和维护
信号机制 EventWaitHandle, Semaphore, Barrier 线程间的协调、通知、同步 目的明确,易于理解 不直接传递数据,容易错过信号
消息传递 BlockingCollection< T > + 并发集合 生产者/消费者、解耦复杂任务 安全性高、解耦、易于扩展 有一定的性能开销(入队/出队)

现代 .NET 多线程编程的最佳实践:

  1. 优先选择消息传递模式:使用 BlockingCollection 或 Channel (.NET Core 3.0+) 可以极大地减少对锁的依赖,从而避免死锁等问题,代码也更清晰。

  2. 避免共享状态:如果可能,尽量设计无状态的操作,让每个线程只处理自己的数据。

  3. 使用高级抽象:优先使用 Task、Parallel 循环和 PLINQ,而不是手动管理 Thread 对象。它们底层使用线程池,效率更高。

  4. 善用异步编程:对于 I/O 密集型操作(如文件、网络),使用 async/await 而不是创建阻塞线程,这样可以释放线程去处理其他请求,大大提高应用程序的吞吐量。

  5. 始终牢记线程安全:只要存在共享,第一反应就应该是“如何同步”。



网站公告

今日签到

点亮在社区的每一天
去签到