EF Core'da (Entity Framework Core) veritabanı G/Ç'si yapan her senkron metodun bir async karşılığı vardır: SaveChangesAsync, ToListAsync, SingleAsync, AsAsyncEnumerable gibi. Bunları kullanırken tek altın kural, her async çağrıyı aynı bağlam üzerinde bir sonraki işlemi başlatmadan önce hemen await etmektir. "A second operation started on this context before a previous operation completed" hatası, aynı DbContext örneğinin eşzamanlı kullanıldığını gösterir; çözüm ya her işlemi sırayla beklemek ya da paralel işler için ayrı bağlam örnekleri kullanmaktır.

Bu yazıda async programlamanın EF Core'daki yerini, hangi LINQ operatörlerinin async karşılığı olduğunu, buffering ile streaming farkını, thread güvenliği hatalarının iki yaygın kaynağını ve bunların bağımlılık enjeksiyonu (DI) ile nasıl önleneceğini anlatıyoruz. C# tarafındaki async/await temelleri için C# nedir yazısına, serinin genel haritası için EF Core öğrenme rehberi sayfasına bakabilirsiniz.

EF Core'da async neden önemli?

Async işlemler, sorgu veritabanında çalışırken thread'in bloklanmasını önler. Belgeler iki ana faydayı öne çıkarır: zengin istemci uygulamalarında arayüzün yanıt vermeye devam etmesi ve web uygulamalarında thread'in serbest kalıp başka istekleri karşılayabilmesi sayesinde iş hacminin artması. Senkron bir SaveChanges çağrısı veritabanı G/Ç'si süresince thread'i tutar; SaveChangesAsync ise bu süre boyunca thread'i serbest bırakır.

Performans rehberi bu konuda net bir genel kural koyar: uygulamanızın ölçeklenebilmesi için senkron API'ler yerine her zaman async API'leri kullanın. Senkron API'ler G/Ç boyunca thread'i bloklar, daha fazla thread ihtiyacına ve daha fazla bağlam değişimine yol açar. Aynı rehber önemli bir uyarı da ekler: aynı uygulamada senkron ve async kodu karıştırmaktan kaçının, çünkü bu karışım farkında olmadan thread havuzu açlığına (thread-pool starvation) yol açabilir.

csharp
var blog = new Blog { Url = "http://sample.com" };
context.Blogs.Add(blog);
await context.SaveChangesAsync();

Async metotlar senkron karşılıklarıyla aynı etkiye sahiptir; tek fark, bekleme sırasında thread'i bloklamamalarıdır. Belgelerin ifadesiyle async karşılıklar G/Ç yapan metotlar içindir; yukarıdaki örnekte Add yalnızca varlığı change tracker'a ekler, veritabanına gidiş ise SaveChangesAsync ile olur.

Async LINQ operatörleri

Async LINQ operatörleri: Sorguyu kuran, Sorguyu yürüten, sonuç toplayan, Sonuçları akış halinde veren, Kaydetme

LINQ sorgularını async olarak yürütmek için EF Core, sorguyu çalıştırıp sonuç döndüren bir dizi async genişletme metodu sağlar: ToListAsync, SingleAsync, FirstOrDefaultAsync, AnyAsync, CountAsync, AsAsyncEnumerable ve diğerleri.

csharp
var blogs = await context.Blogs.Where(b => b.Rating > 3).ToListAsync();

Where veya OrderBy gibi operatörlerin async sürümü yoktur ve olması da gerekmez. Bu operatörler yalnızca LINQ ifade ağacını kurar; sorgunun veritabanında çalışmasına neden olmazlar. Yalnızca sorguyu yürüten operatörlerin async karşılığı vardır. Aşağıdaki tablo bu ayrımı özetler:

Operatör türüÖrneklerAsync sürümü
Sorguyu kuranWhere, OrderBy, Select, Include, TakeYok; yalnızca ifade ağacına ekler
Sorguyu yürüten, sonuç toplayanToList, ToArray, Single, First, Count, AnyVar: ToListAsync, SingleAsync, CountAsync ...
Sonuçları akış halinde verenforeach ile doğrudan dolaşmaAsAsyncEnumerable ve await foreach
KaydetmeSaveChangesSaveChangesAsync

İstemci tarafında async LINQ ve streaming

Bazen veritabanından gelen sonuçlara SQL'e çevrilemeyen bir .NET fonksiyonu uygulamanız gerekir. Bu durumda AsAsyncEnumerable ile sorguyu veritabanında çalıştırır, dönen IAsyncEnumerable üzerinde istemci tarafı LINQ operatörleriyle devam edersiniz:

