Delegates, Events & Async

Advanced 30 min read Lesson 13 of 13
Why This Matters

Delegates, events, and async/await are the backbone of modern C# applications. They let you write code that is flexible, responsive, and scalable. Delegates allow methods to be passed as parameters — like handing a task to someone else. Events enable your app to react to user actions or system changes — think of clicking a button. Async/await keeps your app fast and responsive by letting it do other work while waiting for long operations (like downloading data). Mastering these will make you a true C# expert!

Delegates – The Method Pass

A delegate is a type that holds a reference to a method. It's like a remote control for functions.

Declaring and Using Delegates
// 1. Declare a delegate (a blueprint for methods)
public delegate int Operation(int a, int b);

// 2. Create methods that match the blueprint
public int Add(int a, int b) => a + b;
public int Multiply(int a, int b) => a * b;

// 3. Use the delegate to point to different methods
Operation op = Add;
int result = op(5, 3);   // result = 8

op = Multiply;
result = op(5, 3);       // result = 15
What's happening here?
  • Step 1: We define a delegate called Operation that can point to any method taking two integers and returning an integer.
  • Step 2: We create two methods (Add and Multiply) that match this signature.
  • Step 3: We assign different methods to the delegate variable and call it. This lets us change behavior at runtime - very powerful!

Real-world use: Calculator apps, sorting algorithms (where you can swap comparison logic), and plugin systems.

Built-in Delegates: Func & Action
// Func – returns a value (last type is the return type)
Func<int, int, int> add = (a, b) => a + b;
int sum = add(5, 3);   // sum = 8

// Action – no return value (just does something)
Action<string> print = (msg) => Console.WriteLine(msg);
print("Hello World!");
Why use Func and Action?
  • Func is for methods that return a value. The last type parameter is always the return type.
  • Action is for methods that don't return anything (void).
  • They save you from declaring custom delegates. C# provides them out-of-the-box!

When to use: Use Func for calculations, transformations, or any operation that produces a result. Use Action for logging, UI updates, or side-effect operations.

Events – Notify When Something Happens

An event is a special delegate that lets objects broadcast when something occurs. Other objects can subscribe to react.

Defining and Using an Event
public class Button
{
    // Define the event (using EventHandler is standard)
    public event EventHandler Clicked;

    public void Click()
    {
        // Raise the event – notify all subscribers
        Clicked?.Invoke(this, EventArgs.Empty);
    }
}

// Usage:
Button button = new Button();
button.Clicked += (sender, e) => Console.WriteLine("Button clicked!");
button.Click();   // Output: Button clicked!
How events work:
  • Publisher: The Button class defines the event and raises it when something happens.
  • Subscriber: Your code "subscribes" to the event using += and provides a method to run when the event fires.
  • Safety: The ?.Invoke checks if anyone is subscribed before raising the event (prevents null reference errors).

Why events are powerful: They decouple the sender (button) from the responder (your code). The button doesn't need to know what will happen when clicked - it just notifies. This makes your code more modular and maintainable. Used everywhere in UI frameworks, game development, and system notifications.

Async/Await – Keep Your App Responsive

Async/await lets your app do other work while waiting for slow tasks (like web requests or file I/O). It's the most important feature for building modern, scalable applications.

What Exactly is Async/Await?
Understanding the Concept

Imagine you're cooking dinner. You put the pasta in boiling water (a long task). Instead of standing there staring at the pot, you chop vegetables, set the table, and check your phone. When the pasta is done, you come back to it. That's exactly how async/await works!

  • Synchronous (blocking): You stare at the pot until the pasta is done. Your app freezes.
  • Asynchronous (non-blocking): You do other productive work while waiting. Your app stays responsive.
// Basic structure of an async method
public async Task<string> FetchDataAsync()
{
    // Simulate a 2-second network call
    await Task.Delay(2000);
    return "Data loaded!";
}

