Task 使用详细[基础操作,异步原则,异步函数,异步模式]

线程是创建并发的底层工具,对于开发者而言,想实现细粒度并发具有一定的局限性,比如将小的并发组合成大的并发,还有性能方面的影响。

Task可以很好的解决这些问题,Task是一个更高级的抽象概念,代表一个并发操作,但不一定依赖线程完成。

Task从Framework4.0开始引入,Framework4.5又添加了一些功能,比如Task.Run(),async/await关键字等,

在.NET Framework4.5之后,基于任务的异步处理已经成为主流模式, (Task-based Asynchronous Pattern,TAP)基于任务的异步模式。

在使用异步函数之前,先看下Task的基本操作。

一. Task 基本操作

1.1 Task 启动方式

Task.Run(()=>Console.WriteLine("Hello Task"));
Task.Factory.StartNew(()=>Console.WriteLine("Hello Task"));

Task.Run是Task.Factory.StartNew的快捷方式。

启动的都是后台线程,并且默认都是线程池的线程

Task.Run(() =>
{
    Console.WriteLine(
        $"TaskRun IsBackGround:{CurrentThread.IsBackground}, IsThreadPool:{CurrentThread.IsThreadPoolThread}");
});

Task.Factory.StartNew(() =>
{
    Console.WriteLine(
        $"TaskFactoryStartNew IsBackGround:{CurrentThread.IsBackground}, IsThreadPool:{CurrentThread.IsThreadPoolThread}");
});

如果Task是长任务,可以添加TaskCreationOptions.LongRunning参数,使任务不运行在线程池上,有利于提升性能。

Task.Factory.StartNew(() =>
{
    Console.WriteLine(
        $"TaskFactoryStartNew IsBackGround:{CurrentThread.IsBackground}, IsThreadPool:{CurrentThread.IsThreadPoolThread}");
}, TaskCreationOptions.LongRunning);

 1.2 Task 返回值/带参数

Task 有一个泛型子类Task<TResult>,允许返回一个值。

Task<string> task =Task.Run(()=>SayHello("Jack"));

string SayHello(string name)
{
    return "Hello " + name;
}

Console.WriteLine(task.Result);

通过任务的Result属性获取返回值,这是会堵塞线程,尤其是在桌面客户端程序中,谨慎使用Task.Result,容易导致死锁!

同时带参数的方式也不是很合理,后面可以被async/await方式直接替代。

1.3 Task 异常/异常处理

当任务中的代码抛出一个未处理异常时,调用任务的Wait()或者Result属性时,异常会被重新抛出。

var task = Task.Run(ThrowError);
try
{
    task.Wait();
}
catch(AggregateException ex)
{
    Console.WriteLine(ex.InnerException is NullReferenceException ? "Null Error!" : "Other Error");
}


void ThrowError()
{
    throw new NullReferenceException();
}

对于自治任务(没有wait()和Result或者是延续的任务),使用静态事件TaskScheduler.UnobservedTaskException可以在全局范围订阅未观测的异常。

以便记录错误日志

1.4 Task 延续

延续通常由一个回调方法实现,该方法会在任务完成之后执行,延续方法有两种

(1)调用任务的GetAwaiter方法,将返回一个awaiter对象。这个对象的OnCompleted方法告知任务当执行完毕或者出错时调用一个委托。

Task<string> learnTask = Task.Run(Learn);
var awaiter = learnTask.GetAwaiter();
awaiter.OnCompleted(() =>
{
    var result = awaiter.GetResult();
    Console.WriteLine(result);
});

string Learn()
{
    Console.WriteLine("Learn Method Executing");
    Thread.Sleep(1000);
    return "Learn End";
}

如果learnTask任务出现错误,延续代码awaiter.GetResult()将重新抛出异常,其中GetResult可以直接得到原始的异常,如果使用Result属性,只能解析AggergateException.

这种延续方法更适用于富客户端程序,延续可以提交到同步上下文,延续回到UI线程中。

当编写库文件,可以使用ConfigureAwait方法,延续代码会运行在任务运行的线程上,从而避免不必要的切换开销。

var awaiter =learnTask.ConfigureAwait(false).GetAwaiter(); 

