C#基礎知識梳理系列十三:線程之美


摘 要

線程,一般認為只有在“復雜場景”中才會使用,有人對它望而生畏,因為它難以管理和控制,而又總有人對它摩拳擦掌,因為它提高了程序的響應速度。這一章我們來討論Windows對線程的支持、CPU調度、線程開銷、線程池、多線程數據同步等,並且再介紹一點關於異步編程的東西。

第一節 Windwos線程及CPU調度

在我們學習操作系統的時候已經知道:Windows 是一個多線程但並非實時的操作系統。

Windows是在一個進程中運行應用程序的每個實例,基於Windows內核可以運行多個進程實例,Windows為每個進程分配了一個獨立的虛擬地址空間以保證一個進程無法訪問另一個進程的數據,如此一來,不但提高了各進程數據的安全性,也大大提高了系統的容災能力,因為一個進程出現問題而不會影響到其他進程。

線程是一個輕量級的進程,其實在早期的計算機中就只有一個進程同時它也是一個線程,線程是程序執行流的最小單元。Windows允許一個進程可以同時開啟多個線程分別執行不同的任務。目前一個CLR線程對應於一個Windows線程,也就是當你通過.NET語言開啟一個線程時,CLR會向Windows申請一個線程。

CPU是計算機的大腦,這也是我們剛開始學習計算機的時候都會學到的。以前CPU是單核心單線程運行的一個電子模塊,也就是說,在某一時刻段,它只能執行一個計算任務。后來,隨着技術發展,可以在主板上鑲嵌多個CPU,這樣一來,就可以多個CPU同時工作,大大提高了計算能力。Intel出了一種“超線程芯片”的技術,它以欺騙Windows的方式來“運行兩個CPU”組件,其實它是在一個CPU芯片上組裝了兩個計算架構,從硬件角度,它相當於兩個處理器,但是這兩個處理器最終在某一時間段還是只能運行一個計算,它是從硬件上自己管理兩個“邏輯處理器”的切換,從Windows角度,它不知道CPU有兩個工作者“線程”,只是將任務發送給CPU后,CPU在內部虛擬了兩個計算器正在並發運行。再后來就是出現了雙核CPU乃至三核、四核等多核技術,多核是將多個物理的計算核芯整合到一個CPU組件模塊中,這樣多個計算核心可以互不影響地並行工作,大大提高了CPU的計算能力。

CPU對線程是未知的,它只知道對交給它的線程執行計算,Windows系統會有選擇地選擇線程交給CPU來執行,其實我們有時候所說的“CPU調度”,更確切地說法應該是:Windows調度線程交給CPU執行。Windows會為每個線程分配一段時間片,大約30毫秒,當時間片結束時,Windows會暫停當前的線程,再調度另一個線程給CPU,如此一來,系統里的所有線程都可能在很短的時候內得到運行,給人要感覺是所有的線程在並行工作。

 

第二節 線程開銷

Windows對多線程的支持大大提高了應用程序響應速度最終給用戶提供了良好的用戶體驗,但是對線程的使用是有性能損傷的。

系統每次創建及初始化一個線程時,都會創建一個線程內核對象的數據結構,此結構描述了當前線程的相關屬性和線程上下文,這部分大約占據了幾百到數千字節的內存。創建的新線程還包括一個線程環境塊TEB,它占用了一頁內存(4K或8K)。新線程擁有一個棧user mode stack,此棧大約占據了1M的內存。新線程還有一個棧kernel mode stack,當應用程序代碼向操作系統中一個內核模式函數傳遞一個實參時,Windows會將它們從用戶棧復制到內核棧,內核棧大小為12K或24K。以上這些都是在創建一個進程時的空間和時間開銷,當然,還有其他的消耗。下面我們來看一下在線程調度時的工作。

通過前面地描述,我們已經知道Windows是調度線程交給CPU去執行,在暫停一個線程且啟動另一個線程的過程中會有一個線程上下文切換的過程:

(a)    保存當前線程的狀態值到當前線程的上下文結構中;
(b)    從線程集合中取一個新線程;
(c)    將(b)中所選的線程的相關數據加載到CPU寄存器中准備執行。

一個線程得到的時候片大概是30毫秒,線程也可能用不了30毫秒就已經提前結束,時間片到期后,Windows會調度另一個線程,接着又會發生上下文切換。

從以上的空間和時間開銷中可以看到,每一次線程切換都會帶來一定的性能損傷,在(b)步驟中,如果一個線程與前一線程不在同一個進程,則CPU還要查找到另一個進程的地址空間。所以在我們的開發中,盡量保證少開啟線程,Windows系統中的線程越少,則線程得到CPU執行的機會也就越大,線程等待執行的時間也就越短。如果加多CPU核芯,加多CPU數量也會提高系統的性能,這就是為什么服務器一般都擁有N多核的多個CPU,32位的Windows一台機器支持最多32個CPU,64位的Windows支持最多64個CPU。

 

第三節 使用線程

C#中與線程有關的主要類都在命名空間System.Threading中,創建一個線程通常是使用System.Threading.Thread類,其構造函數接受一個必不可少的參數就是線程將要執行的方法,該方法既可以帶參數,也可以不帶參數,如下:

    public class Code_13 : IApp
    {
        public void DoWork()
        {
            //使用無參的方法
            Thread t = new Thread(new ThreadStart(DoThread1));
            t.Start();
            
            
            //使用有參的方法
            for (int i = 0; i < 100; i++)
            {
                Thread t2 = new Thread(new ParameterizedThreadStart(DoThread2));
                t2.IsBackground = true;
                t2.Start(i);
            }
            //t.Abort();
        }
        private void DoThread1()
        {
            Console.WriteLine("DoThread1");
        }
        private void DoThread2(object msg)
        {
            Console.WriteLine("DoThread2:" + msg);
        }
}

程序迅速打印出了0—99的數字,仔細一點還能從任務管理器中看到這個進程的線程先迅速上升到100多,幾秒鍾后又下降到10幾個,這是因為線程執行完后會自動退出。

初始化完線程對象,必須調用Start()方法啟動線程。線程執行完成后會自動退出,如果想提前終止一個線程,可以調用t.Abort()方法,但在調用此方法的線程上會拋出ThreadAbortException異常。

通常對於一個GUI應用程序來說,還有前台線程和后台線程,如果要在UI線程上啟動一個后台線程,可以設置線程的屬性:IsBackground為true即可,一個線程可以前后台狀態可以隨時轉。一般我們是將有大量進行非人工參與的計算線程設置為后台線程,以防止它在前台線程阻塞時凍結窗口。

 

第四節 異步編程

一個線程由於耗時計算而假死是我們最不願意看到的現象,因為阻塞,程序不能即時運行阻塞處以下的代碼,如果出現在UI上,還會導致UI被凍結,無法操作。那有沒有辦法來解決這一問題呢?當然有,那就是“異步模型”!它可以提供可響應高健壯性的應用。

異步編程模型(APM) 就是一個線程給耗時請求操作一個回調方法,發出操作請求后,立即返回,不用等待耗時操作,立即執行下面的代碼,當耗時操作完成后,CLR的一個線程池線程來執行回調方法,這確是一種美好的體驗!也不用被客戶罵我們的程序“死了”。CLR提供了很多類似BeginXXX和EndXXX的方法供異常編程模型使用,如FileStream的BeginRead和EndRead方法、HttpWebRequest的BeginGetResponse方法和EndGetResponse方法等。下面是HttpWebRequest的使用示例:

        private void TestHttpRequest()
        {
            HttpWebRequest req = WebRequest.Create("http://www.cnblogs.com/solan") as HttpWebRequest;
            req.BeginGetResponse(new AsyncCallback(ResponseCallback), req);
        }
        private void ResponseCallback(IAsyncResult ia)
        {
            HttpWebRequest req = ia.AsyncState as HttpWebRequest;
            HttpWebResponse res = req.EndGetResponse(ia) as HttpWebResponse;
            Console.WriteLine(res.ContentLength);
        }

通常只要調用了BeginXXX方法,就應該在合適的時候調用對應的EndXXX方法,否則可能會發生內存泄露,因為從初始化異步操作后到調用EndXXX方法前CLR將一直保持着與此步常操作對象相關的資源,只有調用了EndXXX方法,這些資源才得以釋放。基於Begin/End的異步操作模型是無法取消的,因為一旦發出異步操作請求,那請求對象就像脫韁的野馬不受控制,只能等它干完它干的事才會回來,當然可以在End取到結果后丟棄它來欺騙我們自己。

在有些開發環境下必須使用異步操作,比如silverlight訪問服務必須以異步的形式訪問,這就給開發帶來了麻煩,如果一個操作會多次調用服務且這些服務有先后順序要求,則更麻煩。幸好在.NET Framework4.5里已經對異步特性進行了增強,如async、await,關於這部分特性,請參考相關資料。

 

第五節 線程池

開啟一個線程是如此簡單,但是在前面我們已經說過,線程的開銷是相當大的,我們應該盡量避免開啟新線程,如果我們的項目要求必須使用多線程來達到性能提升的目的呢?使用線程池!

