Entity Frameworkの非同期操作が完了するまでに10倍の時間がかかる


139

Entity Framework 6を​​使用してデータベースを処理しているMVCサイトがあり、すべてを非同期コントローラーとして実行し、データベースへの呼び出しを非同期対応として実行するように変更しています(例: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

実際のところ、これはそれほど複雑なクエリではありませんが、SQLサーバーが実行するのに約6秒かかります。SQL Serverプロファイラは、完了までに5742msかかると報告しています。

コードを次のように変更した場合:

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

その後、まったく同じSQLが生成されますが、SQL Server Profilerによると、これはわずか474ミリ秒で実行されます。

データベースの「Albums」テーブルには約3500行ありますが、実際にはそれほど多くなく、「Artist_ID」列にインデックスがあるため、かなり高速です。

非同期にはオーバーヘッドがあることは知っていますが、物事を10倍遅くするのは少し急に思えます!ここでどこがいけないのですか?


それは私には正しく見えません。同じデータを使用して同じクエリを実行する場合、SQL Serverプロファイラーによって報告される実行時間は、SQLではなくc#で発生するものであるため、ほぼ同じになるはずです。SQLサーバーは、c#コードが非同期であることさえ認識していません
Khanh TO

生成されたクエリを初めて実行するときは、クエリのコンパイル(ビルドプランの作成など)に少し時間がかかることがあります。2回目からは、同じクエリの方が高速になる場合があります(SQLサーバーがクエリをキャッシュします)。あまり違いはないはずです。
カーンTO

3
何が遅いかを判断する必要があります。クエリを無限ループで実行します。デバッガーを10回一時停止します。最も頻繁に停止する場所はどこですか?外部コードを含むスタックをポストします。
usr

1
問題は、私が完全に忘れていたImageプロパティに関係しているようです。これはVARBINARY(MAX)列であるため、速度低下の原因となる可能性がありますが、速度低下が非同期実行の問題になるだけであるのは少し変です。データベースが再構築され、画像がリンクされたテーブルの一部になり、すべてがはるかに高速になりました。
Dylan Parry

1
問題は、EFが大量の非同期読み取りをADO.NETに発行して、これらのすべてのバイトと行を取得することである可能性があります。そのようにしてオーバーヘッドが拡大されます。あなたが測定を行わなかったので、私は決して知りませんと尋ねました。問題は解決したようです。
usr

回答:


286

特にasyncAdo.NetとEF 6でどこでも使用しているので、この質問は非常に興味深いものでした。誰かがこの質問について説明してくれることを望んでいましたが、実際には起こりませんでした。だから私はこの問題を私の側で再現しようとしました。これが面白いと思う人もいると思います。

最初の良いニュース:私はそれを再現しました:)そして、その違いは非常に大きいです。因数8で...

最初の結果

Ado に関する興味深い記事を読んで次のように言ったCommandBehaviorので、最初に何かを処理していると疑っていました。async

「非順次アクセスモードでは行全体のデータを保存する必要があるため、サーバーから大きな列(varbinary(MAX)、varchar(MAX)、nvarchar(MAX)、XMLなど)を読み取っている場合、問題が発生する可能性があります)」

ToList()呼び出しがCommandBehavior.SequentialAccess非同期であることが疑われましたCommandBehavior.Default(非順次、問題が発生する可能性があります)。そこで、EF6のソースをダウンロードし、どこにでもブレークポイントを配置しました(CommandBehaviorもちろん、使用されている場所です)。

結果:なし。すべての呼び出しはCommandBehavior.Default...で行われるので、何が起こるかを理解するためにEFコードに足を踏み入れようとしました...そして.. ooouch ...私はそのような委任コードを見たことがないので、すべてが遅延実行されているようです...

だから私は何が起こるかを理解するためにいくつかのプロファイリングをしようとしました...

そして、私は何かを持っていると思います...

これは、ベンチマークを行ったテーブルを作成するためのモデルで、内部に3500行があり、それぞれに256 KBのランダムデータがあります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())の場合、プロファイリングは「正常」であり、読みやすいです。

ToListトレース

ここで、ストップウォッチの8.4秒を見つけます(プロファイリングによりパフォーマンスが低下します)。また、コールパスに沿ってHitCount = 3500が見つかります。これは、テストの3500行と一致しています。TDSパーサー側でTryReadByteArray()は、バッファリングループが発生する118 353のメソッド呼び出しを読み取ったため、状況は悪化し始めました。(byte[]256kb ごとに平均33.8コール)

