等待多个具有不同结果的任务

396

我有三个任务:

private async Task<Cat> FeedCat() {}
private async Task<House> SellHouse() {}
private async Task<Tesla> BuyCar() {}

在我的代码继续之前,它们都需要运行,并且我也需要每个任务的结果。这些结果彼此没有任何共同点。

我该如何调用和等待这3个任务完成,然后获取结果?


38
您有任何订购要求吗?也就是说,您是否希望在喂完猫之后再出售房屋? - Eric Lippert
12个回答

688

使用WhenAll后,您可以使用await逐个获取结果:

var catTask = FeedCat();
var houseTask = SellHouse();
var carTask = BuyCar();

await Task.WhenAll(catTask, houseTask, carTask);

var cat = await catTask;
var house = await houseTask;
var car = await carTask;

[请注意,异步方法始终返回“热”(已启动)任务。]

您还可以使用 Task.Result(因为此时您已经知道它们都已成功完成)。不过,我建议使用 await,因为它更加正确明确,而Result在其他情况下可能会导致问题。


131
你可以完全删除这里的WhenAll;等待操作将确保在所有任务完成之前,您不会继续执行后面的三个赋值操作。 - Servy
246
Task.WhenAll()可以让任务以并行模式运行。我不明白为什么@Servy建议将其删除。没有WhenAll,它们将一个接一个地运行。 - SerjG
148
@Sergey: 这些任务立即开始执行。例如,当从 FeedCat 返回时,catTask 已经在运行。因此,任何一种方法都可以 - 唯一的问题是是否想一个接一个地使用 await 或同时使用。错误处理略有不同 - 如果使用 Task.WhenAll,则它将等待所有任务完成,即使其中一个任务早期失败也是如此。 - Stephen Cleary
44
调用 WhenAll 对操作的执行时间和方式没有影响,它仅仅可能影响结果的观察方式。在这种特殊情况下,唯一的区别是前两个方法中出现错误会导致异常比 Stephen 的方法更早地在我的方法中抛出(尽管如果有任何错误都会抛出相同的错误)。 - Servy
61
关键在于异步方法始终返回“热”(已启动)任务。 - Stephen Cleary
显示剩余32条评论

161

启动所有任务后,只需单独 await 这三个任务:

var catTask = FeedCat();
var houseTask = SellHouse();
var carTask = BuyCar();

var cat = await catTask;
var house = await houseTask;
var car = await carTask;

注意:如果任何一个任务抛出异常,此代码可能会在后续任务完成之前返回异常,但它们仍将运行。在几乎所有情况下,在已知结果的情况下不等待是可取的。在极端情况下,可能并非如此。


16
不,那是错误的。他们将并行地完成他们的工作。请放心运行它并亲自查看。 - Servy
14
多年过去了,人们仍在问同一个问题...我感觉有必要再次强调一下,在答案中明确提到任务“在创建时就开始”,或许他们不会注意评论区的内容。 - user6996876
19
在这段话中,@StephenYork添加Task.WhenAll对程序的行为没有任何实际影响,它只是一次多余的方法调用。如果您喜欢,可以将其添加为美学选择,但它不会改变代码的功能。代码的执行时间与或无该方法调用是相同的(严格来说,调用WhenAll会有一个极小的开销,但应该可以忽略不计),仅使该版本比另一个版本稍微更长一些。 - Servy
9
你的示例代码之所以按顺序运行操作有两个原因。你的异步方法实际上不是异步的,而是同步的。因为你有一些始终返回已完成任务的同步方法,这会防止它们并发运行。其次,你没有像这个答案中展示的那样先启动所有三个异步方法,然后依次等待三个任务。你的示例代码在上一个方法完成之前不会调用下一个方法,明确阻止了下一个方法的启动,直到前一个方法完成,这与这段代码不同。 - Servy
18
这是可证明不正确的,正如在这里和其他答案的评论中所讨论的那样。添加“WhenAll”只是纯粹的美学改变。行为上唯一可以观察到的差异是,如果较早的任务出现故障,您是否等待后续任务完成,通常没有必要这样做。如果您不相信关于为什么您的说法不正确的众多解释,您可以直接运行代码并查看它的真实情况。 - Servy
显示剩余30条评论

56

如果您正在使用C# 7,您可以使用这样一个方便的包装方法...

public static class TaskEx
{
    public static async Task<(T1, T2)> WhenAll<T1, T2>(Task<T1> task1, Task<T2> task2)
    {
        return (await task1, await task2);
    }
}

