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 語言函式有更多元素。 就概念上而言,此語言函式會執行下列動作:

  1. 使用 Seq.map 將命令列引數轉換成 Async<unit> 計算的序列。
  2. 建立 Async<'T[]>,讓它排程 printTotalFileBytes 計算的執行時間,使其與自身平行執行。
  3. 建立會執行平行計算並忽略其結果的 Async<unit>,也就是 unit[]
  4. 透過 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()

與多執行緒的關聯性

雖然本文各處都提到執行緒,但最重要的是以下兩點:

  1. 除非在目前的執行緒上明確地啟動,否則非同步計算與執行緒之間並無親和性。
  2. F# 的非同步程式設計不是多執行緒的抽象概念。

例如,視工作的性質而定,計算可能真的會在其呼叫者的執行緒上執行。 計算也可以在執行緒之間「跳躍」,這讓計算可以借用執行緒的一小段時間,以在各個「等候」(例如在網路呼叫傳輸時) 期間之間的空檔執行有用的工作。

雖然 F# 提供了一些在目前執行緒上 (或明確可知不在目前執行緒上) 啟動非同步計算的功能,但非同步通常與特定的執行緒策略無關。

另請參閱