非同期プログラミングは、さまざまな理由で最新のアプリケーションに不可欠なメカニズムです。 ほとんどの開発者が遭遇する主なユース ケースは 2 つあります。
- 大量の同時受信要求を処理できるサーバー プロセスを提示し、要求処理がそのプロセスの外部にあるシステムまたはサービスからの入力を待機している間に占有されるシステム リソースを最小限に抑える
- バックグラウンド作業を同時に進行しながら、応答性の高い UI またはメイン スレッドを維持する
多くの場合、バックグラウンド作業には複数のスレッドの使用が含まれますが、非同期とマルチスレッドの概念を個別に検討することが重要です。 実際、それらは別々の懸念事項であり、一方は他方を意味しません。 この記事では、個別の概念について詳しく説明します。
非同期の定義
前のポイント (非同期性は複数のスレッドの使用率に依存しない) は、もう少し詳しく説明する価値があります。 関連する概念は 3 つありますが、互いに厳密に独立しています。
- 並行 処理;複数の計算が重複する期間に実行される場合。
- 平行;1 つの計算の複数の計算または複数の部分がまったく同時に実行される場合。
- 非同期;1 つ以上の計算がメイン プログラム フローとは別に実行できる場合。
3 つはすべて直交概念ですが、特に一緒に使用する場合は簡単に結合できます。 たとえば、複数の非同期計算を並列で実行する必要がある場合があります。 この関係は、並列処理または非同期性が互いを意味するわけではありません。
"非同期" という単語の語源を考えると、次の 2 つの要素が関係します。
- "a"、"not" を意味します。
- "同期"。"同時に" を意味します。
これら 2 つの用語をまとめると、"非同期" とは "同時にではない" ことを意味することがわかります。 それです! この定義では、コンカレンシーや並列処理の影響はありません。 これは実際にも当てはまります。
実際には、F# での非同期計算は、メイン プログラム フローとは別に実行するようにスケジュールされています。 この独立した実行は、コンカレンシーや並列処理を意味するものではなく、計算が常にバックグラウンドで行われることを意味するものではありません。 実際には、計算の性質や計算の実行環境によっては、非同期計算を同期的に実行することもできます。
必要な主なポイントは、非同期計算がメイン プログラム フローから独立していることです。 非同期計算をいつ、どのように実行するかについての保証はほとんどありませんが、それらを調整およびスケジュールする方法がいくつかあります。 この記事の残りの部分では、F# 非同期の主要な概念と、F# に組み込まれている型、関数、および式の使用方法について説明します。
主要な概念
F# では、非同期プログラミングは、非同期計算とタスクという 2 つの主要な概念を中心にしています。
一般に、タスクを使用する .NET ライブラリと相互運用する場合や、非同期コード のテールコールや暗黙的なキャンセル トークンの伝達に依存しない場合は、新しいコードでasync {…}
よりもtask {…}
を使用することを検討する必要があります。
非同期の主要な概念
次の例では、"async" プログラミングの基本的な概念を確認できます。
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>
を返します。
ReadAllBytesAsyncの結果を適切な型に変換するAsync.AwaitTask
を本体で呼び出します。
もう 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
関数にはさらにいくつかの要素があります。 概念的には、次の処理が行われます。
-
Seq.map
を使用して、コマンド ライン引数を一連のAsync<unit>
計算に変換します。 - 実行時に
printTotalFileBytes
計算を並列にスケジュールして実行するAsync<'T[]>
を作成します。 - 並列計算を実行し、その結果 (
unit[]
) を無視するAsync<unit>
を作成します。 -
Async.RunSynchronously
を使用して構成された全体的な計算を明示的に実行し、完了するまでブロックします。
このプログラムを実行すると、 printTotalFileBytes
はコマンド ライン引数ごとに並列で実行されます。 非同期計算はプログラム フローとは無関係に実行されるため、情報を出力して実行を完了する順序は定義されていません。 計算は並列にスケジュールされますが、実行順序は保証されません。
非同期計算のシーケンス
Async<'T>
は既に実行されているタスクではなく、作業の仕様であるため、より複雑な変換を簡単に実行できます。 非同期計算のセットをシーケンス処理して、順番に実行する例を次に示します。
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
使用するタイミング:
- 非同期計算の途中で呼び出し元スレッドで何かを更新する必要がある場合。
注意が必要な内容:
- 非同期計算のコードは、スケジュールされたスレッド 1 で実行されます。 これは、そのスレッドが 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
次の場合にのみ使用します。
- 結果が得られない、または 1 つの処理を必要としない非同期計算がある。
- 非同期計算がいつ完了するかを知る必要はありません。
- 非同期計算が実行されるスレッドは気にしません。
- 実行の結果として発生する例外を認識したり、報告したりする必要はありません。
注意が必要な内容:
-
Async.Start
で開始された計算によって発生した例外は、呼び出し元に反映されません。 呼び出し履歴は完全に巻き戻されます。 -
Async.Start
で開始された作業 (printfn
の呼び出しなど) は、プログラムの実行のメイン スレッドに影響を与えません。
.NET との相互運用
async { }
プログラミングを使用する場合は、非同期/待機スタイルの非同期プログラミングを使用する .NET ライブラリまたは 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 型を開始および待機できます。
F で .NET タスクを直接記述する#
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 つあります。
- 現在のスレッドで明示的に開始されていない限り、非同期計算とスレッドの間にアフィニティはありません。
- F# での非同期プログラミングは、マルチスレッドの抽象化ではありません。
たとえば、計算は、作業の性質に応じて、呼び出し元のスレッドで実際に実行される場合があります。 また、計算によってスレッド間が "ジャンプ" し、"待機中" の期間 (ネットワーク呼び出しが転送中など) の間に役立つ作業を行うために少しの時間借用することもできます。
F# には、現在のスレッドで非同期計算を開始する機能 (または現在のスレッド上にない明示的な機能) が用意されていますが、通常、非同期は特定のスレッド戦略には関連付けされません。
こちらも参照ください
.NET