CodeGym /Courses /C# SELF /Optimizing Event-Driven Programming

Optimizing Event-Driven Programming

C# SELF
Level 54 , Lesson 2
Available

1. Introduction

In most typical apps events run fast and are almost "free" — the CLR (Common Language Runtime) is well optimized for handling them. However, when an app grows, events become numerous, subscriber chains get long, and performance requirements increase, you may discover that even such a "simple" construct as events can become a bottleneck. This is especially visible in systems with a lot of real-time updates, user interfaces (UI), or when processing hundreds of thousands of sensor notifications in IoT apps.

In this lecture we'll cover:

  • How events and delegates affect performance.
  • Where the bottlenecks are.
  • How to write fast event code and avoid performance-harming issues.

Internal structure of events in .NET

As already mentioned, an event is a wrapper around a delegate. A delegate is a special object that contains a list of methods (invocation list) that are called when invoked. On each event call the CLR iterates that list and calls all methods synchronously. (Asynchrony appears only if you manually add async code there.)

Illustration:


[Publisher] ----- (event) ---> [Delegate (Invocation List)] --> [Handler 1]
                                                           --> [Handler 2]
                                                           --> [Handler N]

2. Cost of delegates and events: breakdown

Storage cost

  • Each delegate is a full object.
  • Each handler (subscriber method) creates another delegate.
  • The more subscribers — the more objects, the more memory.

In simple cases leaks or overhead are negligible. But if there are thousands of handlers — it's worth thinking about it!

Invocation cost

  • Event invocation = iterating the invocation list.
  • Each method is called synchronously (one after another).
  • If a handler does heavy work or "sleeps" for a long time, it slows down everyone else.

Example: simple implementation


public class Counter
{
    public event EventHandler Counted;

    public void Increment()
    {
        // ... skipping counting logic
        // Subscribers are called synchronously!
        Counted?.Invoke(this, EventArgs.Empty);
    }
}

If we have 1000 subscribers whose handlers do Thread.Sleep(10), the event call will take about 10 seconds...

3. "Heavy" subscribers — the enemy of performance

Why handlers should be "light"?

  • Events are invoked synchronously, the calling thread waits for the end of all handlers.
  • One slow handler slows the entire chain.
  • If a handler can "throw" an exception — others may not be called (unless you protect the call with try/catch).

Demonstration


class Program
{
    static void Main()
    {
        var publisher = new Counter();
        // Fast
        publisher.Counted += (s, e) => Console.WriteLine("First");
        // Slow
        publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
        // Another
        publisher.Counted += (s, e) => Console.WriteLine("Last");

        // Time measurement
        var watch = System.Diagnostics.Stopwatch.StartNew();
        publisher.Increment();
        watch.Stop();
        Console.WriteLine($"All handlers finished in {watch.ElapsedMilliseconds} ms.");
    }
}

Try running it — you'll notice a visible pause. The first handler is almost instant, the second is the "delay", and only after that the third runs.

Practical takeaway

  • Don't put heavy business logic directly inside event handlers!
  • Better move such work to a separate thread, task, or an async handler.

4. Exceptions in handlers: performance traps

If one of the subscribers throws an exception, event processing is interrupted — subsequent handlers may not be called!


publisher.Counted += (s, e) => throw new Exception("Error!");
publisher.Counted += (s, e) => Console.WriteLine("You won't see this line.");

To avoid this and not let one "bad apple" slow things down, use manual iteration that protects each handler.

Advanced version of event invocation


protected virtual void OnCounted()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            try
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Handler error: {ex.Message}");
                // Logging, or special error handling
            }
        }
    }
}

This makes the event more "resilient": even if one subscriber fails — the others still run.

5. Asynchronous (fire-and-forget) events

If an event can be slow — sometimes you want to run handlers on separate threads or tasks so you don't block the main thread.

Option 1: run each handler in its own task


protected virtual void OnCountedAsync()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            // Fire-and-forget: don't wait for completion!
            System.Threading.Tasks.Task.Run(() =>
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            });
        }
    }
}

But! Be careful with parallelism

  • If subscribers use shared resources — races (race conditions) can occur.
  • Exceptions in fire-and-forget handlers are hard to catch.
  • If you need to wait for all subscribers to finish — collect tasks and use Task.WhenAll.

For UI (WinForms/WPF) — never invoke handlers outside the UI thread, otherwise you'll get InvalidOperationException.

In general — async events require careful and thoughtful design!

6. Optimizing storage and invocation of events

"Empty" events: saving memory

If your class has many events, most of which are rarely used (for example, many events in a UI component), there is a trick: EventHandlerList.

How it works

.NET controls (for example, in WinForms) don't store a separate delegate for each event, they put all events into a single structure (EventHandlerList) — only if at least one handler is subscribed.

Example of manually creating an EventHandlerList

using System.ComponentModel; // EventHandlerList lives here!

class MyControl
{
    private readonly EventHandlerList _events = new EventHandlerList();

