[C#] Entity Framework 비동기 작업을 완료하는 데 10 배가 걸립니다

데이터베이스를 처리하기 위해 Entity Framework 6을 사용하는 MVC 사이트가 있으며 모든 것이 비동기 컨트롤러로 실행되고 데이터베이스에 대한 호출이 비동기 상대방으로 실행되도록 변경을 실험하고 있습니다 (예 : ToListAsync () ToList ()) 대신

내가 겪고있는 문제는 단순히 쿼리를 비동기로 변경하면 쿼리 속도가 매우 느려진다는 것입니다.

다음 코드는 내 데이터 컨텍스트에서 “Album”개체의 컬렉션을 가져오고 상당히 간단한 데이터베이스 조인으로 변환됩니다.

// 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 Server가 실행하는 데 거의 6 초가 걸립니다. SQL Server 프로파일 러는 완료하는 데 5742ms가 걸린 것으로보고합니다.

코드를 다음과 같이 변경하면

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

그런 다음 정확히 동일한 SQL이 생성되지만 SQL Server 프로파일 러에 따르면 단 474ms 만에 실행됩니다.

데이터베이스에는 “앨범”테이블에 약 3500 개의 행이 있으며 실제로는 많지 않으며 “Artist_ID”열에 인덱스가 있으므로 매우 빠릅니다.

비동기에는 오버 헤드가 있다는 것을 알고 있지만 일을 10 배 느리게 만드는 것은 조금 가파른 것 같습니다! 내가 여기서 잘못 가고 있습니까?



답변

특히 asyncAdo.Net 및 EF 6과 함께 모든 곳에서 사용 하고 있기 때문에이 질문이 매우 흥미 롭습니다.이 질문에 대한 설명을 누군가에게 바라고 있었지만 일어나지 않았습니다. 그래서 나는이 문제를 내 편에서 재현하려고했습니다. 나는 여러분 중 일부가이 흥미로운 것을 발견하기를 바랍니다.

첫번째 좋은 소식 : 나는 그것을 재현했다 🙂 그리고 그 차이는 엄청나 다. 요인 8로 …

첫 결과

우선은 처리 뭔가를 의심했다 CommandBehavior이후, 나는 흥미로운 기사 읽기 에 대해 async이런 말을, 아도로를 :

“비 순차 액세스 모드는 전체 행에 대한 데이터를 저장해야하기 때문에 서버에서 큰 열을 읽는 경우 (예 : varbinary (MAX), varchar (MAX), nvarchar (MAX) 또는 XML) 문제가 발생할 수 있습니다. ). “

나는 ToList()전화를 CommandBehavior.SequentialAccess하고 비동기 전화를해야한다고 의심했습니다 CommandBehavior.Default(비 순차적 인 문제가 발생할 수 있음). 그래서 EF6의 소스를 다운로드하고 어디서나 ( CommandBehavior사용 하는 곳) 중단 점을 두었습니다 .

결과 : 없음 . 모든 호출은 CommandBehavior.Default…. 로 수행됩니다 . 그래서 나는 EF 코드로 들어가서 무슨 일이 일어나는지 이해하려고 노력했습니다 ….. 우 우치 … 나는 그런 위임 코드를 보지 못했습니다.

그래서 무슨 일이 일어나고 있는지 이해하기 위해 프로파일 링을 시도했습니다 …

그리고 나는 무언가가 있다고 생각합니다 …

다음은 내가 벤치마킹 한 테이블을 생성하는 모델이며, 그 안에 3500 개의 라인이 있고 각각에 256KB의 랜덤 데이터가 있습니다 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에서 예약 된 후 대기합니다. 여기서 놀라운 것은 없습니다. 그러나 이제 asyncThreadPool 의 지옥은 다음과 같습니다.

비동기 지옥

첫째, 첫 번째 경우 전체 통화 경로를 따라 3500 개의 적중 횟수를 가졌으며 여기에는 118 371이 있습니다. 또한 스크린 샷에 넣지 않은 모든 동기화 호출을 상상해야합니다 …

