Skip to content
Liam Mooney By Liam Mooney Software Engineer I
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.

Programming C# 12 Book, by Ian Griffiths, published by O'Reilly Media, is now available to buy.

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).

The Introduction to Rx.NET 2nd Edition (2024) Book, by Ian Griffiths & Lee Campbell, is now available to download for FREE.

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".

console output for multicast delegates code

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".

console output for Tasks code

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.

console output for mutlicast delegates with Tasks code

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.

@lg_mooney | @endjin

Liam Mooney

Software Engineer I

Liam Mooney

Liam studied an MSci in Physics at University College London, which included modules on Statistical Data Analysis, High Performance Computing, Practical Physics and Computing. This led to his dissertation exploring the use of machine learning techniques for analysing LHC particle collision data.

Before joining endjin, Liam had a keen interest in data science and engineering, and did a number of related internships. However, since joining endjin he has developed a much broader set of interest, including DevOps and more general software engineering. He is currently exploring those interests and finding his feet in the tech space.