    private static readonly object EventMyEvent = new object();

    public event EventHandler MyEvent
    {
        add    { _events.AddHandler(EventMyEvent, value); }
        remove { _events.RemoveHandler(EventMyEvent, value); }
    }

    protected virtual void OnMyEvent()
    {
        var handler = (EventHandler)_events[EventMyEvent];
        handler?.Invoke(this, EventArgs.Empty);
    }
}

Why do this: you save memory by not creating unnecessary delegates for hundreds of "empty" events.

7. Thread-safety: avoiding races and locks

Events in .NET are NOT thread-safe by themselves! While a subscriber is subscribing or unsubscribing, another thread may be raising the event. This can lead to the delegate becoming null right before invocation, causing a NullReferenceException.

Best practices

  • Use the operator ?. (Counted?.Invoke(...)) — protects against null.
  • For complex cases — lock access to the event using lock.

Example


private readonly object _lockObj = new object();
private EventHandler _myEvent;

public event EventHandler MyEvent
{
    add { lock (_lockObj) { _myEvent += value; } }
    remove { lock (_lockObj) { _myEvent -= value; } }
}

protected virtual void OnMyEvent()
{
    EventHandler handler;
    lock (_lockObj)
    {
        handler = _myEvent;
    }
    handler?.Invoke(this, EventArgs.Empty);
}

When is this complexity needed?

  • In multithreaded applications (for example, servers, multithreaded parsers, etc.).
  • If subscribe/unsubscribe happens from different threads while the event is raised from another.

8. add/remove accessors for control and optimization

In special cases (for example, if you need to log all subscriptions or limit the number of subscribers) you can implement the event manually via accessors:


private EventHandler _event;
public event EventHandler MyEvent
{
    add
    {
        if (_event == null || _event.GetInvocationList().Length < 10)
            _event += value;
        else
            Console.WriteLine("Limit: no more than 10 subscribers allowed.");
    }
    remove { _event -= value; }
}

This allows you to:

  • Inject custom logic.
  • Make events thread-safe.
  • Enforce limits or log subscriptions/unsubscriptions.

9. Useful nuances

Lambda expressions, closures and performance

Lambda expressions are convenient for on-the-fly subscriptions:


var button = new Button();
button.Click += (s, e) => Console.WriteLine("Button clicked");

But if a lambda captures variables — a closure is created, which can increase memory usage. In most UI cases this isn't a big deal, but for low-level code you should watch the number of closures and lifetimes of captured objects.

Interesting fact:
If you add two identical lambdas in a row, they will be two different delegate objects, and the method will run twice.

Profiling events and delegates

When the app becomes large and complex, you should profile events just like any other code.

How to measure event speed?

  • Use Stopwatch to measure the time between raising the event and finishing processing.
  • Use memory profiling tools (for example, dotMemory, Visual Studio built-in tools) to find subscribers that weren't unsubscribed and are kept in memory.
  • To find "zombie subscribers" look for long invocation lists on long-lived objects.

Table of "Optimizations and Pitfalls"

Problem/Scenario Solution
Many long-lived (and useless) events Use EventHandlerList
Subscriber slows everyone down Move heavy logic to a task/separate thread
Thread-safety Copy delegate before invocation, lock on add/remove
Exceptions in handlers Catch with try/catch around each handler
Memory leaks from "zombie subscribers" Always unsubscribe, implement IDisposable, profile

Diagram: "Lifecycle of an optimized event"


+----------------+       +------------------+       +---------------------+
| Subscriber cre |  -->  | Subscription (+=) |  -->  | Entered Invocation  |
+----------------+       +------------------+       +---------------------+
                                |                                ^
                                |                                |
                   Unsubscribe (-=) |                     Exception |
                                v                                |
+----------------+       +--------------------+      +----------------------+
| Subscriber Disp |  -->  | Removed from call  |  --> | No longer a zombie   |
+----------------+       +--------------------+      +----------------------+

10. How to explain "event management" in an interview

If you're asked "What are the inefficiencies of events in C#?" or "When do you need to optimize events?", you should know:

  • Events are good for loose coupling, but inefficient with massive subscriptions and heavy handlers.
  • They are not thread-safe by default.
  • They require manual unsubscription (otherwise memory leaks).
  • For large-scale producers and subscribers — EventHandlerList and custom accessors add/remove.
  • Deep control is rarely needed — most tasks are covered by the standard pattern.

In the next lecture we'll move to advanced scenarios and practical examples of event-delegate programming, where you'll see how all these optimizations work on real tasks.

Common myths and antipatterns

  • Assuming events in .NET are always fast — they are fast until there are many subscribers or heavy handlers.
  • Hoping GC will clean everything up — no, if you don't unsubscribe, the object will live forever!
  • Using events for "distant" connections between business logic layers — better use explicit patterns (for example, Mediator).
2
Task
C# SELF, level 54, lesson 2
Locked
Adding and Removing Subscribers with Limit Control
Adding and Removing Subscribers with Limit Control
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION