Operasi async Entity Framework memakan waktu sepuluh kali lebih lama untuk diselesaikan

139

Saya punya situs MVC yang menggunakan Entity Framework 6 untuk menangani database, dan saya telah bereksperimen dengan mengubahnya sehingga semuanya berjalan sebagai pengontrol async dan panggilan ke database dijalankan sebagai rekan async mereka (mis. ToListAsync () alih-alih ToList ())

Masalah yang saya alami adalah bahwa hanya mengubah pertanyaan saya ke async telah menyebabkan mereka menjadi sangat lambat.

Kode berikut mendapatkan koleksi objek "Album" dari konteks data saya dan diterjemahkan ke dalam basis data yang cukup sederhana:

// Get the albums
var albums = await this.context.Albums
    .Where(x => x.Artist.ID == artist.ID)
    .ToListAsync();

Inilah SQL yang dibuat:

exec sp_executesql N'SELECT 
[Extent1].[ID] AS [ID], 
[Extent1].[URL] AS [URL], 
[Extent1].[ASIN] AS [ASIN], 
[Extent1].[Title] AS [Title], 
[Extent1].[ReleaseDate] AS [ReleaseDate], 
[Extent1].[AccurateDay] AS [AccurateDay], 
[Extent1].[AccurateMonth] AS [AccurateMonth], 
[Extent1].[Type] AS [Type], 
[Extent1].[Tracks] AS [Tracks], 
[Extent1].[MainCredits] AS [MainCredits], 
[Extent1].[SupportingCredits] AS [SupportingCredits], 
[Extent1].[Description] AS [Description], 
[Extent1].[Image] AS [Image], 
[Extent1].[HasImage] AS [HasImage], 
[Extent1].[Created] AS [Created], 
[Extent1].[Artist_ID] AS [Artist_ID]
FROM [dbo].[Albums] AS [Extent1]
WHERE [Extent1].[Artist_ID] = @p__linq__0',N'@p__linq__0 int',@p__linq__0=134

Seperti yang terjadi, ini bukan permintaan yang sangat rumit, tapi butuh hampir 6 detik untuk menjalankan server SQL. SQL Server Profiler melaporkannya untuk menyelesaikan 5742 ms.

Jika saya mengubah kode saya ke:

// Get the albums
var albums = this.context.Albums
    .Where(x => x.Artist.ID == artist.ID)
    .ToList();

Kemudian SQL yang sama persis dihasilkan, namun ini berjalan hanya dalam 474ms menurut SQL Server Profiler.

Basis data memiliki sekitar 3500 baris dalam tabel "Album", yang tidak terlalu banyak, dan memiliki indeks pada kolom "Artist_ID", jadi itu harus cukup cepat.

Saya tahu bahwa async memiliki overhead, tetapi membuat segalanya menjadi sepuluh kali lebih lambat sepertinya agak curam bagi saya! Di mana saya salah di sini?

Dylan Parry
sumber
itu tidak cocok untuk saya. Jika Anda menjalankan kueri yang sama dengan data yang sama, waktu eksekusi yang dilaporkan oleh SQL Server Profiler harus kurang lebih sama karena async adalah apa yang terjadi di c #, bukan Sql. Sql server bahkan tidak menyadari bahwa kode c # Anda adalah async
Khanh TO
ketika Anda menjalankan kueri yang Anda hasilkan pertama kali, mungkin perlu waktu lebih lama untuk mengkompilasi kueri (membangun rencana eksekusi, ...), mulai dari yang kedua, kueri yang sama mungkin lebih cepat (Sql server cache kueri kueri), tetapi Seharusnya tidak terlalu jauh berbeda.
Khanh TO
3
Anda perlu menentukan apa yang lambat. Jalankan kueri dalam loop tak terbatas. Jeda debugger 10 kali. Di mana itu paling sering berhenti? Posting tumpukan termasuk kode eksternal.
usr
1
Sepertinya masalahnya adalah dengan properti Image, yang saya benar-benar lupa. Itu adalah kolom VARBINARY (MAX), jadi pasti akan menyebabkan kelambatan, tetapi masih sedikit aneh bahwa kelambatan hanya menjadi masalah yang menjalankan async. Saya telah merestrukturisasi database saya sehingga gambar sekarang menjadi bagian dari tabel tertaut dan semuanya jauh lebih cepat sekarang.
Dylan Parry
1
Masalahnya mungkin bahwa EF mengeluarkan banyak async membaca ke ADO.NET untuk mengambil semua byte dan baris tersebut. Dengan cara itu biaya overhead diperbesar. Karena Anda tidak melakukan pengukuran saya meminta kami tidak akan pernah tahu. Masalah tampaknya sudah terpecahkan.
usr

Jawaban:

286

Saya menemukan pertanyaan ini sangat menarik, terutama karena saya menggunakan di asyncmana-mana dengan Ado.Net dan EF 6. Saya berharap seseorang memberikan penjelasan untuk pertanyaan ini, tetapi itu tidak terjadi. Jadi saya mencoba mereproduksi masalah ini di pihak saya. Saya harap beberapa dari Anda akan menemukan ini menarik.

Berita baik pertama: Saya mereproduksinya :) Dan perbedaannya sangat besar. Dengan faktor 8 ...

hasil pertama

Pertama saya mencurigai sesuatu yang berhubungan dengan CommandBehavior, karena saya membaca artikel menarik tentang asyncAdo, mengatakan ini:

"Karena mode akses non-sekuensial harus menyimpan data untuk seluruh baris, itu dapat menyebabkan masalah jika Anda membaca kolom besar dari server (seperti varbinary (MAX), varchar (MAX), nvarchar (MAX) atau XML) ). "

Saya mencurigai ToList()panggilan menjadi CommandBehavior.SequentialAccessasync CommandBehavior.Default(tidak berurutan, yang dapat menyebabkan masalah). Jadi saya mengunduh sumber-sumber EF6, dan meletakkan breakpoints di mana-mana (di CommandBehaviormana digunakan, tentu saja).

Hasil: tidak ada . Semua panggilan dibuat dengan CommandBehavior.Default.... Jadi saya mencoba masuk ke kode EF untuk memahami apa yang terjadi ... dan ... ooouch ... Saya tidak pernah melihat kode pendelegasian seperti itu, semuanya tampak malas dijalankan ...

Jadi saya mencoba membuat profil untuk memahami apa yang terjadi ...

Dan saya pikir saya punya sesuatu ...

Inilah model untuk membuat tabel yang saya benchmark, dengan 3.500 baris di dalamnya, dan masing-masing 256 Kb data acak varbinary(MAX). (EF 6.1 - CodeFirst - CodePlex ):

public class TestContext : DbContext
{
    public TestContext()
        : base(@"Server=(localdb)\\v11.0;Integrated Security=true;Initial Catalog=BENCH") // Local instance
    {
    }
    public DbSet<TestItem> Items { get; set; }
}

public class TestItem
{
    public int ID { get; set; }
    public string Name { get; set; }
    public byte[] BinaryData { get; set; }
}

Dan inilah kode yang saya gunakan untuk membuat data uji, dan benchmark EF.

using (TestContext db = new TestContext())
{
    if (!db.Items.Any())
    {
        foreach (int i in Enumerable.Range(0, 3500)) // Fill 3500 lines
        {
            byte[] dummyData = new byte[1 << 18];  // with 256 Kbyte
            new Random().NextBytes(dummyData);
            db.Items.Add(new TestItem() { Name = i.ToString(), BinaryData = dummyData });
        }
        await db.SaveChangesAsync();
    }
}

using (TestContext db = new TestContext())  // EF Warm Up
{
    var warmItUp = db.Items.FirstOrDefault();
    warmItUp = await db.Items.FirstOrDefaultAsync();
}

Stopwatch watch = new Stopwatch();
using (TestContext db = new TestContext())
{
    watch.Start();
    var testRegular = db.Items.ToList();
    watch.Stop();
    Console.WriteLine("non async : " + watch.ElapsedMilliseconds);
}

using (TestContext db = new TestContext())
{
    watch.Restart();
    var testAsync = await db.Items.ToListAsync();
    watch.Stop();
    Console.WriteLine("async : " + watch.ElapsedMilliseconds);
}

using (var connection = new SqlConnection(CS))
{
    await connection.OpenAsync();
    using (var cmd = new SqlCommand("SELECT ID, Name, BinaryData FROM dbo.TestItems", connection))
    {
        watch.Restart();
        List<TestItem> itemsWithAdo = new List<TestItem>();
        var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess);
        while (await reader.ReadAsync())
        {
            var item = new TestItem();
            item.ID = (int)reader[0];
            item.Name = (String)reader[1];
            item.BinaryData = (byte[])reader[2];
            itemsWithAdo.Add(item);
        }
        watch.Stop();
        Console.WriteLine("ExecuteReaderAsync SequentialAccess : " + watch.ElapsedMilliseconds);
    }
}

using (var connection = new SqlConnection(CS))
{
    await connection.OpenAsync();
    using (var cmd = new SqlCommand("SELECT ID, Name, BinaryData FROM dbo.TestItems", connection))
    {
        watch.Restart();
        List<TestItem> itemsWithAdo = new List<TestItem>();
        var reader = await cmd.ExecuteReaderAsync(CommandBehavior.Default);
        while (await reader.ReadAsync())
        {
            var item = new TestItem();
            item.ID = (int)reader[0];
            item.Name = (String)reader[1];
            item.BinaryData = (byte[])reader[2];
            itemsWithAdo.Add(item);
        }
        watch.Stop();
        Console.WriteLine("ExecuteReaderAsync Default : " + watch.ElapsedMilliseconds);
    }
}

