C# 多線程五之Task(任務)一


1、簡介

為什么MS要推出Task,而不推Thread和ThreadPool,以下是我的見解:

(1)、Thread的Api並不靠譜,甚至MS自己都不推薦,原因,它將整個Thread類都不開放給Windows Sotre程序,且它的Api過於強大,如果在程序中過度使用,維護的成本太高,想想代碼中充斥着掛起線程,阻塞線程、后期的應用程序很難維護.

(2)、ThreadPool最大的問題是,所有的輔助線程都是異步的,沒有向Thread的Join方法那樣去等待一個線程執行完,然后執行回調函數的機制,也就是你無法判斷線程什么時候執行完,也沒有機制獲得線程的返回值,所有MS推出了Task來解決Thread和ThreadPool的問題

當然最主要的是,Thread和Thread好用.因為Task是它們的升級版,升級版當然比較好.

 

2、Task的缺點

雖然Task以其強大的Api,以及封裝,讓我們在CLR環境下,能完成高效率的編程,但是它並不是沒有缺點的,高效率的背后,肯定帶來的性能的損失,這一點很多類似的框架都能說明,比如EF,強大的背后,大量的使用了反射等操作,所以雖然開發效率提升了,但是性能卻下降了,這里不想說太多,所以簡單的api可能不會產生過多的性能損耗,所以這也是為什么大型互聯網項目,更願意使用原生Ado或者Dapper去做.所以這些在我們的實際開發中,這些都需要我們去權衡.有得必有失.下面來簡單的說下Task具體在哪里會產生性能損失:

 很直觀,直接分析ThreadPool類和Task類的構造:

ThreadPool類

很簡潔,沒有任何的字段和屬性!

Task類,1700行代碼,里面有大量的字段和屬性,大致如下:

還包括對父任務的引用、任務調度器(TaskScheduler)的引用、對回調方法的引用、對執行上下文(ExecutionContext)的引用、對ManualResetEventSlim信號量的引用、還有CancellationToken取消信號量(我把它理解為信號量)的引用、一個ContinueWithTask的任務集合的引用、還有未拋出異常的Task對象集合的引用等等,這些后面的文章都會介紹.

所以,不分析具體的性能損耗點,但是單單兩個類的構造,你就能清楚使用那個類創建線程所產生的性能消耗大.

 

3、實戰

(1)、不帶返回值,實現和ThreadPool線程池線程一樣的效果

        static void Main(string[] args) 
        {
            var result=Task.Run(() => Calculate("這個參數很六啊"));
            Console.WriteLine("主線程有沒有在繼續執行,look look");
            Console.ReadKey();
        }

        static void Calculate(string param)
        {
            Console.WriteLine("子線程開始執行,帶着主線程給它傳遞的參數呢!參數是:{0}",param);
            Thread.Sleep(2000);
            Console.WriteLine("子線程執行完了");
        }

根據輸出,發現主線程並沒有等帶子線程執行完畢,通過開啟一個新線程之后,立刻返回去執行它自己的任務.

 

(2)、帶返回值

        static void Main(string[] args) 
        {
            var result=Task.Run(() => Calculate(1));
            Console.WriteLine(result.Result);
            Console.WriteLine("主線程有沒有在繼續執行,look look");
            Console.ReadKey();
        }

        /// <summary>
        /// 簡單遞歸計算n+(n-1)+.....+1
        /// </summary>
        /// <param name="param"></param>
        /// <returns></returns>
        static int Calculate(int param)
        {
            if (param == 1)
                return 1;
            return param + Calculate(param - 1);
        }

無論給Calculate方法傳遞的參數多小,主線程都等待子線程返回結果后,在繼續執行它的任務.所以可以得出結論.調用子線程返回值的Result屬性

相等於調用了Wait方法,當然Task確實提供了這個實例方法,但是使用Result屬性一樣有這個效果.主線程會等待子線程執行完畢在執行它的任務.

 

