共用方式為


F 中的異步程序設計#

異步程式設計是新式應用程式因各種原因而不可或缺的機制。 大部分開發人員會遇到兩個主要使用案例:

  • 呈現可服務大量並行連入要求的伺服器進程,同時將要求處理所佔用的系統資源降至最低,而要求處理會等候來自該程式外部系統或服務的輸入
  • 在同時進行背景工作時維護回應式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 式有更多元素。 在概念上,它會執行下列動作:

  1. 使用Async<unit>Seq.map將命令列參數轉換成計算序列。
  2. 建立Async<'T[]>,當其執行時,會並行排程並執行printTotalFileBytes計算。
  3. 創建一個Async<unit>來執行平行計算並忽略其結果(即unit[])。
  4. 明確執行整個計算過程 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() 以等待任務完成。

多線程關聯性

雖然本文中會提及線程處理,但請記住兩個重要事項:

  1. 除非在目前執行緒上明確啟動,否則異步計算與執行緒之間沒有親和性。
  2. F# 中的異步程序設計不是多線程的抽象概念。

例如,計算實際上可能會在呼叫端的線程上執行,視工作的性質而定。 計算也可以在線程之間「跳躍」,在「等待」期間借用它們一小段時間,以執行有用的工作(例如當網路呼叫正在傳輸中時)。

雖然 F# 提供一些功能來在目前執行緒上啟動異步計算(或者明確不在目前執行緒上執行),但異步一般與特定的執行緒策略無關。

另請參閱