(2)另一种方法使用ContiuneWith

Task<string> learnTask = Task.Run(Learn);
learnTask.ContinueWith(antecedent =>
{
    var result = learnTask.Result;
    Console.WriteLine(result);
});

string Learn()
{
    Console.WriteLine("Learn Method Executing");
    Thread.Sleep(1000);
    return "Learn End";
}

当任务出现错误时,必须处理AggregateException, ContiuneWith更适合并行编程场景。

1.5 TaskCompletionSource类使用

从如下源码中可以看出当实例化TaskCompletionSource时,构造函数会新建一个Task任务。

public class TaskCompletionSource
{
  private readonly Task _task;
  
  /// <summary>Creates a <see cref="TaskCompletionSource"/>.</summary>
  public TaskCompletionSource() => _task = new Task();
  
  /// <summary>
        /// Gets the <see cref="Tasks.Task"/> created
        /// by this <see cref="TaskCompletionSource"/>.
        /// </summary>
        /// <remarks>
        /// This property enables a consumer access to the <see cref="Task"/> that is controlled by this instance.
        /// The <see cref="SetResult"/>, <see cref="SetException(Exception)"/>, <see cref="SetException(IEnumerable{Exception})"/>,
        /// and <see cref="SetCanceled"/> methods (and their "Try" variants) on this instance all result in the relevant state
        /// transitions on this underlying Task.
        /// </remarks>
  public Task Task => _task;
}

它的真正的作用是创建一个不绑定线程的任务。

eg: 可以使用Timer类,CLR在定时之后触发一个事件,而无需使用线程。

实现通用Delay方法:

Delay(5000).GetAwaiter().OnCompleted(()=>{ Console.WriteLine("Delay End"); });

Task Delay(int millisecond)
{
    var tcs = new TaskCompletionSource<object>();
    var timer = new System.Timers.Timer(millisecond) { AutoReset = false };
    timer.Elapsed += delegate
    {
        timer.Dispose();
        tcs.SetResult(null);
    };
    timer.Start();
    return tcs.Task;
}

这个方法类似Task.Delay()方法。

二. 异步原则(补充)

同步操作:先完成其工作再返回调用者

异步操作:大部分工作则是在返回调用者之后才完成的,也称非阻塞方法。

异步编程的原则:

(1)以异步的方式编写运行时间很长(或者可能很长)的函数,会在一个新的线程或者任务上调用这些函数,从而实现需要的并发性。

(2)异步方法的并发性是在长时间运行的方法内启动的,而不是从这个方法外启动的。

  • I/O密集的并发性的实现不需要绑定线程(如1.5节的例子所示),因此可以提高可伸缩性和效率。
  • 富客户端应用程序可以减少工作线程的代码,因此可以简化线程安全性的实现。

Task支持延续,因此非常适合进行异步编程的,如1.5节的Delay方法。

在计算密集的方法中,我们使用Task.Run创建线程相关的异步性。但是异步编程的不同点在于,更希望将异步放在底层调用图上,

因此富客户端应用程序的高层方法就可以一直在UI线程上运行,访问控件、共享状态而不用担心会出现线程安全问题。

看Task.Run的例子:

//粗粒度并发
Task.Run(() => DisplayPrimeCounts());

/// <summary>
/// 显示素数个数
/// </summary>
void DisplayPrimeCounts()
{
    for (int i = 0; i < 10; i++)
        Console.WriteLine(GetPrimesCount(i * 1000000 + 2, 1000000) +
                          " primes between " + (i * 1000000) + " and " + ((i + 1) * 1000000 - 1));

    Console.WriteLine("Done!");
}

/// <summary>
/// 获取素数个数
/// </summary>
int GetPrimesCount(int start, int count)
{
    return
        ParallelEnumerable.Range(start, count).Count(n =>
            Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0));
}

这是一种粗粒度并发,如果想实现细粒度并发,需要编写异步的方法。

看异步版本:

DisplayPrimeCountsAsync();

Task DisplayPrimeCountsAsync()
{
    var machine = new PrimesStateMachine();
    machine.DisplayPrimeCountsFrom(0);
    return machine.Task;
}