(3)、關於Task的小要點

當主線程通過Task開啟了一個子線程之后,返回做自己的事情,當它執行到Wait方法,這個時候主線程會阻塞,CPU的執行速度很快,所以它會去判斷子線程有沒有開始執行,如果沒有執行,那么它會自己去做子線程的任務,而不是開啟一個新的線程去做.這樣就節約了系統資源.這樣就不會存在線程阻塞的情況.所有事情都由主線程干完.

 

(4)、關於簡單的死鎖問題

一般死鎖的產生,都是多線程爭用相同的資源導致的.下面就來重現一下.

        private static object lockObj = new object();
        static void Main(string[] args) 
        {
            var result=Task.Run(() => Calculate(100));
            lock (lockObj)
            {
                Console.WriteLine("主線程這個時候爭用了lockObj鎖,並執行子線程");
                Console.WriteLine(result.Result);
            }
            Console.WriteLine("主線程有沒有在繼續執行,look look");
            Console.ReadKey();
        }

        /// <summary>
        /// 簡單遞歸計算n+(n-1)+.....+1
        /// </summary>
        /// <param name="param"></param>
        /// <returns></returns>
        static int Calculate(int param)
        {
            lock (lockObj)
            {
                Console.WriteLine("子線程這個時候也去爭用lockObj鎖,發現主線程已經爭用了這個鎖,那么它等待主線程釋放這個鎖,但是主線程正等待它執行完!");
                Console.WriteLine("好了,這個時候就發生了死鎖現象.主線程等子線程執行完,子線程等主線程釋放lockObj鎖,兩個線程在相互等待,死鎖了");
            }
            if (param == 1)
                return 1;
            return param + Calculate(param - 1);
        }

光標一直在那閃啊閃,好吧,那就都等着吧.誰都執行不下去了.

解決辦法很簡單.在創建一個新的鎖,這個就不代碼演示了.

 

(5)、取消Task創建的子線程

取消Task創建的線程和取消ThreadPool創建的子線程一樣,通過CancellationTokenSource類實現,代碼如下:

            var cancellationSource = new CancellationTokenSource();
            cancellationSource.Cancel();
            try {
                Task.Run(() => ChildThread(cancellationSource.Token));
            }
            catch(AggregateException ex)
            {
                //處理子線程拋出的異常
                ex.Handle((x) => x is OperationCanceledException);
            }
            Console.WriteLine("主線程繼續做它的事情");
            Console.ReadKey();
        }

        /// <summary>
        /// 子線程
        /// </summary>
        static void ChildThread(CancellationToken token)
        {
            token.ThrowIfCancellationRequested();
            Console.WriteLine("子線程做完了它的事情");
        }

 

(6)、任務完成時啟動新的任務 ContinueWith

當使用Task進行多線程任務開發時,不建議使用Wait方法或者Result屬性,去阻塞主線程,原因如下:

i、會卡界面

ii、伸縮性好的軟件,不會這么做,除非迫不得已

iii、很有可能創建新的線程,浪費資源(如果主線程執行的足夠快,它可能自己去完成子線程的任務,而不是創建新的線程)

代碼如下:

        static void Main(string[] args)
        {
            //開啟一個子線程進行計算操作
            var watch = Stopwatch.StartNew();
            Task<int> task=Task.Run(() => ChildThreadOne());
            //當子線程一計算完畢之后,開啟一個新的線程去執行輸出子線程一的結果,這里新的線程不會阻塞
            //只有當子線程完成計算輸出后,它才會開啟,並輸出子線程的值
            //所以該程序並不會發生線程阻塞的情況
            task.ContinueWith(x =>
            {
                watch.Stop();
                Console.WriteLine("輸出子線程一的返回值:{0},耗時:{1}", task.Result, watch.ElapsedMilliseconds / 1000);
            });
            
            Console.WriteLine("主線程繼續做它的事情");
            Console.ReadKey();
        }

        /// <summary>
        /// 子線程一
        /// </summary>
        /// <returns></returns>
        static int ChildThreadOne()
        {
            Thread.Sleep(2000);//模擬長時間運算
            return 666;
        }