線程池是CLR管理的一個線程集合,每個CLR都有一個自己的線程池,CLR初始化時線程池是空的,可能通過一個方法將異步操作注入線程池,CLR會調一個線程池線程(如果有,否則創建線程)來執行線程池里的方法,當方法執行完后,線程回到線程池而不銷毀,等待下一次被使用,這里跟我們在做數據庫開發時的連接池相似。線程池的線程經過一次創建,被重復使用,所以從一定程度上提高了程序的性能。如果向線程池中注入的任務數大於線程池的可用線程數,則CLR會創建更多的線程池線程來接收任務,當線程池中有大量閑置的線程時,線程池內的線程會自動結束自己的生命來釋放資源。

線程池使用System.Threading.ThreadPool類,向線程池中添加一個異步操作非常簡單,有兩個靜態方法:

        public static bool QueueUserWorkItem(WaitCallback callBack);
        public static bool QueueUserWorkItem(WaitCallback callBack, object state);

該靜態方法必須接收一個將要執行的方法,也可以向該方法傳遞待執行方法所用的數據。如下代碼是向線程池添加新任務:

            ThreadPool.QueueUserWorkItem(new WaitCallback(DoThread2));
            ThreadPool.QueueUserWorkItem(new WaitCallback(DoThread2), "QueueUserWorkItem");

線程池如果有閑置線程,會拉出閑置線程讓它去執行DoThread2這個方法,如果沒有,則創建新的線程。

既然是池,它就有一個容量,當然可以設置線程池的最大活躍線程,調用方法ThreadPool.SetMaxThreads設置即可。假如設置了線程池最大線程數是100,且當前這100個線程都在忙,如果此時再向線程池注冊任務,則會排隊,只有這100個線程中有閑置的時候才會執行新注冊的任務。目前CLR默認線程池可以擁有1000個線程,建議不要修改這個值,讓CLR自動管理吧,畢竟微軟已經為線程池做了大量優化。

對於注冊到線程池里的任務,至於它什么時候完成,我們是無法得知,它適合一些“發出請求,不再處理結果”的情景中,如果我既想用線程池,又想得到執行結果怎么辦呢?使用Task!Task允許一個任務啟動后,發出等待,等到任務完成后,我們可以拿到任務的處理結果了。關於任務Task的使用,可以參考MSDN文檔。

有了線程池和異步模型,在構建高並發高可用性的系統就非常方便了,尤其是在開發服務程序時,當然這只是基礎,至於如何應用,還是要我在日常開發中總結經驗。我們還需要繼續努力!

 

第七節 數據同步

如果一個應用程序只有一個線程,那它能非常安全完好地運行,如果多於一個線程,則有可能多個線程同時訪問一個資源。比如在我們平時開發中對登錄用戶狀態數據的管理,為了使這個狀態數據共用,通常將其設為靜態公共的變量,可能有多個線程都去訪問這個變量,這時就可能會出現狀態被“非正常”更改。這就要求每個線程在訪問這個公共變量前必須加鎖以保證某個時間段只有一個線程對其訪問。

我們常用的鎖就是lock語句,加鎖只能對引用類型的對象進行加鎖。其他加鎖也可以使用Monitor、Mutex、EventWaitHandle等,可以參考相關資料。這里我們說一下開發中很常用的雙檢鎖技術。

雙檢鎖(Double Check Locking),開發人員為了保證在一個應用程序的生命周期中某一類型只有一個實例,並且只有對其進行請求時,才構造該對象,如果一直沒對其請求,則其永遠不會被構造。這樣既保證了它的唯一性,也保證了它的延時構造。這也被稱為單件模式。之所以要進行二次檢查,就是為了防止多線程訪問的情況下出現對象被構造多次的可能。如下代碼是用戶公共信息的單件實現:

    public class UserInfo
    {
        static object obj = new object();
        static UserInfo _singleton = null;
        public static UserInfo Singleton
        {
            get
            {
                //第一次檢測
                if (_singleton == null)
                {
                    //注意:在鎖定前,這里可能會有多個線程執行到此
                    //對象還沒有構造,這里線程獲得一個獨占鎖
                    lock (obj)
                    {
                        //加鎖后再進行第二次檢測,以防止在第一次檢測后——加鎖前這一時間段構造_singleton對象。
                        if (_singleton == null)
                        {
                            _singleton = new UserInfo();
                        }
                    }
                }
                return _singleton;
            }
        }

這就能保證_singleton全局的唯一性。但是以上的代碼是在_singleton對象可能在程序中被用到也可能不被用到,且構造它要占用很多資源時,是一個很好的方法,其實有時如果構造對象只耗費了一點點資源,完全可以像下面這樣用,達到同樣的效果:

        public class UserInfo
        {
            static UserInfo _singleton = new UserInfo();
            public static UserInfo Singleton
            {
                get { return _singleton; }
            }
        }

這樣在第一次訪問UserInfo類的時候就構造該類的對象,方便。

 

小 結


免責聲明!

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



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