理解Task和和async await

RyzenAdorer發表於2020-11-09

本文將詳解C#類當中的Task,以及非同步函式async await和Task的關係

一.Task的前世今生

1.Thread

一開始我們需要建立執行緒的時候一般是通過Thread建立執行緒,一般常用建立執行緒方式有以下幾種:

        static void Main(string[] args)
        {
            Console.WriteLine("begin");

            Thread thread = new Thread(() => TestMethod(2));
            thread.IsBackground = true;//設定為後臺執行緒,預設前臺執行緒
            thread.Start();

            Thread thread1 = new Thread(() => TestMethod1());
            //設定thread1優先順序為最高,系統儘可能單位時間內排程該執行緒,預設為Normal
            thread1.Priority = ThreadPriority.Highest;
            thread1.Start();

            Thread thread2 = new Thread((state) => TestMethod2(state));
            thread2.Start("data");
            thread2.Join();//等待thread2執行完成
            Console.WriteLine("end");
        }

        static void TestMethod(int a)
        {
            Thread.Sleep(1000);
            Console.WriteLine($"TestMethod: run on Thread id :{Thread.CurrentThread.ManagedThreadId},is threadPool:{Thread.CurrentThread.IsThreadPoolThread}" +
                $",is Backgound:{Thread.CurrentThread.IsBackground}, result:{a}");
        }

        static void TestMethod1()
        {
            Thread.Sleep(1000);
            Console.WriteLine($"TestMethod1: run on Thread id :{Thread.CurrentThread.ManagedThreadId},is threadPool:{Thread.CurrentThread.IsThreadPoolThread}" +
                $",is Backgound:{Thread.CurrentThread.IsBackground},no result ");
        }

        static void TestMethod2(object state)
        {
            Thread.Sleep(2000);
            Console.WriteLine($"TestMethod2 :run on Thread id :{Thread.CurrentThread.ManagedThreadId},is threadPool:{Thread.CurrentThread.IsThreadPoolThread}" +
               $",is Backgound:{Thread.CurrentThread.IsBackground},result:{state}");
        }

輸出結果:

begin
TestMethod: run on Thread id :4,is threadPool:False,is Backgound:True, result:2
TestMethod1: run on Thread id :5,is threadPool:False,is Backgound:False,no result
TestMethod2 :run on Thread id :7,is threadPool:False,is Backgound:False,result:data
end

or

begin
TestMethod1: run on Thread id :5,is threadPool:False,is Backgound:False,no result
TestMethod: run on Thread id :4,is threadPool:False,is Backgound:True, result:2
TestMethod2 :run on Thread id :7,is threadPool:False,is Backgound:False,result:data
end

由於我的PC是多核CPU,那麼TestMethod和TestMethod1所在兩個執行緒是真正並行的,所以有可能輸出結果先後不確定,雖然TestMethod1所線上程設定優先順序為Highest最高,但可能系統不會優先排程,其實目前不怎麼推薦用Thread.Start去建立執行緒,缺點大概如下:

  • 因為在大量需要建立執行緒情況下,用Thread.Start去建立執行緒是會浪費執行緒資源,因為執行緒用完就沒了,不具備重複利用能力
  • 現在一個程式中的CLR預設會建立執行緒池和一些工作執行緒(不要浪費),且執行緒池的工作執行緒用完會回到執行緒池,能夠重複利用,

除非是以下原因:

  • 真的需要操作執行緒優先順序

  • 需要建立一個前臺執行緒,由於類似於控制檯程式當初始前臺執行緒執行完就會退出程式,那麼建立前臺執行緒可以保證程式退出前該前臺執行緒正常執行成功

    例如在原來的例子註釋掉thread2.Join();,我們會發現輸出完控制檯初始的前臺執行緒輸出完end沒退出程式,只有在TestMethod2(該執行緒凍結2秒最久)執行完才退出

            static void Main(string[] args)
            {
                Console.WriteLine("begin");
    
                Thread thread = new Thread(() => TestMethod(2));
                thread.IsBackground = true;//設定為後臺執行緒,預設前臺執行緒
                thread.Start();
    
                Thread thread1 = new Thread(() => TestMethod1());
                //設定thread1優先順序為最高,系統儘可能單位時間內排程該執行緒,預設為Normal
                thread1.Priority = ThreadPriority.Highest;
                thread1.Start();
    
                Thread thread2 = new Thread((state) => TestMethod2(state));
                thread2.Start("data");
                //thread2.Join();//等待thread2執行完成
                Console.WriteLine("end");
            }       
    

    輸出:

    begin
    end
    TestMethod1: run on Thread id :5,is threadPool:False,is Backgound:False,no result
    TestMethod: run on Thread id :4,is threadPool:False,is Backgound:True, result:2
    TestMethod2 :run on Thread id :7,is threadPool:False,is Backgound:False,result:data
    
  • 需要建立一個後臺執行緒,長時間執行的,其實一個Task的TaskScheduler在Default情況下,設定TaskCreationOptions.LongRunning內部也是建立了一個後臺執行緒Thread,而不是在ThreadPool執行,在不需要Task的一些其他功能情況下,Thread更輕量

      Thread longTask = new Thread(() => Console.WriteLine("doing long Task..."));
      longTask.IsBackground = true;
      longTask.Start();
    
    //等價於
    
       new Task(() => Console.WriteLine("doing long Task..."), TaskCreationOptions.LongRunning).Start();
       //OR
       Task.Factory.StartNew(() => Console.WriteLine("doing long Task..."), TaskCreationOptions.LongRunning);
    

2.ThreadPool

