如何使用LINQ异步等待任务列表?

95

我有一个任务列表,是这样创建的:

public async Task<IList<Foo>> GetFoosAndDoSomethingAsync()
{
    var foos = await GetFoosAsync();

    var tasks = foos.Select(async foo => await DoSomethingAsync(foo)).ToList();

    ...
}

通过使用.ToList(),所有任务都应该开始了。现在我想等待它们完成并返回结果。

这在上面的...块中有效:

var list = new List<Foo>();
foreach (var task in tasks)
    list.Add(await task);
return list;

它能够实现我想要的功能,但这种方式似乎有些笨拙。我更愿意写出类似于这样更简单的代码:

return tasks.Select(async task => await task).ToList();

...但这段代码无法编译。我缺少了什么?还是说用这种方式无法表达这些内容?


дҪ йңҖиҰҒжҢүйЎәеәҸеӨ„зҗҶжҜҸдёӘfooзҡ„DoSomethingAsync(foo)пјҢиҝҳжҳҜеҸҜд»ҘдҪҝз”ЁParallel.ForEach<Foo>жқҘ并иЎҢеӨ„зҗҶпјҹ - mdisibio
1
@mdisibio - Parallel.ForEach 是阻塞的。这里的模式来自Jon Skeet在Pluralsight上的异步C#视频。它可以并行执行而不会阻塞。 - Matt Johnson-Pint
@mdisibio - 不对,它们是并行运行的。试一下。(此外,如果只使用WhenAll,看起来我不需要.ToList()。) - Matt Johnson-Pint
明白了。根据DoSomethingAsync的编写方式,列表可能会并行执行,也可能不会。我能够编写一个测试方法,其中一个版本是并行的,另一个版本则不是,但无论哪种情况,行为都由方法本身而非创建任务的委托所决定。对于混淆造成的困扰,我感到很抱歉。但是,如果DoSomethingAsyc返回Task<Foo>,那么委托中的await并不是绝对必要的……我想这就是我要尝试表达的主要观点。 - mdisibio
GetFoosAsync() 返回什么?@MattJohnson-Pint - ntrch
6个回答

152

LINQ 在处理 async 代码时表现不完美,但你可以这样做:

var tasks = foos.Select(DoSomethingAsync).ToList();
await Task.WhenAll(tasks);

如果你的任务都返回相同类型的值,那么你甚至可以这样做:

var results = await Task.WhenAll(tasks);

这很不错。WhenAll返回一个数组,因此我认为您的方法可以直接返回结果:

return await Task.WhenAll(tasks);

12
想指出这个方法也适用于 var tasks = foos.Select(foo => DoSomethingAsync(foo)).ToList(); - mdisibio
5
Linq为什么不能与异步代码完美地配合使用? - Ehsan Sajjad
3
LINQ to Objects 同步地对内存中的对象进行操作。一些有限的操作可以使用,比如 Select,但大部分操作不行,比如 Where - Stephen Cleary
5
如果操作基于I/O,那么您可以使用"async"来减少线程;如果它是CPU密集型的,并且已经在后台线程上运行,那么"async"不会带来任何好处。 - Stephen Cleary
1
一次只能执行一个 await。EF 支持 await,但不支持在同一个上下文中进行多个同时的请求,因此您不能像我在此处的答案那样使用 Select 后跟 WhenAll(除非您使用多个 db 上下文)。 - Stephen Cleary
显示剩余6条评论

8
为了进一步解释Stephen的答案,我创建了以下扩展方法来保持LINQ的流畅风格。然后你可以这样做:
await someTasks.WhenAll()

namespace System.Linq
{
    public static class IEnumerableExtensions
    {
        public static Task<T[]> WhenAll<T>(this IEnumerable<Task<T>> source)
        {
            return Task.WhenAll(source);
        }
    }
}

11
我会将你的扩展方法命名为ToArrayAsync - torvin

4

Task.WhenAll存在一个问题,它会创建并发。在大多数情况下,这可能更好,但有时您希望避免它。例如,从数据库批量读取数据并将数据发送到某个远程 Web 服务。您不想将所有批次加载到内存中,而是一旦处理完前一个批次就要访问数据库。因此,您必须打破异步性。以下是一个示例:

var events = Enumerable.Range(0, totalCount/ batchSize)
   .Select(x => x*batchSize)
   .Select(x => dbRepository.GetEventsBatch(x, batchSize).GetAwaiter().GetResult())
   .SelectMany(x => x);
foreach (var carEvent in events)
{
}

注意:使用.GetAwaiter().GetResult()将其转换为同步操作。只有在处理了batchSize个事件后,才会懒惰地命中数据库。


2

使用Task.WaitAllTask.WhenAll,根据情况选择其中之一。


1
那也不行。Task.WaitAll是阻塞的,而不是可等待的,并且不能与Task<T>一起使用。 - Matt Johnson-Pint
1
@MattJohnson WhenAll ? - L.B
没错,就这样!我感觉很蠢。谢谢! - Matt Johnson-Pint

1

Task.WhenAll应该能解决这个问题。


0

在Stephen的答案的基础上,也可以不使用.ToList()来表达:

var tasks = foos.Select(aFoo => aFoo.DoSomething());
await Task.WhenAll(tasks).ConfigureAwait(true);

背景:在某些情况下,调用.ToList()可能会导致副作用在那个时候执行,因为枚举是被枚举的。如果可枚举对象是一组API或一组查询的调用,则此时可能不是所需的行为。没有.ToList(),当任务等待时,可枚举对象将被枚举。
更具体地说:使用(Fluent) NHibernate时,通常应避免在查询中使用.ToList(),否则您可能会最终读取整个结果集。这可能是您想要的数据量的数倍以上。

1
在这种情况下,ToList 并没有什么区别。Task.WhenAll 会立即将提供的 IEnumerable<Task<TResult>> 材料化为一个数组(源代码)。 - Theodor Zoulias
1
@TheodorZoulias 好的,说得对。但是实现可能会改变。通常情况下,我避免使用.ToList(),因为它也取决于所使用的LINQ提供程序。虽然目前在您提供的链接中使用的是这种方法,但在其他可能使用.ToList()的场景中并不一定如此。一定要注意LINQ提供程序的特定实现。因此,非常感谢您宝贵的评论,Theodor! - Manfred

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