둘째, 첫 번째 경우에는 TryReadByteArray()메소드 에 대한 “단지 118 353″의 호출이 있었으며 여기에는 2 050 210의 호출이 있습니다! 17 배 더 … (대용량 1Mb 어레이 테스트에서 160 배 더 큼)

또한 있습니다 :

  • 120 000 개의 Task인스턴스가 생성됨
  • 727 519 Interlocked전화
  • 290 569 Monitor전화
  • ExecutionContext264 481 캡처의 98 283 인스턴스
  • 208 733 SpinLock전화

내 생각에 버퍼링은 비동기 방식으로 이루어지며 병렬 작업은 TDS에서 데이터를 읽으려고합니다. 이진 데이터를 구문 분석하기 위해 너무 많은 작업이 생성되었습니다.

예비 결론으로, Async는 훌륭하고 EF6은 훌륭하지만 현재 구현에서 EF6의 비동기 사용은 성능 측면, 스레딩 측면 및 CPU 측면에 큰 오버 헤드를 추가합니다. 8 ~ 10 배 더 긴 작업 ToList()ToListAsync경우 사례와 20 % . 나는 오래된 i7 920에서 실행합니다).

몇 가지 테스트를 수행하는 동안 이 기사에 대해 다시 생각하고 있었고 놓친 것이 있습니다.

“.NET 4.5의 새로운 비동기 메서드의 경우 동작이 주목할만한 예외 하나를 제외하고 동기 메서드와 정확히 동일합니다. 비 순차 모드의 ReadAsync.”

뭐 ?!!!

나는 Ado.Net 일반 / 비동기 호출에서, 그리고에 포함 할 내 벤치 마크를 확장 그래서 CommandBehavior.SequentialAccess/ CommandBehavior.Default, 여기에 큰 놀라움입니다! :

열심으로

우리는 Ado.Net과 똑같은 동작을합니다 !!! 페이스 팜 …

내 결론은 EF 6 구현에 버그가 있다는 것입니다. 그것은 전환해야 CommandBehaviorSequentialAccess비동기 호출이 들어있는 테이블 위에 이루어질 때 binary(max)열. 너무 많은 작업을 생성하고 프로세스 속도를 저하시키는 문제는 Ado.Net 측에 있습니다. EF 문제는 Ado.Net을 사용하지 않는 것입니다.

이제 EF6 비동기 메소드를 사용하는 대신, 비 비동기 방식으로 EF를 호출 한 다음 a TaskCompletionSource<T>를 사용 하여 결과를 비동기 방식으로 리턴하는 것이 좋습니다.

참고 1 : 부끄러운 오류로 인해 게시물을 편집했습니다 …. 로컬이 아닌 네트워크를 통해 첫 번째 테스트를 수행했으며 제한된 대역폭으로 인해 결과가 왜곡되었습니다. 업데이트 된 결과는 다음과 같습니다.

참고 2 : 테스트를 다른 사용 사례 (예 : nvarchar(max)많은 데이터 사용)로 확장하지는 않았지만 동일한 동작이 발생할 가능성이 있습니다.

참고 3 :이 ToList()경우 일반적으로 12 % CPU (내 CPU의 1/8 = 논리 코어 1)입니다. ToListAsync()스케줄러가 모든 트레드를 사용할 수없는 것처럼 이례적인 경우는 최대 20 %입니다 . 아마도 너무 많은 Task가 생성되었거나 TDS 파서의 병목 현상으로 인한 것 같습니다.


답변

며칠 전에이 질문에 대한 링크가 있었기 때문에 작은 업데이트를 게시하기로 결정했습니다. 현재 최신 버전의 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 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 배 빠릅니다.

이 답변이 앞으로이 방법으로 보내질 다른 사람들에게 도움이되기를 바랍니다.


답변