C#5.0的时候引入了async和await两个修饰符,成为异步编程的核心关键字。
async 是修饰符,表明方法含有异步操作,但并不是说整个方法是异步的。async修饰的方法会先同步执行到第一处await的地方而后开始异步。
await可以理解为一种异步特有的“return”。即返回一个正在运行的异步过程。并且等待该异步过程结束后再继续向await的下一句运行。
例如下方法

private static void Main(string[] args)
{
    Console.WriteLine("Application Start");
    AsyncTask1();
    Console.WriteLine("Application End");
}

private static async Task AsyncTask1()
{
    Console.WriteLine("AsyncVoid1");
    Thread.Sleep(1000);
    Console.WriteLine("AsyncVoid1: befor await");
    await Task.Run(() =>
    {
        Console.WriteLine("AsyncVoid1: Task Runing");
        Thread.Sleep(2000);
    });
    Console.WriteLine("AsyncVoid1: after await");
}

运行输出如下:

Application Start
AsyncVoid1
AsyncVoid1: befor await
AsyncVoid1: Task Runing
Application End
AsyncVoid1: after await

异步返回类型

async是方法的修饰,其对应的方法的返回值有

  • void
  • Task
  • Task<TResult>、
  • 任何具有可访问的 GetAwaiter 方法的类型(GetAwaiter 方法返回的对象必须实现 ystem.Runtime.CompilerServices.ICriticalNotifyCompletion 接口)。
  • IAsyncEnumerable<T>(对于返回异步流的异步方法)。

常用的是前三个。暂且只整理前三者的区别与不同。

async void

async不建议修饰void方法,因为void 方法无法使用await。比如上面的例子中,AsyncTask1方法再调用时可以不加await,也可以加await来实现在调用程序结束前等待其执行结束。
比如调用方式改成如下:

static void Main(string[] args)
{
     //控制台Main入口不能加async
      MainAsync();
}
//方法内有await时,方法必须被async修饰。
private static async void MainAsync()
{
    Console.WriteLine("Application Start");
    await AsyncTask1();
    Console.WriteLine("Application End");
}

运行结果如下:

Application Start
AsyncVoid1
AsyncVoid1: befor await
AsyncVoid1: Task Runing
AsyncVoid1: after await
Application End

如果将AsyncTask1的返回类型变成void,则调用方中就不能用await来强制同步了。

private static async void MainAsync()
{
    Console.WriteLine("Application Start");
    await AsyncTask1();//编译报错,“无法等待void”
    Console.WriteLine("Application End");
}

private static async void AsyncTask1()
{
    Console.WriteLine("AsyncVoid1");
    Thread.Sleep(1000);
    Console.WriteLine("AsyncVoid1: befor await");
    await Task.Run(() =>
    {
        Console.WriteLine("AsyncVoid1: Task Runing");
        Thread.Sleep(2000);
    });
    Console.WriteLine("AsyncVoid1: after await");
}

async可以修饰返回void的方法,是因为这种多用于事件,例如按钮点击事件等,这些情景就非常符合async void的使用场景。

public class NaiveButton
{
    public event EventHandler? Clicked;

    public void Click()
    {
        Console.WriteLine("Somebody has clicked a button. Let's raise the event...");
        Clicked?.Invoke(this, EventArgs.Empty);
        Console.WriteLine("All listeners are notified.");
    }
}

public class AsyncVoidExample
{
    public static Main()
    {
        var button = new NaiveButton();
        button.Clicked += OnButtonClickedAsync;
        Console.WriteLine("Before button.Click() is called...");
        button.Click();
        Console.WriteLine("After button.Click() is called...");
    }
    
    private static async void OnButtonClickedAsync(object? sender, EventArgs e)
    {
        Console.WriteLine("   Handler 2 is starting...");
        Task.Delay(100).Wait();
        Console.WriteLine("   Handler 2 is about to go async...");
        await Task.Delay(500);
        Console.WriteLine("   Handler 2 is done.");
    }
}

async Task

返回Task类型的。个人概括的说,就是“执行操作但不返回任何值的异步方法”。即await 等待的一个无返回值的Task。
例如前文已有例子:

