F# 中的非同步程式設計
非同步程式設計因為許多不同的原因,而成為現代應用程式不可或缺的機制。 大多數開發人員會遇到兩個主要的使用案例:
- 提供伺服器流程,此流程可以處理大量同時傳入的要求,並在要求處理作業等待系統或該流程外部服務的輸入內容時,盡可能減少佔用系統資源的情況
- 在進行背景工作的同時,維護回應式 UI 或主執行緒
雖然背景工作通常涉及多個執行緒的使用率,但是請務必將非同步和多執行緒的概念分開考量。 事實上,它們是兩個不同的概念,彼此互不相關。 本文詳細說明這兩個截然不同的概念。
非同步定義
上一點提到非同步與多個執行緒的使用率無關,現在我們要進一步說明這個部分。 以下介紹三個概念,它們有時相關,但嚴格來說,卻又差異甚大:
- 並行:多個計算在重疊的時間期間內執行的情況。
- 平行處理原則:多個計算或單一計算的多個部分同時執行的情況。
- 非同步:一或多個計算可以和主要程式流程分開執行的情況。
雖然這三個都是正交的概念,但很容易混淆,尤其是在同時使用的時候。 舉例來說,您可能需要平行執行多個非同步計算。 此關聯性不代表平行處理原則或非同步彼此相關。
如果您細想「非同步」(asynchronous) 的詞源,會發現此字包含兩個部分:
- 「a」表示「不」。
- 「synchronous」表示「同時」。
如果您將這兩個詞彙放在一起時,您會發現「asynchronous」代表「不同時」。 介紹完畢 此定義並未涉及並行或平行處理原則, 在實務上也是如此。
就實際情況而言,系統會將 F# 中非同步計算的執行時間與主程式流程的執行時間分開。 獨立執行不代表並行或平行處理原則,也不代表計算總是在背景執行。 事實上,非同步計算甚至可以同步執行,這取決於計算的性質以及執行計算所在的環境。
本文的最大重點是讓您了解非同步計算與主程式流程無關。 雖然沒有什麼確切的定論說明應執行非同步計算的時機或方式,但是有一些方法可以協調和排程非同步計算。 本文的其餘部分將探討 F# 非同步的核心概念,以及如何使用 F# 內建的型別、語言函式和運算式。
核心概念
在 F# 中,非同步程式設計是以兩個核心概念為中心,包含非同步計算和工作。
- 具有
async { }
運算式的Async<'T>
型別代表可組合的非同步計算,啟動後可形成工作。 - 具有
task { }
運算式的Task<'T>
型別代表執行中的 .NET 工作。
一般而言,如果與使用工作的 .NET 程式庫交互操作,而且不依賴非同步程式碼尾呼叫或隱含取消語彙基元傳播,則應該考慮在新的程式碼中使用 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>
,可作為非同步執行工作的規格。 此動作會在其主體中呼叫 Async.AwaitTask
,以將 ReadAllBytesAsync 的結果轉換成適當的型別。
另一個重要的程式碼是呼叫 Async.RunSynchronously
。 這是其中一個非同步模組的 starting 語言函式,如果您想要 F# 的非同步計算確實執行,就必須呼叫此語言函式。
這是與 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>
計算的序列。 - 建立
Async<'T[]>
,讓它排程printTotalFileBytes
計算的執行時間,使其與自身平行執行。 - 建立會執行平行計算並忽略其結果的
Async<unit>
,也就是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
這會讓 printTotalFileBytes
依照 argv
的元素順序來執行計算,而非以平行方式執行。 系統會在前一個計算執行完成後才排程每個接續作業執行的時間,因此要確保每個計算作業並無彼此重疊。
重要的非同步模組語言函式
當您在 F# 中撰寫非同步程式碼時,您通常會和為您處理計算排程的架構互動。 不過情況並非總是如此,所以最好了解可用來排程非同步工作的各種語言函式。
F# 非同步計算是工作的規格,而非已經在執行的工作,所以必須以 starting 語言函式來明確啟動。 有許多在不同情境下都很有幫助的非同步啟動方法 (英文)。 下一節會說明一些較常見的 starting 語言函式。
Async.StartChild
在非同步計算中啟動子計算。 這會同時執行多個非同步計算。 子計算與父計算共用取消語彙基元, 因此取消父計算,也會取消子計算。
簽名:
computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>
使用時機:
- 若您想要同時執行多個非同步計算,而非一次執行一個,但並未將這些計算排程為平行執行。
- 若您想要讓子計算的存留期與父計算的存留期建立關聯。
需注意下列情況:
- 使用
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
與引發的例外狀況。 如果針對許多計算所組成的非同步計算組使用此語言函式,且其中一個計算擲回例外狀況,該計算組中包含的計算將完全停止。
簽名:
computation: Async<'T> -> Async<Choice<'T, exn>>
使用時機:
- 若您正在執行可能會因為例外狀況而導致失敗的非同步工作,且您想要在呼叫者中處理該例外狀況。
需注意下列情況:
- 在使用合併或已排列執行順序的非同步計算組時,如果當中的一個「內部」計算擲回例外狀況,所有包含的計算便會完全停止。
Async.Ignore
此語言函式會建立非同步計算,該計算雖然會執行指定的計算,但會捨棄結果。
簽名:
computation: Async<'T> -> Async<unit>
使用時機:
- 如有不需要結果的非同步計算。 此類計算與非同步程式碼的
ignore
語言函式類似。
需注意下列情況:
- 如果您因為想要使用
Async.Start
,或另一個需要Async<unit>
的語言函式,而必須使用Async.Ignore
,請考慮是否可以接受捨棄結果。 請不要為了符合型別特徵標記而捨棄結果。
Async.RunSynchronously
此語言函式會執行非同步計算,並等候呼叫執行緒的結果。 如果計算暫止例外狀況,便會傳播例外狀況。 此呼叫正在封鎖。
簽名:
computation: Async<'T> * ?timeout: int * ?cancellationToken: CancellationToken -> 'T
使用時機:
- 如果您需要此呼叫,請在應用程式中,於可執行檔的進入點使用此呼叫,但只能使用一次。
- 若您不在乎效能,且想要一次執行一組其他非同步作業。
需注意下列情況:
- 呼叫
Async.RunSynchronously
會封鎖呼叫執行緒,直到執行完成。
Async.Start
此語言函式可啟動會在執行緒集區中傳回 unit
的非同步計算, 不會等候該計算完成和/或觀察例外狀況的結果。 透過 Async.Start
來啟動的巢狀計算與呼叫這些計算的父計算,兩者為分開啟動。巢狀計算的存留期不受任何父計算的影響, 因此取消父計算,不會使任何子計算遭到取消。
簽名:
computation: Async<unit> * ?cancellationToken: CancellationToken -> unit
只在以下情況使用:
- 您有一個非同步計算,此計算不會產生結果和/或需要處理結果。
- 您不需要知道非同步計算何時會完成。
- 您不需在乎非同步計算在哪個執行緒上執行。
- 您不需注意或報告執行所產生的例外狀況。
需注意下列情況:
- 透過
Async.Start
啟動的計算所引發的例外狀況不會傳播給呼叫者。 系統將完全回溯呼叫堆疊。 - 任何透過
Async.Start
啟動的工作 (例如呼叫printfn
) 都不會對程式執行的主執行緒造成影響。
與 .NET 交互操作
如果要使用 async { }
程式設計,您可能需要與 .NET 程式庫或 C# 程式碼基底交互操作。這些程式庫或程式碼基底使用 async/await 樣式的非同步程式設計。 由於 C# 和大部分的 .NET 程式庫都使用 Task<TResult> 和 Task 型別作為其核心抽象概念,因此可能會變更您撰寫 F# 非同步程式碼的方式。
一種選項是改為直接使用 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 (即不傳回值的 .NET 非同步計算) 的 API,您可能需要新增可以將 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()
。
與多執行緒的關聯性
雖然本文各處都提到執行緒,但最重要的是以下兩點:
- 除非在目前的執行緒上明確地啟動,否則非同步計算與執行緒之間並無親和性。
- F# 的非同步程式設計不是多執行緒的抽象概念。
例如,視工作的性質而定,計算可能真的會在其呼叫者的執行緒上執行。 計算也可以在執行緒之間「跳躍」,這讓計算可以借用執行緒的一小段時間,以在各個「等候」(例如在網路呼叫傳輸時) 期間之間的空檔執行有用的工作。
雖然 F# 提供了一些在目前執行緒上 (或明確可知不在目前執行緒上) 啟動非同步計算的功能,但非同步通常與特定的執行緒策略無關。