探索 .NET 中的并发性、并行性和异步编程

作者:微信公众号:【架构师老卢】
6-25 8:21
37

概述:在现代软件开发领域,同时执行多项任务的能力对于构建响应迅速且高效的应用程序非常重要。你有没有想过你最喜欢的应用程序如何同时处理多个操作而不会减慢速度?或者如何在眨眼间执行复杂的计算?并发性、并行性和异步编程是实现这一目标的关键技术。并发性是系统同时管理多个任务的能力,尽管不一定同时管理。它以一种在多个任务上取得进展的方式执行任务,而无需等待一个任务完成后再开始另一个任务。想象一下,你正在玩多个球,给每个球一点注意力——这就是并发性。并行性通过同时执行多个任务(通常在多个 CPU 内核上)使并发性更进一步。这允许多任务处理,其中程序的不同部分同时运行。这就像有多个杂耍者,每个人都同时处理自己的一

在现代软件开发领域,同时执行多项任务的能力对于构建响应迅速且高效的应用程序非常重要。你有没有想过你最喜欢的应用程序如何同时处理多个操作而不会减慢速度?或者如何在眨眼间执行复杂的计算?并发性、并行性和异步编程是实现这一目标的关键技术。

并发性是系统同时管理多个任务的能力,尽管不一定同时管理。它以一种在多个任务上取得进展的方式执行任务,而无需等待一个任务完成后再开始另一个任务。想象一下,你正在玩多个球,给每个球一点注意力——这就是并发性。

并行性通过同时执行多个任务(通常在多个 CPU 内核上)使并发性更进一步。这允许多任务处理,其中程序的不同部分同时运行。这就像有多个杂耍者,每个人都同时处理自己的一组球。

异步编程允许任务独立于主程序流运行,即使在等待长时间运行的操作完成时也能保持应用程序的响应速度。这对于从服务器获取数据或读取文件等任务非常重要,这可能需要一些时间。这就像设置一个闹钟,在完成任务时响起,而你则继续做其他事情。当警报响起时,你又回到了它——这是异步编程。

线程和任务

并发性和并行性是通过线程、任务和并行循环等技术实现的。.NET 中并发性的核心是线程和任务。线程是进程中最小的执行单元,而任务表示可以计划在线程上运行的异步操作。

在 .NET 中创建新线程时,运行时会分配资源并计划该线程在其中一个 CPU 内核上运行。操作系统的调度程序管理线程的执行,在线程之间切换以确保公平的资源分配。任务提供对线程的更高级别的抽象,从而更轻松地使用异步操作。.NET 中的任务并行库 (TPL) 管理任务,处理任务的创建、计划和在可用线程上的执行。

Task.Run(() =>  
{  
  Thread.Sleep(1000);  
  Console.WriteLine("Task completed!");  
});

创建新线程时,.NET 运行时初始化新的线程对象,分配必要的资源,如_堆栈内存_和_线程控制块 (TCB)_ 来存储特定于线程的信息。运行时对操作系统进行系统调用,以在操作系统级别创建实际线程。这将分配额外的资源并设置线程的执行上下文。线程最初处于“”状态。当它准备好运行时,它会转换为“就绪”状态。

OS 调度程序管理线程的执行。它决定哪些线程在哪些 CPU 内核上运行以及运行多长时间,使用调度算法来有效地管理此过程。处于“就绪”状态的线程被放置在_就绪队列_中,不同的队列对应不同的优先级。调度程序定期执行_上下文切换_,保存当前正在运行的线程的状态,并加载下一个要执行的线程的状态。这允许多个线程共享 CPU 时间。调度程序根据优先级、负载平衡和关联性(某些内核上的首选执行)等因素_将线程分配给可用的 CPU 内核_。

