C#异步编程学习笔记4 之 异步函数
异步函数
使用 async 和 awit 关键字可以写出和同步代码一样简介且结构相同的异步代码。
awaiting
await 关键字简化了附加 continuation 的过程。其结构如下:
var result = await expression;
statement(s);
它的作用相当于:
var awaiter = expression.GetAwaiter();
awaiter.OnCompleted(() => {
var result = awaiter.GerResult();
statement(s);
});
以下面代码为例,简要说明 async 和 awit 关键字的用法:
class Program
{
static async Task Main(string[] args)
{
await DisplayPrimeCountsAsync();
}
async static Task DisplayPrimeCountsAsync()
{
int result = await GetPrimesCountAsync(2, 1000000);
Console.WriteLine(result);
}
static Task<int> GetPrimesCountAsync(int start, int count)
{
return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n =>
Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)));
}
}
- GetPrimesCountAsync 为异步方法,返回类型为 Task<int>。对于异步的方法,可以通过在方法名前面加上 await 关键字来调用。
- 如果方法的返回值是 void,则无法使用 await 表达式来调用。
- 而在包含 await 表达式的方法中,方法前必须使用 async 关键字来修饰。
async 修饰符
- async 修饰符会让编译器把 await 当作关键字而不是标识符(因为 C# 5 以前,await 不是关键字,代码中可能会使用 await 作为标识符。
- async 修饰符只能应用于方法(包括 lambda 表达式)。
- 使用 async 修饰的方法可以返回 void、Task、Task<TResult>
- asyne 修饰符对方法的签名或 public 源数据没有影响(和 unsafe 一样),它只会影响方法内部
- 在接口内使用 async 是没有意义的
- 使用 async 来重载非 async 的方法却是合法的(只要方法签名一致)
- 使用了 async 修饰符的方法就是“异步函数”。
异步方法如何执行
遇到 await 表达式,执行(正常情况下)会返回调用者(与 Iterator 里面的 yield return 类似)
- 在返回前,运行时会附加一个 continuation 到 await 的 task
- 为保证 task 结束时,执行会跳回原方法,从停止的地方继续执行
- 如果发生故障,那么异常会被重新抛出
- 如果一切正常,那么它的返回值就会赋给 await 表达式
以下面代码为例,代码中 DisplayPrimeCount 方法与 DisplayPrimeCountsAsync 方法完全一致。
class Program
{
static async Task Main(string[] args)
{
await DisplayPrimeCountsAsync();
}
void DisplayPrimeCount()
{
var awaiter = GetPrimesCountAsync(2, 1000000).GetAwaiter();
awaiter.OnCompleted(() =>
{
int result = awaiter.GetResult();
Console.WriteLine(result);
});
}
async static Task DisplayPrimeCountsAsync()
{
int result = await GetPrimesCountAsync(2, 1000000);
Console.WriteLine(result);
}
static Task<int> GetPrimesCountAsync(int start, int count)
{
return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n =>
Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)));
}
}
可以 await 什么?
- await 表达式通常是一个 task
- 也可以是满足下列条件的任意对象:
- 有 GetAwaiter 方法,它返回一个 awaiter(awaiter 实现了 INotifyCompletion.OnCompleted 接口)
- 返回适当类型的 GetResult 方法
- 一个 bool 类型的 IsCompleted 属性
捕获本地状态
await 表达式的最牛之处就是它几乎可以出现在任何地方。
特别的,在异步方法内,await 表达式可以替换任何表达式,除了 lock 表达式和 unsafe 上下文。
class Program
{
static async Task Main(string[] args)
{
}
async void DisplayPrimeCounts()
{
//在下面的循环中,每次遇到 await 表达式,程序运行异步方法 GerPrimeCountAsync,
//每次执行完之后,程序会回到 await 表达式,同时捕获方法本地变量 i
for(int i = 0; i < 10; i++)
Console.WriteLine(await GerPrimeCountAsync(i * 1000000 + 2, 1000000));
}
static Task<int> GetPrimesCountAsync(int start, int count)
{
return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n =>
Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)));
}
}
await 之后在哪个线程上执行
在 await 表达式之后,编译器依赖于 continuation(通过 awaiter 模式)来继续执行
- 如果在富客户端应用的 UI 线程上,同步上下文会保证后续是在原线程上执行;
- 否则,就会在 task 结束的线程上继续执行。
UI 上的 await
下面代码是一个简单的 WPF 代码,运行操作之后,UI 线程会处于一个假死的状态,等待后台完成计算之后,页面会直接显示最终的计算结果。
//WPF
public partial class MainWindow : Window
{
Button _button = new Button { Content = "Go" };
TextBlock _results = new TextBlock();
public MainWindowO
{
InitializeComponent();
var panel = new StackPanel();
panel.Children.Add(_button);
panel.Children.Add(_results);
Content = panel;
_button.Click += (sender, args) => Go();
}
void Go()
{
for(int i = 1; i < 5; i++)
_results.Test += GetPrimesCount(i * 1000000, 1000000) +
" primes between " + (i * 1000000) + " and " + ((i + 1) * 1000000 -1) + Environment.NewLine;
}
int GetPrimesCount(int start, int count)
{
return ParallelEnumerable.Range(start, count).Count(n =>
Enumerable.Range(2, (int)Matb.Sqrt(n) - 1).All(i => n % i > 0));
}
}
通过更改代码为异步形式,可以避免 UI 线程出现假死,页面可以一行一行输出结果,且不影响页面的其它操作。代码如下:
//WPF
public partial class MainWindow : Window
{
Button _button = new Button { Content = "Go" };
TextBlock _results = new TextBlock();
public MainWindowO
{
InitializeComponent();
var panel = new StackPanel();
panel.Children.Add(_button);
panel.Children.Add(_results);
Content = panel;
_button.Click += (sender, args) => Go();
}
async Go()
{
_button.IsEnable = false;
for(int i = 1; i < 5; i++)
_results.Test += await GetPrimesCountAsync(i * 1000000, 1000000) +
" primes between " + (i * 1000000) + " and " + ((i + 1) * 1000000 -1) + Environment.NewLine;
_button.IsEnable = true;
}
Task<int> GetPrimesCountAsync(int start, int count)
{
return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n =>
Enumerable.Range(2, (int)Matb.Sqrt(n) - 1).All(i => n % i > 0)));
}
}
在本例中,只有 GetPrimesCountAsync 中的代码在 worker 线程上运行。Go 中的代码会“租用” UI 线程上的时间。可以说:Go 方法是在消息循环中“伪并发” 的执行。
- 也就是说:Go 方法和 UI 线程处理的其它时间是穿插执行的
- 因为这种伪并发,唯一能发生“抢占”的时刻就是在 await 期间。
- 这其实简化了线程安全,防止重新进入即可(本例中即防止按钮被重复点击即可)
这种并发发生在调用栈较浅的地方(Task.Run 调用的代码里)。
为了从该模型中获益,真正的并发代码要避免访问共享状态或(共享的)UI 控件。
代码运行原理
上述并发过程可以用以下伪代码来表示原理:
为本线程设置同步上下文(WPF)
while(!程序结束)
{
等着消息队列中发生一些事情
发生了事情,是哪种消息?
键盘/鼠标消息 -> 触发 event handler
用户 BeginInvoke/Invoke 消息 -> 执行委托
}
- 附加到 UI 元素的 event handler 通过消息循环执行
- 上述代码中,当 Go 函数运行时,会先一直运行到 await 语句
- await 语句会将 UI 释放出来,让 UI 可以响应其它事件。但是编译器会保证在 await 返回之前,设置好一个 continuation,以便在任务完成的时候,能够接着刚刚停止的地方继续往下执行
- 因为在 UI 线程上 await,continuation 将发送到同步上下文中,该同步上下文通过消息循环执行,来保证整个 Go 方法伪并发的在 UI 线程上执行。
与粗粒度的并发相比
使用 BackgroundWorker 是粗粒度的并发。(Task.Run 与 BackgroundWorker 效果基本一致,但Task.Run 使用更简便,下面以 Task.Run 为例。)
//WPF
public partial class MainWindow : Window
{
Button _button = new Button { Content = "Go" };
TextBlock _results = new TextBlock();
public MainWindowO
{
InitializeComponent();
var panel = new StackPanel();
panel.Children.Add(_button);
panel.Children.Add(_results);
Content = panel;
_button.Click += (sender, args) =>
{
_button.IsEnabled = false;
Task.Run(() => Go());
};
}
void Go()
{
for(int i = 1; i < 5; i++)
{
int result = GetPrimesCount(i * 1000000, 1000000);
Dispatcher.BeginInvoke(new Action(() =>
_results.Test += result + " primes between " + (i * 1000000) + " and " + ((i + 1) * 1000000 -1) + Environment.NewLine));
}
Dispatcher.BeginInvoke(new Action(() => _button.IsEnabled = true));
}
int GetPrimesCount(int start, int count)
{
return ParallelEnumerable.Range(start, count).Count(n =>
Enumerable.Range(2, (int)Matb.Sqrt(n) - 1).All(i => n % i > 0));
}
}
如上代码:
- 整个同步调用图都在 worker 线程上
- 必须在代码中到处使用 Dispatcher.BeginInvoke,否则 UI 线程无法响应更改
- 循环本身在 worker 线程上
- 引入了 race condition(静态条件)
- 若实现取消和过程报告功能,会使得线程安全问题更容易发生,即使在方法中新添加任何的代码也是如此。(这就是在调用图中比较高的位置启用线程带来的危险)
编写异步函数
-
对于任何异步函数,可以使用 Task 代替 void 作为返回类型,让该方法成为更有效的异步(可以进行 await)。
static async Task Main(string[] args) { await PrintAnswerToLife(); //此处不加 await 会报警告。 //如果不加 await,PrintAnswerToLife 方法与 Main 就是并行执行的,而不是顺序执行。 } static async Task PrintAnswerToLife() { await Task.Delay(5000); int answer = 21 + 2; Console.WriteLine(answer); }
-
并不需要在方法体中显式的返回 Task。编译器会生成一个 Task(当方法完成或发生异常时),这使得创建异步的调用链非常方便。如下展示调用链的代码:
static async Task Main(string[] args) { await Go(); } async Task Go() { await PrintAnswerToLife(); Console.WriteLine("Done"); } async Task PrintAnswerToLife() { await Task.Delay(5000); int answer = 21 + 2; Console.WriteLine(answer); }
-
编译器会对返回 Task 的异步函数进行扩展,使其成为当发送信号或发生故障时使用 TaskCompletionSource 来创建 Task 的代码。
static async Task Main(string[] args) { } Task PrintAnswerToLife() { var tcs = new TaskCompletionSource<objcet>(); var awaiter = Task.Delay(5000).GetAwaiter(); awaiter.OnCompleted(() => { try { awaiter.GetResult(); int answer = 21 * 2; Console.WriteLine(answer); tcs.SetResult(null)l } catch(Exception ex) { tcs.SetException(ex); } }); return tcs.Task; }
-
因此,当返回 Task 的异步方法结束的时候,执行就会跳回到对它进行 await 的地方继续执行(通过 continuation)
(编写异步函数)富客户端场景下
富客户端场景下,执行在此刻会跳回到 UI 线程(如果目前不再 UI 线程的话)。
否则,就会在 continuation 返回的任意线程上继续执行。
这就意味着,在异步调用图中向上冒泡的时候,不会发生延迟成本,除非是 UI 线程启动的第一次“反弹”。
返回 Task<TResult>
如果原函数方法体返回 TResult,那么异步方法就可以返回 Task<TResult>。
其原理就是给 TaskCompletionSource 发送信号带有值,而不是 null ;
static async Task Main(string[] args)
{
int answer = await GetAnswerToLife(); //返回 int 类型
//Task<int> answer = GetAnswerToLife(); //返回 Task<int> 类型
Console.WriteLine(answer);
}
async Task<int> GetAnswerToLife()
{
await Task.Delay(5000);
int answer = 21 + 2;
return answer;
}
其实上述异步代码与同步编程相似,是 .Net 故意这样设计的。上述代码的同步版本如下:
static async Task Main(string[] args)
{
}
void Go()
{
PrintAnswerToLife();
Console.WriteLine("Done");
}
void PrintAnswerToLife()
{
int answer = GetAnswerToLife();
Console.WriteLine(answer);
}
int GetAnswerToLife()
{
Thread.Sleep(5000);
int answer = 21 + 2;
return answer;
}
C# 中如何设计异步函数
- 先以同步的方式编写方法
- 使用异步调用来代替同步调用,并且进行 await
- 除了顶层方法外(UI 控件的 event handler),把你方法的返回类型升级为 Task 或 Task<TResult>,这样它们就可以进行 await 了。
编译器能对异步函数生成 Task 意味着什么?
- 大多数情况下,只需要在初始化 IO-bound 并发的底层方法里显式的初始化 TaskCompletionSource。这种情况很少见。
- 针对初始化 compute-bound 的并发方法,可以使用 Task.Run 来创建 Task。
异步调用图执行
如下异步代码:
static async Task Main(string[] args)
{
await Go(); // main thread
}
static async Task Go()
{
var task = PrintAnswerToLife();
await task; //此处在 PrintAnswerToLife() 方法调用后才进行 await 的,说明方法 PrintAnswerToLife() 是在 main thread 上同步执行的
Console.WriteLine("Done");
}
static async Task PrintAnswerToLife()
{
var task = GetAnswerToLife();
int answer = await task; // 同 Go() 中的注释类似
Console.WriteLine(answer);
}
static async Task<int> GetAnswerToLife()
{
var task = Task.Delay(5000); //在此处创建了一个 continuation
await task;
int answer = 21 + 2;
return answer;
}
- 整个代码执行与之前同步例子中调用图执行的顺序一样,因为我们对每个异步函数的调用都进行了 await。
- 在调用图中创建了一个没有并行和重叠的连续流。
- 每个 await 在执行中都创建了一个间隙,在间隙后,程序可以从中断处恢复执行。
并行(Parallelism)
不是用 await 来调用异步函数会导致并行执行的发生
- 例如:_button.Click += (sender, args) => Go();
- 这样确实也能满足保持 UI 响应的并发要求,UI 不会假死
同样的,可以并行两个操作,如下:
var task1 = PrintAnswerToLife();
var task2 = PrintAnswerToLife();
//await task1; await task2; //如果此处不增加着两句 await,则前面的代码是并行执行的
异步 Lambda 表达式
-
匿名方法(包括 Lambda 表达式),通过使用 async 也可以变成异步方法。调用方式也是一样的。
static async Task Main(string[] args) { Func<Task> unnamed = async () => { await Task.Delay(1000); Console.WriteLine("Foo"); }; await NameMethod(); await unnamed(); } static async Task NameMethod() { await Task.Delay(1000); Console.WriteLine("Foo"); }
-
附加 event handler 的时候也可以使用异步 Lambda 表达式
myButton.Click += async (sender, args) => { await Task.Delay(1000); myButton.Content = "Done"; };
上面代码相当于下面代码:
myButton.Click += ButtonHandler; ... async void ButtonHandler(object sender, EventArgs args) { await Task.Delay(1000); myButton.Content = "Done"; };
-
也可以返回 Task<TResult>。
static async Task Main(string[] args) { Func<Task<int>> unnamed = async () => { await Task.Delay(1000); return 123; }; int answer = await unnamed(); }
异步中的同步上下文
发布异常
-
富客户端应用通常依赖于集中的异常处理事件来处理 UI 线程上未捕获的异常。
- 例如 WPF 中的 Application.DispatcherUnhandledException
- ASP.NET Core 中定制 ExceptionFilterAttribute 也是差不多的效果
其内部原理是:通过在它们自己的 try/catch 块来调用 UI 事件(在 ASP.NET Core 里就是页面处理方法的管道)
-
顶层的异步方法会使事情更加复杂,如下代码:
async void ButtonClick(object sender, RoutedEventArgs args) { await Task.Delay(1000); throw new Exception("Will this be ignored?"); }
-
当点击按钮,event handler 运行时,在 await 后,执行会正常地返回到消息循环;1 秒钟后抛出的异常无法被消息循环中的 catch 块捕获。
-
为了缓解该问题,AsyncVoidMethodBuilder 会捕获未处理的异常(在返回 void 的异步方法里),并把它们发布到同步上下文(如果出现的话),以确保全局异常处理事件能够触发。
【注意】
-
编译器只会把上述逻辑应用于返回类型为 void 的异步方法
-
如果 ButtonClick 的返回类型是 Task,那么未处理的异常将导致结果 Task 出错,然后 Task 无处可去(导致未观察到的异常)
-
-
一个有趣的细微差别:无论在 await 前面还是后边抛出异常,都没有区别。
async void Foo(){ throw null; await Task.Delay(1000); }
- 在上例中,异常会被发布到同步上下文(如果出现的话),而不会发布给调用者。
- 如果同步上下文没有出现,异常将会在线程池上传播,从而终止应用程序。
-
不直接将异常抛出回调用者的原因是为了确保可预测性和一致性。
-
在下例中,不管 someCondition 是什么值,InvalidOperationException 将始终得到和导致 Task 出错同样的效果。
async Task Foo() { if(someCondition) await Task.Delay(100); throw new InvalidOperationException(); }
-
-
Iterator 也是一样的:
IEnumerable<int> Foo() { throw null; yeild return 123; }
- 在本例中,异常绝不会直接返回给调用者,直到序列被便利后,才会抛出异常。
OperationStarted 和 OperationCompleted
如果存在同步上下文,返回 void 的异步函数也会在进入函数是调用其 OperationStarted 方法,在函数完成时调用其 OperationCompleted 方法。
如果为了对返回 void 的异步方法进行单元测试而编写一个自定义的同步上下文,那么重写者两个方法确实有用。
优化——同步完成
异步函数可以在 await 之前就返回。
static async Task Main(string[] args)
{
Console.WriteLine(await GetWebPageAsync("http://oreilly.com"));
}
static Dictionary<string, string> _cache = new Dictionary<string, string>();
static async Task<string> GetWebPageAsync(string uri)
{
string html;
if(_cache.TryGetValue(uri, out html))
return html;
return _cache[uri] = await new WebClient().DownloadStringTaskAsync(uri);
}
- 如果 URI 在缓存中存在,那么不会有 await 发生,执行就会返回给调用者,方法会返回一个已经设置信号的 Task,这就是同步完成。
- 当 await 同步完成的 Task 时,执行不会返回到调用者,也不会通过 continuation 跳回。它会立即执行到下个语句。
编译器是如何完成这个优化的?
-
编译器是通过检测 awaiter 上的 IsCompleted 属性来实现这个优化的。也就是说。无论何时,当程序 await 的时候:
Console.WriteLine(await GerWebPageAsync("http://oreilly.com"));
-
如果是同步完成,编译器会释放可短路 continuation 的代码
var awaiter = GetWebPageAsync().GetAwaiter(); if(awaiter.IsCompleted) Console.WriteLine(awaiter.GetResult()); else awaiter.OnCompleted(() => Console.WriteLine(awaiter.GetResult()));
【注意】
- 对一个同步返回的异步方法进行 await,仍然会引起一个小的开销(20纳秒左右,2019年的PC)
- 反过来,跳回到线程池,会引入上下文切换开销,可能是 1-2 毫秒
- 而跳回到 UI 的消息循环,至少是 10 倍开销(如果 UI 繁忙,那时间更长)
其它情况
-
编写完全没有 await 的异步方法也是合法的,但是编译器会发出警告;
async Task<string> Foo() { return "abc"; }
这类方法可以用于重载 virtual/abstract 方法。
-
另外一种可以达到相同结果的方式是:使用 Task.FromResult,它会返回一个已经设置好信号的 Task。
Task<string> Foo() { return Task.FromResult("abc"); }
-
如果从 UI 线程上调用,那么 GetWebPageAsync 方法是隐式线程安全的。您可以连续多次调用它(从而启动多个并发下载),并且不需要 lock 来保护缓存。
- 但是,如果连续调用这个方法传入的是同一个网址的话,那么就相当于启动了多个冗余的下载,这样就比较低效了
-
有一种简单的方法可以实现这一点,而不必求助于 lock 或信令结构。我们创建一个“futures” (Task<string>) 的缓存,而不是字符串的缓存。注意,并没有 async:
static Dictionary<string, Task<string>> _cache = new Dictionary<string, Task<string>>(); Task<string> GetWebPageAsync(string uri) { if(_cache.TryGetValue(uri, out var downloadTask)) return downloadTask; return _cache[uri] = new WebClient().DownloadStringTaskAsync(uri); //这里没有加 await,返回的也是一个 Task<string>。 }
- 这里没有加 await,返回的也是一个 Task<string>。
- 这个时候如果重复调用 GetWebPageAsync,代码会进入 if 分支,返回的是同一个 Task<string>。
- 而且如果这个 Task 已经 Completed 了,那么它的 await 的开销也会非常小。
不使用同步上下文,使用 lock 也可以
lock 的不是下载的过程,lock 的是检测缓存的过程(很短暂)
lock (_cache)
{
if(_cache.TryGetValue (uri, out var downloadTask))
return downloadTask;
else
return _cache[uri] = new WebClient().DownloadStringTaskAsync(uri);
}
ValueTask<T>
ValueTask<T> 用于微优化场景,使用场景非常少,可能永远不需要编写返回此类型的方法。
Task<T> 和 Task 是引用类型,实例化它们需要基于堆的内存分配和后续的(垃圾)收集。
优化此问题的一种极端形式是编写无需分配此类内存的代码;换句话说,这不会实例化任何引用类型,不会给垃圾收集增加负担。
为了支持这种模式,C# 引入了 ValueTask 和 ValueTask<T> 这两个 struct,编译器允许使用它们代替 Task 和 Task<T>
如下例子:
async ValueTask<int> Foo(){...}
-
如果上例操作是同步完成,则 await ValueTask<T> 是无分配的(无需分配堆内存)。如下:
int answer = await Foo(); //(可能是)无分配的
-
如果操作不是同步完成的,ValueTask<T> 实际使就会创建一个普通的 Task<T>(并将 await 转发给它)
-
使用 AsTask 方法,可以把 ValueTask<T> 转化为 Task<T>(也包括非泛型版本)
使用 ValueTask<T> 时的注意事项
-
ValueTask<T> 并不常见,它的出现纯粹时为了性能。
-
这意味着它被不恰当的值类型语义所困扰,这可能会导致意外。为避免错误行为,必须避免以下情况:
- 避免多次 await 同一个 ValueTask<T>
- 避免操作没结束的时候就调用 .GetAwaiter() 和 .GetResult() 方法
-
如果需要进行以上两种操作,那么先调用 AsTask 方法,操作它返回的 Task。
-
避免上述陷阱最简单的办法就是直接 await 方法调用:
- await Foo();
-
将 ValueTask 赋给变量时,就可能引发错误了:
- ValueTask<int> valueTask = Foo();
-
将其立即转化为普通的 Task,就可以避免此类错误的发生:
- Task<int> task = Foo().AsTask();
避免过度的弹回
对于在循环中多次调用的方法,通过调用 ConfigureAwait 方法,就可以避免重复的探花到 UI 消息队列所带来的开销。
这强迫 Task 不把 continuation 弹回给同步上下文。从而将开销削减到接近上下文切换的成本(如果 await 的方法同步完成,则开销会小得多)
async void A() { ... await B(); ...}
async Task B()
{
for(int i = 0; i < 1000: i++)
await C().ConfigureAwait(false);
}
async Task C() { ... }
- 这意味着对于方法 B 和 C,我们取消了 UI 线程中的简单线程安全模型,即代码在 UI 线程上运行,并且只能在 await 语句期间被抢占。但是,方法 A 不受影响,如果在一个 UI 线程上启动,它将保留在 UI 线程上。
- 这种优化在编写库时特别重要:您不需要简化线程安全性带来的好处,因为您的代码通常不与调用方共享状态,也不访问 UI 控件。