Task.Run 和 Task.Factory.StartNew 之间的区别

在这篇文章中,我们将讨论 Task.Run 和 Task.Factory.StartNew 之间的区别。

要下载本文的源代码,您可以访问我们的GitHub 存储库

如果我们曾经讨论过 C# 中基于任务的异步编程,几乎可以肯定我们会看到一些使用 Task.Run 或 Task.Factory.StartNew 的示例。它们是异步启动任务的最广泛使用的方法,在大多数情况下以类似的方式。这引发了一些共同的担忧:它们是否等效且可互换?或者它们有什么不同?或者推荐哪一个?

嗯,这些问题的答案需要深入了解这些Task结构。我们将使用一个单元测试项目并比较它们在不同场景中的行为。此外,我们将讨论决定我们在特定用例中应该采用的方法的关键因素。

为简洁起见,我们将使用 justStartNew而不是Task.Factory.StartNew文章的其余部分。

开始吧。

Task.Run 启动任务

Task.Run有几个带参数的重载,和/或. 为简单起见,我们将从基本示例开始:Action/FuncCancellationTokenTask.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 的区别

因此,在我们的示例中,两个版本显然都在做同样的事情。其实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)有所不同。TaskCreationOptionsTaskScheduler

特别注意

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涉及子任务时表现不同

默认 vs 当前任务调度器

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<string>>又返回一个Task<string>on 调用。最重要的是,StartNew将其包装在一个Task构造中。最终,我们会得到一个Task<Task<string>>不是我们想要的实例。我们必须调用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

正如我们所料,我们不需要UnwrapTask.Run.

一注。出于测试目的,我们使用 和的Result属性。但是您应该小心,因为 Result 属性可能会导致应用程序死锁。我们在 ASP.NET Core 中使用 Async 和 Await 进行异步编程一文中讨论过这一点taskinnerTask

Task.Run 和 Task.Factory.StartNew 与对象状态之间的区别

每当我们处理异步例程时,我们都需要注意“状态突变”。让我们考虑在一个循环中启动一堆任务:

var tasks = new List<Task>();
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<Task>();
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<object> action, object state);

这提供了一种更好的方法来克服状态突变问题,而不会增加额外的内存分配开销:

var tasks = new List<Task>();
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.RunTask.Factory.StartNew处理异步任务的各种场景。我们应该主要Task.Run用于一般工作卸载目的,因为它是最方便和优化的方式。当我们需要在子任务处理、任务调度、绕过线程池或一些经过验证的内存优化好处方面进行高级定制时,我们才应该考虑使用StartNew.

简而言之,Task.Run为我们提供了便利和内置优化的好处,同时StartNew提供了定制的灵活性。

结论

在本文中,我们了解了Task.RunTask.Factory.StartNew之间的区别。我们已经讨论了一些高级用例,哪些StartNew是可行的选择,否则Task.Run通常是推荐的方法。

  • 6
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值