csharp
var blogs = context.Blogs
    .Where(b => b.Rating > 3) // sunucuda degerlendirilir (SQL'e cevrilir)
    .AsAsyncEnumerable()
    .Where(b => SomeLocalFunction(b)); // istemcide degerlendirilir (.NET'te)

await foreach (var blog in blogs)
{
    // ...
}

Belgelere göre IAsyncEnumerable üzerindeki LINQ operatörleri .NET 10 ile geliyor; daha eski bir .NET sürümünde System.Linq.Async paketine başvurmanız gerekir. EF Core 10 zaten .NET 10 gerektirdiği için güncel projelerde bu operatörler doğrudan kullanılabilir. SQL'e çevrilemeyen ifadelerin ayrıntıları için client evaluation hatası yazısına bakın.

Bu örnek aynı zamanda buffering ile streaming arasındaki farkı gösterir. ToListAsync ve ToArrayAsync tüm sonuç kümesini belleğe alır (buffering). await foreach ile AsAsyncEnumerable üzerinde dolaşmak ise sonuçları birer birer işler (streaming); bellek ihtiyacı satır sayısıyla büyümez. Sorgunuz az sayıda satır döndürüyorsa fark önemsizdir, ancak büyük sonuç kümelerinde streaming'i düşünmeye değer. Bir uyarı: yeniden deneme stratejisi etkinse EF sonuçları kendi içinde yine de belleğe alır; bu ayrıntı connection resiliency yazısında anlatılıyor.

DbContext thread güvenli değildir

DbContext thread güvenli değildir

EF Core, aynı DbContext örneği üzerinde birden fazla paralel işlemin çalışmasını desteklemez. Bu kısıt hem async sorguların paralel yürütülmesini hem de birden fazla thread'den açıkça eşzamanlı kullanımı kapsar. Bir işlem bitmeden aynı bağlamda yenisini başlatmamalısınız; bu genellikle her async işlemi hemen await ederek sağlanır.

EF Core eşzamanlı kullanım girişimini tespit ettiğinde, belgelerde gösterildiği şekilde şu mesaja benzer bir InvalidOperationException fırlatır:

Mesajın Türkçe özeti şudur: önceki işlem tamamlanmadan bu bağlam üzerinde ikinci bir işlem başlatıldı; bu genellikle farklı thread'lerin aynı DbContext örneğini kullanmasından kaynaklanır. Daha tehlikeli olan, eşzamanlı erişimin tespit edilemediği durumlardır. Belgeler bu durumun tanımsız davranışa, uygulama çökmelerine ve veri bozulmasına yol açabileceğini belirtir. Yani hata mesajını görmek aslında şanslı olduğunuz anlamına gelir; görmediğiniz durumlarda sorun sessizce büyüyebilir.

Bir başka önemli kural: EF Core kodundan fırlatılan bir InvalidOperationException, bağlamı kurtarılamaz bir duruma sokabilir. Bu tür istisnalar bir program hatasına işaret eder ve kurtarılmak üzere tasarlanmamıştır. Bu yüzden bu hatayı yakalayıp aynı bağlamla devam etmeye çalışmak yerine kök nedeni düzeltmeli, gerekirse yeni bir bağlam örneği oluşturmalısınız.

Hatanın birinci kaynağı: await edilmeyen async çağrılar

Async metotlar EF Core'un veritabanına bloklamadan erişen işlemler başlatmasını sağlar. Ancak çağıran kod bu metotlardan birinin tamamlanmasını beklemeden aynı bağlam üzerinde başka işlemler yapmaya devam ederse, bağlamın durumu bozulabilir; belgeler bunun çok büyük olasılıkla gerçekleşeceğini söyler. En yaygın biçimi, aynı bağlamla iki sorguyu başlatıp sonra ikisini birlikte beklemektir:

csharp
// YANLIS: ayni context uzerinde iki sorgu ayni anda calisir
var blogsTask = context.Blogs.Where(b => b.Rating > 3).ToListAsync();
var postsTask = context.Posts.ToListAsync();
await Task.WhenAll(blogsTask, postsTask);

Bu kodda ikinci ToListAsync çağrısı, birincisi henüz bitmemişken aynı bağlam üzerinde başlar. Doğru yaklaşım iki sorguyu sırayla beklemektir:

csharp
// DOGRU: her islem bir sonrakinden once beklenir
var blogs = await context.Blogs.Where(b => b.Rating > 3).ToListAsync();
var posts = await context.Posts.ToListAsync();

