.NET Event Programming: Implementation Patterns, Thread Safety, and Memory Management

Basic Event Implementation

In the Observer pattern, a subject (publisher) defines an event, and observers (subscribers) register handlers for that event. When the subject triggers the event, all registered observers execute their handlers. A basic implementation follows this pattern:

public class Publisher
{
    public event EventHandler<CustomEventArgs> DataChanged;

    protected virtual void OnDataChanged(CustomEventArgs args)
    {
        DataChanged?.Invoke(this, args);
    }

    public void ProcessData()
    {
        // Business logic here
        OnDataChanged(new CustomEventArgs());
    }
}

public class CustomEventArgs : EventArgs
{
    // Event data properties
}

External objects subscribe to events using the += operator:

var publisher = new Publisher();
publisher.DataChanged += HandleDataChanged;

void HandleDataChanged(object sender, CustomEventArgs e)
{
    // Response logic
}

Custom Event Accessors

Similar to properties encapsulating fields, events can explicitly define add and remove accessors:

public class Publisher
{
    private EventHandler<CustomEventArgs> _dataChangedHandler;

    public event EventHandler<CustomEventArgs> DataChanged
    {
        add
        {
            _dataChangedHandler = (EventHandler<CustomEventArgs>)
                Delegate.Combine(_dataChangedHandler, value);
        }
        remove
        {
            _dataChangedHandler = (EventHandler<CustomEventArgs>)
                Delegate.Remove(_dataChangedHandler, value);
        }
    }
}

Explicit accessors allow adding custom logic during subscription and unsubscription, providing fine-grained control over the event registration process.

Thread Safety Considerations

When using the standard event declaration syntax, the compiler generates thread-safe accessor methods. The generated code resembles:

private EventHandler<CustomEventArgs> _dataChangedHandler;

[MethodImpl(MethodImplOptions.Synchronized)]
public void add_DataChanged(EventHandler<CustomEventArgs> handler)
{
    _dataChangedHandler = (EventHandler<CustomEventArgs>)
        Delegate.Combine(_dataChangedHandler, handler);
}

[MethodImpl(MethodImplOptions.Synchronized)]
public void remove_DataChanged(EventHandler<CustomEventArgs> handler)
{
    _dataChangedHandler = (EventHandler<CustomEventArgs>)
        Delegate.Remove(_dataChangedHandler, handler);
}

The MethodImplOptions.Synchronized attribute synchronizes access using lock(this). This approach has two significant drawbacks:

First, locking on this exposes the synchronization object to external code. If client code locks on the same instance, deadlocks can occur:

private void ProcessData(Publisher pub)
{
    lock (pub) // External lock
    {
        pub.DataChanged += HandleDataChanged; // Attempts to lock again - deadlock
    }
}

Second, when a class contains multiple events, all accessor methods lock on the same object. This unnecessary contention reduces performance since different events can safely be modified concurrently:

public class MultiEventPublisher
{
    private EventHandler _event1Handler;
    private EventHandler _event2Handler;

    public void add_Event1(EventHandler handler)
    {
        lock (this) // Blocks Event2 operations unnecessarily
        {
            _event1Handler = (EventHandler)Delegate.Combine(_event1Handler, handler);
        }
    }

    public void add_Event2(EventHandler handler)
    {
        lock (this) // Blocks Event1 operations unnecessarily
        {
            _event2Handler = (EventHandler)Delegate.Combine(_event2Handler, handler);
        }
    }
}

Improved Thread Safety Implementation

Using dedicated lock objects resolves both issues:

public class Publisher
{
    private EventHandler<CustomEventArgs> _dataChangedHandler;
    private readonly object _eventLock = new object();

    public event EventHandler<CustomEventArgs> DataChanged
    {
        add
        {
            lock (_eventLock)
            {
                _dataChangedHandler = (EventHandler<CustomEventArgs>)
                    Delegate.Combine(_dataChangedHandler, value);
            }
        }
        remove
        {
            lock (_eventLock)
            {
                _dataChangedHandler = (EventHandler<CustomEventArgs>)
                    Delegate.Remove(_dataChangedHandler, value);
            }
        }
    }
}

For multiple events, each can have its own lock object:

public class MultiEventPublisher
{
    private EventHandler _event1Handler;
    private EventHandler _event2Handler;
    private readonly object _event1Lock = new object();
    private readonly object _event2Lock = new object();

