越是資深的程式設計師,對於運用到 Multi-Threading (多執行緒)的程式就越加謹慎,深怕一不小心就會埋下難以查覺的 BUG ,使用多執行緒就像是面對數匹脫疆野馬,一旦牠們開始跑後,你就很難控制牠們的走向,有些會順利到達終點,有些則會在途中出現意外。
The Parallel Programming Of .NET Framework 4.0(1) - Beginning
文/黃忠成
Multi-Threading Programming
越是資深的程式設計師,對於運用到 Multi-Threading (多執行緒)的程式就越加謹慎,深怕一不小心就會埋下難以查覺的 BUG ,使用多執行緒就像是面對數匹脫疆野馬,一旦牠們開始跑後,你就很難控制牠們的走向,有些會順利到達終點,有些則會在途中出現意外。
但不好控制不能成為不用的理由,現今的作業系統均以【多工】做為主軸,如果程式沒有跟上,持續維持在單工模式,那麼難免會被排上【此程式無回應】的酸梅排行榜,因為同時間做很多事的習慣,已經隨著多工系統的普及化深植於人心了。
.NET Framework設計師在這個領域裡,算是相當幸運的族群,在 C#、VB.NET 中建立執行緒是相當簡單且直覺的, .NET Framework 成功的將原本複雜難以理解的Thread建立動作簡化,只需不到10行的程式,就能夠把一個工作丟到Thread裡去執行,令其不致影響主程式的運作,下面是一個簡單的以Windows Form來示範Thread應用的例子。
using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Windows.Forms; using System.Threading; namespace WindowsFormsApplication2 { public partial class Form1 : Form { private List<int> _buffs = new List<int>(); public Form1() { InitializeComponent(); } private void Work1() { for (int i = 0; i < 10000; i++) _buffs.Add(i); } private void button1_Click(object sender, EventArgs e) { Thread th = new Thread(new ThreadStart(Work1)); th.IsBackground = true; th.Start(); } } } |
當使用者按下按鈕後,程式便建立一個Thread物件,然後將Work1做為ThreadStart delegate傳入,在呼叫Thread的Start函式後,.NET Framework便會啟動一個Thread,然後於該Thread中執行Work1,而執行Work1函式的過程中也不會阻檔到主程式的運行。
當IsBackground被設為True,則是告訴CLR此Thread是背景運作,不管其是否完全執行結束,只要主程式結束,那麼此Thread也要被強制結束,簡言之,當將Thread的IsBackground設為False時,當主程式結束時,會持續等待此Thread結束,程式才會完全關閉,此時程式會處於無回應狀態,這通常用於需要保證Thread必須完全執行完畢的狀態下。
舉個例子來說,當你將日結作業排入Thread後,自然不希望使用者按下結束程式後,整個日結作業在未完成下被系統強制結束,此時應該將IsBackground設為False(這是預設值),然後小心的控制程式離開的流程。倘若Thread中執行的工作是可中斷型的,例如一個日報表的產生動作,此Thread的IsBackground應該被設為True,因為日報表的產生動作是可中斷性的,當使用者於此情況下按下離開後,此Thread應該可被強制結束,頂多下次再重新產生日報表即可。
不過在實務上,我們多半會將IsBackground設為False,然後自己來處理Thread的結束過程,以防止因Thread被系統強制結束後帶來的後遺症,簡略的說,就是在Work1中探測使用者是否關閉了主程式(通常是於Form的Closed事件中設旗標,然後於Work1中探測此旗標的值),是的話就跳出函式,結束Thread的執行。
上個例子是很簡單的Thread應用,也是.NET Framework建立Thread的方式,不過從C# 2.0開始,我們有了另一種建立Thread的寫法,比起以往的寫法更直覺、更簡單。
private void button1_Click(object sender, EventArgs e) { List<int> buffs = new List<int>(); Thread th = new Thread(delegate() { for (int i = 0; i < 10000; i++) buffs.Add(i); }); th.IsBackground = true; th.Start(); } |
在Anonymous delegate的協助下,程式碼變的更簡單了。但較細心的讀者應該發現到一個問題,那就是buffs是被宣告成區域變數的,區域變數在未被參考的情況下,應該在函式結束後清除,那麼本例難道不會發生buffs過早被結束的問題嗎?的確,從表象看來是如此,但別忘了C# 的二階段編譯手法,這段程式碼會被編為下面這樣。
private void button1_Click(object sender, EventArgs e) { <>c__DisplayClass1 CS$<>8__locals2 = new <>c__DisplayClass1(); CS$<>8__locals2.buffs = new List<int>(); Thread th = new Thread(new ThreadStart(CS$<>8__locals2.<button1_Click>b__0)); th.IsBackground = true; th.Start(); } |
Anonymous delegate部份被展開成為一個類別,其內所用到的外部區域變數也被展開成為該類別的類別變數,所以buffs不會被清空,因為它被此類別物件的類別變數所參考,只有在此類別物件被清除時,buffs才會被清除,也就是在Thread執行完畢後,所以此例寫法是安全的。
在C# 3.0中,Lambda Expression的寫法就更加簡潔了,效果與使用Anonymous delegate相同。
private void button1_Click(object sender, EventArgs e) { List<int> buffs = new List<int>(); Thread th = new Thread(()=> { for (int i = 0; i < 10000; i++) buffs.Add(i); }); th.IsBackground = true; th.Start(); } |
Thread-safe Operations
多執行緒之所以難控制,不外乎其執行的時間不定、到達終點的時間不定、及最重要的同步鎖定課題,以上面的例子而言,如果改成用兩個執行緒來跑,那麼同步鎖定的問題就會浮現出來。
private void button1_Click(object sender, EventArgs e) { List<int> buffs = new List<int>(); Thread th = new Thread(()=> { for (int i = 0; i < 5000; i++) buffs.Add(i); }); Thread th1 = new Thread(() => { for (int i = 0; i < 5000; i++) buffs.Add(i); }); th.IsBackground = true; th.Start(); th1.IsBackground = true; th1.Start(); } |
很明顯的,我們在兩個執行緒中都對buffs進行新增動作,但先天上,.NET Framework 4之前內建的Collections都不是Thread-Safe的,所以這個程式會拋出奇特、且不固定型態的例外,而從這些例外本身是很難看出問題的。
圖1
問題的原因很簡單,在單CPU或單核的電腦上,兩個執行緒以極快的速度切換執行,很有可能Thread 1正處於Add的中間段,但未完成Add動作時,CPU就將執行權切給Thread 2,造成Collections的狀態出現落差。要解決這個問題,就必須在存取需共同存取之變數時,加上鎖定機制。
private void button1_Click(object sender, EventArgs e) { List<int> buffs = new List<int>(); Thread th = new Thread(()=> { for (int i = 0; i < 10000; i++) { lock (buffs) { buffs.Add(i); } } }); Thread th1 = new Thread(() => { for (int i = 0; i < 10000; i++) { lock (buffs) { buffs.Add(i); } } }); th.IsBackground = true; th.Start(); th1.IsBackground = true; th1.Start(); } |
lock是C#的特殊指令,會被展開成以下的樣子。
for (int i = 0; i < 10000; i++) { Monitor.Enter(buffs); try { buffs.Add(i); } finally { Monitor.Exit(buffs); } } |
意思是,當某一Thread執行完Monitor.Enter後,如果另一個Thread再執行Monitor.Enter,且傳入的是同一個物件,那麼第二個Monitor.Enter就會停住,直到第一個Thread呼叫了Monitor.Exit後才會繼續執行,我們以此手法來確認,同一時間只會有一個Thread呼叫Collection的Add函式。
.NET Framework 提供了三種同步鎖定機制,Monitor是最常用的一種,第二種是Mutex,Mutex的用法與Monitor差不多,唯一不同之處是其可以跨越行程來進行同步鎖定,也就是說你可以有兩個程式共同使用一個設定了同一名稱的Mutex,當第一個程式呼叫了Mutex的WaitOne進入鎖定狀態時,第二個程式的WaitOne就會停住直到第一個程式呼叫了m.ReleaseMutex為止,下面是一個簡單的示範例子。
ConsoleApplication4.exe |
Mutex m = new Mutex(true, "MyMutex"); Thread th = new Thread(() => { for (int i = 0; i < 100000; i++) { m.WaitOne(); try { Console.WriteLine(i.ToString()); } finally { m.ReleaseMutex(); } Thread.Sleep(1000); } }); th.IsBackground = true; th.Start(); m.ReleaseMutex(); Console.ReadLine(); |
ConsoleApplication5.exe |
static void Main(string[] args) { Mutex m = new Mutex(false, "MyMutex"); Thread th = new Thread(() => { for (int i = 0; i < 100000; i++) { m.WaitOne(); try { Console.WriteLine("P2: " + i.ToString()); } finally { //block process 1. //m.ReleaseMutex(); } Thread.Sleep(1000); } }); th.IsBackground = true; th.Start(); Console.ReadLine(); } |
當ConsoleApplication4執行時,你會見到其規律的列出數字來,但當ConsoleApplication5執行時,你會發現ConsoleApplication4將不再列出數字,原因在於ConsoleApplication5呼叫了WaitOne來取得鎖定權,但是沒有呼叫ReleaseMutex來釋放鎖定權,所以ConsoleApplication4會一直在WaitOne區段等待,這證明了Mutex確實可以跨越行程來執行同步鎖定。
那麼我可以把Mutex用在同一行程裡嗎?當然可以,不過殺雞何必用牛刀呢?Mutex可以跨越行程的主因在於其是系統資源,所以比起Monitor來說,Mutex高價的多了,這就類似於把lock放在迴圈裡跟放在迴圈外的差別,兩者都可以完整的控制同步鎖定區段,但前者是在最小區塊下鎖定,而後者則是無差別式鎖定,熟優熟劣立見。
第三種同步鎖定機制是Semaphore,這個機制比較特別,它允許我們設定可同時得到鎖定權的數量,例如下面的程式。
_pool = new Semaphore(0, 3); |
其第二個參數就是此Semaphore可同時鎖定的數量,以此例來說,可以有三個Thread呼叫_pool.WaitOne來進入鎖定區段,此時這三個Thread都會正常執行,但倘若在這三個Thread都未呼叫_pool.Release的情況下,有第四個Thread呼叫了WaitOne,那麼第四個Thread會停住直到其它三個Thread中某個呼叫了Release函式才會繼續執行。下面是一個簡單的範例。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; namespace ConsoleApplication6 { class Program { static void Main(string[] args) { Semaphore sp = new Semaphore(0, 3); Thread th = new Thread((state) => { for (int i = 0; i < 100000; i++) { sp.WaitOne(); Console.WriteLine(state.ToString() + i.ToString()); Thread.Sleep(1000); } }); Thread th1 = new Thread((state) => { for (int i = 0; i < 100000; i++) { sp.WaitOne(); Console.WriteLine(state.ToString() + i.ToString()); Thread.Sleep(1000); } }); Thread th2 = new Thread((state) => { for (int i = 0; i < 100000; i++) { sp.WaitOne(); Console.WriteLine(state.ToString() + i.ToString()); Thread.Sleep(1000); } }); Thread th3 = new Thread((state) => { for (int i = 0; i < 100000; i++) { Thread.Sleep(4000); sp.Release(); } }); th.IsBackground = true; th1.IsBackground = true; th2.IsBackground = true; th3.IsBackground = true; th.Start("Thread 1 : "); th1.Start("Thread 2 : "); th2.Start("Thread 3 : "); th3.Start("Thread 4 : "); Console.ReadLine(); } } } |
執行時讀者可發現,Thread 1,2,3會以間隔約4秒左右同時執行,這展示了Semaphore可同時讓多個Thread進入鎖定區段的機制,特別注意一點,Semaphore是以數字來控制已進入鎖定區段的數量,當呼叫WaitOne函式後,該數字會遞增,當呼叫Release函式後該數字會遞減,連續呼叫WaitOne意味著連續遞增該數字,不管是否是同一Thread,所以假如你將上例的th3的Start移掉,然後加上sp.Release(3)(此動作可視為將Semaphore內的數字歸零),那麼這個程式只會列出三個數字,原因是前3個Thread呼叫了WaitOne後取得鎖定權,但卻沒有人呼叫Release。
Th.IsBackground = true; th1.IsBackground = true; th2.IsBackground = true; th3.IsBackground = true; th.Start("Thread 1 : "); th1.Start("Thread 2 : "); th2.Start("Thread 3 : "); sp.Release(3); //th3.Start("Thread 4 : "); |
就一般應用而言,用的最廣泛的應該是Monitor了,在撰寫多執行緒應用程式時,多少一定會用到此物件,所以熟悉它,且了解如何進行最少鎖定是主要課題,鎖定的區塊多寡直接影響到程式的執行效率。
Wait Handle
鎖定機制是用於當多個執行緒同時需存取同一變數時,但有時候,我們只希望讓程式等待某一個事件產生後才繼續執行,在該事件未發生時,就讓程式處於等待狀態。在使用Thread時,如果要讓主程式等待該Thread結束後再繼續執行,可以透過呼叫Join函式來完成,但這也只能用在讓主程式等待該執行緒完成這件事上而已,倘若一個執行緒需要完成三階段的工作,而主程式需要在其每一階段完成時顯示訊息,那麼Join就完全不適用了,如下列程式碼:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; namespace ConsoleApplication2 { class Program { static void Main(string[] args) { int val1 = 0, val2 = 0, val3 = 0; Thread t1 = new Thread(() => { for (int i = 0; i < 10; i++) val1 += i; //show mesage from main thread part 1. for (int i = 0; i < 10; i++) val2 += i * 2; //show mesage from main thread part 2. for (int i = 0; i < 10; i++) val3 += i * 3; //show mesage from main thread part 2. }); t1.Start(); // ??????? } } } |
很明顯的,此程式需要在執行緒每完成一個迴圈時,由主執行緒來顯示訊息,這點無法透過Join函式達到,當然!我們可以直接在執行緒裡做這件事,只是這麼做之後,主程式的訊息顯示會穿插執行緒的訊息顯示,形成無法預測的情況。
.NET Framework提供了Wait Handle機制來處理此類情況,Wait Handle其實就像是紅綠燈,當設定為紅燈時(False),所有使用該Wait Handle的執行緒都會停滯,直到有人把紅燈切換成綠燈後才會繼續執行。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; namespace ConsoleApplication2 { class Program { static void Main(string[] args) { int val1 = 0, val2 = 0, val3 = 0; ManualResetEvent mr1 = new ManualResetEvent(false); ManualResetEvent mr2 = new ManualResetEvent(false); ManualResetEvent mr3 = new ManualResetEvent(false); ManualResetEvent mrOuter = new ManualResetEvent(false); Thread t1 = new Thread(() => { for (int i = 0; i < 10; i++) val1 += i; mr1.Set(); mrOuter.WaitOne(); mrOuter.Reset(); //show mesage from main thread part 1. for (int i = 0; i < 10; i++) val2 += i * 2; mr2.Set(); mrOuter.WaitOne(); mrOuter.Reset(); //show mesage from main thread part 2. for (int i = 0; i < 10; i++) val3 += i * 3; mr3.Set(); mrOuter.WaitOne(); mrOuter.Reset(); //show mesage from main thread part 2. }); t1.Start(); mr1.WaitOne(); Console.WriteLine(" part 1 finish :{0},{1},{2}", val1,val2,val3); mrOuter.Set(); mr2.WaitOne(); Console.WriteLine(" part 2 finish :{0},{1},{2}", val1, val2, val3); mrOuter.Set(); mr3.WaitOne(); Console.WriteLine(" part 3 finish :{0},{1},{2}", val1, val2, val3); mrOuter.Set(); Console.ReadLine(); } } } |
此例執行結果如下:
part 1 finish :45,0,0 part 2 finish :45,90,0 part 3 finish :45,90,135 |
程式裡用了4個ManualResetEvent,其建構時均傳入False,代表著建構時即初始成紅燈(以術語說,就是未得到訊號),mr1、mr2、mr3用於執行緒中,每階段完成時逐一呼叫 Set函式,代表將該ManualResetEvent設為綠燈,以第一階段而言,完成迴圈後,mr1的Set會被呼叫,此時主程式的mr1.WaitOne函式便會返回(是的,在ManualResetEvent未得到訊號時,呼叫WaitOne會一直等待到得到訊號後才會返回),顯示訊息後,呼叫mrOutet的Set函式,此時第一階段後的mrOuter.WaitOne會因為收到訊號而往下走到第二階段,其間順便呼叫Reset函式,將mrOuter恢復成未得到訊號的狀態。
相對於ManualResetEvent,.NET Framework 還提供了一個AutoResetEvent,如其名,其會在得到訊號後,WaitOne準備返回時立即返回無訊號狀態,也就是不需要設計師呼叫其Reset來回復成無訊號狀態,見下例:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; namespace ConsoleApplication2 { class Program { static void Main(string[] args) { int val1 = 0, val2 = 0, val3 = 0; ManualResetEvent mr1 = new ManualResetEvent(false); ManualResetEvent mr2 = new ManualResetEvent(false); ManualResetEvent mr3 = new ManualResetEvent(false); AutoResetEvent mrOuter = new AutoResetEvent(false); Thread t1 = new Thread(() => { for (int i = 0; i < 10; i++) val1 += i; mr1.Set(); mrOuter.WaitOne(); //show mesage from main thread part 1. for (int i = 0; i < 10; i++) val2 += i * 2; mr2.Set(); mrOuter.WaitOne(); //show mesage from main thread part 2. for (int i = 0; i < 10; i++) val3 += i * 3; mr3.Set(); mrOuter.WaitOne(); //show mesage from main thread part 2. }); t1.Start(); mr1.WaitOne(); Console.WriteLine(" part 1 finish :{0},{1},{2}", val1,val2,val3); mrOuter.Set(); mr2.WaitOne(); Console.WriteLine(" part 2 finish :{0},{1},{2}", val1, val2, val3); mrOuter.Set(); mr3.WaitOne(); Console.WriteLine(" part 3 finish :{0},{1},{2}", val1, val2, val3); mrOuter.Set(); Console.ReadLine(); } } } |
注意,ManualResetEvent的WaitOne與鎖定機制的Enter不同,Enter只會允許一個得到進入鎖定區域的權利,其它都必須依序等待來取得進入權,這很像是購物中心的單一結帳出口,但ManualResetEvent的WaitOne會在其得到訊號時返回,有如紅綠燈般,在不搭配其它鎖定機制的前題下,你無法限制當綠燈後,只有一台車能通過路口。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; namespace ConsoleApplication2 { class Program { static void Main(string[] args) { int val1 = 0, val2 = 0, val3 = 0; ManualResetEvent mr1 = new ManualResetEvent(false); Thread t1 = new Thread(() => { mr1.WaitOne(); for (int i = 0; i < 10; i++) val1 += i; }); Thread t2 = new Thread(() => { mr1.WaitOne(); for (int i = 0; i < 10; i++) val2 += i * 2; }); Thread t3 = new Thread(() => { mr1.WaitOne(); for (int i = 0; i < 10; i++) val3 += i * 3; }); t1.Start(); t2.Start(); t3.Start(); Thread.Sleep(1000); Console.WriteLine(" before :{0},{1},{2}", val1, val2, val3); mr1.Set(); t1.Join(); t2.Join(); t3.Join(); Console.WriteLine(" part 1 finish :{0},{1},{2}", val1,val2,val3); Console.ReadLine(); } } } |
當mr1.Set執行後,三個等待的執行緒都會收到訊號,由WaitOne返回,所以別把ManualResetEvent與同步鎖定的用途搞混了。
相較於ManualResetEvent,AutoResetEvent的用途就與Monitor.Enter類似,其會在WaitOne返回時即恢復未得到訊號狀態,所以可保證當多個執行緒以WaitOne等待訊號時,只會有一個WaitOne取得執行權,如下例。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; namespace ConsoleApplication2 { class Program { static void Main(string[] args) { AutoResetEvent mr = new AutoResetEvent(false); ThreadPool.QueueUserWorkItem((state) => { mr.WaitOne(); for (int i = 0; i < 10; i++) { Console.WriteLine(i.ToString()); Thread.Sleep(1000); } mr.Set(); }); ThreadPool.QueueUserWorkItem((state) => { mr.WaitOne(); for (int i = 0; i < 10; i++) { Console.WriteLine(i.ToString()); Thread.Sleep(1000); } mr.Set(); }); Thread.Sleep(2000); mr.Set(); Console.ReadLine(); } } } |
執行結果如下:
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 |
改成ManualResetEvent後,結果如下:
0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 |
New Lock Classes in .NET Framework 4
.NET Framework 4除了原先的鎖定/訊號類別(Monitor、Mutex、Semaphore、ManualResetEvent、ReadWriterLock)外,還添加了新的鎖定/訊號類別,這些新的類別使用上與同名的類別大致相同,可運用於短時間的鎖定/等待訊號。
一般的鎖定類別如Monitor,在等待時期時多半會引發OS 的Context Switching動作,當Context Switching發生時,CPU必須儲存現今所有暫存器的狀態,然後載入另一個執行緒所需要的暫存器狀態後將執行權交過去,因此當呼叫Monitor. Enter時,而Monitor.Enter需要等待別的執行緒呼叫Monitor.Exit時,即意味著目前執行緒將放棄此次的CPU排程執行,此時Context Switching就會發生,直到循環一輪後才會再次回到現行執行緒來。
新增的幾個鎖定/訊號類別在行為上則改採迴圈等待制(busy waiting),而不使用原來OS所提供的鎖定機制,以此來避免Context Switching的發生。
類別 | 對應之類別 |
SpinLock | lock/Monitor |
SemaphoreSlim | Semaphore |
ManualResetEventSlim | ManualResetEvent |
ReaderWriterLockSlim | ReaderWriterLock |
下例是使用SpinLock來取代Monitor的例子:
private static void UseSpinLock() { List<int> buffs = new List<int>(); SpinLock sp = new SpinLock(); Thread th = new Thread(() => { bool lockTaken = false; for (int i = 0; i < 10000; i++) { lockTaken = false; sp.Enter(ref lockTaken); try { buffs.Add(i); } finally { if (lockTaken) sp.Exit(); } } }); Thread th1 = new Thread(() => { bool lockTaken = false; for (int i = 0; i < 10000; i++) { lockTaken = false; sp.Enter(ref lockTaken); try { buffs.Add(i); } finally { if (lockTaken) sp.Exit(); } } }); th.IsBackground = true; th.Start(); th1.IsBackground = true; th1.Start(); } |
當呼叫Enter時,必須傳入一個bool參數,該參數必須先設定為False,當取得進入許可時,其值會被設為True,此時才能在離開時呼叫Exit。
那該用新的類別來取代舊的類別嗎?得視情況而定,新的類別出現的主因是因為舊的類別多半會在等待時交出執行權給其它執行緒(Context Switching),這原本對於效能的影響就很微妙,一旦使用新的類別,那麼在短時間等待時就不會交出執行權,雖然會提升效能,但很微小。反而是當誤用SpinLock於長時間等待時,會降低程式的效能。
因此,除非你很明白舊類別對於OS排程的影響,及確信自己的程式只需等待很短時間就可以取得鎖定權/訊號,否則還是使用舊的機制較保險。
APM(Asynchronous Programming Model)
一般來說,在單CPU或是單核的情況下,我們會選擇使用多執行緒大多是因為以下幾個原因:
一、進行背景運作,此動作不能影響主程式,例如分公司昨日銷售的檔案上傳,不應該影響到UI介面的操作。
二、效率,例如批次圖檔轉換格式,由於IO動作遠比CPU運算來得慢,所以讀寫檔動作可以放在Thread中進行,而另一Thread在讀檔動作完成後進行處理,兩者並行之。
三、伺服器,這多半應用在Web Server、File Server,這類應用程式會為每一個使用者建立一個Thread來服務,將CPU時間平均分給這些使用者。
以讀寫檔案為例,我們可能會寫成下面這樣,這稱為非同步技巧。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.IO; using System.Threading; namespace ConsoleApplication7 { class Program { private static void LoadFileAsync(Action<byte[]> completeFunction) { Thread th = new Thread(() => { using (FileStream fs = new FileStream(@"C:\Windows\Explorer.exe", FileMode.Open, FileAccess.Read)) { byte[] buff = new byte[fs.Length]; fs.Read(buff, 0, buff.Length); Thread.Sleep(10000); completeFunction(buff); } }); th.Start(); } static void Main(string[] args) { bool flag = false; LoadFileAsync((buff) => { flag = true; }); long i = 0; while (true) { if (flag) { Console.WriteLine("increment " + i.ToString()); Console.WriteLine("done"); Console.ReadLine(); break; } i++; } } } } |
LoadFileAsync會在讀檔完成後呼叫傳入的delegate,在讀檔其間主程式並不會因此停止,而是持續遞增i,下圖是執行結果:
圖2
不過每次都得寫這些程式碼,難免會讓人覺得煩,很幸運的!.NET Framework 2.0 提出了APM(Asynchronous Programming Model),可以簡化這類的寫法。
static void Main(string[] args) { FileStream fs = new FileStream(@"C:\Windows\Explorer.exe", FileMode.Open, FileAccess.Read); byte[] buff = new byte[fs.Length]; IAsyncResult asyncResult = fs.BeginRead(buff, 0, buff.Length, (state) => { Console.WriteLine("{0} bytes loaded.", fs.EndRead(state)); fs.Close(); }, null); long i = 0; while (true) { if (asyncResult.IsCompleted) break; i++; } Console.WriteLine("increment " + i.ToString()); Console.WriteLine("done"); Console.ReadLine(); } |
呼叫BeginRead並不會將主程式停止,其會建立一個Thread來執行讀檔動作,BeginRead會回傳一個IAsyncResult物件,當讀檔動作完成時,其IsCompleted屬性為被設為True,此例以此來判定讀檔動作是否完成。
BeginRead接受一個AsyncCallback delegate參數,當讀檔完成後,其會觸發並呼叫此delegate,此例於此列出所讀到的byte數。另外請特別注意一點,當你於主程式中呼叫BeginRead後緊接著呼叫EndRead,那麼EndRead會等到讀檔動作完成後才會返回,這段期間主程式將處於停止狀態。
Thread Pooling
善用多執行緒的技巧,可以讓應用程式執行的更順暢,減少因進行長時間動作而使UI凍結的情況。但執行緒也不是免費的,每個執行緒需要大約1 MB的記憶體,建立及啟動也需要花一點時間,為了減輕建立執行緒時所耗費的時間,.NET Framework 2.0提出了Thread Pooling概念,底層CLR會維持固定數量的執行緒運行,當設計師透過呼叫QueueUserWorkItem函式將delegate傳入時,Thread Pool會查看目前是否有閒置的Thread於Pool池中,有的話就讓這個Thread來呼叫傳入的delegate,否的話就建立一個新的Thread來執行,Thread Pool透過預建Thread的方式,來減少因頻繁建立/釋放 Thread而耗費的時間。
在.NET Framework 2.0 SP1之前,Thread Pool的最大可用Thread數量為25個/per CPU(Core)、1000 Thread for IO(APM),Thread Pool會在一開始時建立特定數量的Thread(每 1/ per CPU(Core)),當設計師呼叫QueueUserWorkItem時,Thread Pool會查看目前已建立的Thread是否有閒置的,有的話就傳回,否的話則視已建立的Thread數量是否超過上限(CPU/25)而定,未超過則建立,已到上限則將此delegate留滯,直到有閒置的Thread出現為止。
在.NET Framework 2.0 SP1之後,Thread Pool的上限已被放大成 250/per CPU(Core),1000 Thread for IO(APM),下面是一個使用Thread Pool的示範例子:
static void Main(string[] args) { ThreadPool.QueueUserWorkItem((state) => { for(int i = 0;i< 10000;i++) { Console.WriteLine(i.ToString()); Thread.Sleep(1000); } }); Console.ReadLine(); } |
這比起直接建立Thread來得簡單多了,但在.NET Framework 2.0 SP1之前,由於受限於25個的上限,因此我鮮少使用Thread Pool,在.NET Framework 2.0 SP1後,由於Thread的上限放大,使用起來不僅方便,而且還能控制耗費記憶體量(250 * 4 = 1G),所以變成我常常使用QueueUserWorkItem來取代原來的直接建立Thread寫法。
註:透過ThreadPool.SetMaxThreads可調整Thread Pool的最大Thread數,透過ThreadPool.SetMinThreads則可調整Thread Pool的最小Thread數。 |
Thread Pool 與 Wait Handle
Wait Handle在使用Thread Pool時更加重要,原因是當使用Thread Pool來運用執行緒時,若要等待某個執行緒結束,並不像直接使用Thread般有Join可以用,此時就會常常用到Wait Handle。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; namespace ConsoleApplication2 { class Program { static void Main(string[] args) { ManualResetEvent mr = new ManualResetEvent(false); ThreadPool.QueueUserWorkItem((state) => { for (int i = 0; i < 10; i++) Thread.Sleep(1000); mr.Set(); }); mr.WaitOne(); } } } |
Thread-Debugging in Visual Studio 2010
針對多執行緒應用程式除錯,通常是程式設計師惡夢的開端,因為你無法快速的判斷問題是出在那個執行緒,目前特定執行緒又執行到那一段程式碼,針對這類問題,Visual Studio 2010改良了Threads Window,不僅可以列出目前所建立的執行緒,還可以將特定執行緒凍結,亦或是修改其名稱來識別。
圖3
舉個例來說,我們可以為此例的各個Thread命名,然後為感興趣的Thread標上Flag,這有助於我們於除錯時能對目前這些Thread的狀態一目瞭然。
圖4
透過Freeze機制,我們還可以將某個Thread暫停,這可以逐漸將問題聚焦在可能出錯的Thread上,逐步尋找發生錯誤的Threads。
圖5
搭配上新增的Parallel Stack Window,還能以圖像化的方式來觀察目前程式所使用的Thread狀態。
圖6
在使用Thread Pool及傳統直接建立Thread的方式下,Parallel Stack Window看起來只是Threads Window的圖像化版本,但其實它還有個特異功能,當兩個Thread共同執行單一delegate時,Parallel Stack能聰明的告訴你目前那個Thread執行到那段程式碼,且讓你輕易的在這兩個Thread間切換,如下例。
static void Main(string[] args) { WaitCallback wc = new WaitCallback((state) => { for(int i = 0;i< 10000;i++) { Console.WriteLine(i.ToString()); Thread.Sleep(1000); } }); ThreadPool.QueueUserWorkItem(wc); ThreadPool.QueueUserWorkItem(wc); ThreadPool.QueueUserWorkItem((state) => { for (int i = 0; i < 10000; i++) { Console.WriteLine(i.ToString()); Thread.Sleep(1000); } }); ThreadPool.QueueUserWorkItem((state) => { for (int i = 0; i < 10000; i++) { Console.WriteLine(i.ToString()); Thread.Sleep(1000); } }); Console.ReadLine(); } |
在某處下個中斷點後,當停駐於中斷點時,開啟Parallel Stack就可以看到圖7的畫面。
圖7
圖中顯示了目前有兩個Threads共同執行一個區段的程式,其也列出了目前執行到那一行,當持續進行Step Debugging時,你應該還會注意到程式碼的左方有個特別的藍紅波浪小圖示。
圖8
這是告訴我們,在中斷點停駐時,目前所有Thread執行到那一行程式碼,移到圖示上後,Visual Studio 2010還會告訴我們此目前執行到此行的Thread。
圖9
透過設定Flag,我們還能將感興趣的執行緒標示出來。
圖10
圖11
然後於Parallel Stack上按下【Show Only Flagged】按鈕,讓Parallel Stack只秀出標記的Thread資訊。
圖12
如果有在Thread中呼叫其它函式的話,那麼將可以由Parallel Stack中看到每個Thread的Call Stack,這跟以前只能顯示單一Stack的情況好多了。
圖13