何时最适合使用Task.Result而不是等待Task?

21

虽然我已经使用.NET中的异步代码有一段时间了,但最近我才开始研究它并理解其中的原理。我正在检查我的代码,并尝试修改它,以便如果一个任务可以与某些工作并行完成,那么就这样做。例如:

var user = await _userRepo.GetByUsername(User.Identity.Name);

//Some minor work that doesn't rely on the user object

user = await _userRepo.UpdateLastAccessed(user, DateTime.Now);

return user;

现在变成:
var userTask = _userRepo.GetByUsername(User.Identity.Name);

//Some work that doesn't rely on the user object

user = await _userRepo.UpdateLastAccessed(userTask.Result, DateTime.Now);

return user;

我理解的是,用户对象现在正在从数据库中获取,与此同时也在进行一些不相关的工作。然而,我看到的一些帖子暗示着应该很少使用result属性,而是优先使用await,但我不明白为什么我想要等待我的用户对象被获取,如果我可以同时执行一些其他独立的逻辑?


可能是等待已完成的任务与任务结果相同?的重复问题。 - NineBerry
await 不会返回控制,但也不会阻塞线程。这就是它的全部作用。 - zaitsman
3
@zaitsman 技术上讲,对于一个未完成的任务使用await确实会返回控制权 - 其他所有操作都是继续执行;微妙之处在于,通常调用者都表现得很规矩,知道后台有一些事情正在进行,并相应地采取行动 - 但这并非必须。 - Marc Gravell
为什么不在尝试创建/填充用户对象之前完成//一些不依赖于用户对象的工作呢? - user2051770
4个回答

50

让我们确保不要掩盖主要内容:

例如:[一些正确的代码] 变成了 [一些错误的代码]

永远不要这样做。

你想要重新构建你的控制流以提高性能的直觉是非常好而且正确的。但是使用 Result 是错误的。

重写代码的正确方式是

var userTask = _userRepo.GetByUsername(User.Identity.Name);    
//Some work that doesn't rely on the user object    
user = await _userRepo.UpdateLastAccessed(await userTask, DateTime.Now);    
return user;

请注意,await不会使调用异步。它只是表示“如果此任务的结果尚不可用,请去做其他事情,等结果可用时再回到这里”。调用已经是异步的:它返回一个任务
人们似乎认为await具有协同调用的语义;实际上并不是这样。相反,await任务共同子代上提取操作;它是任务而不是调用表达式的运算符。通常你在方法调用中看到它只是因为将异步操作抽象为方法是一种常见模式。等待的是返回的任务,而不是调用

但是,我看到的一些帖子暗示应该很少使用result,而prefer await,但我不明白为什么我需要等待我的用户对象被获取,如果我可以同时执行一些独立的逻辑?

你为什么认为使用Result会让你能够同时执行其他独立的逻辑呢?Result正是防止这样做的。它是一个同步等待。当线程在同步等待任务完成时,它不能执行任何其他工作。使用异步等待可以提高效率。请记住,await只是表示“此工作流无法进一步进行,直到此任务完成,如果未完成,请查找更多工作并稍后返回”。过早地await可能会导致工作流程效率低下,因为有时即使任务尚未完成,工作流程也可以继续执行。 可以移动等待发生的位置以提高工作流程的效率,但决不要将它们变成Result。如果您认为使用Result能够提高工作流程的并行效率,则存在某种深层次的误解关于异步工作流程的工作方式。检查您的信念并看看是否能够找出哪个信念给您带来了这种错误的直觉。
之所以绝不能像这样使用Result,不仅因为在异步工作流程正在进行时同步等待效率低下,它最终会挂起您的进程。请考虑以下工作流程:
  • task1代表一个将在未来的线程上执行并产生结果的作业。
  • 异步函数Foo等待task1
  • task1尚未完成,因此Foo返回,允许该线程运行更多的工作。Foo返回表示其工作流程的任务,并注册完成该任务作为task1完成的标志。
  • 现在,该线程可以在未来做其他工作,包括task1
  • task1完成,触发执行Foo工作流程完成的操作,并最终完成表示Foo工作流程的任务。

