C#基础 - Task

发布时间 2023-09-26 14:47:09作者: tossorrow

前言

原文是Stephen Cleary的系列博客 https://blog.stephencleary.com/2014/04/a-tour-of-task-part-0-overview.html
我很容易迷失在Task,TPL,Async中,经常需要翻文章慢慢捋,索性做个合集争取一次性把Task的方方面面都涉及到。

1,Task的分类

Task分为两类,一类叫Delegate Task,一类叫Promise Task。

  • Delegate Task:包含要运行的代码的任务。在TPL(任务并行库)中,大多数任务都是Delegate Task(对Promise Task有一些支持)。进行并行处理时,各种Delegate Task分配给不同的线程,然后由这些线程实际执行任务中的代码。

  • Promise Task:表示某种事件或信号的任务,通常表示基于I/O事件或信号(比如“HTTP下载已完成”或者“10秒钟计时已到”)。在异步中,大多数任务都是Promise Task(对委托任务有一些支持)。注意Promise Task执行时,并没有线程的参与,代码只是在等待系统完成Promise Task的执行。

有时候把Delegate Task称为code-based Task,把Promise Task称为event-based Task,意思差不多。

2,Task的状态

2.1 TaskStatus枚举

如果将Task看作一个状态机,其Status属性则表示当前状态。Status属性的类型是TaskStatus枚举,它的枚举值如下:

枚举值 描述
Created
这是通过Task构造函数创建的任务的初始状态。处于此状态的任务会保持该状态,直到启动或者取消任务
WaitingForActivation 这是通过ContinueWith、ContinueWhenAll、ContinueWhenAny、 FromAsync等方法或者从TaskCompletionSource创建的任务的初始状态。 该任务尚未被分配(not scheduled),并且在相关操作完成之前都不会被分配
WaitingToRun 任务被分配到TaskScheduler,正在等待TaskScheduler的选取与执行。这是通过 TaskFactory.StartNew创建的任务的初始状态,当StartNew返回任务时,它已经被分配好了,因此状态至少是WaitingToRun(说至少是因为当StartNew返回任务时,任务可能已经处于Running甚至RanToCompletion)
Running 任务正在执行
WaitingForChildrenToComplete 当任务已完成其自身代码的执行,它就会离开Running状态。如果任务有子项,那么任务在其附加的子项完成之前不会被视为已完成,而是进入此状态
RanToCompletion 三个最终状态之一,任务已成功运行到代码结束
Canceled 三个最终状态之一,任务必须在开始执行之前或在执行期间响应取消请求,才能处于取消状态
Faulted 三个最终状态之一,任务执行自身代码时出现未处理的异常或者其子项处于Faulted状态

两种不同类型的任务具有不同的状态机路径。

  • 对于Delegate Task

    大多数情况下,Delegate Task是由Task.Run或者Task.Factory.StartNew创建,一上来就处于WaitingToRun状态了。 当Delegate Task实际开始执行时,任务就处于Running状态。Task完成时,如果有子项任务,则进入WaitingForChildrenToComplete状态等待子项任务。最后Task进入三个最终状态之一,RanToCompletion(成功运行), Faulted或者Canceled
    由于Delegate Task表示包含运行代码的任务,整个过程可能会很快,可能导致看不到其中的一个或多个状态。例如将一个简短的任务分配给线程池,当任务返回时它可能已经处于RanToCompletion状态了。

  • 对于Promise Task

    Promise Task的状态机要简单一些。Promise Task通常表示基于I/O事件或信号,这些基于I/O的操作正在执行时(比如“HTTP下载正在进行”或者“10秒钟计时正在进行”),实际上并没有执行CPU代码(而是交给了系统),因此永远不会进入WaitingToRun或者Running状态。没错,Promise Task可能直接就从WaitingForActivationRanToCompletion了,不经过Running。Promise Task创建时就开始执行了,让人困惑的是这种“执行中”的状态被居然称作WaitingForActivation,不知道微软怎么想的。

2.2 状态相关属性

Task有3个与状态相关属性

bool IsCompleted { get; }
bool IsCanceled { get; }
bool IsFaulted { get; }

IsCanceledIsFaulted很简单,直接判断当前状态是否Canceled或者FaultedIsCompleted表示当前状态是否是三个最终状态之一。

