实体框架异步操作需要十倍的时间来完成

我有一个 MVC 网站,使用实体框架6来处理数据库,我一直在尝试改变它,使一切运行作为异步控制器和数据库的调用运行作为它们的异步对应(例如,。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 服务器运行它需要将近6秒钟。SQLServer 事件探查器报告需要5742ms 才能完成。

如果我将代码更改为:

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

然后生成完全相同的 SQL,但根据 SQLServer 事件探查器,这只需要474毫秒。

该数据库在“ Albums”表中有大约3500行,这并不是很多,并且在“ Artist _ ID”列上有一个索引,所以应该非常快。

我知道异步有一定的开销,但是让事情慢上十倍对我来说似乎有点过分!我到底哪里做错了?

50040 次浏览

我发现这个问题非常有趣,特别是因为我在 Ado 中到处都在使用 async。净值和外汇基金6。我希望有人能对这个问题给出一个解释,但是它没有发生。所以我试图在我这边重现这个问题。我希望你们中的一些人会觉得这很有趣。

第一个好消息: 我复制了它:)而且差别是巨大的。

first results

首先,我怀疑有什么东西在处理 CommandBehavior,因为 我读了一篇有趣的文章关于 async和 Ado 说:

由于非顺序访问模式必须存储整个行的数据,因此如果您从服务器读取一个较大的列(比如 varbin (MAX)、 varchar (MAX)、 nvarchar (MAX)或 XML) ,它可能会导致问题。”

我怀疑 ToList()调用是 CommandBehavior.SequentialAccess,而异步调用是 CommandBehavior.Default(非顺序的,可能会导致问题)。因此,我下载了 EF6的源代码,并把断点放在任何地方(当然是在使用 CommandBehavior的地方)。

结果: 没什么。所有的电话都是用 CommandBehavior.Default打的。所以我尝试着进入 EF 代码来理解发生了什么... 然后。.哎哟... 我从来没有见过这样一个委托代码,一切似乎懒惰执行..。

所以我试着做了一些侧写来了解发生了什么..。

我想我有些东西..。

