为了更好地控制并行操作,可以使用System.Threading.Tasks名称空间中的Task类。任务表示将完成的某个工作单元。这个工作单元可以在单独的线程中运行,也可以以同步方式启动一个任务,这需要等待主调线程。使用任务不仅可以获得一个抽象层,还可以对底层线程进行很多控制。
在安排需要完成的工作时,任务提供了非常强大的灵活性。例如,可以定义连续的工作——在一个任务完成后该执行什么工作。这可以根据任务成功与否来区分。另外,还可以在层次结构中安排任务。例如,父任务可以创建新的子任务。这可以创建一种依赖关系,这样,取消父任务,也会取消其子任务。
1. 启动任务
要启动任务,可以使用TaskFactory类或Task类的构造函数和Start()方法。Task类的构造函数在创建任务上提供的灵活性较大。
TaskSamples的示例代码使用如下名称空间:
名称空间
System
System.Linq
System.Threading
System.Threading.Tasks
在启动任务时,会创建Task类的一个实例,利用Action或Action<object>委托(不带参数或带一个object参数),可以指定将运行的代码。下面定义的方法TaskMethod带一个参数。在实现代码中,调用Log方法,把任务的ID和线程的ID写入控制台中,并且如果线程来自一个线程池,或者线程是一个后台线程,也要写入相关信息。把多条消息写入控制台的操作是使用lock关键字和s_logLock同步对象进行同步的。这样,就可以并行调用Log,而且多次写入控制台的操作也不会彼此交叉。否则,title可能由一个任务写入,而线程信息由另一个任务写入:
static void TaskMethod(object o)
{
Log(o?.ToString());
}
static object s_logLock = new object();
static void Log(string title)
{
System.Console.WriteLine(title);
System.Console.WriteLine($"Task id: {Task.CurrentId?.ToString()?? "no task"}"+
$"thrad: {Thread.CurrentThread.ManagedThreadId}");
System.Console.WriteLine($"is pooled thread: {Thread.CurrentThread.IsTheadPoolThread}");
System.Console.WriteLine($"is background thread: {Thread.CurrentThread.IsBackground}");
}
接下来的几小节描述了启动新任务的不同方法。
a. 使用线程池的任务
在本节中,可以看到启动使用了线程池中线程的任务的不同方式。线程池提供了一个后台线程的池。线程池独自管理线程,根据需要增加或减少线程池中的线程数。线程池中的线程用于实现一些操作,之后仍然返回线程池中。
创建任务的第一种方式是使用实例化的TaskFactory类,在其中把TaskMethod方法传递给StartNew方法,就会立即启动任务。第二种方式是使用Task类的静态属性Factory来访问TaskFactory,以及调用StartNew()方法。它与第一种方式很类似,也使用了工厂,但是对工厂创建的控制则没有那么全面。第三种方式是使用Task类的构造函数。实例化Task对象时,任务不会立即执行,而是指定Created状态。接着调用Task类的Start()方法,来启动任务。第四种方式调用Task类的Run方法,立即启动任务。Run方法没有可以传递Action<object>委托的重载版本,但是通过传递Action类型的lambda表达式并在其实现中使用参数,可以模拟这种行为。
static void TasksUsingTreadPool()
{
var tf = new TaskFactory();
Task t1 = tf.StartNew(TaskMethod,"using a task factory");
//Console.WriteLine($"Task: {Task.CurrentId}, thread: {Thread.CurrentThread.ManagedThreadId}");
Task t2 = Task.Factory.StartNew(TaskMethod,"factory via a task");
Task t3 = new Task(TaskMethod,"using a task constructor and Start");
t3.Start();
Task t4 = Task.Run(()=> TaskMethod("using the Run method"));
}
这些版本返回的输出如下所示。它们都创建一个新任务,并使用线程池中的一个线程:
factory via a task
Task id: 2thread: 4
is pooled thread: True
is background thread: True
using a task factory
Task id: 1thread: 5
is pooled thread: True
is background thread: True
using a task constructor and Start
Task id: 3thread: 6
is pooled thread: True
is background thread: True
using the Run method
Task id: 4thread: 9
is pooled thread: True
is background thread: True
注意:在Main()函数中调用该方法时,需要加上Console.ReadKey()或Console.ReadLine(),延迟主线程退出。否则当主线程退出时,所有后台线程都强制结束,看不到任何输出。
static void Main(string[] args)
{
TasksUsingTreadPool();
Console.ReadKey();
使用Task构造函数和TaskFactory的StartNew()方法时,可以传递TaskCreationOptions枚举中的值。利用这个创建选项,可以改变任务的行为,如接下来的小节所示。
b. 同步任务
任务不一定要使用线程池中的线程,也可以使用其他线程。任务也可以同步运行,以相同的线程作为主调线程。下面的代码段使用了Task类的RunSynchronously()方法:
static void RunSynchronousTask()
{
TaskMethod("just the main thread");
var t1 = new Task(TaskMethod,"run async");
t1.RunSynchronously();
}
这里,TaskMethod()方法首先在主线程上直接调用,然后在新创建的Task上调用。从如下所示的控制台输出可以看到,主线程没有任务ID,也不是线程池中的线程。调用RunSynchronously()方法时,会使用相同的线程作为主调线程,但是如果以前没有创建任务,就会创建一个任务:
just the main thread
Task id: no task thread: 1
is pooled thread: False
is background thread: False
run async
Task id: 1 thread: 1
is pooled thread: False
is background thread: False
c. 使用单独线程的任务
如果任务的代码将长时间运行,就应该使用TaskCreationOptions.LongRunning告诉任务调度器创建一个新线程,而不是使用线程池中的线程。此时,线程可以不由线程池管理。当线程来自线程池时,热舞调度器可以决定等待已经运行的任务完成,然后使用这个线程,而不是在线程池中创建一个新线程。对于长时间运行的线程,任务调度器会立即知道等待它们完成没有意义。下面的代码片段创建了一个长时间运行的任务:
static void LongRunningTask()
{
var t1 = new Task(TaskMethod,"long running",TaskCreationOptions.LongRunning);
t1.Start();
}
实际上,使用TaskCreationOptions.LongRunning选项时,不会使用线程池中的线程,而是创建一个新线程:
long running
Task id: 1 thread: 4
is pooled thread: False
is background thread: True
注意:在Mian()函数中调用时,需要延迟主调线程退出,才可显示结果。
2. Future——任务的结果
当任务结束时,它可以把一些有用的状态信息写到共享对象中。这个共享对象必须是线程安全的。另一个选项是使用返回某个结果的任务。这种任务也称为future,因为它在将来返回一个结果。早期版本的Task Parallel Library(TPL)的类名也称为Future,现在它是Task类的一个泛型版本。使用这个类时,可以定义任务返回的结果的类型。
由任务调用返回结果的方法可以声明为任何返回类型。下面的示例方法TaskWithResult()利用一个元组返回两个int值。该方法的输入可以是void或boject类型,如下所示:
static (int Result,int Remainder) TaskWithResult(object division)
{
(int x,int y) = ((int x,int y))division;
int result = x / y;
int remainder = x % y;
Console.WriteLine("task creates a result...");
return (result,remainder);
}
注意:
元组允许把多个值组合为一个。
当定义一个调用TaskWithResult()方法的任务时,要使用泛型类Task<TResult>。泛型参数定义了返回类型。通过构造函数,把这个方法传递给Func委托,第二个参数定义了输入值。因为这个任务在object参数中需要两个输入值,所以还创建了一个元组。接着启动该任务。Task实例t1块的Result属性被禁止,并一直等到该任务完成。任务完成后,Reuslt属性包含任务的结果。
static void TaskWithResultDemo()
{
var t1 = new Task<(int Result, int Reaminder)>(TaskWithResult,(8,3));
t1.Start();
Console.WriteLine(t1.Result);
t1.Wait();
Console.WriteLine($"result from task: {t1.Result.Result}"+
$"{t1.Result.Reaminder}");
}
输出结果:
task creates a result...
(2, 2)
result from task: 2 2
3. 连续的任务
通过任务,可以指定在任务完成后,应开始运行另一个特定任务,例如,一个新任务使用前一个任务的结果,如果前一个任务失败了,这个任务就应该执行一些清理工作。
通过任务,可以指定在任务完成后,应开始运行另一个特定任务,例如,一个新任务使用前一个任务的结果,如果前一个任务失败了,这个任务就应执行一些清理工作。
任务处理程序或者不带参数,或者带一个参数,而连续处理程序有一个Task类型的参数,这里可以访问起始任务的相关信息:
static void DoOnFirst()
{
System.Console.WriteLine($"doing some task,task id: {Task.CurrentId},thread id: {Thread.CurrentThread.ManagedThreadId}");
Task.Delay(3000).Wait();
}
static void DoOnSecond(Task task)
{
System.Console.WriteLine($"task finished,task id {Task.CurrentId},thread id: {Thread.CurrentThread.ManagedThreadId}");
System.Console.WriteLine("do some cleanup");
Task.Delay(1000).Wait();
}
连续任务通过在任务上调用ContinueWith()方法来定义。也可以使用TaskFactory类来定义。t1.ContinueWith(DoOnSecond)方法表示,调用DoOnSecond()方法的新任务应在任务t1结束时立即启动。在一个任务结束时,可以启动多个任务,连续任务也可以有另一个连续任务,如下面的例子所示:
static void ContinuationTasks()
{
Task t1 = new Task(DoOnFirst);
Task t2 = t1.ContinueWith(DoOnSecond);
Task t3 = t1.ContinueWith(DoOnSecond);
Task t4 = t2.ContinueWith(DoOnSecond);
t1.Start();
无论前一个任务是如何结束的,前面的连续任务总是在前一个任务结束时启动。使用TaskContinuationOptions枚举中的值可以指定,连续任务只有在起始任务成功(或失败)结束时启动。一些可能的值是OnlyOnFaulted、NotOnFaulted、OnlyOnCanceled、NotOnCancelel以及OnlyOnRanToCompletion。
Task t5 = t1.ContinueWith(DoOnError,TaskContinuationOptions.OnlyOnFaulted);
注意:使用await关键字时,编译器生成的代码会使用连续任务。
输出结果:
doing some task,task id: 4,thread id: 4
task finished,task id 1,thread id: 5
do some cleanup
task finished,task id 2,thread id: 4
do some cleanup
task finished,task id 3,thread id: 5
do some cleanup
4. 任务层次结构
利用任务连续性,可以在一个任务结束后启动另一个任务。任务也可以构成一个层次结构。一个任务启动一个新任务时,就启动了一个父/子层次结构。
下面的代码段在父任务内部新建一个任务对象并启动任务。创建子任务的代码与创建父任务的代码相同,唯一的区别是这个任务从另一个任务内部创建:
static void ParentAndChild()
{
var parent = new Task(ParentTask);
parent.Start();
Task.Delay(2000).Wait();
Console.WriteLine(parent.Status);
Task.Delay(4000).Wait();
Console.WriteLine(parent.Status);
}
static void ParentTask()
{
Console.WriteLine($" parent task: task id: {Task.CurrentId}, thread id: {Thread.CurrentThread.ManagedThreadId}");
var child = new Task(ChildTask);
child.Start();
Task.Delay(1000).Wait();
Console.WriteLine("parent started child");
}
static void ChildTask()
{
Console.WriteLine($" child task: task id: {Task.CurrentId}, thread id: {Thread.CurrentThread.ManagedThreadId}");
Task.Delay(5000).Wait();
Console.WriteLine("child finished");
}
如果父任务在子任务之前结束,父任务的状态就显示为WaitingForChildrenToComplete。所有的子任务也结束时,父任务的状态就变成RanToCompletion。当然,如果父任务用TaskCreationOptions.DetachedFromParent(实际码代码时,TaskCreationOptions枚举值中并未有该选项)创建一个任务时,这就无效。
取消父任务,也会取消子任务。接下来就讨论取消架构。
注意:实际运行结果,也与上面介绍不同。两次父任务状态都为RanToCompletion,即使子任务还没有完成。
运行结果:
parent task: task id: 1, thread id: 4
child task: task id: 3, thread id: 5
parent started child
RanToCompletion
child finished
RanToCompletion
5. 从方法中返回任务
返回任务和结果的方法声明为返回Task<T>,例如,方法返回一个任务和字符串集合:
static Task<IEnumerable<string>> TaskMethodAsync()
{
}
创建访问网络或数据的方法通常是异步的,这样,就可以使用任务特性来处理结果(例如使用async关键字)。如果有同步路径,或者需要实现一个用同步代码定义的接口,就不需要为了结果的值创建一个任务。Task类使用方法FromResult()创建已完成任务的结果,该任务用状态RanToCompletion表示完成:
static Task<IEnumerable<string>> TaskMethodAsync()
{
return Task.FromResult<IEnumerable<string>>(new List<string> { "one","two"});
}
6. 等待任务
也许读者学习过Task类的WhenAll()和WaitAll()方法,想知道它们之间的区别。这两个方法都等待传递给它们的所有任务完成。WaitAll()方法阻塞调用线程,直到等待的所有任务完成为止。WhenAll()方法返回一个任务,从而允许使用async关键字等待结果,它不会阻塞等待的任务。
在等待的所有任务都完成后,WhenAll()和WaitAll()方法才完成,而使用WhenAny()和WaitAny()方法,可以等待任务列表中的一个任务完成。类似于WhenAll()和WaitAll()方法,WaitAny()方法会阻塞任务的调用,而WhenAny()返回可以等待的任务。
前面几个示例已经使用了Task.Delay()方法。可以指定这个方法返回的任务完成前要等待的毫秒数。
如果将释放CPU,从而允许其他任务运行,就可以调用Task.Yield()方法。该方法释放CPU,让其他任务运行。如果没有其他的任务等待运行,调用Task.Yield的任务就立即继续执行。否则,需要等到再次调度CPU,以调用任务。
ValueTask
如果方法有时是异步运行的,但并不总是这样,Task类可能有一些不需要的开销。.NET现在提供了ValueTask,它是一个结构,相对于Task类,这样ValueTask就没有堆中对象的开销了。通常调用异步方法,例如对API服务器或数据库进行调用,与需要完成工作的时间相比,Task类型的开销可以忽略。然而,在某些情况下,不能忽略开销,例如,方法被调用数千次时,很少真正需要通过网络进行调用。在这个场景中,ValueTask变得非常方便。
下面看一个例子。方法GetTheRealData模拟通常需要很长时间的方法,在网络或数据库上访问数据。在这里,使用Enumerable类生成示例数据。随着时间的推移,检索数据,结果以元组的形式返回。该方法返回我们常用的Task:
static Task<(IEnumerable<string> data,DateTime retrievedTime)> GetTheRealData()=>
Task.FromResult((Enumerable.Range(0, 10).Select(x => $"item {x}").AsEnumerable(), DateTime.Now));
有趣的部分现在在方法GetSomeData中。这个方法声明为返回一个ValueTask。在实现中,如果缓存的数据不超过5秒,则首先进行检查。如果缓存的数据没有变旧,就直接返回缓存的数据,并传递给ValueTask构造函数。这并不需要后台线程;数据可以直接返回。如果缓存较老,则调用GetTheRealData方法。这个方法需要一个真正的任务,并且可能会出现一些延迟:
static DateTime _retrieved;
static IEnumerable<string> _cachedData;
async static ValueTask<IEnumerable<string>> GetSomeDataAsync()
{
if (_retrieved >= DateTime.Now.AddSeconds(-5))
{
Console.WriteLine("data from the cache");
return await new ValueTask<IEnumerable<string>>(_cachedData);
}
Console.WriteLine("data from the service");
(_cachedData, _retrieved) = await GetTheRealData();
return _cachedData;
}
注意:
ValueTask的构造函数要为返回的数据接受类型TReuslt或Task<TReuslt>,来提供从异步运行的方法中返回的Task。
Main()方法包括一个循环,在每次迭代之后,多次调用GetSomeDataAsync()方法:
static async Task Main(string[] args)
{
for (int i = 0; i < 20; i++)
{
IEnumerable<string> data = await GetSomeDataAsync();
await Task.Delay(1000);
}
}
运行应用程序时,可以看到数据从缓存中返回,并且在缓存数据失效之后,在再次使用缓存之前访问服务。
data from the service
data from the cache
data from the cache
data from the cache
data from the cache
data from the service
data from the cache
data from the cache
data from the cache
data from the cache
data from the service
data from the cache
data from the cache
data from the cache
data from the cache
data from the service
data from the cache
data from the cache
data from the cache
data from the cache