2

I'm trying to understand async actions and I'm a bit confused. Actions are just glorified Delegates. Given the Actions

Action act = null;
act += () => { Console.WriteLine("Sync"); };
act += async () =>  { await File.AppendAllLinesAsync("C:/Test.Txt", 
                                    new[] { "Async File Operation" }); 
                     };

How can we invoke this async seeing as one of the delegates is async and the other is not. I've seen some extension methods in other SO answers simplified for the example would look like so:

public static void InvokeAsync(this Action action, AsyncCallback ar, object userObject = null)
{
    var listeners = action.GetInvocationList();
    foreach (var t in listeners)
    {
        var handler = (Action)t;
        handler.BeginInvoke(ar, userObject);
    }
}

I'm concerned if this even works because it looks like it invokes your callback for each listener which doesn't make sense.

I've only been using async with the more friendly version async/await so I do not understand this syntax as much. (I'm assuming the callback would be everything after the await and the userObject is equivalent to the dreadful SyncronizationContext that causes deadlocks if when calling sync without ConfigureAwait(false), but that is just a guess)

This is syntax inconvenient so I would perfer to use async await syntax, since async/await is called using duck-typing. I've read a blog about using async with delegates which for the example

public static class DelegateExtensions
{
    public static TaskAwaiter GetAwaiter(this Action action)
    {
        Task task = new Task(action);
        task.Start();
        return task.GetAwaiter();
    }
}

This too concerns me for a few reason, this looks much like an anti pattern.
Isn't this just creating a task which will run my action synchronous on a seperate thread? I also don't see this run through the invocation list.

Are either of these methods proper for invoking run delegates asynchronously? Is there a way I can invoke an async delegate with the await syntax while still fully leveraging async? What is the proper way to invoke async delegates with multiple functions in the invocation list?