...以便在您希望等待具有不同返回类型的多个任务时启用方便的语法。当然,您必须为不同数量的任务进行多个重载等待。

var (someInt, someString) = await TaskEx.WhenAll(GetIntAsync(), GetStringAsync());

然而,如果您想将这个示例用于实际应用,请查看Marc Gravell的答案,了解有关ValueTask和已完成任务的一些优化。


我知道元组和C# 7。我的意思是我找不到返回元组的WhenAll方法。它在哪个命名空间/包中? - Yury Scherbakov
1
@YuryShcherbakov Task.WhenAll() 没有返回元组。在由 Task.WhenAll() 返回的任务完成后,将从提供的任务的 Result 属性构造一个元组。 - Chris Charabaruk
2
我建议根据Stephen的理由替换.Result调用,以避免其他人通过复制您的示例来延续不良实践。 - julealgon
1
@nrofis 这是错误的。两个任务都被创建并启动,然后才等待它们。这相当于 Servy 的答案 - MarredCheese
抱歉,你是正确的。 - nrofis
显示剩余3条评论

34
给出三个任务 - FeedCat()SellHouse()BuyCar(),有两种有趣的情况:它们要么全部同步完成(由于某种原因,例如缓存或错误),要么它们不会。

假设我们已经从问题中得到:

Task<string> DoTheThings() {
    Task<Cat> x = FeedCat();
    Task<House> y = SellHouse();
    Task<Tesla> z = BuyCar();
    // what here?
}

现在,一个简单的方法是:

Task.WhenAll(x, y, z);

但是......这对于处理结果来说并不方便;通常我们会想要使用 await

async Task<string> DoTheThings() {
    Task<Cat> x = FeedCat();
    Task<House> y = SellHouse();
    Task<Tesla> z = BuyCar();

    await Task.WhenAll(x, y, z);
    // presumably we want to do something with the results...
    return DoWhatever(x.Result, y.Result, z.Result);
}

但这样做会产生很多开销,并分配各种数组(包括params Task[]数组)和列表(内部)。虽然可以工作,但我认为不是很好。在许多方面,使用async操作并依次await每个操作会更加简单:

async Task<string> DoTheThings() {
    Task<Cat> x = FeedCat();
    Task<House> y = SellHouse();
    Task<Tesla> z = BuyCar();

    // do something with the results...
    return DoWhatever(await x, await y, await z);
}
与上面的一些评论相反,使用await而不是Task.WhenAll对任务运行方式(并发、顺序等)没有任何影响。在最高层次上,Task.WhenAll 早于async/await的良好编译器支持,并且在这些东西不存在时非常有用。它还在您拥有任意数量的任务数组时非常有用,而不仅仅是3个离散任务。
但问题仍然存在:async/await为继续生成了大量编译器噪声。如果可能,任务可能实际上是同步完成的,那么我们可以通过构建带有异步回退的同步路径来优化此过程:
Task<string> DoTheThings() {
    Task<Cat> x = FeedCat();
    Task<House> y = SellHouse();
    Task<Tesla> z = BuyCar();

    if(x.Status == TaskStatus.RanToCompletion &&
       y.Status == TaskStatus.RanToCompletion &&
       z.Status == TaskStatus.RanToCompletion)
        return Task.FromResult(
          DoWhatever(a.Result, b.Result, c.Result));
       // we can safely access .Result, as they are known
       // to be ran-to-completion

    return Awaited(x, y, z);
}

async Task Awaited(Task<Cat> a, Task<House> b, Task<Tesla> c) {
    return DoWhatever(await x, await y, await z);
}

这种“同步路径与异步回退”的方法在高性能代码中越来越常见,特别是当同步完成相对频繁时。请注意,如果完成始终是真正的异步,则此方法将完全无效。

