awaitAll implementation

Hello,
Is there any difference between:

list.awaitAll()		       // A

list.map { it.await() }    // B

I checked awaitAll implementation and it seems fairly complicated. Further, it will convert the list to an array, and then create another array of size n, which seems inefficient.

Can anyone shed some light on why awaitAll has such a complex implementation (at first glance)?

Thanks!

1 Like

As the documentation of awaitAll() states:

This function is not equivalent to deferreds.map { it.await() } which fails only when it sequentially gets to wait for the failing deferred, while this awaitAll fails immediately as soon as any of the deferreds fail.

But I don’t know if there are other differences.

2 Likes

@broot Fascinating, thanks for pointing that out!

But if these are in a coroutineScope should it matter?

    coroutineScope {
        // ...
        list.map { it.await() }
    }

This would still fail as soon as the first one fails. Maybe it’s only different in supervisorScope?

I am sorry to revive this old thread, but I have the same question and it hasn’t been answered here or on other posts I could find.

If I use list.map { it.await() }, where list is a List<Deferred<...>>, and any of the async tasks raise an exception, then this snippet will immediately abort because the surrounding coroutineScope has been cancelled by the exception. It does not matter whether the exception came from the task we are currently awaiting or some other task further down the list. Assuming I don’t catch the exception inside the map { ... } lambda, the whole waiting procedure is immediately terminated.

There could be a difference in the type of exception we get as was discussed here, but other than that I fail to see difference.

The behavior actually makes sense to me: we have suspended waiting on some result, but our scope got cancelled so the waiting is terminated. No problem there. What confuses me is the documentation of awaitAll, posted above by @broot . Specifically the part

… which fails only when it sequentially gets to wait for the failing deferred, …

This just doesn’t seem to be true, it fails immediately, not only when “it sequentially gets to wait for the failing deferred”.

As for supervisorScope, yes here it would make a difference because we would only get an exception once we call await on the task that has failed. But, since we are using a supervisorScope, awaitAll would simply throw an exception as soon as the first exception occurs, and the supervisorScope would wait for the other coroutines in the list to finish, so the runtime should be more or less the same in either case, whether we use awaitAll() or map { it.await() } (we are not cancelling anything so we have to wait for everything to finish). I can’t really think of a use-case for the combination of supervisorScope and awaitAll, because we would only get the first exception, and await/awaitAll implies we are interested in the results of each task, so we would have to use try { it.await() } catch (...) { ... } anyway.

So, can someone explain the part of the documentation of awaitAll() which I mentioned above? Am I missing something? Or is the wording perhaps misleading or incomplete?

Thanks in advance for any answer or thoughts on this.

This is the problem. If you’re using list.map{ it.await() } there’s no other deferred down the list being evaluated.
When you do list.map you’re awaiting all the deferreds in order, first, second, third, … . If the third throws immetiately when you start it, that will be after first and second have completed anyway.

On the other hand .awaitAll starts all the deferreds at the same time, so it’s not waiting for anything else to complete before one can throw.

Note that the difference here is just in how the defereeds are started and awaited for; the exception propagation is identical.

1 Like

Disclaimer: this topic is really old and I learnt a few bits about coroutines since then, so today my answer would be probably a little different.

And you are correct. Even if we still await on the first deferred and the third fails, we still fail, because the failure automatically propagates to the parent. But this is only for the most typical case. If for example tasks are scheduled using another scope, then it won’t propagate to our coroutine. It may sound weird to do multiple anotherScope.async {} and immediately await on them, but imagine we have some kind of a service that schedules tasks and returns deferreds - in this case the exception will most propably not propagate to the caller. Supervisor scope or another similar functionality is another example. @al3c mentioned yet another important aspect that deferreds might be lazy.

I think the general meaning of awaitAll() should be something like: await concurrently on all deferreds. Going in a loop is awaiting sequentially, at least conceptually. Of course, usually if we have a collection of deferreds, that naturally means we run some tasks concurrently, so in practice the difference is often very small or there is no difference at all.

1 Like

Thank you both for your quick replies.
@al3c good point with the lazy tasks, although one should note that this is only true for lazy tasks, as @broot mentioned. Per default, async tasks are started immediately (given thread availabilty, obviously).

@broot well yes, if we put the async coroutines on different scopes than the one where we call await then it becomes a different story. Although if all tasks are started on some scope B (different from the scope A where we wait) then I think the outcome would be the same: Any Exception would immediately propagate to await or awaitAll on Scope A because the entire Scope B is cancelled, unless if B is a supervisorScope or if there are lazy tasks. If however we launch several async tasks on several different scopes then there would be a significant difference between awaitAll and the mapping approach.

The reason I was asking is that in my use-case I don’t have a list but a Map<K, Deferred<V>> which doesn’t have an awaitAll-Method, and because of the documentation of awaitAll I felt like I was doing something wrong using mapValues { (_, v) -> v.await() } for getting the results without losing the key-value-relationship.

My takeaway now is that, if I’m in control of how and on what scope the async-Tasks are launched, this approach is perfectly fine.

I still think the documentation is somewhat misleading because it makes it sound like we would always have to iterate to the first failing task using the mapping approach, but this isn’t true in the use-case that seems most natural to me. Then again, awaitAll can make no assumption about how and when the async tasks were started, so I see now why they would write it like this.

Thanks again for your insight, this has been helpful.

I’ll just leave it here :slight_smile:

suspend fun <K, V> Map<K, Deferred<V>>.awaitAll(): Map<K, V> = keys.zip(values.awaitAll()).toMap()

It requires that iterating over keys and values uses the same ordering, but I believe it should be true for all common implementations of maps, assuming we don’t modify it between iterations. If we need additional guarantees, we can do it with just a little more code.