開篇
異步編程是程序設計的重點也是難點,還記得在剛開始接觸.net的時候,看的是一本c#的Winform實例教程,上面大部分都是教我們如何使用Winform的控件以及操作數據庫的實例,那時候做的基本都是數據庫的demo,數據量也不大,程序在執行的時候基本上不會出現阻塞的情況。隨著不斷的深入.net,也開始進入的實戰,在實際的項目,數據量往往都是比較大,特別是在大量的數據入庫以及查詢數據並進行計算的時候,程序的UI界面往往卡死在那裡,發生了阻塞,這時候就需要對計算時間限制的過程進行異步處理,讓UI線程繼續相應用戶的操作,使得用戶體驗表現比較友好,同時正確的使用異步編程去處理計算限制的操作和耗時IO操作還能提升的應用程序的吞吐量及性能。由此可見,異步編程的重要性。
異步編程在程序設計中也是非常復雜的,稍有不慎,就會使得你的應用程序變得不穩定,出現異常,甚至會奔潰。但是,比較幸運的是,.net提供非常方便的框架來進行異步編程,在我看來.net中實現異步有兩種方式,第一種是多線程的方式,第二種是使用異步函數,其實在異步函數中使用的還是多線程的技術。接下來就介紹在.net中如何使用多線程和異步函數來解決計算限制、耗時等這些不友好用戶體驗的問題。 異步編程中比較關心,也是比較重要的技術點在於,1)當異步線程在工作完成時如何通知調用線程,2)當異步線程出現異常的時候該如何處理,3)異步線程工作的進度如何實時的通知調用線程。4)如何在調用線程中取消正在工作的異步線程,並進行回滾操作。 一、異步函數模型 c#中提供異步函數編程模式,只要是使用委托對象封裝的函數都可以實現該函數的異步調用,這是因為委托類型有BeginInvoke和EndInvoke這兩個方法來支持異步調用。 下面給出一個例子來講解如何使用委托的來實現異步調用函數。class Program { public delegate void DoWork(); static void Main(string[] args) { DoWork d = new DoWork(WorkPro);//no.1 d.BeginInvoke(null, null);//no.2 for (int i = 0; i < 100; i++)//no.3 { Thread.Sleep(10);//主線程需要做的事 } Console.WriteLine("主線程done"); Console.ReadKey(); } public static void WorkPro() { //做一些耗時的工作 Thread.Sleep(2000); Console.WriteLine("異步調用結束"); } }程序定義了一個DoWork類型無參無返回值的的委托類型,no.1用WorkPro方法實例化一個DoWork類型的對象d ,no.2通過委托對象d的BeginInvoke(null,null)(下面將會詳細介紹BeginInvoke函數中兩個參數如何使用)來實現WorkPro函數的異步調用,這樣就使得no.3主線程所做的for循環和WorkPro函數可以同時執行,這樣使得程序的運行效率得到了大幅度的提升。如果程序是同步執行的話,假設WorkPro函數執行需要2秒,for需要1秒,總共執行時間就需要3秒,如果WorkPro是異步執行的話,那麼整個程序執行完畢只需要2秒就夠了。 ------ 上面這個例子只是簡單演示了如何通過委托來實現函數的異步調用,而沒有傳遞給該異步函數任何的參數,也不需要獲取該異步函數的結果。如果主線需要傳遞給該異步函數一個參數,並且還要在該異步函數執行完畢之後獲取其執行結果,那應該如何實現呢?
class Program { public delegate int DoWord(int count); static void Main(string[] args) { DoWord d = new DoWord(WorkPro); IAsyncResult r= d.BeginInvoke(1000,null,null);//no.1 int result= d.EndInvoke(r);//no.2 Console.WriteLine(result); for (int i = 0; i < 100; i++)//no.3 { Thread.Sleep(10);//主線程需要做的事 } Console.WriteLine("主線程done"); Console.ReadKey(); } public static int WorkPro(int count) { int sum = 0; //做一些耗時的工作 for (int i = 0; i < count; i++) { sum += i; } return sum; } }我們已經把委托類型改為具有一個int類型的參數和int類型返回值。在這裡解釋一下,每當你的編譯器發現定義了一個委托類型,就會對應的生成一個類型,並且該類型BeginInvoke方法的參數個數也是不同的,本例聲明的委托類型為: public delegate int DoWord(int count); 實際生成的BeginInvoke原型為:IAsyncResult BeginInvoke(int count, AsyncCallBack callback, object @object) 在no.1處還是和第一個例子一樣調用委托,不同的是用IAsyncResult接口的變量接收了異步調用(並不是異步函數)的返回狀態,這是方便後面調用EndInvoke方法接受這個異步函數調用結果而使用的,也可以通過該參數查看異步函數執行的狀態,該接口有一個IsCompleted的屬性。在no.2處使用d.EndInvoke(r)來接受異步函數返回值的。必須指出的是,主線程在調用委托的EndInvoke(r)方法時,當異步函數沒有執行完畢的話,主線程會一直處於阻塞,等待異步函數執行完畢,獲取返回值之後才執行no.3的for循環。這樣就還會導致主線程處於阻塞狀態。 理想的狀態的是,當異步函數調用完成之後,自動通知任務執行完成。當然委托也能夠做到,這就要使用BeginInvoke方法的後兩個參數啦。看下面這個例子。
class Program { public delegate int DoWord(int count); static void Main(string[] args) { DoWord d = new DoWord(WorkPro); IAsyncResult r= d.BeginInvoke(100,CallBack ,d);//no.1 for (int i = 0; i < 100; i++) { Thread.Sleep(10);//主線程需要做的事 } Console.WriteLine("主線程done"); Console.ReadKey(); } public static int WorkPro(int count) { int sum = 0; //做一些耗時的工作 for (int i = 0; i < count; i++) { sum += i; Thread.Sleep(10); } return sum; } public static void CallBack(IAsyncResult r) { DoWord d = (DoWord)r.AsyncState; Console.WriteLine("異步調用完成,返回結果為{0}", d.EndInvoke(r)); } }首先來解釋一下BeginInvoke方法的第二個參數是AsyncCallBack 類型的委托(回調函數),當該參數不為空,那麼在異步函數執行完畢之後,會調用該委托;第三個參數Object 類型的,代表傳遞給回調函數的異步調用狀態。CallBack回調函數必須帶有一個IAsyncResult 類型的參數,通過這個參數可以在回調方法內部獲取異步調用的結果。在no.1出就給BeginInvoke函數傳遞了回調函數CallBack,和委托d,當異步數WorkPro執行完畢之後,就立即通知CallBack回調函數來顯示執行結果。這下主線程就不需要阻塞一直的等待異步函數的結果,大大的提升了程序的運行效率。在.net還提供許多類的BeinXXX()和EndXXX()的異步版本,比如文件的讀寫等,具體可以查閱相關的資料。 其中異步函數內部所使用的線程均是線程池中的工作線程,由線程池去分配管理的。 二、多線程模型
.net在System.Threading和System.Threading.Tasks這兩個命名空間中提供了Thread,ThreadPool,和Task三個類來處理多線程的問題,其中Thread是建立一個專用線程,ThreadPool是使用線程池中工作線程,而Task類是采用任務的方式,其內部也是使用線程池中的工作線程。本節只講Tread類和Tasks類的使用以及其優劣。
class Program { static void Main(string[] args) { Thread t = new Thread(WorkPro);//no.1 t.IsBackground = true;//no.2 t.Start(1000);//no.3 } public static void WorkPro(object t) { //做一些耗時的工作 int count=(int)t; for (int i = 0; i < count; i++) { Thread.Sleep(2000); } Console.WriteLine("任務處理完成"); } }
no.1實例化一個Thread對象,給傳入一個ParameterizedThreadStart 類型的委托;no.2將建立的專用線程設置為後台的任務線程(後台線程會隨著調用線程(即使任務沒完成)的終止而強制終止,而前台線程如果任務沒有處理完,是不會隨著調用線程的終止而終止的);no.3調用Start(1000)方法,其中1000是傳遞給異步執行函數的參數。記住,如果構造Thread對象是ThreadStart委托,那麼Start()就直接調用,否則會出現異常。只需要簡單的幾行代碼就能實現函數的異步調用。 其中,當異步函數中處理需要多個參數時,那麼只需要建立一個參數類,參數類中包括你函數需要的參數個數,然後將這個參數類傳遞給異步函數即可。 Thread類的使用雖然簡單,但是它還是有一定的劣勢的,一般不推薦使用。 1)Thread類創建的是一個專用線程,建立一個專用線程是非常耗用系統的資源,建議是使用線程池中的線程。 2)Thread類不能很好的和調用線程進行交互,當任務完成時不能及時的通知,在調用線程也不能隨時的取消正在進行的任務。 另外在以下情況下,就只能選擇使用Thread類了。 1)執行任務的線程要以非普通的優先級去執行,因為線程池的線程都是以普通優先級運行的。 2)執行任務的線程要表現為一個前台線程,因為線程池的線程始終都是一個後台線程。 3)異步執行的任務需要長時間的,那麼就可以使用Thread類為該任務建立一個專用線程。
2、Task類
Task類是封裝的一個任務類,內部使用的是ThreadPool類,提供了內建機制,讓你知道什麼時候異步完成以及如何獲取異步執行的結果,並且還能取消異步執行的任務。下面看一個例子是如何使用Task類來執行異步操作的。class Program { static void Main(string[] args) { Task t = new Task((c) => { int count = (int)c; for (int i = 0; i < count; i++) { Thread.Sleep(10); } Console.WriteLine("任務處理完成"); }, 100);//no.1 t.Start(); for (int i = 0; i < 100; i++) { Thread.Sleep(10); } Console.WriteLine("done"); } }no.1處使用Task的構造函數為: public Task( Action<Object> action, Object state )一個Action<Object>類型的委托(即異步調用函數具有一個Object類型的參數),和一個Object類型的參數,也就是傳遞給異步函數的參數,Task類還有幾種方式的重載,我們還可以傳遞一些TaskCreationOptions標志來控制Task的執行方式。在這裡我使用的是lambda表達去寫委托的,這樣使得程序的結構更加的清晰,使用Start()來啟動異步函數的調用。 -------- 如果需要異步函數有返回值,那麼此時就需要使用Task<TResult>泛型類(派生自Task)來實現,其中TResult代表返回的類型。因為異步函數具有返回值,所以Task<TResult>的各種重載版本的構造函數第一個委托類型的參數都是Fun<TResult>或者Fun<Object,TResult>。下面演示等待任務完成並獲取其結果。
class Program { static void Main(string[] args) { Task<int> t = new Task<int>((c) => { int count = (int)c; int sum=0; for (int i = 0; i < count; i++) { Thread.Sleep(10); sum+=i; } Console.WriteLine("任務處理完成"); return sum; }, 100); t.Start(); t.Wait();//no.1 Console.WriteLine("任務執行的結果{0}", t.Result);//no.2 for (int i = 0; i < 100; i++) { Thread.Sleep(10); } Console.WriteLine("done"); } }如果任務中出現了異常,那麼異常會被吞噬掉,並存儲到一個集合中去,而線程可以返回到線程池中去。但是如果在代碼中調用了Wait方法或者是Result屬性,任務有異常發生就會被引發,不會被吞噬掉。其中Result屬性內部本身也調用了Wati方法。Wait方法和上一節中的委托的EndInvoke方法類似,會使得調用線程阻塞直到異步任務完成。下面我們會介紹如何避免獲取異步結果的阻塞情況,在講解之前,先說一下,如何取消正在運行的任務。 ------ 看下面一段代碼如何演示取消正在運行的任務。
class Program { static void Main(string[] args) { CancellationTokenSource cts = new CancellationTokenSource();//no.1 Task<int> t = new Task<int>((c) =>Sum(cts.Token ,(int)c), 100);//no.2 t.Start(); cts.Cancel();//no.3如果任務還沒完成,但是Task有可能完成啦 for (int i = 0; i < 100; i++) { Thread.Sleep(10); } Console.WriteLine("done"); } static int Sum(CancellationToken ct, int count) { int sum = 0; for (int i = 0; i < count; i++) { if (!ct.CanBeCanceled) { Thread.Sleep(10); sum += i; } else { Console.WriteLine("任務取消"); //進行回滾操作 return -1;//退出任務 } } Console.WriteLine("任務處理完成"); return sum; } }取消任務要引用一個CancellationTokenSource 對象。在需要異步執行的方法中增加一個CancellationToken類型的形參。然後在異步函數的for循環代碼中用一個if語句判斷CancellationToken的CanBeCanceled屬性,這個屬性可以用來判斷在調用線程是否取消任務的執行,除CanBeCanceled屬性之外,還可以使用ThrowIfCancellationRequested方法,該方法的作用是如果在調用線程調用CancellationTokenSource對象的Cancel方法,那麼就會引發一個異常,然後在調用線程進行捕捉就好了,這是在異步函數中的處理方式。no.1在構建任務之前需要建立一個CancellationTokenSource ,no2.並且把CancellationTokenSource傳遞給異步調用函數,傳遞的是CancellationTokenSource對象的Toke屬性,該屬性是一個CancellationToken類型的對象。這樣就完成任務的取消模式,如果想在調用線程中取消任務的執行,只需要調用CancellationTokenSource 的Cancel方法就行啦。 ------ 前面就說過了,獲取任務結果調用Wait方法和Result屬性導致調用線程阻塞,那麼如何處理這種情況呢,這就使用了Task<TResult>類提供的ContinueWith方法。該方法的作用是當任務完成時,啟動一個新的任務,不僅僅是如此,該方法還有可以在任務只出現異常或者取消等情況的時候才執行,只需要給該方法傳遞TaskContinuationOptions枚舉類型就可以了。下面就演示一下如何使用ContinueWith方法。 首先看下ContinueWith方法的原型。 public Task ContinueWith( Action<Task> continuationAction )采用一個Action<Task>類型的委托。該方法提供了多種重載的版本,這只是最簡單的一種。
public Task ContinueWith( Action<Task> continuationAction, TaskContinuationOptions continuationOptions )第二個參數代表新任務的執行條件,當任務滿足這個枚舉條件才執行 Action<Task>類型的回調函數。
代碼如下:class Program { static void Main(string[] args) { Task<int> t = new Task<int>((c) =>Sum((int)c), 100); t.Start(); t.ContinueWith(task => Console.WriteLine("任務完成的結果{0}", task.Result));//當任務執行完之後執行 t.ContinueWith(task => Console.WriteLine(""), TaskContinuationOptions.OnlyOnFaulted);//當任務出現異常時才執行 for (int i = 0; i < 200; i++) { Thread.Sleep(10); } Console.WriteLine("done"); } static int Sum( int count) { int sum = 0; for (int i = 0; i < count; i++) { Thread.Sleep(10); sum += i; } Console.WriteLine("任務處理完成"); return sum; } }
t.Start()之後調用第一個ContinueWith方法,該方法第一參數就是一個Action<Task>的委托類型,相當於是一個回調函數,在這裡我也用lambda表達式,當任務完成就會啟用一個新任務去執行這個回調函數。而第二個ContinueWith裡面的回調方法卻不會執行,因為我們的任務也就是Sum方法不會發生異常,不能滿足TaskContinuationOptions.OnlyOnFaulted這個枚舉條件。這種用法比委托的異步函數編程看起來要簡單些。最關鍵的是ContinueWith的還有一個重載版本可以帶一個TaskScheduler對象參數,該對象負責執行被調度的任務。FCL中提供兩種任務調度器,均派生自TaskScheduler類型:線程池調度器,和同步上下文任務調用器。而在Winform窗體程序設計中TaskScheduler尤為有用,為什麼這麼說呢?因為在窗體程序中的控件都是有ui線程去創建,而我們所執行的後台任務使用線程都是線程池中的工作線程,所以當我們的任務完成之後需要反饋到Winform控件上,但是控件創建的線程和任務執行的線程不是同一個線程,如果在任務線程中去更新控件就會導致控件對象安全問題會出現異常。所以操作控件,就必須要使用ui線程去操作。因此在ContinueWith獲取任務執行的結果的並反饋到控件的任務調度上不能使用線程池任務調用器,而要使用同步上下文任務調度器去調度,即采用ui這個線程去調用ContinueWith方法所綁定的回調用函數即Action<Task>類型的委托。下面將使用任務調度器來把異步執行的Sum計算結果反饋到Winform界面的TextBox控件中。
界面如下。
代碼如下。
public partial class Form1 : Form { private readonly TaskScheduler contextTaskScheduler;//聲明一個任務調度器 public Form1() { InitializeComponent(); contextTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();//no.1獲得一個上下文任務調度器 } private void button1_Click(object sender, EventArgs e) { Task<int> t = new Task<int>((n) => Sum((int)n),100); t.Start(); t.ContinueWith(task =>this.textBox1 .Text =task.Result.ToString(),contextTaskScheduler);//當任務執行完之後執行 t.ContinueWith(task=>MessageBox .Show ("任務出現異常"),CancellationToken.None ,TaskContinuationOptions.OnlyOnFaulted,contextTaskScheduler );//當任務出現異常時才執行 } int Sum(int count) { int sum = 0; for (int i = 0; i < count; i++) { Thread.Sleep(10); sum += i; } Console.WriteLine("任務處理完成"); return sum; } }在no.1窗體的構造函數獲取該UI線程的同步上下文調度器。在按鈕的事件接受異步執行的結果時候,都傳遞了contextTaskScheduler同步上下文的調度器,目的是,當異步任務完成之後,調度UI線程去執行任務完成之後的回調函數。 ------ 到目前為止,我平常用到的異步編程模式也就這麼多了,當然Task類的ContinueWith還有很多重載的版本,會提供不一樣效果。在開篇的時候就說,如何在調用線程中實時獲取異步任務的執行情況,比如我的任務是插入100w條數據到數據庫,我在界面中需要實時的刷新數據導入的進度條,這種情況使用上述所講的是做不到的。具體如何做到,我在另外一篇文章已經詳細的講過啦,采用回調函數的方法(委托)來實現,鏈接:http://www.cnblogs.com/mingjiatang/p/5079632.html。 三、小結 雖然在.net中提供了眾多的異步編程模式,但是推薦最好使用Task類,因為Task類使用線程池中的任務線程,又由線程池管理,效率相對來說較高,而且Task類內部有比較好的機制,能讓調用線程與任務進行交互。反正不管用哪種模式,總之盡量不要出現阻塞的情況,只要程序中出現線程阻塞,線程池就會創建新的活動線程,因為線程池總是要保證活動的任務線程數量與CPU的核數一致,它覺得這樣性能最佳,當阻塞的線程恢復正常之後,線程池又會將多余的線程銷毀,避免系統調度線程時頻繁的進行上下文切換。這樣的創建、銷毀線程是非常的浪費系統資源影響性能的。而在線程同步的時候常常會出現阻塞的情況,所以能設計不用線程同步去解決問題,盡量不用線程同步。最後要是有寫的不對的地方,請各位指正,謝謝!