Mert Özen Her Satırda Daha İleri
Backend C# İleri Seviye — #03

C#'ta Event: EventHandler, Güvenli Tetikleme ve Subscription Leak

Mert Özen 7 Kas 2026 10 dk 7 görüntülenme
C#'ta Event: EventHandler, Güvenli Tetikleme ve Subscription Leak

event keyword'ünün delegate'i nasıl koruduğu ve CS0070, compiler'ın ürettiği private field ile add/remove method'ları, EventHandler konvansiyonu ve record ile event verisi, ?.Invoke ile güvenli tetikleme, kendi add/remove accessor'ların ve CS0079, WeakReference ile ölçülen 50 MB'lık subscription leak'i ve IDisposable dönen Subscribe.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Catalog iki şekilde haber veriyor: biri sıradan bir public delegate field'ı, diğeri aynı tipte bir event. Dışarıdaki kod ikisine de subscribe oluyor, sonra ikisini de sıfırlamaya ve kendi başına tetiklemeye çalışıyor. A, B, C ve D satırlarından hangileri derlenir?

Catalog catalog = new Catalog();
catalog.PriceChangedField += title => Console.WriteLine($"field: {title}");
catalog.PriceChangedEvent += title => Console.WriteLine($"event: {title}");

catalog.PriceChangedField = null; // A
catalog.PriceChangedField?.Invoke("Fake"); // B
catalog.PriceChangedEvent = null; // C
catalog.PriceChangedEvent?.Invoke("Fake"); // D

public class Catalog
{
    public Action<string>? PriceChangedField;
    public event Action<string>? PriceChangedEvent;
}

İki satır arasındaki tek fark event kelimesi. O kelimenin neyi değiştirdiği, bu yazının ilk konusu.

event Keyword'ü Neyi Korur

A ve B derlenir, C ve D derlenmez:

Program.cs(7,9): error CS0070: The event 'Catalog.PriceChangedEvent' can only appear on the left hand side of += or -= (except when used from within the type 'Catalog')
Program.cs(8,9): error CS0070: The event 'Catalog.PriceChangedEvent' can only appear on the left hand side of += or -= (except when used from within the type 'Catalog')

Field olan versiyonda dışarıdaki herhangi bir kod = null ile bütün subscriber'ları tek satırda silebilir ya da fiyat hiç değişmemişken event'i kendisi tetikleyip herkesi yanıltabilir. Daha masum ama aynı derecede zararlı bir hata da var: += yerine yanlışlıkla = yazmak, o ana kadarki bütün subscriber'ları sessizce siler. event kelimesi delegate'in etrafına bir duvar örer: dışarıdakiler yalnızca subscribe olabilir (+=) ve unsubscribe olabilir (-=); listeyi değiştirmek ve tetiklemek yalnızca tipin kendisine kalır.

Bunu compiler nasıl yapıyor? Reflection ile bakalım:

using System.Reflection;

Type type = typeof(Catalog);

EventInfo info = type.GetEvent("PriceChanged")!;
Console.WriteLine(info.AddMethod!.Name);
Console.WriteLine(info.RemoveMethod!.Name);

FieldInfo field = type.GetField("PriceChanged", BindingFlags.NonPublic | BindingFlags.Instance)!;
Console.WriteLine($"{field.Name}: {field.FieldType.Name}, private: {field.IsPrivate}");

public class Catalog
{
    public event EventHandler? PriceChanged;

    public void Raise() => PriceChanged?.Invoke(this, EventArgs.Empty);
}
add_PriceChanged
remove_PriceChanged
PriceChanged: EventHandler, private: True

Tek satırlık event declaration'ı üç şeye dönüştü: aynı isimde private bir delegate field'ı, bir add_PriceChanged ve bir remove_PriceChanged method'u. Dışarıdaki += aslında add_ method'unu, -= de remove_ method'unu çağırır. Property'nin get/set ile bir field'ı sarması gibi, event de add/remove ile bir delegate'i sarar. Bu şekilde, gövdesiz yazılan event'lere field-like event denir. Compiler'ın ürettiği add/remove thread-safe'tir; iki thread aynı anda subscribe olduğunda bir subscriber kaybolmaz.