2.3 小结

尽管这些状态很有趣,但在实际编程中几乎用不到(除了调试代码时)。异步编程和并行编程都不怎么关心这些状态,通常都是等待任务完成并提取结果。

3,Task的等待

Task的等待会造成调用线程的阻塞,直到Task完成。因此Promise Task几乎不使用等待,等待Promise Task是造成死锁的常见原因。可见等待几乎是Delegate Task的专用(比如等待Task.Run返回的Task)。

3.1 Wait方法

下面列举几种常见的方法重载

bool Wait(int timeout, CancellationToken token);  //等待一个任务
bool WaitAll(params Task[], int timeout, CancellationToken token);  //等待所有任务
int WaitAny(params Task[], int timeout, CancellationToken token);  //等待任一任务

//其他的等待,比如void Wait(),void WaitAll(params Task[]),int WaitAny(params Task[])最终也是调用上述方法,不再赘述

等待其实相当简单,阻塞调用线程直到Task,直到等待发生超时、等待被取消或任务完成。
如果等待发生超时,则返回false或-1。
如果等待被取消,则引发OperationCanceledException
如果任务在FaultedCanceled状态下完成,则会将任何异常包装到AggregateException中。

需要注意的是,任务取消和等待取消都会引发OperationCanceledException,区别在于任务取消的OperationCanceledException被封装在AggregateException中,而等待取消的OperationCanceledException是直接抛出的。

大多数时候,Task.Wait是危险的,它可能会造成死锁。只在少数情况下我们会使用Task.Wait,比如一个控制台应用的Main方法有异步工作要做,但希望主线程同步阻塞,直到完成该工作时。

3.2 死锁

3.2.1 死锁形成

下面是一个Winform应用的死锁案例

public static async Task<JObject> GetJsonAsync(Uri uri)
{
  // real-world code shouldn't use HttpClient in a using block; this is just example code
  using (var client = new HttpClient())
  {
    var jsonString = await client.GetStringAsync(uri);
    return JObject.Parse(jsonString);
  }
}

public void Button1_Click(...)
{
  var jsonTask = GetJsonAsync(...);
  textBox1.Text = jsonTask.Result;  //效果相当于jsonTask.Wait();
}

点击Button1后代码就死锁了。死锁是如何发生的呢?

  1. Button1_Click方法在UI上下文调用GetJsonAsync方法。
  2. GetJsonAsync方法在UI上下文调用GetStringAsync方法,GetStringAsync返回一个未完成任务(任务1)。
  3. GetJsonAsync开始等待GetStringAsync返回的未完成任务(任务1)。在等待之前GetJsonAsync捕获了UI上下文,将用于任务完成后的继续运行。同时GetJsonAsync也返回一个未完成任务(任务2)给Button1_Click
  4. Button1_Click执行到jsonTask.Result,阻塞UI上下文所在线程等待任务2的完成。
  5. 过了一会,GetStringAsync执行完成了,GetJsonAsync方法需要恢复到之前捕获的UI上下文中继续运行。但此时UI上下文已经被阻塞,无法让GetJsonAsync方法继续,死锁。

3.3.2 死锁避免

有2个办法

  1. 在被调用的方法中,使用ConfigureAwait(false)
public static async Task<JObject> GetJsonAsync(Uri uri)
{
  // real-world code shouldn't use HttpClient in a using block; this is just example code
  using (var client = new HttpClient())
  {
    var jsonString = await client.GetStringAsync(uri).ConfigureAwait(false);
    return JObject.Parse(jsonString);
  }
}

await关键字有切换线程的功能,ConfigureAwait(false)的意思是不要切换线程,避免了上下文的延续。
在此案例中避免了GetJsonAsync方法在先前捕获的UI上下文中继续执行,而是在线程池线程中继续执行,这样就和Button1_Click不冲突了。
但是使用ConfigureAwait(false)并不是最好的办法,因为如果Button1_Click调用了很多异步方法,岂不是要把这些方法都修改一遍?最好的办法还是在调用端不要阻止异步方法。

  1. 不要等待Task,使用async/await
public async void Button1_Click(...)
{
  var json = await GetJsonAsync(...);
  textBox1.Text = json;
}

