非同步編程(Asynchronous Programming)
非同步編程與我們所看過的其他並行編程形式有一些不同,討論的其他主題可以有大量的線程並行運行,可以完全利用系統中可用的處理器;而在非同步編程中,需要避免阻塞線程,我們在這一章的第一節“線程、記憶體、鎖定和阻塞”中已經對阻塞線程的概念有所瞭解了。阻塞的線程是不能工作的線程,因為它需要等待其他任務的完成;線程等待的通常任務是作業系統執行的輸入輸出,但有時也可能是等待鎖,因此會進行臨界區。線程是相對昂貴的資源,每個線程分配 1 MB 的棧(stack),以及作業系統核心為管理大量線程而產生的其他相關消耗。在效能至上的代碼中,保持阻塞線程數量在較低的水平上是至關重要的;理論上,只要做到有多少的處理器,就有多少的線程,就不會有阻塞的線程了。
注意
為了概要地瞭解一下用這些方法能夠達到什麼樣的結果,可以看一下Amanda Laucher 2009 年在 Lang.NET 上的演講,她講解了如何使用F# 工作流程並行化 C# 的程式,以及實現了一些令人印象深刻的結果:www.langnetsymposium.com/2009/speakers.aspx。
在這一節,我們將學習如何使用 .NET 架構的非同步編程模型(asynchronous programming model)避免輸入輸出期間線程的阻塞。非同步編程模型的意思是,在有關流的類上,使用一對Begin/End 方法,比如 BeginRead/EndRead;典型地,這一對方法執行某種輸入輸出任務,比如讀檔案。這種編程方法的名聲很不好,因為需要找到能在Begin/End 調用之間保持狀態的好方法。這一節我們直接討論編程模型,相反,將看一下 F# 的一個功能,非同步工作流程(asynchronous workflows),看如何用它來避免在其他 .NET 語言中與使用非同步編程模型相關的工作。為了更詳細瞭解非同步編程模型,以及使用的困難,請參考Jeffrey Richter 在 MSDN 上的文章《Asynchronous Device Operations》(http://msdn.microsoft.com/en-us/magazine/cc163415.aspx)。
非同步工作流程不是由 .NET 的非同步編程模型所專用。在下一節“訊息傳遞”中,我們將學習如何使用這些工作流程與 F# 的郵箱(mailboxes)來協調大量不同的任務,它可以等待任務完成而不阻塞線程。
瞭解 F# 中非同步工作流程的第一步是瞭解它的文法。建立非同步工作流程,使用一元文法(monadic syntax),同我們在第三章中見過的序列運算式相似;基本文法使用關鍵字async,加用大括弧括起來的工作流程運算式:async { ... }。簡單的工作流程序像這樣使用工作流程:
open System.IO
// a function to read a text fileasynchronusly
let readFile file =
async{ let! stream = File.AsyncOpenText(file)
let! fileContents = stream.AsyncReadToEnd()
return fileContents }
// create an instance of the workflow
let readFileWorkflow = readFile"mytextfile.txt"
// invoke the workflow and get the contents
let fileContents = Async.RunSynchronouslyreadFileWorkflow
編譯這個程式,需要引用 FSharp.PowerPack.dll。程式中的 readFile 函數建立一個工作,非同步讀檔案,然後返迴文件的內容;接下來,建立工作流程的執行個體 readFileWorkflow;最後,運行這個工作流程,獲得檔案的內容。很重要的一點,是要理解,只調用 readFile 函數並不真正讀檔案;相反,它報建工作流程的新執行個體,然後,運行這個工作流程,去執行讀檔案的任務;Async.RunSynchronously 函數真正負責運行工作流程。工作流程執行個體是一種小型的資料結構,有點像一段小程式,能夠解釋一些要做的工作。
關注這個樣本最重要的是 let 後面的驚嘆號(let!),通常讀作let bang。工作流程/一元文法可以為 let! 賦予不同的含義。在非同步工作流程中,它表示將要發生的非同步作業;在非同步作業發生期間工作流程停止運行;線上程池中插入一個回呼函數,當這個非同步作業完成時被調用,如果發出原始調用的線程沒有空閑,就可能發生在不同的線程上;非同步呼叫之後,原始線程被釋放,可以繼續其他工作。
你可能已經注意到,let! 是用在有 Async首碼的一些專用方法,在 FSharp.PowerPack.dll 中,這些函數被定義成類型擴增(type augmentations),它與 C# 的擴充方法(extension
methods)等價,這些方法處理對 Begin/End方法對的調用。如果沒有現成的 Async 方法,我們自己建立也很簡單,使用 Async.Primitive 函數和 Begin/End 方法對。
簡單的步驟可能像這樣:
第一步:主程式線程啟動開啟檔案流的進程,線上程池中插入回呼函數,當這個操作完成時使用,而這個線程現在空閑可以繼續做其他工作;
第二步:當檔案流已經開啟,線程池線程(A thread pool thread)被啟用,開始讀檔案的內容,線上程池中插入回呼函數,當這個操作完成時使用。因為它是一個線程池線程,所以,它將返回到線程池;
第三步:當已經完成讀檔案,線程池線程被啟用,將返回從檔案中讀到的文本資料,並返回到線程池;
第四步:因為我們已經使用了 sync.RunSynchronously 函數,主程式線程將等待工作流程的結果,接收檔案的內容。
在這個簡單的樣本中,你還可能會發現一點缺陷,沒有阻塞主程式線程等待輸入輸出,但是,因為我們等待非同步工作流程完成,也就阻塞了主程式線程,直到輸入輸出完成。換一種方式,在它自己的[ 線程嗎? ]上運行非同步工作流程並等待結果,就沒有或幾乎沒有優勢了。然而,並行運行幾個工作流程相當簡單;同時運行幾個工作流程有一個明顯的優勢,因為原始線程在它啟動了第一個非同步任務之後,不會被阻塞,就是說,它是閒置,可以繼續運行其他非同步任務。
要說明這個也很簡單,我們把原來的樣本作一點修改,不是讀一個檔案,而是讀三個檔案。而把這個與同步版本的程式作一下比較,有助於發現它們之間的差別。我們先看一下同步版本:
open System
open System.IO
open System.Threading
let print s =
lettid = Thread.CurrentThread.ManagedThreadId
Console.WriteLine(sprintf"Thread %i: %s" tid s)
let readFileSync file =
print(sprintf "Beginning file %s" file)
letstream = File.OpenText(file)
letfileContents = stream.ReadToEnd()
print(sprintf "Ending file %s" file)
fileContents
// invoke the workflow and get the contents
let filesContents =
[|readFileSync "text1.txt";
readFileSync"text2.txt";
readFileSync"text3.txt"; |]
這個程式相當簡單,其中還有一些調試代碼,顯示處理檔案的開始與結束。現在再看一下非同步版本:
open System
open System.IO
open System.Threading
let print s =
lettid = Thread.CurrentThread.ManagedThreadId
Console.WriteLine(sprintf"Thread %i: %s" tid s)
// a function to read a text fileasynchronusly
let readFileAsync file =
async{ do print (sprintf "Beginning file %s" file)
let! stream = File.AsyncOpenText(file)
let! fileContents = stream.AsyncReadToEnd()
do print (sprintf "Ending file %s" file)
return fileContents }
let filesContents =
Async.RunSynchronously
(Async.Parallel[ readFileAsync "text1.txt";
readFileAsync "text2.txt";
readFileAsync "text3.txt"; ])
另外,這個版本也包含了一些調試代碼,因此,可以看到程式是如何啟動並執行。最大的改變是現在使用了 Async.Parallel函數,把幾個工作流程組合成一個工作流程。這樣,當第一個線程完成處理第一個非同步呼叫之後,就空閑了,可以繼續處理其他工作流程。看看下面兩個程式的運行結果就知道了:
同步結果:
Thread 1: Beginning file text1.txt
Thread 1: Ending file text1.txt
Thread 1: Beginning file text2.txt
Thread 1: Ending file text2.txt
Thread 1: Beginning file text3.txt
Thread 1: Ending file text3.txt
非同步結果:
Thread 3: Beginning file text1.txt
Thread 4: Beginning file text2.txt
Thread 3: Beginning file text3.txt
Thread 4: Ending file text2.txt
Thread 4: Ending file text1.txt
Thread 4: Ending file text3.txt
兩組結果完全不同。對於同步結果,每一個 Beginning file 後面跟一個 Ending file,且出現在同一個線程中;第二種情況下,所有 Beginningfile 的執行個體同時發生,且在兩個不同的線程中,這是因為每一個線程完成了非同步作業以後,它就空閑了可以繼教啟動另一個操作。輸入輸出一旦完成之後,Ending file 就發生了。