使用基于任务的异步方法,以避免 Microsoft.Data.SqlClient 在等待连接和网络 I/O 时阻止线程。 异步执行不会让命令或读取器可安全地并发使用,也不会减少服务器上的查询执行时间。
查询应用表的示例使用了 AdventureWorks 示例数据库。 C# 示例要求 using Microsoft.Data.SqlClient;。
将调用路径转换为异步代码
一次转换一条完整的呼叫路径:
- 将所在方法设置为异步。
- 将连接、命令、读取器和事务调用替换为基于任务的对应项。
-
await在再次使用同一对象之前,先对每个操作执行此操作。 - 将调用者的取消令牌传递给调用者可取消的每个操作。
- 保持有限的命令超时作为数据库操作的限制。
从连接打开到结果处理期间,使用 await。 以下摘录假设 connectionString 和 sql 已经定义。 它为该操作创建一个具有有限超时时间的 CancellationTokenSource:
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellation.Token);
using var command = new SqlCommand(sql, connection);
await using SqlDataReader reader =
await command.ExecuteReaderAsync(cancellation.Token);
while (await reader.ReadAsync(cancellation.Token))
{
Console.WriteLine(reader.GetValue(0));
}
ExecuteReaderAsync 当第一个结果可用时返回,而不是所有行到达后。 每次 ReadAsync 调用会前进一行,因此循环会在行到达时处理行,并且仅在等待服务器提供更多数据时释放线程。 这个 30 秒的令牌限制整个操作,包括读取循环。
在SqlCommand上,只能有一个异步执行处于待处理状态。 在 SqlDataReader 上,只能有一个异步读取、结果转换或字段操作处于挂起状态。 等待每个操作完成后,再开始对同一对象的下一个操作。
异步编程功能
以下同步方法有基于任务的对应方法,接受取消令牌:
| 同步方法 | 异步方法 | Example |
|---|---|---|
SqlConnection.Open |
SqlConnection.OpenAsync |
转换同步方法 |
SqlCommand.ExecuteNonQuery |
SqlCommand.ExecuteNonQueryAsync |
异步运行命令 |
SqlCommand.ExecuteScalar |
SqlCommand.ExecuteScalarAsync |
异步运行命令 |
SqlCommand.ExecuteXmlReader |
SqlCommand.ExecuteXmlReaderAsync |
异步运行命令 |
SqlCommand.ExecuteReader |
SqlCommand.ExecuteReaderAsync |
将调用路径转换为异步代码 |
SqlDataReader.Read |
SqlDataReader.ReadAsync |
将调用路径转换为异步代码 |
SqlDataReader.NextResult |
SqlDataReader.NextResultAsync |
异步读取结果 |
SqlDataReader.IsDBNull |
SqlDataReader.IsDBNullAsync |
异步读取结果 |
SqlDataReader.GetFieldValue<T> |
SqlDataReader.GetFieldValueAsync<T> |
异步读取结果 |
SqlBatch.ExecuteNonQuery、 ExecuteScalar和 ExecuteReader |
SqlBatch.ExecuteNonQueryAsync、 ExecuteScalarAsync和 ExecuteReaderAsync |
执行批处理 |
SqlBulkCopy.WriteToServer |
SqlBulkCopy.WriteToServerAsync |
使用 SqlBulkCopy 执行异步操作 |
当对象仅实现 IDisposable时,使用同步释放。 对实现了 IAsyncDisposable 的对象使用 await using。 在重复使用其连接前,先释放读取器。
异步运行命令
以下示例假设 connection 处于打开状态。 对单个值使用 ExecuteScalarAsync,并传递调用方拥有的取消标记:
using var cancellation = new CancellationTokenSource(
TimeSpan.FromSeconds(30));
CancellationToken token = cancellation.Token;
using var count = new SqlCommand(
"SELECT COUNT(*) FROM Production.Product;",
connection);
int total = (int)(await count.ExecuteScalarAsync(token))!;
Console.WriteLine($"Row count: {total}");
对数据变更语句使用 ExecuteNonQueryAsync。 对于FOR XML查询,请使用ExecuteXmlReaderAsync。 在同一连接上执行另一个命令前,先释放 XML 读取器。 没有多个活跃结果集(MARS)时,一个连接只允许一个打开的读取器。
异步读取结果
以下示例将相同的取消令牌传递给这些异步调用:
-
ExecuteReaderAsync运行查询。 -
ReadAsync读取每一行。 -
GetFieldValueAsync读取每个列的值。 -
NextResultAsync移至下一个结果集。 -
IsDBNullAsync检查NULL。
using var cancellation = new CancellationTokenSource(
TimeSpan.FromSeconds(30));
CancellationToken token = cancellation.Token;
using var query = new SqlCommand("""
SELECT TOP (3) ProductID, Name
FROM Production.Product
ORDER BY ProductID;
SELECT TOP (1) ProductID, Color
FROM Production.Product
WHERE Color IS NULL;
""", connection);
await using SqlDataReader reader = await query.ExecuteReaderAsync(token);
while (await reader.ReadAsync(token))
{
int id = await reader.GetFieldValueAsync<int>(0, token);
string name = await reader.GetFieldValueAsync<string>(1, token);
Console.WriteLine($"{id}: {name}");
}
if (await reader.NextResultAsync(token) && await reader.ReadAsync(token))
{
int productId = await reader.GetFieldValueAsync<int>(0, token);
bool colorIsNull = await reader.IsDBNullAsync(1, token);
Console.WriteLine($"{productId}: Color is NULL: {colorIsNull}");
}
NextResultAsync 转到下一个结果集。
GetFieldValueAsync 并 IsDBNullAsync 读取当前行的一列。 当你使用默认的 CommandBehavior 时,该行已经被缓冲了,因此这些调用通常无需等待即可完成。 使用 CommandBehavior.SequentialAccess 时,它们可以等待来自网络的列数据。 有关顺序访问的更多信息,请参见 SqlClient 流式支持。
将方法转换为同步方法
此同步方法会打开连接、运行命令,并返回 sys.objects 中的行数:
static int CountObjects(string connectionString)
{
using var connection = new SqlConnection(connectionString);
connection.Open();
using var command = new SqlCommand("SELECT COUNT(*) FROM sys.objects;", connection);
return (int)command.ExecuteScalar()!;
}
要将该方法转换为异步代码,请进行以下修改:
- 将返回类型改为
Task<int>。 - 添加
CancellationToken参数。 - 将
Open替换为OpenAsync,再将ExecuteScalar替换为ExecuteScalarAsync。 - 释放与
await using的连接。
转换后的方法是这样的:
static async Task<int> CountObjectsAsync(
string connectionString,
CancellationToken cancellationToken)
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = new SqlCommand("SELECT COUNT(*) FROM sys.objects;", connection);
return (int)(await command.ExecuteScalarAsync(cancellationToken))!;
}
呼叫者创建令牌并等待结果:
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
int objectCount = await CountObjectsAsync(connectionString, cancellation.Token);
Console.WriteLine($"Objects: {objectCount}");
两种方法返回的计数相同。 输出取决于你连接的数据库。
Connect Timeout 仍会限制连接建立和池等待。 取消令牌可以更快地将控制权还给调用方。
向现有应用添加异步调用
一次转换一条完整的呼叫路径。 避免对数据库任务调用 .Result、.Wait() 或 GetAwaiter().GetResult(),因为这些调用在有同步上下文的环境中会阻塞并可能导致死锁。
不要先启动异步命令,然后在任务结束前同步使用同一个命令或读取器。 没有MARS的话,不要在任务完成前同步使用同一条连接。
增加取消和时间限制
取消异步操作
从打开连接到执行命令,传递同一个取消令牌:
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellation.Token);
using var command = new SqlCommand(
"SELECT COUNT(*) FROM Production.Product;",
connection);
await command.ExecuteScalarAsync(cancellation.Token);
取消会尽力执行,但不保证成功。 操作可能在取消生效前完成,观察到的异常取决于取消发生的位置。 不要以为收到取消请求就意味着服务器回滚了已完成的工作。 当一组变更必须作为一个整体成功或失败时,使用显式事务。 更多信息请参见 本地交易。
Cancel 请求取消当前命令。 它不能取代调用方拥有的取消令牌。 操作可能在取消请求生效前完成。
取消或超时后,等待操作并释放读取器和命令。 重复使用前检查连接状态。 导致连接中断的故障需要建立新连接。
配置超时
连接和命令超时涵盖不同阶段:
| 设置 | 封面 |
|---|---|
Connect Timeout |
建立连接和等待共用连接。 |
CommandTimeout |
命令执行和累计的网络读取等待。 |
| 取消令牌 | 由调用方控制的停止等待请求。 |
CommandTimeout 以秒为度量单位。
0 意味着没有指令时间限制。 如果已配置,则默认为连接的Command Timeout值;否则为30秒。
对于读取器,命令超时适用于每个提供程序读取操作的累计网络等待。 它不包括在应用程序代码中处理一行所花费的时间。 超时可以在第一行之前发生,也可以在取回后续行时发生。
合并取消和命令超时
同时使用有限的命令超时和调用方取消。 这个示例假设 connection 处于打开状态:
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var command = new SqlCommand("""
SELECT TOP (10) SalesOrderID, OrderDate
FROM Sales.SalesOrderHeader
ORDER BY SalesOrderID;
""", connection)
{
CommandTimeout = 15
};
await using SqlDataReader reader =
await command.ExecuteReaderAsync(cancellation.Token);
while (await reader.ReadAsync(cancellation.Token))
{
Console.WriteLine($"{reader.GetInt32(0)} | {reader.GetDateTime(1):O}");
}
如果呼叫者先取消,被等待的调用将抛出异常。 例外类型取决于取消发生的地点。 如果命令超时先到期,提供者会报告超时。 无论哪种情况,命令都可能在取消或超时生效前成功完成。
高级异步模式
通过基础提供者类型使用异步方法
与提供程序无关的代码可以使用 DbConnection、DbCommand 和 DbDataReader 异步方法。 通过提供程序命令创建参数,并避免向下转型,除非应用程序需要 Microsoft.Data.SqlClient 专用功能。
使用基于任务的SQL事务方法
当目标框架暴露基于任务的本地事务方法时,使用它们。 以下示例假设connection处于打开状态,sql存储命令文本,并且调用方传入cancellationToken:
await using SqlTransaction transaction =
(SqlTransaction)await connection.BeginTransactionAsync(cancellationToken);
using var command = new SqlCommand(sql, connection, transaction);
try
{
await command.ExecuteNonQueryAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (Exception operationException)
{
try
{
await transaction.RollbackAsync(CancellationToken.None);
}
catch (Exception rollbackException)
{
throw new AggregateException(
"The operation and transaction rollback both failed.",
operationException,
rollbackException);
}
throw;
}
使用单独的清除令牌进行回滚。 操作令牌可能已经被取消了。 在不丢弃原始操作异常的情况下处理回滚失败。 此示例将两个异常都保留在 AggregateException 中。 应用程序可以记录回滚异常并重新抛出原始异常。
这些 Microsoft.Data.SqlClient 方法使用继承的 DbConnection 和 DbTransaction 实现,这些实现调用同步事务方法并返回已完成的任务。 等待它们时,在事务操作运行期间不会阻塞调用线程。 取消是在操作开始前检查的,且不会中断正在进行的操作。 事务内的命令执行仍然是异步的。
使用异步分布式事务
异步命令可以在 TransactionScope 内执行。 该范围设置 Transaction.Current,.NET 将其称为环境事务。 在作用域内打开的连接会自动参与该事务,因为 Enlist 连接关键字默认为 true。 当事务必须跨 await 流动时,配置 TransactionScopeAsyncFlowOption.Enabled。 分布式事务对平台、服务器和协调器有要求。 更多信息请参见 System.Transactions 集成。
使用 SqlBulkCopy 执行异步操作
使用 WriteToServerAsync 进行异步批量复制。 保持源阅读器或行集合存活,直到返回任务完成。 配置批量大小、事务行为和该操作的超时。 更多信息请参见 批量复制操作。
以异步方式将多个命令与 MARS 结合使用
MARS 允许在一个物理连接上存在多个待处理命令。 只有当应用需要这种连接共享模型时才启用它 MultipleActiveResultSets=True 。
异步编程不需要MARS。 没有 MARS 时,在执行同一连接上的其他命令前,先完成或释放活动 DataReader。 对于真正独立的工作来说,第二条连接通常会更清晰。
关于会话行为和事务限制,请参见 启用多个活跃结果集。
使用 MARS 以异步方式读取和更新数据
启用MARS后,一个命令可以读取行,而另一个命令则在同一连接上执行。 分别等待每个命令和读取操作。 MARS 操作不是线程安全的,并且服务器执行仍可能在让出点串行化。
当读取和更新操作必须是原子级操作时,将相同的本地事务分配给每个命令,并测试该事务的MARS行为。 在该事务中还有其他操作待处理时不要提交。
遗留异步编程
Microsoft.Data.SqlClient 保留了异步编程模型(APM)方法 BeginExecuteNonQuery、BeginExecuteReader 和 BeginExecuteXmlReader,以保持兼容性。 不要在新代码中使用它们。
CommandTimeout 会被这些旧式异步调用忽略,而像 ExecuteReaderAsync 这样的基于任务的方法则会遵循它。
传统方法不要求 Asynchronous Processing=true。 Microsoft.Data.SqlClient 4.0及更高版本不支持Asynchronous Processing连接属性。
相关内容
- 从同步 命令执行的工作流程开始。
- 要处理行,请 使用 SqlDataReader 检索数据。
- 要传输大值,可以使用 SqlClient 流式处理支持。
- 关于MARS会话和事务行为,请参见 多重活跃结果集。