F# の非同期プログラミング

非同期プログラミングは、さまざまな理由で、最新のアプリケーションに不可欠なメカニズムです。 ほとんどの開発者が直面する主なユース ケースが 2 つあります。

  • 多数の同時受信要求を処理できるサーバー プロセスを提示する一方で、要求の処理時にそのプロセスの外部のシステムまたはサービスからの入力を待機している間に使用されるシステム リソースを最小限に抑える
  • バックグラウンド作業の同時進行中に応答性の高い UI またはメイン スレッドを維持する

多くの場合、バックグラウンド作業では複数のスレッドを使用する必要がありますが、非同期性とマルチスレッドの概念を別個に考えることが重要です。 実際に、それらは別個の考慮事項であり、一方によってもう一方が暗示されることはありません。 この記事では、別個の概念について詳しく説明します。

定義された非同期性

前の点 (非同期性は複数スレッドの利用と無関係であること) について、もう少し詳しく説明しておく価値があります。 関連することがあるものの、厳密には互いに独立している概念が 3 つあります。

  • コンカレンシー。複数の計算が重複する時間間隔で実行される場合です。
  • 並列性。複数の計算または 1 つの計算の複数の部分が厳密に同時に実行される場合です。
  • 非同期性。1 つ以上の計算をメインのプログラム フローとは別に実行できる場合です。

この 3 つは直交する概念ですが、一緒に使用される場合には特に融合されやすい可能性があります。 たとえば、複数の非同期計算を並列に実行することが必要になる場合があります。 このリレーションシップは、並列性または非同期性が互いを暗示することを意味するものではありません。

"asynchronous" (非同期) という単語の語源を考えると、2 つの部分が含まれます。

  • "a" は "not" (否定) を意味します。
  • "synchronous" (同期) は、"at the same time" (同時) を意味します。

この 2 つの用語をまとめると、"asynchronous" (非同期) とは "not at the same time" (同時ではない) ことを意味します。 これで完了です。 この定義には、コンカレンシーまたは並列処理という意味はありません。 これは現実にも当てはまります。

現実には、F# での非同期計算は、メイン プログラム フローとは別に実行するようにスケジュールされています。 この独立した実行は、コンカレンシーまたは並列処理を意味しません。また、計算が常にバックグラウンドで行われることも意味しません。 実際に、非同期計算は、計算の性質と計算が実行されている環境に応じて、同期的に実行することもできます。

主要な結論として、非同期計算はメインのプログラム フローに依存しません。 非同期計算を実行するタイミングと方法についての保証はほとんどありませんが、調整とスケジュール設定にはいくつかのアプローチがあります。 この記事の残りの部分では、F# の非同期性の主要な概念と、F# に組み込まれている型、関数、および式の使用方法について説明します。

主要な概念

F# では、非同期プログラミングは、非同期計算とタスクという 2 つの主要な概念を中心にしています。

  • タスクを形成するために開始できる構成可能な非同期計算を表す、async { }を含む Async<'T> 型。
  • 実行中の .NET タスクを表す、task { }を含む Task<'T> 型。

一般に、タスクを使用する .NET ライブラリを相互運用している場合や、非同期コード tailcalls や暗黙的なキャンセル トークンの伝達に依存していない場合は、新しいコードで async {…} よりも task {…} を使用することを検討する必要があります。

非同期の主要な概念

次の例で、"非同期" プログラミングの基本的な概念を確認できます。

open System
open System.IO

// Perform an asynchronous read of a file using 'async'
let printTotalFileBytesUsingAsync (path: string) =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    printTotalFileBytesUsingAsync "path-to-file.txt"
    |> Async.RunSynchronously

    Console.Read() |> ignore
    0

この例では、printTotalFileBytesUsingAsync 関数の型は string -> Async<unit> です。 関数の呼び出しでは、実際には非同期計算は実行されません。 代わりに、非同期的に実行する作業の "仕様" として機能する Async<unit> が返されます。 本体で Async.AwaitTask が呼び出され、ReadAllBytesAsync の結果が適切な型に変換されます。

もう 1 つの重要な行は、Async.RunSynchronously の呼び出しです。 これは、実際に F# の非同期計算を実行する場合に呼び出す必要がある非同期モジュール開始関数の 1 つです。

