In this 2019 post, the .NET Base Class Library engineers announced that the good old Delegate.BeginInvoke
.NET Framework syntax wasn’t supported in .NET Core and consequently in .NET 5, 6 … The reasons for this are twofold:
- The Task-based Asynchronous Pattern (TAP) is the recommended async model as of .NET Framework 4.5.
- The implementation of async delegates depends on remoting features not present in .NET Core,
The detailed discussion about this decision can be found here https://github.com/dotnet/runtime/issues/16312.
In the same post a code sample is provided to achieve the Delegate.BeginInvoke
features with the TAP and the async/await
keywords. I don’t find this solution satisfying because it requires significant refactoring for porting some Delegate.BeginInvoke
calls to .NET Core.
Let’s see how we can implement the two primary Delegate.BeginInvoke
usage scenarios with .NET Standard code. The key is to keep a syntax similar enough to simplify the migration from .NET Fx to .NET Core/5/6.
Scenario 1: Call EndInvoke() and on IAsyncResult object returned by BeginInvoke()
Here is the syntax we can achieve with the code below:
1 2 3 4 5 6 7 8 |
... var asyncResult = new Func<int, int>(FuncDouble).MyBeginInvoke(11); ... // Do some work before calling TryEndInvoke() Assert.IsTrue(asyncResult.TryEndInvoke(out int result, out Exception ex)); Assert.IsTrue(result == 22); ... private int FuncDouble(int a) { return a + a; } ... |
Find below the whole implementation followed by unit tests that 100% test it. Here are some remarks:
- Ideally we would have wanted a syntax like
FuncDouble.MyBeginInvoke(11)
but there is no C# delegate type inference at call site. Thus we need to usenew Func<int, int>(FuncDouble).MyBeginInvoke(11)
instead. - A trivial
MyBeginInvoke()
overload is needed for eachFunc<T0,...,TN,Result>
cardinality N. - I’ve never been a fan of
Delegate.EndInvoke()
re-throwing an exception thrown while executing the asynchronous procedure. ThusIMyAsyncResult<TResult>
presents abool TryEndInvoke(out TResult result, out Exception exception)
method that returns false and the exception when failed. This discards the need for a try{ } catch{ } clause when callingEndInvoke()
. TryEndInvoke()
is called on theIMyAsyncResult
object and not on the delegate object as originally. This syntax is easier since only one object needs to be provided instead of two to conclude the async task.- The key of this implementation is the private nested class
MyAsyncResult<TResult>
that keeps a reference to the async task and waits for termination upon aTaskAwaiter
object. - Finally, notice that
TryEndInvoke()
can be called only once. AnInvalidOperationException
is thrown the second time it is called.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
public interface IMyAsyncResult<TResult> { bool TryEndInvoke(out TResult result, out Exception exception); } public static partial class AsyncDelegateExtensionMethods { // First scenario, gather a IMyAsyncResult<T> object on which TryEndInvoke() can be called internal static IMyAsyncResult<TResult> MyBeginInvoke<T0, TResult>( this Func<T0, TResult> func, T0 arg0) { MyAssert.IsNotNull(func); Task<TResult> task = Task.Run(() => func.Invoke(arg0)); return new MyAsyncResult<TResult>(task); } // Such MyBeginInvoke() method overload is required for each Func<> cardinality, below with 3 arguments public static IMyAsyncResult<TResult> MyBeginInvoke<T0, T1, T2, TResult>( this Func<T0, T1, T2, TResult> func, T0 arg0, T1 arg1, T2 arg2) { MyAssert.IsNotNull(func); Task<TResult> task = Task.Run(() => func.Invoke(arg0, arg1, arg2)); return new MyAsyncResult<TResult>(task); } internal const string ERR_MSG = "TryEndInvoke() has already been called on this object"; sealed class MyAsyncResult<TResult> : IMyAsyncResult<TResult> { private readonly Task<TResult> m_Task; internal MyAsyncResult(Task<TResult> task) { MyAssert.IsNotNull(task); m_Task = task; } private bool m_Disposed; bool IMyAsyncResult<TResult>.TryEndInvoke(out TResult result, out Exception exception) { if(m_Disposed) { throw new InvalidOperationException(ERR_MSG);} m_Disposed = true; TaskAwaiter<TResult> awaiter = m_Task.GetAwaiter(); try { result = awaiter.GetResult(); exception = null; return true; } catch (Exception ex) { result = default; exception = ex; return false; } finally { m_Task.Dispose(); } } } |
Here are the tests that challenge all possible paths. Thanks to some usage of Thread.CurrentThread.ManagedThreadId
these tests check that the asynchronous procedure is actually executed on a background thread.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
[TestFixture] public partial class Test_AsyncDelegateExtensionMethods { [SetUp] public void SetUp() { TestHelper.SetUpTests(); } private int m_ManagedThreadId; private int m_ManagedThreadIdAsync; [Test] public void Test_BeginInvoke_With_AsyncResult_OK() { m_ManagedThreadId = Thread.CurrentThread.ManagedThreadId; var asyncResult = new Func<int, int>(FuncDouble).MyBeginInvoke(11); Assert.IsTrue(asyncResult.TryEndInvoke(out int result, out Exception ex)); Assert.IsTrue(result == 22); Assert.IsNull(ex); // Cannot call TryEndInvoke() a second time! bool exThrown = false; try { asyncResult.TryEndInvoke(out result, out ex); } catch (InvalidOperationException invEx) { Assert.IsTrue(invEx.Message == AsyncDelegateExtensionMethods.ERR_MSG); exThrown = true; } Assert.IsTrue(exThrown); } private int FuncDouble(int a) { Assert.IsTrue(m_ManagedThreadId != Thread.CurrentThread.ManagedThreadId); return a + a; } [Test] public void Test_BeginInvoke_With_AsyncResult_KO() { m_ManagedThreadId = Thread.CurrentThread.ManagedThreadId; var asyncResult = new Func<int, int>(FuncEx).MyBeginInvoke(11); Assert.IsFalse(asyncResult.TryEndInvoke(out int result, out Exception ex)); Assert.IsTrue(result == default); Assert.IsTrue(ex.Message == "Hello"); } private int FuncEx(int a) { Assert.IsTrue(m_ManagedThreadId != Thread.CurrentThread.ManagedThreadId); throw new ArgumentException("Hello"); } [Test] public void Test_BeginInvoke3Args_With_AsyncResult_OK() { m_ManagedThreadId = Thread.CurrentThread.ManagedThreadId; var asyncResult = new Func<int, int, int, int>(Func3Arg).MyBeginInvoke(1, 2, 3); Assert.IsTrue(asyncResult.TryEndInvoke(out int result, out Exception ex)); Assert.IsTrue(result == 6); Assert.IsNull(ex); } private int Func3Arg(int a, int b, int c) { Assert.IsTrue(m_ManagedThreadId != Thread.CurrentThread.ManagedThreadId); return a + b + c; } } |
Scenario 2: Call an On-Task-Completed-Action once the asynchronous call is terminated
The second usual scenario to achieve is to provide an On-Task-Completed-Action to consume the result – or eventually the exception thrown – instead of calling an EndInvoke()
method.
1 2 3 4 5 6 7 8 |
new Func<int, int>(FuncDouble).MyBeginInvoke(11, // The task completed procedure that consumes the result (int result, Exception ex) => { if(ex == null) { // Ok task executed properly, now we can do something with the result Console.WriteLine($"Result {result}"); } else { ... } }); |
Here is the code to achieve that. The astute is to rely on Task.ContinueWith()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
public static partial class AsyncDelegateExtensionMethods { // Second scenario: do some action on task completed instead of calling EndInvoke on an IAsyncResult-like object internal static void MyBeginInvoke<T0, TResult>( this Func<T0, TResult> func, T0 arg0, Action<TResult, Exception> onTaskCompleted) { MyAssert.IsNotNull(func); MyAssert.IsNotNull(onTaskCompleted); Task<TResult> task = Task.Run(() => func.Invoke(arg0)); ContinueOnTaskCompleted(task, onTaskCompleted); } private static void ContinueOnTaskCompleted<TResult>( Task<TResult> task, Action<TResult, Exception> onTaskCompleted) { MyAssert.IsNotNull(task); MyAssert.IsNotNull(onTaskCompleted); Task unused = task.ContinueWith( (Task<TResult> taskTmp) => { try { if (taskTmp.IsFaulted) { AggregateException aggregateEx = taskTmp.Exception; MyAssert.IsNotNull(aggregateEx); Exception ex = aggregateEx.InnerExceptions.Single(); onTaskCompleted(default, ex); return; } MyAssert.IsTrue(taskTmp.IsCompleted); onTaskCompleted(taskTmp.Result, null); } finally { task.Dispose(); taskTmp.Dispose(); } }); } } |
Here are the tests that fully cover this implementation.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
[TestFixture] public partial class Test_AsyncDelegateExtensionMethods { // FuncDouble() / FuncEx() / m_ManagedThreadId are defined in the test code above [Test] public void Test_MyBeginInvoke_With_OnTaskCompleted_OK() { m_ManagedThreadId = Thread.CurrentThread.ManagedThreadId; var @event = new ManualResetEvent(false); new Func<int, int>(FuncDouble).MyBeginInvoke(11, (int result, Exception ex) => { Assert.IsTrue(m_ManagedThreadId != Thread.CurrentThread.ManagedThreadId); Assert.IsTrue(result == 22); Assert.IsNull(ex); @event.Set(); }); @event.WaitOne(); } [Test] public void Test_MyBeginInvoke_With_OnTaskCompleted_KO() { m_ManagedThreadId = Thread.CurrentThread.ManagedThreadId; var @event = new ManualResetEvent(false); new Func<int, int>(FuncEx).MyBeginInvoke(11, (int result, Exception ex) => { Assert.IsTrue(m_ManagedThreadId != Thread.CurrentThread.ManagedThreadId); Assert.IsTrue(result == default); Assert.IsTrue(ex.Message == "Hello"); @event.Set(); }); @event.WaitOne(); } } |
Conclusion
Migrating .NET Fx calls to Delegate.BeginInvoke()
to .NET Core .NET 5/6 can be tricky. Hopefully the code provided in this post greatly simplifies this task.