使用 Task.Run 或其他方法创建新任务时,任务并行库 (TPL) 会将任务排队等待执行。TPL 使用 _.NET 线程池_来管理工作线程池。线程池根据工作负载动态调整工作线程数,添加或删除线程以优化性能。TPL 使用工作窃取算法来平衡线程之间的负载。空闲线程可以从繁忙线程的队列中“窃取”任务。工作线程从队列中执行任务,并在完成时更新任务的状态。已完成的任务可能会触发继续任务或回调。

异步编程

.NET 中的异步编程使用 asyncawait 关键字进行了简化。这些代码允许您编写非阻塞代码,以便在等待长时间运行的操作完成时保持应用程序响应。使用 await 关键字时,该方法将暂停其执行,直到等待的任务完成。.NET 运行时通过释放线程以在等待时执行其他任务来管理此操作,并在等待的任务完成后在原始线程上继续该方法。

public async Task<string> FetchDataAsync()  
{  
    using (HttpClient client = new HttpClient())  
    {  
        string data = await client.GetStringAsync("https://example.com");  
        return data;  
    }  
}

调用异步方法时,它会返回一个 Task 对象,该对象表示正在进行的操作。这允许调用代码继续执行或等待任务。编译器将异步方法转换为状态机。此状态机跟踪方法的进度,并知道如何在每次等待后继续执行。当方法遇到 await 时,将捕获当前上下文(例如,UI 线程的同步上下文)。这允许在等待的任务完成后,该方法可以在同一上下文上继续,并在必要时保持线程相关性。执行该方法的线程将释放回线程池,从而允许其执行其他任务。这就是使异步方法不阻塞的原因。等待的任务完成后,运行时将计划异步方法的继续。这可以位于原始上下文(例如,UI 线程)上,也可以位于线程池中的另一个线程上,具体取决于捕获的上下文。

通过在长时间运行的操作中释放线程,异步编程允许 CPU 执行其他任务,从而提高整体利用率。由于异步方法不会阻塞线程,因此对线程池资源的争用较少,从而带来更好的性能和可伸缩性。应用程序(尤其是基于 UI 的应用程序)保持响应,因为主线程不会被长时间运行的操作阻塞。

对操作系统和硬件的影响

OS 调度程序管理线程的执行。它决定哪些线程在哪些 CPU 内核上运行以及运行多长时间,平衡不同的因素以优化系统性能。调度程序使用_轮询_、_基于优先级的调度_或_多级队列调度_等算法为线程分配 CPU 时间。这样可以公平地分配资源并减少上下文切换开销。当调度程序从一个线程切换到另一个线程时,它会执行上下文切换,保存当前线程的状态并加载下一个线程的状态。调度程序在 CPU 内核之间分配线程以平衡负载,防止某些内核过载而其他内核未充分利用。

在硬件级别,CPU 执行来自多个线程的指令,使用其架构有效地并行运行任务。_超线程_允许单个物理 CPU 内核同时执行多个线程。每个内核都有两组独立的架构状态,在操作系统中显示为两个逻辑内核。这通过利用内核内的空闲线程来提高吞吐量。多个 CPU 内核_允许完全并行,每个内核可以同时执行不同的线程。CPU 通过并行执行来自同一线程的多条指令来使用_指令级并行性 (ILP)。流水线、_超标量执行_和_无序执行_等技术增强了指令级别的并行性。

.NET 中的异步方法旨在提高系统效率和响应能力。当异步方法等待任务时,运行时会将线程释放回线程池,从而允许其他任务运行。这种线程的动态管理可防止创建过多线程,从而减少 CPU 上的负载并避免过多的上下文切换。异步方法可确保线程在等待 I/O 操作时不会被阻塞。这种非阻塞行为可提高 CPU 和内存利用率,因为线程在等待 I/O 操作时不会处于空闲状态。

CPU 会定期中断正在运行的线程,以允许操作系统调度程序做出调度决策。在中断期间,CPU 保存当前线程(寄存器、程序计数器等)的状态并调用调度程序。调度程序加载要执行的下一个线程的状态并恢复其上下文。调度程序将线程分配给可用的 CPU 内核。

