The Perils of Combining Multicast Delegates with Tasks in C#
Delegates commonly show up in C# code where events or call backs need to be handled, they are therefore frequently used in Web applications for handling events like a security token validation. In addition, async (asynchronous) methods are used extensively in Web applications for keeping them responsive. Consequently, the two are often used together, however multicast delegates and async methods are quite incompatible, and very easy to unintentionally produce.
In this post I'm going to explain a tricky problem that arises when using multicast delegates with async methods, and how it's easy to accidentally get into the situation when working with events or call backs in Web applications.
This is an interesting problem that emerges as a consequence of the conflicting features of async methods and multicast delegates in C#. First I'll provide a little context as to why I came across this issue and decided to write the post, afterwards I'll quickly run through what multicast delegates and Tasks are, and then provide a simple example that will demonstrate the problem and help to understand it.
Over the past few weeks me and Ian have been experimenting with implementing single sign on (SSO) with the Microsoft Authentication Library (MSAL) for a particular web app structure. As part of that we're supplying our own delegates to handle events such as OnAuthorizationCodeReceived
; these delegates return Task
objects.
The mistake we made was to add a stray + sign when assigning our own delegate into an event handler. This meant that instead of having an assignment operator we had an add assignment operator, resulting in a multicast delegate. Consequently a piece of code that should have been running was not, and it was not at all apparent why this was happening under debugging. Let's get into why this happening.
Delegates
Delegates are user-defined C# types that act as pointers to methods. To use a delegate, first define one, then create an instance of your delegate type by passing a method for it to point to, then you can invoke the delegate object. When you do invoke a delegate, the method that it points to will be invoked internally, therefore your delegate signature and return type must match that of the method you want to instantiate it with.
The code sample below shows a basic example of defining, instantiating and using a delegate.
public class Program
{
public static string Greeting(string name)
{
return $"Hello {name}";
}
public delegate string GreetingDelegate(string name);
static void Main(string[] args)
{
GreetingDelegate greetingDelegate = new GreetingDelegate(Program.Greeting);
string greetingMessage = greetingDelegate.Invoke("world");
Console.WriteLine(greetingMessage);
}
}
There is a basic method Greeting
that returns "Hello" plus whatever string you pass as an argument. The next line defines a delegate type, GreetingDelegate
(whose signature matches that of the Greeting
method). Next, in the Main
method, an object of the GreetingDelegate
type is created, greetingDelegate
, by supplying the name of the Greeting
method for the delegate to refer to; then greetingDelegate
is invoked with the string argument "world", which means the Greeting
method is invoked internally with that same argument, and consequently "Hello world" is printed to the console. Pretty simple.
Multicast delegates
A delegate that points to a single method, like the one just shown, is called a unicast delegate. A multicast delegate is, therefore, one that refers to multiple methods. To create a multicast delegate, first create two regular unicast delegates and combine them using the +
operator – this internally calls Delegate.Combine
with the two delegates as arguments and returns a new multicast delegate instance.
The behaviour of a multicast delegate when invoked is as follows: the methods that the delegate object points to are invoked in sequence in the order that they were added to the delegate, and the return value of the delegate is the return value of the last method invoked – all the others are discarded.
The code sample below shows an example of creating and using a multicast delegate.
public delegate int SquareDelegate(int length);
public class Square
{
public int GetArea(int length)
{
int Area = length * length;
Console.WriteLine($"Area of square: {Area}");
return Area;
}
public int GetPerimeter(int length)
{
int Perimeter = 4 * length;
Console.WriteLine($"Perimeter of square: {Perimeter}");
return Perimeter;
}
}
public class Program
{
static void Main(string[] args)
{
Square square = new Square();
SquareDelegate squareDelegate = new SquareDelegate(square.GetArea);
//Invoke the unicast delegate
int valueReturnedByUnicastDelegate = squareDelegate(5);
Console.WriteLine($"Value returned by unicast delegate: {valueReturnedByUnicastDelegate}");
// Add another method to the delegate to make it a multicast delegate
Console.WriteLine();
squareDelegate += square.GetPerimeter;
int valueReturnedByMulticastDelegate = squareDelegate(5);
Console.WriteLine($"Value returned by multicast delegate: {valueReturnedByMulticastDelegate}");
}
}
In the sample above we're defining a delegate type, SquareDelegate
, that takes a single parameter of type int
and has a return type of int
, and a class, Square
, with two methods that calculate the area and perimeter of a square given a length argument of type int
(the methods have the same signature as the delegate).
In the Main
method of the program
class, we're creating an instance of our delegate, squareDelegate
, by handing it the GetArea
method of a Square
object – this is therefore a unicast delegate as it refers to a single method. Next we're invoking squareDelegate
with the argument 5
.
Next, we're using the add-assignment operator (+=
) to create a multicast delegate by combining the delegate instance in squareDelegate
with a delegate pointing to square.GetPerimeter
, and assigning that into the the squareDelegate
variable (this is equivalent to creating a separate unicast delegate pointing to square.GetPerimeter
and doing +=
with that and square.Delegate
).
So, given my description of unicast and multicast delegates earlier, what do we expect to see printed to the console?
First the unicast delegate is executed, so we expect to see "Area of square: 25" followed by "Value returned by unicast delegate: 25". Later the multicast delegate is invoked: the first method in the delegate, GetArea
, is first invoked, then the second method, GetPerimeter
, so we should see "Area of square: 25" followed by "Perimeter of square: 20"; then the return value of the delegate is written to the console, which is the return value of the final method in the delegate, which is GetPerimeter
, and therefore, finally, we should see "Value returned by multicast delegate: 20".
Tasks
A Task
is an object that can be returned by an asynchronous method; it represents the asynchronous work in progress. Writing code to run asynchronously means that threads don't get blocked by long running work - allowing the web server to process more requests.
The code sample below demonstrates the use of Tasks
as return types of asynchronous methods.
public class AsyncExampleProgram
{
static async Task Main(string[] args)
{
Task<int> result = LongProcess();
ShortProcess();
int val = await result; // Wait until we get the int return value
Console.WriteLine($"Result: {val}");
Console.ReadKey();
}
static async Task<int> LongProcess()
{
Console.WriteLine("LongProcess started");
await Task.Delay(3000); //hold execution for 3 seconds
Console.WriteLine("LongProcess Completed");
return 10;
}
static void ShortProcess()
{
Console.WriteLine("ShortProcess started");
// Something that doesn't take very long here
Console.WriteLine("ShortProcess completed");
}
}
We have two methods, LongProcess
, which is asynchronous and does something that takes a long time, and ShortProcess
, which will take a relatively short amount of time to execute. When LongProcess
is called and the line with await
in it is reached, the method returns a Task<int>
back to the caller, allowing the method that called the LongProcess
method to continue executing. The Task.Delay(3000)
expression here is meant to simulate a process that may take some time to produce a result, like sending a HTTP request to Web server and waiting for a response; when that process completes (e.g. when the response from the server arrives), the compiler arranges for the lines of code below the await
statement in LongProcess()
to be executed - potentially on a different thread. When LongProcess
finishes executing, the Task<int>
object it returned earlier becomes an int
object and can be accessed. The point is that when a method calls LongProcess()
, the thread is not blocked waiting for it to finish executing and returning its result (which will always be 10
) - the thead can continue executing code in the calling method.
Given that description and now looking at the Main
method: first LongProcess
is called, which returns when the await
statement inside it is reached; ShortProcess
is then called immediately after, which executes very quickly (before LongProcess
finishes); next we hit the await statement inside the Main
method, which means the execution stops until LongProcess
completes and the variable result
becomes an int
. In the period after LongProcess
returning and before it finishing, the Task<int>
object represents the asynchronous operation in progress. Given that, we expect to see the following in the output console: "LongProcess started" then "ShortProcess started" then "ShortProcess completed" then "LongProcess completed", and finally "Result: 10".
Combining multicast delegates and Tasks
Let's say we have a multicast delegate that refers to two asynchronous methods, both of which return Task
objects. Now let's say the second method depends on the 1st method performing some work, say, modifying the value of a field. When the delegate is invoked, based on what we have seen about Tasks and multicast delegates, we can say that the first method in the delegate will run first, which, upon reaching the await statement in its body, will return a Task
back to the caller, at which point the second method in the delegate will run, which will do the same thing, and then whatever comes after the delegate will begin executing.
If the second method begins to use the result that it expects the first method to have completed before the first method has actually completed that work, the second method is expecting certain things about the state of the world at that moment in time which are not going to be accurate - resulting in bugs. This of course wouldn't be a problem with synchronous methods – the methods would execute in succession, with no method starting to execute until the preceding one had finished.
An example
In the code sample below we have a delegate definition, LongProcessDelegate
, with a single int
parameter and return type of Task<int>
; we have a class, ObjectWithLongProcesses
, containing two static methods representing long processes, LongProcess1
and LongProcess2
, (with the same signature and return type as the delegate), and a static property, SomeProperty
.
public delegate Task<int> LongProcessDelegate(int value);
public class ObjectWithLongProcesses
{
public static int SomeProperty { get; private set; }
public static async Task<int> LongProcess1(int value)
{
Console.WriteLine("LongProcess1 started");
await Task.Delay(7000); //hold execution for 7 seconds
SomeProperty = value;
Console.WriteLine("LongProcess1 Completed");
return 1*value;
}
public static async Task<int> LongProcess2(int value)
{
Console.WriteLine("LongProcess2 started");
await Task.Delay(4000); //hold execution for 4 seconds
Console.WriteLine($"Has the value of the static property been changed: {SomeProperty == value}");
Console.WriteLine("LongProcess2 Completed");
return 0*value;
}
static async Task Main(string[] args)
{
LongProcessDelegate longProcDelegate1 = new LongProcessDelegate(LongProcess1);
LongProcessDelegate longProcDelegate2 = new LongProcessDelegate(LongProcess2);
LongProcessDelegate longProcDelegate = LongProcDelegate1 + LongProcDelegate2;
Task<int> result = longProcDelegate(10);
int valueReturnedByMulticastDelegate = await result;
Console.WriteLine($"Value returned by multicast delegate: {valueReturnedByMulticastDelegate}");
Console.ReadKey();
}
}
In the Main
method, one instance of LongProcessDelegate
, longProcDelegate1
, is being created with the LongProcess1
method, and another instance, longProcDelegate2
, with the LongProcess2
method. Next, a multicast delegate, longProcessDelegate
, is being created by combining longProcDelegate1
and longProcDelegate2
(I haven't used the add-assignment operator here to keep things clear, but I could have), which is then being invoked with the argument 10
.
So, just as I described above, here is what's going to happen: the first method in the multicast delegate, LongProcess1
, is first called; once the await
is reached inside its body, the method will return a Task<int>
; then the next method in the delegate, LongProcess2
, is called - which consequently results in the return value of the first method being discarded – and that also returns to the caller with a Task<int>
once the await
is reached in its body (this is the one that will be assigned into the local variable result
).
Now, you can see in LongProcess1
that after the await
statement the static property, SomeProperty
, is being modified; you can also see in LongProcess2
that that property is being accessed after the await
in there. So, if the await
statement in LongProcess2
completes before that in LongProcess1
(which it will because LongProcess1
is being held longer than LongerProcess2
), then the code in LongProcess2
is accessing that property before LongProcess1
has modified it, which in real code would result in a bug. See the console output below.
The thing you would normally try to do in a similar situation, where one asynchronous method depends on another completing execution, is await
the Task
object returned from the first method before trying to use the result of any work done by the first method – guaranteeing that the property had been modified before accessing it in the second method. But this isn't possible with the default behaviour of multicast delegates, as the return value of the first method is lost – it's not possible to await it.
Conclusion
First of all, if you're not intending to use multicast delegates, be careful not to use an add assignment operator where you only want an assignment operator.
On the other hand, if you do intend to use multicast delegates with asynchronous methods returning Task objects, then be wary of what's been discussed in this post. Specifically, if the second method in your mutlicast delegate depends on the first completing some work, then know you will not be able to await
the first method, and so you cannot guarantee the first method will complete before the second starts executing. However, I hope this post has demonstrated that asynchronous methods and multicast delegates fundamentally do not play well together, therefore you should probably not use them together.