johnny 5
  • 19,893
  • 50
  • 121
  • 195
  • Here's hoping that @EricLippert looks at this question... – Enigmativity Mar 08 '18 at 07:47
  • 2
    Overall, you shouldn't do that, and try as much as possible to end up with an array or task, or a `Func` (that last option still involves some trickery). Still, I'm fairly certain you can work your way with `Action` using some SynchronizationContext-fu (even thouh you definitely shouldn't). I'll try to put something together later today – Kevin Gosse Mar 08 '18 at 09:05
  • Interesting it just dawned on me that using an action async will mean that I'm actually invoking an Async void method, which will cause a lot of issues. – johnny 5 Mar 08 '18 at 16:07
  • 1
    I'm finding this question very confusing. How is `async () => { await File.AppendAllLinesAsync(...); };` any different from simply writing `() => { File.AppendAllLinesAsync(...); };` ? They have effectively the same behaviour. The second does a call and ignores the result; the first does a call, gets a task back and assigns "do nothing" as the continuation of that task. – Eric Lippert Mar 08 '18 at 18:27
  • @EricLippert I think my confusion came from the fact that I could assign both an Async function and a syncronous function to an action. and I wanted to assure that the async functions were being called asyncronously, but I've come to realize that If I'm using an Action, that would be equivalent to an Async void Method. Meaning that when the action is executed this would execute as a fire and forget, and potentially cause issues with uncaught exceptions? – johnny 5 Mar 08 '18 at 18:38
  • 1
    That is correct. Remember, unlike most expressions, lambdas are analyzed based on the context in which they are used. If you're using an async lambda in a context where `Action` is expected then the compiler will happily deduce that you intended this to be a void-returning asynchronous function, which is legal. If you use it in a context where a `Func` is expected, then it will assume that you want a task-returning asynchronous function. And so on. – Eric Lippert Mar 08 '18 at 18:40
  • What I'm not super clear on here though is what you mean by "called asynchronously". An async lambda isn't a function that *needs to be called asynchronously*. It's a function that *possibly returns before all its work is done*. – Eric Lippert Mar 08 '18 at 18:41
  • @EricLippert If I were to use multicast Delegate on a Func, and awaited that task, would that combine all of the Tasks into one and effectively do a when all? or would it just execute all of the tasks and only wait the final task? – johnny 5 Mar 08 '18 at 18:42
  • @EricLippert thank you for that clarification, I was referring to the delegates marked with async, when I called the Action delegate, I was originally under the impression that It contained some compiler magic to spawn a task and waited internally one the ones which were marked with async, and perhaps join them all together and `WhenAll` – johnny 5 Mar 08 '18 at 18:48
  • 1
    Let's find out. `Func f = async () => { await Task.Delay(2000); Console.WriteLine(2); }; f += async () => { await Task.Delay(1000); Console.WriteLine(1); }; await f(); Console.WriteLine(3);` produces output `1 3 2`. Think about what happens. First the function is invoked. That invokes all the delegates and returns the value of the last one. The last one is a task that completes one second in the future, so when that task is complete, we keep on going and write `3`. – Eric Lippert Mar 08 '18 at 18:55
  • 1
    Absolutely not, there is no such compiler magic and nor should there be. If those are the semantics you want then you'll have to write that logic yourself. `+=` on delegates is the *sequential composition operator*. The sequential composition of asynchronous operations is not any different than the sequential composition of non-asynchronous operations; the tasks returned are just references to objects, the same as any other. – Eric Lippert Mar 08 '18 at 18:56
  • Similarly, if we had `Func>` and you added together functions that produced sequences `1, 2, 3` and `4, 5, 6` I hope you would not expect that the effect of calling the combined function would be to *concatenate* the sequences! The result of invoking a multicast `Func>` should be to produce the sequence returned by the last of them, not to remember those sequences and concatenate them. – Eric Lippert Mar 08 '18 at 18:59
  • 1
    @EricLippert Thanks multicast delegates make a lot more sense now, thanks for the clarification, – johnny 5 Mar 08 '18 at 19:27

1 Answers1

0

I think Eric Lippert's comment have clarified the situation more than I could ever. Overall, if you need to act on the return type of a method, you shouldn't use multicast delegates. If you still have to, at least use a Func<Task> signature, then you can iterate on each individual delegate using GetInvocationList, as explained here.

But would it be really impossible to work your way out of a multicast delegate with async void method?

It turns out that you can be notified of beginning and end of async void methods by using a custom synchronization context and overriding the OperationStarted and OperationCompleted methods. We can also override the Post method to set the synchronization context of child operations, to capture subsequent async void calls.

Piecing it together, you could come with something like:

class Program
{
    static async Task Main(string[] args)
    {
        Action act = null;
        act += () => { Console.WriteLine("Sync"); };
        act += async () =>
        {
            Callback();

            await Task.Delay(1000);

            Console.WriteLine("Async");
        };

        await AwaitAction(act);

        Console.WriteLine("Done");

        Console.ReadLine();
    }

    static async void Callback()
    {
        await Task.Delay(2000);

        Console.WriteLine("Async2");
    }

    static Task AwaitAction(Action action)
    {
        var delegates = action.GetInvocationList();

        var oldSynchronizationContext = SynchronizationContext.Current;

        var asyncVoidSynchronizationContext = new AsyncVoidSynchronizationContext();

        try
        {
            SynchronizationContext.SetSynchronizationContext(asyncVoidSynchronizationContext);

            var tasks = new Task[delegates.Length];

            for (int i = 0; i < delegates.Length; i++)
            {
                ((Action)delegates[i]).Invoke();
                tasks[i] = asyncVoidSynchronizationContext.GetTaskForLastOperation();
            }

            return Task.WhenAll(tasks);
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(oldSynchronizationContext);
        }
    }
}

public class AsyncVoidSynchronizationContext : SynchronizationContext
{
    private TaskCompletionSource<object> _tcs;
    private Task _latestTask;

    private int _operationCount;

    public Task GetTaskForLastOperation()
    {
        if (_latestTask != null)
        {
            var task = _latestTask;
            _latestTask = null;
            return task;
        }

        return Task.CompletedTask;
    }

    public override void Post(SendOrPostCallback d, object state)
    {
        Task.Run(() =>
        {
            SynchronizationContext.SetSynchronizationContext(this);

            d(state);
        });
    }
  
    public override void OperationStarted()
    {
        if (Interlocked.Increment(ref _operationCount) == 1)
        {
            // First operation
            _tcs = new TaskCompletionSource<object>();
            _latestTask = _tcs.Task;
        }

        base.OperationStarted();
    }

    public override void OperationCompleted()
    {
        if (Interlocked.Decrement(ref _operationCount) == 0)
        {
            // Last operation
            _tcs.TrySetResult(null);
        }

        base.OperationCompleted();
    }
}

The output would be:

Sync

Async

Async2

Done

Of course, this code is provided just for recreational purpose. There's plenty of limitations, such as the fact the fact that it wouldn't work as-is if you're already using a synchronization context (such as the WPF one). I'm also certain that it has a few subtle bugs and concurrency issues here and there.

Community
  • 1
  • 1
Kevin Gosse
  • 38,392
  • 3
  • 78
  • 94
  • Thanks for the explanation and showing how this could work. My Mistake was that I didn't realize at the time that when I created the async delegate for the action, I assumed that that was standard, and I could do the same with a Func and async that returned Task. Which really confused me on how that was possible. But it was just mere coincidence that I was using action, and it was discarding the Task returned – johnny 5 Mar 08 '18 at 22:07