Aynı hata, sonucunu beklemediğiniz bir "ateşle ve unut" çağrısında da ortaya çıkar. Örneğin SaveChangesAsync() çağrısının önündeki await unutulursa metot hemen döner, kayıt arka planda sürerken kodunuz aynı bağlamla yeni bir sorgu başlatabilir. Async metotları her zaman hemen beklemek, belgelerin bu konudaki tek ve net önerisidir.

Gerçekten paralel çalışmak gerekiyorsa

Birden fazla sorguyu gerçekten paralel çalıştırmak istiyorsanız her paralel iş için ayrı bir DbContext örneği kullanmalısınız. DI kullanan uygulamalarda bunun en temiz yolu AddDbContextFactory ile bir bağlam fabrikası kaydetmektir. Fabrikadan alınan bağlamlar uygulamanın servis sağlayıcısı tarafından yönetilmez; dispose etmek sizin sorumluluğunuzdadır.

csharp
builder.Services.AddDbContextFactory<ApplicationDbContext>(
    options => options.UseSqlServer(connectionString));

// Servis icinde
public class RaporServisi
{
    private readonly IDbContextFactory<ApplicationDbContext> _contextFactory;

    public RaporServisi(IDbContextFactory<ApplicationDbContext> contextFactory)
        => _contextFactory = contextFactory;

    public async Task<(int, int)> SayilariGetir()
    {
        await using var ctx1 = _contextFactory.CreateDbContext();
        await using var ctx2 = _contextFactory.CreateDbContext();

        var t1 = ctx1.Blogs.CountAsync();
        var t2 = ctx2.Posts.CountAsync();
        await Task.WhenAll(t1, t2);

        return (t1.Result, t2.Result);
    }
}

Bu örnekte her sorgu kendi bağlamında çalıştığı için eşzamanlılık kuralı ihlal edilmez. Her bağlamın ayrı bir veritabanı bağlantısı kullanacağını unutmayın; paralelleştirme veritabanına daha fazla eşzamanlı bağlantı açar. Fabrika ve bağlam ömrü konusunun tamamı DbContext yapılandırma ve yaşam döngüsü yazısında anlatılıyor.

Hatanın ikinci kaynağı: DI ile örtük paylaşım

AddDbContext, bağlam türlerini varsayılan olarak scoped ömürle kaydeder. ASP.NET Core uygulamalarının çoğunda bu güvenlidir: belirli bir anda her istemci isteğini tek bir thread yürütür ve her istek ayrı bir DI kapsamı, dolayısıyla ayrı bir DbContext örneği alır. ASP.NET Core tarafının ayrıntıları ayrı bir konudur; burada yalnızca EF belgelerinin anlattığı kısımla ilgileniyoruz.

Blazor Server barındırma modelinde ise durum farklıdır: kullanıcı devresini (circuit) sürdürmek için tek bir mantıksal istek kullanılır ve varsayılan kapsamla kullanıcı devresi başına yalnızca bir scoped bağlam örneği bulunur. Bu yüzden Blazor Server'da uzun ömürlü tek bağlam, birden çok olay işleyicisinden eşzamanlı kullanılmaya açıktır; belgeler bu senaryolar için bağlam fabrikasını önerir.

Kendi kodunuzda açıkça birden fazla thread çalıştırıyorsanız, örneğin bir arka plan işinde Parallel.ForEachAsync kullanıyorsanız, bağlam örneklerine asla eşzamanlı erişilmediğinden emin olmalısınız. DI ile bunu iki şekilde sağlayabilirsiniz: bağlamı scoped olarak kaydedip her thread için IServiceScopeFactory ile ayrı kapsam oluşturmak ya da bağlamı ServiceLifetime parametresi alan AddDbContext aşırı yüklemesiyle transient olarak kaydetmek.

csharp
// Secenek 1: her paralel is icin ayri kapsam
await Parallel.ForEachAsync(idler, async (id, ct) =>
{
    using var scope = scopeFactory.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    var blog = await db.Blogs.SingleAsync(b => b.BlogId == id, ct);
    // ...
});

// Secenek 2: baglami transient olarak kaydetmek
builder.Services.AddDbContext<ApplicationDbContext>(
    options => options.UseSqlServer(connectionString),
    ServiceLifetime.Transient);

Transient kayıt, her çözümlemede yeni bir bağlam verir; ancak aynı istek içindeki farklı servisler artık aynı bağlamı ve dolayısıyla aynı change tracker'ı paylaşmaz. Bu, iş birimi (unit of work) desenini bozabileceği için seçimi bilinçli yapmalısınız. Çoğu durumda scoped kayıt ile paralel işler için ayrı kapsam veya fabrika daha anlaşılır bir çözümdür.

Thread güvenliği denetimi ve performans

