异步模型和模式






4.92/5 (75投票s)
介绍 async / await,异步编程中的常见错误和解决方案,以及异步编程的用法和优势。我们还将讨论基于并发的有趣模式。
目录
引言
本文将讨论 C# 5 中使用 async
/ await
的有用概念和总体思想。我们将探讨它为何有用以及这两个关键字的确切作用。我们的旅程将从识别各种程序执行通道(称为线程)以及任务的抽象概念开始。
总而言之,我们将朝着几个关键点努力。本文应从中学习以下经验:
- 避免潜在的竞态条件以及我们可以采取的预防措施
async void
应仅用于事件处理程序- 异步任务可以封装事件,使代码更简洁并提供局部性
- 设计不会导致潜在问题的异步 API
- 将线程池用于计算密集型代码 - 但不要用于内存密集型代码
在深入讨论之前,我们要问的第一个问题是,为什么我们关心拥有多个线程。
为什么需要多线程?
摩尔定律指出,集成电路中的晶体管数量大约每两年翻一番。几十年来,这伴随着单个核心每秒操作次数的增加。然而,这之所以可能,是因为频率也呈指数增长。由于邓纳德定律,不可能同时保持频率和密度(晶体管数量)呈指数增长。因此,频率的增长现已结束,要获得更多每秒操作次数的唯一方法是让更多核心参与计算,并改进芯片架构。
在大多数应用程序中,我们不会受到计算限制(CPU 密集型),即我们不会接近 CPU 的极限。然而,即使在十年前,我们的大多数应用程序也受到内存限制(IO 密集型)。以前,这对于本地 IO 来说是真的,比如内存或磁盘访问。如今,越来越多的流量是网络 IO,比如从互联网或网络上的其他计算机下载数据。虽然计算密集型应用程序无疑将从多核 CPU 中获益最大,但其他应用程序也可以从拥有更多核心中获益。
自 20 世纪 80 年代末以来,每个标准操作系统(OS)都允许我们使用多个应用程序(即使只有一个核心)。操作系统通过为每个应用程序分配一些 CPU 时间来做到这一点。一旦一个应用程序的时间配额用完,操作系统就会切换到另一个应用程序。现代操作系统通过减少必要的上下文切换和(除了时间管理之外)CPU 分配管理来利用多核 CPU。
通常,现代操作系统的时序管理基于所谓的线程或进程。在 Windows 中,我们采用基于线程的时序管理。这导致了我们将 CPU 密集型计算转移到另一个线程(与运行 GUI 的线程不同)的范式,以便 GUI 仍然可以做其他事情。这对于 1 个核心是有意义的(OS 会给 GUI 线程一些时间,也会给计算线程更多的时间),对于 2 个核心则更有意义(现在计算线程可以全职工作直到任务完成,而 GUI 线程在 OS 核心上获得少量时间)。在 Linux 中,每个线程都是一个进程(简化模型,因为存在一些差异)。
现在,对于内存密集型方法,在无法进行异步操作的情况下使用另一个线程是有意义的——只是为了保持 GUI 活跃和响应。如果存在用于执行内存操作的异步操作,这当然是首选方法。论点与计算密集型方法相同;OS 会为 GUI 线程和(等待的)工作线程分配时间。利用另一个核心的论点也相当相似:在这种情况下,我们可能不需要进行上下文切换,从而获得更好的性能和更少的等待时间(然而,差异仅以微秒(10^-6 秒)为单位可衡量)。
因此,使用线程是告诉操作系统哪些计算单元属于一起的一种方式。根据这个,操作系统将安排计算时间、CPU 分配等。将我们的应用程序划分为多个线程将因此保证更好的 CPU 使用率、程序流程和响应能力。尽可能使用异步操作将在不要求花费时间管理线程的情况下,为响应式 UI 提供优势。
竞态条件
从这些线程的角度看待我们的程序会产生两种不同的变量。一种是仅在线程范围内,称为线程局部(有时称为线程静态)变量,另一种是所有线程都可以访问的变量。这些变量称为全局变量。当然,我们仍然有局部变量,但由于函数的范围总是由调用它的线程创建,因此这些变量也属于特定线程,就像线程静态变量一样。
从名称上我们就可以推断出线程局部变量不会带来任何问题。一个线程使用其局部变量直到线程终止。只有线程本身可以读取和写入这些变量的值,这就是为什么没有问题。然而,在全局变量的情况下,我们会引入一种新的问题。根据我们应用程序的结构,我们可能会遇到两个或多个线程访问一个变量的情况。在读取变量的情况下没有问题,但一旦变量的值被修改,问题就开始了。
第一个问题很明显:如果多个线程可以写入该值怎么办?好吧,在这些情况下,最后一个写入变量的线程获胜。乍一看这似乎不是什么大问题,但如果写入值的线程(们)希望稍后使用该值,则可能导致意外行为。在这种情况下,值可能会改变,之后会使用来自另一个线程的不同值。
第二个问题当然与第一个问题有关。如果只有一个线程想写入该值,而其他线程正在读取它呢?即使在这种情况下,我们也可能遇到问题,因为我们无法保证写入发生在读取之前,或者读取发生在写入之前。
这两个问题导致了“竞态条件”一词,即当两个线程实际上在争夺访问资源时所处的状态。通常,竞态条件出现得并不频繁(从计算机的角度来看)且不可预测,然而,考虑到大多数操作都在循环中执行或定期执行,从程序员的角度来看,在某些代码中经常看到竞态条件是非常有可能的。例如:如果竞态条件在十亿条指令中出现一次(对于计算机而言并不频繁),我们每秒就会看到一次(这对我们来说非常频繁)。
避免竞态条件的解决方案通常是通过原子操作提供的。如果一个操作对系统的其余部分来说似乎瞬间发生,那么它就是原子的,即它要么成功地改变了系统的状态,要么没有明显的影响,并且它一次只能由一个线程执行。强制原子性的一种方法是互斥,即不可变对象。在硬件层面,这已通过 MESI 等缓存一致性协议完成,或者在软件层面使用信号量或锁。
让我们使用 TPL 和并行 for 循环来计算某个值,看看 C# 中经典的竞态解决方案。
int count = 0;
int max = 10000;
Parallel.For(0, max, m => count++);
如果我们想为此绘制一个示例图,并附带一些任意的示例数据,那么我们可以使用以下图表(我们有 2 个线程,标记为红色和蓝色)。
这确实是一个非常简单的代码。我们通过在每次迭代中递增 count
来计算其值。所以最终,count
的值应该等于 max
,即迭代次数。在任何串行代码(或 for 循环)中,这不是问题,然而,一旦达到一定的操作次数,我们肯定会遇到竞态条件。
10 op. in parallel resulted in zero race-conditions (00.0%) ...
100 op. in parallel resulted in zero race-conditions (00.0%) ...
1000 op. in parallel resulted in zero race-conditions (00.0%) ...
10000 op. in parallel resulted in some race-conditions (14.8%) ...
100000 op. in parallel resulted in some race-conditions (59.7%) ...
1000000 op. in parallel resulted in some race-conditions (60.3%) ...
10000000 op. in parallel resulted in some race-conditions (61.3%) ...
100000000 op. in parallel resulted in some race-conditions (64.0%) ...
为什么在出现竞态条件之前有一个固定的操作偏移量?答案是,生成线程(即使 TPL 只是激活一个已在优化线程池上生成的线程)需要一些时间。通常,线程需要改变其状态从空闲或睡眠,并需要调用函数。此函数(以及函数可以看到的所有内容,如局部变量和全局变量)需要执行上下文切换。
所以这是一种近似线程唤醒时间的方法。在这里我们看到 10000 次操作足以产生竞态条件,而 1000 次操作似乎太少。因此,我们可以说创建一个新任务至少需要 1000 个周期(由于一次迭代比 1 个周期花费更长时间,实际数字可能更像 5000 个周期)。
要克服这个负担的最简单方法是什么?好吧,在软件层面,我们已经将锁作为一种解决方案。幸运的是,C# 团队知道这一点,并将锁包含在语言中。我们只需要为 lock
提供一个地址,然后将使用该地址来确定 lock
是否适用于给定的语句块,并在其当前使用时等待。拥有支持语法锁的语言极大地提高了生产力!
var lockObj = new object();
int count = 0;
int max = 10000;
Parallel.For(0, max, m => {
lock(lockObj)
{
count++;
}
});
现在这可以正常工作,没有任何竞态条件,然而,看看执行时间,我们可能会发现这通常比原始版本慢至少 N 倍。其中 N 是使用的核心数。在我的 UltraBook(4 核)上,我花了 7804 毫秒,而原始示例代码花了 1548 毫秒。额外的时间来自线程创建和线程同步(lock
本身也需要一些周期,通常在 1000 到 10000 个周期之间)。另请注意:根据频率缩放、Turbo Boost 等技术的当前状态(以及操作系统的工作负载),随机调用时可能看不到 N 倍的差异。
下面是我们的两个示例线程现在的行为图。
一般来说,在一个方法周围放置一个“大”lock
,该方法在并行调用,这是一个坏主意。原因有二:
- 如果该方法是线程执行的唯一内容,那么这就像经典的串行代码,只是开销更大,性能因此降低。不是个好主意!
- 如果该方法只是一个在并行运行的线程中调用的方法,那么以下代码会更方便。
可以声明一个方法只能同步运行。使用我们的示例,我们可以这样做:
int max = 10000;
var sm = new SyncMethod();
Parallel.For(0, max, m => sm.Increment());
同步运行它们代表以下图表,这基本上是顺序(单线程)版本。
SyncMethod
类的实现如下:
class SyncMethod
{
int count = 0;
[MethodImpl(MethodImplOptions.Synchronized)]
public void Increment()
{
count++;
}
public int Count
{
get { return count; }
}
}
总的来说,这是一种更快更好的方法。它也是使某些操作保持原子的好方法。在我的机器上,测试方法的持续时间为 4478 毫秒——比使用 lock
快约 33%。
对于我们的示例,最好的选择是使用线程局部变量。这样的变量为每个线程创建计数器,然后用于在最后进行一次归约。因此,我们将大量的全局操作减少到一个,在最后一步调用。幸运的是,TPL 的创建者们知道这个问题,并提供了一个 Parallel.For
方法的重载,该方法创建并使用这样的线程局部变量。
查看上图,我们看到两个线程都在执行独立的工作。它们(独立)结果将被一个线程使用。值得注意的是,高性能库将以更复杂的方式执行此全局操作(求和归约,以收集的形式)。在网络(CPU 间)中,网络拓扑将被使用,否则(如果不知道拓扑,或者我们是 CPU 内的情况)二进制树将提供更好的性能(使用二进制树的缩放是 log2N 而不是纯粹的顺序收集,后者缩放为 N)。
var lockObj = new object();
int count = 0;
int max = 10000;
Parallel.For(0, max, () => 0,
(m, state, local) =>
{
return local + 1;
},
local =>
{
lock (lockObj)
{
count += local;
}
}
);
在我的机器上,示例代码运行时间为 461 毫秒,比初始的、存在竞态条件问题的并行代码快。加速的原因是使用了线程局部变量,该变量可以直接从核心缓存中获取,而不是从其他核心缓存传输或需要缓存逐出。因此,我们不仅消除了竞态条件,还提高了性能。
一些语言(但不是 C#,因为你不能重载 =
运算符)提供原子对象。在 C++ 中,你可以创建如 atomic<int>
这样的对象(或使用框架提供的对象,如 Intel Thread Building Blocks)。这些对象基本上做同样的事情。它们将是线程局部的,并在请求总值时执行全局操作(在大多数实现中,必须显式请求,以避免开销)。
.NET 框架还为某些类型和操作提供了另一种执行线程安全操作的方法:Interlocked
类。它包含一组辅助方法,如 Add()
,用于添加 4 字节整数(Int32
)或 8 字节整数(Int64
)。还有其他几种可用方法,基本上都是为了使用以下汇编指令:
- BT
- BTS
- BTR
- BTC
- XCHG
- XADD
- ADD
- 或者
- ADC
- SBB
- AND
- SUB
- XOR
- NOT
- NEG
- INC
- DEC
这些指令在 X86 指令集中有锁定版本,即它们具有 LOCK 指令前缀。所以我们不能使用 Interlocked
类来添加两个双精度浮点数,但我们绝对可以在此场景中使用它。
int count = 0;
int max = 10000;
Parallel.For(0, max, m => Interlocked.Increment(ref count));
所以这现在非常接近我们的串行代码,并且性能也相当不错,但是,正如我们所见,Interlocked
类只能用于特殊场景。不用说,在这种场景下绝对应该使用它(不幸的是,大多数数字应用程序都需要浮点运算,这是排除的)。性能肯定比使用锁定或同步方式更好,但仍然远远落后于使用线程局部变量来减少通信的最佳方法。
避免处理更复杂的对象(如数组、字典和列表)中的竞态条件的另一种方法是使用 .NET 框架的所谓并发对象。所有这些对象都可以在 System.Collections.Concurrent
命名空间中找到。这里有 ConcurrentDictionary
或 ConcurrentStack
等类。所有这些对象都提供了对更基础对象成员的并发访问,例如 ConcurrentList<T>
中的 List<T>
,然而,它们不提供对底层元素的线程安全访问。本文将不对这些对象进行完整讨论。
await / async 关键字
我们已经知道计算密集型任务应该与 TPL 一起使用。在这里,使用更多核心是有意义的,因为它将加速计算。在另一个线程中使用 TPL 也有意义,因为这样我们将保持 GUI 的响应能力。对于内存密集型任务,只有后者才有意义,即我们不应使用 TPL,因为在内存密集型任务中使用更多核心/线程只会导致开销。仅作澄清:仅在不存在非阻塞调用方法的情况下,为 IO 密集型方法使用新线程才有意义。在这种情况下,我们使用异步模式异步 (async over sync),稍后将对此进行解释。
在 .NET 的历史中,我们会发现几种用于处理启动新工作线程和处理该线程结果的常见任务的模式。最著名的模式是:
- 异步编程模型 (APM),基于
IAsyncResult
- 基于事件的异步模式 (EAP),最著名的是
BackgroundWorker
实现 - 基于任务的异步模式 (TAP),随 TPL 和
Task<TResult>
类出现
最优雅和现代的模式无疑是 TAP。封装线程的任务有很大的优势。首先,我们可以连接各种任务并建立依赖关系。因此,我们可以创建一个完整的任务树,从而以一种优雅且富有表现力的方式声明非顺序程序流。另一个特性是改进的异常处理和优化的启动时间。任务由 TPL 管理,即从程序员的角度来看,我们不再关心资源(线程),而只关心要作为(独立)任务调用的方法。
然而,任务的问题在于它们仍然需要几行代码。我们仍然希望发生以下情况:执行方法的第一部分,在另一个线程中以异步方式调用该方法的其他(可能是内存密集型)部分,并在(该任务完成后)在与第一部分相同的线程中执行方法的最后一部分。所有这些步骤都应该在处理异常的情况下进行,当然,我们的 GUI 仍然应该保持响应。
让我们看一些示例代码:
void RunSequentially(object sender, RoutedEventArgs e)
{
//First part
var original = Button1.Content;
Button1.Content = "Please wait ...";
Button1.IsEnabled = false;
//Simulate some work
SimulateWork();
//Last part
Button1.Content = original;
Button1.IsEnabled = true;
}
所以这里有我们的 3 个区域。我们可以轻松地看到这 3 个区域可以扩展到 N 个区域。我们唯一关心的是必须从 GUI 线程调用的代码与应该在非 GUI 线程中运行以保持 GUI 响应的代码的交替。让我们用一个简单但有说服力的图来表达这个 3 区域模型。
对代码进行非常简单的修改(不带异常处理等)如下:
void RunAsyncTask(object sender, RoutedEventArgs e)
{
//First part
var original = Button2.Content;
Button2.Content = "Please wait ...";
Button2.IsEnabled = false;
//Simulate some work
var task = Task.Factory.StartNew(() => SimulateWork());
task.ContinueWith(t =>
{
//Last part
Button2.Content = original;
Button2.IsEnabled = true;
}, TaskScheduler.FromCurrentSynchronizationContext());
}
这里有两个重要的点。第一点是我们实际上创建了一个新的正在运行的任务,该任务需要由另一个任务继续。这个延续非常重要,否则我们要么有两个并发运行的任务(只需创建另一个任务),要么有一些代码在工作实际开始之前运行(根本没有将代码封装在另一个 Task
中——请记住,任何线程都有一些启动时间)。另一点是我们必须使用 TaskScheduler.FromCurrentSynchronizationContext()
方法来获取一个 TaskScheduler
实例,该实例使用当前 GUI 线程。如果我们不使用它,我们将面临跨线程异常,因为我们将从非 GUI 线程访问 GUI 元素。
现在,使用 C# 5,我们可以更简单地编写。让我们先看看代码:
async void RunAsyncAwait(object sender, RoutedEventArgs e)
{
//First part
var original = Button3.Content;
Button3.Content = "Please wait ...";
Button3.IsEnabled = false;
//Simulate some work
await Task.Run(() => SimulateWork());
//Last part
Button3.Content = original;
Button3.IsEnabled = true;
}
这看起来几乎与顺序版本相同。我们只改变了两行——方法定义增加了 async
关键字,现在使用封装的 Task
调用 SimulateWork()
方法。此外,通过 await
关键字等待生成的 Task
。
需要注意的是,async
关键字不会生成新线程,也不会开箱即用地执行任何异步操作。该关键字只是将方法的内容封装到 Task
中。这也启用了 await
关键字。
同步模式异步 (Async over sync)
该示例显示了一些被认为是异步模式同步 (async over sync) 的内容。此外,我们还将研究异步模式同步 (sync over async)。另外两种方式是(平凡地)同步模式同步(直接调用即可)和异步模式异步(直接调用即可)。使用 Task.Run()
方法进行异步模式同步绝对是一种选择。我们还可以使用它来使旧的、过时的(同步) API 变为异步。考虑旧方法:
void SimulateWork()
{
//Simulate work
Thread.Sleep(5000);
}
现在我们可以封装这个同步方法使其变为异步:
async Task SimulateWorkAsync()
{
await Task.Run(() => SimulateWork());
}
总的来说,这是一个好习惯(或者更准确地说:唯一应该考虑的习惯)是从异步方法返回 Task
。通常,我们更倾向于使用 Task<TResult>
,即如下所示:
double ComputeNumber()
{
//Simulate work
Thread.Sleep(5000);
//Return some number
return 1.0;
}
async Task<double> ComputeNumberAsync()
{
return await Task.Run(() => ComputeNumber());
}
这样,任务就有了有意义的目标——计算一个双精度数。给任务结果并非总是可能的,也并非总是合理的,然而,如果可能,这种技术绝对应该被应用。
异步模式同步 (Sync over async)
另一种方式也是可能的。如果我们有一个真正的异步进程(如网络或文件系统操作),那么将使用暴露为同步方法是没有意义的。相反,我们应该始终以异步方式暴露方法调用——只是为了继续流程。如果现在有用户想顺序调用异步方法,那么可以通过使用 Task
类的一些功能轻松实现。
让我们看看一个简单的示例代码:
public async Task<string> ReadUrlAsync(string url)
{
var request = WebRequest.Create(url);
using (var response = await request.GetResponseAsync())
using (var stream = new StreamReader(response.GetResponseStream()))
return stream.ReadToEnd();
}
这里我们使用的是 WebRequest
类的 GetResponseAsync()
方法。隐藏此底层异步行为是没有意义的。现在用户基本上有两个选择。通常和首选的选择是使用异步方式。
async Task ReadUrlIntoTextBoxAsync()
{
Output.Text = string.Empty;
ButtonSync.IsEnabled = false;
string response = await ReadUrlAsync("http://www.bing.com");
Output.Text = response;
ButtonSync.IsEnabled = true;
}
现在我们也可以以同步方式使用它,将调用命名为异步模式同步。
void ReadUrlIntoTextBox()
{
Output.Text = string.Empty;
ButtonSync.IsEnabled = false;
var task = ReadUrlAsync("http://www.google.com");
task.Wait();
var response = task.Result;
//Or alternatively just use:
//var response = ReadUrlAsync("http://www.google.com").Result;
Output.Text = response;
ButtonSync.IsEnabled = true;
}
乍一看,这种方式似乎很平凡,但我们的 API 需要重新设计才能正确支持它。我们将在“常见错误”部分深入讨论这个错误。目前,我们只需要知道以下 API 将毫无问题地工作:
public async Task<string> ReadUrlAsync(string url)
{
var request = WebRequest.Create(url);
using (var response = await request.GetResponseAsync().ConfigureAwait(false))
using (var stream = new StreamReader(response.GetResponseStream()))
return stream.ReadToEnd();
}
因此,使用 Wait()
我们可以阻止一个线程等待任务完成。或者,我们可以使用 Result
属性,它隐式使用 Wait()
直到任务的结果被设置。还有一个名为 RunSynchronously()
的方法,但是,在这种情况下不能执行它,因为 RunSynchronously()
不能在未绑定到委托的任务上调用,例如从异步方法返回的任务。
用法和优势
使用 await
最明显的用法是保持 GUI 响应。然而,我们将看到还有更多可能性。在模式部分,我们将发现通过保持局部状态局部来改进代码的方法。
保持 GUI 响应是使用 await
的一个好论据,但对于 async
呢?显然,async
是使用 await
所必需的。但它背后还有更多内容。在 API 中将函数标记为 async
应该只用于真正的异步函数,否则(如果它实际上只是一个异步模式同步函数)API 的设计就是错误的。请记住,每个程序员都可以使用异步模式同步,因此提供仅使用异步模式同步的额外函数绝对是错误的。
那么这里的经验是什么?不要谎称你的 API。如果你在做一些同步的事情,那么就暴露为同步。如果你在做一些真正的异步事情,那么就用 async
来暴露(并遵循约定:方法名应以 Async 结尾)。如果你想公开异步函数的同步版本,那么不要仅仅使用异步模式同步,而是用真正顺序的方式编写函数。
好处很清楚:如果 API 的创建者没有撒谎,用户将始终知道幕后发生了什么。是会生成一个新任务,还是不生成,以及是否应该等待它。始终考虑那 70 毫秒,这大约是任何方法调用应该花费的最大时间。如果超过此时间,那么您的 UI 很可能感觉不那么响应。因此,API 的设计应该能够让用户知道选择哪个版本。
正确的 SynchronizationContext
一个大问题是同步上下文,即执行工作的线程。大多数情况下,我们不会指定一个特定的上下文,然而,在 GUI 应用程序的情况下,我们希望所有改变控件状态的操作都在默认同步上下文中执行。
这实际上是使用 async
和 await
的一个重要优点。通常这会奏效,因为默认设置是在 await
语句后切换上下文。这确保了任务之后的代码实际上将从 GUI 线程运行。然而,我们可能不希望这样。有多种原因可以避免这种情况,最显著的是性能和可靠性。让我们看看如何控制它:
//This code runs in the GUI thread
var request = WebRequest.Create(url);
//This code runs in a different thread
var response = await request.GetResponseAsync().ConfigureAwait(true);
//This code will run in the GUI thread
/* ... */
调用 ConfigureAwait(true)
有点不必要,因为这已经是默认值。尽管如此,我们还可以看看另一种可能性:
//This code runs in the GUI thread
var request = WebRequest.Create(url);
//This code runs in a different thread
var response = await request.GetResponseAsync().ConfigureAwait(false);
//This code will generally run in a different thread than the GUI thread
/* ... */
通过这种方式,我们保留了 await
的主要好处,即使用异步方法而不会在异常处理、延续等方面遇到麻烦,同时又不将 UI 线程用于延续。这实际上是编写 API 的最佳实践,因为我们不希望不必要地阻止 UI。
同步上下文的问题并非新鲜事,并且 Microsoft 长期以来都有一个通用答案:SynchronizationContext
类!过去,我写过许多对框架版本或配置文件有限制的 API。其中一些限制使得公开异步 API 或使用任务/线程成为不可能,然而,非常明显的是该 API 将(在某些情况下)异步使用。在这种情况下,只有两个选择:
- 懒惰,让实际使用该 API 的程序员发现 API 中的所有事件都必须重新路由到 GUI 同步上下文。
- 好心,并从与 GUI 相同的同步上下文中触发事件。
虽然第 1 点显然是某些程序员(也包括早期版本的某些 .NET 框架开发人员;甚至一些异步公开的 API 也不在正确上下文中触发事件)的首选,但选项 2 比听起来要少工作。基本上需要以下构造:
class SomeAPIClass
{
SynchronizationContext context;
public event EventHandler Changed;
public SomeAPIClass()
{
//We either take the currently default one or we create a new one
//if no default sync. context exists (e.g. console projects, ...)
context = SynchronizationContext.Default ?? new SynchronizationContext();
}
public void SomeMaybeAsyncCalledMethodWithAnEvent()
{
/* Do the work */
RaiseEventInContext();
}
void RaiseEventInContext()
{
//Do all the "normal" work
if(Changed != null)
{
//Use the synchronization context to fire the event in
//the right context
context.Post(_ => Changed(this, EventArgs.Empty), null);
}
}
}
值得注意的是,在 Windows Forms 的情况下,通过实例化新的 Form
,或者在 WPF 的情况下通过 Application
类,SynchronizationContext.Default
会自动设置。还必须记住,此默认上下文是线程绑定的,即如果我们的 API 类已经在某个线程中实例化,则无法获取 GUI 线程的默认同步对象。所以这种方式只在对象从 GUI 线程实例化,但对象的(某些)方法是从另一个线程调用的情况下才有效。
这也会带来 API 必须是线程安全的的问题。如果某些方法不是线程安全的,那么 API 设计就是错误的,或者应该限制从各种线程访问它们。无论哪种情况,我们都可能犯了一些编程或设计错误,这些错误应该得到处理。大多数问题来自依赖于 static
成员(读取和可能写入)的 static
函数调用。
SynchronizationContext
提供了两个方法:Send()
和 Post()
。由于我们与事件处理程序一起使用它,因此我们应始终优先使用 Post()
方法。它是异步的,不会捕获异常。这种“即发即弃”的方法正是我们所需要的。否则,使用 Send()
可能是有意义的。在这里,我们可以捕获方法调用的异常,并且我们可以顺序运行命令。
理解 SynchronizationContext
类不执行所有魔术很重要。相反,它是一个非常有用的特定实现。有时使用我们自己实现的类与 Send()
和 Post()
方法,或者在 SynchronizationContext.Default
为 null
的情况下仅使用我们自己的定义可能是有意义的。有关 SynchronizationContext
的更多信息,请参阅 Leslie Sanford 的这篇文章。
常见错误
人们在使用新关键字时犯的最大错误是使用 async void
。使用 async void
只有一个原因,那就是当有人想在事件处理程序的代码中启用 await
时。由于处理程序具有固定的签名,我们无法在此处更改返回类型 void
。通常这不成问题,因为调用这些方法的唯一方法是内部连接的,并且从等待处理程序调用中获益不大。
让我们看看使用 async void
可能出现的一些问题。让我们看看以下代码:
string m_GetResponse;
async void Button1_Click(object Sender, EventArgs e)
{
try
{
SendData("https://secure.flickr.com/services/oauth/request_token");
await Task.Delay(2000);
DebugPrint("Received Data: " + m_GetResponse);
}
catch (Exception ex)
{
rootPage.NotifyUser("Error posting data to server." + ex.Message);
}
}
async void SendData(string url)
{
var request = WebRequest.Create(url);
using (var response = await request.GetResponseAsync())
using (var stream = new StreamReader(response.GetResponseStream()))
m_GetResponse = stream.ReadToEnd();
}
这里有两个主要问题:
- 由于我们不返回
Task
,我们将无法捕获异常! - 我们没有等待 Web 请求……如果请求需要超过 2 秒怎么办?
用图片来说明会产生以下结果:
这实际上是从 MSDN 上的一个 Windows 应用商店示例中复制的。此后,他们显然被告知了这段有缺陷的代码并进行了替换。解决方案也相当直接:
string m_GetResponse;
async void Button1_Click(object Sender, EventArgs e)
{
try
{
await SendDataAsync("https://secure.flickr.com/services/oauth/request_token");
DebugPrint("Received Data: " + m_GetResponse);
}
catch (Exception ex)
{
rootPage.NotifyUser("Error posting data to server." + ex.Message);
}
}
async Task SendDataAsync(string url)
{
var request = WebRequest.Create(url);
using (var response = await request.GetResponseAsync())
using (var stream = new StreamReader(response.GetResponseStream()))
m_GetResponse = stream.ReadToEnd();
}
StackOverflow 上另一个流行的例子是以下代码:
BitmapImage m_bmp;
protected override async void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
await PlayIntroSoundAsync();
image1.Source = m_bmp;
Canvas.SetLeft(image1, Window.Current.Bounds.Width - m_bmp.PixelWidth);
}
protected override async void LoadState(Object nav, Dictionary<String, Object> pageState)
{
m_bmp = new BitmapImage();
var file = await StorageFile.GetFileFromApplicationUriAsync("ms-appx:///pic.png");
using (var stream = await file.OpenReadAsync())
await m_bmp.SetSourceAsync(stream);
}
这段代码的问题在于,它有时会显示 PixelWidth
和 PixelHeight
均为 0。要看到问题,你需要对基类(不是当前类)了解更多:
class LayoutAwarePage : Page
{
string _pageKey;
protected override void OnNavigatedTo(NavigationEventArgs e)
{
if (this._pageKey != null)
return;
this._pageKey = "Page-" + this.Frame.BackStackDepth;
/* ... */
this.LoadState(e.Parameter, null);
}
}
所以现在这非常明显。代码实际上调用了基类的 OnNavigatedTo()
方法,然后该方法调用当前的 LoadState()
实现。当然,什么也没有等待,所以我们的代码继续等待 PlayIntroSoundAsync()
。现在这是一种竞态条件。如果 PlayIntroSoundAsync()
方法比 LoadState()
方法先完成,我们将看到图像的宽度和高度仍然是 0。否则代码将正常工作。
用图片来说明结果与之前几乎相同:
同样,我们可以构建一个非常简单的解决方案。当然,我们不能触及 Page
类的内部——我们也不应该触及 LayoutAwarePage
类的内部。那么解决方案看起来如下:
Task<BitmapImage> m_bmpTask;
protected override async void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
await PlayIntroSoundAsync();
var bmp = await m_bmpTask;
image1.Source = bmp;
Canvas.SetLeft(image1, Window.Current.Bounds.Width - bmp.PixelWidth);
}
protected override void LoadState(Object nav, Dictionary<String, Object> pageState)
{
m_bmpTask = LoadBitmapAsync();
}
async Task<BitmapImage> LoadBitmapAsync()
{
var bmp = new BitmapImage();
...
return bmp;
}
因此,我们仍然在 LoadState()
方法中启动任务,但我们不依赖于该任务在 OnNavigatedTo()
方法中完成。相反,我们等待该任务的结束(如果该任务已经结束,则无需等待)。因此,再次使用 Task
作为返回类型以优雅的方式解决了这个问题。
也就是说,很明显 async void
是一种“即发即弃”机制。调用者无法知道 async void
何时完成,也无法捕获从中抛出的异常。相反,异常会被发布到 UI 消息循环。因此,我们应该仅将 async void
方法用于顶级事件处理程序(及其类似项),而在其他所有地方返回 async Task
。
为了结束关于 async void
的讨论,我们必须意识到我们将在一些意想不到的地方遇到这种灾难。所以即使我们认为自己做对了所有事情,我们仍然可能会遇到麻烦。让我们考虑以下代码:
try
{
await Dispatcher.RunAsync(CoreDispatcherPriority.Normal, async () =>
{
await LoadAsync();
m_Result = "done";
throw new Exception();
});
}
catch (Exception ex)
{ }
finally
{
DebugPrint(m_Result);
}
看起来一切正常,不是吗?通常,async
lambda 表达式会返回一个任务,然而,lambda 表达式也受限于其建议(或必需)的签名。在这种情况下,签名由 DispatchedHandler
委托给出。然而,该委托返回 void
。因此,我们将无法捕获异常,并且还会出现一种竞态条件,因为 finally 块在 m_Result
变量被设置的同时被进入。
这些指令的交互模型可以用以下图表表示:
因此:注意 lambda 表达式,即验证 lambda 的签名并确保它返回一个 Task
。
另一个常见错误已经在讨论正确 API 设计时提到。考虑以下代码片段:
public async Task<string> ReadUrlAsync(string url)
{
var request = WebRequest.Create(url);
using (var response = await request.GetResponseAsync())
using (var stream = new StreamReader(response.GetResponseStream()))
return stream.ReadToEnd();
}
void ReadUrlIntoTextBox()
{
Output.Text = string.Empty;
ButtonSync.IsEnabled = false;
var task = ReadUrlAsync("http://www.google.com");
task.Wait();
var response = task.Result;
Output.Text = response;
ButtonSync.IsEnabled = true;
}
这里有什么问题?嗯,显然 async
方法的返回类型是没问题的,因为我们返回了一个 Task<string>
。在 ReadUrlIntoTextBox()
方法中,我们通过调用任务的 Wait()
方法来顺序使用此方法。问题就在这里(调用 Result
属性也会导致同样的灾难)。我们到底在做什么?
- 首先,我们只是设置一些 UI 组件。
- 然后,我们调用
ReadUrlAsync()
方法。 - 我们创建一个
WebRequest
实例并启动一个新任务。 - 通过使用
await
,我们将一个回调(延续)放在GUI 线程上。 - 现在我们正在等待任务完成,通过阻塞 GUI 线程。
- 就这样了,因为 GUI 线程现在被阻塞了,并且没有任何延续能够在此运行。
这是一个鸡生蛋还是蛋生鸡的问题。延续(任务完成所需)需要 GUI 线程,而 GUI 线程被阻塞直到任务完成。解决方案已经提供:
public async Task<string> ReadUrlAsync(string url)
{
var request = WebRequest.Create(url);
using (var response = await request.GetResponseAsync().ConfigureAwait(false))
using (var stream = new StreamReader(response.GetResponseStream()))
return stream.ReadToEnd();
}
这样,延续就不需要 GUI 线程了。因此,延续不会被 Wait()
方法阻塞,一切都会按预期工作。这里的问题是——为什么这是默认方式?嗯,显然对于普通程序员(主要消费 API 的人)来说,总是回到 GUI 线程会更方便。因此,这些程序员的工作量要少得多。另一方面,API 开发人员被认为对这类问题更敏感,因此默认选项偏向于 API 消费者程序员。
最后一个常见错误再次在于内存密集型任务和 CPU 密集型任务的区别。如果我们有一些应该在另一个线程中运行的旧代码,或者一些 CPU 密集型任务,那么我们应该使用 Task.Run()
。它将创建一个后台线程,即它将使用一个线程池线程。这是可以的。但如果我们只想启动一个内存密集型操作,直到它完成,那么正确的做法是使用 TaskCompletionSource<T>
。
以下代码显示了一个我们通过打开过多线程来人为地给 CPU 施加压力的场景。这最终会导致内存密集型工作转化为部分 CPU 密集型工作,然后由 GUI 线程处理:
protected override async void LoadState(Object nav, Dictionary<String, Object> pageState)
{
var pictures = new ObservableCollection<House>();
listbox.ItemsSource = pictures;
var folder = await Package.Current.InstalledLocation.GetFolderAsync("pictures");
var files = await folder.GetFilesAsync(CommonFileQuery.OrderByName);
var tasks = from file in files select LoadHouseAsync(file, pictures);
await Task.WhenAll(tasks);
}
让我们首先尝试创建一张描绘情况的图:
代码看起来确实不错,但有一个主要缺点。在实际代码中加载了数千张图片。现在这导致了数千个 await
语句(以及同样多的线程)。这导致了很多开销,UI 在一秒多钟内都没有响应。不太好!解决方案很简单——不要尝试同时执行大量 await
;顺序处理它们。
异步设计模式
多年来,我们一直在等待这一刻:拥有看起来像顺序代码的多线程代码。这一切都可能通过将 async
与 await
结合使用来实现。这可能是说明这是并行代码,而不是看起来像顺序或串行代码的正确地方。有一个巨大的区别。并行代码是通过使用 TPL(或最有效地通过优化线程池自行生成所有工作程序)实现的,并需要不同类型的同步。我们在开头看到了一个非常简单(且无用)的并行代码,即 count
变量的递增。
现在的问题是:我们还能用这种编写多线程代码的新优雅方式做什么?在接下来的子章节中,我将尝试提出一些设计模式,这些模式可以降低复杂性,加强可维护性,并为某些问题提供优雅的解决方案。
异步事件模式
此模式由 Lucian Wischik 引入。有关他的更多详细信息,请参考兴趣点部分。
有时状态机(即使对于非常简单的系统)也会爆炸。它们正在扩展,变得越来越复杂。突然,整个代码看起来像是疯狂,没有人知道大多数方法的目的。问题通常是过度泛化。解决方案相当困难,因为保持代码的某些部分更局部需要更多的类、限制等等。然而,主要问题是,为了保持某事物更局部,不得不使用不同的线程(因为否则 UI 线程会被阻塞)。
现在这个问题很容易解决。我们只需引入如下所示的扩展方法:
public static async Task WhenClicked(this Button button)
{
var tcs = new TaskCompletionSource<bool>();
RoutedEventHandler ev = (sender, evt) => tcs.TrySetResult(true);
try
{
button.Click += ev;
await tcs.Task;
}
finally
{
button.Click -= ev;
}
}
现在我们可以轻松地使用某个(现有的)按钮,一旦被点击,就可以回到我们的(局部)代码:
/* Update UI */
await button.WhenClicked();
/* Update UI */
在此之前,我们将有 2 个方法和一个处理程序来处理按钮。第一个方法将是初始状态,第二个方法将是最终状态,一旦进入初始状态,事件处理程序将从初始状态过渡到最终状态。如果这听起来不复杂,我不知道——对我来说,这听起来太复杂了,尤其是因为(通常)我们会有多个状态,而这 2 个状态(初始、最终)将只是 n 个状态中的 2 个状态(n 通常在这种情况下大于 10)。
现在我们可以将其引入一个方法。这个方法是顶级状态之一。因此,我们有一个很好的分组。
我们还可以使用任务使媒体交互更易于管理。让我们考虑以下代码:
protected async void OnResume(Navigation.NavigationEventArgs e)
{
//Start playing sound
var rustleSoundTask = rustleSound.PlayAsync();
using(var wobbleCancel = new CancellationTokenSource())
{
//Animation
foreach(var apple in freeApples)
AnimateAppleWobbleAsync(apple, wobbleCancel.Token);
//Wait for the sound to finish
await rustleSoundTask;
//And cancel all outstanding tasks
wobbleCancel.Cancel();
}
}
所以我们在这里开始播放声音。通常我们需要某个事件来识别播放结束。现在我们只是使用 await
机制来完成。在声音开始播放时,我们忙于生成更多任务,这些任务将执行一些动画(摇动自由悬挂的苹果)。现在我们所有的媒体都已封装为任务并正在运行,我们将等待声音任务停止。一旦完成,我们将取消动画(足够的摇动)。使所有这些操作同步将是一个很大的挑战,但 async
和 Task
使它变得相当容易。
关于上述代码的一点说明:使用 AnimateAppleWobbleAsync()
而不使用 await
将导致警告。要摆脱警告,您必须对 Task
实例做些什么。通常我们会将实例分配给某个变量,但在这种情况下,这并没有多大意义。因此,使用以下内容可能是有意义的:
public static void FireAndForget(this Task task)
{
}
public static void FireAndForget<T>(this Task<T> task)
{
}
这些扩展方法除了告诉编译器我们实际上对该实例做了些什么之外,什么也不做。对我们来说更重要的是,它们告诉用户,“即发即弃”机制是故意使用的。
此模式的示例代码将包含一个小游戏,其目标是点击随机移动的黄色圆圈。点击红色圆圈将减少圆圈数量。一旦所有圆圈都被点击消失,游戏就结束了。整个游戏基本上归结为一个方法:
async void ButtonClicked(object sender, RoutedEventArgs e)
{
Random r = new Random();
var active = new List<GameEllipse>(ellipses);
var cts = new CancellationTokenSource();
startButton.Visibility = Visibility.Hidden;
ShowEllipses();
Task movingEllipses = MoveEllipses(cts);
var time = DateTime.Now;
movingEllipses.Start();
while (active.Count > 0)
{
int index = r.Next(0, active.Count);
var selected = active[index];
selected.Shape.Fill = Brushes.Red;
await selected.Shape.WhenClicked();
selected.Shape.Fill = Brushes.Yellow;
selected.Shape.Visibility = System.Windows.Visibility.Hidden;
active.RemoveAt(index);
}
var ts = DateTime.Now.Subtract(time);
cts.Cancel();
MessageBox.Show("Your time: " + ts.Seconds + " seconds.");
startButton.Visibility = Visibility.Visible;
}
下图显示了示例的屏幕截图:
我们不关心按钮点击,这就是为什么我们这里只有一个“即发即弃”方法。重要部分在代码中。我们可以使游戏变得更加复杂,并仍然保留一种概览,只需将状态本地化到方法中。这里,仅在球移动时使用的变量被放置在方法中。
MoveEllipses()
方法调用返回一个任务。该任务将连续移动椭圆,直到使用取消令牌。
Task MoveEllipses(CancellationTokenSource cancel)
{
return new Task(() =>
{
while (!cancel.Token.IsCancellationRequested)
{
Dispatcher.Invoke(() =>
{
for (int i = 0; i < ellipses.Count; i++)
{
var ellipse = ellipses[i];
ellipse.UpdatePosition();
Canvas.SetLeft(ellipse.Shape, ellipse.X);
Canvas.SetBottom(ellipse.Shape, ellipse.Y);
}
});
Thread.Sleep(10);
}
}, cancel.Token);
}
我们看到所有椭圆实际上都在移动,即使只有活动的椭圆可见。所以这个实现可以在这方面得到改进。另一方面,我们使用 Dispatcher
在 GUI 线程中移动椭圆。每 10 毫秒执行一次这样的移动。
异步模型绑定
我们都知道并喜爱 MVVM,或者至少是 WPF 的绑定功能。一个可能的缺点是绑定通常必须顺序进行,即没有直接的方法可以告诉绑定引擎在另一个线程中执行绑定,而与控件的交互在 GUI 线程中处理。
然而,我们可能希望将数据源或某些字段绑定到 Web 请求或其他源,这些源可能需要一段时间才能将所需数据传输到我们的客户端。无论如何,我们都不希望应用程序的响应能力因绑定引擎而受损。
异步模型绑定的阶段有很多。本节不会讨论如何创建 async
属性——这是不可能的。绑定这些值会很好,但不要将其视为限制,而应将其视为动机和指导。它应该激励我们使用缓存并通过方法进行更改请求。这些(异步)方法然后应该更改属性的值。
构造函数也是如此。构造函数和属性都应该是轻量级的。绕过异步构造函数有一个简单的方法——那就是工厂方法或遵循此私有构造函数模式的东西:
class MyLazyClass
{
private MyLazyClass()
{
/* some light initializations */
}
async Task DoSomeHeavyWork()
{
/* the long taking initializations */
}
public async static Task<MyLazyClass> Create()
{
var m = new MyLazyClass();
await m.DoSomeHeavyWork();
return m;
}
}
此模式还有其他优点(例如在失败时返回 null
的能力,或在满足特定条件时返回更专门的版本),但本节将更多地关注模型绑定问题。异步工厂和类似助手仅为完整性而提供。
WPF 中 MVVM 的有趣之处在于,在 WPF 中,如果后台线程修改了数据绑定的属性,绑定器本身会将该更改重新编组回 UI。更有趣的是:.NET 4.5 中的新功能是能够使用 BindingOperations.EnableCollectionSynchronization()
方法来更新(添加、更改、删除)数据绑定集合(如 ObservableCollection<T>
)中的值。这样,我们就可以轻松地从另一个线程向集合添加值。
因此,没有集合问题就少了一个问题。然而,大多数情况下,我们会启动新线程或等待某个请求,以响应用户输入,即按下某个按钮或更普遍地调用某个命令。问题是,ICommand
以“即发即弃”的范例(返回 void
)实现了所有内容。
因此,关键是避免依赖于命令生成的代码,并仅使用该命令来触发视图模型中的异步代码。视图模型然后通过更改某个属性来控制执行状态。该属性然后用于命令以确定命令当前是否正在执行。如果正在执行,则我们应跳过执行(即,与该命令关联的按钮应被禁用),否则我们将允许它(即,相应的按钮将被启用)。
让我们看一些依赖于名为 AsyncViewModel
的类的示例代码:
public class FetchCommand : ICommand
{
AsyncViewModel vm;
internal FetchCommand(AsyncViewModel vm)
{
this.vm = vm;
}
internal void Invalidate()
{
CanExecuteChanged(this, EventArgs.Empty);
}
public bool CanExecute(object parameter)
{
return !vm.Fetching;
}
public event EventHandler CanExecuteChanged;
public void Execute(object parameter)
{
vm.DoFetch();
}
}
显然,我们使用 Fetching
属性来查看代码是否已在执行。如果是,则我们无法执行命令。Invalidate()
方法用于由视图模型调用 CanExecuteChanged
事件。当 Fetching
属性更改时应调用它。属性的示例实现很简单,如下面的代码片段所示:
public bool Fetching
{
get { return (bool)GetValue(FetchingProperty); }
set { SetValue(FetchingProperty, value); }
}
public static readonly DependencyProperty FetchingProperty =
DependencyProperty.Register("Fetching", typeof(bool), typeof(AsyncViewModel), new PropertyMetadata(
new PropertyChangedCallback((d, e) =>
{
var vm = (AsyncViewModel)d;
vm.Fetch.Invalidate();
vm.Cancel.Invalidate();
})));
源代码中给出的示例是 Lucian Wischik 从 Netflix 获取电影列表的示例代码的修改版本。
上图是示例应用程序的屏幕截图。
异步管道
使用 Task
的另一个非常有用的选项是当需要连续工作时。一个非常说明性的例子是一个处理图像的队列——处理图像可能需要一段时间,然而,一旦处理完一个图像,就应该处理另一个图像(如果可用),否则什么也不做。这种情况是我们只需将一个对象放入队列中,一旦我们有资源来处理它,它就会被处理。
每个人都可能使用过类似的东西,即 Windows 应用程序中的消息循环。在这里,我们可能会进行一些繁重的数据处理(正如我们所学到的,为了保持 UI 响应,处理不应太繁重),然而,一旦处理完成,我们就会接收到下一个指令。在这种情况下,这些指令可能最终会调用事件处理程序。
队列必须是并发的,否则我们无法同时处理传入的图像和处理当前图像。该模式需要以下要素:
- 一个处理“内核”,即一个执行某些工作的函数。
- 一个调用点,即一个管理队列的方法或构造函数。
- 一个
async
队列处理程序,即实际推送工作的函数。
在源代码中提供的示例中,async
队列处理程序如下所示。我们只是模拟了一个名为 WorkItem
的类的操作,该类只有两个字段。一个名为 WorkTime
的字段承载一个随机生成的数字,该数字基本上告诉我们的队列(模拟)工作应持续多长时间。此外,我们还添加了取消当前进程和整体进程的选项,并编写了一些日志。因此,在这种情况下,内核只是 Delay()
方法,然而,在现实中,它可能是一个复杂得多的东西。
async Task QueueHandler()
{
RaiseLog("Kernel started.");
do
{
WorkItem current = null;
if (!queue.TryDequeue(out current))
break;
cancelCurrent = new CancellationTokenSource();
RaiseLog("Element {0} has been dequeued.", current.Id);
try
{
//Here we actually call the kernel
await Task.Delay(current.WorkTime, cancelCurrent.Token);
RaiseLog("Element {0} has been completely processed.", current.Id);
}
catch (TaskCanceledException ex)
{
Debug.WriteLine(ex.Message);
RaiseLog("Processing element {0} was cancelled.", current.Id);
}
cancelCurrent = null;
}
while (!cancelAll.IsCancellationRequested);
cancelAll = null;
RaiseLog("Kernel stopped.");
}
调用调用点为 Push()
。在这里,我们对新项目进行排队,并在必要时(重新)启动处理程序。
public void Push(WorkItem item)
{
queue.Enqueue(item);
RaiseLog("Item {0} enqueued.", item.Id);
if (cancelAll == null)
{
cancelAll = new CancellationTokenSource();
kernel = QueueKernel();
}
}
那么,async
在这里有什么用处?编写一个不带 await
的连续循环曾是一个真正的痛苦。现在我们可以免费获得异常处理和所有其他功能,而且我们不必担心阻塞我们的 UI。阻塞的部分被封装在 async
方法中,这些方法只需被 await
。我们不关心内部结构,如 while
循环或异常处理,而是纯粹关注队列逻辑。
Promises
我希望我能找到时间很快地包含这个。
兴趣点
这里呈现的一些工作基于 Lucian Wischik 的演讲,他从事 C# 语言设计团队的工作,并深度参与了新关键字的引入。他还启发了我撰写异步模式部分。他的博客可以在 blogs.msdn.com/b/lucian/ 上找到。
WinRT API 包含大量标记为 async
的方法。其中一些不提供顺序版本。因此,重要的是要理解,async
/ await
是两个非常重要的关键字,它们使得任何程序员都不可能将多线程代码实现不佳归咎于编程语言。十多年前,任何程序员都应该强制要求编写多线程代码(慢速磁盘访问、更慢的网络访问时间……),但如今,随着超响应式应用程序和比以往任何时候都更多的网络访问,这似乎是唯一正确的事情。
async
/ await
的最低要求已设置为 .NET Framework 4.5 和 C# 5。然而,可以在没有 Visual Studio 2012 的开发计算机上安装 C# 5。Async CTP 为所有安装了 Visual Studio 2010 的计算机提供了 C# 5 的预览版本,该版本几乎与发布版本相同。
最后,框架要求(这是一个更大的问题,因为它不仅是编译所必需的,也是应用程序运行所必需的)也已从 .NET Framework 4.5 降低到 .NET Framework 4。可以通过使用 **AsyncTargetingPack**(参见 NuGet,Microsoft.CompilerServices.AsyncTargetingPack)在 .NET 4 中访问 async
/ await
功能。但是,要求安装 Visual Studio 2010 (VS11)。
结论
自 async
/ await
引入以来,使用异步方法引起了极大的兴趣和势头。许多优秀的 API、应用程序和助手都是使用新关键字编写的。大多数已看到的可以通过不使用 async void
并绘制程序流程图来解决。意识到(即使使用 await
关键字,代码看起来也是顺序的)程序流程不再是顺序的,将有助于检测大多数问题。
有几个理由使用新关键字和多线程,但有两个理由比其他理由更重要:
- 通过保持局部状态局部来降低复杂性的能力。
- 拥有超响应式 UI 的强大功能。
因此,不仅有充分的理由利用新功能来创建新模式、提高 UI 性能并获得更多选项(如停止运行的任务),而且还可以在设计 API 时考虑异步操作。
历史
- v1.0.0 | 初始发布 | 2013年3月14日
- v1.0.1 | 一些小澄清和更正 | 2013年3月15日
- v1.1.0 | 拼写错误、源代码更新和异步管道 | 2013年3月28日
- v1.1.1 | 修正了一个小拼写错误 | 2013年3月28日