非同期プログラミング

Microsoft.Data.SqlClient が接続とネットワーク I/O を待機している間にスレッドがブロックされないように、タスクベースの非同期メソッドを使用しましょう。 非同期実行はコマンドやリーダーを並行使用に安全にするわけではなく、サーバー上のクエリ実行時間を短縮するものでもありません。

アプリケーションテーブルをクエリする例は AdventureWorksのサンプルデータベースを使用しています。 C#の例は using Microsoft.Data.SqlClient;を必要とします。

呼び出し経路を非同期コードに変換する

一度に 1 つの完全な呼び出しパスを変換します:

  1. 含まれるメソッドを非同期にします。
  2. 接続、コマンド、リーダー、トランザクションの呼び出しを、対応するタスクベースの呼び出しに置き換えましょう。
  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 の呼び出しごとに 1 行ずつ処理が進むため、ループは行が到着するたびにそれを処理し、サーバーからのさらなるデータを待機している間のみスレッドを解放します。 30秒トークンは読み取りループを含む全操作を制限します。

SqlCommand上で非同期実行は1回のみ保留可能です。 SqlDataReader に対して、非同期読み取り、結果遷移、またはフィールド操作のうち、未処理の状態にあるのは 1 つだけです。 同じオブジェクトに対して、次の操作を開始する前に、その操作が完了するのを待ちます。

非同期プログラミング機能

以下の同期メソッドには、キャンセルトークンを受け入れるタスクベースの対応するメソッドがあります:

同期メソッド 非同期メソッド 例
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のみを実装する場合は同期処分を使いましょう。 await usingを実装するオブジェクトにはIAsyncDisposableを使いましょう。 その接続を再利用する前に、リーダーを破棄してください。

非同期でコマンドを実行する

以下の例は、 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リーダーを廃棄してください。 Multiple Active Result Sets (MARS) がない場合、1 つの接続で開けるリーダーは 1 つだけです。

非同期で結果を読み取る

以下の例は、これらの非同期呼び出しのそれぞれに同じキャンセルトークンを渡します。

  • 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 は、現在の行の 1 列を読み取ります。 デフォルトの 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 が継続的に、接続の確立とプールの待機を制限しています。 トークンをキャンセルすることで、より早く呼び出し元にコントロールを取り戻すことができます。

既存のアプリケーションに非同期呼び出しを追加する

一度に1つの完全な通話経路を変換します。 データベース操作において .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実装を使用し、同期トランザクションメソッドを呼び出して完了したタスクを返します。 それらを await しても、トランザクション操作の実行中に呼び出しスレッドは解放されません。 キャンセルは操作開始前に確認され、進行中の作業を中断しません。 トランザクション内のコマンド実行は依然として非同期です。

非同期分散トランザクションを活用してください

非同期コマンドは TransactionScope内で実行可能です。 スコープはTransaction.Currentを設定し、.NET ではこれをアンビエント トランザクションと呼びます。 スコープ内で開かれた接続は自動的にそのトランザクションに参加します。なぜなら、Enlist 接続キーワードがデフォルトで「true」になっているからです。 トランザクションがTransactionScopeAsyncFlowOption.Enabledを越えて流れる必要がある場合に、awaitを設定します。 分散トランザクションにはプラットフォーム、サーバー、コーディネーターの要件があります。 詳細は System.Transactions統合をご覧ください。

SqlBulkCopy で非同期操作を使用する

非同期の大量コピーには WriteToServerAsync を使いましょう。 戻されたタスクが完了するまで、ソースリーダーまたは行コレクションを存続させ続けてください。 処理のバッチサイズ、トランザクションの動作、タイムアウトを設定してください。 詳細については「 一括コピー操作」をご覧ください。

MARS と共に複数のコマンドを非同期に使用する

MARSは、1つの物理接続で複数の保留中のコマンドを許可します。 アプリケーションが接続共有モデルを必要とする場合にのみ MultipleActiveResultSets=True を有効にしてください。

非同期プログラミングにはMARSは必須ではありません。 MARSがない場合、同じ接続上で別のコマンドを実行する前に、アクティブなリーダーを終了または廃棄してください。 真に独立した作業においては、2 つ目のつながりの方がより明確です。

セッションの挙動やトランザクション制限については、「 複数のアクティブ結果セットを有効にする」を参照してください。

MARS を使用してデータを非同期的に読み取り、更新する

MARSを有効にすると、あるコマンドが行を読み込みながら、別のコマンドを同じ接続で実行できます。 各コマンドとリーダー操作を別々に待ちます。 MARS操作はスレッド安全ではなく、サーバーの実行はイールドポイントでシリアライズされることがあります。

読み取りと更新操作を原子的に行う必要がある場合は、すべてのコマンドに同じローカルトランザクションを割り当て、トランザクションのMARS動作をテストします。 そのトランザクションの別の操作がまだ保留中な状態でコミットしないでください。

レガシ非同期プログラミング

Microsoft.Data.SqlClientは互換性のために非同期プログラミングモデル(APM)メソッドBeginExecuteNonQuery、BeginExecuteReader、BeginExecuteXmlReaderを維持しています。 新しいコードでは使わないでください。 CommandTimeout はこれらの旧式非同期呼び出しでは無視され、ExecuteReaderAsync のようなタスクベースの手法では考慮されます。

従来の方法ではAsynchronous Processing=trueは必要ありません。 Microsoft.Data.SqlClient 4.0以降のバージョンではAsynchronous Processing接続プロパティはサポートされていません。