非同步程式設計

使用基於任務的非同步方法以避免在 Microsoft.Data.SqlClient 等待連線與網路 I/O 時阻塞執行緒。 非同步執行不會讓指令或讀取器可安全地供並行使用,也不會減少伺服器上的查詢執行時間。

查詢應用程式資料表的範例使用 AdventureWorks 範例資料庫。 C# 範例需要 using Microsoft.Data.SqlClient;。

將呼叫路徑轉換為非同步程式碼

一次轉換一條完整的通話路徑:

  1. 將其所在的方法設為非同步。
  2. 用其對應的 Task 式呼叫取代連線、指令、讀取器和交易呼叫。
  3. await 在再次使用同一物件之前,先完成每項操作。
  4. 將呼叫者的取消權杖一路傳遞到呼叫者可取消的每個操作中。
  5. 以明確的命令逾時作為資料庫操作的上限。

使用 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 上,一次只能有一個非同步讀取、結果轉換或欄位作業處於擱置中。 等待每個操作,然後再開始對同一物件進行下一個操作。

非同步程式設計功能

以下同步方法有基於任務的對應方法,接受取消權杖:

同步方法 非同步方法 範例
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。 使用ExecuteXmlReaderAsync進行FOR XML查詢。 在同一連線執行另一個指令前,先釋放 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 要求取消目前的命令。 它不會取代來電者擁有的取消權杖。 作業可能在取消申請生效前就結束了。

取消或超時後,等待操作並釋放讀取器與指令。 重複使用前請檢查連線狀態。 會中斷連線的失敗需要建立新連線。

設定逾時

連線與指令逾時涵蓋不同階段:

Setting 保護套
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,則在執行同一連線下的其他指令前,先完成或丟棄該主動讀取器。 對於真正獨立的工作來說,第二個連線通常會更清晰。

關於會話行為與交易限制,請參見 啟用多個主動結果集。

使用 MARS 非同步讀取及更新資料

啟用 MARS 後,一個指令可以讀取資料列,而另一個指令則在同一連線上執行。 分別等待每個指令和讀取器操作。 MARS 操作並非執行緒安全,伺服器執行在 yield 點仍可能被序列化。

當讀取與更新操作必須是原子式時,將相同的本地交易指派給每個指令,並測試該交易的 MARS 行為。 當該交易中還有其他操作尚未完成時,不要提交。

舊版非同步程式設計

Microsoft.Data.SqlClient 為了相容性而保留了非同步程式設計模型(APM)方法 BeginExecuteNonQuery、BeginExecuteReader 和 BeginExecuteXmlReader。 不要在新程式碼中使用它們。 CommandTimeout 會被這些舊式非同步呼叫忽略,而像 ExecuteReaderAsync 這樣以工作為基礎的方法則會支援它。

舊有的方法不需要 Asynchronous Processing=true。 Microsoft.Data.SqlClient 4.0 及更新版本不支援Asynchronous Processing連線屬性。