异步基础
所谓异步,对于计算密集型的任务,是以线程为基础的,而在具体使用中,使用线程池里面的线程还是新建独立线程,取决于具体的任务量;对于 I/O
密集型任务的异步,是以 Windows
事件为基础的
.NET
提供了执行异步操作的三种方式:
- 异步编程模型 (
APM
) 模式(也称IAsyncResult
模式):在此模式中异步操作需要Begin
和End
方法(比如用于异步写入操作的BeginWrite
和EndWrite
)。不建议新的开发使用此模式 - 基于事件的异步模式 (
EAP
):这种模式需要一个或多个事件、事件处理程序委托类型和EventArg
派生类型,以便在工作完成时触发。不建议新的开发使用这种模式 - 基于任务的异步模式 (
TAP
):它是在.NET 4
中引入的。C#
中的async
和await
关键字为TAP
提供了语言支持。这是推荐使用方法
由于异步编程模型 (APM
) 模式与基于事件的异步模式 (EAP
)在新的开发中已经不推荐使用。故在此处我们就不介绍了,以下仅介绍基于任务的异步模式(TAP
)
基于任务的异步模式(TAP)
任务是工作的异步抽象,而不是线程的抽象。即当一个方法返回了 Task
或 Task<T>
,我们不应该认为它一定创建了一个线程,而是开始了一个任务。这对于我们理解 TAP
是非常重要的。
TAP
以 Task
和 Task<T>
为基础。它把具体的任务抽象成了统一的使用方式。这样,不论是计算密集型任务,还是 I/O
密集型任务,我们都可以使用 async
、await
关键字来构建更加简洁易懂的代码
任务分为 计算密集型任务和 I/O密集型任务任务两种
- 计算密集型任务:当我们
await
一个操作时,该操作会通过Task.Run
方法启动一个线程来处理相关的工作
工作量大的任务,通过为Task.Factory.StartNew
指定TaskCreateOptions.LongRunning
选项 可以使新的任务运行于独立的线程上,而非使用线程池里面的线程 - I/O 密集型任务:当我们
await
一个操作时,它将返回 一个Task
或Task
。
值得注意的是,这儿并不会启动一个线程
虽然计算密集型任务和 I/O
密集型任务在使用方式上没有多大的区别,但其底层实现却大不相同。
那我们如何区分 I/O
密集型任务和计算密集型任务呢?
比如网络操作,需要从服务器下载我们所需的资源,它就是属于 I/O
密集型的操作;比如我们通过排序算法对一个数组排序时,这时的任务就是计算密集型任务。
简而言之,判断一个任务是计算型还是 I/O
型,就看它占用的 CPU
资源多,还是 I/O
资源多就可以了。
对于I/O
密集型的应用,它们是以 Windows
事件为基础的,因此不需要新建一个线程或使用线程池里面的线程来执行具体工作。但我们仍然可以使用 async
、await
来进行异步处理,这得益于 .Net 为我们提供了一个统一的使用方式: Task
或 Task<T>
举个例子,对于 I/O
密集型任务,使用方式如下
// 这是在 .NET 4.5 及以后推荐的网络请求方式 HttpClient httpClient = new HttpClient(); var result = await httpClient.GetStringAsync("https://www.baidu.com"); // 而不是以下这种方式(虽然得到的结果相同,但性能却不一样,并且在.NET 4.5及以后都不推荐使用) WebClient webClient = new WebClient(); var resultStr = Task.Run(() => { return webClient.DownloadString("https://www.baidu.com"); });
对于计算密集型应用,使用方式如下
Random random = new Random(); List<int> data = new List<int>(); for (int i = 0; i< 50000000; i++) { data.Add(random.Next(0, 100000)); } // 这儿会启动一个线程,来执行排序这种计算型任务 await Task.Run(() => { data.Sort(); });
异步方法返回 Task
或 Task<TResult>
,具体取决于相应方法返回的是 void
还是类型 TResult
。如果返回的是 void
,则使用 Task
,如果是 TResult
,则使用 Task<TResult>
不应该使用 out
或 ref
的方式来返回值,因为这可能产生意料之外的结果。因此,我们应该尽可能的使用 Task<TResult>
中的 TResult
来组合多个返回值
另外,await不能用在返回值为 void 的方法上,否则会有编译错误
针对 TAP
的编码建
针对 TAP
的编码建议
async
与await
应该搭配使用。即它们要么都出现,要么都不出现- 仅在异步方法(即被
async
修饰的方法)中使用await
。否则会有编译器错误 - 如果一个方法内部,没有使用
await
,则该方法不应该使用async
来修饰,否则会有编译器警告 - 如果一个方法为异步方法(被
async
修饰),则它应该以Async
结尾 - 我们应该使用非阻塞的方式来编写等待任务结果的代码:
使用await
、await Task.WhenAny
、await Task.WhenAll
、await Task.Delay
去等待后台任务的结果。
而不是Task.Wait
、Task.Result
、Task.WaitAny
、Task.WaitAll
、Thread.Sleep
,因为这些方式会阻塞当前线程。
即如果需要等待或暂停,我们应该使用.NET 4.5
提供的await
关键字,而不是使用.NET 4.5
之前的版本提供的方式 - 如果是计算密集型任务,则应该使用
Task.Run
来执行任务;如果是耗时比较长的任务,则应该使用Task.Factory.StartNew
并指定TaskCreateOptions.LongRunning
选项来执行任务 - 如果是
I/O
密集型任务,不应该使用Task.Run
。
因为Task.Run
会在一个单独的线程中运行(线程池或者新建一个独立线程),而对于I/O
任务来说,启用一个线程意义不大,反而会浪费线程资源
创建任务
要创建一个计算密集型任务,在 .NET 4.5
及以后,可采用 Task.Run
的方式来快速创建;如果需要对任务有更多的控制权,则可以使用 .NET 4.0
提供的 Task.Factory.StartNew
来创建一个任务。
对于 I/O
密集型任务,我们可以通过将 await
作用于对应的 I/O
操作方法上即可
取消任务
在 TAP
中,任务是可以取消的。通过 CancellationTokenSource
来管理。需要支持取消的任务,必须持有 CancellationTokenSource.Token
(令牌),以便该任务可以通过 CancellationTokenSource.Cancel()
的方式来取消。
使用 CancellationTokenSource
来取消任务,有以下优点
- 可以将令牌传递给多个任务,这样可以同时取消多个任务。类似于一个老师,可以管理多个学生。
- 可以通过
CancellationTokenSource.Token.Register
来监听任务的取消。这样我们可以在任务取消之后做一些其他的工作
任务处理进度
我们可以通过 IProgress<T>
接口监听进度,如下所示
public Task ReadAsync(byte[] buffer, int offset, int count, IProgress<long> progress)
在 .NET 4.5
提供单个 IProgress<T>
实现:Progress<T>
。Progress<T>
类的声明方式如下:
// Progress<T> 类的声明 public class Progress<T> : IProgress<T> { public Progress(); public Progress(Action<T> handler); protected virtual void OnReport(T value); public event EventHandler<T> ProgressChanged; }
举个例子,假设我们需要获取并显示下载进度,则可以按以下方式书写
private async void btnDownload_Click(object sender, RoutedEventArgs e) { btnDownload.IsEnabled = false; try { txtResult.Text = await DownloadStringAsync(txtUrl.Text, new Progress<int>(p => pbDownloadProgress.Value = p)); } finally { btnDownload.IsEnabled = true; } }
部分 API 介绍
Task.WhenAll
此方法可以帮助我们同时等待多个任务,所有任务结束(正常结束、异常结束)后返回
这里需要注意的是,如果单个任务有异常产生,这些异常会合并到 AggregateException
中。我们可以通过 AggregateException.InnerExceptions
来得到异常列表;也可以使用 AggregateException.Handle
来对每个异常进行处理,示例代码如下
public static async void EmailAsync() { List<string> addrs = new List<string>(); IEnumerable<Task> asyncOps = addrs.Select(addr => SendMailAsync(addr)); try { await Task.WhenAll(asyncOps); } catch (AggregateException ex) { // 可以通过 InnerExceptions 来得到内部返回的异常 var exceptions = ex.InnerExceptions; // 也可以使用 Handle 对每个异常进行处理 ex.Handle(innerEx => { // 此处的演示仅仅为了说明 ex.Handle 可以对异常进行单独处理 // 实际项目中不一定会抛出此异常 if (innerEx is OperationCanceledException oce) { // 对 OperationCanceledException 进行单独的处理 return true; } else if (innerEx is UnauthorizedAccessException uae) { // 对 UnauthorizedAccessException 进行单独处理 return true; } return false; }); } }
但,如果我们需要对每个任务进行更加详细的管理,则可以使用以下方式来处理
public static async void EmailAsync() { List<string> addrs = new List<string>(); IEnumerable<Task> asyncOps = addrs.Select(addr => SendMailAsync(addr)); try { await Task.WhenAll(asyncOps); } catch (AggregateException ex) { // 此处可以针对每个任务进行更加具体的管理 foreach (Task<string> task in asyncOps) { if (task.IsCanceled) { }else if (task.IsFaulted) { }else if (task.IsCompleted) { } } } }
这样,就应该基本上足够应对我们工作中的大部分的异常处理了
Task.WhenAny
与 Task.WhenAll
不同,Task.WhenAny
返回的是已完成的任务(可能只是所有任务中的几个任务)
举个例子,比如我们开发了一个图片类App。我们可能需要在打开这个页面时,同时下载并展示多张图片。但我们希望无论是哪一张图片,只要下载完成,就展示出来,而不是所有的图片都下载完了之后再展示。示例代码如下
List<Task<Bitmap>> imageTasks = urls.Select(imgUrl => GetBitmapAsync(imgUrl)).ToList(); // 如果我们需要对图片做一些处理(比如灰度化),可以使用以下代码 // List<Task<Bitmap>> imageTasks = urls.Select(imgUrl => GetBitmapAsync(imgUrl).ContinueWith(task => ConvertToGray(task.Result)).ToList(); while(imageTasks.Count > 0) { try { Task<Bitmap> imageTask = await Task.WhenAny(imageTasks); // 移除已经下载完成的任务 imageTasks.Remove(imageTask); // 同时将该任务的图片,在UI上呈现出来 Bitmap image = await imageTask; panel.AddImage(image); } catch{} }
Task.Delay
此方法用于暂停当前任务的执行,在指定时间之后继续运行。
它可以与 Task.WhenAny
和 Task.WhenAll
结合,实现任务的超时,如下
public async void btnDownload_Click(object sender, EventArgs e) { btnDownload.Enabled = false; try { Task<Bitmap> download = GetBitmapAsync(url); // 以下的这行代码表示,如果在 3s 之内没有下载完成,则认为超时 if (download == await Task.WhenAny(download, Task.Delay(3000))) { Bitmap bmp = await download; pictureBox.Image = bmp; status.Text = "Downloaded"; } else { pictureBox.Image = null; status.Text = "Timed out"; var ignored = download.ContinueWith(t => Trace("Task finally completed")); } } finally { btnDownload.Enabled = true; } }
通过这种方式,也可以监听使用 Task.WhenAll
时多个任务的超时,如下
Task<Bitmap[]> downloads = Task.WhenAll(from url in urls select GetBitmapAsync(url)); if (downloads == await Task.WhenAny(downloads, Task.Delay(3000))) { foreach(var bmp in downloads) panel.AddImage(bmp); status.Text = "Downloaded"; } else { status.Text = "Timed out"; downloads.ContinueWith(t => Log(t)); }
另外,提供两个有用的函数,以方便我们在项目中使用
RetryOnFail
定义如下所示
// 如果下载资源失败后,我们希望重新下载时可以使用此方法 // 我们可以指定失败之后,间隔多长时间才重试。 // 也可以将 retryWhen 指定为 null,以便在失败之后立即重试 public static async Task<T> RetryOnFail<T>(Func<Task<T>> function, int maxTries, Func<Task> retryWhen) { for (int i = 0; i < maxTries; i++) { try { return await function().ConfigureAwait(false); } catch { if (i == maxTries - 1) throw; } if (retryWhen != null) await retryWhen().ConfigureAwait(false); } return default(T); }
使用方式如下,这在失败之后,暂停 1s,然后再重试
string pageContents = await RetryOnFail(() => DownloadStringAsync(url), 3, () => Task.Delay(1000));
或者如下,这将在失败之后立即重试
string pageContents = await RetryOnFail(() => DownloadStringAsync(url), 3, null);
NeedOnlyOne
定义如下
public static async Task<T> NeedOnlyOne<T>(params Func<CancellationToken, Task<T>>[] functions) { var cts = new CancellationTokenSource(); var tasks = functions.Select(func => func(cts.Token)); var completed = await Task.WhenAny(tasks).ConfigureAwait(false); cts.Cancel(); foreach (var task in tasks) { var ignored = task.ContinueWith(t => Trace.WriteLine(t), TaskContinuationOptions.OnlyOnFaulted); } return await completed; }
对于前面我们提到的下载电影的例子:获取到速度最快的渠道之后,立即取消其他的任务。现在我们可以这样做
var line = await NeedOnlyOne( token => DetectSpeedAsync("line_1", movieName, cts.Token), token => DetectSpeedAsync("line_2", movieName, cts.Token), token => DetectSpeedAsync("line_3", movieName, cts.Token) );
以上提供的这两个方法,在实际项目中会非常有用,在需要时可以将它们用起来。当然,通过对 Task
的灵活运用,可以组合出更多方便的方法出来。在具体项目中多多使用即可