以下是适用于此处的其他事项:

  1. 使用最新的C#,常见的模式是将async回退方法实现为本地函数:

  2. Task<string> DoTheThings() {
        async Task<string> Awaited(Task<Cat> a, Task<House> b, Task<Tesla> c) {
            return DoWhatever(await a, await b, await c);
        }
        Task<Cat> x = FeedCat();
        Task<House> y = SellHouse();
        Task<Tesla> z = BuyCar();
    
        if(x.Status == TaskStatus.RanToCompletion &&
           y.Status == TaskStatus.RanToCompletion &&
           z.Status == TaskStatus.RanToCompletion)
            return Task.FromResult(
              DoWhatever(a.Result, b.Result, c.Result));
           // we can safely access .Result, as they are known
           // to be ran-to-completion
    
        return Awaited(x, y, z);
    }
    
  3. 如果有很大可能性在完全同步的情况下返回许多不同的值,请使用ValueTask<T>而不是Task<T>

  4. ValueTask<string> DoTheThings() {
        async ValueTask<string> Awaited(ValueTask<Cat> a, Task<House> b, Task<Tesla> c) {
            return DoWhatever(await a, await b, await c);
        }
        ValueTask<Cat> x = FeedCat();
        ValueTask<House> y = SellHouse();
        ValueTask<Tesla> z = BuyCar();
    
        if(x.IsCompletedSuccessfully &&
           y.IsCompletedSuccessfully &&
           z.IsCompletedSuccessfully)
            return new ValueTask<string>(
              DoWhatever(a.Result, b.Result, c.Result));
           // we can safely access .Result, as they are known
           // to be ran-to-completion
    
        return Awaited(x, y, z);
    }
    
  5. 如果可能的话,优先使用IsCompletedSuccessfully而不是Status == TaskStatus.RanToCompletion;这在.NET Core中现已存在于Task,并且对于ValueTask<T>来说无处不在。


与此处的各种答案相反,使用await而不是Task.WhenAll对任务的运行方式(并发、顺序等)没有影响。我没有看到任何回答说过这样的话。如果有的话,我早就会评论并指出了。有很多评论在很多答案上说了这个问题,但没有答案。你指的是哪一个?另外请注意,你的答案没有处理任务的结果(或者处理结果类型不同的事实)。你已经将它们组合成一个方法,在所有任务完成后只返回一个“Task”,而没有使用结果。 - Servy
@Servy 您是对的,那是注释;我会添加一个小修改来显示使用结果。 - Marc Gravell
1
@Servy 这是一个复杂的话题 - 你会从两种情况中得到不同的异常语义 - 等待触发异常的行为与访问.Result以触发异常的行为不同。在我看来,在这一点上,我们应该await以获得“更好”的异常语义,假设异常是罕见但有意义的。 - Marc Gravell
1
@Almis,你怎么知道它是同步运行的?你有一个最小可运行的代码吗?这是我拥有的,并且它说“async”:https://gist.github.com/mgravell/8e08a7d3dbf3bbfc17ff46c494a59150 - Marc Gravell
4
我仔细阅读了你的回答,并意识到我误解了你关于任务同步完成的声明。我不明白的是,你说Task.WhenAll没有区别。但是我确实看到了Task.WhenAll和在每次迭代中等待之间有明显的区别。如果我创建10个带500毫秒延迟的等待,并使用Task.WhenAll一起启动它们,它们会在不到1秒内完成。而如果我为每个10个等待都等待 - 它们将按顺序执行(正如我所预期的那样),并且需要约5秒钟才能完成。 - Almis
显示剩余4条评论

14

如果你想记录所有的错误,请确保在你的代码中保留Task.WhenAll这行代码,很多评论建议你可以删除它并等待单个任务。Task.WhenAll对于错误处理非常重要。如果没有这行代码,你有可能会让你的代码面临未被观察到的异常风险。

var catTask = FeedCat();
var houseTask = SellHouse();
var carTask = BuyCar();

await Task.WhenAll(catTask, houseTask, carTask);

var cat = await catTask;
var house = await houseTask;
var car = await carTask;

假设以下代码中的 FeedCat 抛出了异常:

var catTask = FeedCat();
var houseTask = SellHouse();
var carTask = BuyCar();

var cat = await catTask;
var house = await houseTask;
var car = await carTask;

在这种情况下,您将永远不会等待houseTask或carTask。这里有3种可能的情况:

  1. 当FeedCat失败时,SellHouse已经成功完成。在这种情况下,一切都很好。

  2. SellHouse未完成并且在某个点上失败了。异常未被观察到并将在finalizer线程上重新抛出。

  3. SellHouse未完成并且其中包含等待。如果您的代码在ASP.NET中运行,则只要其中一些等待完成,SellHouse就会失败。这是因为您基本上进行了“fire & forget”调用,并且在FeedCat失败后同步上下文已丢失。

以下是第三种情况的错误信息:

