在这篇文章中,我们将讨论 Task.Run 和 Task.Factory.StartNew 之间的区别。
如果我们曾经讨论过 C# 中基于任务的异步编程,几乎可以肯定我们会看到一些使用 Task.Run 或 Task.Factory.StartNew 的示例。它们是异步启动任务的最广泛使用的方法,在大多数情况下以类似的方式。这引发了一些共同的担忧:它们是否等效且可互换?或者它们有什么不同?或者推荐哪一个?
嗯,这些问题的答案需要深入了解这些Task
结构。我们将使用一个单元测试项目并比较它们在不同场景中的行为。此外,我们将讨论决定我们在特定用例中应该采用的方法的关键因素。
为简洁起见,我们将使用 justStartNew
而不是Task.Factory.StartNew
文章的其余部分。
开始吧。
Task.Run
有几个带参数的重载,和/或. 为简单起见,我们将从基本示例开始:Action/Func
CancellationToken
Task.Run(action)
void DoWork(int order, int durationMs)
{
Thread.Sleep(durationMs);
Console.WriteLine($"Task {order} executed");
}
var task1 = Task.Run(() => DoWork(1, 500));
var task2 = Task.Run(() => DoWork(2, 200));
var task3 = Task.Run(() => DoWork(3, 300));
Task.WaitAll(task1, task2, task3);
我们启动了三个运行时间不同的任务,每个任务最后都打印一条消息。在这里,我们Thread.Sleep
仅用于模拟正在运行的操作:
// Approximate Output:
Task 2 executed
Task 3 executed
Task 1 executed
虽然任务是一一排队的,但它们不会相互等待。结果,完成消息以不同的顺序弹出。
StartNew 启动任务现在,让我们使用以下方法准备相同示例的版本StartNew
:
var task1 = Task.Factory.StartNew(() => DoWork(1, 500));
var task2 = Task.Factory.StartNew(() => DoWork(2, 200));
var task3 = Task.Factory.StartNew(() => DoWork(3, 300));
Task.WaitAll(task1, task2, task3);
我们可以看到语法与Task.Run
版本完全相同,输出看起来也一样:
// Approximate Output:
Task 2 executed
Task 3 executed
Task 1 executed
因此,在我们的示例中,两个版本显然都在做同样的事情。其实Task.Run
是一个方便的快捷方式Task.Factory.StartNew
。它被有意设计用于代替StartNew
最常见的简单工作卸载到线程池的情况。因此,很容易得出结论,它们是彼此的替代品。但是,如果我们检查下面发生的事情,我们会发现明显的差异。
当我们调用基本StartNew(action)
方法时,就像调用这个重载:
Task.Factory.StartNew(action, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Current);
相比之下,当我们调用 时 Task.Run(action)
,它非常类似于:
Task.Factory.StartNew(action, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
我们称它为近似 等价物,因为当我们StartNew
用于async
委托时,情况略有不同。稍后我们将对此进行更多讨论。
揭示的语义清楚地表明了这一点,Task.Run(action)
并且在模式和 上下文方面StartNew(action)
有所不同。TaskCreationOptions
TaskScheduler
特别注意
StartNew
默认使用TaskScheduler.Current
可能是线程池,但也可能是 UI 线程。
因此,Task.Run
提供了一个有TaskCreationOptions.DenyChildAttach
限制的任务,但StartNew
不施加任何这样的限制。这意味着 我们不能将子任务附加到由 Task.Run
. 准确地说,在这种情况下,附加一个子任务对父任务没有影响,两个任务将独立运行。
让我们考虑一个StartNew
带有子任务的示例:
Task? innerTask = null;
var outerTask = Task.Factory.StartNew(() =>
{
innerTask = new Task(() =>
{
Thread.Sleep(300);
Console.WriteLine("Inner task executed");
}, TaskCreationOptions.AttachedToParent);
innerTask.Start(TaskScheduler.Default);
Console.WriteLine("Outer task executed");
});
outerTask.Wait();
Console.WriteLine($"Inner task completed: {innerTask?.IsCompleted ?? false}");
Console.WriteLine("Main thread exiting");
TaskCreationOptions.AttachedToParent
我们通过指令在外部任务范围内启动内部任务。在这里,我们使用普通的任务构造函数来创建内部任务,以从中立的角度来演示示例。
通过调用outerTask.Wait()
,我们让主线程等待外部任务的完成。外部任务本身并没有太多代码要执行,它只是启动内部任务并立即打印完成消息。然而,由于内部任务附加到父任务(即外部任务),外部任务将不会“完成”,直到内部任务完成。内部任务完成后,执行流程转到下一行
outerTask.Wait():
Outer task executed
Inner task executed
Inner task completed: True
Main thread exiting
现在,让我们看看在以下情况下会发生什么Task.Run
:
Task? innerTask = null;
var outerTask = Task.Run(() =>
{
innerTask = new Task(() =>
{
Thread.Sleep(300);
Console.WriteLine("Inner task executed");
}, TaskCreationOptions.AttachedToParent);
innerTask.Start(TaskScheduler.Default);
Console.WriteLine("Outer task executed");
});
outerTask.Wait();
Console.WriteLine($"Inner task completed: {innerTask?.IsCompleted ?? false}");
Console.WriteLine("Main thread exiting");
与前面的例子不同,这一次outerTask.Wait()
不等待内部任务完成,在外部任务执行后立即执行下一行。这是因为Task.Run
在内部以限制方式启动外部任务,TaskCreationOptions.DenyChildAttach
该限制拒绝TaskCreationOptions.AttachedToParent
来自子任务的请求。由于最后一行代码是在内部任务完成之前执行的,所以我们不会在输出中得到来自内部任务的消息:
Outer task executed
Inner task completed: False
Main thread exiting
简而言之, Task.Run
在 StartNew
涉及子任务时表现不同。
TaskScheduler
现在,让我们从上下文中谈谈差异。Task.Run(action)
内部使用 default TaskScheduler
,这意味着它总是将任务卸载到线程池。 StartNew(action)
,另一方面,使用当前线程的调度程序,可能根本不使用线程池!
这可能是一个值得关注的问题,尤其是当我们使用 UI 线程时!如果我们StartNew(action)
在 UI 线程中启动任务,它将利用 UI 线程的调度程序并且不会发生卸载。这意味着,如果任务是长期运行的,UI 很快就会变得无响应。Task.Run
没有这种风险,因为无论它是在哪个线程中启动的,它都会将工作卸载到线程池中。因此,Task.Run
在这种情况下是更安全的选择。
不同于StartNew
,Task.Run
是-async
感知的。这实际上是什么意思?
async
并且await
是异步编程世界的两个出色的补充。我们现在可以使用语言的控制流结构无缝地编写异步代码块,就像我们编写同步代码流并且编译器为我们完成其余的转换一样。Task
当我们从异步例程返回一些结果(或没有结果)时,我们不需要担心显式构造。但是,当我们使用以下代码时,这种编译器驱动的转换可能会导致意想不到的结果(从开发人员的角度来看)StartNew
:
var task = Task.Factory.StartNew(async () =>
{
await Task.Delay(500);
return "Calculated Value";
});
Console.WriteLine(task.GetType()); // System.Threading.Tasks.Task`1[System.Threading.Tasks.Task`1[System.String]]
var innerTask = task.Unwrap();
Console.WriteLine(innerTask.GetType()); // System.Threading.Tasks.UnwrapPromise`1[System.String]
Console.WriteLine(innerTask.Result); // Calculated Value
我们启动一个将委托的异步例程排队的任务。由于该async
关键字,编译器将此委托映射为该委托,该委托Func
又返回一个Task
on 调用。最重要的是,StartNew
将其包装在一个Task
构造中。最终,我们会得到一个Task
不是我们想要的实例。我们必须调用Unwrap
扩展方法来访问我们预期的内部任务实例。这当然不是问题StartNew
,只是没有设计async
意识。但是,Task.Run
在设计时考虑了这种情况,它在内部执行了这个展开的事情:
var task = Task.Run(async () =>
{
await Task.Delay(500);
return "Calculated Value";
});
Console.WriteLine(task.GetType()); // System.Threading.Tasks.UnwrapPromise`1[System.String]
Console.WriteLine(task.Result); // Calculated Value
正如我们所料,我们不需要Unwrap
在Task.Run
.
一注。出于测试目的,我们使用 和的Result
属性。但是您应该小心,因为 Result 属性可能会导致应用程序死锁。我们在 ASP.NET Core 中使用 Async 和 Await 进行异步编程一文中讨论过这一点task
innerTask
每当我们处理异步例程时,我们都需要注意“状态突变”。让我们考虑在一个循环中启动一堆任务:
var tasks = new List();
for (var i = 1; i < 4; i++)
{
var task = Task.Run(async () =>
{
await Task.Delay(100);
Console.WriteLine($"Iteration {i}");
});
tasks.Add(task);
}
Task.WaitAll(tasks.ToArray());
我们使用一个for
循环Task.Run
来启动三个任务,每个任务都应该打印当前的迭代次数i
(
Iteration 4
Iteration 4
Iteration 4
这是因为,当任务开始执行时,变量的状态i
(范围在迭代块之外)已经改变并达到其最终值 4。解决此问题的一种方法是存储该值在i
迭代块内的局部变量中:
var tasks = new List();
for (var i = 1; i < 4; i++)
{
var iteration = i;
var task = Task.Run(async () =>
{
await Task.Delay(100);
Console.WriteLine($"Iteration {iteration}");
});
tasks.Add(task);
}
Task.WaitAll(tasks.ToArray());
现在,我们得到了想要的输出:
Iteration 3
Iteration 1
Iteration 2
但是,存在性能问题。由于 lambda 变量捕获,该变量有额外的内存分配iteration
。尽管在这个最简单的示例中这不是显着的开销,但在涉及许多变量的复杂例程中,这可能是一个主要问题。Task.Run
没有为此提供任何解决方案,但是StartNew
可以!StartNew
提供了几个接受状态对象的重载,其中之一是:
public Task StartNew (Action action, object state);
这提供了一种更好的方法来克服状态突变问题,而不会增加额外的内存分配开销:
var tasks = new List();
for (var i = 1; i < 4; i++)
{
var task = Task.Factory.StartNew(async (iteration) =>
{
await Task.Delay(100);
Console.WriteLine($"Iteration {iteration}");
}, i)
.Unwrap();
tasks.Add(task);
}
Task.WaitAll(tasks.ToArray());
正如我们所见,StartNew
捕获当前值i
并将这个不可变状态传递给委托操作。我们不再需要本地副本:
// Approximate Output:
Iteration 1
Iteration 3
Iteration 2
总体而言,StartNew
提供了一种方法来避免由于委托中的 lambda 变量捕获而导致的闭包和内存分配,因此可能会带来一些性能提升。也就是说,这种性能提升并不能得到保证,并且可能不足以产生任何影响。因此,如果某个任务使用的内存分析表明传递状态对象会带来显着的好处,我们应该StartNew
在那里使用。
我们现在知道Task.Run
总是使用默认的任务调度器。默认调度程序使用ThreadPool
它提供了一些强大的优化功能,包括用于负载平衡和线程注入/退休的工作窃取。一般来说,它有助于实现最大吞吐量和良好的性能。
因此,当然,我们希望主要使用默认调度程序。然而,在实际应用中,业务情况可能需要复杂的工作分配算法,需要我们自己的任务调度机制。例如,我们可能想要限制并发任务的数量。或者,我们可以考虑一个支持请求引擎,它可能需要优先处理紧急请求并重新安排琐碎的待处理请求。在这种情况下,我们需要自定义调度程序实现。由于没有一个Task.Run
重载接受TaskScheduler
参数,StartNew
因此这里是可行的选择。
我们已经看到通过Task.Run和Task.Factory.StartNew处理异步任务的各种场景。我们应该主要Task.Run
用于一般工作卸载目的,因为它是最方便和优化的方式。当我们需要在子任务处理、任务调度、绕过线程池或一些经过验证的内存优化好处方面进行高级定制时,我们才应该考虑使用StartNew
.
简而言之,Task.Run
为我们提供了便利和内置优化的好处,同时StartNew
提供了定制的灵活性。
在本文中,我们了解了Task.Run和Task.Factory.StartNew之间的区别。我们已经讨论了一些高级用例,哪些StartNew
是可行的选择,否则Task.Run
通常是推荐的方法。