class PrimesStateMachine        
{
    TaskCompletionSource<object> _tcs = new TaskCompletionSource<object>();
    public Task Task { get { return _tcs.Task; } }

    /// <summary>
    /// 异步显示素数个数
    /// </summary>
    /// <param name="i"></param>
    public void DisplayPrimeCountsFrom(int i)
    {
        var awaiter = GetPrimesCountAsync(i * 1000000 + 2, 1000000).GetAwaiter();
        awaiter.OnCompleted(() =>
        {
            Console.WriteLine(awaiter.GetResult()+" primes between " + (i * 1000000) + " and " + ((i + 1) * 1000000 - 1));
            if (i++ < 10) DisplayPrimeCountsFrom(i);
            else { Console.WriteLine("Done"); _tcs.SetResult(null); }
        });
    }

    /// <summary>
    /// 异步获取素数个数
    /// </summary>
    /// <param name="start"></param>
    /// <param name="count"></param>
    /// <returns></returns>
    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改为方法内部启动异步,DisplayPrimeCountsFrom通过TaskCompletionSource实现异步。

这时async和await登场!

async和await关键字极大的简化了程序的复杂度。

async/await版本:

DisplayPrimeCountsAsync();

/// <summary>
/// 异步显示素数个数
/// </summary>
async Task DisplayPrimeCountsAsync()
{
    for (int i = 0; i < 10; i++)
        Console.WriteLine(await GetPrimesCountAsync(i * 1000000 + 2, 1000000) +
                          " primes between " + (i * 1000000) + " and " + ((i + 1) * 1000000 - 1));

    Console.WriteLine("Done!");
}

/// <summary>
/// 异步获取素数个数
/// </summary>
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)));
}

从编程形式上,有点类似同步方法一样直观简洁。其实async/await编译器也是将其转换为一个状态机。通常我们称之为C#语法糖。

 编译器背后的原理可以参考这篇文章:https://www.cnblogs.com/zh7791/p/9951478.html

三. 异步函数

这章开始进入异步函数的使用,由上面一章已经引出async/await关键字。可以使用同步的代码风格编写异步代码,极大地降低了异步编程的复杂度。

简单捋下async/await

如下语句中使用了await附加了延续,statement(s)是expression的延续。

这个“等待”被编译器转化为如下同等功能的代码。

 

 这是如果想要成功编译就必须添加async修饰符,如下图提示。

async修饰符会指示编译器将await作为一个关键字而非标识符,来避免二义性(C#5之前有可能作为标识符使用),添加async修饰符的方法称为异步函数。

3.1 富客户端异步函数Demo

通过WPF的例子展示异步函数在富客户端应用程序中的作用:在执行计算密集的方法时,仍然保持UI的响应,不堵塞UI线程。

 先看同步调用的情况:

private void ExecuteTaskOnClick(object sender, RoutedEventArgs e)
{
    TextBoxMessage.Text = "Call Worker" + Environment.NewLine;
    DoSomething();//同步调用
}

private void DoSomething()
{
    Thread.Sleep(3000);//模拟计算密集耗时
    TextBoxMessage.Text += "Calculate Done" + Environment.NewLine;
}

上图可以清楚的看到,当使用同步调用耗时方法时,UI线程无法响应用户事件请求,TextBox的信息显示也是等耗时方法结束后才更新。

原因是在耗时方法执行期间,UI线程已经被阻塞,UI线程接收的处理请求都会进入请求队列,无法及时响应(包括鼠标键盘的事件请求,控件更新),很影响用户体验。

下面看异步版本:

btnExecuteTaskAsync.Click += (sender, args) => ExecuteTaskAsync();

private async void ExecuteTaskAsync()
{
    btnExecuteTaskAsync.IsEnabled = false;
    TextBoxMessage.Text = "Call Worker Async" + Environment.NewLine;
    await DoSomethingAsync();//异步调用
    TextBoxMessage.Text += "Calculate Async Done" + Environment.NewLine;
    btnExecuteTaskAsync.IsEnabled = true;
}

private async Task DoSomethingAsync()
{
    await Task.Run(() =>
    {
        Thread.Sleep(3000); //模拟计算密集耗时
    });
}

更改为异步版本后,在执行耗时任务时,UI线程没有被堵塞,可以正常响应用户事件和控件更新,提高了用户体验。

3.2 异步调用执行过程

根据3.1节的例子,整个调用过程如下:

当用户点击按钮时触发事件,事件调用ExecuteTaskAsync 方法,ExecuteTaskAsync 方法调用DoSomethingAsync方法,而后调用await,而await会使执行点返回给调用者,

当DoSomethingAsync方法完成(或者出现错误)时,执行点会从停止之处恢复执行DoSomethingAsync后面的代码。

ExecuteTaskAsync 方法则会'租用'UI线程的时间,即ExecuteTaskAsync 方法在消息循环1中是以伪并发的方式执行的(执行会在UI线程的其他事件处理中穿插进行)。

在整个伪并发的过程中,只有await的过程中才会进行抢占,这就简化了线程的安全性。DoSomethingAsync会运行在工作线程上,正真的并发发生在DoSomethingAsync方法的Task.Run部分,在Task.Run部分尽量避免访问共享状态和UI组件。

本小节结尾完善一下上面的例子代码:

btnExecuteTaskAsync.Click += (sender, args) => ExecuteTaskAsync();

private async void ExecuteTaskAsync()
{
    try
    {
        btnExecuteTaskAsync.IsEnabled = false;
        TextBoxMessage.Text = "Calculate Async Start" + Environment.NewLine;
        TextBoxMessage.Text += await DoSomethingAsync(); //异步调用
        btnExecuteTaskAsync.IsEnabled = true;
    }
    catch (Exception e)
    {
        TextBoxMessage.Text += $"Error: {e.Message}" + Environment.NewLine;
    }
    finally
    {
        btnExecuteTaskAsync.IsEnabled = true;
    }
}

private async Task<string> DoSomethingAsync()
{
    await Task.Delay(3000); //模拟计算密集耗时
    return "Calculate Async Done";
} 

 增加了ExecuteTaskAsync方法的异常处理,给DoSomethingAsync方法添加了返回值Task<TResult>

还有一些关于优化方面的内容,简单提一下:

同步完成:执行过程在await之前就返回给调用者,同时这个方法会返回一个已经结束的任务。编译器会在同步完成的情况下跳过延续代码,会awaiter的IsCompleted属性来实现这种优化。

避免大量回弹: 对于一个在循环中多次调用的异步方法,通过调用ConfigureAwait方法可以避免该方法重复回弹到UI消息循环中。

它会阻止任务将延续提交到同步上下文中,将开销降低到了上下文切换的级别,该优化比较适合编写程序库。

四. 异步模式

 4.1 取消操作

在并发操作启动之后,需要能够取消任务,看如下示例:

private CancellationTokenSource? cts;
btnExecuteTaskAsync.Click += (sender, args) => ExecuteTaskAsync();
btnCancel.Click += (sender, args) => ExecuteCancelTask();

private async void ExecuteTaskAsync()
{
    cts = new CancellationTokenSource();
    try
    {
        btnExecuteTaskAsync.IsEnabled = false;
        TextBoxMessage.Text = "Calculate Async Start" + Environment.NewLine;
        TextBoxMessage.Text += await DoSomethingAsync(cts.Token); //异步调用
        btnExecuteTaskAsync.IsEnabled = true;
    }
    catch (OperationCanceledException)
    {
        TextBoxMessage.Text += "任务已经取消!" + Environment.NewLine;
    }
    catch (Exception e)
    {
        TextBoxMessage.Text += $"Error: {e.Message}" + Environment.NewLine;
    }
    finally
    {
        btnExecuteTaskAsync.IsEnabled = true;
    }
}

private async Task<string> DoSomethingAsync(CancellationToken cancellationToken)
{
    for (int i = 0; i < 3; i++)
    {
        await Task.Delay(1000); //模拟计算密集耗时
        cancellationToken.ThrowIfCancellationRequested();
    }
    return "Calculate Async Done";
}

private void ExecuteCancelTask()
{
    cts?.Cancel();
}

在第3章结尾示例的基础上,添加了异步函数可取消功能。

通过实例化CancellationTokenSource类,可以得到取消令牌Token,当取消令牌调用Cancel()方法时,就会将IsCancellationRequested属性设置为True,同时任务会抛出OperationCanceledException。

在设计上将检查方法取消操作和启动取消操作分离开来,具有一定的安全性。

检查取消在CancellationTaken类上,取消动作在CancellationTokenSource类上。

看实际效果:

4.2 进度报告

 一些异步操作需要在运行时报告其执行进度。一种简单的方案时向异步方法传入一个Action委托,在进度发生变化时就触发方法,在上面例子上添加了进度报告,如下:

private async void ExecuteTaskAsync()
{
    cts = new CancellationTokenSource();
    try
    {
        btnExecuteTaskAsync.IsEnabled = false;
        TextBoxMessage.Text = "Calculate Async Start" + Environment.NewLine;
        var result = await DoSomethingAsync(
            (percent) => { TextBoxMessage.Text += "Current progress is " + percent + Environment.NewLine; },
            cts.Token); //异步调用
        TextBoxMessage.Text += result;
        btnExecuteTaskAsync.IsEnabled = true;
    }
    catch (OperationCanceledException)
    {
        TextBoxMessage.Text += "任务已经取消!" + Environment.NewLine;
    }
    catch (Exception e)
    {
        TextBoxMessage.Text += $"Error: {e.Message}" + Environment.NewLine;
    }
    finally
    {
        btnExecuteTaskAsync.IsEnabled = true;
    }
}

private async Task<string> DoSomethingAsync(Action<string> progressReport, CancellationToken cancellationToken)
{
    for (int i = 1; i <= 10; i++)
    {
        await Task.Delay(500); //模拟计算密集耗时
        progressReport($"{i * 10}%".ToString());
        cancellationToken.ThrowIfCancellationRequested();
    }
    return "Calculate Async Done";
}

实现是简单,但是在富客户端应用程序中,有潜在的线程安全问题,由并发性对外暴露所产生的风险。

CLR拥有一对专门针对进度报告的类型:IProgress<T>接口和Progress<T>类 ,它们的作用包装一个委托,以便是UI应用程序可以通过同步上下文安全地报告进度。

private async void ExecuteTaskAsync()
{
    cts = new CancellationTokenSource();
    try
    {
        btnExecuteTaskAsync.IsEnabled = false;
        TextBoxMessage.Text = "Calculate Async Start" + Environment.NewLine;
        //通过Progress<T>构造函数接受一个Action<T>委托并对其进行包装
        var result = await DoSomethingAsync(new Progress<string>((percent) =>
            {
                TextBoxMessage.Text += "Current progress is " + percent + Environment.NewLine;
            })
            , cts.Token); //异步调用
        TextBoxMessage.Text += result;
        btnExecuteTaskAsync.IsEnabled = true;
    }
    catch (OperationCanceledException)
    {
        TextBoxMessage.Text += "任务已经取消!" + Environment.NewLine;
    }
    catch (Exception e)
    {
        TextBoxMessage.Text += $"Error: {e.Message}" + Environment.NewLine;
    }
    finally
    {
        btnExecuteTaskAsync.IsEnabled = true;
    }
}

private async Task<string> DoSomethingAsync(IProgress<string> progressReport, CancellationToken cancellationToken)
{
    for (int i = 1; i <= 10; i++)
    {
        await Task.Delay(500); //模拟计算密集耗时
        progressReport.Report($"{i * 10}%".ToString());
        cancellationToken.ThrowIfCancellationRequested();
    }
    return "Calculate Async Done";
}

对上面的例子稍作改造,就实现使用IProgress<T>和Progress<T>来完成进度报告。

4.3  基于任务的异步模式TAP

一个TAP方法:

  • 返回一个“热”Task或者Task<TResult>
  • 拥有Async后缀,除一些特殊情况或者是任务组合器
  • 若支持取消和进度报告,则需要拥有接受CancellationTaken或者IProgress<T>的重载。
  • 快速返回调用者
  • 对于I/O密集型任务不绑定线程

 

本文主要参考书籍: C#7.0核心技术指南

 

注1:UI线程上的消息循环的伪代码如下:

 

posted @ 2022-01-05 00:50  NiKaFace  阅读(2220)  评论(0编辑  收藏  举报