Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Proposal] Asynchronous switch #5187

Closed
gafter opened this issue Sep 12, 2015 · 42 comments
Closed

[Proposal] Asynchronous switch #5187

gafter opened this issue Sep 12, 2015 · 42 comments
Labels
Area-Language Design Feature Request Feature Specification Language-C# Resolution-Won't Fix A real bug, but Triage feels that the issue is not impactful enough to spend time on.

Comments

@gafter
Copy link
Member

gafter commented Sep 12, 2015

We propose a new statement form that waits for a number of tasks to complete, and executes a code block corresponding to the first one that completes.

Syntax

statement:
    select-statement
select-statement:
    select select-block

select is a new context-sensitive keyword.

select-block:
    { select-case-list select-defaultopt }
select-case-list:
    select-case
    select-case-list select-case
select-case:
    case type identifier = await expression : block
    case await expression : block
select-default:
    default : block

Semantics

It is an error for a select-case to appear where the directly enclosing method is not async.

For each select-case

  • The type of the expression must be Task or some instance of Task<>.
  • If the type is Task, then there is no identifier in the select-case.
  • If the type is Task<T> for some type T, then the type T must be assignable to type.

One may specify var to have the type of the variable inferred.

At runtime, the expressions to the right of the await keywords in each select-case are evaluated, in order. If none of those tasks has completed:

  • If there is a select-default, its corresponding block is executed, and execution of the select-case is done; otherwise
  • The enclosing async method is suspended until one of the tasks completes.

Then (if the select-default block was not executed), for one of those tasks t that has completed:

  • If t ended in the canceled state, an OperationCanceledException is thrown.
  • if t ended in the faulted state, its corresponding exception is thrown.
  • Otherwise the corresponding block is executed.

We still need to define the definite-assignment and reachability rules for the select-statement.

Example

Thanks to @HaloFour

select {
    case string primaryContents = await client.DownloadStringTaskAsync(primaryUri): {
        Console.WriteLine($"Downloaded contents from primary URI: {primaryContents}");
    }
    case string secondaryContents = await client.DownloadStringTaskAsync(secondaryUri): {
        Console.WriteLine($"Downloaded contents from secondary URI: {secondaryContents}");
    }
    case await Task.Delay(1000): {
        Console.WriteLine("Failed to download from either URI within a second.");
    }
}
@gafter
Copy link
Member Author

gafter commented Sep 12, 2015

@MadsTorgersen Is this too many curly braces?

@HaloFour
Copy link

@gafter Neat. Am I interpreting the grammar correctly?

select {
    case string primaryContents = await client.DownloadStringTaskAsync(primaryUri): {
        Console.WriteLine($"Downloaded contents from primary URI: {primaryContents}");
    }
    case string secondaryContents = await client.DownloadStringTaskAsync(secondaryUri): {
        Console.WriteLine($"Downloaded contents from secondary URI: {secondaryContents}");
    }
    case await Task.Delay(1000): {
        Console.WriteLine("Failed to download from either URI within a second.");
    }
};

How does select-default work? If none of the tasks (or awaitables?) are complete it immediately executes that block?

@gafter
Copy link
Member Author

gafter commented Sep 12, 2015

@HaloFour That's right. Adding a default clause makes it synchronous. Otherwise it is asynchronous.

@MgSam
Copy link

MgSam commented Sep 12, 2015

Is this really a common enough problem to warrant language support? Personally, the number of times I have to do something like this is so rare that it's really not that big of a deal to fall back to using Task.ContinueWith to handle this kind of situation.

@vladd
Copy link

vladd commented Sep 12, 2015

Very nice feature; I've got a lot of code which does the same thing doing semantically. At the moment the code needs to check the result of Task.WhenAny. With this feature, the code would be much more clear and concise.

@vladd
Copy link

vladd commented Sep 12, 2015

@gafter What about the the rest of the tasks? Are they going to be (semi-)automatically cancelled? This seems to be a frequent use case.

@gafter
Copy link
Member Author

gafter commented Sep 12, 2015

@vladd Would you prefer the other tasks are cancelled? If so, how would you suggest I modify the proposal?

@vladd
Copy link

vladd commented Sep 12, 2015

@gafter I feel that sometimes cancelling the remaining tasks seems to be the right way. Example: the application uses tasks that track some UI events (await button click, await text input etc.), and wants to cancel the observations as soon as any event arrives (say, because any of them triggers some flow in the program logic). Another example is the mentioned above tasks with download and timeout: after the timeout is reached, continuing the download doesn't make sense any more. But I am pretty sure there is valid use of non-cancelling semantics as well.
About syntactical part I am not sure at the moment. Adding a special keyword for automatically created withing the select CancellationToken (which would allow something like case string primaryContents = await client.DownloadStringTaskAsync(primaryUri, cancellation)) seems to be an overkill.
Another interesting question is whether the cancellation exception(s) should be handled automatically as well. (At the moment by default exception from the unobserved Task is ignored, but this behaviour seems to be configurable.)