下面是创建我测试的表的模型,其中包含3500行,每个 varbinary(MAX)包含256Kb 随机数据。(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()方法上读取了118353个调用,这是发生缓冲循环的情况。(每个 byte[]的平均来电次数为33.8次,总长256kb)

对于 async来说,情况真的很不一样... ..。首先,在 ThreadPool 上安排 .ToListAsync()调用,然后等待。没什么特别的。但是,现在,这里是线程池上的 async地狱:

ToListAsync hell

首先,在第一种情况下,我们只有3500点击数沿着完全调用路径,这里我们有118371。此外,你必须想象所有的同步调用,我没有放在屏幕截图..。

其次,在第一种情况下,对 TryReadByteArray()方法的调用“只有118353个”,这里有2050210个调用!它是... 的17倍(对于大型1Mb 数组的测试,它是160倍以上)

此外,还有:

  • 创建了120000个 Task实例
  • 727519 Interlocked来电
  • 290个569个 Monitor电话
  • 98283个 ExecutionContext实例,264481个捕获
  • 208个733个 SpinLock电话

我的猜测是,缓冲是以异步方式进行的(而且不是很好的方式) ,并行任务试图从 TDS 读取数据。仅仅为了解析二进制数据而创建了太多的 Task。

作为一个初步的结论,我们可以说 Async 很棒,EF6很棒,但是 EF6在当前实现中使用异步增加了很大的开销,在性能方面、线程方面和 CPU 方面(在 ToList()情况下 CPU 使用率为12% ,在 ToListAsync情况下为20% ,工作时间延长了8到10倍... ... 我在一个旧的 i7920上运行它)。

在做一些测试的时候,我在想 又是这篇文章,我注意到我错过了一些东西:

”中的新异步方法。在 Net 4.5中,它们的行为与同步方法完全相同,除了一个明显的例外: 非顺序模式下的 ReadAsync。”

什么!

因此,我将我的基准扩展到包括 Ado。网络在常规/异步调用,并与 CommandBehavior.SequentialAccess/CommandBehavior.Default,这是一个巨大的惊喜!:

with ado

我们和 Ado. Net 有着完全一样的行为! ! ! 掌心..。

我的最终结论是: EF6实现中有一个 bug。当对包含 binary(max)列的表进行异步调用时,它应该将 CommandBehavior切换为 SequentialAccess。创建太多 Task、减慢进程的问题出现在 Ado 上。网侧。EF 的问题在于它没有使用 Ado。本该如此。

现在您知道了,与其使用 EF6异步方法,不如以常规的非异步方式调用 EF,然后使用 TaskCompletionSource<T>以异步方式返回结果。

注1: 我编辑我的文章是因为一个可耻的错误... ..。我已经在网络上做了第一次测试,而不是在本地,有限的带宽已经扭曲了结果。这是最新的结果。

注意2: 我没有将我的测试扩展到其他用例(例如: 带有大量数据的 nvarchar(max)) ,但是有可能发生同样的行为。

注3: 对于 ToList()来说,通常的情况是12% 的 CPU (CPU 的1/8 = 1个逻辑核)。一些不寻常的是最高20% 的 ToListAsync()情况下,如果计划程序不能使用所有的履带。这可能是由于创建了太多的 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 核心是否有改进?

我从一个新的 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 框架的实现。

我希望这个答案可以帮助其他人在未来得到这种方式。

有一个解决方案,允许使用异步而不牺牲性能,测试与 EF 核心和 MS SQL 数据库。

首先,你需要为 DBDataReader做一个包装:

  1. 它的 ReadAsync方法应该读取整个行,将每个列的值存储在一个缓冲区中。
  2. 它的 GetXyz方法应该从前面提到的缓冲区中获取值。
  3. 可选地,使用 GetBytes + Encoding.GetString代替 GetString。对于我的用例(每行16KB 的文本列) ,它导致了同步和异步的显著加速。
  4. 可选地,调整连接字符串的 Packet Size。对于我的用例,值32767导致了同步和异步的显著加速。

你现在可以制作一个 DbCommandInterceptor,拦截 ReaderExecutingAsync来制作一个带有循序存取的 DBDataReader,用上面提到的包装纸包装。

EF Core 将尝试以非顺序的方式访问字段——这就是为什么包装器必须首先读取并缓冲整行的原因。

下面是一个实现示例(拦截异步和同步) :

/// <summary>
/// This interceptor optimizes a <see cref="Microsoft.EntityFrameworkCore.DbContext"/> for
/// accessing large columns (text, ntext, varchar(max) and nvarchar(max)). It enables the
/// <see cref="CommandBehavior.SequentialAccess"/> option and uses an optimized method
/// for converting large text columns into <see cref="string"/> objects.
/// </summary>
public class ExampleDbCommandInterceptor : DbCommandInterceptor
{
public async override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default)
{
var behavior = CommandBehavior.SequentialAccess;


var reader = await command.ExecuteReaderAsync(behavior, cancellationToken).ConfigureAwait(false);


var wrapper = await DbDataReaderOptimizedWrapper.CreateAsync(reader, cancellationToken).ConfigureAwait(false);


return InterceptionResult<DbDataReader>.SuppressWithResult(wrapper);
}


public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
var behavior = CommandBehavior.SequentialAccess;


var reader = command.ExecuteReader(behavior);


var wrapper = DbDataReaderOptimizedWrapper.Create(reader);


return InterceptionResult<DbDataReader>.SuppressWithResult(wrapper);
}