并行性和现代 CPU 的强大功能

并行性将任务分解为更小的子任务,这些子任务可以同时处理,使用多个 CPU 内核来提高性能。现代 CPU 具有多个内核,并支持_同步多线程 (SMT)_ 技术,例如 Intel 的超线程。

大型任务分为较小且独立的子任务。每个子任务都是独立的,可以并发执行。每个子任务都分配给一个单独的线程。具有多个内核的现代 CPU 可以同时执行多个线程。超线程通过允许每个内核处理多个硬件线程来进一步增强此功能。例如,如果一个 CPU 有四个内核,它可以同时处理四个子任务,与顺序处理相比,它加快了整体执行速度。

操作系统调度程序将 CPU 时间分配给线程,并将它们分布在可用内核之间。它确保线程是平衡的,以最大限度地提高 CPU 利用率并最大限度地减少空闲时间。调度程序动态调整线程的分布,以平衡内核之间的负载,防止任何单个内核成为瓶颈。当内核从一个线程切换到另一个线程时,OS 会执行上下文切换。尽管上下文切换会带来一些开销,但通过并行性增强的性能通常超过此成本。

在 .NET 中,TPL 提供了一种并行化任务的简单方法。Parallel 类和 Task 类通常用于创建和管理并行任务。

Parallel.For(0, 100, i =>  
{  
    ProcessItem(i);  
});

**PLINQ(并行 LINQ)**支持对 LINQ 查询进行并行处理,从而允许并行执行对集合的操作。

var results = data.AsParallel().Where(item => item.IsValid).ToList();

异步编程与并行性相结合,可以利用多个内核实现高效且无阻塞的操作。

public async Task ProcessDataAsync(List<int> data)  
{  
    var tasks = data.Select(item => Task.Run(() => ProcessItem(item))).ToArray();  
    await Task.WhenAll(tasks);  
}

在 .NET 中创建并行任务时,运行时将与 OS 交互以请求资源并将任务分配给线程。创建并行任务时,该任务将在线程池中排队。可用的工作线程选取它进行执行。为了防止争用条件并确保线程安全操作,.NET 提供了同步机制,例如_锁_、互斥锁_和_信号量。这些机制协调对共享资源的访问并确保数据完整性。

比赛条件

并发性带来了管理对共享资源的访问的挑战。当软件系统的行为取决于事件的相对时间(例如线程执行、访问共享资源或修改数据的顺序)时,就会发生争用条件。这可能导致不可预测的错误结果。

由于 CPU 并发执行指令而出现争用条件。现代 CPU 具有多个内核,每个内核都可以独立执行线程。这种并发性允许多个线程同时运行,从而可能访问相同的内存位置。当多个线程在没有正确同步的情况下访问共享资源(变量、数据结构或硬件寄存器)时,它们可能会读取或写入数据不一致。

让我们假设两个线程 A 和 B 递增一个共享变量_计数器_。

  • 线程 A 读取_计数器_(值 = 0)
  • 线程 B 读取_计数器_(值 = 0)
  • 线程 A 增量_计数器_(值 = 1)
  • 线程 B 增量_计数器_(值 = 1)
  • 两个线程都写回它们的值,导致_计数器_为 1 而不是预期的 2。
int counter = 0;  
Parallel.For(0, 1000, i =>  
{  
    counter++; // Potential race condition  
});

多任务处理允许操作系统中断正在运行的线程,如果线程访问共享资源而未同步,则会导致潜在的争用情况。在上下文切换期间,OS 会保存当前正在运行的线程的状态,并还原要执行的下一个线程的状态。此切换可能随时发生,如果管理不当,会导致争用情况。当两个或多个线程相互等待释放资源时,就会发生死锁,导致它们无限期地停滞。防止死锁的技术包括资源排序和基于超时的锁定机制。