@svick
Copy link
Contributor

svick commented Sep 12, 2015

I'm not sure about the default case:

  • What's the intended use case?
  • I find it confusing that you write await, but nothing is ever actually awaited.
  • Why can a synchronous construct be used only in async methods? (On the other hand, await in a synchronous method might be confusing.)

@svick
Copy link
Contributor

svick commented Sep 12, 2015

Regarding the whole feature, couldn't it be replaced pretty easily by a small library? Something like (though probably with different naming):

Select
    .Case(client.DownloadStringTaskAsync(primaryUri),
          primaryContents => Console.WriteLine($"Downloaded contents from primary URI: {primaryContents}"))
    .Case(client.DownloadStringTaskAsync(secondaryUri),
          secondaryContents => Console.WriteLine($"Downloaded contents from secondary URI: {secondaryContents}"))
    .Case(Task.Delay(1000),
          () => Console.WriteLine("Failed to download from either URI within a second."))
    .Done();

@orthoxerox
Copy link
Contributor

I like the idea. Parallel async fits in nicely with sequential async that C# already has, but it really needs cancellation support. Something like select and cancel { ... } would look nice, imo, I don't think we need access to the token itself, just like we don't get access to the enumerator in foreach.

@HaloFour
Copy link

@svick I'd have to agree. The behavior of this proposed syntax could be easily accomplished through a library without a lot of additional keystrokes to consume. A fluent API could also make it easier to extend to customize cancellation behavior, etc.

@gafter
Copy link
Member Author

gafter commented Sep 12, 2015

@svick Hmm, I think so. You probably don't need a .Done() at the end, because you need an await syntactically at the beginning which kicks off the thing after executing all the .Case methods:

await Select
    .Case(client.DownloadStringTaskAsync(primaryUri),
          primaryContents => Console.WriteLine($"Downloaded contents from primary URI: {primaryContents}"))
    .Case(client.DownloadStringTaskAsync(secondaryUri),
          secondaryContents => Console.WriteLine($"Downloaded contents from secondary URI: {secondaryContents}"))
    .Case(Task.Delay(1000),
          () => Console.WriteLine("Failed to download from either URI within a second."));

On the other hand, a statement form would not require creating all these lambdas.

@vladd
Copy link

vladd commented Sep 12, 2015

@orthoxerox Perhaps select or cancel sounds better?

@AdamSpeight2008
Copy link
Contributor

  • What happens should one of the task throws an exceptions?
  • How is this "bettter" then existing solutions. like Task.WhenAny ?
try
{
  var tasks = { client.DownloadStringTaskAsync(primaryUri),
                client.DownloadStringTaskAsync(secondaryUri),
                Task.Delay(1000) }
  var res = await TaskFactory.StartNew(()=> Task.WaitAny( tasks ))
  switch(res)
  {
    case 0:
      var primaryContents = tasks[res].Result;
      Console.WriteLine($"Downloaded contents from primary URI: {primaryContents}");
    case 1:
      var secondaryContents = task[res].Result;
      Console.WriteLine($"Downloaded contents from secondary URI: {secondaryContents}");
    default:
      Console.WriteLine("Failed to download from either URI within a second.");
  }
}
catch( exception e)
{
}

@vladd
Copy link

vladd commented Sep 12, 2015

@svick Well, the old good switch and even for could be implemented as a library function (modulo early returns). If you imply that the feature is not strictly needed, the same applies to for loop.

But I think the discussed feature would add more expressive power to the language. And selecting between parallel tasks is a concept deep enough to be reflected in the language.

(By the way, the select feature seems to be somewhat related to Go's select. If we are designing a language with explicit support for asynchrony and parallelism, don't we need to add async data flows as well? (Of course, we have BlockingCollection<T>, DataFlow and Rx as libraries.))

@tpetrina
Copy link

@gafter You could retain the brevity of the statement form if one could write expressions as part of lambdas. Something like

await Select
    .Case((primaryContents = client.DownloadStringTaskAsync(primaryUri))
                           => Console.WriteLine($"Downloaded contents from primary URI: {primaryContents}"))
    .Case((secondaryContents = client.DownloadStringTaskAsync(secondaryUri))
                           => Console.WriteLine($"Downloaded contents from secondary URI: {secondaryContents}"))
    .Case((Task.Delay(1000))
                           => Console.WriteLine("Failed to download from either URI within a second."));