现在假设Foo改为获取task1Result。会发生什么?Foo同步等待task1完成,而我们处于同步等待状态,因此当前线程无法可用。如果任务与当前线程关联,则调用Result会导致线程与自身死锁。现在可以制造不涉及任何锁和只有一个线程的死锁!不要这样做。


是的,谢谢Eric。我已经回头将我的代码更改为等待任务(await the Task),而不是使用task.Result。 我只是误解了.Result的作用,但现在我明白了。 - George Harnwell
2
@GeorgeHarnwell: 很好。现在考虑一下:Foo(DateTime.Now, await userTask);var user = await userTask; Foo(DateTime.Now, user); 之间的区别是什么?你看到为什么你可能更喜欢其中的一个了吗? - Eric Lippert
1
我能看到的唯一问题是,在第一个示例中调试时无法检查用户对象,但我想这其中还有更多的问题吧? - George Harnwell
2
@GeorgeHarnwell:如果等待一个长时间运行的异步任务需要,嗯,比如三分钟会发生什么? - Eric Lippert
@GeorgeHarnwell:部分原因是Eric所提到的问题,我不敢直接将DateTime.Now传递给函数。我的偏好是对于需要知道时间的函数,要么调用DateTime.Now(避免额外参数),要么传递一个变量(该变量被分配了DateTime.Now)。如果您有多个操作在"现在"进行,并且希望所有操作具有相同的日期时间(希望这样的分组操作不是public;在公共API中包含"告诉我现在是什么时间"是稍微有点糟糕的) ,那么后一种方法是合适的。 - Brian
显示剩余3条评论

11

异步等待并不意味着有多个线程在运行您的代码。

然而,它会减少线程空闲等待进程完成的时间,从而提前完成任务。

每当线程通常需要空闲等待某些事情完成时,例如等待网页下载、数据库查询完成或磁盘写入完成时,异步等待线程将不会空闲等待数据写入/获取,而是寻找其他可以做的事情,并在可等待任务完成后返回。

这在这个Eric Lippert的采访中用厨师类比进行了描述。在中间某处搜索异步等待。

Eric Lippert将异步等待与一个(!)必须制作早餐的厨师进行比较。在他开始烤面包之后,他可以等待面包烤好再烧水泡茶,在水沸腾之前等待再把茶叶放入茶壶中,等等。

异步等待的厨师不会等待烤好的面包,而是烧水,当水加热时,他会把茶叶放入茶壶中。

每当厨师需要空闲等待某些事情时,他都会四处寻找是否可以做其他事情。

异步函数中的线程会做类似的事情。因为该函数是异步的,您知道该函数中有一个等待。实际上,如果您忘记编写等待,则编译器将警告您。

