Skip to content

Await a tuple of Tasks #380

Richiban asked this question in General
Await a tuple of Tasks #380
3y ago · 16 answers

I propose that the compiler will allow you to await multiple tasks in the form of a tuple, giving a tuple (of the same size) of results when execution of the method resumes.

Currently in C# it's a pain to await multiple tasks that return results. If all the async methods return Task it's fine, or all of them return the same T in Task<T> then it's not that bad; you can get the results out of the resulting array:

Example 1:

async Task<int> Main()
{
    int[] results = await Task.WhenAll(GetDataAsync("http://example.one"), GetDataAsync("http://example.two"));

    int result1 = results[0];
    int result2 = results[1];

    return result1 + result2;
}

The above example compiles because I've called the same method twice, so I'm awaiting an array of Task<int> and everything is peachy.

But what if the methods return different generic instantiations of Task<T>? Now it's not even possible to use Task.WhenAll to actually return the data--one must use the bare Task objects:

Example 2:

async Task<string> Main()
{
    var task1 = GetStringAsync();
    var task2 = GetGuidAsync();
    
    await Task.WhenAll(task1, task2);

    var result1 = task1.Result;
    var result2 = task2.Result;

    return new { result1, result2 }.ToString();
}

This is clumsy and potentially error-prone, and always has been since await was introduced. But, now we have tuples! Wouldn't it be much better if the compiler would allow the await keyword to be followed by a ValueTuple of Tasks?

Example 3:

async Task<int> Main()
{
    var (result1, result2) = await (GetStringAsync(), GetGuidAsync());

    return new { result1, result2 }.ToString();
}

I think the above is much simpler and easier to read, much quicker to type, and the user is much less likely to accidentally introduce a bug when writing it.

In terms of the implementation of this feature, I'd assume that it's not too much work because Example 3 would compile to the exact same IL as Example 2. In fact, it might even be able to be done with a new type (say, TupleTask) that itself can be awaited and then all that's needed is support from the compiler to hide the call to the TupleTask constructor (just as it does for tuples). Without the compiler support it might look like this:

async Task<int> Main()
{
    var (result1, result2) = await (new TupleTask(GetStringAsync(), GetGuidAsync()));

    return new { result1, result2 }.ToString();
}

Replies

16 comments

You can use extension method for this purpose.

related to dotnet/roslyn#16159
https://gist.github.com/jnm2/3660db29457d391a34151f764bfe6ef7

0 replies

@ufcpp Ahh, I did not realise that the compiler was happy to look for GetAwaiter extension methods when using await... this is very useful! (I happen to know that it doesn't work with foreach, so I assumed it didn't with await)

Perhaps my query then is actually that this extension method should be in the standard library?

For what it's worth, I've implemented the simplest method I can to implement this:

public static class TupleExtensions
{
	public static TaskAwaiter<(T1, T2)> GetAwaiter<T1, T2>(this(Task<T1>, Task<T2>) tasks)
		=> tasks.Transpose().GetAwaiter();
	
	public static async Task<(T1, T2)> Transpose<T1, T2>(this(Task<T1>, Task<T2>) tasks)
	{
		var (task1, task2) = tasks;
		
		await Task.WhenAll(task1, task2);
		
		return (task1.Result, task2.Result);
	}
}
0 replies

@svick svick 3y ago
Collaborator

My questions regarding this are:

  1. Should tuple element names be propagated across an await?

    For example is the following desirable?

    var tuple = await (Foo: task1, Bar: task2);
    var sum = tuple.Foo + tuple.Bar;
  2. Is awaiting a tuple of more than 7 elements important? Can it be implemented well in a library?

    The issue is that tuples with 8 or more elements are encoded using nested ValueTuples, for example, (int, int, int, int, int, int, int, int, int) is compiled into ValueTuple<int, int, int, int, int, int, int, ValueTuple<int, int>>. How would you write an extension method that can deal with that?

If tuple element names shouldn't be propagated and awaiting more than 7 elements is either not important or can be implemented well enough in a library, then I think this should be done using extension methods.

Otherwise, this will have to be implemented by the compiler.

0 replies

@svick

  1. Is awaiting a tuple of more than 7 elements important? Can it be implemented well in a library?
public static TaskAwaiter<(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10)> GetAwaiter<T1, T2, T3, T4, T5, T6, T7, T8, T9, T10>(this (Task<T1>, Task<T2>, Task<T3>, Task<T4>, Task<T5>, Task<T6>, Task<T7>, Task<T8>, Task<T9>, Task<T10>) tasks) {
    // logic here
}

Where compiler support would come in is if we would want await to be able to apply to a tuple full of Task-likes.

0 replies

@svick svick 3y ago
Collaborator