为了防止出现争用情况,开发人员必须使用同步机制来确保一次只有一个线程访问共享资源,或者对共享资源的操作是原子的。.NET 提供同步机制,例如_锁_、信号量_和_并发集合

**互斥(互斥)**是一种锁定机制,可确保一次只有一个线程可以访问关键代码部分。当线程获得互斥锁时,尝试获取互斥锁的其他线程将被阻止,直到互斥锁被释放。

private static readonly Mutex mutex = new Mutex();  
  
public void Increment()  
{  
    mutex.WaitOne();  
    try  
    {  
        counter++;  
    }  
    finally  
    {  
        mutex.ReleaseMutex();  
    }  
}

信号量通过允许固定数量的线程同时访问资源来控制对资源的访问。它对于管理资源池非常有用。

// Allows up to 3 threads  
private static readonly Semaphore semaphore = new Semaphore(3, 3);  
  
public void AccessResource()  
{  
    semaphore.WaitOne();  
    try  
    {  
        // Access shared resource  
    }  
    finally  
    {  
        semaphore.Release();  
    }  
}

**监视器(或锁)**是更高级别的同步机制,它提供相互排斥和等待满足条件的能力。

private static readonly object lockObject = new object();  
  
public void Increment()  
{  
    lock (lockObject)  
    {  
        counter++;  
    }  
}

现代 CPU 和操作系统提供原子操作,这些操作是不可分割的,不能中断。这些操作可确保在执行关键指令期间不会发生争用条件。

public void Increment()  
{  
    Interlocked.Increment(ref counter);  
}

在 .NET 中,volatile 关键字可确保从主内存读取变量并将其写入主内存,从而防止编译器和 CPU 对可能导致争用条件的操作进行重新排序。

private volatile int counter;  
  
public void SetCounter(int value)  
{  
    counter = value;  
}

对由多个线程访问但不应在它们之间共享的变量使用线程本地存储。这将隔离每个线程的数据。

private static ThreadLocal<int> threadLocalVariable = new ThreadLocal<int>(() => 0);  
  
public void UseThreadLocal()  
{  
    // Each thread has its own copy of the variable  
    threadLocalVariable.Value = 42;  
}

事件循环

事件循环维护一个任务队列,该队列包含需要执行的回调和任务。它不断循环访问任务队列,选取任务并一次执行一个任务。异步 I/O 操作(如读取文件、网络请求和计时器)在任务队列中注册回调,而不会阻塞主执行线程。

在 .NET 中,SynchronizationContext 类和基于任务的异步模式 (TAP) 有助于执行异步任务。事件循环通过允许管理和执行多个任务来实现并发。这对于 I/O 任务非常重要,在这些任务中,主线程可以在等待 I/O 操作完成的同时继续执行其他任务。SynchronizationContext 类负责捕获当前执行上下文,并在相应的线程上计划任务。这广泛用于 UI 应用程序,其中任务需要在主 UI 线程上执行。

public async Task PerformAsyncTask()  
{  
    var context = SynchronizationContext.Current;  
    await Task.Run(() =>  
      {  
          // Perform background work  
      }).ContinueWith(t =>  
      {  
          context.Post(_ =>  
          {  
              // Update UI on the main thread  
          }, null);  
      });  
}

基于任务的异步模式 (TAP) 使用事件循环来管理异步任务。asyncawait 关键字简化了异步代码的开发,允许在不阻塞主线程的情况下执行任务。当异步方法等待任务时,它会将方法的延续注册为事件循环中的回调。该方法将控制权返回给调用方,允许主线程执行其他任务。等待的任务完成后,事件循环将选取延续并继续该方法。

了解并发性、并行性和异步编程为创建高效、响应迅速且可扩展的应用程序开辟了无限可能。从任务和异步方法提供的高级抽象到与操作系统调度程序和 CPU 的低级交互,这些概念使开发人员能够充分利用现代硬件的潜力。

阅读排行