    public event EventHandler Event1
    {
        add
        {
            lock (_event1Lock)
            {
                _event1Handler = (EventHandler)Delegate.Combine(_event1Handler, value);
            }
        }
        remove
        {
            lock (_event1Lock)
            {
                _event1Handler = (EventHandler)Delegate.Remove(_event1Handler, value);
            }
        }
    }

    public event EventHandler Event2
    {
        add
        {
            lock (_event2Lock)
            {
                _event2Handler = (EventHandler)Delegate.Combine(_event2Handler, value);
            }
        }
        remove
        {
            lock (_event2Lock)
            {
                _event2Handler = (EventHandler)Delegate.Remove(_event2Handler, value);
            }
        }
    }
}

Managing Multiple Events with Collections

When a class exposes many events, managing each with a separate delegate field becomes impractical. A collection-based approach centralizes event storage:

public class EventPublisher
{
    private readonly Dictionary<object, Delegate> _handlers = new Dictionary<object, Delegate>();

    private static readonly object Event1Key = new object();
    private static readonly object Event2Key = new object();

    public event EventHandler Event1
    {
        add
        {
            lock (_handlers)
            {
                if (_handlers.ContainsKey(Event1Key))
                {
                    _handlers[Event1Key] = Delegate.Combine(_handlers[Event1Key], value);
                }
                else
                {
                    _handlers[Event1Key] = value;
                }
            }
        }
        remove
        {
            lock (_handlers)
            {
                if (_handlers.ContainsKey(Event1Key))
                {
                    _handlers[Event1Key] = Delegate.Remove(_handlers[Event1Key], value);
                }
            }
        }
    }

    protected virtual void OnEvent1()
    {
        if (_handlers.TryGetValue(Event1Key, out Delegate handler))
        {
            ((EventHandler)handler)?.Invoke(this, EventArgs.Empty);
        }
    }
}

This pattern mirrors the EventHandlerList class used by Windows Forms controls. Note that collection-based management introduces its own synchronization challenges, as all events share the same collection lock.

Memory Leaks in Event Programming

Event subscriptions create strong references from publishers to subscribers. When a subscriber registers an event handler, the publisher maintains a reference to the subscriber through the delegate's target object. This "implicit strong reference" prevents garbage collection even when the subscriber is no longer needed:

// Subscriber remains alive due to event subscription
public class Subscriber
{
    public void Subscribe(Publisher publisher)
    {
        publisher.DataChanged += OnDataChanged;
    }

    private void OnDataChanged(object sender, CustomEventArgs e)
    {
        // Handler logic
    }
}

// Even after subscriber goes out of scope, it cannot be collected
// if the publisher is still alive

The publisher's delegate chain holds a reference to each subscriber. If subscribers are not explicit unsubscribed, they remain in memory as "zombie objects" - no longer useful but still occupying heap space. In long-running applications with many short-lived subscribers, this accumulates into significant memory consumption.

Exceptions from Disposed Objects

Beyond memory leaks, unmanaged event subscriptions cause runtime exceptions. Consider this scenario:

public class MainForm : Form
{
    private void MainForm_Load(object sender, EventArgs e)
    {
        var detailForm = new DetailForm();
        detailForm.ActionTriggered += OnDetailAction;
        detailForm.Show();
    }

    private void OnDetailAction(object sender, EventArgs e)
    {
        this.BringToFront(); // Exception if MainForm is disposed
    }
}

After MainForm closes and disposes, the detailForm still holds a reference to it. When the event fires, attempting to acess the disposed form throws an ObjectDisposedException.

Preventing Event-Related Issues

The straightforward solution is unsubscribing from events when subscribers are no longer needed:

public class Subscriber : IDisposable
{
    private readonly Publisher _publisher;

    public Subscriber(Publisher publisher)
    {
        _publisher = publisher;
        _publisher.DataChanged += OnDataChanged;
    }

    public void Dispose()
    {
        _publisher.DataChanged -= OnDataChanged;
    }

    private void OnDataChanged(object sender, CustomEventArgs e)
    {
        // Handler logic
    }
}

However, this approach requires careful tracking of all subscriptions. For complex systems with numerous events, managing unsubscriptions becomes error-prone. Alternative approaches using weak references or weak event patterns can help decouple subscriber lifetimes from publisher references.

Tags: .NET C# events delegate Thread Safety

Posted on Thu, 09 Jul 2026 17:18:09 +0000 by kazuki