これは、async プログラミングの C#/Visual Basic スタイルとの基本的な違いです。 F# では、非同期計算はコールド タスクと考えることができます。 これらを実際に実行するには、明示的に開始する必要があります。 これにはいくつかの利点があります。これにより、C# または Visual Basic よりもはるかに簡単に、非同期作業を組み合わせてシーケンス処理することができます。

非同期計算の結合

次に、計算を組み合わせることによって、前に示したものに基づいて構築する例を示します。

open System
open System.IO

let printTotalFileBytes path =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    argv
    |> Seq.map printTotalFileBytes
    |> Async.Parallel
    |> Async.Ignore
    |> Async.RunSynchronously

    0

ご覧のように、main 関数にはさらに多数の要素があります。 概念的には、これは次のことを行います。

  1. Seq.map を使用して、コマンド ライン引数を Async<unit> 計算のシーケンスに変換します。
  2. 実行時に printTotalFileBytes 計算を並列にスケジュールおよび実行する Async<'T[]> を作成します。
  3. 並列計算を実行する Async<unit> を作成し、その結果 (unit[]) を無視します。
  4. 構成された計算全体を Async.RunSynchronously で明示的に実行し、完了するまでブロックします。

このプログラムが実行されると、printTotalFileBytes がコマンド ライン引数ごとに並列で実行されます。 非同期計算はプログラム フローとは無関係に実行されるため、情報を出力して実行を終了する順序は定義されていません。 計算は並列でスケジュールされますが、その実行順序は保証されません。

非同期計算のシーケンス処理

Async<'T> は、既に実行されているタスクではなく作業の仕様であるため、より複雑な変換を簡単に実行できます。 一連の非同期計算をシーケンス処理して、1 つずつ実行する例を次に示します。

let printTotalFileBytes path =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    argv
    |> Seq.map printTotalFileBytes
    |> Async.Sequential
    |> Async.Ignore
    |> Async.RunSynchronously
    |> ignore

これにより、並列でスケジュールする代わりに argv の要素の順序で実行するように printTotalFileBytes がスケジュールされます。 後続の各操作は、前の計算の実行が完了するまではスケジュールされないため、実行中に重複が発生しないように計算がシーケンス処理されます。

重要な非同期モジュール関数

F# で非同期コードを記述する場合は、通常、計算のスケジューリングを処理するフレームワークと対話します。 ただし、常にそうであるとは限らないため、非同期処理のスケジュール設定に使用できるさまざまな関数について理解しておくことをお勧めします。

F# の非同期計算は、既に実行されている作業の表現ではなく、作業の "仕様" であるため、開始関数を使用して明示的に開始する必要があります。 さまざまなコンテキストで役に立つ非同期開始メソッドが多数あります。 次のセクションでは、一般的な開始関数の一部について説明します。

Async.StartChild

非同期計算内で子計算を開始します。 これにより、複数の非同期計算を同時に実行できます。 子計算では、キャンセル トークンが親計算と共有されます。 親計算が取り消された場合は、子計算も取り消されます。

署名:

computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>

使う状況:

  • 複数の非同期計算を一度に 1 つずつではなく同時に実行する必要があるが、それらが並列にスケジュールされていない場合。
  • 子計算と親計算の有効期間を関連付ける場合。

注意事項:

  • Async.StartChild で複数の計算を開始することは、それらを並列でスケジュール設定することと同じではありません。 計算を並列でスケジュールする場合は、Async.Parallelを使用します。
  • 親計算を取り消すと、開始したすべての子計算の取り消しがトリガーされます。

Async.StartImmediate

非同期計算を実行し、現在のオペレーティング システムのスレッドですぐに開始します。 これは、計算中に呼び出し元のスレッドで何かを更新する必要がある場合に便利です。 たとえば、非同期計算で UI を更新する必要がある場合 (進行状況バーを更新するなど) は、Async.StartImmediate を使用する必要があります。

署名:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

使う状況:

  • 非同期計算の途中に、呼び出し元スレッドで何かを更新する必要がある場合。

注意事項:

  • 非同期計算のコードは、それがスケジュールされている任意のスレッドで実行されます。 これは、そのスレッドになんらかの機密性がある場合 (UI スレッドなど) に問題になることがあります。 このような場合は、Async.StartImmediate を使用するのが不適切である可能性があります。

