Операція асинхронізації Entity Framework займає десять разів більше часу


139

У мене є MVC-сайт, який використовує Entity Framework 6 для обробки бази даних, і я експериментував із його зміною, щоб все працювало як контролери асинхронізації, а виклики до бази даних виконувались як їх аналоги асинхронізації (наприклад, ToListAsync () замість ToList ())

Проблема, яку я маю, полягає в тому, що просто зміна моїх запитів на асинхронізацію призвело до того, що вони були надзвичайно повільними.

Наступний код отримує колекцію об'єктів "Альбом" з мого контексту даних і переводиться на досить просте приєднання до бази даних:

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

Ось створений SQL:

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

По мірі того, що запит не є досить складним, але для його запуску потрібно майже 6 секунд. Профілер SQL Server повідомляє, що він завершує 5742 мс.

Якщо я зміню код на:

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

Тоді генерується той самий SQL, але це працює всього за 474 мс відповідно до SQL Server Profiler.

У базі даних близько 3500 рядків у таблиці "Альбоми", що насправді не дуже багато, і є індекс у стовпці "Artist_ID", тому це має бути досить швидким.

Я знаю, що асинхрон має накладні витрати, але зробити так, щоб справи йшли в десять разів повільніше, мені здається трохи крутим! Де я тут помиляюся?


на мене це не виглядає правильно. Якщо ви виконуєте один і той самий запит з тими ж даними, час виконання повідомлення, який повідомляється у програмі SQL Server Profiler, повинен бути більш-менш однаковим, тому що асинхронізація відбувається в c #, а не в Sql. Сервер Sql навіть не знає, що ваш код # асинхронний
КАН

коли ви запускаєте згенерований запит у перший раз, може знадобитися трохи більше часу, щоб скласти запит (план виконання програми, ...), з другого разу той же запит може бути швидшим (сервер Sql кешує запит), але не повинно бути занадто багато іншого.
КАН

3
Вам потрібно визначити, що повільно. Запустіть запит у нескінченному циклі. Призупиніть налагоджувач 10 разів. Де зупиняється найчастіше? Опублікуйте стек, включаючи зовнішній код.
usr

1
Схоже, проблема пов'язана із властивістю Image, про яку я зовсім забув. Це стовпчик VARBINARY (MAX), тому він може спричинити повільність, але все ще трохи дивно, що повільність стає лише проблемою, що працює з асинхронією. Я реструктуризував свою базу даних, щоб зображення тепер були частиною пов'язаної таблиці, і зараз все набагато швидше.
Ділан Паррі

1
Проблема може полягати в тому, що EF видає тонни асинхронних зчитувань до ADO.NET, щоб отримати всі ці байти та рядки. Таким чином збільшуються накладні витрати. Оскільки ви не виконували вимірювання, я запитував, ми ніколи не дізнаємось. Здається, проблема вирішена.
usr

Відповіді:


286

Мені це питання було дуже цікавим, тим більше, що я користуюся asyncскрізь з Ado.Net та EF 6. Я сподівався, що хтось дасть пояснення цьому питанню, але це не сталося. Тому я спробував відтворити цю проблему на моєму боці. Сподіваюсь, хтось із вас знайде це цікавим.

Перша гарна новина: я відтворив її :) І різниця величезна. З коефіцієнтом 8 ...

перші результати

Спочатку я підозрював щось, що має справу CommandBehavior, оскільки я прочитав цікаву статтю про asyncАдо, кажучи про це:

"Оскільки в режимі непослідовного доступу потрібно зберігати дані для всього рядка, це може спричинити проблеми, якщо ви читаєте великий стовпець із сервера (наприклад, варбінарний (MAX), varchar (MAX), nvarchar (MAX) або XML ) ".

Я підозрював ToList() дзвінки бути CommandBehavior.SequentialAccessта асинхронізуватись CommandBehavior.Default(не послідовно, що може спричинити проблеми). Тому я завантажив джерела EF6 і поставив точки пробиву скрізь (де, CommandBehaviorде, звичайно).

Результат: нічого . Всі дзвінки виконуються за допомогою CommandBehavior.Default.... Тому я спробував зайти в код EF, щоб зрозуміти, що відбувається ... і .. ooouch ... Я ніколи не бачу такого делегуючого коду, все здається ледачим виконанням ...

Тому я спробував зробити кілька профілів, щоб зрозуміти, що відбувається ...

І я думаю, у мене щось є ...

Ось модель для створення таблиці, на яку я орієнтований, з 3500 рядками всередині і 256 кб випадковими даними в кожній 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; }
}

А ось код, який я використовував для створення тестових даних, та еталонного 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);
    }
}

Для звичайного виклику EF ( .ToList()) профілювання здається "нормальним" і його легко читати:

Слід списку

Тут ми знаходимо 8,4 секунди, які ми маємо за допомогою секундоміра (профілювання повільно знижує перфи). Ми також знаходимо HitCount = 3500 вздовж шляху виклику, що відповідає 3500 рядкам у тесті. З боку аналізатора TDS все починає погіршуватися, оскільки ми читаємо 118 353 виклики за TryReadByteArray()методом, який був буферним циклом. (в середньому 33,8 дзвінків на кожен byte[]256 кб)

У asyncвипадку, це дійсно інакше .... Спочатку .ToListAsync()дзвінок запланований на ThreadPool, а потім його очікують. Нічого дивного тут немає. Але ось ось asyncчорт у ThreadPool:

Пекло ToListAsync

По-перше, у першому випадку у нас було всього 3500 підрахунків звернень по повній трасі виклику, тут у нас 118 371. Крім того, ви повинні уявити всі дзвінки синхронізації, які я не робив на знімку екрана ...

По-друге, у першому випадку у нас було "лише 118 353" викликів TryReadByteArray()методу, тут у нас є 2 050 210 дзвінків! Це в 17 разів більше ... (на тесті з великим масивом 1 Мб, це в 160 разів більше)

Крім того, є:

  • TaskСтворено 120 000 екземплярів
  • 727 519 Interlockedдзвінків
  • 290 569 Monitorдзвінків
  • 98 283 ExecutionContextекземпляри з 264 481 захопленнями
  • 208 733 SpinLockдзвінків

Я здогадуюсь, що буферизація проводиться асинхронним способом (і не дуже добре), паралельні завдання намагаються прочитати дані з TDS. Занадто багато завдань створено просто для аналізу бінарних даних.

Як попередній висновок, ми можемо сказати, що Async чудовий, EF6 - чудовий, але використання асинхронного режиму EF6 в його поточній реалізації додає значних витрат на стороні продуктивності, нарізання потоку та процесора (12% використання процесора в ToList()випадку і 20% вToListAsync випадку з 8 до 10 разів довшою роботою ... я запускаю його на старому i7 920).

Роблячи якісь тести, я знову замислювався над цією статтею, і помічаю щось, що мені не вистачає:

"Для нових асинхронних методів у .Net 4.5 їх поведінка точно така ж, як і у синхронних методів, за винятком одного помітного винятку: ReadAsync у не послідовному режимі."

Що ?!!!

Тож я розширюю свої орієнтири, щоб включити Ado.Net у звичайний / асинхронний дзвінок та з CommandBehavior.SequentialAccess/ CommandBehavior.Default, і ось вам великий сюрприз! :

з адо

У нас точно така ж поведінка з Ado.Net !!! Facepalm ...

Мій остаточний висновок такий : у впровадженні EF 6 є помилка. Він повинен перемикатися CommandBehaviorна, SequentialAccessколи відбувається виклик асинхронізації над таблицею, що містить binary(max)стовпець. Проблема створення занадто багато завдань, уповільнення процесу, стоїть на стороні Ado.Net. Проблема EF полягає в тому, що він не використовує Ado.Net як слід.

Тепер ви знаєте, замість того, щоб використовувати методи асинхронізації EF6, вам краще буде викликати EF звичайним способом без асинхронізації, а потім використовувати a, TaskCompletionSource<T>щоб повернути результат асинхронним способом.

Примітка 1: Я відредагував свій пост через ганебну помилку .... Я зробив свій перший тест по мережі, а не локально, і обмежена пропускна здатність спотворила результати. Ось оновлені результати.

Примітка 2: Я не поширював свій тест на інші випадки використання (наприклад, nvarchar(max)з великою кількістю даних), але є ймовірність того, що трапиться те саме.

Примітка 3. Щось звичне для цього ToList()випадку - це 12% процесора (1/8 мого процесора = 1 логічне ядро). Щось незвичне - це максимум 20% для ToListAsync()справи, як ніби планувальник не може використати всі протектори. Це, мабуть, через занадто багато створених завдань, або, можливо, вузьке місце в TDS-аналізаторі, я не знаю ...


2
Я відкрив питання про кодеплекс, сподіваюся, що вони щось з цим зроблять. entitframework.codeplex.com/workitem/2686
rducom

3
Я відкрив випуск щодо нового репортажу
Корайем

5
На жаль, проблема GitHub була закрита порадою не використовувати асинхронізацію з варбінарними. Теоретично варбінарій повинен бути у випадку, коли асинхрон має найбільше сенсу, оскільки потік буде блокований довше, поки файл передається. Отже, що робити зараз, якщо ми хочемо зберегти бінарні дані в БД?
Stilgar

8
Хтось знає, чи це все ще проблема в EF Core? Мені не вдалося знайти жодної інформації чи орієнтирів.
Ендрю Льюїс

2
@AndrewLewis Я не маю ніякої науки за цим, але у мене повторюються тайм-аути пулу з'єднань з EF Core, де два запити, що викликають проблеми, .ToListAsync()і .CountAsync()... Для кого-небудь ще, хто знайде цей потік коментаря, цей запит може допомогти. Швидкість
Скотт

2

Оскільки я отримав посилання на це питання пару днів тому, я вирішив опублікувати невелике оновлення. Я зміг відтворити результати оригінальної відповіді, використовуючи, на даний момент, новітню версію EF (6.4.0) та .NET Framework 4.7.2. Дивно, але ця проблема ніколи не вдосконалювалася.

.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

Це поставило питання: чи є поліпшення ядра dotnet?

Я скопіював код з оригінальної відповіді в новий проект ядра dotnet 3.1.3 і додав EF Core 3.1.3. Результати:

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

Дивно, але багато вдосконалень. Досі існує певний затримка часу, оскільки виклик потокових потоків викликається, але це приблизно в 3 рази швидше, ніж реалізація .NET Framework.

Я сподіваюся, що ця відповідь допоможе іншим людям, які в майбутньому можуть відправитись таким чином.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.