感觉刚开始学的人都知道要这么写,标准做法。

4,Task的结果

4.1 Result

Task<T>类才有成员变量ResultTask类没有

T Result { get; }

Wait一样,Result将同步阻塞调用线程,直到任务完成。这通常不是一个好主意,原因同上:容易导致死锁。
此外,Result会将任何任务异常包装在AggregateException中,这通常会使异常处理变得复杂。

4.2 GetAwaiter().GetResult()

Task<T> task = ...;
T result = task.GetAwaiter().GetResult();

效果和Result是类似的,和Result也存在同样的问题:容易导致死锁。与Result的区别在于发生异常时不会将任务异常包装在AggregateException中,而是直接抛出。

4.3 await关键字

从Promise Task获取结果的最佳方式就是使用await关键字。await以最良性的方式检索任务结果,异步等待结果(不会阻塞),返回成功任务的结果(如果有的话),任务失败时直接抛出异常而不是封装在AggregateException
绝大多数情况下,应该使用await,而不是Wait, Result, 或者GetAwaiter().GetResult()

5,Task的继续

继续即Continuation,Continuation是一个附加到任务的委托,当任务完成时,就会分配资源来执行附加的委托。被附加的任务被称为“先行任务”(Antecedent Task)。
Continuation非常重要,它不会阻塞任何线程,它其实就是异步的本质,async / await关键字某种程度上讲就是封装了Continuation的语法糖。

5.1 ContinueWith方法

附加Continuation到Task最底层的方式就是ContinueWith方法,下面列举几种常见的方法重载

//Task类的ContinueWith方法
Task ContinueWith(Action<Task>, CancellationToken, TaskContinuationOptions, TaskScheduler);  //先行任务和附加委托都没有返回值
Task<TResult> ContinueWith<TResult>(Func<Task, TResult>, CancellationToken, TaskContinuationOptions, TaskScheduler);  //先行任务没有返回值,附加委托有返回值
//Task<TResult>类的ContinueWith方法
Task ContinueWith(Action<Task<TResult>>, CancellationToken, TaskContinuationOptions, TaskScheduler);  //先行任务有返回值,附加委托没有返回值
Task<TContinuationResult> ContinueWith<TContinuationResult>(Func<Task<TResult>, TContinuationResult>, CancellationToken, TaskContinuationOptions, TaskScheduler);  //先行任务和附加委托都有返回值

//其他的继续,比如Task ContinueWith(Action<Task>),Task<TResult> ContinueWith<TResult>(Func<Task, TResult>)最终也是调用上述方法,不再赘述

下面是一个调用ContinueWith方法的例子

public void ContinueWithOperation()  
{
    Task<string> t = Task.Run(() => 
    {
        Thread.Sleep(1000);  //模拟耗时操作
        return "hello world";
    });
    //先行任务有返回值,附加委托没有返回值,对应Task ContinueWith(Action<Task<TResult>>
    Task t2 = t.ContinueWith((t1) =>  
    {
        Thread.Sleep(1000);  //模拟耗时操作
        Console.WriteLine(t1.Result);  
    });  
}

tt1就是先行任务,同一个东西。t2就是继续任务。
ContinueWith(Action<Task<TResult>>)方法最终调用的是Task ContinueWith(Action<Task<TResult>>, CancellationToken, TaskContinuationOptions, TaskScheduler),在此说明一下方法的几个参数。

  • Action<Task<TResult>>:即附加委托
  • CancellationToken:如果在执行附加委托之前响应取消,那么附加委托将永远不会执行,但是如果附加委托已经开始执行,取消就没用了,这可能有一些误导性,换句话说,取消只是取消了附加委托的分配(scheduling),而不是附加委托本身。可以参考另一篇专门写取消的文章C#基础 - Cancellation
  • TaskContinuationOptions:选项集合,这些选项与Continuation的条件、分配和附加有关。
  • TaskScheduler:负责Continuation分配的任务分配器。遗憾的是,此参数的默认值不是TaskScheduler.Default,而是 TaskScheduler.Current,这个设定多年来引起了非常多的混乱,不知道微软怎么想的。因为绝大多数时候,开发者是按照TaskScheduler.Default来做的开发,因此建议调用ContinueWith方法时指定你期望的TaskScheduler。(这里插一句,Task.Factory.StartNew也存在参数默认值是TaskScheduler.Current的问题,后面再详细讲)