Async.StartAsTask

スレッド プールで計算を実行します。 計算が終了した (結果の生成、例外のスロー、またはキャンセル) 後、対応する状態で完了する Task<TResult> を返します。 キャンセル トークンが指定されていない場合は、既定のキャンセル トークンが使用されます。

署名:

computation: Async<'T> * ?taskCreationOptions: TaskCreationOptions * ?cancellationToken: CancellationToken -> Task<'T>

使う状況:

  • 非同期計算の結果を表すために Task<TResult> を生成する .NET API を呼び出す必要がある場合。

注意事項:

  • この呼び出しにより、追加の Task オブジェクトが割り当てられます。これにより、頻繁に使用される場合にオーバーヘッドが増加する可能性があります。

Async.Parallel

並列実行される一連の非同期計算をスケジュールし、指定された順序で結果の配列を生成します。 並列度は、maxDegreeOfParallelism パラメーターを指定することによって、必要に応じてチューニング/調整できます。

署名:

computations: seq<Async<'T>> * ?maxDegreeOfParallelism: int -> Async<'T[]>

使用するタイミング:

  • 一連の計算を同時に実行する必要があり、実行の順序に依存しない場合。
  • すべて完了するまでは、並列でスケジュールされた計算の結果が必要ない場合。

注意事項:

  • 結果として得られる値の配列には、すべての計算が完了した後にのみアクセスできます。
  • 計算は、最終的にスケジュールが設定されるたびに実行されます。 この動作は、実行順序に依存できないことを意味します。

Async.Sequential

渡された順序で実行される一連の非同期計算をスケジュールします。 最初の計算が実行されてから次に進みます。以下も同様です。 計算は並列に実行されません。

署名:

computations: seq<Async<'T>> -> Async<'T[]>

使用するタイミング:

  • 複数の計算を順番に実行する必要がある場合。

注意事項:

  • 結果として得られる値の配列には、すべての計算が完了した後にのみアクセスできます。
  • 計算は、この関数に渡される順序で実行されます。そのため、結果が返されるまでの時間が長くなる可能性があります。

Async.AwaitTask

指定された Task<TResult> が完了するまで待機してその結果を Async<'T> として返す非同期計算を返します

署名:

task: Task<'T> -> Async<'T>

使う状況:

  • F# の非同期計算内で Task<TResult> を返す .NET API を使用する場合。

注意事項:

  • 例外は、タスク並列ライブラリの規則に従って AggregateException にラップされます。この動作は、F# の非同期で一般的に例外が表面化する状況とは異なります。

Async.Catch

指定された Async<'T> を実行し、Async<Choice<'T, exn>> を返す非同期計算を作成します。 指定された Async<'T> が正常に完了した場合は、結果の値と共に Choice1Of2 が返されます。 完了前に例外がスローされた場合は、発生した例外と共に Choice2of2 が返されます。 多くの計算で構成される非同期計算で使用され、その計算の 1 つによって例外がスローされる場合、外側の計算は完全に停止されます。

署名:

computation: Async<'T> -> Async<Choice<'T, exn>>

使う状況:

  • 例外によって失敗する可能性があり、呼び出し元でその例外を処理する必要がある非同期処理を実行する場合。

注意事項:

  • 結合またはシーケンス処理された非同期計算を使用する場合、その "内側" の計算の 1 つによって例外がスローされると、外側の計算が完全に停止されます。

Async.Ignore

指定された計算を実行し、その結果を無視する非同期計算を作成します。

署名:

computation: Async<'T> -> Async<unit>

使う状況:

  • 結果が不要な非同期計算がある場合。 これは、非同期でないコード用の ignore 関数に似ています。

注意事項:

  • Async.Start、または Async<unit> を必要とする別の関数を使用するために Async.Ignore を使用する必要がある場合は、結果を破棄してよいかどうかを検討してください。 型シグネチャに適合させるためだけに結果を破棄することは避けてください。

Async.RunSynchronously

非同期計算を実行し、呼び出し元のスレッドでその結果を待機します。 計算によって例外が生成された場合は、それを伝達します。 この呼び出しによってブロックされます。

署名:

computation: Async<'T> * ?timeout: int * ?cancellationToken: CancellationToken -> 'T

