async
Wydało mi się to bardzo interesujące, zwłaszcza że używam wszędzie z Ado.Net i EF 6. Miałem nadzieję, że ktoś wyjaśni to pytanie, ale tak się nie stało. Próbowałem więc odtworzyć ten problem po swojej stronie. Mam nadzieję, że niektórzy z was uznają to za interesujące.
Pierwsza dobra wiadomość: odtworzyłem to :) A różnica jest ogromna. Przy współczynniku 8 ...
Najpierw podejrzewałem, że coś się z tym wiąże CommandBehavior
, ponieważ przeczytałem ciekawy artykuł o async
Ado, mówiąc o tym:
„Ponieważ tryb dostępu niesekwencyjnego musi przechowywać dane dla całego wiersza, może to powodować problemy, jeśli odczytujesz dużą kolumnę z serwera (na przykład varbinary (MAX), varchar (MAX), nvarchar (MAX) lub XML ). ”
Podejrzewałem, że ToList()
połączenia mają być, CommandBehavior.SequentialAccess
a asynchroniczne CommandBehavior.Default
(niesekwencyjne, co może powodować problemy). Pobrałem więc źródła EF6 i umieściłem punkty przerwania wszędzie ( CommandBehavior
oczywiście tam, gdzie są używane).
Wynik: nic . Wszystkie wywołania są wykonywane za pomocą CommandBehavior.Default
.... Więc próbowałem wejść do kodu EF, aby zrozumieć, co się dzieje ... i ... ooouch ... Nigdy nie widziałem takiego kodu delegującego, wszystko wydaje się być wykonywane leniwie ...
Więc spróbowałem zrobić pewne profilowanie, aby zrozumieć, co się dzieje ...
I chyba coś mam ...
Oto model do utworzenia tabeli, którą przetestowałem, z 3500 wierszami w środku i 256 Kb losowymi danymi w każdej 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; }
}
A oto kod, którego użyłem do utworzenia danych testowych i testu porównawczego 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);
}
}
W przypadku zwykłego wywołania EF ( .ToList()
) profilowanie wydaje się „normalne” i jest łatwe do odczytania:
Tutaj znajdujemy 8,4 sekundy, które mamy ze stoperem (profilowanie spowalnia działanie). Znajdujemy również HitCount = 3500 wzdłuż ścieżki wywołania, co jest zgodne z 3500 liniami w teście. Po stronie parsera TDS sytuacja zaczyna się pogarszać, odkąd przeczytaliśmy 118 353 wywołań TryReadByteArray()
metody, w której występuje pętla buforowania. (średnio 33,8 wywołań na każde byte[]
256kb)
W tym async
przypadku jest naprawdę zupełnie inaczej ... Najpierw .ToListAsync()
wywołanie jest zaplanowane w puli wątków, a następnie jest oczekiwane. Nie ma tu nic niesamowitego. Ale teraz, oto async
piekło w ThreadPool:
Po pierwsze, w pierwszym przypadku mieliśmy tylko 3500 zliczeń trafień na całej ścieżce wywołania, tutaj mamy 118 371. Co więcej, musisz sobie wyobrazić wszystkie wywołania synchronizacyjne, których nie wykonałem podczas zrzutu ekranu ...
Po drugie, w pierwszym przypadku mieliśmy „tylko 118 353” wywołań TryReadByteArray()
metody, tutaj mamy 2 050 210 wywołań! To 17 razy więcej ... (w teście z dużą macierzą 1Mb to 160 razy więcej)
Ponadto są:
Task
Utworzono 120 000 instancji
- 727 519
Interlocked
połączeń
- 290 569
Monitor
wezwań
- 98 283
ExecutionContext
instancji, z 264 481 przechwyceniami
- 208 733
SpinLock
połączeń
Domyślam się, że buforowanie odbywa się w sposób asynchroniczny (i niezbyt dobry), z równoległymi zadaniami próbującymi odczytać dane z TDS. Utworzono zbyt wiele zadań tylko po to, aby przeanalizować dane binarne.
Na wstępny wniosek możemy powiedzieć, że Async jest świetny, EF6 jest świetny, ale użycie async przez EF6 w jego bieżącej implementacji dodaje znaczny narzut po stronie wydajności, po stronie wątkowości i po stronie procesora (12% użycie procesora w ToList()
przypadku i 20% w ToListAsync
przypadku 8 do 10 razy dłuższej pracy ... uruchamiam go na starym i7 920).
Robiąc kilka testów, ponownie myślałem o tym artykule i zauważyłem coś, czego mi brakuje:
„W przypadku nowych metod asynchronicznych w .Net 4.5 ich zachowanie jest dokładnie takie samo, jak w przypadku metod synchronicznych, z wyjątkiem jednego ważnego wyjątku: ReadAsync w trybie niesekwencyjnym”.
Co ?!!!
Więc rozszerzam moje testy porównawcze, aby uwzględnić Ado.Net w zwykłym / asynchronicznym połączeniu, a także z CommandBehavior.SequentialAccess
/ CommandBehavior.Default
, a oto wielka niespodzianka! :
Dokładnie tak samo postępujemy z Ado.Net !!! Facepalm ...
Mój ostateczny wniosek jest taki : w implementacji EF 6 jest błąd. Powinien przełączyć się CommandBehavior
naSequentialAccess
gdy wywołanie asynchroniczne jest wykonywane w tabeli zawierającej binary(max)
kolumnę. Problem z utworzeniem zbyt wielu zadań, spowalniających proces, leży po stronie Ado.Net. Problem z EF polega na tym, że nie używa Ado.Net tak, jak powinien.
Teraz wiesz, że zamiast używać metod asynchronicznych EF6, lepiej byłoby wywołać EF w zwykły sposób inny niż asynchroniczny, a następnie użyć TaskCompletionSource<T>
aby zwrócić wynik w sposób asynchroniczny.
Uwaga 1: Edytowałem swój post z powodu wstydliwego błędu ... Pierwszy test wykonałem przez sieć, a nie lokalnie, a ograniczona przepustowość zniekształciła wyniki. Oto zaktualizowane wyniki.
Uwaga 2: Nie rozszerzyłem mojego testu na inne przypadki zastosowań (np. nvarchar(max)
Przy dużej ilości danych), ale są szanse, że wystąpi to samo zachowanie.
Uwaga 3: Coś zwykle w tym ToList()
przypadku to 12% procesor (1/8 mojego procesora = 1 rdzeń logiczny). Coś niezwykłego to maksymalne 20% dla ToListAsync()
sprawy, tak jakby planista nie mógł wykorzystać wszystkich stopni. Prawdopodobnie jest to spowodowane zbyt dużą liczbą utworzonych zadań, a może wąskim gardłem w parserze TDS, nie wiem ...