EventHandler Konvansiyonu

Tahmin örneğinde event'in tipi Action<string>'di. Derlenir, ama .NET dünyasında beklenen şekil bu değil. Design pattern yazısında gördüğümüz konvansiyonu bir adım ileri götürelim:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Catalog catalog = new Catalog();
catalog.PriceChanged += PrintChange;
catalog.Cleared += (sender, e) => Console.WriteLine($"cleared by {sender?.GetType().Name}, empty args: {e == EventArgs.Empty}");

catalog.SetPrice("Dune", 45.90m);
catalog.SetPrice("Dune", 39.90m);
catalog.SetPrice("Dune", 39.90m);
catalog.Clear();

static void PrintChange(object? sender, PriceChange e)
{
    Console.WriteLine($"{e.Title}: {e.OldPrice} -> {e.NewPrice}");
}

// EventArgs'tan türemek zorunda değil
public record PriceChange(string Title, decimal OldPrice, decimal NewPrice);

public class Catalog
{
    private readonly Dictionary<string, decimal> _prices = new Dictionary<string, decimal>();

    public event EventHandler<PriceChange>? PriceChanged;
    public event EventHandler? Cleared;

    public void SetPrice(string title, decimal price)
    {
        _prices.TryGetValue(title, out decimal oldPrice);
        if (oldPrice == price)
        {
            return;
        }

        _prices[title] = price;
        OnPriceChanged(new PriceChange(title, oldPrice, price));
    }

    public void Clear()
    {
        _prices.Clear();
        OnCleared();
    }

    protected virtual void OnPriceChanged(PriceChange e) => PriceChanged?.Invoke(this, e);

    protected virtual void OnCleared() => Cleared?.Invoke(this, EventArgs.Empty);
}
Dune: 0 -> 45.90
Dune: 45.90 -> 39.90
cleared by Catalog, empty args: True

Konvansiyonun parçaları: delegate tipi EventHandler ya da EventHandler<T>; ilk parametre event'i yayan object (sender), ikincisi event'in verisi. Taşınacak veri yoksa EventHandler ve EventArgs.Empty kullanılır; her seferinde yeni bir boş object üretmeye gerek yok. Event'i tetikleyen kod OnXxx adlı, protected virtual bir method'da durur; böylece bir derived class tetikleme davranışına override ile müdahale edebilir. Event'in adı da bir şeyin olduğunu anlatır: PriceChanged, Cleared. Olmadan hemen önce tetiklenen event'ler için PriceChanging gibi bir isim kullanılır.

Event verisinin EventArgs'tan türemesi eskiden zorunluydu; .NET Framework 4.5 ile EventHandler<TEventArgs>'teki o kısıt kaldırıldı. Yukarıdaki PriceChange sıradan bir record ve sorunsuz derleniyor. Değiştirilemeyen, değer eşitliği olan bir record event verisi için iyi bir seçim: bir subscriber veriyi değiştirip sonraki subscriber'ı şaşırtamaz. Üçüncü SetPrice çağrısının çıktı üretmediğine de dikkat et; fiyat değişmediyse "değişti" demek yanlış olurdu.

Event'i Güvenle Tetiklemek

Subscriber'ı olmayan bir event null'dır; bu yüzden tetiklerken hep ?.Invoke kullandık. Eski kodlarda bunun yerine şu pattern'i görürsün:

EventHandler<PriceChange>? handler = PriceChanged;
if (handler != null)
{
    handler(this, e);
}

Neden önce bir local değişkene kopyalanıyor? Çok thread'li bir uygulamada if (PriceChanged != null) PriceChanged(this, e); yazarsan, null kontrolü ile çağrı arasında başka bir thread son subscriber'ı çıkarabilir ve sen null bir delegate'i çağırırsın. Local kopya, field'ı bir kez okur; delegate'ler immutable olduğu için o kopya artık başka bir thread tarafından değiştirilemez. ?.Invoke tam olarak bunu tek satırda yapar: field'ı bir kez okur, null değilse o okunan değeri çağırır. Bunun bir sonucu da var: yeni unsubscribe olmuş bir subscriber, o anda zaten başlamış bir tetiklemede yine de çağrılabilir. Handler'larını buna dayanıklı yaz.

