web-dev-qa-db-ja.com

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

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 Profilerは、完了するまでに5742ミリ秒かかると報告しています。

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

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

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

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

非同期にはオーバーヘッドがあることを知っていますが、物事を10倍遅くすることは私にとって少し険しいようです!私はここでどこに間違っていますか?

133
Dylan Parry

特にAdo.NetとEF 6でasyncを使用しているため、この質問は非常に興味深いものでした。この質問の説明を誰かにお願いしたかったのですが、それは起こりませんでした。そこで、私はこの問題を私の側で再現しようとしました。皆さんの何人かがこれを面白いと思うことを願っています.

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

first results

最初に CommandBehavior を扱う何かを疑っていました。なぜなら 面白い記事を読んだ Adoのasyncについて、これを言っているからです:

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

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

結果:nothing。すべての呼び出しはCommandBehavior.Default ...で行われます。だから私はEFコードに足を踏み入れて、何が起こるかを理解しようとしました...そして.. ooouch ...私はそのような委任コードを見たことはありません。 ..

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

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

以下に、ベンチマークしたテーブルを作成するためのモデルを示します。内部に3500行あり、各varbinary(MAX)に256 Kbランダムデータがあります。 (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 trace

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

asyncの場合、実際にはまったく異なります。..最初に、.ToListAsync()呼び出しがThreadPoolでスケジュールされ、その後待機されます。ここで素晴らしいことは何もありません。しかし、今、ここにThreadPoolのasync地獄があります:

ToListAsync hell

まず、最初のケースでは、コールパス全体でヒット数が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からデータを読み取ろうとします。バイナリデータを解析するためだけに作成されたタスクが多すぎます。

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

いくつかのテストを行っている間、私は この記事をもう一度 について考えていましたが、見落としていることに気付きました:

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

何 ?!!!

そこで、ベンチマークを拡張して、Ado.Netを通常の/非同期呼び出しに含め、CommandBehavior.SequentialAccess/CommandBehavior.Defaultを含めると、大きな驚きがあります。 :

with ado

Ado.Netでもまったく同じ動作をします!!! Facepalm ...

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

EF6の非同期メソッドを使用する代わりに、通常の非同期ではない方法でEFを呼び出し、TaskCompletionSource<T>を使用して非同期の方法で結果を返す必要があります。

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

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

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

267
rducom