总之ContinueWith是个很底层的方法,除非你需要实现动态任务并行性(dynamic task parallelism),否则都应该用await关键字,而不是ContinueWith方法。

5.2 其他方法

  • TaskFactory.ContinueWhenAny:效果和ContinueWith差不多,不过是一组先行任务中的任何一个完成时开启Continuation。
  • TaskFactory.ContinueWhenAll:效果和ContinueWith差不多,不过是所有先行任务中都完成时开启Continuation。

同样也应该使用await关键字,比如await Task.WhenAny(...)await Task.WhenAll(...),而不是TaskFactory.ContinueWhenAnyTaskFactory.ContinueWhenAll方法。

var client = new HttpClient();
string[] results = await Task.WhenAll(
    client.GetStringAsync("http://example.com"),
    client.GetStringAsync("http://microsoft.com"));
// results[0] has the HTML of example.com
// results[1] has the HTML of microsoft.com
var client = new HttpClient();
Task<string> downloadFastTask = client.GetStringAsync("http://fast.com");
Task<string> downloadSlowTask = client.GetStringAsync("http://slow.com");
Task completedTask = await Task.WhenAny(downloadFastTask, downloadSlowTask);
Debug.Assert(completedTask == downloadFastTask);

6,Task的启动

使用Task构造函数创建出任务时,任务处于Created状态,处于此状态的任务会保持该状态,直到启动或者取消任务。
注意:做开发时基本上不会用到Task构造函数,如果不是出于学习目的,这一章可以直接跳过。

6.1 Start方法

有两个方法重载

void Start();
void Start(TaskScheduler);

Start方法只能由Task构造函数创建出的任务调用,且只有Delegate Task才能使用构造函数创建出来。一旦调用了Start方法,任务进入WaitingToRun状态(永远不会返回Created状态),所以Start方法只能调用一次。做开发时创建任务用Task.Run就好,别用Task构造函数。

6.2 RunSynchronously方法

RunSynchronouslyStart非常相似,有两个方法重载。比Start还冷门,更加不会用到。。

void RunSynchronously();
void RunSynchronously(TaskScheduler);

7,Delegate Task

看看开发中创建Delegate Task的主流方式。

7.1 TaskFactory.StartNew

首先介绍的就是被过度使用的TaskFactory.StartNew方法,下面列举几种常见的方法重载

Task StartNew(Action, CancellationToken, TaskCreationOptions, TaskScheduler);
Task<TResult> StartNew<TResult>(Func<TResult>, CancellationToken, TaskCreationOptions, TaskScheduler);

//其他的StartNew,比如Task StartNew(Action),Task<TResult> StartNew<TResult>(Func<TResult>);最终也是调用上述方法,不再赘述

StartNew方法传入一个委托(Action或者Func),返回一个对应的任务。注意传入的委托不能是异步感知委托(async-aware delegates),因为使用StartNew启动异步任务会导致复杂性(TaskFactory.StartNew不支持异步感知委托,但是Task.Run支持哦)。
StartNew方法的参数默认值均来自TaskFactory实例。比如使用Task StartNew(Action)到最终调用Task StartNew(Action, CancellationToken, TaskCreationOptions, TaskScheduler)时,CancellationToken参数的实参是TaskFactory.CancellationToken
TaskCreationOptions参数的实参是TaskFactory.CreationOptionTaskScheduler参数的实参是TaskFactory.Scheduler。下面讲一下这几个参数。

7.1.1 CancellationToken

传递给StartNewCancellationToken仅在委托开始执行之前有效。换句话说,它用于取消委托的启动,而不是委托本身。一旦该委托开始执行,就不能用它来取消该委托。
如果想要取消委托本身,那么需要在委托中显式使用CancellationToken(比如调用CancelToken.ThrowIfCancelRequest)。
总之,StarNewCancellationToken参数几乎毫无用处。它的行为让许多开发者感到困惑。我自己从不使用它。

7.1.2 TaskCreationOptions