using (var connection = new SqlConnection(CS))
{
    await connection.OpenAsync();
    using (var cmd = new SqlCommand("SELECT ID, Name, BinaryData FROM dbo.TestItems", connection))
    {
        watch.Restart();
        List<TestItem> itemsWithAdo = new List<TestItem>();
        var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess);
        while (reader.Read())
        {
            var item = new TestItem();
            item.ID = (int)reader[0];
            item.Name = (String)reader[1];
            item.BinaryData = (byte[])reader[2];
            itemsWithAdo.Add(item);
        }
        watch.Stop();
        Console.WriteLine("ExecuteReader SequentialAccess : " + watch.ElapsedMilliseconds);
    }
}

using (var connection = new SqlConnection(CS))
{
    await connection.OpenAsync();
    using (var cmd = new SqlCommand("SELECT ID, Name, BinaryData FROM dbo.TestItems", connection))
    {
        watch.Restart();
        List<TestItem> itemsWithAdo = new List<TestItem>();
        var reader = cmd.ExecuteReader(CommandBehavior.Default);
        while (reader.Read())
        {
            var item = new TestItem();
            item.ID = (int)reader[0];
            item.Name = (String)reader[1];
            item.BinaryData = (byte[])reader[2];
            itemsWithAdo.Add(item);
        }
        watch.Stop();
        Console.WriteLine("ExecuteReader Default : " + watch.ElapsedMilliseconds);
    }
}

Untuk panggilan EF biasa ( .ToList()), pembuatan profil tampak "normal" dan mudah dibaca:

Jejak ToList

Di sini kita menemukan 8,4 detik yang kita miliki dengan Stopwatch (membuat profil memperlambat perf). Kami juga menemukan HitCount = 3500 di sepanjang jalur panggilan, yang konsisten dengan 3500 baris dalam tes. Di sisi parser TDS, hal-hal mulai menjadi lebih buruk sejak kita membaca 118 353 panggilan pada TryReadByteArray()metode, yang merupakan loop buffering terjadi. (rata-rata 33,8 panggilan untuk masing-masing byte[]256kb)

Untuk asynckasus ini, ini benar-benar sangat berbeda .... Pertama, .ToListAsync()panggilan dijadwalkan di ThreadPool, dan kemudian ditunggu. Tidak ada yang luar biasa di sini. Tapi, sekarang, inilah yang ada asyncdi ThreadPool:

Neraka ToListAsync

Pertama, dalam kasus pertama kami hanya memiliki 3500 hitungan hit di sepanjang jalur panggilan penuh, di sini kami memiliki 118 371. Selain itu, Anda harus membayangkan semua panggilan sinkronisasi yang saya tidak lakukan pada screenshoot ...

Kedua, dalam kasus pertama, kami memiliki panggilan "hanya 118 353" untuk TryReadByteArray()metode ini, di sini kami memiliki 2 050 210 panggilan! Ini 17 kali lebih banyak ... (pada tes dengan array 1Mb besar, 160 kali lebih banyak)

Apalagi ada:

  • 120 000 Taskinstance dibuat
  • 727 519 Interlockedpanggilan
  • 290 569 Monitorpanggilan
  • 98 283 ExecutionContextinstance, dengan 264 481 Capture
  • 208 733 SpinLockpanggilan

Dugaan saya adalah buffering dibuat dengan cara async (dan bukan yang bagus), dengan Tugas paralel mencoba membaca data dari TDS. Terlalu banyak tugas yang dibuat hanya untuk mem-parsing data biner.

Sebagai kesimpulan awal, kita dapat mengatakan Async hebat, EF6 hebat, tetapi penggunaan async EF6 dalam penerapannya saat ini menambahkan overhead yang besar, di sisi kinerja, sisi Threading, dan sisi CPU (penggunaan CPU 12% di ToList()case dan 20% dalam ToListAsynccase untuk kerja 8 sampai 10 kali lebih lama ... Saya menjalankannya pada i7 920 lama).

Sambil melakukan beberapa tes, saya memikirkan artikel ini lagi dan saya perhatikan sesuatu yang saya lewatkan:

"Untuk metode asinkron baru di. Net 4.5, perilaku mereka persis sama dengan metode sinkron, kecuali untuk satu pengecualian penting: ReadAsync dalam mode non-sekuensial."

Apa ?!!!

Jadi saya memperluas tolok ukur saya untuk memasukkan Ado.Net dalam panggilan biasa / async, dan dengan CommandBehavior.SequentialAccess/ CommandBehavior.Default, dan inilah kejutan besar! :

dengan basa-basi

Kami memiliki perilaku yang sama persis dengan Ado.Net !!! Telapak tangan...

Kesimpulan definitif saya adalah : ada bug dalam implementasi EF 6. Seharusnya beralih CommandBehaviorke SequentialAccessketika panggilan async dilakukan atas tabel yang berisi binary(max)kolom. Masalah membuat terlalu banyak Tugas, memperlambat proses, ada di sisi Ado.Net. Masalah EF adalah tidak menggunakan Ado.Net sebagaimana mestinya.

Sekarang Anda tahu daripada menggunakan metode async EF6, Anda lebih baik harus memanggil EF dengan cara non-async biasa, dan kemudian menggunakan a TaskCompletionSource<T>untuk mengembalikan hasilnya dengan cara async.

Catatan 1: Saya mengedit posting saya karena kesalahan yang memalukan .... Saya telah melakukan tes pertama saya melalui jaringan, bukan secara lokal, dan bandwidth terbatas telah mengubah hasil. Berikut adalah hasil yang diperbarui.

Catatan 2: Saya tidak memperluas pengujian saya ke kasus kegunaan lain (mis: nvarchar(max)dengan banyak data), tetapi ada kemungkinan perilaku yang sama terjadi.

Catatan 3: Sesuatu yang biasa untuk ToList()kasus ini, adalah CPU 12% (1/8 dari CPU saya = 1 inti logis). Sesuatu yang tidak biasa adalah maksimum 20% untuk ToListAsync()kasus ini, seolah-olah Penjadwal tidak dapat menggunakan semua Tapak. Itu mungkin karena terlalu banyak Tugas yang dibuat, atau mungkin hambatan dalam TDS parser, saya tidak tahu ...

rducom
sumber
2
Saya membuka masalah pada codeplex, berharap mereka akan melakukan sesuatu. entitasframework.codeplex.com/workitem/2686
rducom
3
Saya membuka masalah pada repo kode EF baru yang dihosting di github: github.com/aspnet/EntityFramework6/issues/88
Korayem
5
Sayangnya masalah pada GitHub telah ditutup dengan saran untuk tidak menggunakan async dengan varbinary. Secara teori varbinary harus menjadi kasus di mana async paling masuk akal karena utas akan diblokir lebih lama saat file ditransmisikan. Jadi apa yang kita lakukan sekarang jika kita ingin menyimpan data biner di DB?
Stilgar
8
Adakah yang tahu apakah ini masih menjadi masalah di EF Core? Saya tidak dapat menemukan informasi atau tolok ukur apa pun.
Andrew Lewis
2
@AndrewLewis Saya tidak memiliki ilmu di baliknya, tapi saya mengalami timeout koneksi kolam berulang dengan EF Core di mana dua pertanyaan yang menyebabkan masalah adalah .ToListAsync()dan .CountAsync()... Untuk siapa pun yang menemukan utas komentar ini , permintaan ini dapat membantu. Hasil positif.
Scott
2

Karena saya mendapat tautan ke pertanyaan ini beberapa hari yang lalu saya memutuskan untuk mengirim pembaruan kecil. Saya dapat mereproduksi hasil dari jawaban asli menggunakan, saat ini, versi terbaru EF (6.4.0) dan .NET Framework 4.7.2. Anehnya masalah ini tidak pernah diperbaiki.

.NET Framework 4.7.2 | EF 6.4.0 (Values in ms. Average of 10 runs)

non async : 3016
async : 20415
ExecuteReaderAsync SequentialAccess : 2780
ExecuteReaderAsync Default : 21061
ExecuteReader SequentialAccess : 3467
ExecuteReader Default : 3074

Ini menimbulkan pertanyaan: Apakah ada peningkatan dalam dotnet core?

Saya menyalin kode dari jawaban asli ke proyek dotnet core 3.1.3 baru dan menambahkan EF Core 3.1.3. Hasilnya adalah:

dotnet core 3.1.3 | EF Core 3.1.3 (Values in ms. Average of 10 runs)

non async : 2780
async : 6563
ExecuteReaderAsync SequentialAccess : 2593
ExecuteReaderAsync Default : 6679
ExecuteReader SequentialAccess : 2668
ExecuteReader Default : 2315

Anehnya ada banyak perbaikan. Tampaknya masih ada jeda waktu karena threadpool dipanggil tetapi ini sekitar 3 kali lebih cepat daripada implementasi .NET Framework.

Saya harap jawaban ini membantu orang lain yang dapat mengirim cara ini di masa depan.

Xeno-D
sumber