這里注意兩點:

(1)、這里ContinueWith會檢測到子線程完成之后,立即啟動一個新的線程去顯示結果.不會存在子線程還沒有完成計算的情況下,輸出一個空值,或者發生異常,這一點,CLR能保證.

(2)、這里ContinueWith會返回一個Task對象示例,所以可以調用Wait方法,或者Result屬性,單一般不建議這么做,還是那句話會阻塞線程.一般都忽略這個Task實例,所以需要謹慎使用.

        static void Main(string[] args)
        {
            Task<int> task = Task.Run(() => ChildThreadOne());
            var t1=task.ContinueWith((x) => ChildOneContinueOne(task.Result));
            var t2=task.ContinueWith((x) => ChildOneContinueTwo(task.Result));
            t1.ContinueWith(x => { Console.WriteLine("輸出子線程一的計算結果加10后的結果值:{0}", t1.Result); });
            t2.ContinueWith(x => { Console.WriteLine("輸出子線程一的計算結果乘10后的結果值:{0}", t2.Result); });
            Console.WriteLine("主線程繼續做它的事情");
            Console.ReadKey();
        }

        /// <summary>
        /// 子線程一
        /// </summary>
        /// <returns></returns>
        static int ChildThreadOne()
        {
            Thread.Sleep(2000);//模擬長時間運算
            return 10;
        }

        /// <summary>
        /// 在子線程一完成計算后,開啟一個新的線程對子線程一的結果進行+66操作
        /// </summary>
        /// <param name="childOneResult"></param>
        /// <returns></returns>
        static int ChildOneContinueOne(int childOneResult)
        {
            Console.WriteLine("ChildOneContinueOne線程拿到的子線程一的結果值為{0}", childOneResult);
            Thread.Sleep(2000);//模擬長時間計算任務
            return 10 + childOneResult;
        }

        /// <summary>
        /// 在子線程一完成計算后,開啟一個新的線程對子線程一的結果進行乘66操作
        /// </summary>
        /// <param name="childOneResult"></param>
        /// <returns></returns>
        static int ChildOneContinueTwo(int childOneResult)
        {
            Console.WriteLine("ChildOneContinueTwo線程拿到的子線程一的結果值為{0}", childOneResult);
            Thread.Sleep(2000);//模擬長時間計算任務
            return 10 * childOneResult;
        }

用ContinueWith做了一件有趣的事情,大致思路是我們在開發過程中會遇到,到我們拿到一個線程的返回值后,立即開啟兩個新的線程去做兩個方向的任務,如下圖:

 

這在開發中經常使用,整個過程沒有任務阻塞線程.暫時沒有發現多線程爭用問題.

原理淺析:

Task對象實例包含一個ContinueWith任務的一個集合,所以可以使用Task對象多次調用ContinueWith方法(就像上面的代碼一樣),所有的線程都會進入線程池的隊列中,當Task任務執行完畢,線程池回依次調用它們.

 

(2)、使用ContinueWith中產生的特殊情況

當子線程發生異常、取消、或者超時時,這個時候就要告訴線程池如何處理喚起線程,而不是無視,子線程的異常,所以MS給ContinueWith提供了一個TaskContinuationOptions枚舉,來處理這個問題.下面介紹幾個常用的.