// Calling it:
public async Task ProcessAsync()
{
    string result = await FetchDataAsync();
    Console.WriteLine(result);
}
Breaking Down the Keywords:
  • async: This keyword marks the method as asynchronous. It tells the compiler "this method contains await operations."
  • Task<T>: Represents an operation that will return a value of type T in the future. If your method returns nothing, use Task (not void).
  • await: This is where the magic happens! It pauses the method without blocking the thread. While waiting, the thread can go do other work (like handling UI clicks or processing other requests).
  • Return: When the awaited task completes, the method resumes right after the await, with the result ready to use.

⚠️ Important: Never use async void except for event handlers! Always return Task or Task<T> so callers can await your method and handle exceptions properly.

How Async/Await Works Under the Hood
The State Machine

When you write async code, the C# compiler transforms your method into a state machine. This is what happens:

  1. Method Start: The method runs synchronously until it hits the first await.
  2. Await Encountered: The method returns a Task to the caller (who can continue with other work).
  3. Waiting: The awaited operation starts. The thread is freed to do other things.
  4. Completion: When the operation finishes, the state machine resumes the method from where it paused.
  5. Continuation: The method continues executing after the await, eventually returning the result.

Key insight: Async/await doesn't create new threads! It uses the existing thread pool efficiently. For I/O operations (web requests, database queries), it's thread-free during the wait.

// Visual representation of what happens:
// The compiler transforms your code into something like this:

public Task<string> FetchDataAsync()
{
    var stateMachine = new StateMachine();
    stateMachine.MoveNext();
    return stateMachine.Task;
}

private class StateMachine
{
    private int state = 0;
    private TaskAwaiter awaiter;
    public TaskCompletionSource<string> tcs = new TaskCompletionSource<string>();
    
    public Task<string> Task => tcs.Task;
    
    public void MoveNext()
    {
        switch(state)
        {
            case 0:
                awaiter = Task.Delay(2000).GetAwaiter();
                if (!awaiter.IsCompleted)
                {
                    state = 1;
                    awaiter.OnCompleted(MoveNext);
                    return;
                }
                goto case 1;
            case 1:
                awaiter.GetResult();
                tcs.SetResult("Data loaded!");
                break;
        }
    }
}
💡 Don't worry about writing this yourself! The compiler handles all this complexity for you.
Async with HTTP (Real-world Example)
using HttpClient client = new HttpClient();

public async Task<string> GetWebContentAsync(string url)
{
    try
    {
        // This starts the network request and immediately returns control
        HttpResponseMessage response = await client.GetAsync(url);
        
        // Check if the request succeeded
        response.EnsureSuccessStatusCode();
        
        // Read the response body asynchronously
        string content = await response.Content.ReadAsStringAsync();
        
        return content;
    }
    catch (HttpRequestException ex)
    {
        return $"Error: {ex.Message}";
    }
    catch (TaskCanceledException)
    {
        return "Request timed out!";
    }
}
What's happening in this real-world example:
  • GetAsync: Starts a network request. This could take 1-10 seconds. The await releases the thread while waiting for the server response.
  • EnsureSuccessStatusCode: Throws an exception for HTTP errors (404, 500, etc.) so you can handle them gracefully.
  • ReadAsStringAsync: Reads the response body - also asynchronous because it might be large and require multiple network packets.
  • Exception Handling: Catch specific exceptions to provide meaningful error messages to users.

Pro tip: Always use HttpClient as a static or singleton instance. Creating a new one for each request can cause socket exhaustion!

Parallel Async Operations
// Scenario: Fetch data from three different APIs
public async Task ProcessAllAsync()
{
    // Start ALL tasks at the same time (they run in parallel)
    Task<string> task1 = FetchUserDataAsync();
    Task<string> task2 = FetchProductDataAsync();
    Task<string> task3 = FetchOrderDataAsync();

    // Wait for ALL to complete
    string[] results = await Task.WhenAll(task1, task2, task3);
    
    // All data is ready to use
    Console.WriteLine($"User: {results[0]}");
    Console.WriteLine($"Products: {results[1]}");
    Console.WriteLine($"Orders: {results[2]}");
}

// Alternative: Process tasks one by one (slower)
public async Task ProcessSequentialAsync()
{
    string user = await FetchUserDataAsync();      // Wait 3 seconds
    string products = await FetchProductDataAsync(); // Wait 2 more seconds
    string orders = await FetchOrderDataAsync();   // Wait 1 more second
    // Total: 6 seconds
}

// Parallel version: All start at the same time
// Total: Only 3 seconds (the longest one)!
Why parallel execution is a game-changer:
  • Task.WhenAll starts multiple tasks simultaneously, not one after another.
  • If each fetch takes 2 seconds, sequential would take 6 seconds, but parallel takes only 2 seconds! That's 3x faster.
  • The await waits for ALL tasks to complete before continuing.
  • Perfect for loading dashboards, reports, or any page that aggregates data from multiple sources.

Performance boost: Use this pattern whenever you have independent operations that don't depend on each other's results. This is how modern websites load so fast!

Cancellation and Timeouts
// Using CancellationToken to cancel long-running operations
public async Task<string> FetchWithTimeoutAsync(
    string url, 
    CancellationToken cancellationToken = default)
{
    using (CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken,
        new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token))
    {
        try
        {
            HttpResponseMessage response = await client.GetAsync(url, cts.Token);
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync();
        }
        catch (OperationCanceledException)
        {
            return "Request was cancelled or timed out!";
        }
    }
}

// Usage with cancellation
public async Task ExampleUsageAsync()
{
    // Create a cancellation token that cancels after 3 seconds
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
    
    string result = await FetchWithTimeoutAsync("https://api.example.com/data", cts.Token);
    Console.WriteLine(result);
}
Why cancellation matters:
  • User experience: Users can cancel long operations (like downloading a large file).
  • Resource management: Prevent wasted resources on operations that are no longer needed.
  • Timeouts: Automatically cancel operations that take too long (5 seconds is a good default for web APIs).
  • Graceful degradation: Provide fallback behavior when operations fail or timeout.

Best practice: Always provide cancellation token support in your async methods. Your users will thank you!

Async/Await Best Practices
Golden Rules of Async/Await:
DO use async Task for async methods
DO use await with Task.WhenAll for parallel operations
DO propagate cancellation tokens
DON'T use async void (except event handlers)
DON'T use .Result or .Wait() - they can deadlock!
DON'T wrap everything in Task.Run - only for CPU-bound work
// ❌ BAD: Blocking on async code (causes deadlocks!)
public void BadExample()
{
    var result = FetchDataAsync().Result;  // This will deadlock in UI apps!
}

// ✅ GOOD: Use async all the way down
public async Task GoodExample()
{
    var result = await FetchDataAsync();  // Proper async handling
}

// ❌ BAD: Async void (exceptions are lost, hard to test)
public async void BadEventHandler()
{
    await SomeAsyncOperation();  // If this throws, your app crashes without warning
}

// ✅ GOOD: Return Task from event handlers
public async Task GoodEventHandler()
{
    await SomeAsyncOperation();  // Properly handled
}
📚 Common pitfall: Always use async all the way up! If you start with async, make sure every caller is also async.
Key Takeaway

Delegates = method references (like assigning a task).
Events = notifications (like a alarm bell).
Async/await = non-blocking operations (keep UI responsive, scale web apps).
Master these and you'll write cleaner, faster, and more maintainable C# code.

Exercise – Build a Simple Event System
Task:

Create a WeatherStation class that:

  • Has a TemperatureChanged event (use EventHandler<int>).
  • Has a SetTemperature(int temp) method that raises the event.
  • In Main, subscribe to the event and print "Temperature is now {temp}°C".
  • Also use a Func<int, int> delegate to convert Celsius to Fahrenheit (formula: F = C * 9/5 + 32).

Hint: Use the code snippets above as reference. Write the solution, then click below to check.

Test Your Knowledge - Take Quiz