漫談非同步

by Vivid 2. 一月 2013 03:45

.NET Magazine國際中文電子雜誌
作 者:許薰尹
審 稿:張智凱
文章編號:N130113201
出刊日期:2013/1/2

 

若一個作業要花很長的時間等待某件事情完成,例如可能要等待輸入(Input)、輸出(Outout),稱之為I/O Bound,例如下載圖片、叫用Thread.Sleep、Console.Read方法。而一個作業若需花費很多時間讓CPU執行計算的動作,就稱之為Compute-Bound。為了處理這些需要花費很久才能做完的程式不要停滯主執行緒的執行,.NET設計許多非同步的模式利用多執行緒來解決問題,本文將介紹一些常用的非同步模式。

在使用非同步模式執行之前,我們先來看一個不使用非同步的範例程式。以下範例程式碼,Main方法中呼叫Add方法,Add方法故意使用Thread.Sleep方法暫停執行3秒,模擬長時間執行。但Main方法呼叫Add方法後要等Add方法執行完,才能執行下一行,印出執行結果:

 

namespace AsyncDemo {
  class Program {
    static void Main( string[ ] args ) {
      Console.WriteLine( "Main Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
      int result = Add( 10 , 50 );
      Console.WriteLine( "after Add");
      Console.WriteLine( "Finish, result : " + result.ToString( ) );
    }
    public static int Add( int a , int b ) {
      Console.WriteLine( "Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
     System.Threading.Thread.Sleep( 3000 );    
      return a + b;
    }
  }
}

 

 

此範例叫用Thread.Sleep方法使用I/O-Bound操作,此行為將會停滯目前執行緒的執行,浪費CPU執行時間。參考此範例的執行結果如下,主控台將印出以下字串印出第二段文字之後,要等約三秒才會印出「after Add」字串,再印出執行結果:

Main Thread ID : 1
Thread ID : 1
after Add
Finish, result : 60

 

.NET非同步模式-以事件為基礎的非同步模型

為了簡化非同步應用程式的撰寫,.NET Framework中有許多類別預設便支援非同步處理的功能。其中一種是以事件為基礎的非同步模型,例如以下範例程式使用WebClient類別的DownloadDataAsync方法從微軟網站下載網頁:

namespace AsyncDemo {
  class demo {
    static void Main( string[ ] args ) {
      var c = new WebClient( );
      c.DownloadDataCompleted += c_DownloadDataCompleted;
      c.DownloadDataAsync( new Uri("http://www.microsoft.com" ));
      Console.WriteLine( "Main Thread ID : " + Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Console.ReadLine( );    }

    static void c_DownloadDataCompleted( object sender , DownloadDataCompletedEventArgs e ) {
      Console.WriteLine( "Thread ID : " + Thread.CurrentThread.ManagedThreadId.ToString( ) );   
      byte[ ] data = e.Result;
      string r = System.Text.Encoding.UTF8.GetString( data );
      Console.WriteLine(r );
    }
  }
}

在叫用WebClient類別的DownloadDataAsync方法之前,先註冊了DownloadDataCompleted事件的事件處理常式—c_DownloadDataCompleted方法。c_DownloadDataCompleted方法沒有回傳值,將傳入一個DownloadDataCompletedEventArgs參數,其中將包含下載完成的網頁內容。當程式執行到DownloadDataAsync此行程式之後會啟動一條執行緒,並馬上往下執行下一行程式;新啟動的執行緒則負責下載網頁。待下載動作完成時,便可從DownloadDataCompletedEventArgs物件取得執行結果。

參考此範例的執行結果如下,主控台將印出以下字串:

clip_image002

圖 1:以事件為基礎的非同步程式執行結果。

 

.NET非同步模式-Begin/End方法

另一種非同步的模式是採用Begin/End方法搭配IAsyncResult介面,.NET Framework中許多類別都提供Begin開頭與End開頭的方法,來達成非同步模式。例如以下使用FileStream類別的BeginRead方法讀取a.txt檔案內容,BeginRead方法倒數第二個參數是一個AsyncCallback Delegate指向一個回呼函式CB;CB函式需要傳入實作IAsyncResult介面的物件當參數,並於其中叫用EndRead方法取得非同步執行結果。

 

namespace AsyncDemo {
  class demo {
    static byte[ ] data = null;
    static void Main( string[ ] args ) {
      FileStream fs = new FileStream( @"c:\temp\a.txt" , FileMode.Open );
      data = new byte[ fs.Length ];
      fs.BeginRead( data , 0 , data.Length , CB , fs );
      Console.WriteLine( "Main Thread ID : " + Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Console.ReadLine( );
    }
    private static void CB( IAsyncResult ar ) {
      Console.WriteLine( "Worker Thread ID : " + Thread.CurrentThread.ManagedThreadId.ToString( ) );
      FileStream fs = (FileStream) ar.AsyncState;
      fs.EndRead( ar );
      string r = System.Text.Encoding.UTF8.GetString( data );
      Console.WriteLine( r );
    }
  }
}

 

 

參考此範例的執行結果如下,主控台將印出以下字串:

Worker Thread ID : 3
This is a test.
Main Thread ID : 1

 

以Task為基礎的非同步模式

在.NET Framework 4版之後,System.Threading.Task命名空間下包含一個Task類別,可以以多執行緒(Multi-Tread)執行非同步作業,另有一個通用的Task<TResult>類別,TResult代表非同步動作執行完回傳值的型別。

.NET Framework中的Task.Run方法,只要傳入一個Action或Func類型的delegate,就可以啟動一條背景執行緒來執行程式碼。因為Task.Run方法會從執行緒集區(Thread Pool)啟動背景執行緒來執行,所以當主執行緒結束時,背景執行緒也會跟著結束。

例如以下程式碼範例,使用Task.Run方法來執行一個非同步作業:

 

namespace AsyncDemo {
  class _2_TaskDemo {
    static void Main( string[ ] args ) {
      Console.WriteLine( "Main Thread ID : " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Task<int> t = Task.Run( ( ) => Add( 10 , 50 ) );
      Console.WriteLine( "after run" );
      Console.WriteLine( "Result : " + t.Result );
      Console.Read( );
    }
    public static int Add( int a , int b ) {
      Console.WriteLine( "Thread ID : " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString( ) );
      return a + b;
    }
  }
}

 

 

Task.Run方法類似ThreadPool.QueueUserWorkItem方法,從執行緒集區(Thread Pool)取得工作執行緒來執行Add方法,Task.Run方法傳入一個Func<TResult> delegate,指向Add方法,回傳Task<TResult>物件,其中Result屬性將會包含非同步執行完的結果。

Task.Run方法這個方法一呼叫,主執行緒會馬上執行下一行程式碼,印出「after run」訊息。若存取Result屬性時,工作執行緒尚未執行完,執行此行程式的執行緒就會停滯執行,一直等待直到工作執行緒做完為止,才會繼續往下執行。

參考此範例的執行結果如下,主控台將印出以下字串,您將發現主執行緒的代號為1;而新啟動的工作執行緒代號為3:

Main Thread ID:1
after run
Thread ID:3
Result: 60

 

IsCompleted屬性與Wait方法

如果想要等待Task.Run起始的執行緒工作結束,可以利用wait方法,要判斷Task.Run起始的執行緒工作是否已結束執行,可以使用IsCompleted屬性,例如以下程式範例:

namespace AsyncDemo {
  class _2_TaskDemo {
    static void Main( string[ ] args ) {
      Console.WriteLine( "Main Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Task<int> t = Task.Run( ( ) => Add( 10 , 50 ) );
      Console.WriteLine( "after run" );
      if ( !t.IsCompleted ) {
        Console.WriteLine( "等待Task結束" );
        t.Wait( );
      }
      Console.WriteLine( "Result : " + t.Result );
      Console.Read( );
    }
    public static int Add( int a , int b ) {
      Console.WriteLine( "Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Thread.Sleep( 1000 );
      return a + b;
    }
  }
}

若執行到IsCompleted屬性這行程式碼時,工作執行緒尚未做完,我們可以叫用Wait方法等待,執行工作執行緒做完之後,再繼續往下執行。參考此範例的執行結果如下,主控台將印出以下字串:

Main Thread ID:1
after run
Task ID : 1
等待Task結束
Thread ID : 3
Result : 60

 

TaskCreationOptions

TaskCreationOptions列舉常數值可以用來控制Task的行為。若工作執行緒要執行的Task需要花費很久時間才做完,您可以改用Task.Factory.StartNew方法,搭配TaskCreationOptions.LongRunning列舉常數值來啟動工作,例如以下範例程式碼:

 

namespace AsyncDemo {
  class _2_TaskDemo {
    static void Main( string[ ] args ) {
      Console.WriteLine( "Main Thread ID : " + Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Task<int> t = Task.Factory.StartNew( ( ) => Add( 10 , 40 ) , TaskCreationOptions.LongRunning );
      Console.WriteLine( "after run" );
      Console.WriteLine( "Result : " + t.Result );
      Console.Read( );
    }
    public static int Add( int a , int b ) {
      Console.WriteLine( "Thread ID : " + Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Thread.Sleep( 1000 );
      return a + b;
    }
  }
}

 

 

參考此範例的執行結果如下,主控台將印出以下字串:

Main Thread ID : 1
after run
Thread ID : 3
Result : 50

 

Continuations

有時我們會使用回呼函式(Callback)在工作執行緒完成工作時,執行後續的程式碼,例如取回執行結果,這種回呼函式稱之為Continuations。使用Continuations取回執行結果的方法有兩種,呼叫Task<TResult>物件的ContinueWith方法,以及使用TaskAwaiter<TResult>物件的OnCompleted方法。

使用Continuations的好處是,您不會因為Task尚未完成工作,就因為存取Result屬性,而停滯在此行程式碼,浪費執行效能。

 

ContinueWith方法

參考以下程式範例,使用ContinueWith方法:

 

namespace AsyncDemo {
  class _2_TaskDemo {
    static void Main( string[ ] args ) {
      Console.WriteLine( "Main Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Task<int> t = Task.Run( ( ) => Add( 10 , 50 ) );
      Console.WriteLine( "after run" );
      var awaiter = t.GetAwaiter( );
      t.ContinueWith( myTask => {
        Console.WriteLine( "ContinueWith Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
        int r = awaiter.GetResult( );
        Console.WriteLine( "Result : " + r.ToString( ) );
      } );
      Console.Read( );
    }
    public static int Add( int a , int b ) {
     Console.WriteLine( "Add Thread ID : " +         Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Task.Delay( 1000 );
      return a + b;
    }
  }
}

 

 

呼叫Task物件的GetAwaiter方法取回一個awaiter物件,再呼叫ContinueWith方法,此方法需要傳入一個Action<Task<int>> delegate,指明工作執行緒後續要執行的程式碼,利用awaiter物件的GetResult方法取得執行結果。

注意,當呼叫Add方法做兩數相加的程式碼執行完,這個Task將會起始一個新Task,從執行緒集區取得一條執行緒來顯示執行結果(有可能取得相同執行緒),參考此範例的執行結果如下,主控台將印出以下字串,主執行緒、執行Add方法的工作執行緒,以及ContinueWith中的執行緒都不相同:

Main Thread ID : 1
after run
Add Thread ID : 3
ContinueWith Thread ID : 4
Result : 60

 

TaskContinuationOptions

如果希望使用同一條執行緒集區的執行緒來執行Task與ContinueWith方法,則叫用ContinueWith方法時,可以設定TaskContinuationOptions選項,設定其值為ExecuteSynchronously,改用同步方式來執行。例如修改上個範例如下:

namespace AsyncDemo {
  class _2_TaskDemo {
    static void Main( string[ ] args ) {
      Console.WriteLine( "Main Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Task<int> t = Task.Run( ( ) => Add( 10 , 50 ) );
      Console.WriteLine( "after run" );
      var awaiter = t.GetAwaiter( );

      t.ContinueWith( myTask => {
        Console.WriteLine( "ContinueWith Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
        int r = awaiter.GetResult( );
        Console.WriteLine( "Result : " + r.ToString( ) );
      } , TaskContinuationOptions.ExecuteSynchronously );


      Console.Read( );
    }
    public static int Add( int a , int b ) {
      Console.WriteLine( "Add Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Task.Delay( 1000 );
      return a + b;
    }
  }
}

 

參考此範例的執行結果如下,主控台將印出以下字串:

Main Thread ID : 1
after run
Add Thread ID : 3
ContinueWith Thread ID : 3
Result : 60

 

OnCompleted方法

第二種使用Continuations取回執行結果的方法是使用TaskAwaiter<TResult>物件的OnCompleted方法。參考以下程式範例,使用OnCompleted方法:

 

namespace AsyncDemo {
  class _2_TaskDemo {
    static void Main( string[ ] args ) {
      Console.WriteLine( "Main Thread ID : " + Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Task<int> t = Task.Run( ( ) => Add( 10 , 50 ) );
      Console.WriteLine( "after run" );
      var awaiter = t.GetAwaiter( );
      awaiter.OnCompleted( ( ) => {
        int r = awaiter.GetResult( );
        Console.WriteLine( "Result : " + r.ToString( ) );
         }
        );
      Console.Read( );
    }
    public static int Add( int a , int b ) {
      Console.WriteLine( "Thread ID : " + Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Thread.Sleep( 1000 );
      return a + b;
    }
  }
}

 

 

呼叫Task物件的GetAwaiter方法取回一個awaiter物件,再呼叫awaiter物件OnCompleted方法,以便在Task 非同步工作完成時,執行delegate,利用awaiter物件的GetResult方法取得執行結果。

參考此範例的執行結果如下,主控台將印出以下字串:

Main Thread ID : 1
after run
Thread ID : 3
Result : 60

 

例外錯誤

在前一個範例中,若Add方法執行發生例外錯誤,CLR會在你叫用awaiter.GetResult( )方法這一行程式時,.NET會重新丟出例外錯誤,以便進行錯誤處理,例如可以修改程式如下來撰寫錯誤處理常式:

 

namespace AsyncDemo {
  class _2_TaskDemo {
    static void Main( string[ ] args ) {
      Console.WriteLine( "Main Thread ID : " + Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Task<int> t = Task.Run( ( ) => Add( 10 , 50 ) );
      Console.WriteLine( "after run" );
      var awaiter = t.GetAwaiter( );
      awaiter.OnCompleted( ( ) => {
        int r = 0;
        try {
          r = awaiter.GetResult( );
          Console.WriteLine( "Result : " + r.ToString( ) );
        } catch ( Exception ex ) {
          Console.WriteLine( ex.Message );
        }
        Console.WriteLine( "IsFaulted : " + t.IsFaulted.ToString( ) );
        Console.WriteLine( "IsCanceled : " + t.IsCanceled.ToString( ) );
        Console.WriteLine( "IsCompleted : " + t.IsCompleted.ToString( ) );
      }
        );
      Console.Read( );
    }
    public static int Add( int a , int b ) {
      Console.WriteLine( "Thread ID : " + Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Thread.Sleep( 1000 );
      throw new ArithmeticException( "計算發生錯誤!!" );
      return a + b;
    }
  }
}

 

 

範例故意在Add方法中丟出ArithmeticException例外錯誤,並且在叫用awaiter.GetResult方法時,利用try..catch錯誤處理常式來補捉CLR重丟出的例外錯誤。Task物件的IsFaulted屬性可以用來判斷是否發生未處理的例外錯誤。Task物件的IsCanceled屬性則是用來判斷此工作是否被取消;而Task物件的IsCompleted屬性則是用來判斷工作是否執行完成。

參考此範例的執行結果如下,主控台將印出以下字串:

Main Thread ID : 1
after run
Thread ID : 3
計算發生錯誤!!
IsFaulted : True
IsCanceled : False
IsCompleted : True

 

await與async

C# 5.0新增非同步程式設計功能,簡化撰寫Continuations的程式語法,由編譯器為你自動產生,為達到此功能加入了await與async兩個關鍵字。

例如以下程式範例,宣告一個非同步的AddAsync方法,方法前方加上async關鍵字:

using System;
using System.Threading;
using System.Threading.Tasks;
namespace AsyncDemo {
  class _2_TaskDemo {
    static void Main( string [ ] args ) {
      Console.WriteLine( "Main Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
      AddAsync( );
      Console.Read( );
    }

    static async void AddAsync( ) {
    
      Task<int> t = Task.Run( ( ) => Add( 10 , 50 ) );
      Console.WriteLine( "before await" );
      Console.WriteLine( "AddAsync Thread ID : " +
       Thread.CurrentThread.ManagedThreadId.ToString( ) );
      int r = await t;
      Console.WriteLine( "after await" );
      Console.WriteLine( "Result : " + r.ToString( ) );
      Console.WriteLine( "Result : " + t.Result.ToString( ) );
      Console.WriteLine( "after AddAsync Thread ID : " +
    Thread.CurrentThread.ManagedThreadId.ToString( ) );
    }


    public static int Add( int a , int b ) {
      Console.WriteLine( "Add Thread ID : " +
        Thread.CurrentThread.ManagedThreadId.ToString( ) );
      Task.Delay( 1000 );
      return a + b;
    }
  }
}

 

因為async關鍵字只能套用在方法和Lamdba運算式(但不可以套用在Main方法),所以範例將建立Task的程式碼獨立出來放在AddAsync方法之中。套用async關鍵字的方法只能夠回傳void、Task或Task<TResult>型別的物件。標識async關鍵字的方法稱之為非同步方法,或叫非同步函數(Asynchronous Function)。

在AddAsync方法中則加上await關鍵字,編譯器會自動附加類似下列的Continuations程式碼,以取回執行結果:

 

var awaiter = t.GetAwaiter( );
awaiter.OnCompleted( ( ) => {
  int r = awaiter.GetResult( );
}
);

因此你可以透過Task<int>物件的Result屬性取得執行結果。範例中await 那行程式碼回傳的結果是int型別,這是因為編譯器產生的awaiter.GetResult( )方法回傳值是int型別的關係。

參考此範例的執行結果如下,主控台將印出以下字串,當AddAsync方法使用了await關鍵字,代表主執行緒建立Task之後,馬上會往下執行,印出「before await」字串,停在await這行程式。一旦Add方法執行完,才會交回主控權,印出「after await」字串,r.ToString()這行程式才能取得執行結果。同樣地也可以利用Task<int>物件的Result屬性取得執行結果:

Main Thread ID : 1
before await
AddAsync Thread ID : 1
Add Thread ID : 3
after await
Result : 60
Result : 60
after AddAsync Thread ID : 4

 

 

Tags:

.NET Magazine國際中文電子雜誌 | NET 開發 | 許薰尹Vivid Hsu

新增評論




  Country flag
biuquote
  • 評論
  • 線上預覽
Loading






NET Magazine國際中文電子雜誌

NET Magazine國際中文電子版雜誌,由恆逸資訊創立於2000,自發刊日起迄今已發行超過500篇.NET相關技術文章,擁有超過40000名註冊讀者群。NET Magazine國際中文電子版雜誌希望藉於電子雜誌與NET Developer達到共同學習與技術新知分享,歡迎每一位對.NET 技術有興趣的朋友們多多支持本雜誌,讓作者群們可以有持續性的動力繼續爬文。<請加入免費訂閱>

月分類Month List