TaskContinuationOptions.OnlyOnRanToCompletion 主要當前面的任務,完美的完成任務,才能執行延續任務.

 class Program
    {
        static void Main(string[] args)
        {
            Task<int> task = Task.Run(() => ChildThreadOne());
            task.ContinueWith(t => Console.WriteLine("子線程一的延續任務,只有在子線程一完美的完成的任務的情況下,才會執行"), TaskContinuationOptions.OnlyOnRanToCompletion);
            Console.WriteLine("主線程繼續執行它的操作");
            Console.ReadKey();//必須加這行代碼,因為Task時線程池線程,屬於后台線程
        }

        /// <summary>
        /// 子線程一
        /// </summary>
        static int ChildThreadOne()
        {
            Thread.Sleep(2000);//模擬執行長時間計算任務
            Console.WriteLine("子線程一完成了計算任務,返回值6");
            return 6;
        }
    }

 

這里,看着,讓子線程一拋出異常,看看延續任務會不會繼續執行.

        static void Main(string[] args)
        {
            Task<int> task = Task.Run(() => ChildThreadOne());
            task.ContinueWith(t => Console.WriteLine("子線程一的延續任務,只有在子線程一完美的完成的任務的情況下,才會執行"), TaskContinuationOptions.OnlyOnRanToCompletion);
            Console.WriteLine("主線程繼續執行它的操作");
            Console.ReadKey();//必須加這行代碼,因為Task時線程池線程,屬於后台線程
        }

        /// <summary>
        /// 子線程一
        /// </summary>
        static int ChildThreadOne()
        {
            Thread.Sleep(2000);//模擬執行長時間計算任務
            Console.WriteLine("子線程一完成了計算任務,返回值6");
            throw new Exception("模擬拋出異常");
        }

因為子線程一拋出了異常,所以延續任務沒有執行.這里取消線程,也不會執行延續任務,因為MS為了區分Task的任務完成和任務取消,選擇讓取消的任務拋出OperationCanceledException異常,所以和拋出簡單一樣,延續任務並不會執行.超時同理.

 

TaskContinuationOptions.OnlyOnFaulted 當前面的任務拋出未處理的異常是,執行延續任務.

        static void Main(string[] args)
        {
            CancellationTokenSource source = new CancellationTokenSource();
            Task<int> task = Task.Run(() => ChildThreadOne(source.Token));
            task.ContinueWith(t =>
            {
                Console.WriteLine("子線程一的延續任務,只有在子線程一拋出了未處理的異常,才會執行,這里嘗試處理拋出的異常");
                //一般記日志,Logger.Error("");
                task.Exception.Handle(x =>
                {
                    Console.WriteLine("最好在這里就處理掉異常,以免讓外部try canth捕獲到,並處理產生的性能損失");
                    if (x is OperationCanceledException)
                    {
                        Console.WriteLine("子線程一拋出了取消異常,異常信息為{0}", x.Message);
                    }
                    else {
                        Console.WriteLine("子線程一拋出了一般異常,異常信息為{0}", x.Message);
                    }

                    return true;//返回true,告訴CLR異常已被處理,這樣外部try catch就捕獲不到了.
                });
            }, TaskContinuationOptions.OnlyOnFaulted);
            Console.WriteLine("主線程繼續執行它的操作");
            source.Cancel();
            Console.ReadKey();//必須加這行代碼,因為Task時線程池線程,屬於后台線程
        }

        /// <summary>
        /// 子線程一
        /// </summary>
        static int ChildThreadOne(CancellationToken cancellation)
        {
            Thread.Sleep(2000);//模擬執行長時間計算任務
            Console.WriteLine("子線程一完成了計算任務,返回值6");
            cancellation.ThrowIfCancellationRequested();//拋出取消異常
            return 6;
        }

這里建議對TaskContinueWith做一個封裝,讓它能處理不同的異常,並且這樣異常,能在內部就被全部處理掉,而不需要在外部進行try catch處理,並且有一個友好的異常記錄和輸出.這里我就不做了,有需要的可以聯系我.

ok,這里就介紹TaskContinuationOptions常用的兩個值,其余的用法都差不多,可以看Ms的提供的備注,或者參看MSDN,這里就不全介紹了.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM