本文介紹非同步程式設計的基本思想和語法。在程式處理裡,程式基本上有兩種處理方式:同步和非同步。對於有些新手,甚至認為「同步」是同時進行的意思,這顯然是錯誤的。
同步的基本意思是:程式一個個執行方法,或者說在方法呼叫上,fun1(), fun2(), fun3(),fun4().. 按順序呼叫,而非同步的意思是:方法不是按順序執行,可能fun2執行的時間比較長
那就先執行fun3,fun4。等執行完了fun2 在執行後面的fun1,fun6,fun7..., 很顯然,非同步程式設計比同步程式設計複雜很多,因為他涉及到執行緒的同步。
注意:對於單核CPU來說,任一時刻只能執行一條指令,對於這種微觀觀點,我們不用太過於深究,因為作業系統會幫助我們排程。換句話說,我們一邊列印word檔案,一邊聽歌,一邊寫字
雖然我們感覺是「同時」進行的,但是其實是CPU是在後臺不停的幫助我們切換程序,只是CPU切換的速度太快了,讓我們感覺我們是在「同時」做很多件事。
(一)基本非同步範例
下面程式碼演示了一個基本上非同步程式:(程式使用VS2022+.NET 7.0 開發的)
(1)HandleFileAsync() 表示這是一個非同步的方法,方法名稱前有一個 await 關鍵字。
作為一個約定,方法總是以Async結尾,這樣,使用者看到這個方法就知道了這是一個非同步方法,這僅僅是方法名稱的一個約定,不加Async不影響使用。
(2)在HandleFileAsync 方法裡,模擬執行一些費時的操作。
(3)在HandleFileAsync 執行期間,不會阻塞主執行緒,現在輸入字串 123 ,系統會顯示出入的結果。
(4)在非同步方法執行完畢後,返回主執行緒,輸出計數的結果。
using System; using System.IO; using System.Threading.Tasks; class Program { public static void Main() { // Part 1: 開始處理大檔案檔案 Task<int> task = HandleFileAsync(); // 在檔案處理前,把控制權交給控制檯 // 讓使用者輸入一些文字 Console.WriteLine("請耐心等待,系統正在處理檔案," +" 但是此時,你可以輸入一些字母,回車後顯示"); // 在檔案處理時,同時讀取你的輸入 string line = Console.ReadLine(); Console.WriteLine("你剛剛輸入的是: " + line); // Part 3: 等候處理結果 // 顯示處理結果 task.Wait(); var x = task.Result; Console.WriteLine("計數: " + x); Console.WriteLine("程式執行完畢!"); Console.ReadLine(); } static async Task<int> HandleFileAsync() { string file = @"C:\qmx\token.txt"; // Part 2: 下面開始處理大檔案 Console.WriteLine("檔案處理開始"); int count = 0; // 讀取檔案 using (StreamReader reader = new StreamReader(file)) { string v = await reader.ReadToEndAsync(); // 處理資料 count += v.Length; // 這裡是模擬程式碼,並沒有實際的意義, 讓程式執行1000萬次, // 純粹是模擬這是一個耗時的操作 for (int i = 0; i < 1000000; i++) { int x = v.GetHashCode(); if (x == 0) { count--; } } } Console.WriteLine("檔案處理結束"); return count; } }
下面顯示的是執行結果
當然上面後面的程式碼可以簡寫為 var x =await task.Result;
(二)執行緒阻塞(死鎖)
在上面方法裡,必須小心的呼叫 Wait方法,因為處理不好,很容易發生任務阻塞。 Stephen Cleary 曾經給了一個典型的例子:見
https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html
想象一下,我們有一個winForm應用程式,裡面有一個Button,在Button的點選事件裡,我們呼叫 HttpClient 的 GetStringAsync 方法獲取返回的JSON字串,然後把字串顯示在文字方塊裡。
為此,我們編寫了如下程式碼:
// Button的點選事件 public void Button1_Click(...) { //獲取Web返回的字串 var jsonTask = GetJsonAsync(...); //把字串顯示在文字方塊裡 textBox1.Text = jsonTask.Result; } public static async Task<JObject> GetJsonAsync(Uri uri) { // 呼叫 HttpClient 的 GetStringAsync 方法獲取JSON using (var client = new HttpClient()) { var jsonString = await client.GetStringAsync(uri); return JObject.Parse(jsonString); } }
現在你執行上面的程式碼,當你點選按鈕時,你會發現程式沒有出現你所想要的結果:因為程式被卡死了,根本無法進行其他操作。
除了終止應用程式,你別無選擇。為什麼會發生什麼死鎖現象呢?
為了讓通俗解釋死鎖看下面一個例子:假設我們有一把藍鑰匙,可以開啟一扇藍色門;以及一把紅鑰匙,可以開啟一扇紅色門。兩把鑰匙被儲存在一個皮箱裡。同時我們定義六種行為:獲取藍鑰匙,開啟藍色門,歸還藍鑰匙,獲取紅鑰匙,開啟紅色門,歸還紅鑰匙。如下圖:你可以把6個行為理解為函數裡6個方法 (以下內容改寫自知乎)
但是,當非同步調動時,每個方法順序就不那麼確定了,就可能出現如下這個情況
可以看到,當兩個執行緒都執行到第三步的時候,執行緒A在等執行緒B歸還紅鑰匙,執行緒B在等執行緒A歸還藍鑰匙,因而兩個執行緒都永遠卡在那裡無法前進。
這就是形成了死鎖。
理解了上面的死鎖,回頭再來看為什麼winForm裡產生了死鎖,主執行緒呼叫非同步方法返回的結果,被告知方法未完成,因此主執行緒在等待方法完成。
當非同步方法完成後,把自己狀態告知主執行緒已經Compled時,但是主執行緒一直在繁忙狀態,他在等待任務完成,因此,發生了死鎖。
這告訴我們在非同步程式設計時,要特別需要注意死鎖的問題。作為一個簡單的解決方法:只要加一個await 非同步就可以了
public async void Button1_Click(...) { var json = await GetJsonAsync(...); textBox1.Text = json; }
這也就是大家常說「一路異到底」。(不要在同步方法裡呼叫非同步方法,要非同步呼叫非同步,一路異到底)
(三)ContinueWith
在現實世界裡,經常會發生在一個方法完成之後,在進行下一個方法的呼叫,
例如,在Button 事件裡 (1)非同步從網路獲取HTML原始碼。 (2)把原始碼寫入 C:\File.txt 裡
這就需要第二步驟需要在第一步完成之後執行,此時需要用到ContinueWith 方法。
下面的程式碼簡單演示了 ContinueWith (其實,ContinueWith 這個方法的名字就已經很好的解釋了他的作用)
using System; using System.Threading.Tasks; class Program { static void Main() { //呼叫10次非同步方法 for (int i = 0; i < 10; i++) { Run2Methods(i); } //所有呼叫都是非同步 Console.ReadLine(); } static async void Run2Methods(int count) { // 在調動完後,呼叫 ContinueWith 繼續操作 int result = await Task.Run(() => GetSum(count)) .ContinueWith(task => MultiplyNegative1(task)); Console.WriteLine("Run2Methods 結果: " + result); } static int GetSum(int count) { //這裡模擬一些額外操作 int sum = 0; for (int z = 0; z < count; z++) { sum += (int)Math.Pow(z, 2); } return sum; } static int MultiplyNegative1(Task<int> task) { // 這裡模擬對數位取其相反數 return task.Result * -1; } }
下面顯示了執行結果
上面簡單的介紹了非同步程式設計。