C#异步编程基础

C#异步编程基础

什么是异步

启动程序时,系统会在内存中创建一个新的进程,进程是构成运行程序的资源的集合。这些资源包括虚地址空间、文件句柄和程序运行所需要的其他许多东西。

在进程的内部,系统会创建一个称为线程的内核对象,他代表了真正执行的程序。一旦线程建立,系统会在Main方法的第一行语句初开始线程的执行。

关于线程,我们需要了解以下知识点:

  • 默认情况下,一个进程只会包含一个线程,从程序的开始一直执行到结束,一般我们称其为UI线程或者主线程。
  • 线程可以派生其他线程,但是注意所有的线程资源都是程序向OS申请的。因此在任意时刻,一个进程都可能包含不同状态的多个线程,他们执行程序的不同部分。
  • 如果一个进程拥有多个线程,则他们会共享进程的资源。
  • 进程是程序执行流的最小单位,系统为处理器调度的单元是线程而不是进程。

为什么要学习异步编程

在很多种情况下,单线程模型都会在性能或用户体验上导致难以接受的行为。

async/await

先来看一个例子:

在下面的例子中我们使用异步方法对两个网站的字符串进行了下载。


using System;
using System.Diagnostics;
using System.Net;
using System.Threading.Tasks;

namespace MultiThreadTest_1
{
    
    
    internal class Program
    {
    
    
        public static void Main(string[] args)
        {
    
    
            MyDownloadString myDownloadString = new MyDownloadString();
            myDownloadString.DoRun();
        }
    }

    public class MyDownloadString
    {
    
    
        //使用Stopwatch记录时间
        Stopwatch _stopwatch = new Stopwatch();
        /// <summary>
        /// 执行主方法
        /// </summary>
        public void DoRun()
        {
    
    
            _stopwatch.Start();
            //执行两个异步方法分别获得两个网站的字符串长度
            Task<int> task = CountCharacterAsync(1, "https://www.bilibili.com/");
            Task<int> task2 = CountCharacterAsync(1, "https://www.processon.com/");
            //最后输出结果
            Console.WriteLine($"Chars in https://www.bilibili.com/ {
      
      task.Result}");
            Console.WriteLine($"Chars in https://www.processon.com/ {
      
      task2.Result}");
            _stopwatch.Stop();
        }
		//这里是异步方法
        public async Task<int> CountCharacterAsync(int id, string url)
        {
    
    
            //使用WebClient类来进行相应资源的下载
            WebClient webClient = new WebClient();
            Console.WriteLine($"Download Start At {
      
      _stopwatch.ElapsedMilliseconds}ms");
            //需要执行异步操作的表达式
            string str = await webClient.DownloadStringTaskAsync(new Uri(url));
            Console.WriteLine($"Download End At {
      
      _stopwatch.ElapsedMilliseconds}ms");
            return str.Length;
        }
    }
}

执行结果:

Download Start At 1ms
Download Start At 182ms
Download End At 456ms
Download End At 1123ms
Chars in https://www.bilibili.com/ 87599
Chars in https://www.processon.com/ 28559

Process finished with exit code 0.

由此可以看出,使用异步方法后,程序不再是顺序执行了。

async/await特性的结构

首先先来知道两个概念:同步,异步

如果一个方法调用某个方法,并在等待方法执行所有处理后才可以继续执行,我们就称这样的方法为同步方法,这也是默认的形式。

如果一个方法在完成其所有工作之前就返回到调用方法,我们就称这样的方法为异步方法。利用#提供的async/await特性可以实现。

该特性由三部分组成:

  • 调用方法:该方法调用异步方法,然后在异步方法执行其任务的时候继续执行(可能在相同的线程上,也可能是不同的线程上)。

  • 异步方法:被async关键字标明的方法就是异步方法,该方法异步执行其工作,然后立即返回调用方法。

  • await表达式:被await标明,用于异步方法内部指明需要异步执行的操作。一个异步方法可以包含任意多个await表达式,不过如果一个都不含编译器会发出警告。通常async和await都是一起出现的。

以上面的例子为例:

DoRun就是调用方法,CountCharacterAsync就是异步方法,webClient.DownloadStringTaskAsync就是await表达式。

什么是异步方法

上面说到,异步方法在完成其工作之前就会马上返回到调用方法中,然后再调用方法继续执行的时候完成其工作。