Or basically

Action<T>:       (x = expr) => statement
Action<T1, T2>:  (x = expr, y = expr) => statement
Action:          (expr) => statement

@gafter
Copy link
Member Author

gafter commented Sep 12, 2015

@AdamSpeight2008 Unfortunately Task.WhenAny doesn't do what you appear to think it does. Among other things, it does not return an int. Your other question would be answered by reading the spec.

@AdamSpeight2008
Copy link
Contributor

@gafter I meant Task.WaitAny , updated the sample.
Read the spec, just say it throws. You still don't know which task, threw the exception.
With a separate collection of tasks at least you can look through them to find the faulted one.

@HaloFour
Copy link

@vladd Agreed, and it's a game of balance. But there are so many variations on the logic that you'd want to do with something like this that building the syntax into the language doesn't make a lot of sense since it would have to expand into something more complex than probably originally intended. Cancellation by itself is a good example, both in the sense of how to handle canceling the entire operation as well as what to do with the other tasks, not to mention to do it right you should be using a CancellationToken which you can pass to the methods returning those tasks. You either end up with a very limited implementation or a Cartesian product of grammars.

Not to mention, making it a library feature makes it easier to extend via extension methods.

@sharwell
Copy link
Member

Right now I'm apprehensive regarding this proposal for the following reasons:

  1. I'm not sure this pattern is applicable in enough situations to see special language support.
  2. I'm not sure this pattern substantially improves over Task.WhenAny such that it would need to be a language feature and not just a library feature.

On a side note, cancellation support could be added to this:

select (expression)
{
}

With the following semantics:

  1. expression evaluates to a CancellationToken
  2. If the cancellation token is canceled prior to any of the case tasks completing, the default case (if any) runs.
  3. If a case task completes prior to the cancellation token being canceled, the cancellation token is canceled prior to entering the case block.
  4. If the expression is omitted, as in select { }, the behavior is equivalent to select (CancellationToken.None) { }.

@paulomorgado
Copy link

  • The type of the expression must be Task or some instance of Task<> .

Why not just any awaitable?

Is the example the same as:

var firstTask = client1.DownloadStringTaskAsync(primaryUri);
var secondTask = client2.DownloadStringTaskAsync(secondaryUri);
var thirdTask = Task.Delay(1000);

await Task.WhenAny(firstTask, secondTask, thirdTask);

if (firstTask.IsCompleted)
{
    string primaryContents = await firstTask;
    Console.WriteLine($"Downloaded contents from primary URI: {primaryContents}");
}
else
{
    if (secondTask.IsCompleted)
    {
        string secondaryContents = await secondTask;
        Console.WriteLine($"Downloaded contents from secondary URI: {secondaryContents}");
    }
    else
    {
        if (thirdTask.IsCompleted)
        {
            Console.WriteLine("Failed to download from either URI within a second.");
        }
        else
        {
            // default
        }
    }
}

If so, what happens to the unwaited tasks?

@gafter
Copy link
Member Author

gafter commented Sep 13, 2015

@paulomorgado Yes, those are the semantics when a default is present. As currently specified, nothing happens to the unawaited tasks. Do you think that is a problem? If so, what do you think should be done about it?

@paulomorgado
Copy link

@gafter, I was not particularly mentioning the default part but the fact that possibly faulted tasks are left unchecked. I was under the impression one should not do that.

@gafter
Copy link
Member Author

gafter commented Sep 13, 2015

@paulomorgado I'm open to suggestions.

@paulomorgado
Copy link

I'm confused, @gafter. Are unobserved task exceptions OK now?

/cc @stephentoub @StephenCleary

@stephentoub
Copy link
Member

FWIW, I get nervous about a language feature that makes it easy to ignore tasks. I'll think about it some more.

@gafter
Copy link
Member Author

gafter commented Sep 14, 2015

@paulomorgado I was not intending to recommend anything about the way tasks should be used. This proposal should be modified to follow the most useful pattern, or if that doesn't make sense it should be abandoned.

@paulomorgado
Copy link

Thus the discussion, @gafter!

@HaloFour
Copy link

I'd think that a language way of doing it would be akin to promoting an idiomatic approach. But I'd think that with cancellation and exception handling alone there isn't necessarily an idiomatic approach, what should be done depends highly on the types of tasks being executed and what needs to happen to the result. Between those two alone you'll end up with some nasty additional syntax.

@erik-kallen
Copy link

I don't think this feature is worth the complication. I work with C# daily and have done so for 10 years, and I have needed to do this thing once or twice, and it is not that hard to implement with Task.WhenAny. I recon most developers would not know that this exists and will be surprised whenever it is used.

@mattwar
Copy link
Contributor

mattwar commented Sep 14, 2015

I can't tell when you would use this feature. Why would you care to know which task finished first and to execute code based explicitly on it, and forget all the other tasks? You could try to cancel them, but they could be already finished by then too. You couldn't guarantee cancellation in any transactional way. I supposed you could use it when trying to parallel execute multiple redundant computations/fetches of data, and just use the first one that comes back, if you've got the resources to burn. I can't imagine this being common enough to warrant language syntax. But what do I know? Maybe it will some day be the basis of a new-fangled language all of it own, where triple redundant execution is all the rage.

@paulomorgado
Copy link

@erik-kallen, how come you've been working with async-await daily for the past 10 years?

@paulomorgado
Copy link

@mattwar, one scenario would be have several providers for the same calculation and only caring for the first one that responds.

@mattwar
Copy link
Contributor

mattwar commented Sep 14, 2015

@paulomorgado Yes, I agree. That's why I considered that in my prior post and could not think of a reason why that would be common enough to become a regularly used pattern.

@paulomorgado
Copy link

@mattwar, specially with the ignored tasks.

@vladd
Copy link

vladd commented Sep 14, 2015

@mattwar My experience says this pattern would be very useful in coding the typical business logic flow. Off the top of my head: you run a game, where the program logic waits until your player finishes the quest, the enemy player finishes the quest (this comes from network), or the player closes the window, whichever happens first. After that, you don't need to track any of the remaining conditions. Each of the conditions is easily representable by a Task (or other awaitable).

Another quick example: the program needs to look up for the user input in local database (and present the result), in the network database (and present the result), or handle user's request cancellation (waiting for the cancellation is a Task itself). After any result comes, the remaining tasks are not interesting any more and can be safely cancelled.

@HaloFour
Copy link

@vladd I'd say that this approach is anything but typical. Even if you wanted to hit multiple sources like that you probably wouldn't want to discard every other result. In your second example I'd want to combine the local and network results to present them both to the user as they are available. My experience is that each form of problem like this is very specific and that a single idiomatic approach simply does not exist. Modifying the proposal above to allow the flexibility of handling exceptions or cancellation does not make a lot of sense. You'd end up with something even more confusing and used in such limited circumstances that developers are never going to attain a level of familiarity with it.

@ufcpp
Copy link
Contributor

ufcpp commented Sep 15, 2015

I agree with cancellation support is needed. I often use methods like below instead of using Task.WhenAny directly:

using System.Linq;
using System.Threading;
using System.Threading.Tasks;

public delegate Task AsyncAction(CancellationToken ct);
public delegate Task<T> AsyncFunc<T>(CancellationToken ct);

public class TaskEx
{
    public static async Task First(params AsyncAction[] actions)
    {
        var cts = new CancellationTokenSource();
        var ct = cts.Token;

        await Task.WhenAny(actions.Select(a => a(ct)));

        cts.Cancel();
    }

    public static async Task<T> First<T>(params AsyncFunc<T>[] actions)
    {
        var cts = new CancellationTokenSource();
        var ct = cts.Token;

        var t = await Task.WhenAny(actions.Select(a => a(ct)));

        cts.Cancel();

        return t.Result;
    }
}

@sharwell
Copy link
Member

@ufcpp The form you put is essentially the syntax I suggested in my post above. 😄

@erik-kallen
Copy link

@paulomorgado Thanks for your snarky comment, of course I haven't worked with async/await for that long (and I never said I have), but back in the day I did use the old APM (BeginXX / EndXX), which does achieve the same goal (very interesting problem in WebForms), reasonably often to solve the problem of performing multiple calls in parallell. Needing to wait for just the first of many calls to complete has been very uncommon at least for for me. Perhaps it could potentially have a little use to achieve timeouts, but it doesn't seem worth a language feature to me.

@GSPP
Copy link

GSPP commented Oct 2, 2015

I can think of a lot of more valuable things that the C# team could pursue with the same resources.

I'm not even sure that this is a net positive change to the language given well known the "minus 100 points" model.

The Go language has such a construct I believe. It might be worth checking out why they need/have that. That said the Go language concurrency design is rather primitive and seems to be a inferior to C# 5. Maybe not a good example.

@gafter gafter added the Resolution-Won't Fix A real bug, but Triage feels that the issue is not impactful enough to spend time on. label Oct 20, 2015
@gafter gafter closed this as completed Oct 30, 2015
@gafter gafter modified the milestone: C# 7 and VB 15 Nov 26, 2015
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-Language Design Feature Request Feature Specification Language-C# Resolution-Won't Fix A real bug, but Triage feels that the issue is not impactful enough to spend time on.
Projects
None yet
Development

No branches or pull requests