TaskCreationOptions是枚举类型

  • TaskCreationOptions.PreferFairness:以FIFO方式执行任务(尽量让先分配的任务先执行,后分配的后执行)。
  • TaskCreationOptions.LongRunning:长时间运行的任务(不使用线程池线程,而是新开一个独立的线程来执行任务)。
  • TaskCreationOptions.DenyChildAttach:禁止当前任务添加Continuation(Task.Run的默认行为)。
  • TaskCreationOptions.HideScheduler:执行任务时假装没有TaskScheduler。
  • TaskCreationOptions.RunContinuationsAsynchronously:强制任务的Continuation异步执行。
  • TaskCreationOptions.None:TaskFactory.StarNew的默认行为

7.1.3 TaskScheduler

TaskScheduler参数指定任务的分配者。TaskFactory有自己默认的TaskScheduler。但要注意TaskFactory默认的TaskScheduler不是TaskScheduler.Default,而是TaskScheduler.Current(重要的事情反复说)。

下面在winform里演示一下TaskScheduler.Current的效果。

private void Button_Click(object sender, EventArgs e)
{
    TaskFactory factory = new TaskFactory(TaskScheduler.FromCurrentSynchronizationContext());  //指定UI上下文的TaskScheduler
    factory.StartNew(() =>
    {
        Debug.WriteLine("UI work on thread " + Environment.CurrentManagedThreadId);
        Task.Factory.StartNew(() =>
        {
            Debug.WriteLine("Background work on thread " + Environment.CurrentManagedThreadId);
        });
    });
}
//输出:
//UI work on thread 1(UI线程)
//Background work on thread 1(UI线程)
private void Button_Click(object sender, EventArgs e)
{
    TaskFactory factory = new TaskFactory();  //默认是线程池的TaskScheduler
    factory.StartNew(() =>
    {
        Debug.WriteLine("UI work on thread " + Environment.CurrentManagedThreadId);
        Task.Factory.StartNew(() =>
        {
            Debug.WriteLine("Background work on thread " + Environment.CurrentManagedThreadId);
        });
    });
}
//输出:
//UI work on thread 3(线程池线程)
//Background work on thread 4(线程池线程)

7.2 Task.Run

Task.Run是将委托排队到线程池的首选方法,提供了比Task.Factory.StartNew更简单的API,并且支持异步感知。Task.Run默认的TaskSchedulerTaskScheduler.Default,这一点很棒,但是如果你想使用自定义的TaskScheduler,就只能用TaskFactory了。下面列举几种常见的方法重载

Task Run(Action);
Task Run(Action, CancellationToken);
Task Run(Func<Task>);
Task Run(Func<Task>, CancellationToken);
Task<TResult> Run<TResult>(Func<TResult>);
Task<TResult> Run<TResult>(Func<TResult>, CancellationToken);
Task<TResult> Run<TResult>(Func<Task<TResult>>);
Task<TResult> Run<TResult>(Func<Task<TResult>>, CancellationToken);

对于TaskFactory.StartNew,委托参数是Action / Func<TResult>时结果具有合理的预期,而委托参数是Func<Task> / Func<Task<TResult>>时结果却变得复杂,这就是所谓的不支持异步感知。
对于Task.Run,不论委托参数是Action / Func<TResult>还是Func<Task> / Func<Task<TResult>>,结果都具有合理的预期,这就是所谓的支持异步感知。(关于异步感知,后面再详细讲)
CancellationToken参数和在StarNew存在一样的问题,几乎毫无用处。

8,Promise Task

Promise Task是表示系统事件或信号的任务,它没有需要执行的用户代码。看看开发中创建Promise Task的方式。

8.1 Task.Delay

几种常见的方法重载

Task Delay(int);
Task Delay(int, CancellationToken);

Delay方法本质上是一个计时器,当计时器时间到时会让返回的Task进入RanToCompletion状态。CancellationToken参数与Task.Run不同,此参数时可以取消Delay本身的。因此响应取消时,返回的Task进入Canceled状态。

8.2 Task.Yield

Task.Yield有点奇怪。它不返回Task,因此它并不是正宗的创建Promise Task方法,但是它使用起来很像Promise Task。

YieldAwaitable Yield();

Task.Yield就像执行一个已经完成的任务,或者说就像Task.Delay(0)

private async void button_Click(object sender, EventArgs e)
{
    await Task.Yield(); // Make us async right away
    var data = DoSomethingOnUIThread(); // This will run on the UI thread at some point later
    await UseDataAsync(data);
}