このasync場合、それは本当に本当に異なります...最初に、.ToListAsync()呼び出しはThreadPoolでスケジュールされ、それから待機されます。ここでは驚くべきことは何もありません。しかし、今、asyncこれがThreadPoolの地獄です:

ToListAsync地獄

まず、最初のケースでは、フルコールパスに沿ってヒットカウントが3500しかありませんでしたが、ここでは118 371です。

次に、最初のケースでは、TryReadByteArray()メソッドへの呼び出しが「118 353」になりました。ここでは2 050 210呼び出しです。それは17倍です...(大きな1Mbアレイでのテストでは、160倍以上です)

さらにあります:

  • 120 000個のTaskインスタンスが作成されました
  • 727 519 Interlockedコール
  • 290 569 Monitorコール
  • 98 283 ExecutionContextインスタンス、264 481キャプチャ
  • 208 733 SpinLockコール

私の推測では、バッファリングは非同期の方法で(そして良い方法ではなく)行われ、並列タスクがTDSからデータを読み取ろうとします。バイナリデータを解析するためだけに作成されたタスクが多すぎます。

予備的な結論として、非同期は素晴らしい、EF6は素晴らしいと言えますが、現在の実装での非同期のEF6の使用は、パフォーマンス側、スレッド側、およびCPU側に大きなオーバーヘッドを追加します(ToList()ケースとケースの20%で、ToListAsync8〜10倍の時間で動作します...古いi7 920で実行します)。

いくつかのテストを行っているときに、この記事についてもう一度考えていたところ、見落としていることに気づきました。

「.Net 4.5の新しい非同期メソッドの場合、その動作は同期メソッドの場合とまったく同じですが、1つの注目すべき例外として、非シーケンシャルモードのReadAsyncがあります。」

何 ?!!!

私はAdo.Net定期的/非同期呼び出しでは、とに含まれるように、私のベンチマークを拡張してCommandBehavior.SequentialAccess/ CommandBehavior.Default、ここで大きな驚きです!:

騒ぎで

Ado.Netとまったく同じ動作です!!! Facepalm ...

私の決定的な結論は、EF 6の実装にバグがあることです。列を含むテーブルに対して非同期呼び出しが行われると、CommandBehaviorを切り替えます。タスクを作成しすぎてプロセスが遅くなるという問題は、Ado.Net側にあります。EFの問題は、Ado.Netを本来の方法で使用しないことです。SequentialAccessbinary(max)

EF6非同期メソッドを使用する代わりに、通常の非同期ではない方法でEFを呼び出してから、a TaskCompletionSource<T>を使用して結果を非同期で返すことをお勧めします。

注1:恥ずかしいエラーが発生したため、投稿を編集しました...ローカルではなくネットワーク経由で最初のテストを実行しましたが、帯域幅の制限により結果が歪んでいます。更新された結果は次のとおりです。

注2:テストを他のユースケース(たとえばnvarchar(max)、大量のデータを使用する)に拡張していませんが、同じ動作が発生する可能性があります。

注3:通常のToList()場合、12%CPU(私のCPUの1/8 = 1論理コア)です。ToListAsync()スケジューラがすべてのトレッドを使用できなかったかのように、異常な場合はケースの最大20%です。作成されたタスクが多すぎるか、TDSパーサーのボトルネックが原因である可能性があります。


2
私はcodeplexに関する問題を公開しました。彼らがそれについて何かしてくれることを願っています。entityframework.codeplex.com/workitem/2686
rducom


5
悲しいことに、GitHubの問題は、varbinaryでasyncを使用しないようにというアドバイスで終了しました。理論的には、varbinaryは、ファイルが送信されている間、スレッドがより長くブロックされるため、非同期が最も理にかなうケースです。では、バイナリデータをDBに保存したい場合はどうすればよいでしょうか。
スティルガー、

8
これがEF Coreの問題であるかどうか誰でも知っていますか?情報やベンチマークを見つけることができませんでした。
Andrew Lewis

2
@AndrewLewis私はその背後に科学はありませんが、EF Coreで接続プールのタイムアウトを繰り返しています。そこでは、問題を引き起こしている2つのクエリが.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 core 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

驚くべきことに、多くの改善があります。スレッドプールが呼び出されるため、まだタイムラグがあるようですが、.NET Framework実装よりも約3倍高速です。

この答えが将来この方法で送信される他の人々の助けになることを願っています。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.