@HaloFour My (not well articulated) point was that the compiler supports, say, tuple with 100 elements, but you don't want 100+ overloads of GetAwaiter.

So, if you want to implement this using a library, you need to stop at some fairly small number of elements (though you're right it wouldn't have to be 7).

And the point about custom awaitables is also a good one.

0 replies

@svick

My (not well articulated) point was that the compiler supports, say, tuple with 100 elements, but you don't want 100+ overloads of GetAwaiter.

That could happen via source generators and perhaps dotnet/roslyn#162 to not bloat production assembly with unused code.

The definitive answer to this problem would be variadic generics dotnet/roslyn#5058 which is unlikely to happen anytime soon.

Rel: https://github.com/dotnet/corefx/issues/16010 and https://github.com/dotnet/corefx/issues/16011

0 replies

@jnm2 jnm2 3y ago
Collaborator

I'd love compiler support for awaiting all in a tuple of tasklikes of heterogenous types.

0 replies

We can accept length-dependant operations as a parameter, and generalize the rest,

     static TaskAwaiter<TResultTuple> GetAwaiter<TTuple, TResultTuple>(
    	this TTuple tasks,
    	Func<TTuple, Task[]> splat,
    	Func<TTuple, TResultTuple> getResults)
    {
        return Task.WhenAll(splat(tasks)).ContinueWith(_ => getResults(tasks)).GetAwaiter();
    }

    GetAwaiter((task1, task2),
        tasks => new Task[] { tasks.task1, tasks.task2 },
        tasks => (tasks.task1.Result, tasks.task2.Result);

With "tuple splatting" (#424) we can write less code in the call-site,

    GetAwaiter((task1, task2), tasks => new Task[] { tasks.. }, tasks => tasks..Result);

As a result tuple element names of the returned task will be reserved. However, to be able to use await, the compiler should provide those lambdas, and the actual method will look like this:

     static TaskAwaiter<T..> GetAwaiter<T..>(this Task<T>.. tasks)
        => Task.WhenAll(tasks..).ContinueWith(_ => tasks..Result).GetAwaiter();

which translates to,

     static TaskAwaiter<TResultTuple> GetAwaiter<TTuple, TResultTuple>(
    	this TTuple tasks,
    	[TupleSplat] Func<TTuple, Task[]> splat,
    	[TupleMemberSplat("Result")] Func<TTuple, TResultTuple> getResults)
    {
        return Task.WhenAll(splat(tasks)).ContinueWith(_ => getResults(tasks)).GetAwaiter();
    }

Attributes TupleSplat and TupleMemberSplat specify that what lambda should be generated.

Then we can await any tuple of tasks regardless of the actual length,

var (a, b, c) = await (task1, task2, task3); 

PS: The two operations can also be defined as extension methods over length-dependant tuples so you don't need to repeat every method for any given tuple length and just pass the result of those extensions.

This does not work for delegates since we require Action<T, U> to be convertible to Action<(T, U)>.

0 replies

@jnm2 jnm2 3y ago
Collaborator

Fyi, added ConfigureAwait support to TaskTupleExtensions.

0 replies

@jnm2 I put up a NuGet package based on your work let me know if you object or want me to change any of the metadata I wanted to use your code but I didn't want it in my main repository at work.

https://www.nuget.org/packages/TaskTupleAwaiter/

In order to use it simply import the TakeTupleAwaiter package via a using statement.

0 replies

@jnm2 jnm2 3y ago
Collaborator

@buvinghausen Very nice. I'm happy you went to that effort and added tests. As far as package copyright, it should match the copyright line in your license link. MIT is good. 👍

0 replies

@jnm2 I also updated the NuGet package to now support both .NET Framework 4.5 & .NET Standard 1.0 to get the broadest coverage.

link: NuGet

0 replies

@jnm2 jnm2 2y ago
Collaborator

I updated the gist. The ConfigureAwait extension methods for tuples of non-generic Tasks were missing the this keyword.

@buvinghausen Want to update the NuGet package?

0 replies

@jnm2 I am crazy busy at work right now but I will try to get to it during some downtime. I logged an issue against the repo so I don't lose sight of this.

Thanks!

0 replies

@jnm2 jnm2 2y ago
Collaborator

Hopefully buvinghausen/TaskTupleAwaiter#4 helps then. =)

0 replies

@jnm2 jnm2 2y ago
Collaborator

I updated the gist and Brian merged buvinghausen/TaskTupleAwaiter#8. tuple.ConfigureAwait was causing only the first two tasks to be awaited due to a copy/paste error. 🤦‍♂️ Thanks for the heads up, @maxbrodin!

0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
#️⃣
General
7 participants
Converted from issue
Beta
You can’t perform that action at this time.