C#5.0引入了两个新关键词,async和await,大大简化了异步方法编程。

微软在不同时期使用的不同的异步模式,有3种:

  1. 异步模式
  2. 基于T事件的异步模式
  3. 基于Task的异步模式(TAP,Task Asynchronous Programming)

TAP是利用async和await关键字实现的,而且是微软极力推崇的一种异步编程方式。

注意!async和await关键字只是编译器功能。编译器会用Task类创建代码。如果不使用这两个关键词,C#4.0的Task类同样可以实现相同的功能,只是易读性和便利性差一些。

认识async和await

使用async和await关键字编写异步代码,具有与同步代码相当的结构和简单性,并且摒弃了异步编程的复杂结构。

但是在理解上刚开始会很不习惯,而且会把一些情况想当然了。现在我们用一些示例慢慢开始理解它

一个简单的同步方法

首先先写一个同步方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace AsyncandAwaitTest
{
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine($"头部已执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
string result = SayHi("jack");
Console.WriteLine(result);
Console.WriteLine($"尾部已执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
Console.ReadKey();
}
static string SayHi(string name)
{
Task.Delay(2000).Wait();//异步等待2s
Console.WriteLine($"SayHi执行,当前线程Id为:{Environment.CurrentManagedThreadId}");
return $"Hello,{name}";
}
}
}

同步方法

SayHi方法在主线程中运行,主线程被阻塞。

同步方法异步化

示例方法放到任务内执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace AsyncandAwaitTest
{
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine($"头部已执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
string result = SayHiAsync("jack").Result;
Console.WriteLine(result);
Console.WriteLine($"尾部已执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
Console.ReadKey();
}
static Task<string> SayHiAsync(string name)
{
return Task.Run(() => { return SayHi(name); });
}
static string SayHi(string name)
{
Task.Delay(2000).Wait();//异步等待2s
Console.WriteLine($"SayHi执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
return $"Hello,{name}";
}
}
}

我们在Main方法中调用了Task<string>.Result ,调用Result的时候就会在Task内部使用Wait,主线程还是被阻塞。

异步化方法执行结果

注意SayHi执行的线程Id指的是SayHiAsync方法的线程Id,Task.Delay(2000).Wait();会占用别的线程执行

延续任务(ContinueWith)

为了避免阻塞主线程使用任务延续的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
namespace AsyncandAwaitTest
{
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine($"头部已执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
Task<string> task = SayHiAsync("jack");//这行代码会直接启动Task
task.ContinueWith(t =>
{
Console.WriteLine($"延续执行,当前线程Id为{Environment.CurrentManagedThreadId}");
string result = t.Result;
Console.WriteLine(result);
});
Console.WriteLine($"尾部已执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
Console.ReadKey();
}
static Task<string> SayHiAsync(string name)
{
return Task.Run(() => { return SayHi(name); });
}
static string SayHi(string name)
{
Task.Delay(2000).Wait();//异步等待2s
Console.WriteLine($"SayHi执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
return $"Hello,{name}";
}
}
}

使用延续任务,延续任务内部的方法也是在另外一个线程中运行,可以用来在任务完成后处理返回值,而主线程一直不会阻塞。

延续任务

WhenAnyWhenAllContinueWith都属于Task中的延续操作,都不会阻塞主线程

使用await、async构建异步方法调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
namespace AsyncandAwaitTest
{
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine($"头部已执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
CallerWithAsync("jack");
Console.WriteLine($"尾部已执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
Console.ReadKey();
}
async static void CallerWithAsync(string name)
{
Console.WriteLine($"异步调用头部执行,当前线程Id为:{Environment.CurrentManagedThreadId}");
string result = await SayHiAsync(name);
Console.WriteLine($"异步调用尾部执行,当前线程Id为:{Environment.CurrentManagedThreadId}");
Console.WriteLine(result);
}
static Task<string> SayHiAsync(string name)
{
return Task.Run(() => { return SayHi(name); });
}
static string SayHi(string name)
{
Task.Delay(2000).Wait();//异步等待2s
Console.WriteLine($"SayHi执行,当前主线程Id为:{Environment.CurrentManagedThreadId}");
return $"Hello,{name}";
}
}
}

执行结果如下

async、await异步方法调用

使用await关键字调用返回Task的异步方法SayHiAsync,而使用await关键字的方法之前必须用async修饰,在SayHiAsync方法完成前,下面的方法不会执行,但是主线程并没有被阻塞。

本质

编译器将await关键字后的所有代码放进了延续(ContinueWith)方法的代码块中来转换await关键词。注意异步调用尾部执行的线程也是3,所以说明异步方法中await后面的代码都在子线程中调用了。如果一个异步方法中有多个await,它们就会互相嵌套,但是使用多个await的例子很少。

异步方法一运行到await,马上返回到主线程,主线程继续运行,await后面的代码在子线程中处理。

解析async和await

异步async

使用async修饰符标记的方法称为异步方法,异步方法只可以具有以下返回类型:

  1. Task
  2. Task<TResult>
  3. void
  4. 从C#7.0开始,任何具有可访问的GetAwaiter方法的类型GetAwaiter 方法返回的对象必须实现 System.Runtime.CompilerServices.ICriticalNotifyCompletion 接口。

异步方法通常包含await运算符的一个或多个实例,但缺少await也不会导致编译器错误,如果异步方法未使用await运算符标记暂停点,那么这个异步方法会作为同步方法执行。不过只有async没有await的方法编译器会发出警告

等待await

await表达式只能在由async修饰符标记的封闭方法体、Lambda表达式或异步方法中出现。在其他位置,它会解释为标识符。

使用await运算符的任务只可用于返回Task、Task<TResult>和System.Threading.Tasks.ValueType<TResult>对象的方法。

异步方法同步运行,直至到达其第一个await表达式,此时await在方法的执行中插入挂起点,会将方法挂起直到所等待的任务完成,然后执行await后面的代码区域。

await表达式不会阻止正在执行它的线程。而是使编译器将剩下的异步方法注册为等待任务的延续任务(在上面的例子中,Console.WriteLine($"异步调用尾部执行,当前线程Id为:{Environment.CurrentManagedThreadId}");Console.WriteLine(result);被注册成为了SayHiAsync的延续任务)。控制权会返回给异步方法的调用方。任务完成时,它会调用其延续任务,异步方法的执行会在暂停的位置处恢复。

注意:

  1. 无法等待具有void返回类型的异步方法,并且无效返回方法的调用方捕获不到异步方法抛出的任何异常
  2. 异步方法无法声明in、ref或out参数,但可以调用包含此类参数的方法。同样,异步方法无法通过引用返回值,但可以调用包含ref返回值的方法。

异步方法运行机理(控制流)

异步编程中最需要弄清的是控制流是如何从方法移动到方法的。

下列示例及说明引自使用 Async和 Await 的任务异步编程 (TAP) 模型 (C#) | Microsoft Docs,清晰说明了控制流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
namespace ControlFlowOfAsync
{
internal class Program
{
static void Main(string[] args)
{
var result = GetUrlContentLengthAsync();
Console.WriteLine(result.Result);
Console.ReadKey();
}
async static Task<int> GetUrlContentLengthAsync()
{
HttpClient client = new HttpClient();

//GetStringAsync返回Task<string>
Task<string> getStringTask = client.GetStringAsync("https://docs.microsoft.com/dotnet");

//可以在这里完成不依赖于GetStringAsync字符串的工作
DoIndependentWork();

//运行到await运算符,会暂停 GetUrlContentLengthAsync
//在 getStringTask 完成之前,GetUrlContentLengthAsync 无法继续
//同时,控件返回至 GetUrlContentLengthAsync 的调用方
//当 getStringTask 完成时,控件将在此处继续
//然后,await运算符会从 getStringTask 检索 string 结果。
string contents = await getStringTask;

//return语句指定一个整数结果
return contents.Length;

}

static void DoIndependentWork()
{
Console.WriteLine("Working");
}
}
}

await后面带有返回值的Task<TResult>会直接取得Result,这是因为Task实现了GetWaiter方法

异步控制流的跟踪导航

多个异步方法

在一个异步方法里,可以调用一个或多个异步方法,如何编写取决于异步方法之间的结果是否互相依赖。

顺序调用异步方法

使用await关键字可以调用每个异步方法,如果一个异步方法需要使用另一个异步方法的结果,await关键词就非常必要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace MultipleAsyncMethods
{
internal class MultAsyncTest
{
static void Main(string[] args)
{
Console.WriteLine("执行前……");
GetResultAsync();
Console.WriteLine("执行中……");
Console.ReadKey();
}
async static void GetResultAsync()
{
var number1 = await GetResult(10);
var number2 = GetResult(number1);
Console.WriteLine($"结果分别为:{number1}{number2.Result}");
}

static Task<int> GetResult(int number)
{
return Task.Run<int>(() => { Task.Delay(1000).Wait(); return number + 10; });
}
}
}

如果不添加await,number2就无法获取到

使用组合器

如果异步方法相互不依赖,则每个异步方法都不适用await,而是通过Task变量引用每个异步方法,然后使用WhenAll、WhenAny等Task的延续操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
namespace MultipleAsyncMethods
{
internal class MultAsyncTest
{
static void Main(string[] args)
{
Console.WriteLine("执行前……");
GetResultAsync();
Console.WriteLine("执行中……");
Console.ReadKey();
}
async static void GetResultAsync()
{
var number1 = GetResult(10);
var number2 = GetResult(20);
await Task.WhenAll(number1, number2);
Console.WriteLine($"结果分别为:{number1.Result}{number2.Result}");
}

static Task<int> GetResult(int number)
{
return Task.Run<int>(() => { Task.Delay(1000).Wait(); return number + 10; });
}
}
}

异步方法的异常处理

异常处理

下面的示例中演示了捕获异常的错误方式和正确方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
namespace ExceptionHandlingTest
{
internal class ExceptionHandling
{
static void Main(string[] args)
{
HandleWrong();
HandlerError();
Console.ReadKey();
}
//错误方式
static void HandleWrong()
{
try
{
var task = ThrowAfter(0, "Handle Error");
}
catch (Exception ex)
{

Console.WriteLine(ex.Message);
}
}
//正确方式
static async void HandlerError()
{
try
{
await ThrowAfter(1000, "HandlerError");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
//会抛出异常的异步方法
static async Task ThrowAfter(int seconds,string message)
{
await Task.Delay(seconds);
throw new Exception(message);
}
}
}

执行结果如下

正确处理异步方法异常

调用异步方法,如果只是简单放在try/catch中,将会捕获不到异常,这是因为HandleWrong方法在ThrowAfter抛出异常之前就已经执行完毕。

注意我们的异步方法ThrowAfter返回的是Task,如果返回void的话就不会捕获异常了。

异步方法的异常一个较好的捕获方式,就是使用await关键字,将其防在try/catch语句块中

多个异步方法异常处理

如果调用了多个异步方法,在第一个异步方法抛出异常,后续的方法将不会被调用,catch块内只会处理出现的第一个异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
namespace ExceptionHandlingTest
{
internal class ExceptionHandling
{
static void Main(string[] args)
{
HandlerError();
Console.ReadKey();
}

//正确方式
static async void HandlerError()
{
try
{
await ThrowAfter(1000, "HandlerError");
await ThrowAfter(2000, "HandleError2");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);//只会处理第一个异常
}
}
//会抛出异常的异步方法
static async Task ThrowAfter(int seconds,string message)
{
await Task.Delay(seconds);
throw new Exception(message);
}
}
}

所以正确的做法是使用Task.WhenAll,不管任务是否抛出异常都会等到所有任务完成。Task.WhenAll结束后,异常被catch语句捕捉到。

但是如果只是捕捉Exception,我们只能看到WhenAll方法的第一个发生异常的任务信息,不会抛出后续的异常任务。

所以要捕获所有任务的异常信息,需要对任务声明内部变量,在try块引用变量,在catch块访问变量,然后在使用IsFaulted属性检查任务状态,以确认它们是否出现错误,然后再进行处理。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
namespace ExceptionHandlingTest
{
internal class ExceptionHandling
{
static void Main(string[] args)
{
HandlerError();
Console.ReadKey();
}

//正确方式
static async void HandlerError()
{
Task? task1 = null;
Task? task2 = null;
try
{
task1 = ThrowAfter(1000, "HandleError-One-Error");
task2 = ThrowAfter(2000, "HandleError-Two-Error");
await Task.WhenAll(task1, task2);
}
catch (Exception ex)
{
if (task1.IsFaulted)
{
Console.WriteLine(task1.Exception.InnerException.Message);
}
if (task2.IsFaulted)
{
Console.WriteLine(task2.Exception.InnerException.Message);
}
}
}
//会抛出异常的异步方法
static async Task ThrowAfter(int seconds,string message)
{
await Task.Delay(seconds);
throw new Exception(message);
}
}
}

全部异常抛出

使用AggregateException获取异步方法异常

AggregateException包含了等待中所有异常的列表,Task.Exception返回的就是AggregateException,可以轻松遍历处理所有异常信息。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
namespace ExceptionHandlingTest
{
internal class ExceptionHandling
{
static void Main(string[] args)
{
HandlerError();
Console.ReadKey();
}

//正确方式
static async void HandlerError()
{
Task? taskResult = null;
try
{
Task task1 = ThrowAfter(1000, "HandleError-One-Error");
Task task2 = ThrowAfter(2000, "HandleError-Two-Error");
await (taskResult = Task.WhenAll(task1, task2));
}
catch (Exception)
{
foreach (var ex in taskResult.Exception.InnerExceptions)
{
Console.WriteLine(ex.Message);
}
}
}
//会抛出异常的异步方法
static async Task ThrowAfter(int seconds,string message)
{
await Task.Delay(seconds);
throw new Exception(message);
}
}
}

使用AggregateException

重要补充与建议

提高相应能力

Dot Net有很多异步API我们可以通过async、await构建调用提高响应能力,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace ControlFlowOfAsync
{
internal class Program
{
static void Main(string[] args)
{
Demo();
Console.ReadKey();
}
async static void Demo()
{
HttpClient client = new HttpClient();

var getTaskResult = await client.GetStringAsync("https://docs.microsoft.com/dotnet");

Console.WriteLine(getTaskResult);

}
}
}

这些API都以Asyc结尾。

下面列表展示了包含异步API的一些类

应用程序功能 支持包含异步方法的类
Web访问 HttpClient、SyndicationClient
处理文件 StorageFile、StreamWriter、StreamReader、XmlReader
使用图像处理 MediaCapture、BitmapEncoder、BitmapDecoder
WCF编程 同步和异步操作
套接字处理 Socket

重要建议

  1. async方法需要在主体中具有await关键字,否则它们将永不暂停。同时C#编译器将生成一个警告,此代码将会以类似普通方法的方式进行编译和运行。请注意这会导致效率低下,因为由C#编译器为异步方法生成的状态机将不会完成任何任务。
  2. 应将Async作为后缀添加到所编写的每个异步方法名称中。这是dot Net中的惯例,以便更轻松区分同步和异步方法。
  3. async void 应仅用于事件处理程序。因为事件不具有返回类型(因此无法返回Task和Task<TResult>)。其他任何对async void 的使用都不遵循TAP模型,且可能存在一定难度。例如:async void 方法中引发的异常无法在该方法外部被捕获或十分难以测试async void方法。

以非阻止方式处理等待任务

将阻止当前线程作为等待任务完成的方法,可能导致死锁和已阻止的上下文线程,且可能需要更复杂的错误处理。下表提供了关于如何以非阻止方式处理等待任务的指南:

使用一下方式 而不是…… 若要执行此操作
await Task.Wait或Task.Result 检索后台任务的结果
await Task.WhenAny Task.WhenAny 等待任何任务完成
await Task.WhenAll Task.WhenAll 等待所有任务完成
await Task.Delay Thread.Sleep 等待一段时间

异步编程准则

异步编程的准则是确定所需执行的操作是I/O-Bound还是CPU-Bound。因为这会极大影响代码性能,并可能导致某些构造的误用。

考虑两个问题:

  1. 你的代码是否会“等待”某些内容,例如数据库中的数据Web资源等?如果答案为“是”,则你的工作是I/O-Bound。
  2. 你的代码是否要执行开销巨大的计算?如果答案为“是”,则你的工作是CPU-Bound。

如果你的工作是I/O-Bound,请使用async和await(而不是使用Task.Run),不应使用任务并行库。

如果你的工作为CPU-Bound,并且你重视相应能力,请使用async和await,并在另一个线程上使用Task.Run生成工作。如果该工作同时适用于并发和并行,则应考虑使用任务并行库。

Async/Await根本原理

Async和Await异步编程的原理 - 天边彩云 - 博客园 (cnblogs.com)