System.AggregateException: A Task's exception(s) were not observed either by Waiting on the Task or accessing its Exception property. As a result, the unobserved exception was rethrown by the finalizer thread. ---> System.NullReferenceException: Object reference not set to an instance of an object.
   at System.Web.ThreadContext.AssociateWithCurrentThread(Boolean setImpersonationContext)
   at System.Web.HttpApplication.OnThreadEnterPrivate(Boolean setImpersonationContext)
   at System.Web.HttpApplication.System.Web.Util.ISyncContext.Enter()
   at System.Web.Util.SynchronizationHelper.SafeWrapCallback(Action action)
   at System.Threading.Tasks.Task.Execute()
   --- End of inner exception stack trace ---
---> (Inner Exception #0) System.NullReferenceException: Object reference not set to an instance of an object.
   at System.Web.ThreadContext.AssociateWithCurrentThread(Boolean setImpersonationContext)
   at System.Web.HttpApplication.OnThreadEnterPrivate(Boolean setImpersonationContext)
   at System.Web.HttpApplication.System.Web.Util.ISyncContext.Enter()
   at System.Web.Util.SynchronizationHelper.SafeWrapCallback(Action action)
   at System.Threading.Tasks.Task.Execute()<---

对于情况(2),你会得到类似的错误,但同时还有原始异常堆栈跟踪。

在.NET 4.0及更高版本中,您可以使用TaskScheduler.UnobservedTaskException捕获未观察到的异常。在.NET 4.5及更高版本中,默认情况下会吞噬未观察到的异常,而对于.NET 4.0未观察到的异常会使进程崩溃。

更多细节请参见:.NET 4.5中的任务异常处理


2
这个应该被点赞。我很惊讶上面所有的讨论都在说Task.WhenAll和单独等待是等价的。但事实并非如此。如果任何一个任务抛出异常,其他任务可能无法完成或者你可能会丢失它们的错误信息。由于我的场景涉及到对所有任务的错误处理以及在某些情况下重试,所以在我添加了Task.WhenAll之前,我的代码并没有按预期工作。感谢您指出这一点。 - gabnaim
1
为什么在调用await Task.WhenAll之后还要等待任务?直接调用catTask.Result会不会更好,因为await操作符会在状态机中生成不必要的代码?在await Task.WhenAll之后,我能够安全地使用Result属性吗,或者是我理解错了什么? - Florent
1
你是对的,在这里调用.Result是安全的。只有当所有任务都完成且每个任务没有错误时,我们才会到达单个任务等待。因此,await和.Result将产生相同的结果,而.Results将具有较少的指令。但是通常情况下,当您在代码中看到.Result时,这是一个触发器,用于检查死锁问题和错误处理是否已解决。因此,除非代码不是性能关键,否则我会坚持使用await。更多信息请参见-https://dev59.com/h2Af5IYBdhLWcg3wdCh1#24657079 - samfromlv

12
您可以将它们存储在任务中,然后等待它们全部完成:
var catTask = FeedCat();
var houseTask = SellHouse();
var carTask = BuyCar();

await Task.WhenAll(catTask, houseTask, carTask);

Cat cat = await catTask;
House house = await houseTask;
Car car = await carTask;

2
“var catTask = FeedCat()” 不是执行函数 “FeedCat()”,并将结果存储到 “catTask” 中,使得 “await Task.WhenAll()” 部分有点无用吗?因为该方法已经执行了? - Kraang Prime
2
@sanuel 如果它们返回任务<t>,那么它们会启动异步打开,但不会等待它完成。 - Reed Copsey
2
如果我需要添加.ConfigrtueAwait(false),我是将其添加到Task.WhenAll还是每个随后的awaiter中? - AstroSharp
1
@KraangPrime 不,这并不是无用的。如果任务已经完成,那么它确实不会有任何区别,但如果这些是长时间运行的任务,那么这种方法意味着每个任务都并发运行,并且在所有任务完成之前,没有任何东西可以继续进行...尽管一个任务可能会立即完成。 - Stephen York
2
@user44 它们将并行运行 - 如果没有WhenAll,你可能会在house完成之前得到Cat的结果,这可能很重要,也可能不重要。 - Reed Copsey
显示剩余3条评论

5
您可以像提到的那样使用Task.WhenAll,或者使用Task.WaitAll,具体取决于您是否希望线程等待。查看链接以了解两者的解释。 WaitAll和WhenAll的区别

4

前置警告

如果您正在访问此类线程并寻找使用async+await+task工具集并行化EntityFramework的方法,请注意:这里展示的模式是可靠的,但是,当涉及到EF这个特殊的项目时,除非您在每个*Async()调用中使用一个单独(新的)db-context实例,否则您将无法实现并行执行。

由于ef-db-context的固有设计限制禁止在同一ef-db-context实例中并行运行多个查询,因此需要进行这种操作。


利用已经给出的答案,以下是确保即使其中一个或多个任务结果出现异常也能收集所有值的方法:

  public async Task<string> Foobar() {
    async Task<string> Awaited(Task<Cat> a, Task<House> b, Task<Tesla> c) {
        return DoSomething(await a, await b, await c);
    }

    using (var carTask = BuyCarAsync())
    using (var catTask = FeedCatAsync())
    using (var houseTask = SellHouseAsync())
    {
        if (carTask.Status == TaskStatus.RanToCompletion //triple
            && catTask.Status == TaskStatus.RanToCompletion //cache
            && houseTask.Status == TaskStatus.RanToCompletion) { //hits
            return Task.FromResult(DoSomething(catTask.Result, carTask.Result, houseTask.Result)); //fast-track
        }

        cat = await catTask;
        car = await carTask;
        house = await houseTask;
        //or Task.AwaitAll(carTask, catTask, houseTask);
        //or await Task.WhenAll(carTask, catTask, houseTask);
        //it depends on how you like exception handling better

        return Awaited(catTask, carTask, houseTask);
   }
 }

另一个实现方案,其性能特征与原方案相差不大,可能是:

 public async Task<string> Foobar() {
    using (var carTask = BuyCarAsync())
    using (var catTask = FeedCatAsync())
    using (var houseTask = SellHouseAsync())
    {
        cat = catTask.Status == TaskStatus.RanToCompletion ? catTask.Result : (await catTask);
        car = carTask.Status == TaskStatus.RanToCompletion ? carTask.Result : (await carTask);
        house = houseTask.Status == TaskStatus.RanToCompletion ? houseTask.Result : (await houseTask);

        return DoSomething(cat, car, house);
     }
 }

0

你例子中的三个任务在重要性上有很大的不同。如果其中一个失败了,你可能想知道其他任务发生了什么。例如,如果与自动猫喂食器的通信失败,你不想错过房子出售成功或失败的消息。因此,返回的不仅应该是 CatHouseTesla,还应该包括任务本身。调用代码将能够单独查询每个任务,并根据它们的成功或失败情况做出适当的反应:

public async Task<(Task<Cat>, Task<House>, Task<Tesla>)> FeedCatSellHouseBuyCar()
{
    Task<Cat> task1 = FeedCat();
    Task<House> task2 = SellHouse();
    Task<Tesla> task3 = BuyCar();

    // All three tasks are launched at this point.

    try { await Task.WhenAll(task1, task2, task3).ConfigureAwait(false); } catch { }

    // All three tasks are completed at this point.
    
    return (task1, task2, task3);
}

使用示例:

var (catTask, houseTask, teslaTask) = await FeedCatSellHouseBuyCar();

// All three tasks are completed at this point.

if (catTask.IsCompletedSuccessfully)
    Console.WriteLine($"{catTask.Result.Name} is eating her healthy meal.");
else
    Console.WriteLine("Your cat is starving!");

if (houseTask.IsCompletedSuccessfully)
    Console.WriteLine($"Your house at {houseTask.Result.Address} was sold. You are now rich and homeless!");
else
    Console.WriteLine("You are still the poor owner of your house.");

if (teslaTask.IsCompletedSuccessfully)
    Console.WriteLine($"You are now the owner a battery-powered {teslaTask.Result.Name}.");
else
    Console.WriteLine("You are still driving a Hyundai.");

使用空的catchtry块是必需的,因为.NET 7仍然没有提供适当的方法在取消或失败的情况下等待任务而不抛出异常。


0

使用 Task.WhenAll 然后等待结果:

var tCat = FeedCat();
var tHouse = SellHouse();
var tCar = BuyCar();
await Task.WhenAll(tCat, tHouse, tCar);
Cat cat = await tCat;
House house = await tHouse;
Tesla car = await tCar; 
//as they have all definitely finished, you could also use Task.Value.

可能在2013年之前有过,但现在不是Task.Value,而是tCat.Result、tHouse.Result或tCar.Result。 - Stephen York

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