操屁眼的视频在线免费看,日本在线综合一区二区,久久在线观看免费视频,欧美日韩精品久久综

新聞資訊

    多數應用程序需要一次處理多件事()。在本章中,我們從基本的先決條件開始,即線程和任務的基礎知識,然后詳細描述異步和 C# 異步函數的原理。

    在第章中,我們將更詳細地重新討論多線程,在第中,我們將介紹并行編程的相關主題。

    介紹

    以下是最常見的并發方案:

    編寫響應式用戶界面

    在 Windows Presentation Foundation (WPF)、移動和 Windows 窗體應用程序中,必須同時運行耗時的任務以及運行用戶界面的代碼以保持響應能力。

    允許同時處理請求

    在服務器上,客戶端請求可以并發到達,因此必須并行處理以保持可伸縮性。如果使用 ASP.NET 核心或 Web API,運行時會自動執行此操作。但是,您仍然需要了解共享狀態(例如,使用靜態變量進行緩存的效果)。

    并行編程

    如果工作負載在內核之間分配,則執行密集型計算的代碼可以在多核/多處理器計算機上更快地執行(專門討論這一點)。

    投機執行

    在多核計算機上,有時可以通過預測可能需要完成的操作,然后提前執行來提高性能。LINQPad 使用此技術來加快新查詢的創建速度。是并行運行許多不同的算法,這些算法都解決相同的任務。無論哪個先完成,“獲勝”——當你無法提前知道哪種算法將執行得最快時,這是有效的。

    程序可以同時執行代碼的一般機制稱為。多線程處理受 CLR 和操作系統的支持,并且是并發的基本概念。了解線程的基礎知識,特別是線程對的影響,至關重要。

    線程

    線程是可以獨立于其他進行的執行路徑。

    每個線程都在操作系統進程中運行,該進程提供了一個運行程序的獨立環境。對于單線程程序,只有一個線程在進程的獨立環境中運行,因此該對它具有獨占訪問權限。對于多線程程序,多個在單個進程中運行,共享相同的執行環境(特別是內存)。這在一定程度上就是多線程有用的原因:例如,一個線程可以在后臺獲取數據,而另一個線程在數據到達時顯示數據。此數據稱為。

    創建線程

    程序(控制臺、WPF、UWP 或 Windows 窗體)在操作系統自動創建的單個線程(“主”線程)中啟動。在這里,它作為單線程應用程序活出它的生命,除非您通過創建更多線程(直接或間接)來執行其他操作。1

    可以通過實例化 Thread 對象并調用其 Start 方法來創建和啟動新線程。Thread 最簡單的構造函數采用 ThreadStart 委托:一個指示應從何處開始執行的無參數方法。下面是一個示例:

    // 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
    ...

    主線程創建一個新線程 t,它在其上運行重復打印字符 的方法。同時,主線程重復打印字符 ,如圖 所示。在單核計算機上,操作系統必須為每個線程分配“片”時間(在 Windows 中通常為 20 毫秒)以模擬并發,從而導致 和 塊。在多核或多處理器計算機上,兩個線程可以真正并行執行(受計算機上其他活動進程的競爭),盡管在此示例中,由于控制臺處理并發請求的機制存在細微之處,您仍然會得到重復的 和 塊。


    啟動新線程

    注意

    線程被稱為在其執行與另一個線程上的代碼執行穿插在一起的點被。這個詞經常出現在解釋為什么出了問題的時候!

    啟動后,線程的 IsAlive 屬性返回 true ,直到線程結束的點。當傳遞給線程的構造函數的委托完成執行時,線程結束。結束后,線程無法重新啟動。

    每個線程都有一個 Name 屬性,您可以設置該屬性以方便調試。這在 Visual Studio 中特別有用,因為線程的名稱顯示在“線程窗口”和“調試位置”工具欄中。您只能設置一次線程的名稱;稍后嘗試更改它將引發異常。

    靜態 Thread.CurrentThread 屬性為您提供當前正在執行的線程:

    Console.WriteLine (Thread.CurrentThread.Name);

    加入和睡眠

    您可以通過調用其 Join 方法來等待另一個線程結束:

    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 已結束!調用 Join 時可以包含超時,以毫秒為單位,也可以以時間跨度 為單位。然后,如果線程結束,則返回 true,如果超時,則返回 false。

    Thread.Sleep 將當前線程暫停指定的時間段:

    Thread.Sleep (TimeSpan.FromHours (1));  // Sleep for 1 hour
    Thread.Sleep (500);                     // Sleep for 500 milliseconds

    Thread.Sleep(0) 立即放棄線程的當前時間片,自愿將 CPU 移交給其他線程。Thread.Yield() 做同樣的事情,只是它只讓給處理器上運行的線程。

    注意

    Sleep(0) 或 Yield 在生產代碼中偶爾可用于高級性能調整。它也是一個很好的診斷工具,有助于發現線程安全問題:如果在代碼中的任何位置插入 Thread.Yield() 會破壞程序,則幾乎可以肯定存在錯誤。

    在等待睡眠或加入時,線程被阻塞。

    阻塞

    當線程的執行由于某種原因而暫停時,例如當休眠或等待另一個線程通過 Join 結束時,該線程被視為。被阻塞的線程會立即其處理器時間片,從那時起,在滿足其阻塞條件之前,它不會消耗任何處理器時間。您可以通過其 ThreadState 屬性測試線程是否被阻止:

    bool blocked=(someThread.ThreadState & ThreadState.WaitSleepJoin) !=0;

    注意

    ThreadState 是一個標志枚舉,以按位方式組合三個數據“層”。但是,大多數值都是冗余的、未使用的或已棄用的。以下擴展方法將 ThreadState 剝離為四個有用值之一:Unstarted 、Running 、WaitSleepJoin 和 Stop:

    public static ThreadState Simplify (this ThreadState ts)
    {
      return ts & (ThreadState.Unstarted |
                   ThreadState.WaitSleepJoin |
                   ThreadState.Stopped);
    }

    屬性可用于診斷目的,但不適合同步,因為線程的狀態可能會在測試 ThreadState 和處理該信息之間發生更改。

    當線程阻塞或取消阻止時,OS 會執行。這會產生很小的開銷,通常為一到兩微秒。

    I/O 綁定與計算綁定

    花費大部分時間某些事情發生的操作稱為 — 例如下載網頁或調用 Console.ReadLine 。(I/O 綁定操作通常涉及輸入或輸出,但這不是硬性要求:Thread.Sleep 也被視為 I/O 綁定。相比之下,花費大部分時間執行 CPU 密集型工作的操作稱為。

    阻塞與旋轉

    I/O 綁定操作以以下兩種方式之一工作:它在當前線程上等待,直到操作完成(例如 Console.ReadLine 、 Thread.Sleep 或 Thread.Join ),或者異步操作,完成時觸發回調(稍后會詳細介紹)。

    同步等待的 I/O 綁定操作花費大部分時間阻塞線程。它們還可以周期性地循環“旋轉”:

    while (DateTime.Now < nextStartTime)
      Thread.Sleep (100);

    撇開有更好的方法(例如計時器或信令結構)不談,另一種選擇是線程可以連續旋轉:

    while (DateTime.Now < nextStartTime);

    通常,這會浪費處理器時間:就 CLR 和 OS 而言,線程正在執行重要的計算,因此相應地分配了資源。實際上,我們已經將應該是 I/O 綁定的操作變成了計算綁定的操作。

    注意

    關于旋轉與阻塞有一些細微差別。首先,當您期望很快滿足條件(可能在幾微秒內)時,的旋轉可能是有效的,因為它避免了上下文切換的開銷和延遲。.NET 提供了特殊的方法和類來提供幫助 — 請參閱聯機補充

    其次,阻止不會產生成本。這是因為每個線程只要存在就會占用大約 1 MB 的內存,并導致 CLR 和操作系統的持續管理開銷。因此,在需要處理數百或數千個并發操作的大量 I/O 綁定程序的上下文中,阻塞可能會很麻煩。相反,此類程序需要使用基于回調的方法,在等待時完全取消其線程。這(部分)是我們稍后討論的異步模式的目的。

    本地狀態與共享狀態

    CLR 為每個線程分配自己的內存堆棧,以便局部變量保持獨立。在下一個示例中,我們使用局部變量定義一個方法,然后在主線程和新創建的線程上同時調用該方法:

    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 ('?');
    }

    在每個線程的內存堆棧上創建 cycle 變量的單獨副本,因此可以預見的是,輸出是 10 個問號。

    如果線程對同一對象或變量具有公共引用,則線程共享數據:

    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();

    不過,更常見的是,字段用于在線程之間共享數據。在以下示例中,兩個線程在同一個 ThreadTest 實例上調用 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"); }
      }
    }

    靜態字段提供了另一種在線程之間共享數據的方法:

    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"); }
      }
    }

    所有四個示例都說明了另一個關鍵概念:線程安全(或者更確切地說,缺乏它!輸出實際上是不確定的:“完成”有可能(盡管不太可能)打印兩次。但是,如果我們在 Go 方法中交換語句的順序,則“完成”被打印兩次的幾率會急劇上升:

    static void Go()
    {
      if (!_done) { Console.WriteLine ("Done"); _done=true; }
    }

    問題在于,一個線程可以在另一個線程執行 WriteLine 語句的同時計算 if 語句 — 在它有機會將 done 設置為 true 之前。

    注意

    我們的示例說明了可能引入多線程臭名昭著的間歇性錯誤的多種方式之一。接下來,我們看看如何通過鎖定來修復我們的程序;但是,最好盡可能完全避免共享狀態。稍后我們將看到異步編程模式如何對此有所幫助。

    鎖定和螺紋安全

    注意

    鎖定和線程安全是大主題。有關完整討論,請參閱中的“和

    我們可以通過在讀取和寫入共享字段時獲取來修復前面的示例。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; }
        }
      }
    }

    當兩個線程同時爭用一個鎖(可以在任何引用類型對象上;在本例中為 _locker)時,一個線程等待或阻塞,直到鎖可用。在這種情況下,它確保一次只有一個線程可以進入其代碼塊,并且“完成”將只打印一次。以這種方式(避免在多線程上下文中出現不確定性)的代碼稱為代碼。

    警告

    即使是自動遞增變量的行為也不是線程安全的:表達式 x++ 作為不同的讀-增量-寫操作在底層處理器上執行。因此,如果兩個線程在鎖外部同時執行 x++,則變量最終可能會遞增一次而不是兩次(或者更糟糕的是,在某些情況下,x 可能會,最終導致新舊內容的按位混合)。

    鎖定不是線程安全的靈丹妙藥 - 很容易忘記鎖定訪問字段,并且鎖定本身會產生問題(例如死鎖)。

    何時可以使用鎖定的一個很好的例子是訪問 ASP.NET 應用程序中經常訪問的數據庫對象的共享內存中緩存。這種應用程序很容易正確,并且沒有死鎖的機會。我們在的中給出了一個例子。

    將數據傳遞到線程

    有時,您需要將參數傳遞給線程的啟動方法。最簡單的方法是使用 lambda 表達式,該表達式使用所需參數調用該方法:

    Thread t=new Thread ( ()=> Print ("Hello from t!") );
    t.Start();
    
    void Print (string message)=> Console.WriteLine (message);

    使用此方法,可以將任意數量的參數傳遞給該方法。您甚至可以將整個實現包裝在多語句 lambda 中:

    new Thread (()=>
    {
      Console.WriteLine ("I'm running on another thread!");
      Console.WriteLine ("This is so easy!");
    }).Start();

    另一種(不太靈活)的技術是將參數傳遞到 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 的構造函數被重載以接受兩個之一:

    public delegate void ThreadStart();
    public delegate void ParameterizedThreadStart (object obj);

    Lambda 表達式和捕獲的變量

    正如我們所看到的,lambda 表達式是將數據傳遞到線程的最方便、最強大的方式。但是,您必須小心,以免在啟動線程后意外修改。例如,請考慮以下事項:

    for (int i=0; i < 10; i++)
      new Thread (()=> Console.Write (i)).Start();

    輸出是不確定的!下面是一個典型的結果:

    0223557799

    問題是 i 變量在循環的整個生命周期中引用內存位置。因此,每個線程都會調用 Console.Write 在一個變量上,該變量的值可以在運行時更改!解決方案是使用臨時變量

    for (int i=0; i < 10; i++)
    {
      int temp=i;
      new Thread (()=> Console.Write (temp)).Start();
    }

    然后,每個數字 0 到 9 只寫一次。(仍未定義,因為線程可以在不確定的時間啟動。

    注意

    這類似于我們在中描述的問題。問題與 C# 在循環中捕獲變量的規則和多線程一樣多。

    可變溫度現在是每個循環迭代的本地變量。因此,每個線程捕獲不同的內存位置,沒有問題。我們可以用下面的例子在前面的代碼中更簡單地說明這個問題:

    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 打印兩次。

    異常處理

    創建線程時生效的任何嘗試/捕獲/最終塊在開始執行時與線程無關。請考慮以下程序:

    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 語句無效,新創建的線程將受到未處理的 NullReferenceException 的阻礙。當您認為每個線程都有獨立的執行路徑時,此行為是有意義的。

    補救措施是將異常處理程序移動到 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
        ...
      }
    }

    您需要在生產應用程序中的所有線程入口方法上使用異常處理程序,就像在主線程上所做的那樣(通常在執行堆棧中的更高級別)。未經處理的異常會導致整個應用程序關閉 - 并顯示一個丑陋的對話框!

    注意

    在編寫此類異常處理塊時,您很少會錯誤:通常,您會記錄異常的詳細信息。對于客戶端應用程序,您可能會顯示一個對話框,允許用戶自動將這些詳細信息提交到 Web 服務器。然后,您可以選擇重新啟動應用程序,因為意外的異常可能會使程序處于無效狀態。

    集中式異常處理

    在 WPF、UWP 和 Windows 窗體應用程序中,您可以分別訂閱“全局”異常處理事件:Application.DispatcherUnhandledException 和 Application.ThreadException。在通過消息循環調用的程序的任何部分中發生未經處理的異常后,這些異常將觸發(這相當于應用程序處于活動狀態時在主線程上運行的所有代碼)。這可用作日志記錄和報告 bug 的后盾(盡管它不會針對您創建的非用戶界面 [UI] 線程上的未經處理的異常觸發)。處理這些事件可防止程序關閉,盡管您可以選擇重新啟動應用程序以避免可能從(或導致)未處理的異常導致狀態的潛在損壞。

    前臺線程與后臺線程

    默認情況下,顯式創建的線程是。只要前臺線程中的任何一個正在運行,前臺線程就會使應用程序保持活動狀態,而則不會。所有前臺線程完成后,應用程序結束,并且仍在運行的任何后臺線程將突然終止。

    注意

    線程的前臺/后臺狀態與其(執行時間分配)無關。

    您可以使用線程的 IsBackground 查詢或更改線程的背景狀態:

    static void Main (string[] args)
    {
      Thread worker=new Thread ( ()=> Console.ReadLine() );
      if (args.Length > 0) worker.IsBackground=true;
      worker.Start();
    }

    如果在沒有參數的情況下調用此程序,則工作線程將處于前臺狀態,并將等待 ReadLine 語句,以便用戶按 Enter 鍵。同時,主線程退出,但應用程序繼續運行,因為前臺線程仍處于活動狀態。另一方面,如果將參數傳遞給 Main(),則為工作線程分配后臺狀態,并且程序在主線程結束時幾乎立即退出(終止 ReadLine)。

    當進程以這種方式終止時,后臺線程的執行堆棧中的任何 finally 塊都會被規避。如果您的程序使用 final(或使用)塊來執行清理工作,例如刪除臨時文件,您可以通過在退出應用程序時顯式等待此類后臺線程來避免這種情況,方法是通過加入線程或使用信令構造(請參閱)。無論哪種情況,您都應該指定超時,以便在叛徒線程拒絕完成時可以放棄它;否則,您的應用程序將無法關閉,而無需用戶從任務管理器(或在 Unix 上為 kill 命令)尋求幫助。

    前臺線程不需要這種處理,但您必須注意避免可能導致線程無法結束的 bug。應用程序無法正確退出的常見原因是存在活動的前臺線程。

    線程優先級

    線程的 Priority 屬性確定相對于操作系統中的其他活動線程分配的執行時間量,具體比例如下:

    enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }

    當多個線程同時處于活動狀態時,這變得很重要。提升線程的優先級時需要小心,因為它可能會使其他線程匱乏。如果希望某個線程的優先級高于進程中的線程,則還必須使用 System.Diagnostics 中的 Process 類提升進程優先級:

    using Process p=Process.GetCurrentProcess();
    p.PriorityClass=ProcessPriorityClass.High;

    這對于執行最少工作且需要低延遲(能夠快速響應)的非 UI 進程非常有效。對于計算量大的應用程序(尤其是具有用戶界面的應用程序),提升進程優先級可能會使其他進程匱乏,從而降低整個計算機的速度。

    信號

    有時,您需要一個線程等待,直到收到來自其他線程的通知。這稱為。最簡單的信令結構是 手動重置事件 。在 ManualResetEvent 上調用 WaitOne 會阻止當前線程,直到另一個線程通過調用 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

    調用 Set 后,信號保持打開狀態;您可以通過調用 重置 再次關閉它。

    手動重置事件是 CLR 提供的幾種信令構造之一;我們將在第中詳細介紹所有這些。

    胖客戶端應用程序中的線程處理

    在 WPF、UWP 和 Windows 窗體應用程序中,在主線程上執行長時間運行的操作會使應用程序無響應,因為主線程還處理執行呈現和處理鍵盤和鼠標事件的消息循環。

    一種流行的方法是啟動“worker”線程以進行耗時的操作。工作線程上的代碼運行耗時的操作,然后在完成后更新 UI。但是,所有胖客戶端應用程序都有一個線程模型,其中 UI 元素和控件只能從創建它們的線程(通常是主 UI 線程)訪問。違反此規定會導致引發不可預知的行為或引發異常。

    因此,當您想要從工作線程更新 UI 時,必須將請求轉發到 UI 線程(技術術語是執行此操作的低級方法如下(稍后,我們將討論基于這些解決方案的其他解決方案):

    • 在 WPF 中,調用 BeginInvoke 或 Invoke,對元素的調度程序對象進行調用。
    • 在 UWP 應用中,在調度程序對象上調用 RunAsync 或 Invoke。
    • 在 Windows 窗體中,調用控件上的 BeginInvoke 或 Invoke。

    所有這些方法都接受引用要運行的方法的委托。BeginInvoke/RunAsync 的工作原理是將委托排隊到 UI 線程的消息隊列(處理鍵盤、鼠標和計時器事件的同一)。Invoke 執行相同的操作,但隨后會阻止,直到 UI 線程讀取和處理消息。因此,Invoke 允許您從方法中獲取返回值。如果你不需要返回值,BeginInvoke / RunAsync 更可取,因為它們不會阻止調用方,也不會引入死鎖的可能性(參見中的)。

    注意

    可以想象,當您調用 Application.Run 時,將執行以下偽代碼:

    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
    }

    正是這種循環使工作線程能夠將委托封送到 UI 線程上執行。

    為了演示,假設我們有一個 WPF 窗口,其中包含一個名為 txtMessage ,我們希望工作線程在執行耗時的任務后更新其內容(我們將通過調用 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 線程擁有不同的窗口,則可以有多個 UI 線程。主要方案是當您有一個具有多個頂級窗口的應用程序時,通常稱為 (SDI) 應用程序,如 Microsoft Word。每個SDI窗口通常在任務欄上顯示為單獨的“應用程序”,并且在功能上與其他SDI窗口基本隔離。通過為每個此類窗口提供自己的 UI 線程,可以使每個窗口相對于其他窗口更具響應性。

    運行此操作會導致立即出現響應窗口。五秒鐘后,它將更新文本框。代碼與Windows窗體類似,只是我們調用(窗體的)BeginInvoke方法,而不是:

      void UpdateMessage (string message)
      {
        Action action=()=> txtMessage.Text=message;
        this.BeginInvoke (action);
      }

    同步上下文

    在 System.ComponentModel 命名空間中,有一個名為 SynchronizationContext 的類,它支持線程封送處理的泛化。

    適用于移動和桌面的胖客戶端 API(UWP、WPF 和 Windows 窗體)分別定義并實例化 SynchronizationContext 子類,您可以通過靜態屬性 SynchronizationContext.Current 獲取這些子類(在 UI 線程上運行時)。通過捕獲此屬性,可以稍后從工作線程“發布”到 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);
      }
    }

    這很有用,因為相同的技術適用于所有富客戶端用戶界面 API。

    調用帖子等效于在調度程序或控件上調用 BeginInvoke ;還有一個等效于 Invoke 的 Send 方法。

    線程池

    每當您啟動線程時,都會花費幾百微秒來組織諸如新的局部變量堆棧之類的東西。線程通過具有預先創建的可回收線程池來減少此開銷。線程池對于高效的并行編程和細粒度并發至關重要;它允許短操作運行,而不會被線程啟動的開銷所淹沒。

    使用池線程時需要注意以下幾點:

    • 不能設置池線程的名稱,從而使調試更加困難(盡管可以在 Visual Studio 的“線程”窗口中進行調試時附加說明)。
    • 池線程始終是。
    • 阻塞池化線程會降低性能(請參閱)。

    您可以自由更改池線程的優先級 - 當釋放回池時,它將恢復正常。

    您可以通過屬性 Thread.CurrentThread.IsThreadPoolThread 確定當前是否正在池線程上執行。

    進入線程池

    在池線程上顯式運行某些內容的最簡單方法是使用 Task.Run(我們將在下一節中更詳細地介紹這一點):

    // Task is in System.Threading.Tasks
    Task.Run (()=> Console.WriteLine ("Hello from the thread pool"));

    由于任務在 .NET Framework 4.0 之前不存在,因此常見的替代方法是調用 ThreadPool.QueueUserWorkItem :

    ThreadPool.QueueUserWorkItem (notUsed=> Console.WriteLine ("Hello"));

    注意

    以下內容隱式使用線程池:

    • ASP.NET 核心和 Web API 應用程序服務器
    • System.Timers.Timer 和 System.Threading.Timer
    • 我們在中描述的并行編程結構
    • (遺留)背景工人類

    線程池中的衛生

    線程池提供另一個功能,即確保臨時超出計算密集型工作不會導致 CPU 。超額訂閱是活動線程多于 CPU 內核的條件,操作系統必須對線程進行時間切片。超額訂閱會損害性能,因為時間切片需要昂貴的上下文切換,并且可能會使 CPU 緩存失效,而 CPU 緩存對于為現代處理器提供性能至關重要。

    CLR 通過對任務進行排隊并限制其啟動來防止線程池中的超額訂閱。它首先運行與硬件內核一樣多的并發任務,然后通過爬山算法調整并發級別,在特定方向上不斷調整工作負載。如果吞吐量提高,則繼續沿同一方向發展(否則將反轉)。這可確保它始終跟蹤最佳性能曲線,即使面對計算機上競爭的過程活動也是如此。

    如果滿足兩個條件,CLR 的策略效果最佳:

    • 工作項大多是短期運行的(<250 毫秒,理想情況下是<100 毫秒),因此 CLR 有很多機會進行度量和調整。
    • 大部分時間都被阻止的工作不會主導池。

    阻塞很麻煩,因為它給 CLR 一個錯誤的想法,即它正在加載 CPU。CLR 足夠智能,可以檢測和補償(通過將更多線程注入池),盡管這可能會使池容易受到后續超額訂閱的影響。它還可能引入延遲,因為 CLR 會限制它注入新線程的速率,尤其是在應用程序生命周期的早期(在它傾向于降低資源消耗的客戶端操作系統上更是如此)。

    當您想要充分利用 CPU 時(例如,通過中的并行編程 API),在線程池中保持良好的衛生狀況尤其重要。

    任務

    線程是用于創建并發的低級工具,因此,它有局限性。特別:

    • 盡管將數據傳遞到您啟動的線程中很容易,但沒有簡單的方法可以從您加入的線程中獲取“返回值”。您需要設置某種共享字段。如果操作引發異常,捕獲和傳播該異常同樣痛苦。
    • 你不能告訴一個線程在完成后開始其他事情;相反,您必須加入它(在此過程中阻止您自己的線程)。

    這些限制阻礙了細粒度并發;換句話說,它們使得通過組合較小的并發操作來組合較大的并發操作變得困難(這對于我們在以下各節中介紹的異步編程至關重要)。這反過來又導致對手動同步(鎖定、信令等)的更大依賴以及隨之而來的問題。

    直接使用線程也具有我們在中討論的性能影響。如果您需要運行數百或數千個并發 I/O 綁定操作,基于線程的方法純粹在線程開銷中消耗數百或數千兆字節的內存。

    Task 類有助于解決所有這些問題。與線程相比,Task 是更高級別的抽象 - 它表示線程可能支持也可能不支持的并發操作。任務是(您可以通過使用將它們鏈接在一起)。他們可以使用來減少啟動延遲,并且使用 TaskCompletionSource ,他們可以采用回調方法,在等待 I/O 綁定操作時完全避免線程。

    任務類型在框架 4.0 中作為并行編程庫的一部分引入。但是,它們后來得到了增強(通過使用),以便在更一般的并發方案中同樣出色地發揮作用,并且是 C# 異步函數的支持類型。

    注意

    在本節中,我們忽略了專門針對并行編程的任務的功能;我們將在第中介紹它們。

    啟動任務

    啟動由線程支持的任務的最簡單方法是使用靜態方法 Task.Run(Task 類位于 System.Threading.Tasks 命名空間中)。只需傳入操作委托:

    Task.Run (()=> Console.WriteLine ("Foo"));

    注意

    默認情況下,任務使用池線程,即后臺線程。這意味著當主線程結束時,您創建的任何任務也會結束。因此,要從控制臺應用程序運行這些示例,您必須在啟動任務后阻止主線程(例如,通過等待任務或調用 Console.ReadLine ):

    Task.Run (()=> Console.WriteLine ("Foo"));
    Console.ReadLine();

    在本書的 LINQPad 配套示例中,省略了 Console.ReadLine,因為 LINQPad 進程使后臺線程保持活動狀態。

    以這種方式調用 Task.Run 類似于啟動線程,如下所示(除了我們稍后討論的線程池含義):

    new Thread (()=> Console.WriteLine ("Foo")).Start();

    Task.Run 返回一個 Task 對象,我們可以使用它來監視其進度,就像 Thread 對象一樣。(但是請注意,我們沒有在調用 Task.Run 后調用 Start,因為此方法創建“熱”任務;您可以改用 Task 的構造函數來創建“冷”任務,盡管在實踐中很少這樣做。

    可以通過任務的 Status 屬性跟蹤任務的執行狀態。

    調用任務塊上的等待,直到它完成,相當于在線程上調用 Join:

    Task task=Task.Run (()=>
    {
      Thread.Sleep (2000);
      Console.WriteLine ("Foo");
    });
    Console.WriteLine (task.IsCompleted);  // False
    task.Wait();  // Blocks until task is complete

    等待允許您選擇指定超時和取消令牌以提前結束等待(請參閱)。

    長時間運行的任務

    默認情況下,CLR 在池線程上運行任務,這非常適合短期運行的計算密集型工作。對于運行時間較長的操作和阻塞操作(如前面的示例),可以阻止使用池化線程,如下所示:

    Task task=Task.Factory.StartNew (()=> ...,
                                       TaskCreationOptions.LongRunning);

    注意

    在池線程上運行長時間運行的任務不會造成麻煩;當您并行運行多個長時間運行的任務(尤其是那些阻塞的任務)時,性能可能會受到影響。在這種情況下,通常有比TaskCreationOptions.LongRun更好的解決方案:

    • 如果任務是 I/O 綁定的,則 TaskCompletionSource 和允許您使用回調(延續)而不是線程實現并發。
    • 如果任務是計算密集型的,則允許您限制這些任務的并發性,從而避免其他線程和進程的匱乏(請參閱中的)。

    返回值

    Task有一個名為Task<TResult>的泛型子類,它允許任務發出返回值。您可以通過使用 Func<TResult> 委托(或兼容的 lambda 表達式)而不是 Action 調用 Task.Run 來獲取 Task<TResult>:

    Task<int> task=Task.Run (()=> { Console.WriteLine ("Foo"); return 3; });
    // ...

    以后可以通過查詢 Result 屬性來獲取結果。如果任務尚未完成,則訪問此屬性將阻止當前線程,直到任務完成:

    int result=task.Result;      // Blocks if not already finished
    Console.WriteLine (result);    // 3

    在下面的示例中,我們創建一個任務,該任務使用 LINQ 計算前三百萬 (+2) 個整數中的素數數:

    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);

    這寫著“任務正在運行...”然后幾秒鐘后寫下216816的答案。

    注意

    任務<TResult>可以被認為是一個“未來”,因為它封裝了一個稍后可用的結果。

    異常

    與線程不同,任務可以方便地傳播異常。因此,如果任務中的代碼拋出未經處理的異常(換句話說,如果任務),則該異常會自動重新拋出給調用 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 中,以便很好地與并行編程方案配合使用;我們將在第 中對此進行討論。

    您可以通過任務的 IsFaulted 和 IsCanceled 屬性測試出錯的任務,而無需重新引發異常。如果兩個屬性都返回 false,則未發生錯誤;如果 IsCanceled 為 true,則為該任務拋出 OperationCanceledException(請參閱);如果 IsFaulted 為 true,則引發另一種類型的異常,并且 Exception 屬性將指示錯誤。

    異常和自主任務

    對于自主的“設置并忘記”任務(那些您不通過 Wait() 或 Result 會合的任務,或者執行相同操作的延續),最好顯式異常處理任務代碼以避免靜默失敗,就像使用線程一樣。

    注意

    當異常僅表示無法獲得您不再感興趣的結果時,忽略異常是可以的。例如,如果用戶取消了下載網頁的請求,我們不會關心該網頁是否存在。

    當異常指示程序中存在錯誤時,忽略異常是有問題的,原因有兩個:

    • 該錯誤可能使程序處于無效狀態。
    • 由于 bug,以后可能會發生更多異常,并且未能記錄初始錯誤可能會使診斷。

    您可以通過靜態事件 TaskScheduler.UnobservedTaskException 在全局級別訂閱未觀察到的異常;處理此事件并記錄錯誤可能很有意義。

    關于什么算作未觀察到,有幾個有趣的細微差別:

    • 如果故障發生在超時間隔,則等待超時的任務將生成未觀察到的異常。
    • 在任務出錯后檢查任務的 Exception 屬性的操作會使異常“被觀察到”。

    延續

    延續對任務說:“當你完成時,繼續做其他事情。延續通常由在操作完成后執行一次的回調實現。有兩種方法可以將延續附加到任務。第一個特別重要,因為它被 C# 的異步函數使用,你很快就會看到。我們可以通過不久前在中編寫的素數計數任務來演示它:

    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
    });

    在任務上調用 GetAwaiter 會返回一個對象,其 OnCompleted 方法告訴任務 ( primeNumberTask ) 在完成(或錯誤)時執行委托。將延續附加到已完成的任務是有效的,在這種情況下,延續將計劃為立即執行。

    注意

    是公開我們剛剛看到的兩個方法(OnComplete和GetResult)和一個名為IsComplete的布爾屬性的任何對象。沒有接口或基類來統一所有這些成員(盡管OnComplete是接口INotifyComplete的一部分)。我們在中解釋了該模式的重要性。

    如果先前的任務出錯,則在繼續代碼調用 awaiter 時將重新引發異常。獲取結果() .與其調用 GetResult ,我們可以簡單地訪問前置的 Result 屬性。調用 GetResult 的好處是,如果先驗錯誤,則直接拋出異常,而不被包裝在 AggregateException 中,從而允許更簡單、更干凈的捕獲塊。

    對于非泛型任務,GetResult() 具有 void 返回值。它的有用功能只是重新引發異常。

    如果存在同步上下文,OnDone 會自動捕獲該上下文并將延續發布到該上下文。這在胖客戶端應用程序中非常有用,因為它會將延續反彈回 UI 線程。但是,在編寫庫時,通常不希望這樣做,因為相對昂貴的 UI 線程反彈應該只在離開庫時發生一次,而不是在方法調用之間發生。因此,您可以使用 ConfigureAwait 方法擊敗它:

    var awaiter=primeNumberTask.ConfigureAwait (false).GetAwaiter();

    如果不存在同步上下文,或者您使用 ConfigureAwait(false),則延續(通常)將在與前置相同的線程上執行,從而避免不必要的開銷。

    附加延續的另一種方法是調用任務的 ContinueWith 方法:

    primeNumberTask.ContinueWith (antecedent=> 
    {
      int result=antecedent.Result;
      Console.WriteLine (result);          // Writes 123
    });

    ContinueWith 本身返回一個 Task ,如果你想附加進一步的延續,這很有用。但是,如果任務出錯,則必須直接處理 AggregateException,并編寫額外的代碼以在 UI 應用程序中封送延續(請參閱中的)。在非 UI 上下文中,如果您希望延續在同一線程上執行,則必須指定 TaskContinuationOptions.ExecuteSyncly;否則它將反彈到線程池。繼續在并行編程方案中特別有用;我們將在第中詳細介紹它。

    任務完成源

    我們已經了解了 Task.Run 如何創建一個在池(或非池)線程上運行委托的任務。創建任務的另一種方法是使用 任務完成源 .

    TaskCompletionSource 允許您從稍后開始和完成的任何操作中創建任務。它的工作原理是為您提供手動驅動的“從屬”任務 - 通過指示操作何時完成或出現故障。這是 I/O 密集型工作的理想選擇:您可以獲得任務的所有好處(以及它們傳播返回值、異常和延續的能力),而不會在操作期間阻塞線程。

    要使用 TaskCompletionSource ,您只需實例化該類。它公開一個 Task 屬性,該屬性返回一個任務,您可以在該任務上等待并附加延續 — 就像任何其他任務一樣。但是,該任務完全由 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);
      ...
    }

    調用這些方法中的任何一個都會發出,將其置于已完成、出錯或已取消狀態(我們將在一節中介紹后者)。您應該只調用一次這些方法之一:如果再次調用,SetResult 、SetException 或 SetCanceled 將引發異常,而 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

    使用 任務完成源 ,我們可以編寫自己的 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; });

    調用此方法等效于使用 TaskCreationOptions.LongRun 選項調用 Task.Factory.StartNew 來請求非池化線程。

    TaskCompletionSource的真正力量在于創建不占用線程的任務。例如,假設一個任務等待五秒鐘,然后返回數字 42。我們可以使用 Timer 類在沒有線程的情況下編寫它,該類在 CLR(反過來還有操作系統)的幫助下,在 毫秒內觸發一個事件(我們將在第 中重新訪問計時器):

    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;
    }

    因此,我們的方法返回一個任務,該任務在五秒后完成,結果為 42。通過將延續附加到任務,我們可以在不阻塞線程的情況下編寫其結果:

    var awaiter=GetAnswerToLife().GetAwaiter();
    awaiter.OnCompleted (()=> Console.WriteLine (awaiter.GetResult()));

    我們可以使其更有用,并通過參數化延遲時間和擺脫返回值將其轉換為通用的 Delay 方法。這意味著讓它返回一個任務而不是一個任務<int> 。但是,沒有非泛型版本的 任務完成源 ,這意味著我們不能直接創建非泛型任務。解決方法很簡單:因為 Task<TResult> 派生自 任務 ,我們創建一個,然后將其給你的隱式轉換為任務,如下所示:TaskCompletionSource<anything>Task<anything>

    var tcs=new TaskCompletionSource<object>();
    Task task=tcs.Task;

    現在我們可以編寫通用的 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。

    以下是我們如何使用它在五秒后寫“42”:

    Delay (5000).GetAwaiter().OnCompleted (()=> Console.WriteLine (42));

    我們使用不帶線程的 TaskCompletionSource 意味著線程僅在延續開始時(五秒后)才會參與。我們可以通過一次啟動 10,000 個這樣的操作來證明這一點,而不會出錯或過度資源:

    for (int i=0; i < 10000; i++)
      Delay (5000).GetAwaiter().OnCompleted (()=> Console.WriteLine (42));

    注意

    計時器在池化線程上觸發回調,因此五秒鐘后,線程池將收到 10,000 個請求,以調用 TaskCompletionSource 上的 SetResult(null)。如果請求到達的速度快于處理速度,則線程池將通過排隊然后以 CPU 的最佳并行級別處理它們來響應。如果線程綁定作業運行時間較短,這是理想的選擇,在這種情況下也是如此:線程綁定作業只是對 SetResult 的調用,加上將延續發布到同步上下文的操作(在 UI 應用程序中)或其他延續本身( Console.WriteLine(42) )。

    任務延遲

    我們剛剛編寫的 Delay 方法非常有用,可以作為 Task 類上的靜態方法使用:

    Task.Delay (5000).GetAwaiter().OnCompleted (()=> Console.WriteLine (42));

    Task.Delay (5000).ContinueWith (ant=> Console.WriteLine (42));

    Task.Delay 是 Thread.Sleep 的等價物。

    異步原則

    在演示 TaskCompletionSource 時,我們最終編寫了方法。在本節中,我們將準確定義異步操作是什么,并解釋這如何導致異步編程。

    同步操作與異步操作

    在返回到調用方完成其工作。

    可以在返回到調用方完成(大部分或全部)工作。

    您編寫和調用的大多數方法是同步的。一個例子是List<T>。Add , or Console.WriteLine , or Thread.Sleep 。異步方法不太常見,它們會啟動性,因為工作與調用方并行進行。異步方法通常快速(或立即)返回給調用方;因此,它們也稱為。

    到目前為止,我們看到的大多數異步方法都可以描述為通用方法:

    • 線程啟動
    • 任務運行
    • 將延續附加到任務的方法

    此外,我們在中討論的一些方法( 調度程序.開始調用 , 控制.開始調用 和 SynchronizationContext.Post )是異步的,我們在中編寫的方法也是如此,包括延遲 。

    什么是異步編程?

    異步編程的原則是異步編寫長時間運行(或可能長時間運行)的函數。這與同步編寫長時間運行的函數,然后根據需要從新線程或任務調用這些函數以引入并發性的傳統方法形成對比。

    與異步方法的不同之處在于,并發性是在長時間運行的函數啟動的,而不是從函數啟動的。這有兩個好處:

    • I/O 綁定并發可以在不占用線程的情況下實現(正如我們在中演示的那樣),從而提高了可伸縮性和效率。
    • 富客戶端應用程序最終在工作線程上的代碼更少,從而簡化了線程安全性。

    這反過來又導致了異步編程的兩種不同用途。第一種是編寫(通常是服務器端)應用程序,以有效地處理大量并發 I/O。這里的挑戰不是線程(因為通常共享狀態最小),而是線程;特別是,不為每個網絡請求消耗一個線程。因此,在此上下文中,只有 I/O 綁定操作才能從異步中受益。

    第二個用途是簡化富客戶端應用程序中的線程安全。隨著程序規模的擴大,這一點尤其重要,因為為了處理復雜性,我們通常會將較大的方法重構為較小的方法,從而導致相互調用的方法鏈()。

    對于傳統的調用圖,如果圖中的任何操作長時間運行,我們必須在工作線程上運行整個調用圖以維護響應式 UI。因此,我們最終得到一個跨越許多方法的并發操作(這需要考慮圖中每個方法的線程安全性。

    對于調用圖,我們不需要啟動線程,直到實際需要它,通常在圖中較低(或者在 I/O 綁定操作的情況下根本不啟動)。所有其他方法都可以完全在 UI 線程上運行,線程安全性大大簡化。這會導致 - 一系列小型并發操作,執行在這些操作之間反彈到 UI 線程。

    注意

    為了從中受益,需要異步編寫 I/O 和計算綁定操作;一個好的經驗法則是包括可能需要超過 50 毫秒的任何內容。

    (另一方面,細粒度的異步可能會損害性能,因為異步操作會產生開銷 — 請參閱

    在本章中,我們將主要關注富客戶端方案,這是兩者中更復雜的方案。在第中,我們給出了兩個示例來說明I/O綁定場景(參見“和)。

    注意

    UWP 框架鼓勵異步編程,使某些長時間運行的方法的同步版本不公開或引發異常。相反,必須調用返回任務(或可通過 AsTask 擴展方法轉換為任務的對象)的異步方法。

    異步編程和延續

    任務非常適合異步編程,因為它們支持延續,這對于異步至關重要(考慮我們在中編寫的 Delay 方法)。在編寫延遲時,我們使用了TaskCompletionSource,這是實現“底層”I/O綁定異步方法的標準方法。

    對于計算綁定方法,我們使用 Task.Run 啟動線程綁定并發。只需將任務返回給調用方,我們就會創建一個異步方法。異步編程的區別在于,我們的目標是在調用圖中較低的位置執行此操作,以便在富客戶端應用程序中,更高級別的方法可以保留在UI線程和訪問控制以及共享狀態上,而不會出現線程安全問題。為了說明這一點,請考慮以下使用所有可用內核計算和計數素數的方法(我們將在第 中討論 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));
    }

    這如何工作的細節并不重要;重要的是它可能需要一段時間才能運行。我們可以通過編寫另一個方法來調用它來演示這一點:

    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

    現在我們有一個,DisplayPrimeCounts調用GetPrimesCount。前者使用Console.WriteLine來簡化,盡管實際上它更有可能更新富客戶端應用程序中的UI控件,正如我們稍后演示的那樣。我們可以為此調用圖啟動粗粒度并發,如下所示:

    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)));
    }

    為什么語言支持很重要

    現在我們必須修改 DisplayPrimeCounts,以便它調用 .這就是 C# 的 await 和 async 關鍵字發揮作用的地方,因為否則這樣做比聽起來更棘手。如果我們簡單地修改循環如下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");

    循環將快速旋轉 10 次迭代(方法是非阻塞的),所有 10 個操作將并行執行(然后是過早的“完成”)。

    注意

    在這種情況下,并行執行這些任務是不可取的,因為它們的內部實現已經并行化;它只會讓我們等待更長的時間才能看到第一個結果(并搞砸排序)。

    但是,需要任務執行還有一個更常見的原因,即任務 B 依賴于任務 A 的結果。例如,在獲取網頁時,DNS 查找必須在 HTTP 請求之前進行。

    為了使它們按順序運行,我們必須從延續本身觸發下一個循環迭代。這意味著消除 for 循環并在延續中訴諸遞歸調用:

    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 異步,返回它在完成時發出信號的任務,情況會變得更糟。要完成此操作,需要創建一個 任務完成源:

    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 關鍵字,我們只需要寫這個:

    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!");
    }

    因此,異步和等待對于實現異步至關重要,而不會過于復雜。現在讓我們看看這些關鍵字是如何工作的。

    注意

    另一種看待這個問題的方法是命令式循環結構(for、foreach 等)不能很好地與延續混合,因為它們依賴于方法的(“這個循環還要運行多少次?”)。

    盡管 async 和 await 關鍵字提供了一種解決方案,但有時可以通過將命令性循環構造替換為(換句話說,LINQ 查詢)來以另一種方式解決它。這是 (Rx) 的基礎,當您想要對結果執行查詢運算符或組合多個序列時,這可能是一個不錯的選擇。要付出的代價是,為了防止阻塞,Rx在基于的序列上運行,這在概念上可能很棘手。

    C 語言中的異步函數#

    async 和 await 關鍵字允許您編寫與同步代碼具有相同結構和簡單性的異步代碼,同時消除異步編程的“管道”。

    等待

    await 關鍵字簡化了延續的附加。從基本方案開始,編譯器對此進行了擴展

    var result=await expression;
    statement(s);

    變成功能上與此類似的內容:

    var awaiter=expression.GetAwaiter();
    awaiter.OnCompleted (()=> 
    {
      var result=awaiter.GetResult();
      statement(s);
    });

    注意

    編譯器還會發出代碼,以便在同步完成的情況下縮短延續(參見),并處理我們在后面的部分中了解到的各種細微差別。

    為了演示,讓我們重新審視我們之前編寫的計算和計算素數的異步方法:

    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 關鍵字,我們可以按如下方式調用它:

    int result=await GetPrimesCountAsync (2, 1000000);
    Console.WriteLine (result);

    要編譯,我們需要將異步修飾符添加到包含方法中:

    async void DisplayPrimesCount()
    {
      int result=await GetPrimesCountAsync (2, 1000000);
      Console.WriteLine (result);
    }

    async 修飾符指示編譯器在該方法中出現歧義時將 await 視為關鍵字而不是標識符(這可確保在 C# 5 之前編寫的可能使用 await 作為標識符的代碼仍將編譯而不會出錯)。異步修飾符只能應用于返回 void 或(稍后您將看到的)任務或任務<TResult> 的方法(和 lambda 表達式)。

    注意

    異步修飾符類似于不安全修飾符,因為它對方法的簽名或公共元數據沒有影響;它僅影響方法發生的情況。因此,在接口中使用異步是沒有意義的。但是,例如,在覆蓋非異步虛擬方法時引入異步是合法的,只要您保持簽名相同。

    具有異步修飾符的方法稱為,因為它們本身通常是異步的。為了了解原因,讓我們看看執行如何通過異步函數進行。

    遇到 await 表達式后,執行(通常)返回到調用方,就像迭代器中的 yield return 一樣。但在返回之前,運行時會將延續附加到等待的任務,確保在任務完成時,執行將跳回到方法中,并從中斷的位置繼續。如果任務出錯,則重新引發其異常,否則將其返回值分配給 await 表達式。我們可以通過查看我們剛剛檢查的異步方法的邏輯擴展來總結我們剛才所說的一切:

    void DisplayPrimesCount()
    {
      var awaiter=GetPrimesCountAsync (2, 1000000).GetAwaiter();
      awaiter.OnCompleted (()=>    
      {
        int result=awaiter.GetResult();
        Console.WriteLine (result);
      });
    }

    您等待的表達式通常是一個任務;但是,任何具有返回 GetAwaiter 方法的對象(實現 INotifyCompletion.OnComplete,并使用適當類型的 GetResult 方法和布爾 IsCompleted 屬性)將滿足編譯器的要求。

    請注意,我們的 await 表達式的計算結果為 int 類型;這是因為我們等待的表達式是一個 Task<int>(其 GetAwaiter()。GetResult() 方法返回一個 int )。

    等待非通用任務是合法的,并生成一個 void 表達式:

    await Task.Delay (5000);
    Console.WriteLine ("Five seconds passed!");

    捕獲本地狀態

    await 表達式的真正強大之處在于它們幾乎可以出現在代碼中的任何位置。具體而言,await 表達式可以代替任何表達式(在異步函數中)出現,但鎖表達式或不安全上下文中除外。

    在下面的示例中,我們在循環中等待:

    async void DisplayPrimeCounts()
    {
      for (int i=0; i < 10; i++)
        Console.WriteLine (await GetPrimesCountAsync (i*1000000+2, 1000000));
    }

    在第一次執行 GetPrimesCountAsync 時,執行會通過 await 表達式返回給調用方。當方法完成(或出錯)時,執行將從中斷的位置繼續,并保留局部變量和循環計數器的值。

    如果沒有 await 關鍵字,最簡單的等價物可能是我們在中寫的示例。但是,編譯器采用更通用的策略,將此類方法重構到狀態機中(就像迭代器一樣)。

    編譯器依賴于延續(通過等待者模式)在等待表達式之后恢復執行。這意味著,如果在富客戶端應用程序的 UI 線程上運行,同步上下文可確保在同一線程上恢復執行。否則,將在任務完成的任何線程上恢復執行。線程的更改不會影響執行順序,并且無關緊要,除非您以某種方式依賴于線程親和性,也許通過使用線程本地存儲(請參閱中的)。這就像游覽一個城市,叫出租車從一個目的地到另一個目的地。使用同步上下文,您將始終獲得相同的出租車;如果沒有同步上下文,您通常每次都會得到不同的出租車。但是,無論哪種情況,旅程都是一樣的。

    在 UI 中等待

    我們可以通過編寫一個簡單的 UI 在更實際的上下文中演示異步函數,該 UI 在調用計算綁定方法時保持響應。讓我們從一個同步解決方案開始:

    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”按鈕后,應用程序在執行計算綁定代碼所需的時間內變得無響應。異步有兩個步驟;首先是切換到我們在前面的示例中使用的異步版本的 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 以調用 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;
    }

    這說明了使用異步函數編程的簡單性:您可以像同步編程一樣編程,但調用異步函數而不是阻塞函數并等待它們。只有 GetPrimesCountAsync 中的代碼在工作線程上運行;Go 中的代碼在 UI 線程上“租用”時間。我們可以說 Go 偽地執行消息循環(因為它的執行與 UI 線程處理的其他事件穿插在一起)。使用此偽并發時,唯一可能發生搶占的時間點是在等待期間。這簡化了線程安全性:在我們的例子中,這可能導致的唯一問題是(在按鈕運行時再次單擊按鈕,我們通過禁用按鈕來防止)。真正的并發性發生在調用堆棧的較低位置,即 Task.Run 調用的代碼中。為了從此模型中受益,真正的并發代碼可防止訪問共享狀態或 UI 控件。

    再舉一個例子,假設我們不是計算質數,而是下載幾個網頁并將它們的長度相加。.NET 公開了許多任務返回異步方法,其中之一是 中的 WebClient 類。DownloadDataTaskAsync 方法異步下載一個 URI 到一個字節數組,返回一個 任務<字節[]> ,所以通過等待它,我們得到一個字節[]。現在讓我們重寫我們的 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; }
    }

    同樣,這反映了我們如何同步編寫它——包括使用 catch 和 finally 塊。即使執行在第一個等待后返回給調用方,finally 塊也不會執行,直到方法邏輯完成(由于其所有代碼執行 - 或提前返回或未處理的異常)。

    準確考慮下面發生的事情可能會有所幫助。首先,我們需要重新訪問在 UI 線程上運行消息循環的偽代碼:

    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 元素的事件處理程序通過此消息循環執行。當我們的 Go 方法運行時,執行一直持續到 await 表達式,然后返回到消息循環(釋放 UI 以響應進一步的事件)。但是,編譯器對 await 的擴展可確保在返回之前設置延續,以便在任務完成后從中斷的位置恢復執行。由于我們在 UI 線程上等待,因此延續發布到同步上下文,同步上下文通過消息循環執行它,從而使我們的整個 Go 方法在 UI 線程上偽并發執行。True(I/O 綁定)并發發生在 DownloadDataTaskAsync 的實現中。

    與粗粒度并發的比較

    在 C# 5 之前,異步編程很困難,不僅因為沒有語言支持,還因為 .NET Framework 通過稱為 EAP 和 APM 的笨拙模式(請參閱而不是任務返回方法公開異步功能。

    流行的解決方法是粗粒度并發(事實上,甚至還有一種稱為 BackgroundWorker 的類型來幫助解決這個問題)。回到我們原來的示例 GetPrimesCount ,我們可以通過修改按鈕的事件處理程序來演示粗粒度異步,如下所示:

      ...
      _button.Click +=(sender, args)=>
      {
        _button.IsEnabled=false;
        Task.Run (()=> Go());
      };

    (我們選擇使用 Task.Run 而不是 BackgroundWorker ,因為后者不會簡化我們的特定示例。無論哪種情況,最終結果都是我們的整個同步調用圖(Go加GetPrimesCount)在工作線程上運行。由于 Go 更新了 UI 元素,我們現在必須使用 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));
    }

    與異步版本不同,循環本身在工作線程上運行。這似乎無害,然而,即使在這種簡單的情況下,我們對多線程的使用也引入了競爭條件。(你能發現它嗎?如果沒有,請嘗試運行該程序:它幾乎肯定會變得明顯。

    實現取消和進度報告會為線程安全錯誤創造更多可能性,方法中的任何其他代碼也是如此。例如,假設循環的上限不是硬編碼的,而是來自方法調用:

      for (int i=1; i < GetUpperBound(); i++)

    現在假設 GetUpperBound() 從延遲加載的配置文件中讀取值,該文件在第一次調用時從磁盤加載。所有這些代碼現在都在工作線程上運行,這些代碼很可能不是線程安全的。這是在調用圖中啟動高位工作線程的危險。

    編寫異步函數

    對于任何異步函數,都可以將 void 返回類型替換為 Task,以使方法本身(并且 await 可用)。無需進一步更改:

    async Task PrintAnswerToLife()   // We can return Task instead of void
    {
      await Task.Delay (5000);
      int answer=21 * 2;
      Console.WriteLine (answer);  
    }

    請注意,我們不會在方法主體中顯式返回任務。編譯器制造任務,并在方法完成(或未處理的異常)時發出信號。這使得創建異步調用鏈變得容易:

    async Task Go()
    {
      await PrintAnswerToLife();
      Console.WriteLine ("Done");
    }

    而且由于我們已經聲明了帶有任務返回類型的 Go,因此 Go 本身是可以等待的。

    編譯器擴展異步函數,這些函數將任務返回到代碼中,該代碼使用 TaskCompletionSource 創建任務,然后發出信號或出錯。

    撇開細微差別不談,我們可以將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;
    }

    因此,每當任務返回異步方法完成時,執行都會跳回到等待它的任何內容(通過延續)。

    注意

    在胖客戶端方案中,此時執行將反彈回 UI 線程(如果它尚未在 UI 線程上)。否則,它會在延續返回的任何線程上繼續。這意味著冒泡異步調用圖沒有延遲成本,如果它是 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
    }

    在內部,這會導致 TaskCompletionSource 發出值而不是 null 的信號。我們可以通過從 PrintAnswerToLife 調用它來演示 GetAnswerToLife(反過來,從 Go 調用):

    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;
    }

    實際上,我們已經將原始的PrintAnswerToLife重構為兩種方法 - 就像我們同步編程一樣容易。與同步編程的相似性是有意的;下面是我們的調用圖的同步等價物,調用 Go() 在阻塞五秒后給出相同的結果:

    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# 中使用異步函數進行設計的基本原理:

    1. 同步編寫方法。
    2. 將方法調用替換為方法調用,并等待它們。
    3. 除了“頂級”方法(通常是 UI 控件的事件處理程序)之外,將異步方法的返回類型升級到 Task 或 Task<TResult>以便它們可以等待。

    編譯器為異步函數制造任務的能力意味著在大多數情況下,您只需要在啟動 I/O 綁定并發的底層方法(相對罕見的情況下)顯式實例化 TaskCompletionSource。(對于啟動計算綁定并發的方法,您可以使用 Task.Run 創建任務。

    異步調用圖執行

    要確切地了解其執行方式,按如下方式重新排列我們的代碼會很有幫助:

    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 調用 PrintAnswerToLife,它調用 GetAnswerToLife,調用 Delay,然后等待。await 導致執行返回到 PrintAnswerToLife ,它本身在等待,返回到 Go ,它也在等待并返回給調用方。所有這些都同步發生,在名為 Go ;這是執行的簡短階段。

    五秒鐘后,延遲上的延續將觸發,執行將返回到池線程上的 GetAnswerToLife。(如果我們從 UI 線程開始,執行現在會反彈到該線程。然后運行GetAnswerToLife中的其余語句,之后該方法的Task<int>以結果42完成,并在PrintAnswerToLife中執行,這將執行該方法中的其余語句。這個過程一直持續到 Go 的任務被標記為完成。

    執行流與我們之前顯示的同步調用圖匹配,因為我們遵循一種模式,即在調用每個異步方法后立即等待它。這將創建一個順序流,在調用圖中沒有并行性或重疊執行。每個 await 表達式都會在執行中創建一個“間隙”,之后程序將從中斷的位置恢復。

    排比

    調用異步方法而不等待它允許以下代碼并行執行。您可能已經注意到,在前面的示例中,我們有一個按鈕,其事件處理程序名為 Go ,如下所示:

    _button.Click +=(sender, args)=> Go();

    盡管 Go 是一種異步方法,但我們并沒有等待它,這確實有助于維護響應式 UI 所需的并發性。

    我們可以使用相同的原理并行運行兩個異步操作:

    var task1=PrintAnswerToLife();
    var task2=PrintAnswerToLife();
    await task1; await task2;

    (通過等待之后的兩個操作,我們在這一點上“結束”并行性。稍后,我們將介紹 WhenAll 任務組合器如何幫助處理此模式。

    無論操作是否在 UI 線程上啟動,都會以這種方式創建的并發,盡管其發生方式有所不同。在這兩種情況下,我們都會在啟動它的底層操作(例如 Task.Delay 或 Task.Run 的代碼)中獲得相同的“真”并發性。僅當操作是在不存在同步上下文的情況下啟動時,調用堆棧中高于此值的方法才受 true 并發性的約束;否則,它們將受制于我們之前討論的偽并發(和簡化的線程安全),其中唯一可以搶占的地方是 await 語句。例如,這讓我們可以定義一個共享字段,_x ,并在 GetAnswerToLife 中遞增它而不會鎖定:

    async Task<int> GetAnswerToLife()
    {
      _x++;
      await Task.Delay (5000);
      return 21 * 2;
    }

    (但是,我們無法假設_x在等待之前和之后具有相同的值。

    異步 Lambda 表達式

    就像普通的方法可以是異步的一樣

    async Task NamedMethod()
    {
      await Task.Delay (1000);
      Console.WriteLine ("Foo");
    }

    的方法(Lambda 表達式和匿名方法)也是如此,如果前面有 async 關鍵字:

    Func<Task> unnamed=async ()=>
    {
      await Task.Delay (1000);
      Console.WriteLine ("Foo");
    };

    我們可以以相同的方式調用和等待這些:

    await NamedMethod();
    await unnamed();

    我們可以在附加事件處理程序時使用異步 lambda 表達式:

    myButton.Click +=async (sender, args)=>
    {
      await Task.Delay (1000);
      myButton.Content="Done";
    };

    這比具有相同效果的以下內容更簡潔:

    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 ,您可以編寫一個異步函數。(來自 C# 8)結合了這些概念,并允許您編寫等待的迭代器,異步生成元素。此支持基于以下接口對,這些接口是我們中描述的枚舉接口的異步對應項:

    public interface IAsyncEnumerable<out T>
    {
      IAsyncEnumerator<T> GetAsyncEnumerator (...);
    }
    
    public interface IAsyncEnumerator<out T>: IAsyncDisposable
    {
      T Current { get; }
      ValueTask<bool> MoveNextAsync();
    }

    ValueTask<T> 是一個包裝 Task<T> 的結構,在行為上類似于 Task<T>同時在任務同步完成時實現更高效的執行(這在枚舉序列時經常發生)。有關差異的討論,請參閱 IAsyncDisposable 是 IDisposable 的異步版本;如果您選擇手動實現接口,它提供了執行清理的機會:

    public interface IAsyncDisposable
    {
      ValueTask DisposeAsync();
    }

    注意

    從序列中獲取每個元素的操作( MoveNextAsync )是一種異步操作,因此當元素以分段方式到達時(例如處理來自視頻流的數據時),異步流是合適的。相反,當序列時,以下類型更合適,但元素在到達時會一起到達:

    Task<IEnumerable<T>>

    若要生成異步流,請編寫一個結合了迭代器和異步方法原則的方法。換句話說,你的方法應該同時包括 yield return 和 await ,并且它應該返回 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);

    請注意,數據每 500 毫秒穩定到達一次(或者在現實生活中,當數據可用時)。將此與使用 Task<IEnumerable<T 的類似構造進行對比>>在最后一條數據可用之前不會返回任何數據:

    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);

    正在查詢 IAsyncEnumerable<T>

    NuGet 包定義了通過 IAsyncEnumerable<T> 運行的 LINQ 查詢運算符,允許您像使用 IEnumerable<T> 一樣編寫查詢。

    例如,我們可以在上一節中定義的 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 等。

    注意

    如果您熟悉反應式擴展,也可以通過調用 ToObservable 擴展方法從其(更強大的)查詢運算符中受益,該方法將 IAsyncEnumerable<T> 轉換為 IObservable<T>。還可以使用ToAsyncEnumerable擴展方法,以反向轉換。

    IAsyncEnumerable<T> in ASP.Net Core

    ASP.Net 核心控制器操作現在可以返回 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;
    }

    WinRT 中的異步方法

    如果你正在開發 UWP 應用程序,則需要使用操作系統中定義的 WinRT 類型。WinRT相當于Task的是IAsyncAction,相當于Task<TResult>是IAsyncOperation<TResult>。對于報告進度的操作,等效項是 IAsyncActionWithProgress<TProgress> 和 IAsyncOperationWithProgress<TResult, TProgress> 。它們都是在 Windows.Foundation 命名空間中定義的。

    您可以通過 AsTask 擴展方法從 Task 轉換為 Task 或 Task<Task>seult:

    Task<StorageFile> fileTask=KnownFolders.DocumentsLibrary.CreateFileAsync
                                 ("test.txt").AsTask();

    或者,或者您可以直接等待他們:

    StorageFile file=await KnownFolders.DocumentsLibrary.CreateFileAsync
                             ("test.txt");

    注意

    由于 COM 類型系統的限制,IAsyncActionWithProgress<TProgress> 和 IAsyncOperationWithProgress<TResult, TProgress> 并不像您期望的那樣基于 IAsyncAction。相反,兩者都繼承自名為 IAsyncInfo 的公共基類型。

    AsTask 方法也會重載以接受取消令牌(請參閱)。當鏈接到 WithProgress 變體時,它也可以接受 IProgress<T> 對象(請參閱)。

    異步和同步上下文

    我們已經看到同步上下文的存在在發布延續方面的重要性。還有其他幾種更微妙的方式,此類同步上下文與 void 返回異步函數一起發揮作用。這些不是 C# 編譯器擴展的直接結果,而是編譯器在擴展異步函數時使用的 System.CompilerServices 命名空間中的 Async*MethodBuilder 類型的。

    異常發布

    在胖客戶端應用程序中,通常的做法是依靠中心異常處理事件(WPF 中的 Application.DispatcherUnhandledException)來處理 UI 線程上引發的未經處理的異常。在 ASP.NET Core 應用程序中,ConfigureServices 方法中的自定義 ExceptionFilterAttribute 執行類似的工作。在內部,它們通過在自己的 try / catch 塊中調用 UI 事件(或在 ASP.NET Core 中,頁面處理方法的管道)來工作。

    頂級異步函數使這變得復雜。請考慮以下用于按鈕單擊的事件處理程序:

    async void ButtonClick (object sender, RoutedEventArgs args)
    {
      await Task.Delay(1000);
      throw new Exception ("Will this be ignored?");
    }

    單擊按鈕并運行事件處理程序時,執行將正常返回到 await 語句之后的消息循環,并且消息循環中的 catch 塊無法捕獲一秒鐘后引發的異常。

    為了緩解此問題,AsyncVoidMethodBuilder 捕獲未經處理的異常(在返回 void 的異步函數中),并將其發布到同步上下文(如果存在),從而確保全局異常處理事件仍會觸發。

    注意

    編譯器僅將此邏輯應用于返回 的異步函數。因此,如果我們更改 ButtonClick 以返回任務而不是 void,則未處理的異常將錯誤生成的任務,然后該任務將無處可去(導致的異常)。

    一個有趣的細微差別是,無論您是在等待之前還是之后投擲都沒有區別。因此,在下面的示例中,異常將發布到同步上下文(如果存在),而不是發布到調用方:

    async void Foo() { throw null; await Task.Delay(1000); }

    (如果不存在同步上下文,則異常將在線程池上傳播,從而終止應用程序。

    異常不直接拋回調用方的原因是為了確保可預測性和一致性。在下面的示例中,InvalidOperationException 將始終具有與錯誤結果任務相同的效果 — 無論 :someCondition

    async Task Foo()
    {
      if (someCondition) await Task.Delay (100);
      throw new InvalidOperationException();
    }

    迭代器的工作方式類似:

    IEnumerable<int> Foo() { throw null; yield return 123; }

    在此示例中,永遠不會將異常直接拋出回調用方:直到枚舉序列才會引發異常。

    操作已啟動和操作已完成

    如果存在同步上下文,則返回 void 的異步函數也會在進入函數時調用其 OperationStarted 方法,并在函數完成時調用其 OperationCompleted 方法。

    如果為單元測試 void 返回異步方法編寫自定義同步上下文,則重寫這些方法很有用。上進行了討論。

    優化

    同步完成

    異步函數可以在等待返回。請考慮以下緩存網頁下載的方法:

    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,則執行將返回到調用方,而不會發生等待,并且該方法返回。這稱為。

    當您等待同步完成的任務時,執行不會返回到調用方并通過延續反彈;相反,它會立即進入下一條語句。編譯器通過檢查等待器上的 IsCompleted 屬性來實現此優化;換句話說,只要你等待

    Console.WriteLine (await GetWebPageAsync ("http://oreilly.com"));

    編譯器發出代碼以在同步完成的情況下短路延續:

    var awaiter=GetWebPageAsync().GetAwaiter();
    if (awaiter.IsCompleted)
      Console.WriteLine (awaiter.GetResult());
    else
      awaiter.OnCompleted (()=> Console.WriteLine (awaiter.GetResult());

    注意

    等待同步返回的異步函數仍然會產生(非常)小的開銷——在 20 年代的 PC 上可能是 2019 納秒。

    相比之下,彈到線程池會引入上下文切換的成本(可能是一到兩微秒),而彈跳到 UI 消息循環的成本至少是其 10 倍(如果 UI 線程繁忙,則更長)。

    編寫等待的異步方法甚至是合法的,盡管編譯器會生成警告:

    async Task<string> Foo() { return "abc"; }

    如果您的實現碰巧不需要異步,則在重寫虛擬/抽象方法時,此類方法可能很有用。(一個例子是MemoryStream的ReadAsync / WriteAsync方法;見。實現相同結果的另一種方法是使用 Task.FromResult ,它返回一個已經發出信號的任務:

    Task<string> Foo() { return Task.FromResult ("abc"); }

    如果從 UI 線程調用,我們的 GetWebPageAsync 方法是隱式線程安全的,因為您可以連續多次調用它(從而啟動多個并發下載),并且不需要鎖定來保護緩存。但是,如果一系列調用是針對同一 URI,我們最終會啟動多個冗余下載,所有這些下載最終都會更新相同的緩存條目(最后一個獲勝)。雖然沒有錯誤,但如果對同一 URI 的后續調用可以(異步)等待正在進行的請求的結果,則會更有效。

    有一種簡單的方法可以實現這一點 - 無需訴諸鎖或信號結構。我們創建一個“期貨”緩存(任務<字符串> ):

    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);
    }

    (請注意,我們不會將該方法標記為異步,因為我們直接返回從調用WebClient的方法中獲得的任務。

    如果我們使用相同的 URI 重復調用 GetWebPageAsync,我們現在保證會得到相同的 Task<string> 對象。(這還具有最小化垃圾回收負載的額外好處。如果任務完成,等待它很便宜,這要歸功于我們剛剛討論的編譯器優化。

    我們可以進一步擴展我們的示例,通過鎖定整個方法主體,使其在沒有同步上下文保護的情況下實現線程安全:

    lock (_cache)
      if (_cache.TryGetValue (uri, out var downloadTask))
        return downloadTask;
      else
        return _cache [uri]=new WebClient().DownloadStringTaskAsync (uri);
    }

    這是有效的,因為我們在下載頁面期間沒有鎖定(這會損害并發性);我們將鎖定檢查緩存、在必要時啟動新任務以及使用該任務更新緩存的短時間內。

    ValueTask<T>

    注意

    ValueTask<T> 適用于微優化方案,您可能永遠不需要編寫返回此類型的方法。但是,了解我們在下一節中概述的預防措施仍然是值得的,因為某些 .NET 方法返回 ValueTask<T> ,并且 IAsyncEnumerable<T> 也使用它。

    我們剛剛描述了編譯器如何在同步完成的任務上優化 await 表達式 — 通過縮短延續并立即繼續執行下一條語句。如果同步完成是由于緩存,我們看到緩存任務本身可以提供優雅高效的解決方案。

    但是,在所有同步完成方案中緩存任務是不切實際的。有時,必須實例化一個新任務,這會產生(微小的)潛在效率低下。這是因為 Task 和 Task<T> 是引用類型,因此實例化需要基于堆的內存分配和后續集合。優化的一種極端形式是編寫免分配的代碼;換句話說,這不會實例化任何引用類型,不會給垃圾回收增加負擔。為了支持這種模式,引入了 ValueTask 和 ValueTask<T> 結構,編譯器允許用它們代替 Task 和 Task<T> :

    async ValueTask<int> Foo() { ... }

    等待 ValueTask<T> 無需分配:

    int answer=await Foo();   // (Potentially) allocation-free

    如果操作未同步完成,ValueTask<T> 會在后臺創建一個普通的 Task<T>(它將等待轉發到該任務),并且不會獲得任何結果。

    可以通過調用 AsTask 方法將 ValueTask<T> 轉換為普通 Task<T>。

    還有一個非通用版本——ValueTask——類似于Task。

    使用ValueTask<T時的注意事項>

    ValueTask<T> 相對不尋常,因為它被定義為出于性能原因的結構。這意味著它被值類型語義所困擾,可能會導致意外。若要避免不正確的行為,必須避免以下情況:

    • 多次等待相同的 ValueTask<T>
    • 叫。GetAwaiter()。GetResult() 當操作尚未完成時

    如果需要執行這些操作,請調用 。AsTask() 并改為對生成的 Task 進行操作。

    注意

    避免這些陷阱的最簡單方法是直接等待方法調用,例如:

    await Foo();   // Safe

    錯誤行為的大門在將(值)任務分配給變量時打開

    ValueTask<int> valueTask=Foo();  // Caution!
    // Our use of valueTask can now lead to errors.

    可以通過立即轉換為普通任務來緩解:

    Task<int> task=Foo().AsTask();   // Safe
    // task is safe to work with.

    避免過度彈跳

    對于在循環中多次調用的方法,可以通過調用 ConfigureAwait 來避免重復彈跳到 UI 消息循環的成本。這會強制任務不將延續反彈到同步上下文,從而將開銷降低到更接近上下文切換的成本(如果您正在等待的方法同步完成,則開銷要低得多):

    async void A() { ... await B(); ... }
    
    async Task B()
    {
      for (int i=0; i < 1000; i++)
        await C().ConfigureAwait (false);
    }
    
    async Task C() { ... }

    這意味著對于 B 和 C 方法,我們取消了 UI 應用中的簡單線程安全模型,其中代碼在 UI 線程上運行,并且只能在 await 語句期間被搶占。但是,方法 A 不受影響,如果它在 UI 線程上啟動,它將保留在 UI 線程上。

    此優化在編寫庫時尤其重要:您不需要簡化線程安全的好處,因為您的代碼通常不與調用方共享狀態,并且不訪問 UI 控件。(在我們的示例中,如果方法 C 知道操作可能運行時間較短,則同步完成也是有意義的。)

    異步模式

    取消

    能夠在并發操作啟動后取消并發操作(可能是為了響應用戶請求)通常很重要。實現這一點的一種簡單方法是使用取消標志,我們可以通過編寫這樣的類來封裝它:

    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();
      }
    }

    當調用方想要取消時,它會在傳遞給 Foo 的取消令牌上調用 Cancel。這會將 IsCancelRequest 設置為 true,這會導致 Foo 在不久之后出現 OperationCanceledException(System 命名空間中為此目的設計的預定義異常)出錯。

    除了線程安全(我們應該鎖定讀取/寫入IsCancelRequest),這種模式是有效的,CLR提供了一個名為CancelToken的類型,與我們剛剛展示的類型非常相似。但是,它缺少取消方法;相反,此方法在另一種名為 取消令牌源 。這種分離提供了一些安全性:只能訪問 CancelToken 對象的方法可以檢查但不能取消。

    要獲取取消令牌,我們首先實例化一個取消令牌源:

    var cancelSource=new CancellationTokenSource();

    這將公開一個 Token 屬性,該屬性返回一個 CancelToken 。因此,我們可以調用我們的 Foo 方法,如下所示:

    var cancelSource=new CancellationTokenSource();
    Task foo=Foo (cancelSource.Token);
    ...
    ... (some time later)
    cancelSource.Cancel();

    CLR 中的大多數異步方法都支持取消令牌,包括 延遲 。如果我們修改Foo,使其令牌傳遞到Delay方法中,則任務將在請求時立即結束(而不是最多一秒鐘后):

    async Task Foo (CancellationToken cancellationToken)
    {
      for (int i=0; i < 10; i++)
      {
        Console.WriteLine (i);
        await Task.Delay (1000, cancellationToken);
      }
    }

    請注意,我們不再需要調用 ThrowIfCancelRequest,因為 Task.Delay 正在為我們執行此操作。取消令牌很好地沿調用堆棧向下傳播(就像取消請求通過例外在調用堆棧聯一樣)。

    注意

    UWP 依賴于 WinRT 類型,其異步方法遵循較差的取消協議,因此 IAsyncInfo 類型公開取消方法,而不是接受取消令牌。但是,AsTask 擴展方法已重載以接受取消令牌,從而彌合了差距。

    同步方法也可以支持取消(例如任務的等待方法)。在這種情況下,取消指令需要異步發送(例如,來自另一個任務)。例如:

    var cancelSource=new CancellationTokenSource();
    Task.Delay (5000).ContinueWith (ant=> cancelSource.Cancel());
    ...

    事實上,您可以在構建 CancelTokenSource 時指定一個時間間隔,以便在設定的時間段后啟動取消(正如我們演示的那樣)。它對于實現超時(無論是同步還是異步)都很有用:

    var cancelSource=new CancellationTokenSource (5000);
    try { await Foo (cancelSource.Token); }
    catch (OperationCanceledException ex) { Console.WriteLine ("Cancelled"); }

    CancelToken 結構提供了一個 Register 方法,用于注冊將在取消時觸發的回調委托;它返回一個對象,可以釋放該對象以撤消注冊。

    編譯器的異步函數生成的任務在未處理的 OperationCanceledException 時自動進入“已取消”狀態(IsCanceled 返回 true,IsFaulted 返回 false)。使用Task.Run創建的任務也是如此,您將(相同的)CancelToken傳遞給構造函數。在異步方案中,出錯的任務和取消的任務之間的區別并不重要,因為兩者都在等待時拋出操作取消異常;它在高級并行編程方案中很重要(特別是條件延續)。我們在中討論這個主題。

    進度報告

    有時,你會希望異步操作在運行時報告進度。一個簡單的解決方案是將 Action 委托傳遞給異步方法,每當進度更改時,該方法都會觸發該方法:

    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...
        }
      });
    }

    以下是我們如何稱呼它:

    Action<int> progress=i=> Console.WriteLine (i + " %");
    await Foo (progress);

    盡管這在控制臺應用程序中運行良好,但在富客戶端方案中并不理想,因為它報告工作線程的進度,從而給使用者帶來潛在的線程安全問題。(實際上,我們允許并發的副作用“泄漏”到外部世界,這是不幸的,因為如果從 UI 線程調用該方法,則會被隔離。

    IProgress<T>和Progress<T>

    CLR 提供了一對類型來解決此問題:一個名為 IProgress<T> 的接口和一個名為 Progress<T> 的實現此接口的類。實際上,它們的目的是“包裝”委托,以便 UI 應用程序可以通過同步上下文安全地報告進度。

    該接口僅定義一種方法:

    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> 類有一個構造函數,該構造函數接受 Action<T> 類型的委托,它包裝:

    var progress=new Progress<int> (i=> Console.WriteLine (i + " %"));
    await Foo (progress);

    (Progress<T> 還有一個 ProgressChanged 事件,您可以訂閱該事件,而不是 [或除了] 將操作委托傳遞給構造函數。在實例化 Progress<int> 時,該類會捕獲同步上下文(如果存在)。當Foo調用報告時,委托是通過該上下文調用的。

    異步方法可以通過將 int 替換為公開一系列屬性的自定義類型來實現更詳細的進度報告。

    注意

    如果您熟悉反應式擴展,您會注意到 IProgress<T> 與異步函數返回的任務一起提供了類似于 IObserver<T 的功能集> 。不同之處在于,除了 IProgress<T 發出的值,任務還可以公開“最終”返回值(并且類型不同>。

    IProgress<T>發出的值通常是“一次性”值(例如,完成百分比或到目前為止下載的字節數),而IObserver<T>的OnNext推送的值通常包含結果本身,并且是調用它的原因。

    WinRT 中的異步方法還提供進度報告,盡管該協議因 COM 的(相對)基元類型系統而變得復雜。報告進度的異步 WinRT 方法不接受 IProgress<T> 對象,而是返回以下接口之一,而不是 IAsyncAction 和 IAsyncOperation<TResult> :

    IAsyncActionWithProgress<TProgress>
    IAsyncOperationWithProgress<TResult, TProgress>

    有趣的是,兩者都基于 IAsyncInfo(不是 IAsyncAction 和 IAsyncOperation<TResult> )。

    好消息是,AsTask 擴展方法也重載以接受上述接口的 IProgress<T>,因此作為 .NET 使用者,您可以忽略 COM 接口并執行以下操作:

    var progress=new Progress<int> (i=> Console.WriteLine (i + " %"));
    CancellationToken cancelToken=...
    var task=someWinRTobject.FooAsync().AsTask (cancelToken, progress);

    基于任務的異步模式

    .NET 公開了數百個可以等待的任務返回異步方法(主要與 I/O 相關)。這些方法中的大多數(至少部分)都遵循一種稱為(TAP)的模式,它是我們迄今為止所描述的合理形式化。TAP 方法執行以下操作:

    • 返回“熱”(正在運行)任務或任務<任務>
    • 具有“異步”后綴(任務組合器等特殊情況除外)
    • 重載以接受取消令牌和/或 IProgress<T>如果它支持取消和/或進度報告
    • 快速返回給調用方(只有一個很小的初始)
    • 如果 I/O 綁定,則不占用線程

    正如我們所看到的,TAP方法很容易使用C#的異步函數編寫。

    任務組合器

    異步函數有一個一致的協議(它們一致地返回任務)的一個很好的結果是,可以使用和編寫任務組合器——有效地組合任務的函數,而不考慮這些特定的作用。

    CLR 包括兩個任務組合器:Task.WhenAny 和 Task.WhenAll。在描述它們時,我們假設定義了以下方法:

    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 返回一個任務,該任務在一組任務中的任何一個完成時完成。以下內容在一秒鐘內完成:

    Task<int> winningTask=await Task.WhenAny (Delay1(), Delay2(), Delay3());
    Console.WriteLine ("Done");
    Console.WriteLine (winningTask.Result);   // 1

    因為 Task.WhenAny 本身返回一個任務,所以我們等待它,它返回首先完成的任務。我們的示例是完全非阻塞的——包括我們訪問 Result 屬性時的最后一行(因為 winningTask 已經完成)。盡管如此,通常最好等待獲勝任務

    Console.WriteLine (await winningTask);   // 1

    因為任何異常都會在沒有聚合異常包裝的情況下重新引發。實際上,我們可以在一個步驟中執行這兩個 await s:

    int answer=await await Task.WhenAny (Delay1(), Delay2(), Delay3());

    如果非獲勝任務隨后出錯,則除非隨后等待該任務(或查詢其 Exception 屬性),否則將不觀察到異常。

    WhenAny 對于將超時或取消應用于不支持它的操作很有用:

    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

    請注意,因為在本例中我們使用不同類型的任務調用 WhenAny,因此將獲勝者報告為普通任務(而不是 Task<string> )。

    當所有

    Task.WhenAll 返回一個任務,該任務在您傳遞給它任務完成時完成。以下內容在三秒后完成(并演示模式):

    await Task.WhenAll (Delay1(), Delay2(), Delay3());

    我們可以通過依次等待任務 1、任務 2 和任務 3 而不是使用 WhenAll 來獲得類似的結果:

    Task task1=Delay1(), task2=Delay2(), task3=Delay3();
    await task1; await task2; await task3;

    區別(除了由于需要三個等待而不是一個而效率較低)是,如果task1出錯,我們將永遠不會等待任務2 / task3,并且它們的任何異常都不會被觀察到。

    相比之下,Task.WhenAll 在所有任務完成之前不會完成,即使出現故障也是如此。如果有多個錯誤,它們的異常將合并到任務的 AggregateException 中(這是 AggregateException 真正變得有用的時候 - 也就是說,如果你對所有異常感興趣)。但是,等待組合任務只會引發第一個異常,因此要查看所有異常,您需要執行以下操作:

    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> 的任務調用 WhenAll,返回一個 Task<TResult[]> ,給出所有任務的組合結果。等待時,這將減少為 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);
    }

    但是,這里有一個輕微的低效率,因為我們不必要地掛在我們下載的字節數組上,直到每個任務都完成。如果我們在下載字節數組后立即將其折疊成它們的長度,那會更有效。這就是異步 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();
    }

    定制組合器

    編寫自己的任務組合器可能很有用。最簡單的“組合器”接受單個任務,如下所示,它允許您等待任何超時的任務:

    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
    }

    由于這在很大程度上是一種不訪問外部共享狀態的“庫方法”,因此我們在等待時使用 ConfigureAwait(false) 以避免可能反彈到 UI 同步上下文。當任務按時完成時,我們可以通過取消 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
    }

    以下內容允許您通過取消令牌“放棄”任務:

    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;
    }

    任務組合器編寫起來可能很復雜,有時需要使用信號結構,我們將在第中介紹。這實際上是一件好事,因為它將與并發相關的復雜性排除在業務邏輯之外,并保留到可以單獨測試的可重用方法中。

    下一個組合器的工作方式類似于 WhenAll ,只是如果任何任務出錯,則生成的任務會立即出錯:

    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);
    }

    我們首先創建一個 TaskCompletionSource,它的唯一工作是在任務出錯時結束參與方。因此,我們從不調用它的 SetResult 方法,只調用它的 TrySetCanceled 和 TrySetException 方法。在這種情況下,ContinueWith 比 GetAwaiter() 更方便。OnComplete,因為我們沒有訪問任務的結果,并且不想在此時反彈到 UI 線程。

    異步鎖定

    在第 的中,我們描述了如何使用 SemaphoreSlim 異步鎖定或限制并發性。

    過時的模式

    .NET 采用其他異步模式,這些模式位于任務和異步函數之前。現在很少需要這些,因為基于任務的異步已成為主導模式。

    異步編程模型

    最古老的模式稱為(APM),它使用從“開始”和“結束”開始的一對方法以及一個名為IAsyncResult的接口。為了說明這一點,讓我們以 Stream 類 System.IO 為例,并查看其 Read 方法。一、同步版本:

    public int Read (byte[] buffer, int offset, int size);

    您可以預測基于的異步版本是什么樣子的:

    public Task<int> ReadAsync (byte[] buffer, int offset, int size);

    現在讓我們檢查一下 APM 版本:

    public IAsyncResult BeginRead (byte[] buffer, int offset, int size,
                                   AsyncCallback callback, object state);
    public int EndRead (IAsyncResult asyncResult);

    調用 Begin* 方法將啟動該操作,并返回一個 IAsyncResult 對象,該對象充當異步操作的令牌。當操作完成(或出錯)時,AsyncCallback 委托將觸發:

    public delegate void AsyncCallback (IAsyncResult ar);

    然后,處理此委托的人員調用 End* 方法,該方法提供操作的返回值,并在操作出錯時重新引發異常。

    APM 不僅使用起來很笨拙,而且很難正確實現。處理 APM 方法的最簡單方法是調用 Task.Factory.FromAsync 適配器方法,該方法將 APM 方法對轉換為 Task 。在內部,它使用 TaskCompletionSource 為您提供一個任務,該任務在 APM 操作完成或出錯時發出信號。

    FromAsync 方法需要以下參數:

    • 指定方法的委托BeginXXX
    • 指定方法的委托EndXXX
    • 將傳遞給這些方法的其他參數

    FromAsync 重載以接受與 .NET 中找到的幾乎所有異步方法簽名匹配的委托類型和參數。例如,假設流是一個流,緩沖區是一個字節[],我們可以這樣做:

    Task<int> readChunk=Task<int>.Factory.FromAsync (
      stream.BeginRead, stream.EndRead, buffer, 0, 1000, null);

    基于事件的異步模式

    (EAP) 于 2005 年引入,旨在為 APM 提供更簡單的替代方案,尤其是在 UI 方案中。然而,它只在少數幾種類型中實現,最著名的是 System.Net 中的 WebClient。EAP 只是一種模式;不提供任何類型來提供幫助。本質上,模式是這樣的:類提供一系列在內部管理并發的成員,類似于:

    // 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

    *異步方法異步啟動操作。操作完成后,將觸發事件(如果存在,則自動發布到捕獲的同步上下文)。此事件傳回包含以下內容的事件參數對象:*Completed

    • 指示操作是否已取消的標志(由調用 CancelAsync 的使用者)
    • 一個 Error 對象,指示引發的異常(如果有)
    • 用戶令牌對象(如果在調用異步方法時提供)

    EAP 類型還可以公開進度報告事件,該事件在進度更改時觸發(也通過同步上下文發布):

    public event DownloadProgressChangedEventHandler DownloadProgressChanged;

    實現 EAP 需要大量樣板代碼,這使得模式的組成很差。

    后臺工作者

    System.ComponentModel 中的 BackgroundWorker 是 EAP 的通用實現。它允許富客戶端應用啟動工作線程并報告完成情況和基于百分比的進度,而無需顯式捕獲同步上下文。下面是一個示例:

    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 啟動該操作,在池工作線程上觸發 DoWork 事件。它還捕獲同步上下文,當操作完成(或出錯)時,將通過該同步上下文(如延續)調用 RunWorkerCompleted 事件。

    BackgroundWorker 創建粗粒度并發,因為 DoWork 事件完全在工作線程上運行。如果需要更新該事件處理程序中的 UI 控件(而不是發布完成百分比消息),則必須使用 Dispatcher.BeginInvoke 或類似內容)。

    我們將在 更詳細地描述BackgroundWorker 。

    喜歡手游的小伙伴肯定都遇到這種情況,一局游戲正玩得興起,突然來了個電話,再打開游戲就需要重啟了,或者瀏覽器正在查閱資料,恰好朋友來了微信,聊幾句再回來,瀏覽器也重啟了,剛剛查看的頁面也沒了。



    這些情況大家都不陌生,甚至可以說很常見,因為它有一個大家都熟悉的名字——殺后臺。所謂殺后臺其實就是手機在處理多任務時,因內存資源占用過多,系統會將暫時不用的應用關閉,從而確保手機始終處于一個流暢的運行狀態。一般來說,這是比較正常的情況,初衷也是好的,但當用戶“左手娛樂,右手聊天”時,后臺應用頻頻需要重啟,那就有點影響體驗了。

    01不同手機應用留存實測

    正因為如此,安卓廠商為了能夠提升手機系統應用留存率,想出來很多方法,比如搭載更大的運存,畢竟只要運存足夠大,后臺能夠同時駐留的應用也就越多。

    一般8GB運存的手機可以支持15個應用同時運行,而12GB基本能支持20多個以上,更大的16GB運存,差不多可以同時運行30個左右,只不過這么多App同開,系統流暢性已經很難保證。

    另外,也不是所有手機都能動輒12GB、16GB起步的,一些中低端的機型,出于成本的考量,運存往往還處于6GB的范疇,這種情況下也可以通過開啟“內存拓展”來提升應用同開的數量。


    ColorOS 13支持最大7GB內存拓展

    所謂內存拓展其實并不復雜。眾所周知,諸如PC、手機、平板這樣的電子產品都有運存(RAM)和閃存(ROM)兩種存儲,內存拓展說白了就是把一部分的ROM“借給”RAM,通過這樣一種“借空間”的操作實現大運存,提升手機的負載能力,也就可以同時運行更多的App了。

    需要注意的一點的是,擁有同時運行多個App的能力,并不等于手機不會“殺后臺”。其中差異主要取決于系統的后臺管理策略,這也是為什么會出現兩臺手機明明有著同樣大小的運存,在應用保活率上卻存在很大的不同。


    ColorOS 13打開18個應用依舊很流暢

    以我手上這臺OPPOFind X5 Pro為例,系統為最新的ColorOS 13,RAM為12GB,內存拓展3GB。筆者從應用商城下載了18款日常使用的App,全部開啟后駐留到系統后臺,接下來運行《王者榮耀》10分鐘,結束后依次打開后臺駐留應用。

    從結果來看,18個應用均不需要重啟,留存率達到了100%。而在第二輪測試中,成績還是一模一樣,并且操作全程流暢,一點也不卡頓。筆者又測試了另外兩臺搭載驍龍8+和天璣9000芯片的機型,一樣的應用,一樣的操作流程,但最高只有12個App得到保留,并且伴隨一定的掉幀情況。

    為了進一步驗證ColorOS13在日常游戲中會不會存在“來電重啟”的問題,筆者又進行了一輪新的測試,在《王者榮耀》中打進電話,再等上5分鐘,回到游戲后發現應用還能正常運行;相較之下,另外一款天璣9000測試機就需要重新啟動了,從這一波的對比來看,顯然還是ColorOS 13更厲害一些。

    02ColorOS 13為什么不會“殺后臺”?

    ColorOS13有這樣的表現完全在意料之中。這一次,ColorOS 13全新升級了ColorOS超算平臺,這是OPPO自研的系統級計算中樞。

    這項技術主要的能力就是對手機硬件資源進行合理的優化和分配,比如有些輕量的App一旦開啟就會自動調用處理器的大核,理論上講這樣做不算錯,可以確保App始終處于一個流暢狀態,但問題是這個級別的App其實根本不需要用到大核,一般中核甚至小核就足以駕馭,大核固然能夠提升體驗但也帶來了更大的功耗和發熱。

    這其實是對手機性能資源的一種過度索取,而ColorOS超算平臺則能夠對這些資源不合理分配、內存沖突的行為做出“糾正”。它可以通過算力模型對硬件計算資源的精準調度,并結合并行計算、高性能計算、端云計算、智能計算的綜合調優,從而給用戶帶來了全方面的流暢、穩定、續航體驗提升。

    根據OPPO實驗室數據顯示,升級到ColorOS 13的Find X5 Pro,整機性能提升10%,在某熱門MOBA游戲測試中,能夠做到高幀率穩定,性能無損失,且續航提升4.7%,同時將游戲最高溫度降低了1°C。并且在后臺同開18個應用的前提下,同時做到重載場景下丟幀率降低至少25%。

    這一數據結果也十分符合我們自己測得的情況和體驗,這意味著搭載ColorOS 13的機型在日常使用上會有一個很顯著的提升。

    最后有話說:

    智能手機發展到今天,硬件能力越來越強,卻依然難以避免如“殺后臺”、卡頓不流暢、發熱續航低這樣的問題,有些情況與手機自身的硬件有一定關系,但大部分還是與系統優化、后臺管理有關。

    ColorOS13通過ColorOS超算平臺有效改善了這種狀況,通過對手機性能資源的合理分配,實現了高性能與低功耗的平衡,同時借助了微內核的設計思想,實現重載多任務場景下的流暢體驗。值得注意的是,隨著ColorOS 13逐漸適配開放,還會有更多的老機型、中低端機型搭載這一功能,這對提升老舊機型體驗也是一大助益。

網站首頁   |    關于我們   |    公司新聞   |    產品方案   |    用戶案例   |    售后服務   |    合作伙伴   |    人才招聘   |   

友情鏈接: 餐飲加盟

地址:北京市海淀區    電話:010-     郵箱:@126.com

備案號:冀ICP備2024067069號-3 北京科技有限公司版權所有