2019年5月29日 星期三

C# 編譯器對於 async Task Main 方法做了什麼事情,使得可以在 Main 方法內使用非同步方法的 await 關鍵字

C# 編譯器對於 async Task Main 方法做了什麼事情,使得可以在 Main 方法內使用非同步方法的 await 關鍵字

當使用 .NET Framework 或者 .NET Core 建立一個新的 主控台應用程式的時候,若想要在該主控台應用程式的進入點 Entry Point,也就是這個專案一起動的時候,第一個要執行的方法,那就是 static void Main(string[] args) 這個方法;可是,若想要在這個方法內使用 await 關鍵字來等候一個非同步工作的話,編譯器將會提示您有錯誤產生,例如,底下的範例程式碼將會有編譯錯誤。
CS4009%26rd%3Dtrue) 不得同步傳回進入點的 void 或 int
CS5001%26rd%3Dtrue)) 程式未包含適合進入點的靜態 'Main' 方法
其中, CS4009 這個錯誤,透過 Visual Studio 上面的連結並打開使錯誤說明文件,將會看到這樣的說明: 您無法使用async關鍵字,在應用程式進入點 (通常Main方法)。
會有這樣的問題,那是因為可以在 Main 方法上使用 async 這個修飾詞,需要使用 C# 7.1 以上的版本,如同 CS4009 錯誤說明文件上提到的:開頭為C# 7.1 Main方法可以有async修飾詞,因此,想要解決此一錯誤,那就是要將這個專案設定為使用 C# 7.1 以上的版本。
而在 CS5001 的錯誤說明網頁中,將會看到這樣的內容:程式 'program' 未包含適合進入點的靜態 'Main' 方法。當沒有靜態時,會發生此錯誤Main產生可執行檔的程式碼中找不到具有正確的簽章的方法。
根據 CS5001 錯誤說明文件中提到的:當沒有靜態時,會發生此錯誤Main產生可執行檔的程式碼中找不到具有正確的簽章的方法。那是因為有了 CS4009 的錯誤,在 Main 方法中使用了 async 是不合法的,導致編譯器無法找到正確、合法的 Main 簽章方法。
可是,若不能夠在 Main 方法內使用 await 關鍵字 (因為,不想要使用封鎖執行緒 Block Thread 的做法),這要如何做到當要等候一個非同步工作的時候,可以立即返回到原先呼叫端那裏去這樣的需求呢?
C Sharp / C#
class Program
{
    static async void Main(string[] args)
    {
        await Task.Delay(1000);
        Console.WriteLine("Hello World!");
    }
}
要解決此一問題,請在方案總管內,使用滑鼠右擊該主控台應用程式專案的節點,點選 [屬性] 項目;當該專案的屬性視窗出現之後,切換到 [建置] 標籤頁次,在該標籤頁次最下方,會有一個 [進階] 按鈕,請點選此一按鈕;如下面螢幕截圖所示:
現在,[進階建置設定] 對話窗將會出現,請點選該對話窗的 [語言版本] 這個欄位旁邊的下拉選單控制項,這個下拉選單控制項原先預設的選項為 [c# 支援的最新次要版本],請在這個下拉選單中選擇 C# 7.1 以上的版本,在這裡將會選取 C# 7.3。完成後,在 [進階建置設定] 對話窗右下方點選 [確定] 按鈕,關閉此對話窗
操作方式摘要:[打開專案屬性視窗] > [建置] 標籤頁次 > [進階] 按鈕 > [語言版本] 7.3 > [確定] 按鈕
當經過這樣修正之後,再度重新進行專案建置, CS4009 的錯誤訊息消失了,而 CS5001 的錯誤訊息依然存在,這是因為還需要修正 Main 方法的宣告,把 void 修改成為 Task,也就是這樣 static async Task Main(string[] args) 這樣的目的是要讓 Main 這個方法成為一個非同步的方法,所以,這個 Main 方法將會回傳一個 Task 物件,因此,當執行到 await Task.Delay(1000); 敘述之後,就可以立即返回到原先呼叫 Main 方法的呼叫端。
現在,再度重新建置該專案,就會發現到所有的錯誤都消失了。
會需要這樣做,這是因為 C# 要求這樣做,需要一個 async Task Main 的方法,可是,這也產生另外一個問題,當在 Main 方法內執行敘述的時候,遇到了 await 關鍵字,將會立即返回到呼叫端,而當初呼叫該 Main 方法的呼叫端,也就是開始執行該程式的一開始處,如此,那不就造成了該程式就會結束執行了呀。可是,當實際執行該專案,卻發現到該專案真的會停留 1秒鐘後,才會顯示出一段字串內容,並沒有遇到 await 關鍵字之後,就立即結束執行了。
想要知道為什麼會發生這樣的問題,那就需要使用 .NET 組件 Assembly 反组譯工具,查看編譯器到底做了甚麼事情,以及真正要執行的程式碼變成如何?所以,在這裡將會透過 ILSpy 這套 .NET 組件反组譯工具來進行深度了解,想要取得 ILSpy 這套工具,可以從 ILSpy 這裡免費取得。
當下載完成 ILSpy 這套工具後,這裡下載的是 .zip 檔案格式,下載完成之後,可以解開該壓縮檔案到任何一個電腦上的目錄,接著在解壓縮目錄內找到 ILSpy.exe 這個檔案,請執行這個檔案。底下螢幕截圖為執行 ILSpy 工具的畫面。
如此,可以點選 ILSpy 功能表 [File] > [Open...] ,接著,切換到剛剛建立專案的 bin\Debug 目錄下,找到該專案的組件檔案名稱,開啟這個組件檔案;現在, ILSpy 將會顯示出這個組件中有哪些類別、方法與相關程式碼,如下圖所示。
在這裡建立的專案名稱為 ConsoleApp2,展開這個節點,將會看到 Program 命名空間下會有 Base Type、 Derived Typed 、 <Main>d__0 、 Program() 、 <Main>(string[]) : void 、 Main(string[]) : Task 這些傑點出現;現在又發現到一個怪異點,那就是這個範例程式僅有一個 Program 命名空間與 Main 方法,那麼,為什麼從 ILSpy 上看到這麼多其他的項目出現。這些原先程式碼沒有的項目,都是編譯器自動幫忙產生的,這是因為要能夠處理、讓這個程式可以使用非同步的 await 方法呼叫。
若看不到如上圖的畫面內容,請點選 ILSpy 功能表 [View] > [Show all types and members] 選項,就可以看到這些內容。
好的,接下來進行剖析,了解到底出了甚麼樣的變化,首先,在 C# 原始碼寫的 static async Task Main(string[] args) 方法,將會出現在上圖 標記1 的節點,其實,這個方法僅僅是一個非同步的方法,並不是這個程式的進入點,那麼,這隻程式的進入點在哪裡呢?請查看 標記2 的節點,也就是 <Main>(string[]) : void ,這就是這個程式的進入點,不過,編譯器將原先程式進入點的 Main 方法,改成使用 <Main> 方法;請注意,該方法的回傳值也是 void 喔。
而在 標記3 的節點,這個類別是編譯器產生出來的,這是因為有使用到 Main(string[]) : Task 方法,編譯器將會產生相對應的非同步處理狀態機所需要用到的相關程式碼。
到現在為止,原本單純 C# 程式碼中的Main 方法 ,經過建置之後,產生出這麼多的程式碼,接下來將逐步了解這些程式碼做了甚麼事情。
首先,來看看編譯過後的專案進入點,也就是 <Main>(string[]) : void ;請在 ILSpy 中點選這個節點,將會看到如下圖畫面,其該程式進入點的程式碼經過反组譯後,將會如同底下程式碼列表。
當這個程式一開始啟動之後,將會進入到這個進入點方法( <Main>)來執行,這個方法內僅有一行敘述,一開始將會呼叫 Main(args) 方法,而這個方法就是使用 C# 所設計的非同步方法,因為是非同步方法,將會回傳一個 Task 物件,所以,可以呼叫 Task.GetAwaiter 方法,該方法將會取得用來等候這個 Task 的 awaiter。而且,這個方法僅供編譯器使用,而不是應用程式程式碼中使用。
當取得了 TaskAwaiter 物件之後(因為 GetAwaiter() 方法會回傳這個型別的物件), 就可以再度呼叫 TaskAwaiter.GetResult 。這個方法將會結束對非同步工作完成的等候。此 API 支援此產品基礎結構,但無法直接用於程式碼之中。也就是說,這個方法將會由編譯器來使用,不建議在自己的 C# 程式碼中來使用。
講白話一點,這一行敘述,就是要呼叫 Main 非同步方法,並且使用封鎖執行緒的方式,等候非同步方法結束完成,因此,當在 C# 程式碼中的 static async Task Main(string[] args) 方法內有使用到 await 關鍵字之後,將會返回到原先呼叫端,也就是這個 <Main> 方法,而這個方法將會等候到該非同步方法完全結束之後,才會回傳到原先呼叫端,也就是結束執行,所以,遇到有 await 關鍵字之後,並不會造成該程式結束執行。
C Sharp / C#
// ConsoleApp2.Program
private static void <Main>(string[] args)
{
    Main(args).GetAwaiter().GetResult();
}
那麼,為什麼編譯器要在產生一個 <Main>(string[]) : void 方法呢?因為原先在 C# 程式碼中,已經將原先的 void Main 方法改寫成為一個非同務的方法,static async Task Main(string[] args),使得原先 C# 內的程式進入點函式不見了,所以,C# 編譯器要產生一個新的 Main 函式,作為這個程式的進入點要呼叫的函式,但是,若這個編譯器產生的進入點函式名稱命名為 Main(string[]) : void,將會與C#寫的 Main(string[]) : Task 方法會視為相同,也就是同一個方法,這樣會產生錯誤;會有這樣的錯誤,這是因為在 C# 內,判斷一個類別內的方法,其 方法名稱 / 參數型別與數量 (這是函式特徵的定義) 都要相同,就會視為兩個相同的方法,而不會因為其回傳值的型別不同,而是為不同的函數。所以,需要重新命名一個新的函式名稱,作為此程序的進入點要呼叫的函數。
最後,若不想要變更 [進階建置設定] 對話窗內的 [語言版本] 下拉選單控制項值,此時,可以參考使用底下的程式碼寫法,也會達成相同的執行結果的,也就是可以在 Console App 內輕鬆使用 await 來呼叫非同步方法。
C Sharp / C#
class Program
{
    static void Main(string[] args)
    {
        MainAsync(args).GetAwaiter().GetResult();
    }
    static async Task MainAsync(string[] args)
    {
        await Task.Delay(5000);
        Console.WriteLine("Hello World!");
    }
}



沒有留言:

張貼留言