当您的线程遇到await时,它会向上查看其调用堆栈以查看是否可以执行其他操作,直到它看到一个await,再次向上查看调用堆栈,等待所有人都在等待后,他将向下移动调用堆栈并开始空闲等待,直到第一个可等待进程完成。
等待进程完成后,线程将继续处理await之后的语句,直到再次看到await。
可能会有另一个线程继续处理await之后的语句(通过检查线程ID可以在调试器中看到此情况)。但是,这个其他线程具有原始线程的上下文,因此它可以像它是原始线程一样运行。不需要互斥体、信号量、IsInvokeRequired(在winforms中)等。对于您来说,似乎只有一个线程。
有时,您的厨师必须做一些需要花费时间而不是空闲等待的事情,比如切番茄。在这种情况下,聘请另一个厨师并命令他进行切片可能是明智的选择。同时,您的厨师可以继续煮好刚刚煮沸并需要剥皮的鸡蛋。
在计算机术语中,如果您有一些大型计算而不需要等待其他进程,则会出现这种情况。请注意与例如将数据写入磁盘的情况的区别。一旦您的线程已经命令需要将数据写入磁盘,它通常会空闲等待直到数据已经被写入。但是在进行大型计算时不是这种情况。
您可以使用Task.Run聘请额外的厨师。
async Task<DateTime> CalculateSunSet()
{
    // start fetching sunset data. however don't wait for the result yet
    // you've got better things to do:
    Task<SunsetData> taskFetchData = FetchSunsetData();

    // because you are not awaiting your thread will do the following:
    Location location = FetchLocation();

    // now you need the sunset data, start awaiting for the Task:
    SunsetData sunsetData = await taskFetchData;

    // some big calculations are needed, that take 33 seconds,
    // you want to keep your caller responsive, so start a Task
    // this Task will be run by a different thread:
    Task<DateTime> taskBigCalculations = Taks.Run( () => BigCalculations(sunsetData, location);

    // again no await: you are still free to do other things
    ...
    // before returning you need the result of the big calculations.
    // wait until big calculations are finished, keep caller responsive:
    DateTime result = await taskBigCalculations;
    return result;
}

1
这个解释帮助我理解了一个包含长时间运行任务的方法的结构,以及如何正确使用await。 - Roofus McDuffle

4
在您的情况下,您可以使用以下方法:
user = await _userRepo.UpdateLastAccessed(await userTask, DateTime.Now);

或许更加清晰地说:
var user = await _userRepo.GetByUsername(User.Identity.Name);
//Some work that doesn't rely on the user object
user = await _userRepo.UpdateLastAccessed(user, DateTime.Now);

只有在您知道任务已经完成的情况下才应该操作 .Result。这在某些情况下非常有用,例如您试图避免创建一个 async 状态机,并且您认为任务可能已经同步完成(也许使用本地函数来处理 async 情况),或者如果您正在使用回调而不是 async/await,并且您位于回调函数内部。

以下是避免状态机的示例:

ValueTask<int> FetchAndProcess(SomeArgs args) {
    async ValueTask<int> Awaited(ValueTask<int> task) => SomeOtherProcessing(await task);
    var task = GetAsyncData(args);
    if (!task.IsCompletedSuccessfully) return Awaited(task);
    return new ValueTask<int>(SomeOtherProcessing(task.Result));
}

这里的关键是,如果GetAsyncData返回一个同步完成的结果,我们就可以完全避免所有的async机制。

1
“或许更清晰地表达”这一行后,我不确定我理解你的代码。 它不是与 OP 报告的第一个示例完全相同吗? - Luca Cremonesi

0

你考虑过这个版本吗?

var userTask = _userRepo.GetByUsername(User.Identity.Name);

//Some work that doesn't rely on the user object

user = await _userRepo.UpdateLastAccessed(await userTask, DateTime.Now);

return user;

在获取到用户的同时执行“工作”,但这也具备了所有已完成任务上等待同Task.Result一样await优点。


如建议的那样,您也可以使用更明确的版本以便在调试器中检查调用结果。

var userTask = _userRepo.GetByUsername(User.Identity.Name);

//Some work that doesn't rely on the user object

user = await userTask;
user = await _userRepo.UpdateLastAccessed(user, DateTime.Now);

return user;

await放在调用内有点奇怪。这也使得调试更加困难 - 在传递给UpdateLastAccessed之前,您无法检查返回的用户对象。 - Panagiotis Kanavos
@PanagiotisKanavos - 如果任务没有完成,userTask.Result会抛出异常吗?我以为它会等待任务完成然后返回结果(回想起来,这并不是很合理)。 - George Harnwell
结果会等待任务完成,同时阻塞当前线程。不同之处在于 await 不会阻塞当前线程。 - NineBerry
1
@PanagiotisKanavos:这与Foo(Bar());x = Foo() + Bar();有何不同?在许多情况下,很难看到子表达式的结果;如果您想使这些更容易调试,请将子表达式放入赋值语句中并创建一个变量。 - Eric Lippert
@GeorgeHarnwell 即使如此,您也可以将所有异步代码提取到内部方法中,并仅在返回之前使用单个阻塞调用。 - Panagiotis Kanavos
显示剩余3条评论

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