多數(shù)應(yīng)用程序需要一次處理多件事()。在本章中,我們從基本的先決條件開始,即線程和任務(wù)的基礎(chǔ)知識,然后詳細描述異步和 C# 異步函數(shù)的原理。
在第章中,我們將更詳細地重新討論多線程,在第中,我們將介紹并行編程的相關(guān)主題。
以下是最常見的并發(fā)方案:
編寫響應(yīng)式用戶界面
在 Windows Presentation Foundation (WPF)、移動和 Windows 窗體應(yīng)用程序中,必須同時運行耗時的任務(wù)以及運行用戶界面的代碼以保持響應(yīng)能力。
允許同時處理請求
在服務(wù)器上,客戶端請求可以并發(fā)到達,因此必須并行處理以保持可伸縮性。如果使用 ASP.NET 核心或 Web API,運行時會自動執(zhí)行此操作。但是,您仍然需要了解共享狀態(tài)(例如,使用靜態(tài)變量進行緩存的效果)。
并行編程
如果工作負載在內(nèi)核之間分配,則執(zhí)行密集型計算的代碼可以在多核/多處理器計算機上更快地執(zhí)行(專門討論這一點)。
投機執(zhí)行
在多核計算機上,有時可以通過預(yù)測可能需要完成的操作,然后提前執(zhí)行來提高性能。LINQPad 使用此技術(shù)來加快新查詢的創(chuàng)建速度。是并行運行許多不同的算法,這些算法都解決相同的任務(wù)。無論哪個先完成,“獲勝”——當(dāng)你無法提前知道哪種算法將執(zhí)行得最快時,這是有效的。
程序可以同時執(zhí)行代碼的一般機制稱為。多線程處理受 CLR 和操作系統(tǒng)的支持,并且是并發(fā)的基本概念。了解線程的基礎(chǔ)知識,特別是線程對的影響,至關(guān)重要。
線程是可以獨立于其他進行的執(zhí)行路徑。
每個線程都在操作系統(tǒng)進程中運行,該進程提供了一個運行程序的獨立環(huán)境。對于單線程程序,只有一個線程在進程的獨立環(huán)境中運行,因此該對它具有獨占訪問權(quán)限。對于多線程程序,多個在單個進程中運行,共享相同的執(zhí)行環(huán)境(特別是內(nèi)存)。這在一定程度上就是多線程有用的原因:例如,一個線程可以在后臺獲取數(shù)據(jù),而另一個線程在數(shù)據(jù)到達時顯示數(shù)據(jù)。此數(shù)據(jù)稱為。
程序(控制臺、WPF、UWP 或 Windows 窗體)在操作系統(tǒng)自動創(chuàng)建的單個線程(“主”線程)中啟動。在這里,它作為單線程應(yīng)用程序活出它的生命,除非您通過創(chuàng)建更多線程(直接或間接)來執(zhí)行其他操作。1
可以通過實例化 Thread 對象并調(diào)用其 Start 方法來創(chuàng)建和啟動新線程。Thread 最簡單的構(gòu)造函數(shù)采用 ThreadStart 委托:一個指示應(yīng)從何處開始執(zhí)行的無參數(shù)方法。下面是一個示例:
// NB: All samples in this chapter assume the following namespace imports:
using System;
using System.Threading;
Thread t=new Thread (WriteY); // Kick off a new thread
t.Start(); // running WriteY()
// Simultaneously, do something on the main thread.
for (int i=0; i < 1000; i++) Console.Write ("x");
void WriteY()
{
for (int i=0; i < 1000; i++) Console.Write ("y");
}
// Typical Output:
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
...
主線程創(chuàng)建一個新線程 t,它在其上運行重復(fù)打印字符 的方法。同時,主線程重復(fù)打印字符 ,如圖 所示。在單核計算機上,操作系統(tǒng)必須為每個線程分配“片”時間(在 Windows 中通常為 20 毫秒)以模擬并發(fā),從而導(dǎo)致 和 塊。在多核或多處理器計算機上,兩個線程可以真正并行執(zhí)行(受計算機上其他活動進程的競爭),盡管在此示例中,由于控制臺處理并發(fā)請求的機制存在細微之處,您仍然會得到重復(fù)的 和 塊。
線程被稱為在其執(zhí)行與另一個線程上的代碼執(zhí)行穿插在一起的點被。這個詞經(jīng)常出現(xiàn)在解釋為什么出了問題的時候!
啟動后,線程的 IsAlive 屬性返回 true ,直到線程結(jié)束的點。當(dāng)傳遞給線程的構(gòu)造函數(shù)的委托完成執(zhí)行時,線程結(jié)束。結(jié)束后,線程無法重新啟動。
每個線程都有一個 Name 屬性,您可以設(shè)置該屬性以方便調(diào)試。這在 Visual Studio 中特別有用,因為線程的名稱顯示在“線程窗口”和“調(diào)試位置”工具欄中。您只能設(shè)置一次線程的名稱;稍后嘗試更改它將引發(fā)異常。
靜態(tài) Thread.CurrentThread 屬性為您提供當(dāng)前正在執(zhí)行的線程:
Console.WriteLine (Thread.CurrentThread.Name);
您可以通過調(diào)用其 Join 方法來等待另一個線程結(jié)束:
Thread t=new Thread (Go);
t.Start();
t.Join();
Console.WriteLine ("Thread t has ended!");
void Go() { for (int i=0; i < 1000; i++) Console.Write ("y"); }
這將打印“y”1,000 次,緊接著是“線程 t 已結(jié)束!調(diào)用 Join 時可以包含超時,以毫秒為單位,也可以以時間跨度 為單位。然后,如果線程結(jié)束,則返回 true,如果超時,則返回 false。
Thread.Sleep 將當(dāng)前線程暫停指定的時間段:
Thread.Sleep (TimeSpan.FromHours (1)); // Sleep for 1 hour
Thread.Sleep (500); // Sleep for 500 milliseconds
Thread.Sleep(0) 立即放棄線程的當(dāng)前時間片,自愿將 CPU 移交給其他線程。Thread.Yield() 做同樣的事情,只是它只讓給處理器上運行的線程。
Sleep(0) 或 Yield 在生產(chǎn)代碼中偶爾可用于高級性能調(diào)整。它也是一個很好的診斷工具,有助于發(fā)現(xiàn)線程安全問題:如果在代碼中的任何位置插入 Thread.Yield() 會破壞程序,則幾乎可以肯定存在錯誤。
在等待睡眠或加入時,線程被阻塞。
當(dāng)線程的執(zhí)行由于某種原因而暫停時,例如當(dāng)休眠或等待另一個線程通過 Join 結(jié)束時,該線程被視為。被阻塞的線程會立即其處理器時間片,從那時起,在滿足其阻塞條件之前,它不會消耗任何處理器時間。您可以通過其 ThreadState 屬性測試線程是否被阻止:
bool blocked=(someThread.ThreadState & ThreadState.WaitSleepJoin) !=0;
ThreadState 是一個標志枚舉,以按位方式組合三個數(shù)據(jù)“層”。但是,大多數(shù)值都是冗余的、未使用的或已棄用的。以下擴展方法將 ThreadState 剝離為四個有用值之一:Unstarted 、Running 、WaitSleepJoin 和 Stop:
public static ThreadState Simplify (this ThreadState ts)
{
return ts & (ThreadState.Unstarted |
ThreadState.WaitSleepJoin |
ThreadState.Stopped);
}
屬性可用于診斷目的,但不適合同步,因為線程的狀態(tài)可能會在測試 ThreadState 和處理該信息之間發(fā)生更改。
當(dāng)線程阻塞或取消阻止時,OS 會執(zhí)行。這會產(chǎn)生很小的開銷,通常為一到兩微秒。
花費大部分時間某些事情發(fā)生的操作稱為 — 例如下載網(wǎng)頁或調(diào)用 Console.ReadLine 。(I/O 綁定操作通常涉及輸入或輸出,但這不是硬性要求:Thread.Sleep 也被視為 I/O 綁定。相比之下,花費大部分時間執(zhí)行 CPU 密集型工作的操作稱為。
I/O 綁定操作以以下兩種方式之一工作:它在當(dāng)前線程上等待,直到操作完成(例如 Console.ReadLine 、 Thread.Sleep 或 Thread.Join ),或者異步操作,完成時觸發(fā)回調(diào)(稍后會詳細介紹)。
同步等待的 I/O 綁定操作花費大部分時間阻塞線程。它們還可以周期性地循環(huán)“旋轉(zhuǎn)”:
while (DateTime.Now < nextStartTime)
Thread.Sleep (100);
撇開有更好的方法(例如計時器或信令結(jié)構(gòu))不談,另一種選擇是線程可以連續(xù)旋轉(zhuǎn):
while (DateTime.Now < nextStartTime);
通常,這會浪費處理器時間:就 CLR 和 OS 而言,線程正在執(zhí)行重要的計算,因此相應(yīng)地分配了資源。實際上,我們已經(jīng)將應(yīng)該是 I/O 綁定的操作變成了計算綁定的操作。
關(guān)于旋轉(zhuǎn)與阻塞有一些細微差別。首先,當(dāng)您期望很快滿足條件(可能在幾微秒內(nèi))時,的旋轉(zhuǎn)可能是有效的,因為它避免了上下文切換的開銷和延遲。.NET 提供了特殊的方法和類來提供幫助 — 請參閱聯(lián)機補充
其次,阻止不會產(chǎn)生成本。這是因為每個線程只要存在就會占用大約 1 MB 的內(nèi)存,并導(dǎo)致 CLR 和操作系統(tǒng)的持續(xù)管理開銷。因此,在需要處理數(shù)百或數(shù)千個并發(fā)操作的大量 I/O 綁定程序的上下文中,阻塞可能會很麻煩。相反,此類程序需要使用基于回調(diào)的方法,在等待時完全取消其線程。這(部分)是我們稍后討論的異步模式的目的。
CLR 為每個線程分配自己的內(nèi)存堆棧,以便局部變量保持獨立。在下一個示例中,我們使用局部變量定義一個方法,然后在主線程和新創(chuàng)建的線程上同時調(diào)用該方法:
new Thread (Go).Start(); // Call Go() on a new thread
Go(); // Call Go() on the main thread
void Go()
{
// Declare and use a local variable - 'cycles'
for (int cycles=0; cycles < 5; cycles++) Console.Write ('?');
}
在每個線程的內(nèi)存堆棧上創(chuàng)建 cycle 變量的單獨副本,因此可以預(yù)見的是,輸出是 10 個問號。
如果線程對同一對象或變量具有公共引用,則線程共享數(shù)據(jù):
bool _done=false;
new Thread (Go).Start();
Go();
void Go()
{
if (!_done) { _done=true; Console.WriteLine ("Done"); }
}
兩個線程共享_done變量,因此“完成”打印一次而不是兩次。
也可以共享 lambda 表達式捕獲的局部變量:
bool done=false;
ThreadStart action=()=>
{
if (!done) { done=true; Console.WriteLine ("Done"); }
};
new Thread (action).Start();
action();
不過,更常見的是,字段用于在線程之間共享數(shù)據(jù)。在以下示例中,兩個線程在同一個 ThreadTest 實例上調(diào)用 Go(),因此它們共享相同的_done字段:
var tt=new ThreadTest();
new Thread (tt.Go).Start();
tt.Go();
class ThreadTest
{
bool _done;
public void Go()
{
if (!_done) { _done=true; Console.WriteLine ("Done"); }
}
}
靜態(tài)字段提供了另一種在線程之間共享數(shù)據(jù)的方法:
class ThreadTest
{
static bool _done; // Static fields are shared between all threads
// in the same process.
static void Main()
{
new Thread (Go).Start();
Go();
}
static void Go()
{
if (!_done) { _done=true; Console.WriteLine ("Done"); }
}
}
所有四個示例都說明了另一個關(guān)鍵概念:線程安全(或者更確切地說,缺乏它!輸出實際上是不確定的:“完成”有可能(盡管不太可能)打印兩次。但是,如果我們在 Go 方法中交換語句的順序,則“完成”被打印兩次的幾率會急劇上升:
static void Go()
{
if (!_done) { Console.WriteLine ("Done"); _done=true; }
}
問題在于,一個線程可以在另一個線程執(zhí)行 WriteLine 語句的同時計算 if 語句 — 在它有機會將 done 設(shè)置為 true 之前。
我們的示例說明了可能引入多線程臭名昭著的間歇性錯誤的多種方式之一。接下來,我們看看如何通過鎖定來修復(fù)我們的程序;但是,最好盡可能完全避免共享狀態(tài)。稍后我們將看到異步編程模式如何對此有所幫助。
鎖定和線程安全是大主題。有關(guān)完整討論,請參閱中的“和
我們可以通過在讀取和寫入共享字段時獲取來修復(fù)前面的示例。C# 僅為此目的提供了 lock 語句:
class ThreadSafe
{
static bool _done;
static readonly object _locker=new object();
static void Main()
{
new Thread (Go).Start();
Go();
}
static void Go()
{
lock (_locker)
{
if (!_done) { Console.WriteLine ("Done"); _done=true; }
}
}
}
當(dāng)兩個線程同時爭用一個鎖(可以在任何引用類型對象上;在本例中為 _locker)時,一個線程等待或阻塞,直到鎖可用。在這種情況下,它確保一次只有一個線程可以進入其代碼塊,并且“完成”將只打印一次。以這種方式(避免在多線程上下文中出現(xiàn)不確定性)的代碼稱為代碼。
即使是自動遞增變量的行為也不是線程安全的:表達式 x++ 作為不同的讀-增量-寫操作在底層處理器上執(zhí)行。因此,如果兩個線程在鎖外部同時執(zhí)行 x++,則變量最終可能會遞增一次而不是兩次(或者更糟糕的是,在某些情況下,x 可能會,最終導(dǎo)致新舊內(nèi)容的按位混合)。
鎖定不是線程安全的靈丹妙藥 - 很容易忘記鎖定訪問字段,并且鎖定本身會產(chǎn)生問題(例如死鎖)。
何時可以使用鎖定的一個很好的例子是訪問 ASP.NET 應(yīng)用程序中經(jīng)常訪問的數(shù)據(jù)庫對象的共享內(nèi)存中緩存。這種應(yīng)用程序很容易正確,并且沒有死鎖的機會。我們在的中給出了一個例子。
有時,您需要將參數(shù)傳遞給線程的啟動方法。最簡單的方法是使用 lambda 表達式,該表達式使用所需參數(shù)調(diào)用該方法:
Thread t=new Thread ( ()=> Print ("Hello from t!") );
t.Start();
void Print (string message)=> Console.WriteLine (message);
使用此方法,可以將任意數(shù)量的參數(shù)傳遞給該方法。您甚至可以將整個實現(xiàn)包裝在多語句 lambda 中:
new Thread (()=>
{
Console.WriteLine ("I'm running on another thread!");
Console.WriteLine ("This is so easy!");
}).Start();
另一種(不太靈活)的技術(shù)是將參數(shù)傳遞到 Thread 的 Start 方法中:
Thread t=new Thread (Print);
t.Start ("Hello from t!");
void Print (object messageObj)
{
string message=(string) messageObj; // We need to cast here
Console.WriteLine (message);
}
這是有效的,因為 Thread 的構(gòu)造函數(shù)被重載以接受兩個之一:
public delegate void ThreadStart();
public delegate void ParameterizedThreadStart (object obj);
正如我們所看到的,lambda 表達式是將數(shù)據(jù)傳遞到線程的最方便、最強大的方式。但是,您必須小心,以免在啟動線程后意外修改。例如,請考慮以下事項:
for (int i=0; i < 10; i++)
new Thread (()=> Console.Write (i)).Start();
輸出是不確定的!下面是一個典型的結(jié)果:
0223557799
問題是 i 變量在循環(huán)的整個生命周期中引用內(nèi)存位置。因此,每個線程都會調(diào)用 Console.Write 在一個變量上,該變量的值可以在運行時更改!解決方案是使用臨時變量
for (int i=0; i < 10; i++)
{
int temp=i;
new Thread (()=> Console.Write (temp)).Start();
}
然后,每個數(shù)字 0 到 9 只寫一次。(仍未定義,因為線程可以在不確定的時間啟動。
這類似于我們在中描述的問題。問題與 C# 在循環(huán)中捕獲變量的規(guī)則和多線程一樣多。
可變溫度現(xiàn)在是每個循環(huán)迭代的本地變量。因此,每個線程捕獲不同的內(nèi)存位置,沒有問題。我們可以用下面的例子在前面的代碼中更簡單地說明這個問題:
string text="t1";
Thread t1=new Thread ( ()=> Console.WriteLine (text) );
text="t2";
Thread t2=new Thread ( ()=> Console.WriteLine (text) );
t1.Start(); t2.Start();
由于兩個 lambda 表達式捕獲相同的文本變量,因此 t2 打印兩次。
創(chuàng)建線程時生效的任何嘗試/捕獲/最終塊在開始執(zhí)行時與線程無關(guān)。請考慮以下程序:
try
{
new Thread (Go).Start();
}
catch (Exception ex)
{
// We'll never get here!
Console.WriteLine ("Exception!");
}
void Go() { throw null; } // Throws a NullReferenceException
此示例中的 try / catch 語句無效,新創(chuàng)建的線程將受到未處理的 NullReferenceException 的阻礙。當(dāng)您認為每個線程都有獨立的執(zhí)行路徑時,此行為是有意義的。
補救措施是將異常處理程序移動到 Go 方法中:
new Thread (Go).Start();
void Go()
{
try
{
...
throw null; // The NullReferenceException will get caught below
...
}
catch (Exception ex)
{
// Typically log the exception, and/or signal another thread
// that we've come unstuck
...
}
}
您需要在生產(chǎn)應(yīng)用程序中的所有線程入口方法上使用異常處理程序,就像在主線程上所做的那樣(通常在執(zhí)行堆棧中的更高級別)。未經(jīng)處理的異常會導(dǎo)致整個應(yīng)用程序關(guān)閉 - 并顯示一個丑陋的對話框!
在編寫此類異常處理塊時,您很少會錯誤:通常,您會記錄異常的詳細信息。對于客戶端應(yīng)用程序,您可能會顯示一個對話框,允許用戶自動將這些詳細信息提交到 Web 服務(wù)器。然后,您可以選擇重新啟動應(yīng)用程序,因為意外的異常可能會使程序處于無效狀態(tài)。
在 WPF、UWP 和 Windows 窗體應(yīng)用程序中,您可以分別訂閱“全局”異常處理事件:Application.DispatcherUnhandledException 和 Application.ThreadException。在通過消息循環(huán)調(diào)用的程序的任何部分中發(fā)生未經(jīng)處理的異常后,這些異常將觸發(fā)(這相當(dāng)于應(yīng)用程序處于活動狀態(tài)時在主線程上運行的所有代碼)。這可用作日志記錄和報告 bug 的后盾(盡管它不會針對您創(chuàng)建的非用戶界面 [UI] 線程上的未經(jīng)處理的異常觸發(fā))。處理這些事件可防止程序關(guān)閉,盡管您可以選擇重新啟動應(yīng)用程序以避免可能從(或?qū)е拢┪刺幚淼漠惓?dǎo)致狀態(tài)的潛在損壞。
默認情況下,顯式創(chuàng)建的線程是。只要前臺線程中的任何一個正在運行,前臺線程就會使應(yīng)用程序保持活動狀態(tài),而則不會。所有前臺線程完成后,應(yīng)用程序結(jié)束,并且仍在運行的任何后臺線程將突然終止。
線程的前臺/后臺狀態(tài)與其(執(zhí)行時間分配)無關(guān)。
您可以使用線程的 IsBackground 查詢或更改線程的背景狀態(tài):
static void Main (string[] args)
{
Thread worker=new Thread ( ()=> Console.ReadLine() );
if (args.Length > 0) worker.IsBackground=true;
worker.Start();
}
如果在沒有參數(shù)的情況下調(diào)用此程序,則工作線程將處于前臺狀態(tài),并將等待 ReadLine 語句,以便用戶按 Enter 鍵。同時,主線程退出,但應(yīng)用程序繼續(xù)運行,因為前臺線程仍處于活動狀態(tài)。另一方面,如果將參數(shù)傳遞給 Main(),則為工作線程分配后臺狀態(tài),并且程序在主線程結(jié)束時幾乎立即退出(終止 ReadLine)。
當(dāng)進程以這種方式終止時,后臺線程的執(zhí)行堆棧中的任何 finally 塊都會被規(guī)避。如果您的程序使用 final(或使用)塊來執(zhí)行清理工作,例如刪除臨時文件,您可以通過在退出應(yīng)用程序時顯式等待此類后臺線程來避免這種情況,方法是通過加入線程或使用信令構(gòu)造(請參閱)。無論哪種情況,您都應(yīng)該指定超時,以便在叛徒線程拒絕完成時可以放棄它;否則,您的應(yīng)用程序?qū)o法關(guān)閉,而無需用戶從任務(wù)管理器(或在 Unix 上為 kill 命令)尋求幫助。
前臺線程不需要這種處理,但您必須注意避免可能導(dǎo)致線程無法結(jié)束的 bug。應(yīng)用程序無法正確退出的常見原因是存在活動的前臺線程。
線程的 Priority 屬性確定相對于操作系統(tǒng)中的其他活動線程分配的執(zhí)行時間量,具體比例如下:
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
當(dāng)多個線程同時處于活動狀態(tài)時,這變得很重要。提升線程的優(yōu)先級時需要小心,因為它可能會使其他線程匱乏。如果希望某個線程的優(yōu)先級高于進程中的線程,則還必須使用 System.Diagnostics 中的 Process 類提升進程優(yōu)先級:
using Process p=Process.GetCurrentProcess();
p.PriorityClass=ProcessPriorityClass.High;
這對于執(zhí)行最少工作且需要低延遲(能夠快速響應(yīng))的非 UI 進程非常有效。對于計算量大的應(yīng)用程序(尤其是具有用戶界面的應(yīng)用程序),提升進程優(yōu)先級可能會使其他進程匱乏,從而降低整個計算機的速度。
有時,您需要一個線程等待,直到收到來自其他線程的通知。這稱為。最簡單的信令結(jié)構(gòu)是 手動重置事件 。在 ManualResetEvent 上調(diào)用 WaitOne 會阻止當(dāng)前線程,直到另一個線程通過調(diào)用 Set 來“打開”信號。在下面的示例中,我們啟動一個等待 手動重置事件 .它保持阻塞兩秒鐘,直到主線程:
var signal=new ManualResetEvent (false);
new Thread (()=>
{
Console.WriteLine ("Waiting for signal...");
signal.WaitOne();
signal.Dispose();
Console.WriteLine ("Got signal!");
}).Start();
Thread.Sleep(2000);
signal.Set(); // “Open” the signal
調(diào)用 Set 后,信號保持打開狀態(tài);您可以通過調(diào)用 重置 再次關(guān)閉它。
手動重置事件是 CLR 提供的幾種信令構(gòu)造之一;我們將在第中詳細介紹所有這些。
在 WPF、UWP 和 Windows 窗體應(yīng)用程序中,在主線程上執(zhí)行長時間運行的操作會使應(yīng)用程序無響應(yīng),因為主線程還處理執(zhí)行呈現(xiàn)和處理鍵盤和鼠標事件的消息循環(huán)。
一種流行的方法是啟動“worker”線程以進行耗時的操作。工作線程上的代碼運行耗時的操作,然后在完成后更新 UI。但是,所有胖客戶端應(yīng)用程序都有一個線程模型,其中 UI 元素和控件只能從創(chuàng)建它們的線程(通常是主 UI 線程)訪問。違反此規(guī)定會導(dǎo)致引發(fā)不可預(yù)知的行為或引發(fā)異常。
因此,當(dāng)您想要從工作線程更新 UI 時,必須將請求轉(zhuǎn)發(fā)到 UI 線程(技術(shù)術(shù)語是執(zhí)行此操作的低級方法如下(稍后,我們將討論基于這些解決方案的其他解決方案):
所有這些方法都接受引用要運行的方法的委托。BeginInvoke/RunAsync 的工作原理是將委托排隊到 UI 線程的消息隊列(處理鍵盤、鼠標和計時器事件的同一)。Invoke 執(zhí)行相同的操作,但隨后會阻止,直到 UI 線程讀取和處理消息。因此,Invoke 允許您從方法中獲取返回值。如果你不需要返回值,BeginInvoke / RunAsync 更可取,因為它們不會阻止調(diào)用方,也不會引入死鎖的可能性(參見中的)。
可以想象,當(dāng)您調(diào)用 Application.Run 時,將執(zhí)行以下偽代碼:
while (!thisApplication.Ended)
{
wait for something to appear in message queue
Got something: what kind of message is it?
Keyboard/mouse message -> fire an event handler
User BeginInvoke message -> execute delegate
User Invoke message -> execute delegate & post result
}
正是這種循環(huán)使工作線程能夠?qū)⑽蟹馑偷?UI 線程上執(zhí)行。
為了演示,假設(shè)我們有一個 WPF 窗口,其中包含一個名為 txtMessage ,我們希望工作線程在執(zhí)行耗時的任務(wù)后更新其內(nèi)容(我們將通過調(diào)用 Thread.Sleep 來模擬)。以下是我們的做法:
partial class MyWindow : Window
{
public MyWindow()
{
InitializeComponent();
new Thread (Work).Start();
}
void Work()
{
Thread.Sleep (5000); // Simulate time-consuming task
UpdateMessage ("The answer");
}
void UpdateMessage (string message)
{
Action action=()=> txtMessage.Text=message;
Dispatcher.BeginInvoke (action);
}
}
如果每個 UI 線程擁有不同的窗口,則可以有多個 UI 線程。主要方案是當(dāng)您有一個具有多個頂級窗口的應(yīng)用程序時,通常稱為 (SDI) 應(yīng)用程序,如 Microsoft Word。每個SDI窗口通常在任務(wù)欄上顯示為單獨的“應(yīng)用程序”,并且在功能上與其他SDI窗口基本隔離。通過為每個此類窗口提供自己的 UI 線程,可以使每個窗口相對于其他窗口更具響應(yīng)性。
運行此操作會導(dǎo)致立即出現(xiàn)響應(yīng)窗口。五秒鐘后,它將更新文本框。代碼與Windows窗體類似,只是我們調(diào)用(窗體的)BeginInvoke方法,而不是:
void UpdateMessage (string message)
{
Action action=()=> txtMessage.Text=message;
this.BeginInvoke (action);
}
在 System.ComponentModel 命名空間中,有一個名為 SynchronizationContext 的類,它支持線程封送處理的泛化。
適用于移動和桌面的胖客戶端 API(UWP、WPF 和 Windows 窗體)分別定義并實例化 SynchronizationContext 子類,您可以通過靜態(tài)屬性 SynchronizationContext.Current 獲取這些子類(在 UI 線程上運行時)。通過捕獲此屬性,可以稍后從工作線程“發(fā)布”到 UI 控件:
partial class MyWindow : Window
{
SynchronizationContext _uiSyncContext;
public MyWindow()
{
InitializeComponent();
// Capture the synchronization context for the current UI thread:
_uiSyncContext=SynchronizationContext.Current;
new Thread (Work).Start();
}
void Work()
{
Thread.Sleep (5000); // Simulate time-consuming task
UpdateMessage ("The answer");
}
void UpdateMessage (string message)
{
// Marshal the delegate to the UI thread:
_uiSyncContext.Post (_=> txtMessage.Text=message, null);
}
}
這很有用,因為相同的技術(shù)適用于所有富客戶端用戶界面 API。
調(diào)用帖子等效于在調(diào)度程序或控件上調(diào)用 BeginInvoke ;還有一個等效于 Invoke 的 Send 方法。
每當(dāng)您啟動線程時,都會花費幾百微秒來組織諸如新的局部變量堆棧之類的東西。線程通過具有預(yù)先創(chuàng)建的可回收線程池來減少此開銷。線程池對于高效的并行編程和細粒度并發(fā)至關(guān)重要;它允許短操作運行,而不會被線程啟動的開銷所淹沒。
使用池線程時需要注意以下幾點:
您可以自由更改池線程的優(yōu)先級 - 當(dāng)釋放回池時,它將恢復(fù)正常。
您可以通過屬性 Thread.CurrentThread.IsThreadPoolThread 確定當(dāng)前是否正在池線程上執(zhí)行。
在池線程上顯式運行某些內(nèi)容的最簡單方法是使用 Task.Run(我們將在下一節(jié)中更詳細地介紹這一點):
// Task is in System.Threading.Tasks
Task.Run (()=> Console.WriteLine ("Hello from the thread pool"));
由于任務(wù)在 .NET Framework 4.0 之前不存在,因此常見的替代方法是調(diào)用 ThreadPool.QueueUserWorkItem :
ThreadPool.QueueUserWorkItem (notUsed=> Console.WriteLine ("Hello"));
以下內(nèi)容隱式使用線程池:
線程池提供另一個功能,即確保臨時超出計算密集型工作不會導(dǎo)致 CPU 。超額訂閱是活動線程多于 CPU 內(nèi)核的條件,操作系統(tǒng)必須對線程進行時間切片。超額訂閱會損害性能,因為時間切片需要昂貴的上下文切換,并且可能會使 CPU 緩存失效,而 CPU 緩存對于為現(xiàn)代處理器提供性能至關(guān)重要。
CLR 通過對任務(wù)進行排隊并限制其啟動來防止線程池中的超額訂閱。它首先運行與硬件內(nèi)核一樣多的并發(fā)任務(wù),然后通過爬山算法調(diào)整并發(fā)級別,在特定方向上不斷調(diào)整工作負載。如果吞吐量提高,則繼續(xù)沿同一方向發(fā)展(否則將反轉(zhuǎn))。這可確保它始終跟蹤最佳性能曲線,即使面對計算機上競爭的過程活動也是如此。
如果滿足兩個條件,CLR 的策略效果最佳:
阻塞很麻煩,因為它給 CLR 一個錯誤的想法,即它正在加載 CPU。CLR 足夠智能,可以檢測和補償(通過將更多線程注入池),盡管這可能會使池容易受到后續(xù)超額訂閱的影響。它還可能引入延遲,因為 CLR 會限制它注入新線程的速率,尤其是在應(yīng)用程序生命周期的早期(在它傾向于降低資源消耗的客戶端操作系統(tǒng)上更是如此)。
當(dāng)您想要充分利用 CPU 時(例如,通過中的并行編程 API),在線程池中保持良好的衛(wèi)生狀況尤其重要。
線程是用于創(chuàng)建并發(fā)的低級工具,因此,它有局限性。特別:
這些限制阻礙了細粒度并發(fā);換句話說,它們使得通過組合較小的并發(fā)操作來組合較大的并發(fā)操作變得困難(這對于我們在以下各節(jié)中介紹的異步編程至關(guān)重要)。這反過來又導(dǎo)致對手動同步(鎖定、信令等)的更大依賴以及隨之而來的問題。
直接使用線程也具有我們在中討論的性能影響。如果您需要運行數(shù)百或數(shù)千個并發(fā) I/O 綁定操作,基于線程的方法純粹在線程開銷中消耗數(shù)百或數(shù)千兆字節(jié)的內(nèi)存。
Task 類有助于解決所有這些問題。與線程相比,Task 是更高級別的抽象 - 它表示線程可能支持也可能不支持的并發(fā)操作。任務(wù)是(您可以通過使用將它們鏈接在一起)。他們可以使用來減少啟動延遲,并且使用 TaskCompletionSource ,他們可以采用回調(diào)方法,在等待 I/O 綁定操作時完全避免線程。
任務(wù)類型在框架 4.0 中作為并行編程庫的一部分引入。但是,它們后來得到了增強(通過使用),以便在更一般的并發(fā)方案中同樣出色地發(fā)揮作用,并且是 C# 異步函數(shù)的支持類型。
在本節(jié)中,我們忽略了專門針對并行編程的任務(wù)的功能;我們將在第中介紹它們。
啟動由線程支持的任務(wù)的最簡單方法是使用靜態(tài)方法 Task.Run(Task 類位于 System.Threading.Tasks 命名空間中)。只需傳入操作委托:
Task.Run (()=> Console.WriteLine ("Foo"));
默認情況下,任務(wù)使用池線程,即后臺線程。這意味著當(dāng)主線程結(jié)束時,您創(chuàng)建的任何任務(wù)也會結(jié)束。因此,要從控制臺應(yīng)用程序運行這些示例,您必須在啟動任務(wù)后阻止主線程(例如,通過等待任務(wù)或調(diào)用 Console.ReadLine ):
Task.Run (()=> Console.WriteLine ("Foo"));
Console.ReadLine();
在本書的 LINQPad 配套示例中,省略了 Console.ReadLine,因為 LINQPad 進程使后臺線程保持活動狀態(tài)。
以這種方式調(diào)用 Task.Run 類似于啟動線程,如下所示(除了我們稍后討論的線程池含義):
new Thread (()=> Console.WriteLine ("Foo")).Start();
Task.Run 返回一個 Task 對象,我們可以使用它來監(jiān)視其進度,就像 Thread 對象一樣。(但是請注意,我們沒有在調(diào)用 Task.Run 后調(diào)用 Start,因為此方法創(chuàng)建“熱”任務(wù);您可以改用 Task 的構(gòu)造函數(shù)來創(chuàng)建“冷”任務(wù),盡管在實踐中很少這樣做。
可以通過任務(wù)的 Status 屬性跟蹤任務(wù)的執(zhí)行狀態(tài)。
調(diào)用任務(wù)塊上的等待,直到它完成,相當(dāng)于在線程上調(diào)用 Join:
Task task=Task.Run (()=>
{
Thread.Sleep (2000);
Console.WriteLine ("Foo");
});
Console.WriteLine (task.IsCompleted); // False
task.Wait(); // Blocks until task is complete
等待允許您選擇指定超時和取消令牌以提前結(jié)束等待(請參閱)。
默認情況下,CLR 在池線程上運行任務(wù),這非常適合短期運行的計算密集型工作。對于運行時間較長的操作和阻塞操作(如前面的示例),可以阻止使用池化線程,如下所示:
Task task=Task.Factory.StartNew (()=> ...,
TaskCreationOptions.LongRunning);
在池線程上運行長時間運行的任務(wù)不會造成麻煩;當(dāng)您并行運行多個長時間運行的任務(wù)(尤其是那些阻塞的任務(wù))時,性能可能會受到影響。在這種情況下,通常有比TaskCreationOptions.LongRun更好的解決方案:
Task有一個名為Task<TResult>的泛型子類,它允許任務(wù)發(fā)出返回值。您可以通過使用 Func<TResult> 委托(或兼容的 lambda 表達式)而不是 Action 調(diào)用 Task.Run 來獲取 Task<TResult>:
Task<int> task=Task.Run (()=> { Console.WriteLine ("Foo"); return 3; });
// ...
以后可以通過查詢 Result 屬性來獲取結(jié)果。如果任務(wù)尚未完成,則訪問此屬性將阻止當(dāng)前線程,直到任務(wù)完成:
int result=task.Result; // Blocks if not already finished
Console.WriteLine (result); // 3
在下面的示例中,我們創(chuàng)建一個任務(wù),該任務(wù)使用 LINQ 計算前三百萬 (+2) 個整數(shù)中的素數(shù)數(shù):
Task<int> primeNumberTask=Task.Run (()=>
Enumerable.Range (2, 3000000).Count (n=>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i=> n % i > 0)));
Console.WriteLine ("Task running...");
Console.WriteLine ("The answer is " + primeNumberTask.Result);
這寫著“任務(wù)正在運行...”然后幾秒鐘后寫下216816的答案。
任務(wù)<TResult>可以被認為是一個“未來”,因為它封裝了一個稍后可用的結(jié)果。
與線程不同,任務(wù)可以方便地傳播異常。因此,如果任務(wù)中的代碼拋出未經(jīng)處理的異常(換句話說,如果任務(wù)),則該異常會自動重新拋出給調(diào)用 Wait() 或訪問 Task<TResult 的 Result 屬性的人> :
// Start a Task that throws a NullReferenceException:
Task task=Task.Run (()=> { throw null; });
try
{
task.Wait();
}
catch (AggregateException aex)
{
if (aex.InnerException is NullReferenceException)
Console.WriteLine ("Null!");
else
throw;
}
(CLR 將異常包裝在 AggregateException 中,以便很好地與并行編程方案配合使用;我們將在第 中對此進行討論。
您可以通過任務(wù)的 IsFaulted 和 IsCanceled 屬性測試出錯的任務(wù),而無需重新引發(fā)異常。如果兩個屬性都返回 false,則未發(fā)生錯誤;如果 IsCanceled 為 true,則為該任務(wù)拋出 OperationCanceledException(請參閱);如果 IsFaulted 為 true,則引發(fā)另一種類型的異常,并且 Exception 屬性將指示錯誤。
對于自主的“設(shè)置并忘記”任務(wù)(那些您不通過 Wait() 或 Result 會合的任務(wù),或者執(zhí)行相同操作的延續(xù)),最好顯式異常處理任務(wù)代碼以避免靜默失敗,就像使用線程一樣。
當(dāng)異常僅表示無法獲得您不再感興趣的結(jié)果時,忽略異常是可以的。例如,如果用戶取消了下載網(wǎng)頁的請求,我們不會關(guān)心該網(wǎng)頁是否存在。
當(dāng)異常指示程序中存在錯誤時,忽略異常是有問題的,原因有兩個:
您可以通過靜態(tài)事件 TaskScheduler.UnobservedTaskException 在全局級別訂閱未觀察到的異常;處理此事件并記錄錯誤可能很有意義。
關(guān)于什么算作未觀察到,有幾個有趣的細微差別:
延續(xù)對任務(wù)說:“當(dāng)你完成時,繼續(xù)做其他事情。延續(xù)通常由在操作完成后執(zhí)行一次的回調(diào)實現(xiàn)。有兩種方法可以將延續(xù)附加到任務(wù)。第一個特別重要,因為它被 C# 的異步函數(shù)使用,你很快就會看到。我們可以通過不久前在中編寫的素數(shù)計數(shù)任務(wù)來演示它:
Task<int> primeNumberTask=Task.Run (()=>
Enumerable.Range (2, 3000000).Count (n=>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i=> n % i > 0)));
var awaiter=primeNumberTask.GetAwaiter();
awaiter.OnCompleted (()=>
{
int result=awaiter.GetResult();
Console.WriteLine (result); // Writes result
});
在任務(wù)上調(diào)用 GetAwaiter 會返回一個對象,其 OnCompleted 方法告訴任務(wù) ( primeNumberTask ) 在完成(或錯誤)時執(zhí)行委托。將延續(xù)附加到已完成的任務(wù)是有效的,在這種情況下,延續(xù)將計劃為立即執(zhí)行。
是公開我們剛剛看到的兩個方法(OnComplete和GetResult)和一個名為IsComplete的布爾屬性的任何對象。沒有接口或基類來統(tǒng)一所有這些成員(盡管OnComplete是接口INotifyComplete的一部分)。我們在中解釋了該模式的重要性。
如果先前的任務(wù)出錯,則在繼續(xù)代碼調(diào)用 awaiter 時將重新引發(fā)異常。獲取結(jié)果() .與其調(diào)用 GetResult ,我們可以簡單地訪問前置的 Result 屬性。調(diào)用 GetResult 的好處是,如果先驗錯誤,則直接拋出異常,而不被包裝在 AggregateException 中,從而允許更簡單、更干凈的捕獲塊。
對于非泛型任務(wù),GetResult() 具有 void 返回值。它的有用功能只是重新引發(fā)異常。
如果存在同步上下文,OnDone 會自動捕獲該上下文并將延續(xù)發(fā)布到該上下文。這在胖客戶端應(yīng)用程序中非常有用,因為它會將延續(xù)反彈回 UI 線程。但是,在編寫庫時,通常不希望這樣做,因為相對昂貴的 UI 線程反彈應(yīng)該只在離開庫時發(fā)生一次,而不是在方法調(diào)用之間發(fā)生。因此,您可以使用 ConfigureAwait 方法擊敗它:
var awaiter=primeNumberTask.ConfigureAwait (false).GetAwaiter();
如果不存在同步上下文,或者您使用 ConfigureAwait(false),則延續(xù)(通常)將在與前置相同的線程上執(zhí)行,從而避免不必要的開銷。
附加延續(xù)的另一種方法是調(diào)用任務(wù)的 ContinueWith 方法:
primeNumberTask.ContinueWith (antecedent=>
{
int result=antecedent.Result;
Console.WriteLine (result); // Writes 123
});
ContinueWith 本身返回一個 Task ,如果你想附加進一步的延續(xù),這很有用。但是,如果任務(wù)出錯,則必須直接處理 AggregateException,并編寫額外的代碼以在 UI 應(yīng)用程序中封送延續(xù)(請參閱中的)。在非 UI 上下文中,如果您希望延續(xù)在同一線程上執(zhí)行,則必須指定 TaskContinuationOptions.ExecuteSyncly;否則它將反彈到線程池。繼續(xù)在并行編程方案中特別有用;我們將在第中詳細介紹它。
我們已經(jīng)了解了 Task.Run 如何創(chuàng)建一個在池(或非池)線程上運行委托的任務(wù)。創(chuàng)建任務(wù)的另一種方法是使用 任務(wù)完成源 .
TaskCompletionSource 允許您從稍后開始和完成的任何操作中創(chuàng)建任務(wù)。它的工作原理是為您提供手動驅(qū)動的“從屬”任務(wù) - 通過指示操作何時完成或出現(xiàn)故障。這是 I/O 密集型工作的理想選擇:您可以獲得任務(wù)的所有好處(以及它們傳播返回值、異常和延續(xù)的能力),而不會在操作期間阻塞線程。
要使用 TaskCompletionSource ,您只需實例化該類。它公開一個 Task 屬性,該屬性返回一個任務(wù),您可以在該任務(wù)上等待并附加延續(xù) — 就像任何其他任務(wù)一樣。但是,該任務(wù)完全由 TaskCompletionSource 對象通過以下方法控制:
public class TaskCompletionSource<TResult>
{
public void SetResult (TResult result);
public void SetException (Exception exception);
public void SetCanceled();
public bool TrySetResult (TResult result);
public bool TrySetException (Exception exception);
public bool TrySetCanceled();
public bool TrySetCanceled (CancellationToken cancellationToken);
...
}
調(diào)用這些方法中的任何一個都會發(fā)出,將其置于已完成、出錯或已取消狀態(tài)(我們將在一節(jié)中介紹后者)。您應(yīng)該只調(diào)用一次這些方法之一:如果再次調(diào)用,SetResult 、SetException 或 SetCanceled 將引發(fā)異常,而 Try* 方法返回 false 。
下面的示例在等待五秒鐘后打印 42:
var tcs=new TaskCompletionSource<int>();
new Thread (()=> { Thread.Sleep (5000); tcs.SetResult (42); })
{ IsBackground=true }
.Start();
Task<int> task=tcs.Task; // Our "slave" task.
Console.WriteLine (task.Result); // 42
使用 任務(wù)完成源 ,我們可以編寫自己的 Run 方法:
Task<TResult> Run<TResult> (Func<TResult> function)
{
var tcs=new TaskCompletionSource<TResult>();
new Thread (()=>
{
try { tcs.SetResult (function()); }
catch (Exception ex) { tcs.SetException (ex); }
}).Start();
return tcs.Task;
}
...
Task<int> task=Run (()=> { Thread.Sleep (5000); return 42; });
調(diào)用此方法等效于使用 TaskCreationOptions.LongRun 選項調(diào)用 Task.Factory.StartNew 來請求非池化線程。
TaskCompletionSource的真正力量在于創(chuàng)建不占用線程的任務(wù)。例如,假設(shè)一個任務(wù)等待五秒鐘,然后返回數(shù)字 42。我們可以使用 Timer 類在沒有線程的情況下編寫它,該類在 CLR(反過來還有操作系統(tǒng))的幫助下,在 毫秒內(nèi)觸發(fā)一個事件(我們將在第 中重新訪問計時器):
Task<int> GetAnswerToLife()
{
var tcs=new TaskCompletionSource<int>();
// Create a timer that fires once in 5000 ms:
var timer=new System.Timers.Timer (5000) { AutoReset=false };
timer.Elapsed +=delegate { timer.Dispose(); tcs.SetResult (42); };
timer.Start();
return tcs.Task;
}
因此,我們的方法返回一個任務(wù),該任務(wù)在五秒后完成,結(jié)果為 42。通過將延續(xù)附加到任務(wù),我們可以在不阻塞線程的情況下編寫其結(jié)果:
var awaiter=GetAnswerToLife().GetAwaiter();
awaiter.OnCompleted (()=> Console.WriteLine (awaiter.GetResult()));
我們可以使其更有用,并通過參數(shù)化延遲時間和擺脫返回值將其轉(zhuǎn)換為通用的 Delay 方法。這意味著讓它返回一個任務(wù)而不是一個任務(wù)<int> 。但是,沒有非泛型版本的 任務(wù)完成源 ,這意味著我們不能直接創(chuàng)建非泛型任務(wù)。解決方法很簡單:因為 Task<TResult> 派生自 任務(wù) ,我們創(chuàng)建一個,然后將其給你的隱式轉(zhuǎn)換為任務(wù),如下所示:TaskCompletionSource<anything>Task<anything>
var tcs=new TaskCompletionSource<object>();
Task task=tcs.Task;
現(xiàn)在我們可以編寫通用的 Delay 方法:
Task Delay (int milliseconds)
{
var tcs=new TaskCompletionSource<object>();
var timer=new System.Timers.Timer (milliseconds) { AutoReset=false };
timer.Elapsed +=delegate { timer.Dispose(); tcs.SetResult (null); };
timer.Start();
return tcs.Task;
}
.NET 5 引入了一個非通用的 TaskCompletionSource,因此如果您的目標是 .NET 5 或更高版本,則可以將 TaskCompletionSource<object> 替換為 TaskCompletionSource。
以下是我們?nèi)绾问褂盟谖迕牒髮憽?2”:
Delay (5000).GetAwaiter().OnCompleted (()=> Console.WriteLine (42));
我們使用不帶線程的 TaskCompletionSource 意味著線程僅在延續(xù)開始時(五秒后)才會參與。我們可以通過一次啟動 10,000 個這樣的操作來證明這一點,而不會出錯或過度資源:
for (int i=0; i < 10000; i++)
Delay (5000).GetAwaiter().OnCompleted (()=> Console.WriteLine (42));
計時器在池化線程上觸發(fā)回調(diào),因此五秒鐘后,線程池將收到 10,000 個請求,以調(diào)用 TaskCompletionSource 上的 SetResult(null)。如果請求到達的速度快于處理速度,則線程池將通過排隊然后以 CPU 的最佳并行級別處理它們來響應(yīng)。如果線程綁定作業(yè)運行時間較短,這是理想的選擇,在這種情況下也是如此:線程綁定作業(yè)只是對 SetResult 的調(diào)用,加上將延續(xù)發(fā)布到同步上下文的操作(在 UI 應(yīng)用程序中)或其他延續(xù)本身( Console.WriteLine(42) )。
我們剛剛編寫的 Delay 方法非常有用,可以作為 Task 類上的靜態(tài)方法使用:
Task.Delay (5000).GetAwaiter().OnCompleted (()=> Console.WriteLine (42));
或
Task.Delay (5000).ContinueWith (ant=> Console.WriteLine (42));
Task.Delay 是 Thread.Sleep 的等價物。
在演示 TaskCompletionSource 時,我們最終編寫了方法。在本節(jié)中,我們將準確定義異步操作是什么,并解釋這如何導(dǎo)致異步編程。
在返回到調(diào)用方完成其工作。
可以在返回到調(diào)用方完成(大部分或全部)工作。
您編寫和調(diào)用的大多數(shù)方法是同步的。一個例子是List<T>。Add , or Console.WriteLine , or Thread.Sleep 。異步方法不太常見,它們會啟動性,因為工作與調(diào)用方并行進行。異步方法通??焖伲ɑ蛄⒓矗┓祷亟o調(diào)用方;因此,它們也稱為。
到目前為止,我們看到的大多數(shù)異步方法都可以描述為通用方法:
此外,我們在中討論的一些方法( 調(diào)度程序.開始調(diào)用 , 控制.開始調(diào)用 和 SynchronizationContext.Post )是異步的,我們在中編寫的方法也是如此,包括延遲 。
異步編程的原則是異步編寫長時間運行(或可能長時間運行)的函數(shù)。這與同步編寫長時間運行的函數(shù),然后根據(jù)需要從新線程或任務(wù)調(diào)用這些函數(shù)以引入并發(fā)性的傳統(tǒng)方法形成對比。
與異步方法的不同之處在于,并發(fā)性是在長時間運行的函數(shù)啟動的,而不是從函數(shù)啟動的。這有兩個好處:
這反過來又導(dǎo)致了異步編程的兩種不同用途。第一種是編寫(通常是服務(wù)器端)應(yīng)用程序,以有效地處理大量并發(fā) I/O。這里的挑戰(zhàn)不是線程(因為通常共享狀態(tài)最?。蔷€程;特別是,不為每個網(wǎng)絡(luò)請求消耗一個線程。因此,在此上下文中,只有 I/O 綁定操作才能從異步中受益。
第二個用途是簡化富客戶端應(yīng)用程序中的線程安全。隨著程序規(guī)模的擴大,這一點尤其重要,因為為了處理復(fù)雜性,我們通常會將較大的方法重構(gòu)為較小的方法,從而導(dǎo)致相互調(diào)用的方法鏈()。
對于傳統(tǒng)的調(diào)用圖,如果圖中的任何操作長時間運行,我們必須在工作線程上運行整個調(diào)用圖以維護響應(yīng)式 UI。因此,我們最終得到一個跨越許多方法的并發(fā)操作(這需要考慮圖中每個方法的線程安全性。
對于調(diào)用圖,我們不需要啟動線程,直到實際需要它,通常在圖中較低(或者在 I/O 綁定操作的情況下根本不啟動)。所有其他方法都可以完全在 UI 線程上運行,線程安全性大大簡化。這會導(dǎo)致 - 一系列小型并發(fā)操作,執(zhí)行在這些操作之間反彈到 UI 線程。
為了從中受益,需要異步編寫 I/O 和計算綁定操作;一個好的經(jīng)驗法則是包括可能需要超過 50 毫秒的任何內(nèi)容。
(另一方面,細粒度的異步可能會損害性能,因為異步操作會產(chǎn)生開銷 — 請參閱
在本章中,我們將主要關(guān)注富客戶端方案,這是兩者中更復(fù)雜的方案。在第中,我們給出了兩個示例來說明I/O綁定場景(參見“和)。
UWP 框架鼓勵異步編程,使某些長時間運行的方法的同步版本不公開或引發(fā)異常。相反,必須調(diào)用返回任務(wù)(或可通過 AsTask 擴展方法轉(zhuǎn)換為任務(wù)的對象)的異步方法。
任務(wù)非常適合異步編程,因為它們支持延續(xù),這對于異步至關(guān)重要(考慮我們在中編寫的 Delay 方法)。在編寫延遲時,我們使用了TaskCompletionSource,這是實現(xiàn)“底層”I/O綁定異步方法的標準方法。
對于計算綁定方法,我們使用 Task.Run 啟動線程綁定并發(fā)。只需將任務(wù)返回給調(diào)用方,我們就會創(chuàng)建一個異步方法。異步編程的區(qū)別在于,我們的目標是在調(diào)用圖中較低的位置執(zhí)行此操作,以便在富客戶端應(yīng)用程序中,更高級別的方法可以保留在UI線程和訪問控制以及共享狀態(tài)上,而不會出現(xiàn)線程安全問題。為了說明這一點,請考慮以下使用所有可用內(nèi)核計算和計數(shù)素數(shù)的方法(我們將在第 中討論 ParallelEnumerable):
int GetPrimesCount (int start, int count)
{
return
ParallelEnumerable.Range (start, count).Count (n=>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i=> n % i > 0));
}
這如何工作的細節(jié)并不重要;重要的是它可能需要一段時間才能運行。我們可以通過編寫另一個方法來調(diào)用它來演示這一點:
void DisplayPrimeCounts()
{
for (int i=0; i < 10; i++)
Console.WriteLine (GetPrimesCount (i*1000000 + 2, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
Console.WriteLine ("Done!");
}
下面是輸出:
78498 primes between 0 and 999999
70435 primes between 1000000 and 1999999
67883 primes between 2000000 and 2999999
66330 primes between 3000000 and 3999999
65367 primes between 4000000 and 4999999
64336 primes between 5000000 and 5999999
63799 primes between 6000000 and 6999999
63129 primes between 7000000 and 7999999
62712 primes between 8000000 and 8999999
62090 primes between 9000000 and 9999999
現(xiàn)在我們有一個,DisplayPrimeCounts調(diào)用GetPrimesCount。前者使用Console.WriteLine來簡化,盡管實際上它更有可能更新富客戶端應(yīng)用程序中的UI控件,正如我們稍后演示的那樣。我們可以為此調(diào)用圖啟動粗粒度并發(fā),如下所示:
Task.Run (()=> DisplayPrimeCounts());
使用細粒度異步方法,我們從編寫 GetPrimesCount 的異步版本開始:
Task<int> GetPrimesCountAsync (int start, int count)
{
return Task.Run (()=>
ParallelEnumerable.Range (start, count).Count (n=>
Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i=> n % i > 0)));
}
現(xiàn)在我們必須修改 DisplayPrimeCounts,以便它調(diào)用 .這就是 C# 的 await 和 async 關(guān)鍵字發(fā)揮作用的地方,因為否則這樣做比聽起來更棘手。如果我們簡單地修改循環(huán)如下GetPrimesCountAsync
for (int i=0; i < 10; i++)
{
var awaiter=GetPrimesCountAsync (i*1000000 + 2, 1000000).GetAwaiter();
awaiter.OnCompleted (()=>
Console.WriteLine (awaiter.GetResult() + " primes between... "));
}
Console.WriteLine ("Done");
循環(huán)將快速旋轉(zhuǎn) 10 次迭代(方法是非阻塞的),所有 10 個操作將并行執(zhí)行(然后是過早的“完成”)。
在這種情況下,并行執(zhí)行這些任務(wù)是不可取的,因為它們的內(nèi)部實現(xiàn)已經(jīng)并行化;它只會讓我們等待更長的時間才能看到第一個結(jié)果(并搞砸排序)。
但是,需要任務(wù)執(zhí)行還有一個更常見的原因,即任務(wù) B 依賴于任務(wù) A 的結(jié)果。例如,在獲取網(wǎng)頁時,DNS 查找必須在 HTTP 請求之前進行。
為了使它們按順序運行,我們必須從延續(xù)本身觸發(fā)下一個循環(huán)迭代。這意味著消除 for 循環(huán)并在延續(xù)中訴諸遞歸調(diào)用:
void DisplayPrimeCounts()
{
DisplayPrimeCountsFrom (0);
}
void DisplayPrimeCountsFrom (int i)
{
var awaiter=GetPrimesCountAsync (i*1000000 + 2, 1000000).GetAwaiter();
awaiter.OnCompleted (()=>
{
Console.WriteLine (awaiter.GetResult() + " primes between...");
if (++i < 10) DisplayPrimeCountsFrom (i);
else Console.WriteLine ("Done");
});
}
如果我們想使 DisplayPrimesCount 異步,返回它在完成時發(fā)出信號的任務(wù),情況會變得更糟。要完成此操作,需要創(chuàng)建一個 任務(wù)完成源:
Task DisplayPrimeCountsAsync()
{
var machine=new PrimesStateMachine();
machine.DisplayPrimeCountsFrom (0);
return machine.Task;
}
class PrimesStateMachine
{
TaskCompletionSource<object> _tcs=new TaskCompletionSource<object>();
public Task Task { get { return _tcs.Task; } }
public void DisplayPrimeCountsFrom (int i)
{
var awaiter=GetPrimesCountAsync (i*1000000+2, 1000000).GetAwaiter();
awaiter.OnCompleted (()=>
{
Console.WriteLine (awaiter.GetResult());
if (++i < 10) DisplayPrimeCountsFrom (i);
else { Console.WriteLine ("Done"); _tcs.SetResult (null); }
});
}
}
幸運的是,C# 的為我們完成了所有這些工作。使用 async 和 await 關(guān)鍵字,我們只需要寫這個:
async Task DisplayPrimeCountsAsync()
{
for (int i=0; i < 10; i++)
Console.WriteLine (await GetPrimesCountAsync (i*1000000 + 2, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
Console.WriteLine ("Done!");
}
因此,異步和等待對于實現(xiàn)異步至關(guān)重要,而不會過于復(fù)雜?,F(xiàn)在讓我們看看這些關(guān)鍵字是如何工作的。
另一種看待這個問題的方法是命令式循環(huán)結(jié)構(gòu)(for、foreach 等)不能很好地與延續(xù)混合,因為它們依賴于方法的(“這個循環(huán)還要運行多少次?”)。
盡管 async 和 await 關(guān)鍵字提供了一種解決方案,但有時可以通過將命令性循環(huán)構(gòu)造替換為(換句話說,LINQ 查詢)來以另一種方式解決它。這是 (Rx) 的基礎(chǔ),當(dāng)您想要對結(jié)果執(zhí)行查詢運算符或組合多個序列時,這可能是一個不錯的選擇。要付出的代價是,為了防止阻塞,Rx在基于的序列上運行,這在概念上可能很棘手。
async 和 await 關(guān)鍵字允許您編寫與同步代碼具有相同結(jié)構(gòu)和簡單性的異步代碼,同時消除異步編程的“管道”。
await 關(guān)鍵字簡化了延續(xù)的附加。從基本方案開始,編譯器對此進行了擴展
var result=await expression;
statement(s);
變成功能上與此類似的內(nèi)容:
var awaiter=expression.GetAwaiter();
awaiter.OnCompleted (()=>
{
var result=awaiter.GetResult();
statement(s);
});
編譯器還會發(fā)出代碼,以便在同步完成的情況下縮短延續(xù)(參見),并處理我們在后面的部分中了解到的各種細微差別。
為了演示,讓我們重新審視我們之前編寫的計算和計算素數(shù)的異步方法:
Task<int> GetPrimesCountAsync (int start, int count)
{
return Task.Run (()=>
ParallelEnumerable.Range (start, count).Count (n=>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i=> n % i > 0)));
}
使用 await 關(guān)鍵字,我們可以按如下方式調(diào)用它:
int result=await GetPrimesCountAsync (2, 1000000);
Console.WriteLine (result);
要編譯,我們需要將異步修飾符添加到包含方法中:
async void DisplayPrimesCount()
{
int result=await GetPrimesCountAsync (2, 1000000);
Console.WriteLine (result);
}
async 修飾符指示編譯器在該方法中出現(xiàn)歧義時將 await 視為關(guān)鍵字而不是標識符(這可確保在 C# 5 之前編寫的可能使用 await 作為標識符的代碼仍將編譯而不會出錯)。異步修飾符只能應(yīng)用于返回 void 或(稍后您將看到的)任務(wù)或任務(wù)<TResult> 的方法(和 lambda 表達式)。
異步修飾符類似于不安全修飾符,因為它對方法的簽名或公共元數(shù)據(jù)沒有影響;它僅影響方法發(fā)生的情況。因此,在接口中使用異步是沒有意義的。但是,例如,在覆蓋非異步虛擬方法時引入異步是合法的,只要您保持簽名相同。
具有異步修飾符的方法稱為,因為它們本身通常是異步的。為了了解原因,讓我們看看執(zhí)行如何通過異步函數(shù)進行。
遇到 await 表達式后,執(zhí)行(通常)返回到調(diào)用方,就像迭代器中的 yield return 一樣。但在返回之前,運行時會將延續(xù)附加到等待的任務(wù),確保在任務(wù)完成時,執(zhí)行將跳回到方法中,并從中斷的位置繼續(xù)。如果任務(wù)出錯,則重新引發(fā)其異常,否則將其返回值分配給 await 表達式。我們可以通過查看我們剛剛檢查的異步方法的邏輯擴展來總結(jié)我們剛才所說的一切:
void DisplayPrimesCount()
{
var awaiter=GetPrimesCountAsync (2, 1000000).GetAwaiter();
awaiter.OnCompleted (()=>
{
int result=awaiter.GetResult();
Console.WriteLine (result);
});
}
您等待的表達式通常是一個任務(wù);但是,任何具有返回 GetAwaiter 方法的對象(實現(xiàn) INotifyCompletion.OnComplete,并使用適當(dāng)類型的 GetResult 方法和布爾 IsCompleted 屬性)將滿足編譯器的要求。
請注意,我們的 await 表達式的計算結(jié)果為 int 類型;這是因為我們等待的表達式是一個 Task<int>(其 GetAwaiter()。GetResult() 方法返回一個 int )。
等待非通用任務(wù)是合法的,并生成一個 void 表達式:
await Task.Delay (5000);
Console.WriteLine ("Five seconds passed!");
await 表達式的真正強大之處在于它們幾乎可以出現(xiàn)在代碼中的任何位置。具體而言,await 表達式可以代替任何表達式(在異步函數(shù)中)出現(xiàn),但鎖表達式或不安全上下文中除外。
在下面的示例中,我們在循環(huán)中等待:
async void DisplayPrimeCounts()
{
for (int i=0; i < 10; i++)
Console.WriteLine (await GetPrimesCountAsync (i*1000000+2, 1000000));
}
在第一次執(zhí)行 GetPrimesCountAsync 時,執(zhí)行會通過 await 表達式返回給調(diào)用方。當(dāng)方法完成(或出錯)時,執(zhí)行將從中斷的位置繼續(xù),并保留局部變量和循環(huán)計數(shù)器的值。
如果沒有 await 關(guān)鍵字,最簡單的等價物可能是我們在中寫的示例。但是,編譯器采用更通用的策略,將此類方法重構(gòu)到狀態(tài)機中(就像迭代器一樣)。
編譯器依賴于延續(xù)(通過等待者模式)在等待表達式之后恢復(fù)執(zhí)行。這意味著,如果在富客戶端應(yīng)用程序的 UI 線程上運行,同步上下文可確保在同一線程上恢復(fù)執(zhí)行。否則,將在任務(wù)完成的任何線程上恢復(fù)執(zhí)行。線程的更改不會影響執(zhí)行順序,并且無關(guān)緊要,除非您以某種方式依賴于線程親和性,也許通過使用線程本地存儲(請參閱中的)。這就像游覽一個城市,叫出租車從一個目的地到另一個目的地。使用同步上下文,您將始終獲得相同的出租車;如果沒有同步上下文,您通常每次都會得到不同的出租車。但是,無論哪種情況,旅程都是一樣的。
我們可以通過編寫一個簡單的 UI 在更實際的上下文中演示異步函數(shù),該 UI 在調(diào)用計算綁定方法時保持響應(yīng)。讓我們從一個同步解決方案開始:
class TestUI : Window
{
Button _button=new Button { Content="Go" };
TextBlock _results=new TextBlock();
public TestUI()
{
var panel=new StackPanel();
panel.Children.Add (_button);
panel.Children.Add (_results);
Content=panel;
_button.Click +=(sender, args)=> Go();
}
void Go()
{
for (int i=1; i < 5; i++)
_results.Text +=GetPrimesCount (i * 1000000, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) +
Environment.NewLine;
}
int GetPrimesCount (int start, int count)
{
return ParallelEnumerable.Range (start, count).Count (n=>
Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i=> n % i > 0));
}
}
按下“Go”按鈕后,應(yīng)用程序在執(zhí)行計算綁定代碼所需的時間內(nèi)變得無響應(yīng)。異步有兩個步驟;首先是切換到我們在前面的示例中使用的異步版本的 GetPrimesCount:
Task<int> GetPrimesCountAsync (int start, int count)
{
return Task.Run (()=>
ParallelEnumerable.Range (start, count).Count (n=>
Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i=> n % i > 0)));
}
第二步是修改 Go 以調(diào)用 GetPrimesCountAsync:
async void Go()
{
_button.IsEnabled=false;
for (int i=1; i < 5; i++)
_results.Text +=await GetPrimesCountAsync (i * 1000000, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) +
Environment.NewLine;
_button.IsEnabled=true;
}
這說明了使用異步函數(shù)編程的簡單性:您可以像同步編程一樣編程,但調(diào)用異步函數(shù)而不是阻塞函數(shù)并等待它們。只有 GetPrimesCountAsync 中的代碼在工作線程上運行;Go 中的代碼在 UI 線程上“租用”時間。我們可以說 Go 偽地執(zhí)行消息循環(huán)(因為它的執(zhí)行與 UI 線程處理的其他事件穿插在一起)。使用此偽并發(fā)時,唯一可能發(fā)生搶占的時間點是在等待期間。這簡化了線程安全性:在我們的例子中,這可能導(dǎo)致的唯一問題是(在按鈕運行時再次單擊按鈕,我們通過禁用按鈕來防止)。真正的并發(fā)性發(fā)生在調(diào)用堆棧的較低位置,即 Task.Run 調(diào)用的代碼中。為了從此模型中受益,真正的并發(fā)代碼可防止訪問共享狀態(tài)或 UI 控件。
再舉一個例子,假設(shè)我們不是計算質(zhì)數(shù),而是下載幾個網(wǎng)頁并將它們的長度相加。.NET 公開了許多任務(wù)返回異步方法,其中之一是 中的 WebClient 類。DownloadDataTaskAsync 方法異步下載一個 URI 到一個字節(jié)數(shù)組,返回一個 任務(wù)<字節(jié)[]> ,所以通過等待它,我們得到一個字節(jié)[]?,F(xiàn)在讓我們重寫我們的 Go 方法:
async void Go()
{
_button.IsEnabled=false;
string[] urls="www.albahari.com www.oreilly.com www.linqpad.net".Split();
int totalLength=0;
try
{
foreach (string url in urls)
{
var uri=new Uri ("http://" + url);
byte[] data=await new WebClient().DownloadDataTaskAsync (uri);
_results.Text +="Length of " + url + " is " + data.Length +
Environment.NewLine;
totalLength +=data.Length;
}
_results.Text +="Total length: " + totalLength;
}
catch (WebException ex)
{
_results.Text +="Error: " + ex.Message;
}
finally { _button.IsEnabled=true; }
}
同樣,這反映了我們?nèi)绾瓮骄帉懰ㄊ褂?catch 和 finally 塊。即使執(zhí)行在第一個等待后返回給調(diào)用方,finally 塊也不會執(zhí)行,直到方法邏輯完成(由于其所有代碼執(zhí)行 - 或提前返回或未處理的異常)。
準確考慮下面發(fā)生的事情可能會有所幫助。首先,我們需要重新訪問在 UI 線程上運行消息循環(huán)的偽代碼:
Set synchronization context for this thread to WPF sync context
while (!thisApplication.Ended)
{
wait for something to appear in message queue
Got something: what kind of message is it?
Keyboard/mouse message -> fire an event handler
User BeginInvoke/Invoke message -> execute delegate
}
我們附加到 UI 元素的事件處理程序通過此消息循環(huán)執(zhí)行。當(dāng)我們的 Go 方法運行時,執(zhí)行一直持續(xù)到 await 表達式,然后返回到消息循環(huán)(釋放 UI 以響應(yīng)進一步的事件)。但是,編譯器對 await 的擴展可確保在返回之前設(shè)置延續(xù),以便在任務(wù)完成后從中斷的位置恢復(fù)執(zhí)行。由于我們在 UI 線程上等待,因此延續(xù)發(fā)布到同步上下文,同步上下文通過消息循環(huán)執(zhí)行它,從而使我們的整個 Go 方法在 UI 線程上偽并發(fā)執(zhí)行。True(I/O 綁定)并發(fā)發(fā)生在 DownloadDataTaskAsync 的實現(xiàn)中。
在 C# 5 之前,異步編程很困難,不僅因為沒有語言支持,還因為 .NET Framework 通過稱為 EAP 和 APM 的笨拙模式(請參閱而不是任務(wù)返回方法公開異步功能。
流行的解決方法是粗粒度并發(fā)(事實上,甚至還有一種稱為 BackgroundWorker 的類型來幫助解決這個問題)?;氐轿覀冊瓉淼氖纠?GetPrimesCount ,我們可以通過修改按鈕的事件處理程序來演示粗粒度異步,如下所示:
...
_button.Click +=(sender, args)=>
{
_button.IsEnabled=false;
Task.Run (()=> Go());
};
(我們選擇使用 Task.Run 而不是 BackgroundWorker ,因為后者不會簡化我們的特定示例。無論哪種情況,最終結(jié)果都是我們的整個同步調(diào)用圖(Go加GetPrimesCount)在工作線程上運行。由于 Go 更新了 UI 元素,我們現(xiàn)在必須使用 Dispatcher.BeginInvoke 亂扔代碼:
void Go()
{
for (int i=1; i < 5; i++)
{
int result=GetPrimesCount (i * 1000000, 1000000);
Dispatcher.BeginInvoke (new Action (()=>
_results.Text +=result + " primes between " + (i*1000000) +
" and " + ((i+1)*1000000-1) + Environment.NewLine));
}
Dispatcher.BeginInvoke (new Action (()=> _button.IsEnabled=true));
}
與異步版本不同,循環(huán)本身在工作線程上運行。這似乎無害,然而,即使在這種簡單的情況下,我們對多線程的使用也引入了競爭條件。(你能發(fā)現(xiàn)它嗎?如果沒有,請嘗試運行該程序:它幾乎肯定會變得明顯。
實現(xiàn)取消和進度報告會為線程安全錯誤創(chuàng)造更多可能性,方法中的任何其他代碼也是如此。例如,假設(shè)循環(huán)的上限不是硬編碼的,而是來自方法調(diào)用:
for (int i=1; i < GetUpperBound(); i++)
現(xiàn)在假設(shè) GetUpperBound() 從延遲加載的配置文件中讀取值,該文件在第一次調(diào)用時從磁盤加載。所有這些代碼現(xiàn)在都在工作線程上運行,這些代碼很可能不是線程安全的。這是在調(diào)用圖中啟動高位工作線程的危險。
對于任何異步函數(shù),都可以將 void 返回類型替換為 Task,以使方法本身(并且 await 可用)。無需進一步更改:
async Task PrintAnswerToLife() // We can return Task instead of void
{
await Task.Delay (5000);
int answer=21 * 2;
Console.WriteLine (answer);
}
請注意,我們不會在方法主體中顯式返回任務(wù)。編譯器制造任務(wù),并在方法完成(或未處理的異常)時發(fā)出信號。這使得創(chuàng)建異步調(diào)用鏈變得容易:
async Task Go()
{
await PrintAnswerToLife();
Console.WriteLine ("Done");
}
而且由于我們已經(jīng)聲明了帶有任務(wù)返回類型的 Go,因此 Go 本身是可以等待的。
編譯器擴展異步函數(shù),這些函數(shù)將任務(wù)返回到代碼中,該代碼使用 TaskCompletionSource 創(chuàng)建任務(wù),然后發(fā)出信號或出錯。
撇開細微差別不談,我們可以將PrintAnswerToLife擴展為以下功能等效項:
Task PrintAnswerToLife()
{
var tcs=new TaskCompletionSource<object>();
var awaiter=Task.Delay (5000).GetAwaiter();
awaiter.OnCompleted (()=>
{
try
{
awaiter.GetResult(); // Re-throw any exceptions
int answer=21 * 2;
Console.WriteLine (answer);
tcs.SetResult (null);
}
catch (Exception ex) { tcs.SetException (ex); }
});
return tcs.Task;
}
因此,每當(dāng)任務(wù)返回異步方法完成時,執(zhí)行都會跳回到等待它的任何內(nèi)容(通過延續(xù))。
在胖客戶端方案中,此時執(zhí)行將反彈回 UI 線程(如果它尚未在 UI 線程上)。否則,它會在延續(xù)返回的任何線程上繼續(xù)。這意味著冒泡異步調(diào)用圖沒有延遲成本,如果它是 UI 線程啟動的,則除了第一次“反彈”。
如果方法體返回 TResult>則可以返回 Task<TResult:
async Task<int> GetAnswerToLife()
{
await Task.Delay (5000);
int answer=21 * 2;
return answer; // Method has return type Task<int> we return int
}
在內(nèi)部,這會導(dǎo)致 TaskCompletionSource 發(fā)出值而不是 null 的信號。我們可以通過從 PrintAnswerToLife 調(diào)用它來演示 GetAnswerToLife(反過來,從 Go 調(diào)用):
async Task Go()
{
await PrintAnswerToLife();
Console.WriteLine ("Done");
}
async Task PrintAnswerToLife()
{
int answer=await GetAnswerToLife();
Console.WriteLine (answer);
}
async Task<int> GetAnswerToLife()
{
await Task.Delay (5000);
int answer=21 * 2;
return answer;
}
實際上,我們已經(jīng)將原始的PrintAnswerToLife重構(gòu)為兩種方法 - 就像我們同步編程一樣容易。與同步編程的相似性是有意的;下面是我們的調(diào)用圖的同步等價物,調(diào)用 Go() 在阻塞五秒后給出相同的結(jié)果:
void Go()
{
PrintAnswerToLife();
Console.WriteLine ("Done");
}
void PrintAnswerToLife()
{
int answer=GetAnswerToLife();
Console.WriteLine (answer);
}
int GetAnswerToLife()
{
Thread.Sleep (5000);
int answer=21 * 2;
return answer;
}
這也說明了如何在 C# 中使用異步函數(shù)進行設(shè)計的基本原理:
編譯器為異步函數(shù)制造任務(wù)的能力意味著在大多數(shù)情況下,您只需要在啟動 I/O 綁定并發(fā)的底層方法(相對罕見的情況下)顯式實例化 TaskCompletionSource。(對于啟動計算綁定并發(fā)的方法,您可以使用 Task.Run 創(chuàng)建任務(wù)。
要確切地了解其執(zhí)行方式,按如下方式重新排列我們的代碼會很有幫助:
async Task Go()
{
var task=PrintAnswerToLife();
await task; Console.WriteLine ("Done");
}
async Task PrintAnswerToLife()
{
var task=GetAnswerToLife();
int answer=await task; Console.WriteLine (answer);
}
async Task<int> GetAnswerToLife()
{
var task=Task.Delay (5000);
await task; int answer=21 * 2; return answer;
}
Go 調(diào)用 PrintAnswerToLife,它調(diào)用 GetAnswerToLife,調(diào)用 Delay,然后等待。await 導(dǎo)致執(zhí)行返回到 PrintAnswerToLife ,它本身在等待,返回到 Go ,它也在等待并返回給調(diào)用方。所有這些都同步發(fā)生,在名為 Go ;這是執(zhí)行的簡短階段。
五秒鐘后,延遲上的延續(xù)將觸發(fā),執(zhí)行將返回到池線程上的 GetAnswerToLife。(如果我們從 UI 線程開始,執(zhí)行現(xiàn)在會反彈到該線程。然后運行GetAnswerToLife中的其余語句,之后該方法的Task<int>以結(jié)果42完成,并在PrintAnswerToLife中執(zhí)行,這將執(zhí)行該方法中的其余語句。這個過程一直持續(xù)到 Go 的任務(wù)被標記為完成。
執(zhí)行流與我們之前顯示的同步調(diào)用圖匹配,因為我們遵循一種模式,即在調(diào)用每個異步方法后立即等待它。這將創(chuàng)建一個順序流,在調(diào)用圖中沒有并行性或重疊執(zhí)行。每個 await 表達式都會在執(zhí)行中創(chuàng)建一個“間隙”,之后程序?qū)闹袛嗟奈恢没謴?fù)。
調(diào)用異步方法而不等待它允許以下代碼并行執(zhí)行。您可能已經(jīng)注意到,在前面的示例中,我們有一個按鈕,其事件處理程序名為 Go ,如下所示:
_button.Click +=(sender, args)=> Go();
盡管 Go 是一種異步方法,但我們并沒有等待它,這確實有助于維護響應(yīng)式 UI 所需的并發(fā)性。
我們可以使用相同的原理并行運行兩個異步操作:
var task1=PrintAnswerToLife();
var task2=PrintAnswerToLife();
await task1; await task2;
(通過等待之后的兩個操作,我們在這一點上“結(jié)束”并行性。稍后,我們將介紹 WhenAll 任務(wù)組合器如何幫助處理此模式。
無論操作是否在 UI 線程上啟動,都會以這種方式創(chuàng)建的并發(fā),盡管其發(fā)生方式有所不同。在這兩種情況下,我們都會在啟動它的底層操作(例如 Task.Delay 或 Task.Run 的代碼)中獲得相同的“真”并發(fā)性。僅當(dāng)操作是在不存在同步上下文的情況下啟動時,調(diào)用堆棧中高于此值的方法才受 true 并發(fā)性的約束;否則,它們將受制于我們之前討論的偽并發(fā)(和簡化的線程安全),其中唯一可以搶占的地方是 await 語句。例如,這讓我們可以定義一個共享字段,_x ,并在 GetAnswerToLife 中遞增它而不會鎖定:
async Task<int> GetAnswerToLife()
{
_x++;
await Task.Delay (5000);
return 21 * 2;
}
(但是,我們無法假設(shè)_x在等待之前和之后具有相同的值。
就像普通的方法可以是異步的一樣
async Task NamedMethod()
{
await Task.Delay (1000);
Console.WriteLine ("Foo");
}
的方法(Lambda 表達式和匿名方法)也是如此,如果前面有 async 關(guān)鍵字:
Func<Task> unnamed=async ()=>
{
await Task.Delay (1000);
Console.WriteLine ("Foo");
};
我們可以以相同的方式調(diào)用和等待這些:
await NamedMethod();
await unnamed();
我們可以在附加事件處理程序時使用異步 lambda 表達式:
myButton.Click +=async (sender, args)=>
{
await Task.Delay (1000);
myButton.Content="Done";
};
這比具有相同效果的以下內(nèi)容更簡潔:
myButton.Click +=ButtonHandler;
...
async void ButtonHander (object sender, EventArgs args)
{
await Task.Delay (1000);
myButton.Content="Done";
};
異步 lambda 表達式也可以返回 Task<TResult> :
Func<Task<int>> unnamed=async ()=>
{
await Task.Delay (1000);
return 123;
};
int answer=await unnamed();
有了收益率回報,就可以寫一個迭代器了;使用 await ,您可以編寫一個異步函數(shù)。(來自 C# 8)結(jié)合了這些概念,并允許您編寫等待的迭代器,異步生成元素。此支持基于以下接口對,這些接口是我們中描述的枚舉接口的異步對應(yīng)項:
public interface IAsyncEnumerable<out T>
{
IAsyncEnumerator<T> GetAsyncEnumerator (...);
}
public interface IAsyncEnumerator<out T>: IAsyncDisposable
{
T Current { get; }
ValueTask<bool> MoveNextAsync();
}
ValueTask<T> 是一個包裝 Task<T> 的結(jié)構(gòu),在行為上類似于 Task<T>同時在任務(wù)同步完成時實現(xiàn)更高效的執(zhí)行(這在枚舉序列時經(jīng)常發(fā)生)。有關(guān)差異的討論,請參閱 IAsyncDisposable 是 IDisposable 的異步版本;如果您選擇手動實現(xiàn)接口,它提供了執(zhí)行清理的機會:
public interface IAsyncDisposable
{
ValueTask DisposeAsync();
}
從序列中獲取每個元素的操作( MoveNextAsync )是一種異步操作,因此當(dāng)元素以分段方式到達時(例如處理來自視頻流的數(shù)據(jù)時),異步流是合適的。相反,當(dāng)序列時,以下類型更合適,但元素在到達時會一起到達:
Task<IEnumerable<T>>
若要生成異步流,請編寫一個結(jié)合了迭代器和異步方法原則的方法。換句話說,你的方法應(yīng)該同時包括 yield return 和 await ,并且它應(yīng)該返回 IAsyncEnumerable<T> :
async IAsyncEnumerable<int> RangeAsync (
int start, int count, int delay)
{
for (int i=start; i < start + count; i++)
{
await Task.Delay (delay);
yield return i;
}
}
要使用異步流,請使用 await foreach 語句:
await foreach (var number in RangeAsync (0, 10, 500))
Console.WriteLine (number);
請注意,數(shù)據(jù)每 500 毫秒穩(wěn)定到達一次(或者在現(xiàn)實生活中,當(dāng)數(shù)據(jù)可用時)。將此與使用 Task<IEnumerable<T 的類似構(gòu)造進行對比>>在最后一條數(shù)據(jù)可用之前不會返回任何數(shù)據(jù):
static async Task<IEnumerable<int>> RangeTaskAsync (int start, int count,
int delay)
{
List<int> data=new List<int>();
for (int i=start; i < start + count; i++)
{
await Task.Delay (delay);
data.Add (i);
}
return data;
}
下面介紹如何將它與 foreach 語句一起使用:
foreach (var data in await RangeTaskAsync(0, 10, 500))
Console.WriteLine (data);
NuGet 包定義了通過 IAsyncEnumerable<T> 運行的 LINQ 查詢運算符,允許您像使用 IEnumerable<T> 一樣編寫查詢。
例如,我們可以在上一節(jié)中定義的 RangeAsync 方法上編寫 LINQ 查詢,如下所示:
IAsyncEnumerable<int> query=from i in RangeAsync (0, 10, 500)
where i % 2==0 // Even numbers only.
select i * 10; // Multiply by 10.
await foreach (var number in query)
Console.WriteLine (number);
這將輸出 0、20、40 等。
如果您熟悉反應(yīng)式擴展,也可以通過調(diào)用 ToObservable 擴展方法從其(更強大的)查詢運算符中受益,該方法將 IAsyncEnumerable<T> 轉(zhuǎn)換為 IObservable<T>。還可以使用ToAsyncEnumerable擴展方法,以反向轉(zhuǎn)換。
ASP.Net 核心控制器操作現(xiàn)在可以返回 IAsyncEnumerable<T> 。此類方法必須標記為異步。例如:
[HttpGet]
public async IAsyncEnumerable<string> Get()
{
using var dbContext=new BookContext();
await foreach (var title in dbContext.Books
.Select(b=> b.Title)
.AsAsyncEnumerable())
yield return title;
}
如果你正在開發(fā) UWP 應(yīng)用程序,則需要使用操作系統(tǒng)中定義的 WinRT 類型。WinRT相當(dāng)于Task的是IAsyncAction,相當(dāng)于Task<TResult>是IAsyncOperation<TResult>。對于報告進度的操作,等效項是 IAsyncActionWithProgress<TProgress> 和 IAsyncOperationWithProgress<TResult, TProgress> 。它們都是在 Windows.Foundation 命名空間中定義的。
您可以通過 AsTask 擴展方法從 Task 轉(zhuǎn)換為 Task 或 Task<Task>seult:
Task<StorageFile> fileTask=KnownFolders.DocumentsLibrary.CreateFileAsync
("test.txt").AsTask();
或者,或者您可以直接等待他們:
StorageFile file=await KnownFolders.DocumentsLibrary.CreateFileAsync
("test.txt");
由于 COM 類型系統(tǒng)的限制,IAsyncActionWithProgress<TProgress> 和 IAsyncOperationWithProgress<TResult, TProgress> 并不像您期望的那樣基于 IAsyncAction。相反,兩者都繼承自名為 IAsyncInfo 的公共基類型。
AsTask 方法也會重載以接受取消令牌(請參閱)。當(dāng)鏈接到 WithProgress 變體時,它也可以接受 IProgress<T> 對象(請參閱)。
我們已經(jīng)看到同步上下文的存在在發(fā)布延續(xù)方面的重要性。還有其他幾種更微妙的方式,此類同步上下文與 void 返回異步函數(shù)一起發(fā)揮作用。這些不是 C# 編譯器擴展的直接結(jié)果,而是編譯器在擴展異步函數(shù)時使用的 System.CompilerServices 命名空間中的 Async*MethodBuilder 類型的。
在胖客戶端應(yīng)用程序中,通常的做法是依靠中心異常處理事件(WPF 中的 Application.DispatcherUnhandledException)來處理 UI 線程上引發(fā)的未經(jīng)處理的異常。在 ASP.NET Core 應(yīng)用程序中,ConfigureServices 方法中的自定義 ExceptionFilterAttribute 執(zhí)行類似的工作。在內(nèi)部,它們通過在自己的 try / catch 塊中調(diào)用 UI 事件(或在 ASP.NET Core 中,頁面處理方法的管道)來工作。
頂級異步函數(shù)使這變得復(fù)雜。請考慮以下用于按鈕單擊的事件處理程序:
async void ButtonClick (object sender, RoutedEventArgs args)
{
await Task.Delay(1000);
throw new Exception ("Will this be ignored?");
}
單擊按鈕并運行事件處理程序時,執(zhí)行將正常返回到 await 語句之后的消息循環(huán),并且消息循環(huán)中的 catch 塊無法捕獲一秒鐘后引發(fā)的異常。
為了緩解此問題,AsyncVoidMethodBuilder 捕獲未經(jīng)處理的異常(在返回 void 的異步函數(shù)中),并將其發(fā)布到同步上下文(如果存在),從而確保全局異常處理事件仍會觸發(fā)。
編譯器僅將此邏輯應(yīng)用于返回 的異步函數(shù)。因此,如果我們更改 ButtonClick 以返回任務(wù)而不是 void,則未處理的異常將錯誤生成的任務(wù),然后該任務(wù)將無處可去(導(dǎo)致的異常)。
一個有趣的細微差別是,無論您是在等待之前還是之后投擲都沒有區(qū)別。因此,在下面的示例中,異常將發(fā)布到同步上下文(如果存在),而不是發(fā)布到調(diào)用方:
async void Foo() { throw null; await Task.Delay(1000); }
(如果不存在同步上下文,則異常將在線程池上傳播,從而終止應(yīng)用程序。
異常不直接拋回調(diào)用方的原因是為了確保可預(yù)測性和一致性。在下面的示例中,InvalidOperationException 將始終具有與錯誤結(jié)果任務(wù)相同的效果 — 無論 :someCondition
async Task Foo()
{
if (someCondition) await Task.Delay (100);
throw new InvalidOperationException();
}
迭代器的工作方式類似:
IEnumerable<int> Foo() { throw null; yield return 123; }
在此示例中,永遠不會將異常直接拋出回調(diào)用方:直到枚舉序列才會引發(fā)異常。
如果存在同步上下文,則返回 void 的異步函數(shù)也會在進入函數(shù)時調(diào)用其 OperationStarted 方法,并在函數(shù)完成時調(diào)用其 OperationCompleted 方法。
如果為單元測試 void 返回異步方法編寫自定義同步上下文,則重寫這些方法很有用。上進行了討論。
異步函數(shù)可以在等待返回。請考慮以下緩存網(wǎng)頁下載的方法:
static Dictionary<string,string> _cache=new Dictionary<string,string>();
async Task<string> GetWebPageAsync (string uri)
{
string html;
if (_cache.TryGetValue (uri, out html)) return html;
return _cache [uri]=await new WebClient().DownloadStringTaskAsync (uri);
}
如果緩存中已存在 URI,則執(zhí)行將返回到調(diào)用方,而不會發(fā)生等待,并且該方法返回。這稱為。
當(dāng)您等待同步完成的任務(wù)時,執(zhí)行不會返回到調(diào)用方并通過延續(xù)反彈;相反,它會立即進入下一條語句。編譯器通過檢查等待器上的 IsCompleted 屬性來實現(xiàn)此優(yōu)化;換句話說,只要你等待
Console.WriteLine (await GetWebPageAsync ("http://oreilly.com"));
編譯器發(fā)出代碼以在同步完成的情況下短路延續(xù):
var awaiter=GetWebPageAsync().GetAwaiter();
if (awaiter.IsCompleted)
Console.WriteLine (awaiter.GetResult());
else
awaiter.OnCompleted (()=> Console.WriteLine (awaiter.GetResult());
等待同步返回的異步函數(shù)仍然會產(chǎn)生(非常)小的開銷——在 20 年代的 PC 上可能是 2019 納秒。
相比之下,彈到線程池會引入上下文切換的成本(可能是一到兩微秒),而彈跳到 UI 消息循環(huán)的成本至少是其 10 倍(如果 UI 線程繁忙,則更長)。
編寫等待的異步方法甚至是合法的,盡管編譯器會生成警告:
async Task<string> Foo() { return "abc"; }
如果您的實現(xiàn)碰巧不需要異步,則在重寫虛擬/抽象方法時,此類方法可能很有用。(一個例子是MemoryStream的ReadAsync / WriteAsync方法;見。實現(xiàn)相同結(jié)果的另一種方法是使用 Task.FromResult ,它返回一個已經(jīng)發(fā)出信號的任務(wù):
Task<string> Foo() { return Task.FromResult ("abc"); }
如果從 UI 線程調(diào)用,我們的 GetWebPageAsync 方法是隱式線程安全的,因為您可以連續(xù)多次調(diào)用它(從而啟動多個并發(fā)下載),并且不需要鎖定來保護緩存。但是,如果一系列調(diào)用是針對同一 URI,我們最終會啟動多個冗余下載,所有這些下載最終都會更新相同的緩存條目(最后一個獲勝)。雖然沒有錯誤,但如果對同一 URI 的后續(xù)調(diào)用可以(異步)等待正在進行的請求的結(jié)果,則會更有效。
有一種簡單的方法可以實現(xiàn)這一點 - 無需訴諸鎖或信號結(jié)構(gòu)。我們創(chuàng)建一個“期貨”緩存(任務(wù)<字符串> ):
static Dictionary<string,Task<string>> _cache=new Dictionary<string,Task<string>>();
Task<string> GetWebPageAsync (string uri)
{
if (_cache.TryGetValue (uri, out var downloadTask)) return downloadTask;
return _cache [uri]=new WebClient().DownloadStringTaskAsync (uri);
}
(請注意,我們不會將該方法標記為異步,因為我們直接返回從調(diào)用WebClient的方法中獲得的任務(wù)。
如果我們使用相同的 URI 重復(fù)調(diào)用 GetWebPageAsync,我們現(xiàn)在保證會得到相同的 Task<string> 對象。(這還具有最小化垃圾回收負載的額外好處。如果任務(wù)完成,等待它很便宜,這要歸功于我們剛剛討論的編譯器優(yōu)化。
我們可以進一步擴展我們的示例,通過鎖定整個方法主體,使其在沒有同步上下文保護的情況下實現(xiàn)線程安全:
lock (_cache)
if (_cache.TryGetValue (uri, out var downloadTask))
return downloadTask;
else
return _cache [uri]=new WebClient().DownloadStringTaskAsync (uri);
}
這是有效的,因為我們在下載頁面期間沒有鎖定(這會損害并發(fā)性);我們將鎖定檢查緩存、在必要時啟動新任務(wù)以及使用該任務(wù)更新緩存的短時間內(nèi)。
ValueTask<T> 適用于微優(yōu)化方案,您可能永遠不需要編寫返回此類型的方法。但是,了解我們在下一節(jié)中概述的預(yù)防措施仍然是值得的,因為某些 .NET 方法返回 ValueTask<T> ,并且 IAsyncEnumerable<T> 也使用它。
我們剛剛描述了編譯器如何在同步完成的任務(wù)上優(yōu)化 await 表達式 — 通過縮短延續(xù)并立即繼續(xù)執(zhí)行下一條語句。如果同步完成是由于緩存,我們看到緩存任務(wù)本身可以提供優(yōu)雅高效的解決方案。
但是,在所有同步完成方案中緩存任務(wù)是不切實際的。有時,必須實例化一個新任務(wù),這會產(chǎn)生(微小的)潛在效率低下。這是因為 Task 和 Task<T> 是引用類型,因此實例化需要基于堆的內(nèi)存分配和后續(xù)集合。優(yōu)化的一種極端形式是編寫免分配的代碼;換句話說,這不會實例化任何引用類型,不會給垃圾回收增加負擔(dān)。為了支持這種模式,引入了 ValueTask 和 ValueTask<T> 結(jié)構(gòu),編譯器允許用它們代替 Task 和 Task<T> :
async ValueTask<int> Foo() { ... }
等待 ValueTask<T> 無需分配:
int answer=await Foo(); // (Potentially) allocation-free
如果操作未同步完成,ValueTask<T> 會在后臺創(chuàng)建一個普通的 Task<T>(它將等待轉(zhuǎn)發(fā)到該任務(wù)),并且不會獲得任何結(jié)果。
可以通過調(diào)用 AsTask 方法將 ValueTask<T> 轉(zhuǎn)換為普通 Task<T>。
還有一個非通用版本——ValueTask——類似于Task。
ValueTask<T> 相對不尋常,因為它被定義為出于性能原因的結(jié)構(gòu)。這意味著它被值類型語義所困擾,可能會導(dǎo)致意外。若要避免不正確的行為,必須避免以下情況:
如果需要執(zhí)行這些操作,請調(diào)用 。AsTask() 并改為對生成的 Task 進行操作。
避免這些陷阱的最簡單方法是直接等待方法調(diào)用,例如:
await Foo(); // Safe
錯誤行為的大門在將(值)任務(wù)分配給變量時打開
ValueTask<int> valueTask=Foo(); // Caution!
// Our use of valueTask can now lead to errors.
可以通過立即轉(zhuǎn)換為普通任務(wù)來緩解:
Task<int> task=Foo().AsTask(); // Safe
// task is safe to work with.
對于在循環(huán)中多次調(diào)用的方法,可以通過調(diào)用 ConfigureAwait 來避免重復(fù)彈跳到 UI 消息循環(huán)的成本。這會強制任務(wù)不將延續(xù)反彈到同步上下文,從而將開銷降低到更接近上下文切換的成本(如果您正在等待的方法同步完成,則開銷要低得多):
async void A() { ... await B(); ... }
async Task B()
{
for (int i=0; i < 1000; i++)
await C().ConfigureAwait (false);
}
async Task C() { ... }
這意味著對于 B 和 C 方法,我們?nèi)∠?UI 應(yīng)用中的簡單線程安全模型,其中代碼在 UI 線程上運行,并且只能在 await 語句期間被搶占。但是,方法 A 不受影響,如果它在 UI 線程上啟動,它將保留在 UI 線程上。
此優(yōu)化在編寫庫時尤其重要:您不需要簡化線程安全的好處,因為您的代碼通常不與調(diào)用方共享狀態(tài),并且不訪問 UI 控件。(在我們的示例中,如果方法 C 知道操作可能運行時間較短,則同步完成也是有意義的。)
能夠在并發(fā)操作啟動后取消并發(fā)操作(可能是為了響應(yīng)用戶請求)通常很重要。實現(xiàn)這一點的一種簡單方法是使用取消標志,我們可以通過編寫這樣的類來封裝它:
class CancellationToken
{
public bool IsCancellationRequested { get; private set; }
public void Cancel() { IsCancellationRequested=true; }
public void ThrowIfCancellationRequested()
{
if (IsCancellationRequested)
throw new OperationCanceledException();
}
}
然后,我們可以編寫一個可取消的異步方法,如下所示:
async Task Foo (CancellationToken cancellationToken)
{
for (int i=0; i < 10; i++)
{
Console.WriteLine (i);
await Task.Delay (1000);
cancellationToken.ThrowIfCancellationRequested();
}
}
當(dāng)調(diào)用方想要取消時,它會在傳遞給 Foo 的取消令牌上調(diào)用 Cancel。這會將 IsCancelRequest 設(shè)置為 true,這會導(dǎo)致 Foo 在不久之后出現(xiàn) OperationCanceledException(System 命名空間中為此目的設(shè)計的預(yù)定義異常)出錯。
除了線程安全(我們應(yīng)該鎖定讀取/寫入IsCancelRequest),這種模式是有效的,CLR提供了一個名為CancelToken的類型,與我們剛剛展示的類型非常相似。但是,它缺少取消方法;相反,此方法在另一種名為 取消令牌源 。這種分離提供了一些安全性:只能訪問 CancelToken 對象的方法可以檢查但不能取消。
要獲取取消令牌,我們首先實例化一個取消令牌源:
var cancelSource=new CancellationTokenSource();
這將公開一個 Token 屬性,該屬性返回一個 CancelToken 。因此,我們可以調(diào)用我們的 Foo 方法,如下所示:
var cancelSource=new CancellationTokenSource();
Task foo=Foo (cancelSource.Token);
...
... (some time later)
cancelSource.Cancel();
CLR 中的大多數(shù)異步方法都支持取消令牌,包括 延遲 。如果我們修改Foo,使其令牌傳遞到Delay方法中,則任務(wù)將在請求時立即結(jié)束(而不是最多一秒鐘后):
async Task Foo (CancellationToken cancellationToken)
{
for (int i=0; i < 10; i++)
{
Console.WriteLine (i);
await Task.Delay (1000, cancellationToken);
}
}
請注意,我們不再需要調(diào)用 ThrowIfCancelRequest,因為 Task.Delay 正在為我們執(zhí)行此操作。取消令牌很好地沿調(diào)用堆棧向下傳播(就像取消請求通過例外在調(diào)用堆棧聯(lián)一樣)。
UWP 依賴于 WinRT 類型,其異步方法遵循較差的取消協(xié)議,因此 IAsyncInfo 類型公開取消方法,而不是接受取消令牌。但是,AsTask 擴展方法已重載以接受取消令牌,從而彌合了差距。
同步方法也可以支持取消(例如任務(wù)的等待方法)。在這種情況下,取消指令需要異步發(fā)送(例如,來自另一個任務(wù))。例如:
var cancelSource=new CancellationTokenSource();
Task.Delay (5000).ContinueWith (ant=> cancelSource.Cancel());
...
事實上,您可以在構(gòu)建 CancelTokenSource 時指定一個時間間隔,以便在設(shè)定的時間段后啟動取消(正如我們演示的那樣)。它對于實現(xiàn)超時(無論是同步還是異步)都很有用:
var cancelSource=new CancellationTokenSource (5000);
try { await Foo (cancelSource.Token); }
catch (OperationCanceledException ex) { Console.WriteLine ("Cancelled"); }
CancelToken 結(jié)構(gòu)提供了一個 Register 方法,用于注冊將在取消時觸發(fā)的回調(diào)委托;它返回一個對象,可以釋放該對象以撤消注冊。
編譯器的異步函數(shù)生成的任務(wù)在未處理的 OperationCanceledException 時自動進入“已取消”狀態(tài)(IsCanceled 返回 true,IsFaulted 返回 false)。使用Task.Run創(chuàng)建的任務(wù)也是如此,您將(相同的)CancelToken傳遞給構(gòu)造函數(shù)。在異步方案中,出錯的任務(wù)和取消的任務(wù)之間的區(qū)別并不重要,因為兩者都在等待時拋出操作取消異常;它在高級并行編程方案中很重要(特別是條件延續(xù))。我們在中討論這個主題。
有時,你會希望異步操作在運行時報告進度。一個簡單的解決方案是將 Action 委托傳遞給異步方法,每當(dāng)進度更改時,該方法都會觸發(fā)該方法:
Task Foo (Action<int> onProgressPercentChanged)
{
return Task.Run (()=>
{
for (int i=0; i < 1000; i++)
{
if (i % 10==0) onProgressPercentChanged (i / 10);
// Do something compute-bound...
}
});
}
以下是我們?nèi)绾畏Q呼它:
Action<int> progress=i=> Console.WriteLine (i + " %");
await Foo (progress);
盡管這在控制臺應(yīng)用程序中運行良好,但在富客戶端方案中并不理想,因為它報告工作線程的進度,從而給使用者帶來潛在的線程安全問題。(實際上,我們允許并發(fā)的副作用“泄漏”到外部世界,這是不幸的,因為如果從 UI 線程調(diào)用該方法,則會被隔離。
CLR 提供了一對類型來解決此問題:一個名為 IProgress<T> 的接口和一個名為 Progress<T> 的實現(xiàn)此接口的類。實際上,它們的目的是“包裝”委托,以便 UI 應(yīng)用程序可以通過同步上下文安全地報告進度。
該接口僅定義一種方法:
public interface IProgress<in T>
{
void Report (T value);
}
使用 IProgress<T> 很簡單:我們的方法幾乎不會改變:
Task Foo (IProgress<int> onProgressPercentChanged)
{
return Task.Run (()=>
{
for (int i=0; i < 1000; i++)
{
if (i % 10==0) onProgressPercentChanged.Report (i / 10);
// Do something compute-bound...
}
});
}
Progress<T> 類有一個構(gòu)造函數(shù),該構(gòu)造函數(shù)接受 Action<T> 類型的委托,它包裝:
var progress=new Progress<int> (i=> Console.WriteLine (i + " %"));
await Foo (progress);
(Progress<T> 還有一個 ProgressChanged 事件,您可以訂閱該事件,而不是 [或除了] 將操作委托傳遞給構(gòu)造函數(shù)。在實例化 Progress<int> 時,該類會捕獲同步上下文(如果存在)。當(dāng)Foo調(diào)用報告時,委托是通過該上下文調(diào)用的。
異步方法可以通過將 int 替換為公開一系列屬性的自定義類型來實現(xiàn)更詳細的進度報告。
如果您熟悉反應(yīng)式擴展,您會注意到 IProgress<T> 與異步函數(shù)返回的任務(wù)一起提供了類似于 IObserver<T 的功能集> 。不同之處在于,除了 IProgress<T 發(fā)出的值,任務(wù)還可以公開“最終”返回值(并且類型不同>。
IProgress<T>發(fā)出的值通常是“一次性”值(例如,完成百分比或到目前為止下載的字節(jié)數(shù)),而IObserver<T>的OnNext推送的值通常包含結(jié)果本身,并且是調(diào)用它的原因。
WinRT 中的異步方法還提供進度報告,盡管該協(xié)議因 COM 的(相對)基元類型系統(tǒng)而變得復(fù)雜。報告進度的異步 WinRT 方法不接受 IProgress<T> 對象,而是返回以下接口之一,而不是 IAsyncAction 和 IAsyncOperation<TResult> :
IAsyncActionWithProgress<TProgress>
IAsyncOperationWithProgress<TResult, TProgress>
有趣的是,兩者都基于 IAsyncInfo(不是 IAsyncAction 和 IAsyncOperation<TResult> )。
好消息是,AsTask 擴展方法也重載以接受上述接口的 IProgress<T>,因此作為 .NET 使用者,您可以忽略 COM 接口并執(zhí)行以下操作:
var progress=new Progress<int> (i=> Console.WriteLine (i + " %"));
CancellationToken cancelToken=...
var task=someWinRTobject.FooAsync().AsTask (cancelToken, progress);
.NET 公開了數(shù)百個可以等待的任務(wù)返回異步方法(主要與 I/O 相關(guān))。這些方法中的大多數(shù)(至少部分)都遵循一種稱為(TAP)的模式,它是我們迄今為止所描述的合理形式化。TAP 方法執(zhí)行以下操作:
正如我們所看到的,TAP方法很容易使用C#的異步函數(shù)編寫。
異步函數(shù)有一個一致的協(xié)議(它們一致地返回任務(wù))的一個很好的結(jié)果是,可以使用和編寫任務(wù)組合器——有效地組合任務(wù)的函數(shù),而不考慮這些特定的作用。
CLR 包括兩個任務(wù)組合器:Task.WhenAny 和 Task.WhenAll。在描述它們時,我們假設(shè)定義了以下方法:
async Task<int> Delay1() { await Task.Delay (1000); return 1; }
async Task<int> Delay2() { await Task.Delay (2000); return 2; }
async Task<int> Delay3() { await Task.Delay (3000); return 3; }
Task.WhenAny 返回一個任務(wù),該任務(wù)在一組任務(wù)中的任何一個完成時完成。以下內(nèi)容在一秒鐘內(nèi)完成:
Task<int> winningTask=await Task.WhenAny (Delay1(), Delay2(), Delay3());
Console.WriteLine ("Done");
Console.WriteLine (winningTask.Result); // 1
因為 Task.WhenAny 本身返回一個任務(wù),所以我們等待它,它返回首先完成的任務(wù)。我們的示例是完全非阻塞的——包括我們訪問 Result 屬性時的最后一行(因為 winningTask 已經(jīng)完成)。盡管如此,通常最好等待獲勝任務(wù)
Console.WriteLine (await winningTask); // 1
因為任何異常都會在沒有聚合異常包裝的情況下重新引發(fā)。實際上,我們可以在一個步驟中執(zhí)行這兩個 await s:
int answer=await await Task.WhenAny (Delay1(), Delay2(), Delay3());
如果非獲勝任務(wù)隨后出錯,則除非隨后等待該任務(wù)(或查詢其 Exception 屬性),否則將不觀察到異常。
WhenAny 對于將超時或取消應(yīng)用于不支持它的操作很有用:
Task<string> task=SomeAsyncFunc();
Task winner=await (Task.WhenAny (task, Task.Delay(5000)));
if (winner !=task) throw new TimeoutException();
string result=await task; // Unwrap result/re-throw
請注意,因為在本例中我們使用不同類型的任務(wù)調(diào)用 WhenAny,因此將獲勝者報告為普通任務(wù)(而不是 Task<string> )。
Task.WhenAll 返回一個任務(wù),該任務(wù)在您傳遞給它任務(wù)完成時完成。以下內(nèi)容在三秒后完成(并演示模式):
await Task.WhenAll (Delay1(), Delay2(), Delay3());
我們可以通過依次等待任務(wù) 1、任務(wù) 2 和任務(wù) 3 而不是使用 WhenAll 來獲得類似的結(jié)果:
Task task1=Delay1(), task2=Delay2(), task3=Delay3();
await task1; await task2; await task3;
區(qū)別(除了由于需要三個等待而不是一個而效率較低)是,如果task1出錯,我們將永遠不會等待任務(wù)2 / task3,并且它們的任何異常都不會被觀察到。
相比之下,Task.WhenAll 在所有任務(wù)完成之前不會完成,即使出現(xiàn)故障也是如此。如果有多個錯誤,它們的異常將合并到任務(wù)的 AggregateException 中(這是 AggregateException 真正變得有用的時候 - 也就是說,如果你對所有異常感興趣)。但是,等待組合任務(wù)只會引發(fā)第一個異常,因此要查看所有異常,您需要執(zhí)行以下操作:
Task task1=Task.Run (()=> { throw null; } );
Task task2=Task.Run (()=> { throw null; } );
Task all=Task.WhenAll (task1, task2);
try { await all; }
catch
{
Console.WriteLine (all.Exception.InnerExceptions.Count); // 2
}
使用類型為 Task<TResult> 的任務(wù)調(diào)用 WhenAll,返回一個 Task<TResult[]> ,給出所有任務(wù)的組合結(jié)果。等待時,這將減少為 TResult[]:
Task<int> task1=Task.Run (()=> 1);
Task<int> task2=Task.Run (()=> 2);
int[] results=await Task.WhenAll (task1, task2); // { 1, 2 }
為了給出一個實際示例,下面并行下載 URI 并對其總長度求和:
async Task<int> GetTotalSize (string[] uris)
{
IEnumerable<Task<byte[]>> downloadTasks=uris.Select (uri=>
new WebClient().DownloadDataTaskAsync (uri));
byte[][] contents=await Task.WhenAll (downloadTasks);
return contents.Sum (c=> c.Length);
}
但是,這里有一個輕微的低效率,因為我們不必要地掛在我們下載的字節(jié)數(shù)組上,直到每個任務(wù)都完成。如果我們在下載字節(jié)數(shù)組后立即將其折疊成它們的長度,那會更有效。這就是異步 lambda 派上用場的地方,因為我們需要將 await 表達式饋送到 LINQ 的選擇查詢運算符中:
async Task<int> GetTotalSize (string[] uris)
{
IEnumerable<Task<int>> downloadTasks=uris.Select (async uri=>
(await new WebClient().DownloadDataTaskAsync (uri)).Length);
int[] contentLengths=await Task.WhenAll (downloadTasks);
return contentLengths.Sum();
}
編寫自己的任務(wù)組合器可能很有用。最簡單的“組合器”接受單個任務(wù),如下所示,它允許您等待任何超時的任務(wù):
async static Task<TResult> WithTimeout<TResult> (this Task<TResult> task,
TimeSpan timeout)
{
Task winner=await Task.WhenAny (task, Task.Delay (timeout))
.ConfigureAwait (false);
if (winner !=task) throw new TimeoutException();
return await task.ConfigureAwait (false); // Unwrap result/re-throw
}
由于這在很大程度上是一種不訪問外部共享狀態(tài)的“庫方法”,因此我們在等待時使用 ConfigureAwait(false) 以避免可能反彈到 UI 同步上下文。當(dāng)任務(wù)按時完成時,我們可以通過取消 Task.Delay 來進一步提高效率(這避免了計時器的小開銷):
async static Task<TResult> WithTimeout<TResult> (this Task<TResult> task,
TimeSpan timeout)
{
var cancelSource=new CancellationTokenSource();
var delay=Task.Delay (timeout, cancelSource.Token);
Task winner=await Task.WhenAny (task, delay).ConfigureAwait (false);
if (winner==task)
cancelSource.Cancel();
else
throw new TimeoutException();
return await task.ConfigureAwait (false); // Unwrap result/re-throw
}
以下內(nèi)容允許您通過取消令牌“放棄”任務(wù):
static Task<TResult> WithCancellation<TResult> (this Task<TResult> task,
CancellationToken cancelToken)
{
var tcs=new TaskCompletionSource<TResult>();
var reg=cancelToken.Register (()=> tcs.TrySetCanceled ());
task.ContinueWith (ant=>
{
reg.Dispose();
if (ant.IsCanceled)
tcs.TrySetCanceled();
else if (ant.IsFaulted)
tcs.TrySetException (ant.Exception.InnerException);
else
tcs.TrySetResult (ant.Result);
});
return tcs.Task;
}
任務(wù)組合器編寫起來可能很復(fù)雜,有時需要使用信號結(jié)構(gòu),我們將在第中介紹。這實際上是一件好事,因為它將與并發(fā)相關(guān)的復(fù)雜性排除在業(yè)務(wù)邏輯之外,并保留到可以單獨測試的可重用方法中。
下一個組合器的工作方式類似于 WhenAll ,只是如果任何任務(wù)出錯,則生成的任務(wù)會立即出錯:
async Task<TResult[]> WhenAllOrError<TResult>
(params Task<TResult>[] tasks)
{
var killJoy=new TaskCompletionSource<TResult[]>();
foreach (var task in tasks)
task.ContinueWith (ant=>
{
if (ant.IsCanceled)
killJoy.TrySetCanceled();
else if (ant.IsFaulted)
killJoy.TrySetException (ant.Exception.InnerException);
});
return await await Task.WhenAny (killJoy.Task, Task.WhenAll (tasks))
.ConfigureAwait (false);
}
我們首先創(chuàng)建一個 TaskCompletionSource,它的唯一工作是在任務(wù)出錯時結(jié)束參與方。因此,我們從不調(diào)用它的 SetResult 方法,只調(diào)用它的 TrySetCanceled 和 TrySetException 方法。在這種情況下,ContinueWith 比 GetAwaiter() 更方便。OnComplete,因為我們沒有訪問任務(wù)的結(jié)果,并且不想在此時反彈到 UI 線程。
在第 的中,我們描述了如何使用 SemaphoreSlim 異步鎖定或限制并發(fā)性。
.NET 采用其他異步模式,這些模式位于任務(wù)和異步函數(shù)之前?,F(xiàn)在很少需要這些,因為基于任務(wù)的異步已成為主導(dǎo)模式。
最古老的模式稱為(APM),它使用從“開始”和“結(jié)束”開始的一對方法以及一個名為IAsyncResult的接口。為了說明這一點,讓我們以 Stream 類 System.IO 為例,并查看其 Read 方法。一、同步版本:
public int Read (byte[] buffer, int offset, int size);
您可以預(yù)測基于的異步版本是什么樣子的:
public Task<int> ReadAsync (byte[] buffer, int offset, int size);
現(xiàn)在讓我們檢查一下 APM 版本:
public IAsyncResult BeginRead (byte[] buffer, int offset, int size,
AsyncCallback callback, object state);
public int EndRead (IAsyncResult asyncResult);
調(diào)用 Begin* 方法將啟動該操作,并返回一個 IAsyncResult 對象,該對象充當(dāng)異步操作的令牌。當(dāng)操作完成(或出錯)時,AsyncCallback 委托將觸發(fā):
public delegate void AsyncCallback (IAsyncResult ar);
然后,處理此委托的人員調(diào)用 End* 方法,該方法提供操作的返回值,并在操作出錯時重新引發(fā)異常。
APM 不僅使用起來很笨拙,而且很難正確實現(xiàn)。處理 APM 方法的最簡單方法是調(diào)用 Task.Factory.FromAsync 適配器方法,該方法將 APM 方法對轉(zhuǎn)換為 Task 。在內(nèi)部,它使用 TaskCompletionSource 為您提供一個任務(wù),該任務(wù)在 APM 操作完成或出錯時發(fā)出信號。
FromAsync 方法需要以下參數(shù):
FromAsync 重載以接受與 .NET 中找到的幾乎所有異步方法簽名匹配的委托類型和參數(shù)。例如,假設(shè)流是一個流,緩沖區(qū)是一個字節(jié)[],我們可以這樣做:
Task<int> readChunk=Task<int>.Factory.FromAsync (
stream.BeginRead, stream.EndRead, buffer, 0, 1000, null);
(EAP) 于 2005 年引入,旨在為 APM 提供更簡單的替代方案,尤其是在 UI 方案中。然而,它只在少數(shù)幾種類型中實現(xiàn),最著名的是 System.Net 中的 WebClient。EAP 只是一種模式;不提供任何類型來提供幫助。本質(zhì)上,模式是這樣的:類提供一系列在內(nèi)部管理并發(fā)的成員,類似于:
// These members are from the WebClient class:
public byte[] DownloadData (Uri address); // Synchronous version
public void DownloadDataAsync (Uri address);
public void DownloadDataAsync (Uri address, object userToken);
public event DownloadDataCompletedEventHandler DownloadDataCompleted;
public void CancelAsync (object userState); // Cancels an operation
public bool IsBusy { get; } // Indicates if still running
*異步方法異步啟動操作。操作完成后,將觸發(fā)事件(如果存在,則自動發(fā)布到捕獲的同步上下文)。此事件傳回包含以下內(nèi)容的事件參數(shù)對象:*Completed
EAP 類型還可以公開進度報告事件,該事件在進度更改時觸發(fā)(也通過同步上下文發(fā)布):
public event DownloadProgressChangedEventHandler DownloadProgressChanged;
實現(xiàn) EAP 需要大量樣板代碼,這使得模式的組成很差。
System.ComponentModel 中的 BackgroundWorker 是 EAP 的通用實現(xiàn)。它允許富客戶端應(yīng)用啟動工作線程并報告完成情況和基于百分比的進度,而無需顯式捕獲同步上下文。下面是一個示例:
var worker=new BackgroundWorker { WorkerSupportsCancellation=true };
worker.DoWork +=(sender, args)=>
{ // This runs on a worker thread
if (args.Cancel) return;
Thread.Sleep(1000);
args.Result=123;
};
worker.RunWorkerCompleted +=(sender, args)=>
{ // Runs on UI thread
// We can safely update UI controls here...
if (args.Cancelled)
Console.WriteLine ("Cancelled");
else if (args.Error !=null)
Console.WriteLine ("Error: " + args.Error.Message);
else
Console.WriteLine ("Result is: " + args.Result);
};
worker.RunWorkerAsync(); // Captures sync context and starts operation
RunWorkerAsync 啟動該操作,在池工作線程上觸發(fā) DoWork 事件。它還捕獲同步上下文,當(dāng)操作完成(或出錯)時,將通過該同步上下文(如延續(xù))調(diào)用 RunWorkerCompleted 事件。
BackgroundWorker 創(chuàng)建粗粒度并發(fā),因為 DoWork 事件完全在工作線程上運行。如果需要更新該事件處理程序中的 UI 控件(而不是發(fā)布完成百分比消息),則必須使用 Dispatcher.BeginInvoke 或類似內(nèi)容)。
我們將在 更詳細地描述BackgroundWorker 。
喜歡手游的小伙伴肯定都遇到這種情況,一局游戲正玩得興起,突然來了個電話,再打開游戲就需要重啟了,或者瀏覽器正在查閱資料,恰好朋友來了微信,聊幾句再回來,瀏覽器也重啟了,剛剛查看的頁面也沒了。
這些情況大家都不陌生,甚至可以說很常見,因為它有一個大家都熟悉的名字——殺后臺。所謂殺后臺其實就是手機在處理多任務(wù)時,因內(nèi)存資源占用過多,系統(tǒng)會將暫時不用的應(yīng)用關(guān)閉,從而確保手機始終處于一個流暢的運行狀態(tài)。一般來說,這是比較正常的情況,初衷也是好的,但當(dāng)用戶“左手娛樂,右手聊天”時,后臺應(yīng)用頻頻需要重啟,那就有點影響體驗了。
01不同手機應(yīng)用留存實測
正因為如此,安卓廠商為了能夠提升手機系統(tǒng)應(yīng)用留存率,想出來很多方法,比如搭載更大的運存,畢竟只要運存足夠大,后臺能夠同時駐留的應(yīng)用也就越多。
一般8GB運存的手機可以支持15個應(yīng)用同時運行,而12GB基本能支持20多個以上,更大的16GB運存,差不多可以同時運行30個左右,只不過這么多App同開,系統(tǒng)流暢性已經(jīng)很難保證。
另外,也不是所有手機都能動輒12GB、16GB起步的,一些中低端的機型,出于成本的考量,運存往往還處于6GB的范疇,這種情況下也可以通過開啟“內(nèi)存拓展”來提升應(yīng)用同開的數(shù)量。
ColorOS 13支持最大7GB內(nèi)存拓展
所謂內(nèi)存拓展其實并不復(fù)雜。眾所周知,諸如PC、手機、平板這樣的電子產(chǎn)品都有運存(RAM)和閃存(ROM)兩種存儲,內(nèi)存拓展說白了就是把一部分的ROM“借給”RAM,通過這樣一種“借空間”的操作實現(xiàn)大運存,提升手機的負載能力,也就可以同時運行更多的App了。
需要注意的一點的是,擁有同時運行多個App的能力,并不等于手機不會“殺后臺”。其中差異主要取決于系統(tǒng)的后臺管理策略,這也是為什么會出現(xiàn)兩臺手機明明有著同樣大小的運存,在應(yīng)用?;盥噬蠀s存在很大的不同。
ColorOS 13打開18個應(yīng)用依舊很流暢
以我手上這臺OPPOFind X5 Pro為例,系統(tǒng)為最新的ColorOS 13,RAM為12GB,內(nèi)存拓展3GB。筆者從應(yīng)用商城下載了18款日常使用的App,全部開啟后駐留到系統(tǒng)后臺,接下來運行《王者榮耀》10分鐘,結(jié)束后依次打開后臺駐留應(yīng)用。
從結(jié)果來看,18個應(yīng)用均不需要重啟,留存率達到了100%。而在第二輪測試中,成績還是一模一樣,并且操作全程流暢,一點也不卡頓。筆者又測試了另外兩臺搭載驍龍8+和天璣9000芯片的機型,一樣的應(yīng)用,一樣的操作流程,但最高只有12個App得到保留,并且伴隨一定的掉幀情況。
為了進一步驗證ColorOS13在日常游戲中會不會存在“來電重啟”的問題,筆者又進行了一輪新的測試,在《王者榮耀》中打進電話,再等上5分鐘,回到游戲后發(fā)現(xiàn)應(yīng)用還能正常運行;相較之下,另外一款天璣9000測試機就需要重新啟動了,從這一波的對比來看,顯然還是ColorOS 13更厲害一些。
02ColorOS 13為什么不會“殺后臺”?
ColorOS13有這樣的表現(xiàn)完全在意料之中。這一次,ColorOS 13全新升級了ColorOS超算平臺,這是OPPO自研的系統(tǒng)級計算中樞。
這項技術(shù)主要的能力就是對手機硬件資源進行合理的優(yōu)化和分配,比如有些輕量的App一旦開啟就會自動調(diào)用處理器的大核,理論上講這樣做不算錯,可以確保App始終處于一個流暢狀態(tài),但問題是這個級別的App其實根本不需要用到大核,一般中核甚至小核就足以駕馭,大核固然能夠提升體驗但也帶來了更大的功耗和發(fā)熱。
這其實是對手機性能資源的一種過度索取,而ColorOS超算平臺則能夠?qū)@些資源不合理分配、內(nèi)存沖突的行為做出“糾正”。它可以通過算力模型對硬件計算資源的精準調(diào)度,并結(jié)合并行計算、高性能計算、端云計算、智能計算的綜合調(diào)優(yōu),從而給用戶帶來了全方面的流暢、穩(wěn)定、續(xù)航體驗提升。
根據(jù)OPPO實驗室數(shù)據(jù)顯示,升級到ColorOS 13的Find X5 Pro,整機性能提升10%,在某熱門MOBA游戲測試中,能夠做到高幀率穩(wěn)定,性能無損失,且續(xù)航提升4.7%,同時將游戲最高溫度降低了1°C。并且在后臺同開18個應(yīng)用的前提下,同時做到重載場景下丟幀率降低至少25%。
這一數(shù)據(jù)結(jié)果也十分符合我們自己測得的情況和體驗,這意味著搭載ColorOS 13的機型在日常使用上會有一個很顯著的提升。
最后有話說:
智能手機發(fā)展到今天,硬件能力越來越強,卻依然難以避免如“殺后臺”、卡頓不流暢、發(fā)熱續(xù)航低這樣的問題,有些情況與手機自身的硬件有一定關(guān)系,但大部分還是與系統(tǒng)優(yōu)化、后臺管理有關(guān)。
ColorOS13通過ColorOS超算平臺有效改善了這種狀況,通過對手機性能資源的合理分配,實現(xiàn)了高性能與低功耗的平衡,同時借助了微內(nèi)核的設(shè)計思想,實現(xiàn)重載多任務(wù)場景下的流暢體驗。值得注意的是,隨著ColorOS 13逐漸適配開放,還會有更多的老機型、中低端機型搭載這一功能,這對提升老舊機型體驗也是一大助益。