EF Core, aynı bağlamın farklı thread'lerden eşzamanlı kullanımını birçok durumda (ama hepsinde değil) tespit eden ve anında bilgilendirici bir istisna fırlatan bir güvenlik özelliğine sahiptir. Bu özelliğin küçük bir çalışma zamanı maliyeti vardır. Gelişmiş performans rehberi, çok yüksek performans gereken uygulamalar için bağlam yapılandırmasında EnableThreadSafetyChecks değerini false yapmayı bir seçenek olarak anar, ancak bunun yalnızca uygulamanızın bu tür eşzamanlılık hataları içermediğini kapsamlı biçimde test ettikten sonra yapılması gerektiğini açıkça uyarır. Diğer düşük maliyet teknikleri için DbContext pooling ve compiled query yazısına bakın.

Sağlayıcıya özgü notlar

Sağlayıcıya özgü notlar: Microsoft.Data.SqlClient, CancellationToken, TransactionScope
  • Microsoft.Data.SqlClient: Belgeler, SqlClient'ın async uygulamasında bilinen bazı sorunlar olduğunu belirtir. Beklenmedik performans sorunları görüyorsanız, özellikle büyük metin veya ikili değerlerle çalışırken, senkron komut yürütmeyi denemeniz önerilir.
  • CancellationToken: EF Core iptal belirteçlerini alttaki sağlayıcıya iletir; ancak bu belirteçlerin dikkate alınıp alınmayacağı sağlayıcıya bağlıdır. Kullandığınız sağlayıcının belgelerine bakmanız gerekir.
  • TransactionScope: Async kodda ortam transaction'ı kullanıyorsanız TransactionScopeAsyncFlowOption.Enabled belirtmelisiniz; ayrıntılar transaction yönetimi yazısında.

Sık yapılan hatalar

  • await'i unutmak: Beklenmeyen bir async çağrıdan sonra aynı bağlamı kullanmak, "second operation" hatasının veya daha kötüsü sessiz veri bozulmasının en yaygın nedenidir.
  • Aynı bağlamla Task.WhenAll: Birden fazla sorguyu aynı bağlamda paralel başlatmak desteklenmez; ayrı bağlamlar kullanın.
  • Singleton servise DbContext enjekte etmek: Uzun ömürlü bir servis, scoped bağlamı farklı isteklerden eşzamanlı kullanılır hale getirebilir. Bağlam ömrünü işin ömrüyle eşleştirin.
  • Senkron ve async kodu karıştırmak: Belgeler bunun thread havuzu açlığına yol açabileceği konusunda uyarır.
  • Hatayı yakalayıp aynı bağlamla devam etmek: EF Core'dan gelen InvalidOperationException bağlamı kurtarılamaz duruma sokabilir.
  • Gereksiz ToListAsync: Sonuç üzerinde başka LINQ operatörleri uygulayacaksanız tüm sonucu belleğe almak yerine streaming düşünün.
  • using eksikliği: ToListAsync görünmüyorsa büyük olasılıkla Microsoft.EntityFrameworkCore ad alanı eklenmemiştir.

Sık Sorulan Sorular

"A second operation started on this context" hatasını nasıl düzeltirim?

Önce aynı bağlam üzerinde beklenmeyen bir async çağrı olup olmadığını kontrol edin ve her çağrıyı hemen await edin. Kod gerçekten paralel çalışmak zorundaysa her paralel iş için IDbContextFactory veya ayrı DI kapsamı ile ayrı bağlam kullanın.

Where veya OrderBy için neden async metot yok?

Bu operatörler sorguyu çalıştırmaz, yalnızca ifade ağacına bir adım ekler. Veritabanına gidiş, ToListAsync veya FirstOrDefaultAsync gibi yürütücü bir operatör çağrıldığında olur; async sürümler yalnızca bunlar için vardır.

ASP.NET Core'da scoped DbContext güvenli mi?

EF belgelerine göre çoğu ASP.NET Core uygulamasında evet: her istek kendi kapsamını ve bağlamını alır ve belirli bir anda tek thread tarafından işlenir. Sorun, istek içinde kendiniz paralel işler başlattığınızda veya bağlamı daha uzun ömürlü bir servise taşıdığınızda başlar.

EnableThreadSafetyChecks'i kapatmalı mıyım?

Çoğu uygulama için hayır. Bu denetim hatayı erken ve anlaşılır biçimde yakalar. Belgeler kapatmayı yalnızca yüksek performans gereken ve eşzamanlılık hatası içermediği kapsamlı testlerle doğrulanmış uygulamalar için önerir.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: EF Core öğrenme rehberi · DbContext yaşam döngüsü · DbContext pooling · Transaction yönetimi · Client evaluation · C# nedir