如果没有Task.Yield()DoSomethingOnUIThread方法将会立刻在UI线程上同步执行。Task.Yield()配合await关键字让后续代码成为Task的Continuation,需要TaskScheduler来重新分配。但是这有什么用呢??没想明白。。

8.3 Task.FromResult

Task.FromResult返回一个带返回值的已经完成的任务

Task<TResult> FromResult<TResult>(TResult);

有点像在Task.Yield的基础上加了一个返回值,除了用于直接返回一个带返回值的已经完成的任务,在一些其他情况下也是有用的。
比如一个接口中有一个异步方法,如果方法的实现是同步的,就可以用Task.FromResult包装这个同步结果。

interface IMyInterface
{
    // Implementations might need to be asynchronous, so we define an asynchronous API.
    Task<int> DoSomethingAsync();
}
class MyClass : IMyInterface
{
    // This particular implementation is not asynchronous.
    public Task<int> DoSomethingAsync()
    {
        int result = 42;  // Do synchronous work.
        return Task.FromResult(result);
    }
}

还有一种情况就是使用缓存时,如果缓存中检索到了数据则用Task.FromResult包装同步结果,否则执行真正的异步操作。

public Task<string> GetValueAsync(int key)
{
    string result;
    if (cache.TryGetValue(key, out result))
    {
        return Task.FromResult(result);
    }
    return DoGetValueAsync(key);
}
private async Task<string> DoGetValueAsync(int key)
{
    string result = await GetValueAsync();
    cache.TrySetValue(key, result);
    return result;
}

是否还有其他的方法返回已经完成的任务呢?有的,类似Task.FromResult返回状态为RanToCompletion的任务,还有Task.FromCanceledTask.FromException分别返回状态为CanceledFaulted的任务。

8.4 TaskCompletionSource

TaskCompletionSource用于创建一个任务,并且可以手动设置任务的最终状态。有点像Task.FromResultTask.FromCanceledTask.FromException三者的合集。
举个例子,在不使用Task.RunStartNew的前提下,如何实现异步执行Func<T>并且用Task<T>来表示这个操作呢?用TaskCompletionSource<T>就可以做到。

public static Task<T> RunAsync<T>(Func<T> function)
{
    if (function == null) 
    {
        throw new ArgumentNullException(“function”);
    }
    var tcs = new TaskCompletionSource<T>();
    ThreadPool.QueueUserWorkItem(_ =>
    {
        try
        { 
            T result = function();
            tcs.SetResult(result); 
        }
        catch(Exception e) 
        { 
            tcs.SetException(e); 
        }
    });
    return tcs.Task;
}

SetResult方法将任务状态设为RanToCompletionSetException方法将任务状态设为Faulted,还有SetCanceled方法将任务状态设为Canceled

9,补充

9.1 Task.Run vs Task.Factory.StartNew

9.1.1 简单理解

Task.Run可以看作是Task.Factory.StartNew的一种简单快捷方式。

//下面两段代码是等价的
Task.Run(someAction);

