異步程式設計是新式應用程式因各種原因而不可或缺的機制。 大部分開發人員會遇到兩個主要使用案例:
- 呈現可服務大量並行連入要求的伺服器進程,同時將要求處理所佔用的系統資源降至最低,而要求處理會等候來自該程式外部系統或服務的輸入
- 在同時進行背景工作時維護回應式UI或主線程
雖然背景工作通常涉及使用多個線程,但請務必個別考慮異步和多線程的概念。 事實上,它們是個別的擔憂,一個並不代表另一個。 本文將更詳細地說明個別的概念。
非同步的定義
前一點:「異步執行不依賴於多個執行緒的使用」,值得進一步解釋。 有時有三個概念相關,但嚴格獨立於彼此:
- 併發;當多個計算在重疊的時間週期中執行時。
- 並行; 當多個計算或單一計算的多個部分在完全相同的時間點同時執行時。
- 異步;當一或多個計算可以與主要程式流程分開執行時。
這三個概念都是正交的概念,但很容易混為一體,尤其是在一起使用時。 例如,您可能需要平行執行多個異步計算。 這種關係並不意味著平行處理和異步是相互暗示的。
如果您考慮「異步」一詞的詞源,則涉及兩個部分:
- 「a」,作為前綴,表示“不”。
- 同步,意思是“同時”。
當您將這兩個詞彙放在一起時,您會看到「非同步」表示「不同時發生」。 就是這樣! 此定義中沒有任何並行或平行處理的意味。 在實務上也是如此。
實際上,F# 中的異步計算會被排程成獨立於主要程式流程來執行。 此獨立執行並不表示並行或平行處理原則,也不表示計算一律發生在背景中。 事實上,異步計算甚至可以同步執行,視計算的性質和計算執行的環境而定。
您應該擁有的主要要點是異步計算與主要程式流程無關。 雖然有關於異步計算執行時機或方式的保證很少,但有一些方法來協調和排程它們。 本文的其餘部分會探索 F# 異步的核心概念,以及如何使用 F# 內建的類型、函式和運算式。
核心概念
在 F# 中,異步程式設計以兩個核心概念為中心:異步計算和工作。
-
Async<'T>型別擁有async { }表達式,代表一種可組合的異步計算,可啟動以形成任務。 -
Task<'T>類型,具有task { }表達式,表示的執行中的 .NET 工作。
一般而言,如果您與使用 Tasks 的 .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。 這是其中一個 Async 模組啟動函式,如果您想要實際執行 F# 異步計算,則需要呼叫此函式。
這是 C#/Visual Basic 程式設計樣式 async 的基本差異。 在 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 式有更多元素。 在概念上,它會執行下列動作:
- 使用
Async<unit>與Seq.map將命令列參數轉換成計算序列。 - 建立
Async<'T[]>,當其執行時,會並行排程並執行printTotalFileBytes計算。 - 創建一個
Async<unit>來執行平行計算並忽略其結果(即unit[])。 - 明確執行整個計算過程
Async.RunSynchronously,並封鎖執行直到完成。
當此程式執行時, printTotalFileBytes 會針對每個命令行自變數平行執行。 因為異步計算是獨立於程式流程執行的,所以它們列印資訊和完成執行的順序是未定義的。 計算會以平行方式排程,但不保證其執行順序。
序列化異步計算
因為 Async<'T> 是工作的規格,而不是已經執行的工作,因此您可以輕鬆地執行更複雜的轉換。 以下範例會排序一組 Async 計算,使其逐一執行。
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# 異步計算是工作的 規格 ,而不是已經執行的工作表示法,所以必須使用啟動函式明確啟動它們。 有許多 異步起始方法 適用於不同的情境。 下一節說明一些較常見的啟動函式。
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>
使用時機:
- 當您需要呼叫一個 .NET API,其產生的 Task<TResult> 代表異步計算的結果時。
要注意的:
- 此呼叫會配置額外的
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>
使用時機:
- 當您使用 .NET API 並在 F# 異步計算中獲取返回值 Task<TResult> 時。
要注意的:
- 例外狀況會依照工作平行庫的慣例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.Ignore,因為您想要使用Async.Start或其他需要Async<unit>的功能,請考慮捨棄結果是否可以接受。 請避免僅為了符合類型簽章而捨棄結果。
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計算所引發的例外狀況不會傳播至呼叫端。 呼叫堆疊將會完全解開。 - 任何以 開頭
printfn的工作(例如呼叫Async.Start),都不會影響程序執行的主要線程。
與 .NET 互作
如果使用 async { } 程式設計,您可能需要與使用 async/await 風格非同步程式設計的 .NET 程式庫或 C# 程式碼庫進行互操作。 因為 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 API(即不會傳回值的 .NET 異步計算),您可能需要新增一個將 Async<'T> 轉換為 Task 的函式:
module Async =
// Async<unit> -> Task
let startTaskFromAsyncUnit (comp: Async<unit>) =
Async.StartAsTask comp :> Task
已經有一個能接收 Async.AwaitTask 做為輸入的 Task 。 透過這個 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# 提供一些功能來在目前執行緒上啟動異步計算(或者明確不在目前執行緒上執行),但異步一般與特定的執行緒策略無關。