Unutma ki event'ler senkron çalışır. SetPrice, bütün subscriber'ların işi bitene kadar dönmez ve bir subscriber'ın fırlattığı exception, sonraki subscriber'ların çağrılmasını engelleyip SetPrice'ı çağıran koda kadar çıkar. İlk yazıda multicast delegate'te gördüğümüz davranışın aynısı; subscriber'ların birbirinden yalıtılması gerekiyorsa GetInvocationList() ile tek tek çağırırsın.

add ve remove'u Kendin Yazmak

Property'lerde get/set'i kendin yazabildiğin gibi event'lerde de add/remove'u kendin yazabilirsin. Subscription'ları log'lamak, subscriber'ları başka bir object'e yönlendirmek ya da yüzlerce event'i tek bir dictionary'de saklamak için bu yola başvurulur:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Catalog catalog = new Catalog();
CartView view = new CartView();

catalog.PriceChanged += view.OnPriceChanged;
catalog.PriceChanged += view.OnPriceChanged;
Console.WriteLine($"subscribers: {catalog.SubscriberCount}");

catalog.SetPrice("Dune", 39.90m);

catalog.PriceChanged -= view.OnPriceChanged;
catalog.PriceChanged -= view.OnPriceChanged;
Console.WriteLine($"subscribers: {catalog.SubscriberCount}");

public record PriceChange(string Title, decimal NewPrice);

public class CartView
{
    public void OnPriceChanged(object? sender, PriceChange e)
    {
        Console.WriteLine($"cart refreshed: {e.Title} {e.NewPrice}");
    }
}

public class Catalog
{
    private EventHandler<PriceChange>? _priceChanged;

    public int SubscriberCount => _priceChanged?.GetInvocationList().Length ?? 0;

    public event EventHandler<PriceChange>? PriceChanged
    {
        add
        {
            Console.WriteLine($"add: {value?.Method.Name}");
            _priceChanged += value;
        }
        remove
        {
            Console.WriteLine($"remove: {value?.Method.Name}");
            _priceChanged -= value;
        }
    }

    public void SetPrice(string title, decimal price)
    {
        // event'in kendisi değil, arkasındaki field tetiklenir
        _priceChanged?.Invoke(this, new PriceChange(title, price));
    }
}
add: OnPriceChanged
add: OnPriceChanged
subscribers: 2
cart refreshed: Dune 39.90
cart refreshed: Dune 39.90
remove: OnPriceChanged
remove: OnPriceChanged
subscribers: 0

value, eklenen ya da çıkarılan delegate. Accessor'ları kendin yazınca compiler arkada bir field üretmez; delegate'i saklamak senin işin. Bu yüzden event'i class'ın içinden de tetikleyemezsin: SetPrice'ta PriceChanged?.Invoke(...) yazınca derleyici error CS0079: The event 'Catalog.PriceChanged' can only appear on the left hand side of += or -= der. Bir şeyi de kaybedersin: yukarıdaki basit add/remove, compiler'ın ürettikleri gibi thread-safe değil. Gerekiyorsa bir lock ile korursun.

Çıktıda bir tuzak da görünüyor: aynı handler iki kez subscribe olunca iki kez çağrıldı. Event'ler aynı subscriber'ı engellemez. Bir ekranın constructor'ında subscribe olup ekran her açıldığında aynı publisher'a yeniden subscribe olmak, bu çift çağrının en yaygın kaynağıdır.

Leak'i Gözle Görmek

İlk yazıda delegate'in Target ile object'e referans tuttuğunu, ikinci yazıda lambda'nın this'i yakalayabildiğini gördük. Event'te bu, publisher'ın subscriber'ını canlı tuttuğu anlamına gelir. Uygulama boyunca yaşayan bir fiyat akışı ve kapatılan ekranlarla bunu ölçelim:

using System.Globalization;
using System.Runtime.CompilerServices;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

PriceFeed feed = new PriceFeed();

WeakReference leaked = OpenAndCloseView(feed, dispose: false);
WeakReference cleaned = OpenAndCloseView(feed, dispose: true);

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

Console.WriteLine($"closed without Dispose, alive: {leaked.IsAlive}");
Console.WriteLine($"closed with Dispose, alive: {cleaned.IsAlive}");
Console.WriteLine($"subscribers: {feed.SubscriberCount}");

feed.Publish("Dune", 39.90m);

long before = GC.GetTotalMemory(forceFullCollection: true);
for (int i = 0; i < 50; i++)
{
    OpenAndCloseView(feed, dispose: false);
}
long after = GC.GetTotalMemory(forceFullCollection: true);
Console.WriteLine($"50 more views closed without Dispose: +{(after - before) / 1_000_000} MB, subscribers: {feed.SubscriberCount}");

[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference OpenAndCloseView(PriceFeed feed, bool dispose)
{
    CartView view = new CartView(feed);
    if (dispose)
    {
        view.Dispose();
    }
    return new WeakReference(view);
}

public record PriceChange(string Title, decimal NewPrice);

public class PriceFeed
{
    public event EventHandler<PriceChange>? PriceChanged;

    public int SubscriberCount => PriceChanged?.GetInvocationList().Length ?? 0;

    public void Publish(string title, decimal price) => PriceChanged?.Invoke(this, new PriceChange(title, price));
}

public sealed class CartView : IDisposable
{
    private static int _nextId;

    private readonly PriceFeed _feed;
    private readonly int _id = ++_nextId;
    private readonly byte[] _renderCache = new byte[1_000_000];

    public CartView(PriceFeed feed)
    {
        _feed = feed;
        _feed.PriceChanged += OnPriceChanged;
    }

    private void OnPriceChanged(object? sender, PriceChange e)
    {
        Console.WriteLine($"view {_id} refreshed: {e.Title} {e.NewPrice}");
    }

    public void Dispose()
    {
        _feed.PriceChanged -= OnPriceChanged;
    }
}
closed without Dispose, alive: True
closed with Dispose, alive: False
subscribers: 1
view 1 refreshed: Dune 39.90
50 more views closed without Dispose: +50 MB, subscribers: 51

WeakReference, object'i canlı tutmadan ona bakmamızı sağlıyor; IsAlive garbage collector'ın onu toplayıp toplamadığını söylüyor. OpenAndCloseView'ı ayrı ve inline edilmeyen bir method yaptık ki ekran object'ine bizim kodumuzda hiçbir local referans kalmasın. Tam bir GC'den sonra Dispose edilen ekran toplandı, edilmeyen hâlâ yaşıyor. Üstelik sadece yaşamıyor, çalışıyor: kapatılmış 1 numaralı ekran fiyat değişince kendini yeniledi. Gerçek bir uygulamada bu, kapatılmış bir ekranın veritabanına gitmesi ya da log'a yazması demektir.

Zincir şöyle: feed yaşıyor, onun private delegate field'ı yaşıyor, delegate'in Target'ı ekranı gösteriyor, ekran da 1 MB'lık cache'ini. Elli ekran daha açıp kapatınca bellek 50 MB büyüdü ve subscriber sayısı 51 oldu. Garbage collector burada hata yapmıyor; ulaşılabilir bir object'i toplamaması gerekir. Leak, unutulan bir -=.

03-event-leak-tr.png

Subscription'ı Yönetmek

Kural basit: subscriber, publisher'dan kısa yaşıyorsa işi bitince unsubscribe olmalı. Bunu hatırlamaya bırakmak yerine subscription'ı bir object'e çevirebilirsin. Subscribe bir IDisposable döndürür, onu dispose etmek unsubscribe olmak demektir:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

PriceFeed feed = new PriceFeed();

using (IDisposable subscription = feed.Subscribe((_, e) => Console.WriteLine($"badge: {e.Title} {e.NewPrice}")))
{
    feed.Publish("Dune", 39.90m);
    Console.WriteLine($"inside using: {feed.SubscriberCount}");
}

feed.Publish("Dune", 35.00m);
Console.WriteLine($"after using: {feed.SubscriberCount}");

public record PriceChange(string Title, decimal NewPrice);

public class PriceFeed
{
    public event EventHandler<PriceChange>? PriceChanged;

    public int SubscriberCount => PriceChanged?.GetInvocationList().Length ?? 0;

    public void Publish(string title, decimal price) => PriceChanged?.Invoke(this, new PriceChange(title, price));

    public IDisposable Subscribe(EventHandler<PriceChange> handler)
    {
        PriceChanged += handler;
        return new Subscription(() => PriceChanged -= handler);
    }

    private sealed class Subscription : IDisposable
    {
        private Action? _unsubscribe;

        public Subscription(Action unsubscribe)
        {
            _unsubscribe = unsubscribe;
        }

        public void Dispose()
        {
            // ikinci Dispose bir şey yapmasın
            _unsubscribe?.Invoke();
            _unsubscribe = null;
        }
    }
}
badge: Dune 39.90
inside using: 1
after using: 0

Lambda ile subscribe olduk ama sorun yaşamadık, çünkü çıkarken aynı delegate'i kullanıyoruz: handler değişkeni closure ile Subscription'ın içinde saklanıyor. BCL'deki IObservable<T>.Subscribe da aynı şekildedir ve System.Reactive bu interface'in üzerine kuruludur. Bir ekran ya da bir arka plan servisi subscription'larını bir list'te toplayıp kendi Dispose'unda hepsini dispose edebilir. Bazı UI framework'leri, örneğin WPF, bu soruna weak event çözümleri de sunar; ama en okunur çözüm hâlâ açık bir unsubscribe'dır.

Sık Düşülen Tuzaklar

Static event'e kısa ömürlü object'le subscribe olmak. Static bir event'in publisher'ı uygulama kapanana kadar yaşar; ona subscribe olan her object de öyle. Static event görürsen unsubscribe'ın nerede olduğunu ilk iş olarak ara.

Subscribe olmayı tekrarlamak. Yukarıdaki çift çağrıyı hatırla. Subscribe olan kodun kaç kez çalıştığını bil; gerekirse subscribe olmadan önce bir kez -= yapmak zararsızdır, listede yoksa hiçbir şey olmaz.

Event handler'ında async void. Event handler'ları async void'in kabul gördüğü tek yer, ama içinde yakalanmayan bir exception process'i çökertebilir ve publisher handler'ın bittiğini bilemez. Asenkron Programlama serisinin async void yazısında ayrıntısıyla göreceğiz.

Event'i public bir delegate field'ıyla taklit etmek. Tahmin örneğindeki A ve B satırları derlendi; dışarıdaki herhangi bir kod subscriber listeni silebilir. Bildirim yayınlıyorsan event kelimesini unutma.

Küçük Bir Deneme

★☆☆ Catalog'a, fiyat değişmeden önce tetiklenen bir PriceChanging event'i ekle ve iki event'in sırasını çıktıda gör.

★★☆ PriceChanging'in verisine bool Cancel property'si ekle. Subscriber'lardan biri fiyatın yarıdan fazla düşmesini Cancel = true ile engellesin ve SetPrice bu durumda fiyatı değiştirmesin. Burada record yerine neden değiştirilebilir bir class gerekti?

★★★ Leak örneğinde CartView'ın constructor'ındaki +='yi feed.Subscribe(...) ile değiştir, dönen IDisposable'ı field'da tut ve Dispose'ta onu dispose et. Ardından 50 ekranlık döngüyü dispose: true ile çalıştır ve bellek artışının kaybolduğunu ölç.

Bu yazıda event verisini record'larla taşıdık. Record'ların ve genel olarak tiplerin içine bakarak karar vermenin C#'taki en güçlü aracı pattern matching. Bir sonraki yazıda pattern matching'i derinlemesine açıyoruz: property, positional ve tuple pattern'ler, relational ve logical pattern'lerle birleşen switch expression'lar ve exhaustiveness uyarıları.