Task.Factory.StartNew(someAction, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

9.1.2 异步感知

上文提到Task.Run支持异步感知(async-aware),而Task.Factory.StartNew不支持。考虑如下代码

Task<int> t = await Task.Factory.StartNew(async () =>
{
    await Task.Delay(1000);
    return 42;
});

按初学者的思路,尝试用Task.Run实现上面代码的功能

int result = await Task.Run(async () =>
{
    await Task.Delay(1000);
    return 42;
});

发现问题了吧,await Task.Factory.StartNew返回类型是Task<int>,而await Task.Run(async)返回类型是int
StartNew的参数类型为Func<Task<int>>,那么StartNew的返回类型是Task<Task<int>>await Task<Task<int>>就会得到Task<int>,没毛病啊。
那问题肯定就出在Task.Run,还有一层Task到哪去了呢?实际上将上面使用Task.Run的代码片段改用StartNew,会变成下面这样

int result = await Task.Factory.StartNew(async () =>
{
    await Task.Delay(1000);
    return 42;
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap();

Unwrap方法解封装了Func<Task<int>>委托返回的内部任务,更明确地讲Unwrap方法使得Task<Task<int>>变成了Task<Task<int>>(带删除线的就是被解封装的Task)。
Task.Run的委托参数是异步委托时,它能自动识别并且在内部调用Unwrap方法进行解封装。这就是异步感知的本质,微软偷偷摸摸做的好事。

9.1.3 TaskScheduler.Current的问题

Task.Run的默认TaskSchedulerTaskScheduler.DefaultStartNew的默认TaskSchedulerTaskScheduler.Current。上文推荐TaskScheduler.Default,并反复吐槽了TaskScheduler.Current

Task.Factory.StartNew(A);

请问方法A会在哪个线程上执行?回答不上来?那我们再补充上下文

private void Form1_Load(object sender, EventArgs e)
{
    Task.Factory.StartNew(A);
}

再次请问方法A会在哪个线程上执行?A会在线程池线程上执行。
为什么?Task.Factory.StartNew首先检查当前的TaskScheduler。结果当前没有,所以它使用了线程池的TaskScheduler。对于简单的情况来说已经足够了,让我们考虑一个更实际的例子。

private void Form1_Load(object sender, EventArgs e)
{
    Compute(3);
}
private void Compute(int counter)
{
    if (counter == 0)  // If we're done computing, just return.
    {
        return;
    }
    TaskScheduler ui = TaskScheduler.FromCurrentSynchronizationContext();
    Task.Factory.StartNew(() => A(counter))
        .ContinueWith(t =>
        {
            this.Text = t.Result.ToString(); // Update UI with results.
            Compute(counter - 1);  // Continue working.
        }, ui);
}
private int A(int value)
{
    return value; // CPU-intensive work.
}

还是同样的问题,方法A会在哪个线程上执行?上文其实还有一个类似的例子,如果看懂了应该能答出这个问题。
方法A一共执行了3次,第1次在线程池线程上执行,后2次在UI线程上执行。
第1次执行A时,TaskFactory首先检查当前的TaskScheduler。结果当前没有,所以它使用了线程池的TaskScheduler。第1次执行ContinueWith时,指定了UI的TaskScheduler,当第2次执行A时,TaskScheduler.Current指导TaskFactory获取到了UI的TaskScheduler,因此第2次在UI线程上执行,第3次情况一样。
TaskScheduler.Current经常会导致不可预知的行为,因此很多开发团队要求在使用StartNew时必须显式地指定TaskScheduler参数。遗憾的是具有TaskScheduler参数的唯一重载方法也具有CancellationToken参数和 TaskCreationOptions参数。为了使Task.Factory.StartNew可靠地、可预测地将任务安排到线程池,就应该这么写

Task.Factory.StartNew(A, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

你可能发现了,这不就是Task.Run(A)嘛??

9.2 Promise Task的执行

考虑一个常见的Promise Task,比如写入操作(写入硬盘文件, 网络流, 内存流等)

private async void Button_Click(object sender, EventArgs e)
{
    byte[] data = ...
    await myDevice.WriteAsync(data, 0, data.Length);
}

await期间UI线程没有阻塞,那么是谁在执行写入操作从而解放了UI线程呢?
首先,假设WriteAsync是使用.NET的标准P/Invoke异步I/O系统(standard P/Invoke asynchronous I/O system)实现的,那么它会在设备的底层HANDLE上启动一个Win32异步I/O操作(overlapped I/O operation)。
然后,操作系统要求设备驱动开始写入操作,它首先构造了一个表示写入请求的对象,称作I/O请求包(I/O Request Packet, IRP)。设备驱动收到IRP并向对应的设备发出写入数据的命令。如果设备支持直接内存访问(Direct Memory Access, DMA),写入操作就会像把缓存地址写入设备寄存器一样简单。这就是设备驱动做的事:将IRP标记为挂起(pending)并返回给操作系统。

在处理IRP时不允许阻止设备驱动。这意味着,如果无法立即完成IRP,则必须异步处理它,即使对于同步API也是如此。在设备驱动的级别,所有的请求都是异步的。
操作系统收到挂起的IRP并返回给函数库,函数库再将IRP作为一个未完成的Task返回给Button_Click方法,Button_Click方法收到未完成的Task便继续执行UI线程。
纵观整个过程,没有线程参与写入操作;驱动程序线程、操作系统线程、BCL线程或线程池线程都没有,没有任何线程
后面设备完成写入操作,也没有线程的参与,想知道更多细节可以看Stephen Cleary的博客。

9.3 Task.Run使用建议

Task.Run用于以异步的方式执行CPU密集型代码(CPU-bound code)。更明确地,Task.Run在线程池线程上执行方法并返回一个代表此方法的Task。
应该在什么情况下使用Task.Run?使用Task.Run调用CPU密集型代码,That is all。

9.3.1 简单例子

考虑如下CPU密集型的简单例子

class MyService
{
    public int CalculateMandelbrot()
    {
        for (int i = 0; i != 10000000; ++i)  // Tons of work to do in here
        {
            // heavy calculation
        }
        return 42;
    }
}
private void MyButton_Click(object sender, EventArgs e)
{
    myService.CalculateMandelbrot();  // UI线程阻塞了
}

我们不希望阻塞UI线程,下面尝试用Task.Run来执行这些CPU密集型代码避免阻塞。

class MyService
{
    public int CalculateMandelbrot()
    {
        for (int i = 0; i != 10000000; ++i)  // Tons of work to do in here
        {
            // heavy calculation
        }
        return 42;
    }
}
private async void MyButton_Click(object sender, EventArgs e)
{
    await Task.Run(() => myService.CalculateMandelbrot());  // Use Task.Run here
}

不要在实现方法时使用Task.Run,应该在调用方法时使用Task.Run。既然UI层需要异步API,那么就让UI层使用Task.Run来解决问题,保持服务MyService的干净整洁。

9.3.2 复杂例子

再考虑一个CPU密集型和IO密集型的复杂例子

// Bad code
class MyService
{
    public int PredictStockMarket()
    {
        Thread.Sleep(1000);  // Do some I/O first
        for (int i = 0; i != 10000000; ++i)  // Tons of work to do in here
        {
            // heavy calculation
        }
        Thread.Sleep(1000);  // Possibly some more I/O here
        for (int i = 0; i != 10000000; ++i)  // More work
        {
            // heavy calculation
        }
        return 42;
    }
}

对于CPU密集型部分,使用异步代码将阻塞I/O替换为异步I/O。但是我们如何处理CPU密集型部分呢?先看一个常见的错误做法

// Bad code
class MyService
{
    public async Task<int> PredictStockMarketAsync()
    {
        await Task.Delay(1000);  // Do some I/O first
        await Task.Run(() =>  // Bad
        {
            for (int i = 0; i != 10000000; ++i)  // Tons of work to do in here
            {
                // heavy calculation
            }
        });
        await Task.Delay(1000);  // Possibly some more I/O here
        await Task.Run(() =>  // Bad
        {
            for (int i = 0; i != 10000000; ++i)  // More work
            {
                // heavy calculation
            }
        });
        return 42;
    }
}

API不能是异步的(因为它有CPU密集型部分),也不能是同步的(因为我们想要使用异步I/O)。因此,这里并没有理想的解决方案。经过讨论,最好的办法还是使用异步签名,同时记录此方法包含CPU密集型部分。

// Acceptable code
class MyService
{
    // This method is CPU-bound!
    public async Task<int> PredictStockMarketAsync()
    {
        await Task.Delay(1000);  // Do some I/O first
        for (int i = 0; i != 10000000; ++i)  // Tons of work to do in here
        {
            // heavy calculation
        }
        await Task.Delay(1000);  // Possibly some more I/O here
        for (int i = 0; i != 10000000; ++i)  // More work
        {
            // heavy calculation
        }
        return 42;
    }
}

桌面应用使用Task.Run调用此方法,ASP.NET应用则直接调用此方法。

private async void MyButton_Click(object sender, EventArgs e)
{
    await Task.Run(() => myService.PredictStockMarketAsync());
}

public class StockMarketController: Controller
{
    public async Task<ActionResult> IndexAsync()
    {
        var result = await myService.PredictStockMarketAsync();
        return View(result);
    }
}

即便在复杂情况下,也不应该在实现方法时使用Task.Run,而是调用方法时使用Task.Run

作者:tossorrow
出处:C#基础 - Task
转载:欢迎转载,请保留此段声明,请在文章中给出原文链接;