温故之.NET 异步

这篇文章包含以下内容

  • 异步基础
  • 基于任务的异步模式
  • 部分 API 介绍

异步基础

所谓异步,对于计算密集型的任务,是以线程为基础的,而在具体使用中,使用线程池里面的线程还是新建独立线程,取决于具体的任务量;对于 I/O 密集型任务的异步,是以 Windows 事件为基础的

.NET 提供了执行异步操作的三种方式:

  • 异步编程模型 (APM) 模式(也称 IAsyncResult 模式):在此模式中异步操作需要 BeginEnd 方法(比如用于异步写入操作的 BeginWriteEndWrite)。不建议新的开发使用此模式
  • 基于事件的异步模式 (EAP):这种模式需要一个或多个事件、事件处理程序委托类型和 EventArg 派生类型,以便在工作完成时触发。不建议新的开发使用这种模式
  • 基于任务的异步模式 (TAP):它是在 .NET 4 中引入的。C# 中的 asyncawait 关键字为 TAP 提供了语言支持。这是推荐使用方法

由于异步编程模型 (APM) 模式与基于事件的异步模式 (EAP)在新的开发中已经不推荐使用。故在此处我们就不介绍了,以下仅介绍基于任务的异步模式(TAP

基于任务的异步模式(TAP)

任务是工作的异步抽象,而不是线程的抽象。即当一个方法返回了 TaskTask<T>,我们不应该认为它一定创建了一个线程,而是开始了一个任务。这对于我们理解 TAP 是非常重要的。

TAPTaskTask<T> 为基础。它把具体的任务抽象成了统一的使用方式。这样,不论是计算密集型任务,还是 I/O 密集型任务,我们都可以使用 async 、await 关键字来构建更加简洁易懂的代码

任务分为 计算密集型任务I/O密集型任务任务两种

  • 计算密集型任务:当我们 await 一个操作时,该操作会通过 Task.Run 方法启动一个线程来处理相关的工作
    工作量大的任务,通过为 Task.Factory.StartNew 指定 TaskCreateOptions.LongRunning选项 可以使新的任务运行于独立的线程上,而非使用线程池里面的线程
  • I/O 密集型任务:当我们 await 一个操作时,它将返回 一个 TaskTask<T>
    值得注意的是,这儿并不会启动一个线程

虽然计算密集型任务和 I/O 密集型任务在使用方式上没有多大的区别,但其底层实现却大不相同。

那我们如何区分 I/O 密集型任务和计算密集型任务呢?

比如网络操作,需要从服务器下载我们所需的资源,它就是属于 I/O 密集型的操作;比如我们通过排序算法对一个数组排序时,这时的任务就是计算密集型任务。

简而言之,判断一个任务是计算型还是 I/O 型,就看它占用的 CPU 资源多,还是 I/O 资源多就可以了。

对于I/O密集型的应用,它们是以 Windows 事件为基础的,因此不需要新建一个线程或使用线程池里面的线程来执行具体工作。但我们仍然可以使用 asyncawait 来进行异步处理,这得益于 .Net 为我们提供了一个统一的使用方式: TaskTask<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();
});

异步方法返回 TaskTask<TResult>,具体取决于相应方法返回的是 void 还是类型 TResult。如果返回的是 void,则使用 Task,如果是 TResult,则使用 Task<TResult>

不应该使用 outref 的方式来返回值,因为这可能产生意料之外的结果。因此,我们应该尽可能的使用 Task<TResult> 中的 TResult 来组合多个返回值

另外,await不能用在返回值为 void 的方法上,否则会有编译错误

针对 TAP 的编码建议

  • asyncawait 应该搭配使用。即它们要么都出现,要么都不出现
  • 仅在异步方法(即被 async 修饰的方法)中使用 await。否则会有编译器错误
  • 如果一个方法内部,没有使用 await,则该方法不应该使用 async 来修饰,否则会有编译器警告
  • 如果一个方法为异步方法(被 async 修饰),则它应该以 Async 结尾
  • 我们应该使用非阻塞的方式来编写等待任务结果的代码:
    使用 awaitawait Task.WhenAnyawait Task.WhenAllawait Task.Delay 去等待后台任务的结果。
    而不是 Task.WaitTask.ResultTask.WaitAnyTask.WaitAllThread.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.WhenAnyTask.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 的灵活运用,可以组合出更多方便的方法出来。在具体项目中多多使用即可

关于 Task 的一些基本的用法就介绍到这儿了


至此,本节内容讲解完毕。下一篇文章我们将讲解 .NET 中的并行编程。欢迎关注公众号【嘿嘿的学习日记】,所有的文章,都会在公众号首发,Thank you~

公众号二维码.jpg

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容