你必须将Task.Run放在一个方法中才能使其异步吗?

376

我试图以最简单的形式理解async await。为了举例说明,我想创建一个非常简单的方法来添加两个数字,尽管在这个例子中它不需要任何处理时间,它只是一个制定示例的问题。

示例1

private async Task DoWork1Async()
{
    int result = 1 + 2;
}

例子2

private async Task DoWork2Async()
{
    Task.Run( () =>
    {
        int result = 1 + 2;
    });
}

如果我等待 DoWork1Async(),该代码是同步运行还是异步运行?

是否需要使用 Task.Run 包装同步代码,以使该方法可等待和异步运行,以避免阻塞 UI 线程?

我正在尝试确定我的方法是一个 Task 还是返回 Task<T>,我是否需要使用 Task.Run 将代码包装起来以使其异步运行。

我在网上看到一些示例,人们正在等待没有任何异步内容且未包装在 Task.RunStartNew 中的代码。


36
你的第一个片段没有给出任何警告吗? - svick
3个回答

703

首先,让我们澄清一些术语:“异步”(async)意味着它可能在开始之前将控制权还回给调用线程。在 async 方法中,这些“yield”点是 await 表达式。

这与多年来被 MSDN 文档误用为“在后台线程上执行”的术语“异步”非常不同。

更进一步混淆问题的是,async 与“可等待”非常不同;有一些返回类型不可等待的 async 方法,以及许多返回可等待类型但不是 async 的方法。

够说它们不是什么了;以下是它们是什么:

  • async 关键字允许异步方法(即允许 await 表达式)。async 方法可以返回 TaskTask<T> 或(如果必须)void
  • 遵循某种模式的任何类型都可以是可等待的。最常见的可等待类型是 TaskTask<T>

因此,如果我们将您的问题改为“如何以可等待的方式在后台线程上运行操作”,答案是使用 Task.Run

private Task<int> DoWorkAsync() // No async because the method does not need await
{
  return Task.Run(() =>
  {
    return 1 + 2;
  });
}

(但是这种模式是一个不好的方法;请参见下文。)

但是,如果你的问题是“如何创建一个async方法,它可以向其调用者yield而不是阻塞”,那么答案是声明该方法为async并在其“yielding”点使用await

private async Task<int> GetWebPageHtmlSizeAsync()
{
  var client = new HttpClient();
  var html = await client.GetAsync("http://www.example.com/");
  return html.Length;
}

所以,事物的基本模式是使async代码依赖于其await表达式中的“awaitables”。这些“awaitables”可以是其他async方法或只返回awaitable的常规方法。只返回Task/Task<T>的常规方法可以使用Task.Run在后台线程上执行代码,或者(更常见)它们可以使用TaskCompletionSource<T>或其快捷方式(TaskFactory.FromAsyncTask.FromResult等)。我不建议将整个方法包装在Task.Run中;同步方法应具有同步签名,并且应该由消费者决定是否将其包装在Task.Run中:

private int DoWork()
{
  return 1 + 2;
}

private void MoreSynchronousProcessing()
{
  // Execute it directly (synchronously), since we are also a synchronous method.
  var result = DoWork();
  ...
}

private async Task DoVariousThingsFromTheUIThreadAsync()
{
  // I have a bunch of async work to do, and I am executed on the UI thread.
  var result = await Task.Run(() => DoWork());
  ...
}

我在我的博客上写了一篇 async/await入门 的文章,在结尾处提供了一些好的后续资源。MSDN文档中有关于async的内容也非常不错。


12
是的,async 方法必须返回 TaskTask<T>voidTaskTask<T> 可以被等待(awaitable),而 void 则不行。 - Stephen Cleary
3
实际上,一个 async void 方法签名可以通过编译,但这是一个相当糟糕的想法,因为你会失去对异步任务的指针。 - IEatBagels
4
是的,它们可以编译通过,但void类型不能作为可等待对象。 - Stephen Cleary
4
@ohadinho:不,我在博客文章中所说的是当整个方法只是调用 Task.Run(例如此答案中的 DoWorkAsync)时。在UI上下文中使用 Task.Run调用方法是合适的(例如 DoVariousThingsFromTheUIThreadAsync)。 - Stephen Cleary
5
没错,使用 Task.Run 来调用方法是有效的,但如果整个方法(或几乎所有)都被包裹在 Task.Run 中,那就是一个反模式 - 只需将该方法保持同步并将 Task.Run 提升到更高层级。 - Stephen Cleary
显示剩余17条评论

30

在使用 async 修饰方法时,最重要的一点是该方法中至少有一个 await 操作符。 在您的示例中,我将使用 TaskCompletionSource 进行如下翻译。

private Task<int> DoWorkAsync()
{
    //create a task completion source
    //the type of the result value must be the same
    //as the type in the returning Task
    TaskCompletionSource<int> tcs = new TaskCompletionSource<int>();
    Task.Run(() =>
    {
        int result = 1 + 2;
        //set the result to TaskCompletionSource
        tcs.SetResult(result);
    });
    //return the Task
    return tcs.Task;
}

private async Task DoWork()
{
    int result = await DoWorkAsync();
}

38
为什么你使用TaskCompletionSource,而不是只返回Task.Run()方法返回的任务(并且更改其主体以返回结果)? - ironic
7
仅供参考:具有“async void”签名的方法通常被认为是糟糕的实践和坏代码,因为它很容易导致UI死锁。主要的例外情况是异步事件处理程序。 - Jazzeroki
1
不知道为什么“async void”被认为是“不良实践”,因为有很多应用场景可以使用它,基本上任何时候你需要做一些不关心何时结束的事情。 - Sasino
问题在于 async void 的使用,因为调用者无法等待方法的结果。他们无法访问 Task。所以你实际上忽略了这个任务。 - metoyou

19
当您使用Task.Run运行方法时,Task从线程池获取一个线程来运行该方法。因此,从UI线程的角度来看,它是“异步”的,因为它不会阻塞UI线程。对于桌面应用程序来说,这很好,因为通常不需要多个线程来处理用户交互。
然而,对于Web应用程序,每个请求都由线程池线程服务,因此通过保存这些线程来增加活动请求的数量是不可扩展的。经常使用线程池线程模拟异步操作对于Web应用程序来说是不可扩展的。
真正的异步操作不一定涉及使用线程进行I/O操作,例如文件/数据库访问等。您可以阅读此文章以了解为什么I/O操作不需要线程。http://blog.stephencleary.com/2013/11/there-is-no-thread.html 在您的简单示例中,它是一个纯CPU绑定的计算,因此使用Task.Run是可以的。

如果我必须在Web API控制器中使用同步外部API,那么我不应该将同步调用包装在Task.Run()中,对吗?正如您所说,这样做将保持初始请求线程未被阻塞,但它会使用另一个池线程来调用外部API。实际上,我认为这仍然是一个好主意,因为通过这种方式,理论上可以使用两个池线程来处理许多请求,例如一个线程可以处理许多传入的请求,而另一个线程可以为所有这些请求调用外部API? - dragonfly02
我同意。我并不是说你不应该在Task.Run()中绝对包装所有同步调用。我只是指出可能存在的问题。 - zheng yu
3
“@stt106 我不应该把同步调用包装在Task.Run()中是正确的。如果你这样做,那么你只是在切换线程。也就是说,你解除了初始请求线程的阻塞,但你又拿走了线程池中本可以用来处理其他请求的线程。这样做唯一的结果就是在完全没有任何收益的情况下增加了上下文切换开销。” - Saeb Amini

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接