private static async Task AsyncTask1()
{
    Console.WriteLine("AsyncVoid1");
    Thread.Sleep(1000);
    Console.WriteLine("AsyncVoid1: befor await");
    await Task.Run(() =>
    {
        Console.WriteLine("AsyncVoid1: Task Runing");
        Thread.Sleep(2000);
    });
    Console.WriteLine("AsyncVoid1: after await");
}

需要说明的是,这里说的是async修饰的Task返回类型,从外部调用上,她只需要返回Task类型的实例对象。但实际上行需要返回已经开始运行Task的对象。在调用处不能通过Task.Start()来开始任务,await后接一个未开始的任务也只能如等一个已死的人回心转意。

从设计上讲,async和await总是搭配使用的,await后接的必定是正在运行的Task。

async Task<TResult>

返回类型为Task<TResult>的异步方法,和返回类型为Task<TResult>的方法是两回事。直接看代码:

private static async Task<int> AsyncFuncReturn1()
{
    await Task.Run(() => 
    { 
        Thread.Sleep(500); 
    });            
    return 1;
}
private static Task<int> AsyncFuncReturn2()
{
    return Task<int>.Run(() => 
    { 
        Thread.Sleep(500); 
        return 1; 
    });
}

后者只是将Task的实例化过程进行包装。前者具有两方面功能,一方提供Task实例的操控权力,另一方面说明异步方法的返回值类型。两者是独立的。即异步方法中的Task类型可以自定义,只需要确保方法的返回值类型正确即可。

但是,这两个方法再调用时却是一致的

private async static void Caller()
{
    //通过await 等待获取结果
    int s1 = await AsyncFuncReturn1();
    int s2 = await AsyncFuncReturn2();
    //通过Result(转同步)获取结果
    int s11 = AsyncFuncReturn1().Result;
    int s12 = AsyncFuncReturn2().Result;
    //先获取Task
    var t1 = AsyncFuncReturn1();
    var t2 = AsyncFuncReturn2();
    //在Task运行时先同步处理其他事情之后再获取结果
    int s111 = t1.Result;
    int s112 = t2.Result;
}

但优先考虑使用有async修饰的方式,因为这样,如果你没有写await,编译器通常会提醒你,以防止你返回的是一个没有开始的任务。

await 和 Task.Wait()/Task.Result

await 和Task.Wait()的作用都有等待任务结束的功能,不同的是,就像前面说的,await还有一层“return”的意思,或者说更像“yield”。即让await所在方法的被调用者可以继续下一步操作。(这里只是说“可以”,即对外产生这样的能力,而如果await所在方法返回的值是void类型,调用者就不能强制同步了)
依然用上面的示例:

private static void Main(string[] args)
{
    Console.WriteLine("Application Start");
    AsyncTask1();
    Console.WriteLine("Application End");
}

private static async Task AsyncTask1()
{
    Console.WriteLine("AsyncVoid1");
    Thread.Sleep(1000);
    Console.WriteLine("AsyncVoid1: befor await");
    await Task.Run(() =>
    {
        Console.WriteLine("AsyncVoid1: Task Runing");
        Thread.Sleep(2000);
    });
    Console.WriteLine("AsyncVoid1: after await");
}

运行输出如下:

Application Start
AsyncVoid1
AsyncVoid1: befor await
AsyncVoid1: Task Runing
Application End
AsyncVoid1: after await

可以看到,先输出了Application End,即AsyncTask1中的await等待异步任务时,主程序依然在运行。
一方面,如果将AsyncTask1改造成如下

private static void AsyncTask1_2()
{//没有await,不需要async修饰,也不会返回Task.
    Console.WriteLine("AsyncVoid1");
    Thread.Sleep(1000);
    Console.WriteLine("AsyncVoid1: befor await");
    Task.Run(() =>
    {
        Console.WriteLine("AsyncVoid1: Task Runing");
        Thread.Sleep(2000);
    }).Wait();
    Console.WriteLine("AsyncVoid1: after await");
}

这样调用AsyncTask1_2()方法将变成纯粹的同步任务。
另一方面,如果不改变AsyncTask1()方法,而改变调用方式:

private static void Main(string[] args)
{
    Console.WriteLine("Application Start");
    AsyncTask1().Wait();
    Console.WriteLine("Application End");
}

其也是将AsyncTask1()强制转换同步方法。
Task.Wait()和Task.Result在异步同步问题上是一致的,不同的是Result是针对Task<TResult>任务的,先内部Wait()而后返回结果。

async与await的执行顺序

需要引用一下巨硬家的官方讲解,还是很清楚的

在这里插入图片描述
关系图中的数字对应于以下步骤,在调用方法调用异步方法时启动。

  1. 调用方法调用并等待 GetUrlContentLengthAsync 异步方法。
  2. GetUrlContentLengthAsync 可创建 HttpClient 实例并调用 GetStringAsync 异步方法以下载网站内容作为字符串。
  3. GetStringAsync 中发生了某种情况,该情况挂起了它的进程。 可能必须等待网站下载或一些其他阻止活动。 为避免阻止资源,GetStringAsync 会将控制权出让给其调用方 GetUrlContentLengthAsync。
  4. 由于尚未等待 getStringTask,因此,GetUrlContentLengthAsync 可以继续执行不依赖于 GetStringAsync 得出的最终结果的其他工作。 该任务由对同步方法 DoIndependentWork 的调用表示。
  5. DoIndependentWork 是完成其工作并返回其调用方的同步方法。
  6. GetUrlContentLengthAsync 已运行完毕,可以不受 getStringTask 的结果影响。 接下来,GetUrlContentLengthAsync 需要计算并返回已下载的字符串的长度,但该方法只有在获得字符串的情况下才能计算该值。
  7. GetStringAsync 完成并生成一个字符串结果。 字符串结果不是通过按你预期的方式调用 GetStringAsync 所返回的。 (记住,该方法已返回步骤 3 中的一个任务)。相反,字符串结果存储在表示 getStringTask 方法完成的任务中。 await 运算符从 getStringTask 中检索结果。 赋值语句将检索到的结果赋给 contents。
  8. 当 GetUrlContentLengthAsync 具有字符串结果时,该方法可以计算字符串长度。 然后,GetUrlContentLengthAsync 工作也将完成,并且等待事件处理程序可继续使用。 在此主题结尾处的完整示例中,可确认事件处理程序检索并打印长度结果的值。 如果你不熟悉异步编程,请花 1 分钟时间考虑同步行为和异步行为之间的差异。 当其工作完成时(第 5 步)会返回一个同步方法,但当其工作挂起时(第 3 步和第 6 步),异步方法会返回一个任务值。 在异步方法最终完成其工作时,任务会标记为已完成,而结果(如果有)将存储在任务中。

简单说下重点,就是await执行完后会回到方法调用方地方。

private static void Main(string[] args)
{
    Caller();
}
private static void Caller()
{
    Console.WriteLine("Caller:Call AsyncFunc");
    AsyncFunc();
    Console.WriteLine("Caller:Call Finished");
    Thread.Sleep(300);
    Console.WriteLine("Caller:sleep 300ms");
    Thread.Sleep(300);
    Console.WriteLine("Caller:sleep 300ms");
    Console.WriteLine("Caller:end");
}

private static async Task AsyncFunc()
{
    Console.WriteLine("AsyncFunc:befor await");
    await Task.Run(() => { Console.WriteLine("await task running"); Thread.Sleep(500); });
    Console.WriteLine("AsyncFunc:after await");
}
Caller:Call AsyncFunc
AsyncFunc:befor await
Caller:Call Finished
await task running
Caller:sleep 300ms
AsyncFunc:after await
Caller:sleep 300ms
Caller:end

即是说上面例子中的AsyncFunc()异步方法执行到await会返回到Caller()中,如果Caller()中对异步方法的调用方式是同步的(如上)则Caller自身线程继续执行后续任务。
这一点在界面化编程中较为重要,代码不正确顺序会在窗体程序中造成死锁或异常,这一点在后续文章《C# 异步编程 WinForm中的常见问题》中做详细说明。

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