使用するタイミング:

  • 必要に応じて、アプリケーション内で 1 回だけ (実行可能ファイルのエントリ ポイントで) 使用します。
  • パフォーマンスを気にせずに、他の一連の非同期操作を一度に実行する場合。

注意事項:

  • Async.RunSynchronously を呼び出すと、実行が完了するまで呼び出し元のスレッドがブロックされます。

Async.Start

スレッド プール内の unit を返す非同期計算を開始します。 完了するのを待ったり、例外の結果を確認したりすることはありません。 Async.Start で開始された入れ子になった計算は、それらを呼び出した親計算とは無関係に開始されます。その有効期間は親計算に関連付けられていません。 親計算が取り消されても、子計算は取り消されません。

署名:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

次の場合にのみ使用します。

  • 結果を生成しない、またはその処理を必要としない非同期計算がある。
  • 非同期計算がいつ完了するかを知る必要がない。
  • 非同期計算を実行するスレッドを考慮する必要がない。
  • 実行の結果として発生した例外を認識またはレポートする必要がない。

注意事項:

  • Async.Start で開始された計算によって発生した例外は、呼び出し元に伝達されません。 呼び出し履歴は完全にアンワインドされます。
  • Async.Start で開始された作業 (printfn の呼び出しなど) は、プログラムの実行のメイン スレッドに影響しません。

.NET との相互運用

async { } プログラミングを使用すると、.NET ライブラリ、または async/await スタイルの非同期プログラミングを使用する C# コードベースとの相互運用が必要になることがあります。 C# と、大部分の .NET ライブラリでは、Task<TResult>Task の型がコアの抽象化として使われるため、これにより F# の非同期コードの記述方法が変わる場合があります。

選択肢の 1 つは、task { } を使用して .NET タスクを直接記述する方法に切り替えることです。 または、Async.AwaitTask 関数を使用して .NET 非同期計算を待機することもできます。

let getValueFromLibrary param =
    async {
        let! value = DotNetLibrary.GetValueAsync param |> Async.AwaitTask
        return value
    }

Async.StartAsTask 関数を使用すると、非同期計算を .NET の呼び出し元に渡すことができます。

let computationForCaller param =
    async {
        let! result = getAsyncResult param
        return result
    } |> Async.StartAsTask

Task を使用する API (つまり、値を返さない .NET 非同期計算) を操作するには、Async<'T>Task に変換する関数の追加が必要な場合があります。

module Async =
    // Async<unit> -> Task
    let startTaskFromAsyncUnit (comp: Async<unit>) =
        Async.StartAsTask comp :> Task

Task を入力として受け取る Async.AwaitTask が既に存在します。 これと以前に定義された startTaskFromAsyncUnit 関数を使用すると、F# の非同期計算を開始し、そこからの Task 型を待機できます。

.NET タスクを直接 F# で記述する

F# では、task { } を使用してタスクを直接記述できます。次に例を示します。

open System
open System.IO

/// Perform an asynchronous read of a file using 'task'
let printTotalFileBytesUsingTasks (path: string) =
    task {
        let! bytes = File.ReadAllBytesAsync(path)
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    let task = printTotalFileBytesUsingTasks "path-to-file.txt"
    task.Wait()

    Console.Read() |> ignore
    0

この例では、printTotalFileBytesUsingTasks 関数の型は string -> Task<unit> です。 関数を呼び出すと、タスクの実行が開始されます。 task.Wait() の呼び出しは、タスクが完了するまで待機します。

マルチスレッドとの関係

この記事全体でスレッド処理について触れていますが、覚えておくべき 2 つの重要な点があります。

  1. 現在のスレッドで明示的に開始されている場合を除き、非同期計算とスレッド間にアフィニティはありません。
  2. F# での非同期プログラミングは、マルチスレッドに対する抽象化ではありません。

たとえば、作業の性質に応じて、実際には呼び出し元のスレッドで計算が実行される場合があります。 また、計算がスレッド間を "ジャンプ" して、それらを短時間借用し、"待機中" (ネットワーク呼び出しが転送中の場合など) の期間の合間に有用な作業を行うことができます。

F# を使用すると、現在のスレッドで (または明示的に現在のスレッド以外で) 非同期計算を開始できますが、非同期性は通常、特定のスレッド戦略に関連付けられていません。

関連項目