在语法上异步方法具有以下特点:

  • 方法头中包含async关键字。
  • 包含有一个或者多个await表达式。
  • 必须具备以下三种返回类型之一:
    • void
    • Task
    • Task<泛型类型>
    • ValueTask<泛型类型>
  • 任何具有公开可访问的GetAwaiter方法的类型。
  • 异步方法的形参可以为任意类型,任意数量,但不可以是out,ref参数。
  • 按照约定,异步方法应该以Async作为后缀结尾。
  • 除了方法外,Lambda表达式和匿名方法都可以作为异步方法。

下面我们来详细介绍:

  • 异步方法必须在方法头中包含async关键字,且必须位于返回值类型之前。该类型只是一个标识符标明该方法是异步方法,包含一个或者多个await表达式,其本身并没有异步操作。
    • async关键字是一个上下文关键字,也就是说除了作为方法修饰符,其也可以作为标识符。
  • 返回值类型必须是以下类型之一。
    • Task:如果调用方法不需要从异步方法中返回某个值,但是需要检查异步方法的状态,那么异步方法可以返回一个Task类型的对象。这种情况下如果异步方法中包含任何return语句,则他们不能返回任何东西。Task类型将由编译器为我们进行封装。
    • Task<泛型类型>:如果这时我们的调用方法需要从异步方法中获得一个T类型的值,那么异步方法的返回值类型就需要选取这种作为返回。调用方法可以通过读取Task类型中的Result属性来获得这个值。
    • ValueTask<泛型类型>:这是一个值类型的Task对象,在一些情况下使用ValueTask会比使用Task更加高效。
    • void:如果调用方法仅仅是想只能怪异步方法,而不需要与他做任何进一步的交互时,我们可以使用void返回作为返回值,这其中情况下我们称之为调用并忘记。
    • 任何可访问的GetAwaiter方法类型,我们很快就会谈到这种情况。
  • 你可能发现在之前的例子中发现,返回值类型为Task,但是方法体中并不包含任何一个返回Task类型的return语句,相反在方法的最后我们返回的是一个int类型的数据。我们先将结论记下,之后会详细说明。任何返回Task类型的异步方法,其返回值必须为T类型或可以隐式转换为T类型的类型。

异步方法控制流

异步方法的结构包含三个不同区域,这个三个区域如下:

  • 第一个await表达式之前的部分:
    • 从方法开头到第一个await表达式之前的所有代码。这一部分应该只包含少量无需长时间处理的代码。
    • 在到达await表达式之前,这块区域是同步执行的。
  • await表达式:
    • 表示将被异步执行的任务。
  • 后续部分:
    • await表达式之后的方法中 的其余代码。包含其运行环境,如所在线程信息、目前作用域内的变量值,以及当await表达式完成后需要重新执行时所需的其他信息。

当到达await表达式时,异步方法将控制返回到调用方法。如果方法的返回值类型为Task相关,则方法将会创建一个Task对象来代表当前需要异步完成的任务和其后续,然后将该Task返回到调用方法。此时调用方法可以继续向下执行,异步方法内的代码将完成以下工作:

  • 异步执行await表达式的任务
  • 当await表达式完成后执行后续部分
  • 如果在后续部分遇到了await表达式则会按照相同的方式处理。
  • 如果后续部分遇到return语句,则设置相应的Task数据,如果存在的话,然后退出控制流。

在上述异步方法的控制流执行的时候,由于在一开始遇到await表达式控制就返回了,所以在异步方法执行的同时,调用方法的控制流也在继续,从异步方法获得Task相关的返回类型后当调用方法需要实际值时就引用Task或者ValueTask对象的Result属性。届时,如果异步方法设置了该属性,调用方法就可以获得该值并继续,否则它将暂停并等待该属性被设置(线程被异步阻塞,Unity中可以使用协程来让线程不被异步阻塞),然后继续执行。

很多人可能不理解的一点是当我们异步方法的控制流第一次遇到await时所返回的对象类型。这返回值类型就是异步方法头中的返回类型。与await表达式的返回值无关。

还有一点可能令人疑惑的地方就是,异步方法的return语句“返回”一个结果或到达异步方法末尾时,他并没有真正的返回一个值,他只是退出了当前异步方法,值会被设置在之前返回的Task对象的Result属性中。

await表达式

await表达式指定一个异步执行的任务。其语法如下所示,由await关键字和一个“任务”组成。这个任务可能是一个Task类型的对象,也可能不是。默认情况下,这个任务在当前线程上异步执行。

await task

任务是一个awaitable类型的实例。awaitable类型是指包含GetAwaiter方法的类型,该方法没有参数,返回一个awaiter类型的对象。

C#为我们提供了大量的awaitable类型方法,我们可以直接使用。它们的返回值都是Task或ValueTask类型。

尽管当前C#的BCL中存在许多返回Task或ValueTask类型的方法,你仍然可能需要编写自己的方法来作为await表达式的任务。最简单的方法就是使用Task.Run方法来创建一个Task。关于Task.Run有一个非常重要的点就是他会在不同的线程上运行你的方法。

Task.Run需要我们传入一个委托,常用Task.Run重载如下所示:

返回类型 方法签名
Task Run(Action func)
Task Run(Action func, CancellationToken token)
Task Run(Func func)
Task Run(Func func, CancellationToken token)
Task Run(Func func)
Task Run(Func func, CancellationToken token)
Task Run(Func<Task> func)
Task Run(Func<Task> func, CancellationToken token)

取消一个异步操作

一些.NET的异步方法允许你请求终止执行。你也快可以在自己的异步方法中加入这个特性。在System.Threading.Tasks命名空间中有两个类是为此目的设计的:CancellationToken(struct)和CancellationTokenSource(class)。对于这两个类的关系你可以理解为轻量版和原版的关系。CancellationToken中的属性都引用自CancellationTokenSource。

  • CancellationToken对象包含一个任务是否应被取消的信息。
  • 拥有CancellationToken对象的任务需要定期检查其令牌状态。如果CancellationToken对象的IsCancellationRequested属性为true,任务就需要停止其操作并返回。这个定期检查令牌状态的代码需要我们自己书写。
  • CancellationToken是不可逆的,并且只能使用一次,也就是说,一旦IsCancellationRequested属性被设置为true,就不可以再更改了。简单来说CancellationTokenSource只提供了修改为true的方法Cancel(),而没有提供修改为false的方法。

等待任务

等待任务分为两种:在调用方法中同步等待,和在异步方法中异步等待

在调用方法中同步等待,调用方法在任务完成之前不会继续往下执行,涉及到的API:

Task.Wait,Task.WaitAll,Task.WaitAny

在异步方法中异步等待,调用方法不会因为异步方法等待而等待,相反调用方法会继续执行。涉及到的API:

Task.WhenAll,Task.WhenAny

当我们使用异步等待的时候,我们的await表达式就变成了调用Task.WhenAll或者Task.WhenAny。

Task.Delay方法

Task.Delay方法会创建一个Task对象,该对象将暂停其在线程中的处理,并在一定时间之后完成。和Thread.Sleep阻塞线程不同的是,Task.Delay不会阻塞线程,线程可以继续处理其他工作。通常会配合await表达式使用。

测试时,可以使用该方法来模拟耗时异步操作。

Task.Yield方法

在计算机中,线程是非常宝贵的资源。在await一个异步任务(函数)的时候,它会先判断该Task是否已经完成,如果已经完成,则继续执行下去,不会返回到调用方,原因是尽量避免线程切换,因为await后面部分的代码很可能是另一个不同的线程执行,而Task.Yeild()则可以强制回到调用方,或者说主动让出执行权,给其他Task执行的机会。

如下代码所示:

public static async Task OP1()
{
    
    
     while (true)
     {
    
    
         await Task.Yield();//这里会捕捉同步上下文,由于是控制台程序,没有同步上下文,所以默认的线程池任务调度器变成同步上下文
                                     //也就是说后面的代码将会在线程池上执行,由于线程池工作线程数量设置为1,所以必须主动让出执行权,让其他的
                                     //任务有执行的机会
         Console.WriteLine("OP1在执行");
         Thread.Sleep(1000);//模拟一些需要占用CPU的操作
     }
}
public static async Task OP2()
{
    
    
     while (true)
     {
    
    
         await Task.Yield();
         Console.WriteLine("OP2在执行");
         Thread.Sleep(1000);
     }
}
static async Task Main(string[] args)
{
    
    
     ThreadPool.SetMinThreads(1, 1);
     ThreadPool.SetMaxThreads(1, 1);
     //Task.Run()方法默认使用线程池任务调度器执行任务,由于主线程不是线程池线程,所以使用Task.Run()
     var t = Task.Run(async () =>
     {
    
    
         var t1 = OP1();
         var t2 = OP2();
         await Task.WhenAll(t1, t2);
     });
     await t;
     Console.ReadLine();
}

使用异步Lambda表达式

和方法使用的方法一样,下面是使用示例:

Action action = async () =>
{
    
    
    //await表达式必须是一个awaiter类型。
    await Task.Run(() =>
    {
    
    
        Console.WriteLine("123");
    });
};

BackgroundWorker

async/await特性更适合那些需要在后台完成的不相关的小任务。但是在有些时候,你可能会需要另建一个线程,在后台持续运行以完成某项任务,并不时地需要与主线程进行通信。BackgroundWorker类就是为此而生的。

