關於線程的知識點其實是很多的,比如多線程編程、線程上下文、異步編程、線程同步構造、GUI的跨線程訪問等等,本文只是從常見面試題的角度(也是開發過程中常用)去深入淺出線程相關的知識。如果想要系統的學習多線程,沒有捷徑的,也不要偷懶,還是去看專業書籍的比較好。
1. 描述線程與進程的區別?
2. 為什麼GUI不支持跨線程訪問控件?一般如何解決這個問題?
3. 簡述後台線程和前台線程的區別?
4. 說說常用的鎖,lock是一種什麼樣的鎖?
5. lock為什麼要鎖定一個參數,可不可鎖定一個值類型?這個參數有什麼要求?
6. 多線程和異步有什麼關系和區別?
7. 線程池的優點有哪些?又有哪些不足?
8. Mutex和lock有何不同?一般用哪一個作為鎖使用更好?
9. 下面的代碼,調用方法DeadLockTest(20),是否會引起死鎖?並說明理由。
public void DeadLockTest(int i) { lock (this) //或者lock一個靜態object變量 { if (i > 10) { Console.WriteLine(i--); DeadLockTest(i); } } }
10. 用雙檢鎖實現一個單例模式Singleton。
11.下面代碼輸出結果是什麼?為什麼?如何改進她?
int a = 0; System.Threading.Tasks.Parallel.For(0, 100000, (i) => { a++; }); Console.Write(a);
補充一句,CLR線程是直接對應於一個Windows線程的。
還記得以前學校裡學習計算機課程裡講到,計算機的核心計算資源就是CPU核心和CPU寄存器,這也就是線程運行的主要戰場。操作系統中那麼多線程(一般都有上千個線程,大部分都處於休眠狀態),對於單核CPU,一次只能有一個線程被調度執行,那麼多線程怎麼分配的呢?Windows系統采用時間輪詢機制,CPU計算資源以時間片(大約30ms)的形式分配給執行線程。
計算雞資源(CPU核心和CPU寄存器)一次只能調度一個線程,具體的調度流程:
上面線程調度的過程,就是一次線程切換,一次切換就涉及到線程上下文等數據的搬入搬出,性能開銷是很大的。因此線程不可濫用,線程的創建和消費也是很昂貴的,這也是為什麼建議盡量使用線程池的一個主要原因。
對於Thread的使用太簡單了,這裡就不重復了,總結一下線程的主要幾點性能影響:
當然現在硬件的發展,CPU的核心越來越多,多線程技術可以極大提高應用程序的效率。但這也必須在合理利用多線程技術的前提下,了線程的基本原理,然後根據實際需求,還要注意相關資源環境,如磁盤IO、網絡等情況綜合考慮。
單線程的使用這裡就略過了,那太easy了。上面總結了線程的諸多不足,因此微軟提供了可供多線程編程的各種技術,如線程池、任務、並行等等。
每個CLR都有一個線程池,線程池在CLR內可以多個AppDomain共享,線程池是CLR內部管理的一個線程集合,初始是沒有線程的,在需要的時候才會創建。線程池的主要結構圖如下圖所示,基本流程如下:
線程池是有一個容量的,因為他是一個池子嘛,可以設置線程池的最大活躍線程數,調用方法ThreadPool.SetMaxThreads可以設置相關參數。但很多編程實踐裡都不建議程序猿們自己去設置這些參數,其實微軟為了提高線程池性能,做了大量的優化,線程池可以很智能的確定是否要創建或是消費線程,大多數情況都可以滿足需求了。
線程池使得線程可以充分有效地被利用,減少了任務啟動的延遲,也不用大量的去創建線程,避免了大量線程的創建和銷毀對性能的極大影響。
上面了解了線程的基本原理和諸多優點後,如果你是一個愛思考的猿類,應該會很容易發現很多疑問,比如把任務添加到線程池隊列後,怎麼取消或掛起呢?如何知道她執行完了呢?下面來總結一下線程池的不足:
因此微軟為我們提供了另外一個東西叫做Task來補充線程池的某些不足。
並行Parallel內部其實使用的是Task對象(TPL會在內部創建System.Threading.Tasks.Task的實例),所有並行任務完成後才會返回。少量短時間任務建議就不要使用並行Parallel了,並行Parallel本身也是有性能開銷的,而且還要進行並行任務調度、創建調用方法的委托等等。
因為Windows是基於消息機制的,我們在UI上所有的鍵盤、鼠標操作都是以消息的形式發送給各個應用程序的。GUI線程內部就有一個消息隊列,GUI線程不斷的循環處理這些消息,並根據消息更新UI的呈現。如果這個時候,你讓GUI線程去處理一個耗時的操作(比如花10秒去下載一個文件),那GUI線程就沒辦法處理消息隊列了,UI界面就處於假死的狀態。
//1.Winform:Invoke方法和BeginInvoke this.label.Invoke(method, null); //2.WPF:Dispatcher.Invoke this.label.Dispatcher.Invoke(method, null);
② 使用.NET中提供的BackgroundWorker執行耗時計算操作,在其任務完成事件RunWorkerCompleted 中更新UI控件
using (BackgroundWorker bw = new BackgroundWorker()) { bw.RunWorkerCompleted += new RunWorkerCompletedEventHandler((ojb,arg) => { this.label.Text = "anidng"; }); bw.RunWorkerAsync(); }
③ 看上去很高大上的方法:使用GUI線程處理模型的同步上下文來送封UI控件修改操作,這樣可以不需要調用UI控件元素
.NET中提供一個用於同步上下文的類SynchronizationContext,利用它可以把應用程序模型鏈接到他的線程處理模型,其實它的本質還是調用的第一步①中的方法。
實現代碼分為三步,第一步定義一個靜態類,用於GUI線程的UI元素訪問封裝:
public static class GUIThreadHelper { public static System.Threading.SynchronizationContext GUISyncContext { get { return _GUISyncContext; } set { _GUISyncContext = value; } } private static System.Threading.SynchronizationContext _GUISyncContext = System.Threading.SynchronizationContext.Current; /// <summary> /// 主要用於GUI線程的同步回調 /// </summary> /// <param name="callback"></param> public static void SyncContextCallback(Action callback) { if (callback == null) return; if (GUISyncContext == null) { callback(); return; } GUISyncContext.Post(result => callback(), null); } /// <summary> /// 支持APM異步編程模型的GUI線程的同步回調 /// </summary> public static AsyncCallback SyncContextCallback(AsyncCallback callback) { if (callback == null) return callback; if (GUISyncContext == null) return callback; return asynresult => GUISyncContext.Post(result => callback(result as IAsyncResult), asynresult); } }
第二步,在主窗口注冊當前SynchronizationContext:
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); CLRTest.ConsoleTest.GUIThreadHelper.GUISyncContext = System.Threading.SynchronizationContext.Current; }
第三步,就是使用了,可以在任何地方使用
GUIThreadHelper.SyncContextCallback(() => { this.txtMessage.Text = res.ToString(); this.btnTest.Content = "DoTest"; this.btnTest.IsEnabled = true; });
多線程編程中很常用、也很重要的一點就是線程同步問題,掌握線程同步對臨界資源正確使用、線程性能有至關重要的作用!基本思路是很簡單的,就是加鎖嘛,在臨界資源的門口加一把鎖,來控制多個線程對臨界資源的訪問。但在實際開發中,根據資源類型不同、線程訪問方式的不同,有多種鎖的方式或控制機制(基元用戶模式構造和基元內核模式構造)。.NET提供了兩種線程同步的構造模式,需要理解其基本原理和使用方式。
基元線程同步構造分為:基元用戶模式構造和基元內核模式構造,兩種同步構造方式各有優缺點,而混合構造(如lock)就是綜合兩種構造模式的優點。
缺點有沒有發現?線程2會一直使用CPU時間(假如當前系統只有這兩個線程在運行),也就意味著不僅浪費了CPU時間,而且還會有頻繁的線程上下文切換,對性能影響是很嚴重的。
當然她的優點是效率高,適合哪種對資源占用時間很短的線程同步。.NET中為我們提供了兩種原子性操作,利用原子操作可以實現一些簡單的用戶模式鎖(如自旋鎖)。
System.Threading.Interlocked:易失構造,它在包含一個簡單數據類型的變量上執行原子性的讀或寫操作。
Thread.VolatileRead 和 Thread.VolatileWrite:互鎖構造,它在包含一個簡單數據類型的變量上執行原子性的讀和寫操作。
以上兩種原子性操作的具體內涵這裡就細說了(有興趣可以去研究文末給出的參考書籍或資料),針對題目11,來看一下題目代碼:
int a = 0; System.Threading.Tasks.Parallel.For(0, 100000, (i) => { a++; }); Console.Write(a);
上面代碼是通過並行(多線程)來更新共享變量a的值,結果肯定是小於等於100000的,具體多少是不穩定的。解決方法,可以使用我們常用的Lock,還有更有效的就是使用System.Threading.Interlocked提供的原子性操作,保證對a的值操作每一次都是原子性的:
System.Threading.Interlocked.Add(ref a, 1);//正確
下面的圖是一個簡單的性能驗證測試,分別使用Interlocked、不用鎖、使用lock鎖三種方式來測試。不用鎖的結果是95,這答案肯定不是你想要的,另外兩種結果都是對的,性能差別卻很大。
System.Threading.Tasks.Parallel.For(0, 100, (i) => { lock (_obj) { a++; //不正確 Thread.Sleep(20); } });
看上去是不是非常棒!徹底解決了用戶模式構造的缺點,但內核模式也有缺點的:將線程從用戶模式切換到內核模式(或相反)導致巨大性能損失。調用線程將從托管代碼轉換為內核代碼,再轉回來,會浪費大量CPU時間,同時還伴隨著線程上下文切換,因此盡量不要讓線程從用戶模式轉到內核模式。
她的優點就是阻塞線程,不浪費CPU時間,適合那種需要長時間占用資源的線程同步。
內核模式構造的主要有兩種方式,以及基於這兩種方式的常見的鎖:
常用的混合鎖還不少呢!如SemaphoreSlim、ManualResetEventSlim、Monitor、ReadWriteLockSlim,這些鎖各有特點和鎖使用的場景。這裡主要就使用最多的lock來詳細了解下。
lock的本質就是使用的Monitor,lock只是一種簡化的語法形式,實質的語法形式如下:
bool lockTaken = false; try { Monitor.Enter(obj, ref lockTaken); //... } finally { if (lockTaken) Monitor.Exit(obj); }
那lock或Monitor需要鎖定的那個對象是什麼呢?注意這個對象才是鎖的關鍵,在此之前,需要先回顧一下引用對象的同步索引塊(AsynBlockIndex),這是前面文章中提到過的引用對象的標准配置之一(還有一個是類型對象指針TypeHandle),它的作用就在這裡了。
同步索引塊是.NET中解決對象同步問題的基本機制,該機制為每個堆內的對象(即引用類型對象實例)分配一個同步索引,她其實是一個地址指針,初始值為-1不指向任何地址。
首先還是盡量避免線程同步,不管使用什麼方式都有不小的性能損失。一般情況下,大多使用Lock,這個鎖是比較綜合的,適應大部分場景。在性能要求高的地方,或者根據不同的使用場景,可以選擇更符合要求的鎖。
在使用Lock時,關鍵點就是鎖對象了,需要注意以下幾個方面:
因為GUI應用程序引入了一個特殊的線程處理模型,為了保證UI控件的線程安全,這個線程處理模型不允許其他子線程跨線程訪問UI元素。解決方法還是比較多的,如:
上面幾個方式在文中已詳細給出。
應用程序必須運行完所有的前台線程才可以退出,或者主動結束前台線程,不管後台線程是否還在運行,應用程序都會結束;而對於後台線程,應用程序則可以不考慮其是否已經運行完畢而直接退出,所有的後台線程在應用程序退出時都會自動結束。
通過將 Thread.IsBackground 設置為 true,就可以將線程指定為後台線程,主線程就是一個前台線程。
常用的如如SemaphoreSlim、ManualResetEventSlim、Monitor、ReadWriteLockSlim,lock是一個混合鎖,其實質是Monitor['mɒnɪtə]。
lock的鎖對象要求為一個引用類型。她可以鎖定值類型,但值類型會被裝箱,每次裝箱後的對象都不一樣,會導致鎖定無效。
對於lock鎖,鎖定的這個對象參數才是關鍵,這個參數的同步索引塊指針會指向一個真正的鎖(同步塊),這個鎖(同步塊)會被復用。
多線程是實現異步的主要方式之一,異步並不等同於多線程。實現異步的方式還有很多,比如利用硬件的特性、使用進程或纖程等。在.NET中就有很多的異步編程支持,比如很多地方都有Begin***、End***的方法,就是一種異步編程支持,她內部有些是利用多線程,有些是利用硬件的特性來實現的異步編程。
優點:減小線程創建和銷毀的開銷,可以復用線程;也從而減少了線程上下文切換的性能損失;在GC回收時,較少的線程更有利於GC的回收效率。
缺點:線程池無法對一個線程有更多的精確的控制,如了解其運行狀態等;不能設置線程的優先級;加入到線程池的任務(方法)不能有返回值;對於需要長期運行的任務就不適合線程池。
Mutex是一個基於內核模式的互斥鎖,支持鎖的遞歸調用,而Lock是一個混合鎖,一般建議使用Lock更好,因為lock的性能更好。
9. 下面的代碼,調用方法DeadLockTest(20),是否會引起死鎖?並說明理由。
public void DeadLockTest(int i) { lock (this) //或者lock一個靜態object變量 { if (i > 10) { Console.WriteLine(i--); DeadLockTest(i); } } }
不會的,因為lock是一個混合鎖,支持鎖的遞歸調用,如果你使用一個ManualResetEvent或AutoResetEvent可能就會發生死鎖。
public static class Singleton<T> where T : class,new() { private static T _Instance; private static object _lockObj = new object(); /// <summary> /// 獲取單例對象的實例 /// </summary> public static T GetInstance() { if (_Instance != null) return _Instance; lock (_lockObj) { if (_Instance == null) { var temp = Activator.CreateInstance<T>(); System.Threading.Interlocked.Exchange(ref _Instance, temp); } } return _Instance; } }
int a = 0; System.Threading.Tasks.Parallel.For(0, 100000, (i) => { a++; }); Console.Write(a);
輸出結果不穩定,小於等於100000。因為多線程訪問,沒有使用鎖機制,會導致有更新丟失。具體原因和改進在文中已經詳細的給出了。
版權所有,文章來源:http://www.cnblogs.com/anding
個人能力有限,本文內容僅供學習、探討,歡迎指正、交流。
.NET面試題解析(00)-開篇來談談面試 & 系列文章索引
書籍:CLR via C#
書籍:你必須知道的.NET
.NET基礎拾遺(5)多線程開發基礎
歸納一下:C#線程同步的幾種方法
C#並行編程-相關概念
多線程之旅七——GUI線程模型,消息的投遞(post)與處理(IOS開發前傳)
C# 溫故而知新: 線程篇(一)