/// <summary>
/// This wrapper caches the values of accessed columns of each row, allowing non-sequential access
/// even when <see cref="CommandBehavior.SequentialAccess"/> is specified. It enables using this option it with EF Core.
/// In addition, it provides an optimized method for reading text, ntext, varchar(max) and nvarchar(max) columns.
/// All in all, it speeds up database operations reading from large text columns.
/// </summary>
sealed class DbDataReaderOptimizedWrapper : DbDataReader
{
readonly DbDataReader reader;
readonly DbColumn[] schema;


readonly object[] cache;
readonly Func<object>[] materializers;


[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
private T Get<T>(int ordinal)
{
if (cache[ordinal] != DBNull.Value) return (T)cache[ordinal];


return (T)(object)null; // this line will throw an exception if T is not a reference type (class), otherwise it will return null
}


private DbDataReaderOptimizedWrapper(DbDataReader reader, IEnumerable<DbColumn> schema)
{
this.reader = reader;
this.schema = schema.OrderBy(x => x.ColumnOrdinal).ToArray();


cache = new object[this.schema.Length];




byte[] stringGetterBuffer = null;


string stringGetter(int i)
{
var dbColumn = this.schema[i];


// Using GetBytes instead of GetString is much faster, but only works for text, ntext, varchar(max) and nvarchar(max)
if (dbColumn.ColumnSize < int.MaxValue) return reader.GetString(i);


if (stringGetterBuffer == null) stringGetterBuffer = new byte[32 * 1024];


var totalRead = 0;


while (true)
{
var offset = totalRead;


totalRead += (int)reader.GetBytes(i, offset, stringGetterBuffer, offset, stringGetterBuffer.Length - offset);


if (totalRead < stringGetterBuffer.Length) break;


const int maxBufferSize = int.MaxValue / 2;


if (stringGetterBuffer.Length >= maxBufferSize)


throw new OutOfMemoryException($"{nameof(DbDataReaderOptimizedWrapper)}.{nameof(GetString)} cannot load column '{GetName(i)}' because it contains a string longer than {maxBufferSize} bytes.");


Array.Resize(ref stringGetterBuffer, 2 * stringGetterBuffer.Length);
}


var c = dbColumn.DataTypeName[0];


var encoding = (c is 'N' or 'n') ? Encoding.Unicode : Encoding.ASCII;


return encoding.GetString(stringGetterBuffer.AsSpan(0, totalRead));
}


var dict = new Dictionary<Type, Func<DbColumn, int, Func<object>>>
{
[typeof(bool)] = (column, index) => () => reader.GetBoolean(index),
[typeof(byte)] = (column, index) => () => reader.GetByte(index),
[typeof(char)] = (column, index) => () => reader.GetChar(index),


[typeof(short)] = (column, index) => () => reader.GetInt16(index),
[typeof(int)] = (column, index) => () => reader.GetInt32(index),
[typeof(long)] = (column, index) => () => reader.GetInt64(index),


[typeof(float)] = (column, index) => () => reader.GetFloat(index),
[typeof(double)] = (column, index) => () => reader.GetDouble(index),
[typeof(decimal)] = (column, index) => () => reader.GetDecimal(index),


[typeof(DateTime)] = (column, index) => () => reader.GetDateTime(index),
[typeof(Guid)] = (column, index) => () => reader.GetGuid(index),


[typeof(string)] = (column, index) => () => stringGetter(index),
};


materializers = schema.Select((column, index) => dict[column.DataType](column, index)).ToArray();
}


public static DbDataReaderOptimizedWrapper Create(DbDataReader reader)


=> new DbDataReaderOptimizedWrapper(reader, reader.GetColumnSchema());


public static async ValueTask<DbDataReaderOptimizedWrapper> CreateAsync(DbDataReader reader, CancellationToken cancellationToken)
            

=> new DbDataReaderOptimizedWrapper(reader, await reader.GetColumnSchemaAsync(cancellationToken).ConfigureAwait(false));


protected override void Dispose(bool disposing) => reader.Dispose();


public async override ValueTask DisposeAsync() => await reader.DisposeAsync().ConfigureAwait(false);




public override object this[int ordinal] => Get<object>(ordinal);
public override object this[string name] => Get<object>(GetOrdinal(name));


public override int Depth => reader.Depth;


public override int FieldCount => reader.FieldCount;


public override bool HasRows => reader.HasRows;


public override bool IsClosed => reader.IsClosed;


public override int RecordsAffected => reader.RecordsAffected;


public override int VisibleFieldCount => reader.VisibleFieldCount;




public override bool GetBoolean(int ordinal) => Get<bool>(ordinal);


public override byte GetByte(int ordinal) => Get<byte>(ordinal);


public override long GetBytes(int ordinal, long dataOffset, byte[] buffer, int bufferOffset, int length) => throw new NotSupportedException();


public override char GetChar(int ordinal) => Get<char>(ordinal);


public override long GetChars(int ordinal, long dataOffset, char[] buffer, int bufferOffset, int length) => throw new NotSupportedException();


public override string GetDataTypeName(int ordinal) => reader.GetDataTypeName(ordinal);


public override DateTime GetDateTime(int ordinal) => Get<DateTime>(ordinal);


public override decimal GetDecimal(int ordinal) => Get<decimal>(ordinal);


public override double GetDouble(int ordinal) => Get<double>(ordinal);


public override IEnumerator GetEnumerator() => reader.GetEnumerator();


public override Type GetFieldType(int ordinal) => reader.GetFieldType(ordinal);


public override float GetFloat(int ordinal) => Get<float>(ordinal);


public override Guid GetGuid(int ordinal) => Get<Guid>(ordinal);


public override short GetInt16(int ordinal) => Get<short>(ordinal);


public override int GetInt32(int ordinal) => Get<int>(ordinal);


public override long GetInt64(int ordinal) => Get<long>(ordinal);


public override string GetName(int ordinal) => reader.GetName(ordinal);


public override int GetOrdinal(string name) => reader.GetOrdinal(name);


public override string GetString(int ordinal) => Get<string>(ordinal);


public override object GetValue(int ordinal) => Get<object>(ordinal);


public override int GetValues(object[] values)
{
var min = Math.Min(cache.Length, values.Length);


Array.Copy(cache, values, min);


return min;
}


public override bool IsDBNull(int ordinal) => Convert.IsDBNull(cache[ordinal]);


public override bool NextResult() => reader.NextResult();


public override bool Read()
{
Array.Clear(cache, 0, cache.Length);


if (reader.Read())
{
for (int i = 0; i < cache.Length; ++i)
{
if ((schema[i].AllowDBNull ?? true) && reader.IsDBNull(i))
                        

cache[i] = DBNull.Value;


else cache[i] = materializers[i]();
}


return true;
}


return false;
}


public override void Close() => reader.Close();


public async override Task CloseAsync() => await reader.CloseAsync().ConfigureAwait(false);


public override DataTable GetSchemaTable() => reader.GetSchemaTable();


public async override Task<DataTable> GetSchemaTableAsync(CancellationToken cancellationToken = default) => await reader.GetSchemaTableAsync(cancellationToken).ConfigureAwait(false);


public async override Task<ReadOnlyCollection<DbColumn>> GetColumnSchemaAsync(CancellationToken cancellationToken = default) => await reader.GetColumnSchemaAsync(cancellationToken).ConfigureAwait(false);


public async override Task<bool> NextResultAsync(CancellationToken cancellationToken) => await reader.NextResultAsync(cancellationToken).ConfigureAwait(false);


public async override Task<bool> ReadAsync(CancellationToken cancellationToken)
{
Array.Clear(cache, 0, cache.Length);


if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
for (int i = 0; i < cache.Length; ++i)
{
if ((schema[i].AllowDBNull ?? true) && await reader.IsDBNullAsync(i, cancellationToken).ConfigureAwait(false))
                        

cache[i] = DBNull.Value;


else cache[i] = materializers[i]();
}


return true;
}


return false;
}
}
}

我现在不能提供一个基准,希望有人会在评论中这样做。

增加@rducom 给出的答案。这个问题在 Microsoft.EntityFrameworkCore 6.0.0中仍然存在

阻塞部分实际上是 SqlClient,@AndriySvyryd 在 EF 核心项目中推荐的解决方案是:

不要使用 VARCHAR (MAX)或不要使用异步查询。

在使用 async查询读取大型 JSON 对象和 Image (二进制)数据时,我遇到了这种情况。

相关网址:

Https://github.com/dotnet/efcore/issues/18571#issuecomment-545992812

Https://github.com/dotnet/efcore/issues/18571

Https://github.com/dotnet/efcore/issues/885

Https://github.com/dotnet/sqlclient/issues/245

Https://github.com/dotnet/sqlclient/issues/593

对我来说,快速修复是将调用包装在任务中,而只是使用同步方法。

它不是一个通用的解决方案,但是对于小的聚合,它可以局限于应用程序的一小部分。