主要成员:

属性

属性名称 属性访问权限 属性介绍
WorkReportsProgress R/W 当前线程是否可以向主线程汇报状态,希望汇报设置为true
WorkerSupportsCanellation R/W 是否支持从主线程取消该线程,希望可以取消设置为true
IsBusy R
CancellationPending R 判断是否应该停止线程

方法

方法名称 方法介绍
RunWorkerAsync 调用该方法获得后台线程并且执行DoWork回调函数
CanelAsync 该方法会将会把CancellationPending属性设置为true,和CanellationToken一样,这个过程是协同的
ReportProgress 后台线程希望向主线程汇报状态时调用此方法

事件

事件名称 事件介绍 触发时机
DoWork 附加在DoWork事件的回调函数包含你希望在后台独立线程上执行的代码,主线程通过调用RunWorkerAsync来执行DoWork事件 当后台线程开始的时候触发DoWork
ProgressChanged 后台线程通过调用ReportProgress方法与主线程进行通信,届时会触发此事件,主线程可以使用附加在此事件上的回调函数来处理事件 当后台任务汇报进度的时候触发ProgressChanged
RunWorkerCompleted 附加在此事件的回调函数应该包含在后台线程退出后需要执行的代码 当后台工作线程退出的时候触发RunWorkerCompleted

其他异步编程模式

BeginInvoke和EndInvoke

BeginInvoke和EndInvoke都是委托的方法,在调用BeginInvoke时,其参数列表中的实际参数组成如下:

  • 引用方法需要的参数(引用方法就是将在新线程上执行的方法);
  • 两个额外参数——callback参数和status参数

BeginInvoke在线程池中获得一个新线程并且让引用方法在新线程中开始运行。

BeginInvoke返回给调用线程一个实现IAsyncResult接口的对象的引用。这个接口引用包含了在线程池中运行的异步方法的当前状态。然后原始线程可以继续执行。EndInvoke方法用于获得由异步方法调用返回的值,并且释放线程的使用资源。EndInvoke有以下特性:

  • 他接受一个由BeginInvoke返回的IAsyncResult对象的引用作为参数,并找到他关联的线程。
  • 如果线程池的线程已经退出,则EndInvoke做以下事情:
    • 清理退出线程的状态并释放其资源。
    • 找到引用方法返回的值并把它作为返回值返回。
  • 如果当EndInvoke被调用时线程池的线程仍然在运行,调用线程就会停止并等待他完成。然后再清理退出线程的状态并释放其资源,找到引用方法返回的值并把它作为返回值返回。因为EndInvoke是为开启的线程进行清理,所以必须确保对每一个BeginInvoke都调用EndInvoke。
  • 如果异步方法触发了异常,则在调用EndInvoke时会抛出异常。
  • EndInvoke提供了异步方法调用的所有输出,包括ref和out参数。如果委托的引用方法有ref或out参数,则它们必须包含在EndInvoke的参数列表中,并且再IAsyncResult对象引用之前。

等待直到完成

在此模式下,在发起了异步方法以及做了一些其他处理之后,原始线程就中断并且等异步方法完成之后再继续,会发生同步等待。

轮询

在轮询模式下,原始线程发起了异步方法的调用,做一些其他处理,然后使用IAsyncResult对象的IsCompleted属性来定期检查开启的线程是否完成。如果异步方法已经完成,原始线程就调用EndInvoke并继续,否则就做一些其他处理并进行轮询检查。

回调

在之前的等待直到完成模式和轮询模式下,初始线程仅在知道开启线程已经完成之后才继续它的控制流程。然后,他获得结果并继续。

回调模式的不同之处在于:一旦初始线程发起了异步方法,他就自己管自己了,不在考虑同步。当一部方法调用结束之后,系统调用一个用户自定义的方法来处理结果,并且调用委托的EndInvoke方法。这个用户自定义的方法叫做回调方法或回调。这里就需要使用到BeginInvoke的两个额外参数了。

BeginInvoke两个额外参数的使用:

  • 第一个参数callback时回调函数的名字,回调函数必须是AsyncCallBack委托类型的。
  • 第二个参数state可以是null,此参数可以通过IAsyncResult的AsyncState属性来获得。

在回调方法内调用EndInovke,想要调用EndInvoke就需要知道委托对象的引用,而他在初始线程中,这时如果state不做特殊用途的话我们就可以通过state来将委托对象传入,并在回调方法中通过IAsyncResult的AsyncState来获得这个引用。

猜你喜欢

转载自blog.csdn.net/BraveRunTo/article/details/120611366