一個.NET程式中的CLR在程式初始化時,CLR會開闢一塊記憶體空間給ThreadPool,預設ThreadPool預設沒有執行緒,在內部會維護一個任務請求佇列,當這個佇列存在任務時,執行緒池則會通過開闢工作執行緒(都是後臺執行緒)去請求該佇列執行任務,任務執行完畢則回返回執行緒池,執行緒池儘可能會用返回的工作執行緒去執行(減少開闢),如果沒返回執行緒池,則會開闢新的執行緒去執行,而後執行完畢又返回執行緒池,大概執行緒池模型如下:

我們通過程式碼來看:

        static void Main(string[] args)
        {
            //獲取預設執行緒池允許開闢的最大工作執行緒樹和最大I/O非同步執行緒數
            ThreadPool.GetMaxThreads(out int maxWorkThreadCount, 
                                     out int maxIOThreadCount);
            Console.WriteLine($"maxWorkThreadCount:{maxWorkThreadCount},
                              maxIOThreadCount:{maxIOThreadCount}");
            //獲取預設執行緒池併發工作執行緒和I/O非同步執行緒數
            ThreadPool.GetMinThreads(out int minWorkThreadCount, 
                                     out int minIOThreadCount);
            Console.WriteLine($"minWorkThreadCount:{minWorkThreadCount},
                              minIOThreadCount:{minIOThreadCount}");
            for (int i = 0; i < 20; i++)
            {
                ThreadPool.QueueUserWorkItem(s =>
                {
                    var workThreadId = Thread.CurrentThread.ManagedThreadId;
                    var isBackground = Thread.CurrentThread.IsBackground;
                    var isThreadPool = Thread.CurrentThread.IsThreadPoolThread;
                    Console.WriteLine($"work is on thread {workThreadId}, 
                                      Now time:{DateTime.Now.ToString("ss.ff")}," +
                        $" isBackground:{isBackground}, isThreadPool:{isThreadPool}");
                    Thread.Sleep(5000);//模擬工作執行緒執行
                });
            }
            Console.ReadLine();
        }

輸出如下:

maxWorkThreadCount:32767,maxIOThreadCount:1000
minWorkThreadCount:16,minIOThreadCount:16
work is on thread 18, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 14, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 16, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 5, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 13, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 12, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 10, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 4, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 15, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 7, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 19, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 17, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 8, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 11, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 9, Now time:06.50, isBackground:True, isThreadPool:True
work is on thread 6, Now time:06.50, isBackground:True, isThreadPool:True

work is on thread 20, Now time:07.42, isBackground:True, isThreadPool:True
work is on thread 21, Now time:08.42, isBackground:True, isThreadPool:True
work is on thread 22, Now time:09.42, isBackground:True, isThreadPool:True
work is on thread 23, Now time:10.42, isBackground:True, isThreadPool:True

​ 由於我CPU為8核16執行緒,預設執行緒池給我分配了16條工作執行緒和I/O執行緒,保證在該程式下實現真正的並行,可以看到前16條工作執行緒的啟動時間是一致的,到最後四條,執行緒池嘗試去用之前的工作執行緒去請求那個任務佇列執行任務,由於前16條還在執行沒返回到執行緒池,則每相隔一秒,建立新的工作執行緒去請求執行,而且該開闢的最多執行緒數是和執行緒池允許開闢的最大工作執行緒樹和最大I/O非同步執行緒數有關的

我們可以通過ThreadPool.SetMaxThreads 將工作執行緒數設定最多隻有16,在執行任務前新增幾行程式碼:

var success = ThreadPool.SetMaxThreads(16, 16);//只能設定>=最小併發工作執行緒數和I/O執行緒數
Console.WriteLine($"SetMaxThreads success:{success}");
ThreadPool.GetMaxThreads(out int maxWorkThreadCountNew, out int maxIOThreadCountNew);
Console.WriteLine($"maxWorkThreadCountNew:{maxWorkThreadCountNew},
                  maxIOThreadCountNew:{maxIOThreadCountNew}");

輸出如下:

maxWorkThreadCount:32767,maxIOThreadCount:1000
minWorkThreadCount:16,minIOThreadCount:16
SetMaxThreads success:True
maxWorkThreadCountNew:16,maxIOThreadCountNew:16
work is on thread 6, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 12, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 7, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 8, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 16, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 10, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 15, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 13, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 11, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 4, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 9, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 19, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 17, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 5, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 14, Now time:01.71, isBackground:True, isThreadPool:True
work is on thread 18, Now time:01.71, isBackground:True, isThreadPool:True

work is on thread 8, Now time:06.72, isBackground:True, isThreadPool:True
work is on thread 5, Now time:06.72, isBackground:True, isThreadPool:True
work is on thread 19, Now time:06.72, isBackground:True, isThreadPool:True
work is on thread 10, Now time:06.72, isBackground:True, isThreadPool:True
 

可以很清楚知道,由於執行緒池最多隻允許開闢16條工作執行緒和I/O執行緒,那麼線上程池再開闢了16條執行緒之後,將不會再開闢新執行緒,新的任務也只能等前面的工作執行緒執行完回執行緒池後,再用返回的執行緒去執行新任務,導致新任務的開始執行時間會在5秒後

ThreadPool的優點如下:

  • 預設執行緒池已經根據自身CPU情況做了配置,在需要複雜多工並行時,智慧在時間和空間上做到均衡,在CPU密集型操作有一定優勢,而不是像Thread.Start那樣,需要自己去判斷和考慮
  • 同樣可以通過執行緒池一些方法,例如ThreadPool.SetMaxThreads手動配置執行緒池情況,很方便去模擬不同電腦硬體的執行情況
  • 有專門的I/O執行緒,能夠實現非阻塞的I/O,I/O密集型操作有優勢(後續Task會提到)

但同樣,缺點也很明顯:

  • ThreadPool原生不支援對工作執行緒取消、完成、失敗通知等互動性操作,同樣不支援獲取函式返回值,靈活度不夠,Thread原生有Abort (同樣不推薦)、Join等可選擇
  • 不適合LongTask,因為這類會造成執行緒池多建立執行緒(上述程式碼可知道),這時候可以單獨去用Thread去執行LongTask

3.Task

在.NET 4.0時候,引入了任務並行庫,也就是所謂的TPL(Task Parallel Library),帶來了Task類和支援返回值的Task<TResult> ,同時在4.5完善優化了使用,Task解決了上述Thread和ThreadPool的一些問題,Task究竟是個啥,我們來看下程式碼:

以下是一個WPF的應用程式,在Button的Click事件:

 private void Button_Click(object sender, RoutedEventArgs e)
 {
     Task.Run(() =>
     {
         var threadId = Thread.CurrentThread.ManagedThreadId;
         var isBackgound = Thread.CurrentThread.IsBackground;
         var isThreadPool = Thread.CurrentThread.IsThreadPoolThread;
         Thread.Sleep(3000);//模擬耗時操作
         Debug.WriteLine($"task1 work on thread:{threadId},isBackgound:{isBackgound},isThreadPool:{isThreadPool}");
            });
         new Task(() =>
         {
             var threadId = Thread.CurrentThread.ManagedThreadId;
             var isBackgound = Thread.CurrentThread.IsBackground;
             var isThreadPool = Thread.CurrentThread.IsThreadPoolThread;
             Thread.Sleep(3000);//模擬耗時操作
             Debug.WriteLine($"task2 work on thread:{threadId},isBackgound:{isBackgound},isThreadPool:{isThreadPool}");
         }).Start(TaskScheduler.FromCurrentSynchronizationContext());

         Task.Factory.StartNew(() =>
         {
            var threadId = Thread.CurrentThread.ManagedThreadId;
            var isBackgound = Thread.CurrentThread.IsBackground;
            var isThreadPool = Thread.CurrentThread.IsThreadPoolThread;
            Thread.Sleep(3000);//模擬耗時操作
            Debug.WriteLine($"task3 work on thread:{threadId},isBackgound:{isBackgound},isThreadPool:{isThreadPool}");
          }, TaskCreationOptions.LongRunning);
    }

輸出:

main thread id :1
//由於是並行,輸出結果的前後順序可能每次都不一樣
task1 work on thread:4,isBackgound:True,isThreadPool:True
task3 work on thread:10,isBackgound:True,isThreadPool:False
task2 work on thread:1,isBackgound:False,isThreadPool:False

我用三種不同的Task開闢執行任務的方式,可以看到,Task執行在三種不同的執行緒:

  • task1是執行線上程池上,是沒進行任何對Task的設定
  • task2通過設定TaskSchedulerTaskScheduler.FromCurrentSynchronizationContext()是沒有開闢執行緒,利用主執行緒執行
  • task3通過設定TaskCreationOptionsLongRunning和預設TaskScheduler情況下,實際是開闢了一個後臺Thread去執行

因此,其實Task不一定代表開闢了新執行緒,可為線上程池上執行,又或是開闢一個後臺Thread,又或者沒有開闢執行緒,通過主執行緒執行任務,這裡提一句TaskScheduler.FromCurrentSynchronizationContext(),假設在控制檯或者ASP.NET Core程式執行,會發生報錯,原因是主執行緒的SynchronizationContext為空,可通過TaskScheduler原始碼得知:

public static TaskScheduler FromCurrentSynchronizationContext()
{
     return new SynchronizationContextTaskScheduler();
}
        
internal SynchronizationContextTaskScheduler()
{
     m_synchronizationContext = SynchronizationContext.Current ??
     throw new InvalidOperationException
     (SR.TaskScheduler_FromCurrentSynchronizationContext_NoCurrent);
}

大致對於Task在通過TaskScheduler和TaskCreationOptions設定後對於將任務分配在不同的執行緒情況,如下圖:

原生支援延續、取消、異常(失敗通知)

1.延續

Task其實有兩種延續任務的方式,一種通過ContinueWith方法,這是Task在.NET Framework4.0就支援的,一種則是通過GetAwaiter方法,則是在.NET Framework4.5開始支援,而且該方法也是async await非同步函式所用到

控制檯程式碼:

 static void Main(string[] args)
 {
      Task.Run(() =>
      {
          Console.WriteLine($"ContinueWith:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
                return 25;
      }).ContinueWith(t =>
      {
          Console.WriteLine($"ContinueWith Completed:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
          Console.WriteLine($"ContinueWith Completed:{t.Result}");
      });

//等價於
     
     var awaiter = Task.Run(() =>
     {
          Console.WriteLine($"GetAwaiter:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
          return 25;
     }).GetAwaiter();
     awaiter.OnCompleted(() =>
     {
          Console.WriteLine($"GetAwaiter Completed:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
          Console.WriteLine($"GetAwaiter Completed:{awaiter.GetResult()}");
     });

     Console.ReadLine();
}

輸出結果:

ContinueWith:threadId:4,isThreadPool:True
GetAwaiter:threadId:5,isThreadPool:True
GetAwaiter Completed:threadId:5,isThreadPool:True
GetAwaiter Completed:25
ContinueWith Completed:threadId:4,isThreadPool:True
ContinueWith Completed:25

//事實上,執行的程式碼執行緒,可能和延續的執行緒有可能不是同一執行緒,取決於執行緒池本身的排程
可以手動設定TaskContinuationOptions.ExecuteSynchronously(同一執行緒)
或者 TaskContinuationOptions.RunContinuationsAsynchronously(不同執行緒)
預設RunContinuationsAsynchronously優先順序大於ExecuteSynchronously

但有意思的是,同樣的程式碼,在WPF/WinForm等程式,執行的輸出是不一樣的:

WPF程式程式碼:

      private void Button_Click(object sender, RoutedEventArgs e)
        {
            Task.Run(() =>
            {
                Debug.WriteLine($"ContinueWith:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
            }).ContinueWith(t =>
            {
                Debug.WriteLine($"ContinueWith Completed:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
            }, TaskContinuationOptions.ExecuteSynchronously);


            Task.Run(() =>
            {
                Debug.WriteLine($"GetAwaiter:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
            }).GetAwaiter().OnCompleted(() =>
            {
                Debug.WriteLine($"GetAwaiter Completed:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
            });
        }

輸出:

ContinueWith:threadId:7,isThreadPool:True
GetAwaiter:threadId:9,isThreadPool:True
ContinueWith Completed:threadId:7,isThreadPool:True
GetAwaiter Completed:threadId:1,isThreadPool:False

原因就是GetAwaiter().OnCompleted()會去檢測有沒有SynchronizationContext,因此其實就是相當於以下程式碼:

 Task.Run(() =>
  {
       Debug.WriteLine($"GetAwaiter:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
  }).ContinueWith(t =>
  {
       Debug.WriteLine($"GetAwaiter Completed:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
  },TaskScheduler.FromCurrentSynchronizationContext());

如果在WPF程式中要獲得控制檯那樣效果,只需要修改為ConfigureAwait(false),延續任務不在SynchronizationContext即可,如下:

 Task.Run(() =>
 {
      Debug.WriteLine($"GetAwaiter:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
 }).ConfigureAwait(false).GetAwaiter().OnCompleted(() =>
 {
     Debug.WriteLine($"GetAwaiter Completed:threadId:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
 });

2.取消

在.NET Framework4.0帶來Task的同時,同樣帶來了與取消任務有關的類CancellationTokenSourceCancellationToken,下面我們將大致演示下其用法

WPF程式程式碼如下:

CancellationTokenSource tokenSource;


private void BeginButton_Click(object sender, RoutedEventArgs e)
{

      tokenSource = new CancellationTokenSource();
      LongTask(tokenSource.Token);
}
        
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
      tokenSource?.Cancel();
}

private void LongTask(CancellationToken cancellationToken)
{
      Task.Run(() =>
      {
          for (int i = 0; i < 10; i++)
          {
               Dispatcher.Invoke(() =>
               {
                  this.tbox.Text += $"now is {i} \n";
               });
               Thread.Sleep(1000);
               if (cancellationToken.IsCancellationRequested)
               {
                   MessageBox.Show("取消了該操作");
                   return;
               }
           }
        }, cancellationToken);
}


效果如下:

其實上述程式碼,也可以適用於Thread和ThreadPool,等價於如下程式碼:

//當TaskCreationOptions為LongRunning和預設TaskScheduler情況下
new Thread(() =>
{
    for (int i = 0; i < 10; i++)
    {
         Dispatcher.Invoke(() =>
         {
            this.tbox.Text += $"now is {i} \n";
         });
         Thread.Sleep(1000);
         if (cancellationToken.IsCancellationRequested)
         {
             MessageBox.Show("取消了該操作");
             return;
         }
   }
}).Start();

//預設TaskScheduler情況下
ThreadPool.QueueUserWorkItem(t =>
{
      for (int i = 0; i < 10; i++)
      {
           Dispatcher.Invoke(() =>
           {
                this.tbox.Text += $"now is {i} \n";
           });
           Thread.Sleep(1000);
           if (cancellationToken.IsCancellationRequested)
           {
               MessageBox.Show("取消了該操作");
               return;
           }
      }
});

因此,.NET Framework4.0後ThreadThreadPool也同樣能夠通過CancellationTokenSourceCancellationToken類支援取消功能,只是一般這兩者都可以用Task通過設定,底層同樣呼叫的ThreadThreadPool,所以一般沒怎麼這麼使用,而且關於Task的基本很多方法都預設支援了,例如,Task.Wait、Task.WaitAll、Task.WaitAny、Task.WhenAll、Task.WhenAny、Task.Delay等等

3.異常(失敗通知)

下面控制檯程式碼:

 static void Main(string[] args)
 {
      var parent = Task.Factory.StartNew(() =>
      {
            int[] numbers = { 0 };
            var childFactory = new TaskFactory(TaskCreationOptions.AttachedToParent, TaskContinuationOptions.None);
            childFactory.StartNew(() => 5 / numbers[0]); // Division by zero 
            childFactory.StartNew(() => numbers[1]); // Index out of range 
            childFactory.StartNew(() => { throw null; }); // Null reference 
       });
       try
       {
            parent.Wait();
       }
       catch (AggregateException aex)
       {
            foreach (var item in aex.InnerExceptions)
            {
                Console.WriteLine(item.InnerException.Message.ToString());
            }
        }
        Console.ReadLine();
   }

輸出如下:

嘗試除以零。
索引超出了陣列界限。
未將物件引用設定到物件的例項。

這裡面parent任務有三個子任務,三個並行子任務分別都丟擲不同異常,返回到parent任務中,而當你對parent任務Wait或者獲取其Result屬性時,那麼將會丟擲異常,而使用AggregateException則能將全部異常放在其InnerExceptions異常列表中,我們則可以分別對不同異常進行處理,這在多工並行時候是非常好用的,而且AggregateException的功能異常強大,遠遠不止上面的功能,但是如果你只是單任務,使用AggregateException比普通則其實會有浪費效能,也可以這樣做;

try
{
     var task = Task.Run(() =>
     {
         string str = null;
         str.ToLower();
         return str;
     });
     var result = task.Result;
}
catch (Exception ex)
{

     Console.WriteLine(ex.Message.ToString());
}

//或者通過async await
try
{
      var result = await Task.Run(() =>
      {
          string str = null;
          str.ToLower();
          return str;
      });
      
catch (Exception ex)
{

      Console.WriteLine(ex.Message.ToString());
}

輸出:

未將物件引用設定到物件的例項。

二.非同步函式async await

async await是C#5.0,也就是.NET Framework 4.5時期推出的C#語法,通過與.NET Framework 4.0時引入的任務並行庫,也就是所謂的TPL(Task Parallel Library)構成了新的非同步程式設計模型,也就是TAP(Task-based asynchronous pattern),基於任務的非同步模式

語法糖async await

我們先來寫下程式碼,看看async await的用法:

下面是個控制檯的程式碼:

 static async Task Main(string[] args)
 {
     var result = await Task.Run(() =>
     {
         Console.WriteLine($"current thread:{Thread.CurrentThread.ManagedThreadId}," +
                    $"isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
         Thread.Sleep(1000);
         return 25;
     });
    Console.WriteLine($"current thread:{Thread.CurrentThread.ManagedThreadId}," +
    $"isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
    Console.WriteLine(result);
    Console.ReadLine();
 }

輸出結果:

current thread:4,isThreadPool:True
current thread:4,isThreadPool:True
25

換成在WPF/WinForm程式執行,結果如下:

current thread:4,isThreadPool:True
current thread:1,isThreadPool:false
25

是不是感覺似曾相識?上面埋下的彩蛋在這裡揭曉了,在講Task的延續的時候我們講到.NET Framework4.5的一種通過GetAwaiter延續方法,事實上,async await就是上面的一種語法糖,編譯的時候大致會編譯成那樣,所以我們一般不手動寫GetAwaiter的延續方法,而是通過async await,大大簡化了程式設計方式,說它是語法糖,那麼有啥證據呢?

我們再寫一些程式碼來驗證:

class Program
{
    static void Main(string[] args)
    {
       ShowResult(classType: typeof(Program), methodName: nameof(AsyncTaskResultMethod));
       ShowResult(classType: typeof(Program), methodName: nameof(AsyncTaskMethod));
       ShowResult(classType: typeof(Program), methodName: nameof(AsyncVoidMethod));
       ShowResult(classType: typeof(Program), methodName: nameof(RegularMethod));
       Console.ReadKey();
    }

    public static async Task<int> AsyncTaskResultMethod()
    {
       return await Task.FromResult(5);
    }

    public static async Task AsyncTaskMethod()
    {
       await new TaskCompletionSource<int>().Task;
    }

    public static async void AsyncVoidMethod()
    {

    }

    public static int RegularMethod()
    {
        return 5;
    }

    private static bool IsAsyncMethod(Type classType, string methodName)
    {
       MethodInfo method = classType.GetMethod(methodName);

       Type attType = typeof(AsyncStateMachineAttribute);

       var attrib = (AsyncStateMachineAttribute)method.GetCustomAttribute(attType);

       return (attrib != null);
    }

    private static void ShowResult(Type classType, string methodName)
    {
       Console.Write((methodName + ": ").PadRight(16));

       if (IsAsyncMethod(classType, methodName))
           Console.WriteLine("Async method");
       else
           Console.WriteLine("Regular method");
    }
}

輸出:

AsyncTaskResultMethod: Async method
AsyncTaskMethod: Async method
AsyncVoidMethod: Async method
RegularMethod:  Regular method

在這其中,其實async在方法名的時候,只允許,返回值為void,Task,Task,否則會發生編譯報錯,事實上,這和其編譯後的結果有關,我們通過ILSpy反編譯這段程式碼,截圖關鍵程式碼:

internal class Program
{
  [CompilerGenerated]
  private sealed class <AsyncTaskResultMethod>d__1 : IAsyncStateMachine
  {
	  public int <>1__state;
	  public AsyncTaskMethodBuilder<int> <>t__builder;
	  private int <>s__1;
	  private TaskAwaiter<int> <>u__1;
	  void IAsyncStateMachine.MoveNext()
	  {
		  int num = this.<>1__state;
		  int result;
		  try
		  {
			 TaskAwaiter<int> awaiter;
			 if (num != 0)
			 {
				awaiter = Task.FromResult<int>(5).GetAwaiter();
				if (!awaiter.IsCompleted)
				{
					this.<>1__state = 0; 
					this.<>u__1 = awaiter;
				    Program.<AsyncTaskResultMethod>d__1 <AsyncTaskResultMethod>d__ = this;
					this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<AsyncTaskResultMethod>d__1>(ref awaiter, ref <AsyncTaskResultMethod>d__);
					return;
				}
		         }
		         else
		         {
		                awaiter = this.<>u__1;
				this.<>u__1 = default(TaskAwaiter<int>);
				this.<>1__state = -1;
		         }
			 this.<>s__1 = awaiter.GetResult();
			 result = this.<>s__1;
		  }
		  catch (Exception exception)
		  {
			this.<>1__state = -2;
			this.<>t__builder.SetException(exception);
			return;
		  }
		  this.<>1__state = -2;
		  this.<>t__builder.SetResult(result);
	}
	[DebuggerHidden]
	void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
	{
	}
  }
    
  [CompilerGenerated]
  private sealed class <AsyncTaskMethod>d__2 : IAsyncStateMachine
  {
	  public int <>1__state;
	  public AsyncTaskMethodBuilder <>t__builder;
	  private TaskAwaiter<int> <>u__1;
	  void IAsyncStateMachine.MoveNext()
	  {
		   int num = this.<>1__state;
		   try
		   {
				TaskAwaiter<int> awaiter;
				if (num != 0)
				{
					awaiter = new TaskCompletionSource<int>().Task.GetAwaiter();
					if (!awaiter.IsCompleted)
					{
						this.<>1__state = 0;
						this.<>u__1 = awaiter;
						Program.<AsyncTaskMethod>d__2 <AsyncTaskMethod>d__ = this;
						this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<AsyncTaskMethod>d__2>(ref awaiter, ref <AsyncTaskMethod>d__);
						return;
					}
				}
				else
				{
					awaiter = this.<>u__1;
					this.<>u__1 = default(TaskAwaiter<int>);
					this.<>1__state = -1;
				}
				awaiter.GetResult();
			}
			catch (Exception exception)
			{
				this.<>1__state = -2;
				this.<>t__builder.SetException(exception);
				return;
			}
			this.<>1__state = -2;
			this.<>t__builder.SetResult();
		}
      
		[DebuggerHidden]
		void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
		{
		}
	}
    
    private sealed class <AsyncVoidMethod>d__3 : IAsyncStateMachine
	{
		public int <>1__state;
		public AsyncVoidMethodBuilder <>t__builder;
		void IAsyncStateMachine.MoveNext()
		{
			int num = this.<>1__state;
			try
			{
			}
			catch (Exception exception)
			{
				this.<>1__state = -2;
				this.<>t__builder.SetException(exception);
				return;
			}
			this.<>1__state = -2;
			this.<>t__builder.SetResult();
		}
		[DebuggerHidden]
		void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
		{
		}
	}
    
   [DebuggerStepThrough, AsyncStateMachine(typeof(Program.<AsyncTaskResultMethod>d__1))]
   public static Task<int> AsyncTaskResultMethod()
   {
	   Program.<AsyncTaskResultMethod>d__1 <AsyncTaskResultMethod>d__ = new Program.<AsyncTaskResultMethod>d__1();
	  <AsyncTaskResultMethod>d__.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
	  <AsyncTaskResultMethod>d__.<>1__state = -1;
	  <AsyncTaskResultMethod>d__.<>t__builder.Start<Program.<AsyncTaskResultMethod>d__1>(ref <AsyncTaskResultMethod>d__);
	  return <AsyncTaskResultMethod>d__.<>t__builder.Task;
	}
    
  [DebuggerStepThrough, AsyncStateMachine(typeof(Program.<AsyncTaskMethod>d__2))]
   public static Task AsyncTaskMethod()
   {
		Program.<AsyncTaskMethod>d__2 <AsyncTaskMethod>d__ = new Program.<AsyncTaskMethod>d__2();
		<AsyncTaskMethod>d__.<>t__builder = AsyncTaskMethodBuilder.Create();
		<AsyncTaskMethod>d__.<>1__state = -1;
		<AsyncTaskMethod>d__.<>t__builder.Start<Program.<AsyncTaskMethod>d__2>(ref <AsyncTaskMethod>d__);
		return <AsyncTaskMethod>d__.<>t__builder.Task;
   }

   [DebuggerStepThrough, AsyncStateMachine(typeof(Program.<AsyncVoidMethod>d__3))]
   public static void AsyncVoidMethod()
   {
	Program.<AsyncVoidMethod>d__3 <AsyncVoidMethod>d__ = new Program.<AsyncVoidMethod>d__3();
	<AsyncVoidMethod>d__.<>t__builder = AsyncVoidMethodBuilder.Create();
	<AsyncVoidMethod>d__.<>1__state = -1;
	<AsyncVoidMethod>d__.<>t__builder.Start<Program.<AsyncVoidMethod>d__3>(ref <AsyncVoidMethod>d__);
   }
    
   public static int RegularMethod()
   {
	return 5;
   }
    
}

我們大致來捋一捋,事實上,從反編譯後的程式碼可以看出來一些東西了,編譯器大致是這樣的,以AsyncTaskResultMethod方法為例子:

  1. 將標識async的方法,打上AsyncStateMachine 特性
  2. 根據AsyncStateMachine 該特性,編譯器為該方法新增一個以該方法名為名的類AsyncTaskMethodClass,並且實現介面IAsyncStateMachine,其中最主要的就是其MoveNext方法
  3. 該方法去除標識async,在內部例項化新增的類AsyncTaskMethodClass,用AsyncTaskMethodBuilder的Create方法建立一個狀態機物件賦值給物件的該型別的build欄位,並且將狀態state設定為-1.即初始狀態,然後通過build欄位啟動狀態機

實際上,上述只是編譯器為async做的事情,我們可以看到通過AsyncVoidMethod方法編譯器生成的東西和其他方法大致一樣,那麼await為編譯器做的就是MoveNext方法裡面try那段,這也是AsyncVoidMethod方法和其他方法不一致的地方:

private TaskAwaiter<int> <>u__1;

try
{
	  TaskAwaiter<int> awaiter;
	  if (num != 0)
	  {
		  awaiter = new TaskCompletionSource<int>().Task.GetAwaiter();
		  if (!awaiter.IsCompleted)
		  {
			  this.<>1__state = 0;
			  this.<>u__1 = awaiter;
			  Program.<AsyncTaskMethod>d__2 <AsyncTaskMethod>d__ = this;
			  this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<AsyncTaskMethod>d__2>(ref awaiter, ref <AsyncTaskMethod>d__);
			  return;
		  }
	  }
	  else
	  {
		awaiter = this.<>u__1;
	        this.<>u__1 = default(TaskAwaiter<int>);
		this.<>1__state = -1;
	  }
	  awaiter.GetResult();
}

我們再看看this.<>t__builder.AwaitUnsafeOnCompleted內部:

public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine
{
	try
	{
		AsyncMethodBuilderCore.MoveNextRunner runner = null;
		Action completionAction = this.m_coreState.GetCompletionAction(AsyncCausalityTracer.LoggingOn ? this.Task : null, ref runner);
		if (this.m_coreState.m_stateMachine == null)
		{
			Task<TResult> task = this.Task;
			this.m_coreState.PostBoxInitialization(stateMachine, runner, task);
		}
		awaiter.UnsafeOnCompleted(completionAction);
	}
	catch (Exception exception)
	{
		AsyncMethodBuilderCore.ThrowAsync(exception, null);
	}
}

GetCompletionAction方法內部:

[SecuritySafeCritical]
internal Action GetCompletionAction(Task taskForTracing, ref AsyncMethodBuilderCore.MoveNextRunner runnerToInitialize)
{
	Debugger.NotifyOfCrossThreadDependency();
	ExecutionContext executionContext = ExecutionContext.FastCapture();
	Action action;
	AsyncMethodBuilderCore.MoveNextRunner moveNextRunner;
	if (executionContext != null && executionContext.IsPreAllocatedDefault)
	{
		action = this.m_defaultContextAction;
		if (action != null)
		{
			return action;
		}
		moveNextRunner = new AsyncMethodBuilderCore.MoveNextRunner(executionContext, this.m_stateMachine);
		action = new Action(moveNextRunner.Run);
		if (taskForTracing != null)
		{
			action = (this.m_defaultContextAction = this.OutputAsyncCausalityEvents(taskForTracing, action));
		}
		else
		{
			this.m_defaultContextAction = action;
		}
	}
	else
	{
		moveNextRunner = new AsyncMethodBuilderCore.MoveNextRunner(executionContext, this.m_stateMachine);
		action = new Action(moveNextRunner.Run);
		if (taskForTracing != null)
		{
		    action = this.OutputAsyncCausalityEvents(taskForTracing, action);
		}
	}
	if (this.m_stateMachine == null)
	{
	    runnerToInitialize = moveNextRunner;
	}
	return action;
}

void moveNextRunner.Run()
{
  if (this.m_context != null)
  {
	 try
	 {
		ContextCallback contextCallback = AsyncMethodBuilderCore.MoveNextRunner.s_invokeMoveNext;
		if (contextCallback == null)
		{
		    contextCallback = (AsyncMethodBuilderCore.MoveNextRunner.s_invokeMoveNext = new ContextCallback(AsyncMethodBuilderCore.MoveNextRunner.InvokeMoveNext));
		}
		ExecutionContext.Run(this.m_context, contextCallback, this.m_stateMachine, true);
		return;
	}
	finally
	{
	     this.m_context.Dispose();
	}
  }
	this.m_stateMachine.MoveNext();
}

從上面的程式碼可以看出,其實this.<>t__builder.AwaitUnsafeOnCompleted內部就做了以下:

  1. 從GetCompletionAction方法獲取要給awaiter.UnsafeOnCompleted的action
  2. GetCompletionAction內部先用ExecutionContext.FastCapture()捕獲了當前執行緒的執行上下文,在用執行上下文執行了那個回撥方法MoveNext,也就是又一次回到那個一開始那個MoveNext方法

大致執行流程圖如下:

因此,我們驗證了async await確實是語法糖,編譯器為其在背後做了太多的事情,簡化了我們編寫非同步程式碼的方式,我們也注意到了其中一些問題:

  • 方法標識async,方法內部沒使用await實際就是同步方法,但是會編譯出async有關的東西,會浪費一些效能
  • 能await Task,事實上能await Task是因為後面編譯器有用到了awaiter的一些東西,例如:
    • !awaiter.IsCompleted
    • awaiter.GetResult()
    • awaiter.UnsafeOnCompleted

確實如猜想的,像await Task.Yield()等等,被await的物件,它必須包含以下條件:

  • 有一個GetAwaiter方法,為例項方法或者擴充套件方法

  • GetAwaiter方法的返回值類,必須包含以下條件

    • 直接或者間接實現INotifyCompletion介面,ICriticalNotifyCompletion也繼承自ICriticalNotifyCompletion介面,也就是實現了其UnsafeOnCompleted或者OnCompleted方法

    • 有個布林屬性IsCompleted,且get開放

    • 有個GetResult方法,返回值為void或者TResult

    因此可以自定義一些能被await的類,關於如何自定義的細節,可以參考林德熙大佬的這篇文章:C# await 高階用法

async await的正確用途

事實上,我們線上程池上還埋下一個彩蛋,執行緒池上有工作執行緒適合CPU密集型操作,還有I/O完成埠執行緒適合I/O密集型操作,而async await非同步函式實際上的主場是在I/O密集型這裡,我們先通過一段程式碼

static void Main(string[] args)
{
     ThreadPool.SetMaxThreads(8, 8);//設定執行緒池最大工作執行緒和I/O完成埠執行緒數量
     Read();
     Console.ReadLine();
}

static void Read()
{
      byte[] buffer;
      byte[] buffer1;

       FileStream fileStream = new FileStream("E:/test1.txt", FileMode.Open, FileAccess.Read, FileShare.Read, 10000, useAsync: true);
       buffer = new byte[fileStream.Length];
       var state = Tuple.Create(buffer, fileStream);

       FileStream fileStream1 = new FileStream("E:/test2.txt", FileMode.Open, FileAccess.Read, FileShare.Read, 10000, useAsync: true);
       buffer1 = new byte[fileStream1.Length];
       var state1 = Tuple.Create(buffer1, fileStream1);

       fileStream.BeginRead(buffer, 0, (int)fileStream.Length, EndReadCallback, state);
       fileStream1.BeginRead(buffer, 0, (int)fileStream1.Length, EndReadCallback, state1);

}

 static void EndReadCallback(IAsyncResult asyncResult)
 {
       Console.WriteLine("Starting EndWriteCallback.");
       Console.WriteLine($"current thread:{Thread.CurrentThread.ManagedThreadId},isThreadPool:{Thread.CurrentThread.IsThreadPoolThread}");
       try
       {
          var state = (Tuple<byte[], FileStream>)asyncResult.AsyncState;
          ThreadPool.GetAvailableThreads(out int workerThreads, out int portThreads);
          Console.WriteLine($"AvailableworkerThreads:{workerThreads},AvailableIOThreads:{portThreads}");
          state.Item2.EndRead(asyncResult);
        }
        finally
        {
           Console.WriteLine("Ending EndWriteCallback.");
        }
}

輸出結果:

Starting EndWriteCallback.
current thread:3,isThreadPool:True
AvailableworkerThreads:8,AvailableIOThreads:7
Ending EndWriteCallback.
Starting EndWriteCallback.
current thread:3,isThreadPool:True
AvailableworkerThreads:8,AvailableIOThreads:7
Ending EndWriteCallback.

我們看到,事實上,兩個回撥方法都呼叫了相同的執行緒,且是執行緒池的I/O完成埠執行緒,假如將兩個例項化FileStream時的引數改下,改為useAsync: false,輸出結果如下:

Starting EndWriteCallback.
current thread:4,isThreadPool:True
AvailableworkerThreads:6,AvailableIOThreads:8
Ending EndWriteCallback.
Starting EndWriteCallback.
current thread:5,isThreadPool:True
AvailableworkerThreads:7,AvailableIOThreads:8
Ending EndWriteCallback.

我們會發現這次用到的是執行緒池的兩條工作執行緒了,其實這就是同步I/O和非同步I/O的區別,我們可以大概看下最底層BeginRead程式碼:

private unsafe int ReadFileNative(SafeFileHandle handle, byte[] bytes, int offset, int count, NativeOverlapped* overlapped, out int hr)
 {
       if (bytes.Length - offset < count)
       {
            throw new IndexOutOfRangeException(Environment.GetResourceString("IndexOutOfRange_IORaceCondition"));
       }

       if (bytes.Length == 0)
       {
           hr = 0;
           return 0;
       }

       int num = 0;
       int numBytesRead = 0;
       fixed (byte* ptr = bytes)
       {
           num = ((!_isAsync) ? Win32Native.ReadFile(handle, ptr + offset, count, out numBytesRead, IntPtr.Zero) : Win32Native.ReadFile(handle, ptr + offset, count, IntPtr.Zero, overlapped));
       }

       if (num == 0)
       {
           hr = Marshal.GetLastWin32Error();
           if (hr == 109 || hr == 233)
           {
               return -1;
           }

           if (hr == 6)
           {
               _handle.Dispose();
           }

           return -1;
       }
        hr = 0;
        return numBytesRead;
 }

實際上底層是Pinvoke去呼叫win32api ,Win32Native.ReadFile,關於該win32函式細節可參考MSDN:ReadFile,是否非同步的關鍵就是判斷是否傳入overlapped物件,而該物件會關聯到一個window核心物件,IOCP(I/O Completion Port),也就是I/O完成埠,事實上程式建立的時候,建立執行緒池的同時就會建立這麼一個I/O完成埠核心物件,大致流程如下:

  • 我們兩個I/O請求,事實上對應著我們傳入的兩個IRP(I/O request packet)資料結構,其中包括檔案控制程式碼和檔案中偏移量,會在Pinvoke去呼叫win32api進入win32使用者模式
  • 然後通過win32api函式進入window核心模式,我們兩個請求之後會放在一個IRP佇列
  • 之後系統就會從該IRP佇列,根據檔案控制程式碼和偏移量等資訊去對應請求處理不同的I/O裝置,完成後會放入到一個完成IRP佇列中
  • 然後執行緒池的I/O完成埠執行緒通過執行緒池的I/O完成埠物件去拿取那些已經完成IRP佇列

那麼在多請求的時候,IOCP模型非同步的這種情況,少量的I/O完成埠執行緒就能做到這一切,而同步則要因為一條執行緒要等待該請求處理的完成,那麼會大大浪費執行緒,正如上面一樣,兩個請求卻要兩個工作執行緒完成通知,而在async await時期,上面的一些方法已經被封裝以TaskTask<TResult> 物件來代表完成讀取了,那麼上面可以簡化為:

 static async Task Main(string[] args)
{
      ThreadPool.SetMaxThreads(8, 8);//設定執行緒池最大工作執行緒和I/O完成埠執行緒數量
      await ReadAsync();
      Console.ReadLine();
}

static async Task<int> ReadAsync()
{
      FileStream fileStream = new FileStream("E:/test1.txt", FileMode.Open, FileAccess.Read, FileShare.Read, 10000, useAsync: true);
      var buffer = new byte[fileStream.Length];
      var result = await fileStream.ReadAsync(buffer, 0, (int)fileStream.Length);
      return result;
 }

底層沒變,只是回撥的時候I/O完成埠執行緒再通過工作執行緒進行回撥(這能避免之前回撥的時候阻塞I/O完成埠執行緒的操作),但是大大的簡化了非同步I/O程式設計,而async await並非不適合CPU密集型,只是I/O操作一般比較耗時,如果用執行緒池的工作執行緒,就會有可能建立更多執行緒來應付更多的請求,CPU密集型的任務並行庫 (TPL)有很多合適的api

總結

我們瞭解了Task是.NET 編寫多執行緒的一個非常方便的高層抽象類,你可以不用擔心底層執行緒處理,通過對Task不同的配置,能寫出較高效能的多執行緒併發程式,然後探尋了.NET 4.5引入了的async await非同步函式內部做了些啥,知道async await通過和TPL的配合,簡化了編寫非同步程式設計的方式,特別適合I/O密集型的非同步操作,本文只是起到對於Task和async await有個快速的理解作用,而關於微軟圍繞Task做的事情遠遠不止如此,例如通過ValueTask優化Task,還有更利於CPU密集型操作的TPL中的Parallel和PLINQ api等等,可以參考其他書籍或者msdn更深入瞭解

參考

Asynchronous programming patterns
Async in depth
ThreadPool 類
Understanding C# async / await
《CLR Via C# 第四版》
《Window核心程式設計第五版》

相關文章