C#基础 - Task
前言
原文是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(对Delegate 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 |
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可能直接就从WaitingForActivation
到RanToCompletion
了,不经过Running
。Promise Task创建时就开始执行了,让人困惑的是这种“执行中”的状态被居然称作WaitingForActivation
,不知道微软怎么想的。
2.2 状态相关属性
Task有3个与状态相关属性
bool IsCompleted { get; }
bool IsCanceled { get; }
bool IsFaulted { get; }
IsCanceled
和IsFaulted
很简单,直接判断当前状态是否Canceled
或者Faulted
。IsCompleted
表示当前状态是否是三个最终状态之一。
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
。
如果任务在Faulted
或Canceled
状态下完成,则会将任何异常包装到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
后代码就死锁了。死锁是如何发生的呢?
Button1_Click
方法在UI上下文
调用GetJsonAsync
方法。GetJsonAsync
方法在UI上下文
调用GetStringAsync
方法,GetStringAsync
返回一个未完成任务(任务1)。GetJsonAsync
开始等待GetStringAsync
返回的未完成任务(任务1)。在等待之前GetJsonAsync
捕获了UI上下文
,将用于任务完成后的继续运行。同时GetJsonAsync
也返回一个未完成任务(任务2)给Button1_Click
。Button1_Click
执行到jsonTask.Result
,阻塞UI上下文
所在线程等待任务2的完成。- 过了一会,
GetStringAsync
执行完成了,GetJsonAsync
方法需要恢复到之前捕获的UI上下文
中继续运行。但此时UI上下文
已经被阻塞,无法让GetJsonAsync
方法继续,死锁。
3.3.2 死锁避免
有2个办法
- 在被调用的方法中,使用
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
调用了很多异步方法,岂不是要把这些方法都修改一遍?最好的办法还是在调用端不要阻止异步方法。
- 不要等待Task,使用async/await
public async void Button1_Click(...)
{
var json = await GetJsonAsync(...);
textBox1.Text = json;
}
感觉刚开始学的人都知道要这么写,标准做法。
4,Task的结果
4.1 Result
Task<T>
类才有成员变量Result
,Task
类没有
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非常重要,它不会阻塞任何线程,它其实就是异步的本质,抛开事实不谈,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);
});
}
t
和t1
就是先行任务,同一个东西。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.ContinueWhenAny
和TaskFactory.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方法
RunSynchronously
和Start
非常相似,有两个方法重载。比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或者FuncStartNew
启动异步任务会导致复杂性(TaskFactory.StartNew
不支持异步感知委托,但是Task.Run
支持哦)。
StartNew
方法的参数默认值均来自TaskFactory
实例。比如使用Task StartNew(Action)
到最终调用Task StartNew(Action, CancellationToken, TaskCreationOptions, TaskScheduler)
时,CancellationToken
参数的实参是TaskFactory.CancellationToken
,
TaskCreationOptions
参数的实参是TaskFactory.CreationOption
,TaskScheduler
参数的实参是TaskFactory.Scheduler
。下面讲一下这几个参数。
7.1.1 CancellationToken
传递给StartNew
的CancellationToken
仅在委托开始执行之前有效。换句话说,它用于取消委托的启动,而不是委托本身。一旦该委托开始执行,就不能用它来取消该委托。
如果想要取消委托本身,那么需要在委托中显式使用CancellationToken
(比如调用CancelToken.ThrowIfCancelRequest
)。
总之,StarNew
的CancellationToken
参数几乎毫无用处。它的行为让许多开发者感到困惑。我自己从不使用它。
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
默认的TaskScheduler
是TaskScheduler.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.FromCanceled
和Task.FromException
分别返回状态为Canceled
和Faulted
的任务。
8.4 TaskCompletionSource
TaskCompletionSource用于创建一个任务,并且可以手动设置任务的最终状态。有点像Task.FromResult
,Task.FromCanceled
和Task.FromException
三者的合集。
举个例子,在不使用Task.Run
或StartNew
的前提下,如何实现异步执行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
方法将任务状态设为RanToCompletion
,SetException
方法将任务状态设为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
的默认TaskScheduler
是TaskScheduler.Default
,StartNew
的默认TaskScheduler
是TaskScheduler.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),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
。