如何使用 .NET Core、C# 和 VS Code 中的 async/await 提高 .NET 程序的响应速度

2025-05-24

如何使用 .NET Core、C# 和 VS Code 中的 async/await 提高 .NET 程序的响应速度

在Twitter上关注我,很高兴接受您对主题或改进的建议/Chris

当我们运行同步代码时,我们会阻止主线程执行除当前正在执行的操作之外的任何其他操作。这会导致软件运行速度和用户体验比预期更慢。

简而言之:.NET/.NET Core 中存在线程的概念,它们是调度并行执行工作的绝佳方式。然而,它们使用起来可能比较麻烦。不过,有一个名为 TPL(Task Parallel Library )的库,基于线程模型,可以非常轻松地调度和管理工作。

参考

什么

所以我们提到 TPL 是一个库。我们需要知道什么?TPL 是一个非常核心且重要的概念,它存在于核心 API 中。它是System.ThreadingSystem.Threading.Tasks命名空间的一部分。它为我们做了很多事情,例如:

  • 工作划分
  • 线程调度ThreadPool
  • 取消支持
  • 状态管理

以及其他低级细节。

我们需要了解一些基本概念。

  • Task,一个任务代表一个异步操作,例如从文件获取内容或进行需要时间的计算。Task 有一些有趣的属性,允许我们与 UI 进行通信,例如异步工作的进展情况,例如:
    • 状态,这可以告诉我们它当前是否正在处理某件事、是否已完成、是否出错或是否已取消
    • IsCanceled,如果取消,则设置为true
    • IsFaulted,如果出现问题(例如异常),则将其设置为true
    • IsCompleted,一旦完成操作,它将被设置为true
  • Async/Await。该await关键字表示我们等待异步操作结束,并在操作结束时获得结果,例如var fileContent = await GetFileAsync()。任何使用该await概念的方法都需要将async关键字作为方法头的一部分。
  • 阻塞/非阻塞。当我们使用 Task 时,我们不会阻塞,其他线程可以继续工作。不过,当我们Wait()在 Task 上使用该方法时,会有一些例外。这时,我们会强制代码同步运行。我们将在下一节的演示中展示这一点。

为什么

很多事情,例如打开大文件、执行 Web 请求,甚至搜索计算机,都可以并行完成这意味着您可以更快地将结果返回给用户,您的应用也会被认为运行速度更快、响应更灵敏。Web 开发已经大量使用了任务的概念,这是 TPL 的核心概念。学习如何使用 TPL 可以真正提升您的应用程序的响应速度。我希望通过本文,您能够更好地掌握 TPL 和任务的使用方法。

演示

在我们的演示中,我们将演示以下内容:

  • 编写方法,如何使用编写方法async/await以及如何返回不同类型的方法
  • 控制流,我们将展示如何等待所有以及特定的任务
  • 阻塞代码,我们将展示如何使用Result以及如何Wait()影响您的代码

搭建项目脚手架

让我们首先创建一个如下解决方案:

mkdir tasks
cd tasks

dotnet new sln

这应该创建一个解决方案文件。

接下来,我们将创建一个控制台项目,如下所示:

dotnet new solution -o task-demo

现在将其添加到解决方案中,如下所示:

dotnet sln add task-demo/task-demo.csproj

好的,我们准备开始编码了。打开一个 IDE,我选择 VS Code。

创作方法

让我们打开文件Program.cs并在类中添加以下方法Program

static async Task<int> Sum(int a, int b) 
{
    var result = await Task.FromResult(a + b);
    return result;
}

上面发生了一些有趣的事情:

  • 返回类型Task<int>。这告诉我们它将是一个任务,一旦解决就会返回某种类型的东西int
  • Task.FromResult(),这将创建一个给定值的 Task。我们给它指定要执行的计算,例如a+b
  • Async/Await,我们可以看到如何在方法内部使用async关键字来等待结果返回。async为了确保编译器正常运行,后面需要跟上关键字。

很容易认为上述方法不需要异步,但想象一下这是一个需要时间的计算,那么它会更有意义。

还有一种情况,Task.FromResult当答案立即已知时使用,也就是它的状态是,RanToCompletion并且答案可以立即使用,所以你可以争辩说这await是不必要的,因为它已经在Task.Result属性中可用了。另一种实现上述操作的方法是:

static async Task<int> Sum2(int a, int ab) 
{
  var result = await Task.Run(() => {
    // do some time-consuming work
    return a + b;
  })
  return result;
}

await Sum2(1,2)

控制流

任务不仅仅是标记它们async。我们可以确保等待所有或部分任务完成后再继续执行代码。我们有一些结构可以帮助我们控制此流程:

  • Task.WaitAll(),这个方法接收一个任务列表。你本质上的意思是,所有任务都需要完成才能继续,这是阻塞的。你可以看到它返回了void一个典型的用例,等待所有 Web 请求完成,因为我们希望返回一个由我们拼接所有数据组成的结果。
  • Task.WaitAny(),我们在这里也给出了一个任务列表,但含义不同。我们说只要任何一个任务完成就没问题。这通常是争夺端点的数据,或者搜索磁盘上的文件/文件内容。我们不关心谁先完成,只要我们得到响应就行。这也是阻塞并等待其中一个任务完成。
  • Task.WhenAll(),这会给你一个Task可以互动的后台。所有任务完成后,它就会解决。
  • Task.WhenAny(),这会为您提供一个Task可以交互的后台。当其中一个任务完成后,它将得到解决。

让我们创建一个控制流的演示。我们将通过在类中添加一个额外的方法来假装执行耗时的工作,如下所示:

static async Task DoSomething()
{
  await Task.Delay(2000);
}

演示 - 控制流

现在我们可以在我们的方法中添加一些控制流代码,Main()如下所示:

var start  = DateTime.Now;

var taskSum = Sum(2,2);
var taskDelay = DoSomething();
Task.WaitAll(taskSum, taskDelay);

end = DateTime.Now;

Console.WriteLine("Time taken {0}",end - start);

我们的完整代码Program.cs现在应该是这样的:

using System;
using System.Threading.Tasks;
using System.IO;

namespace task_demo
{
    class Program
    {
        static async Task DoSomething()
        {
            await Task.Delay(2000);
        }
        static async Task<int> Sum(int a, int b) 
        {
            var result = await Task.FromResult(a + b);
            return result;
        }

        static void Main(string[] args)
        {
            var start  = DateTime.Now;

            var taskSum = Sum(2,2);
            var taskDelay = DoSomething();

            Task.WaitAll(taskSum, taskDelay);

            end = DateTime.Now;

            Console.WriteLine("Time taken! {0}", end-start);
        }
    }
}

让我们编译:

dotnet build

并运行它:

dotnet run

我们应该得到以下回应:

4
Time taken! 00:00:02.0026920

尽管从调用开始的计算Sum()只花了几毫秒,但直到 2 秒后计算DoSomething()完成时我们才得到任何响应。

如果我们现在将代码从 改为 ,WaitAll我们WhenAll得到的行为就会大不相同。代码会继续运行,并报告以下内容:

4
Time taken! 00:00:00.0235860

所以这里的教训是,如果我们想让代码在特定点等待,使用WaitAny是一个好主意,但如果你想启动大量异步工作,那么使用When...

我们仍然可以使代码正常运行,WhenAll但我们需要像这样调查状态:

var twoTasks = Task.WhenAll(taskSum, taskDelay);
if(twoTasks.IsCompleted) 
{
    var end = DateTime.Now;
    Console.WriteLine("{0}", taskSum.Result);
}

DEMO - 等待任何

为了测试这一点,我们创建了三个模拟打开文件的新方法。这三个方法都内置了不同的延迟时间:

static async Task<string> ReadFile1() 
{
    await Task.Delay(3000);
    return "file1";
}

static async Task<string> ReadFile2()
{
    await Task.Delay(4000);
    return "file2";
}

static async Task<string> ReadFile3()
{
    await Task.Delay(2000);
    return "file3";
}

让我们Program()用一些代码来更新我们的方法:

var task1 = ReadFile1();
var task2 = ReadFile2();
var task3 = ReadFile3();

start = DateTime.Now;
Task.WaitAny(task1, task2, task3);


Console.WriteLine("Task1, completed: {0}", task1.IsCompleted);

Console.WriteLine("Task2, completed: {0}", task2.IsCompleted);

Console.WriteLine("Task3, completed: {0}", task3.IsCompleted);
Console.WriteLine("Task3, completed: {0}", task3.Result);

end = DateTime.Now;
Console.WriteLine("Time taken! {0}", end - start);

正如您在上面看到的,我们正在等待三个任务之一完成,其结构如下:

Task.WaitAny(task1, task2, task3);

根据我们对所调用方法的了解,ReadFile3()应该在几秒钟后首先完成2,但让我们通过运行程序来测试一下:

Task1, completed: False
Task2, completed: False
Task3, completed: True
Task3, completed: file3
Time taken! 00:00:02.0031370

我们可以从上面看到Task3已经完成,其他任务尚未完成。

使用异步 API

好的,我们现在对异步有了更多的了解,并且能够在现有的 API 上利用它。让我们看看如何读取文件的内容。通常,你会创建一个如下方法:

 static async string ReadTxtFile() 
{
    using(var sr = new StreamReader(File.Open("test.txt", FileMode.Open)))
    {
        return sr.ReadToEnd();
    }
}

上面的代码会阻塞,在执行完成之前你无法做太多其他事情。想象一下,这是一个非常大的文件,那么它会非常明显。如果我们重写该方法以使用异步版本,我们会得到如下所示的代码:

 static async Task<string> ReadTxtFile() 
{
    using(var sr = new StreamReader(File.Open("test.txt", FileMode.Open)))
    {
        return await sr.ReadToEndAsync();
    }
}

这不会造成阻碍,每个人都很高兴。

 阻塞代码

使用 TPL 的棘手之处之一在于了解哪些代码会调用阻塞。你很高兴你的代码现在是异步的,但突然之间你又陷入了阻塞状态。那么我们该注意什么呢?好吧,我们已经讨论过这个话题了:

  • WaitAllWaitAny块,这里的经验法则似乎是它们返回 void 并使用Wait...这个词。但有时你希望它等待,所以要学会有意识地使用块/非块
  • task.Result,这也会阻塞并等待结果可用
  • Wait(),Task 上的这个方法将会阻塞,并导致你在这里等待,直到代码完成,例如Task.Delay(2000).Wait()

完整代码

如果您想自己探索的话,这是我正在使用的完整代码:

using System;
using System.Threading.Tasks;
using System.IO;

namespace task_demo
{
  class Program
  {
      static async Task<string> ReadTxtFile() 
      {
          using(var sr = new StreamReader(File.Open("test.txt", FileMode.Open)))
          {
              return await sr.ReadToEndAsync();
          }
      }

      static string ReadFileSync1() 
      {
          Task.Delay(2000).Wait();
          return "content1";
      }

      static string ReadFileSync2()
      {
          Task.Delay(2000).Wait();
          return "content2";
      }

      static string ReadFileSync3()
          {
          Task.Delay(2000).Wait();
          return "content3";
      }

      static async Task DoSomething()
      {
          await Task.Delay(2000);
      }
      static async Task<int> Sum(int a, int b) 
      {
          var result = await Task.FromResult(a + b);
          return result;
      }

      static async Task<string> ReadFile1() 
      {
          await Task.Delay(3000);
          return "file1";
      }

      static async Task<string> ReadFile2()
      {
          await Task.Delay(4000);
          return "file2";
      }

      static async Task<string> ReadFile3()
      {
          await Task.Delay(2000);
          return "file3";
      }

      static void Main(string[] args)
      {
          var start = DateTime.Now;
          var c1 = ReadFileSync1();
          var c2 = ReadFileSync2();
          var c3 = ReadFileSync3();
          var end = DateTime.Now;
          Console.WriteLine("Time taken {0}", end-start);

          start  = DateTime.Now;

          var taskSum = Sum(2,2);
          var taskDelay = DoSomething();

          Task.WaitAll(taskSum, taskDelay);

          end = DateTime.Now;

          Console.WriteLine("{0}",taskSum.Result);

          Console.WriteLine("Time taken! {0}", end-start);

          var task1 = ReadFile1();
          var task2 = ReadFile2();
          var task3 = ReadFile3();

          start = DateTime.Now;
          Task.WaitAny(task1, task2, task3);


          Console.WriteLine("Task1, completed: {0}", task1.IsCompleted);

          // this forces everyone to wait for this Task1
          // Console.WriteLine("Task1, completed: {0}", task1.Result);

          Console.WriteLine("Task2, completed: {0}", task2.IsCompleted);

          Console.WriteLine("Task3, completed: {0}", task3.IsCompleted);
          Console.WriteLine("Task3, completed: {0}", task3.Result);

          end = DateTime.Now;
          Console.WriteLine("Time taken! {0}", end - start);

      }
  }
}

概括

总而言之,我们学习了任务的概念及其构成。此外,我们还学习了控制流,并讨论了阻塞/非阻塞代码。不过,还有更多内容需要学习,例如如何取消任务。我将另开一篇文章来介绍取消操作。我会在本文的参考资料部分添加“取消”部分的链接。

文章来源:https://dev.to/dotnet/how-you-can-make-your-net-programs-faster-with-asynchronous-code-in-net-core-c-and-vs-code-471c
PREV
了解如何在 .NET Core 和 C# 中使用 GraphQL
NEXT
如何学习 .NET Core 和 C# 中的依赖注入