1.線程的概念
單核CPU的計算機中,一個時刻只能執行一條指令,操作系統以“時間片輪轉”的方式實現多個程序“同時”運行。操作系統以進程(Process)的方式運行應用程序,進程不但包括應用程序的指令流,也包括運行程序所需的內存、寄存器等資源。因為交替時間很短(一般只有幾十毫秒),人們根本感覺不到如此短暫的停頓,所以在表面上看來就像多個工作同時進行似的。因此進程在宏觀上是并發進行的,在微觀上是交替進行的。
后來出現了多線程技術(Multi-threading),可以通過在一個進程中創建多個線程(Threading),系統以“時間片輪轉”的方式交替執行多個線程,使得可以在一個程序中同時執行多項工作。同一個進程中的所有線程共享進程的資源,所以它們之間的切換就比進程間的切換快的多,因此線程可以看作輕量級進程(Lightweight Process)。現代的操作系統都是多進程(Multi-process)的操作系統,每個進程中運行一個或多個線程,所以大多數時間操作系統中都有多個線程并發運行。操作系統中有專門的調度程序管理線程,它根據事先設計好的算法輪流執行每個線程。線程是操作系統進行CPU 調度的基本單位,線程的調度是由操作系統自動完成的,無須程序員關心。程序員只需編寫好線程即可,線程的輪轉交由操作系統完成。隨著多核心CPU的出現,使得線程能夠真正的實現同步執行,多線程技術從此翻開新的篇章。
2.Thread類
一般情況下,每開啟一個應用程序,系統就會創建一個與該程序相關的的進程,緊接著進程就會創建一個主線程(Main Thread),然后從主函數中的代碼開始執行。可以在一個應用程序中創建任意多個線程,每個線程完成一項任務。C#中,線程由System.Threading 命名空間中的Thread 類實現,聲明語句:
Thread workThread = new Thread(entryPoint);
其中entryPoint 代表一個入口方法,線程的具體代碼放在入口方法中,系統從入口方法的第一句代碼開始執行線程。入口方法的參數和返回值類型由ThreadStart 委托或ParameterizedThreadStart 委托規定。
public delegate void ThresdStart();
public delegate void ParameterizedThreadStart(Object obj);
除了通過委托傳遞線程的入口方法外,還可以通過匿名方法或Lambda表達式創建線程。
Thread drawGraphThread=new Thread(delegate() { //入口方法中的代碼});
Thread drawGraphThread=new Thread(() => { //入口方法中的代碼});
匿名方法可以使用外部變量,所以用匿名方法定義的線程可以使用在線程前面定義的變量,這彌補了入口方法沒有參數和返回值的問題。
3.線程的優先級
計算機中經常會有多個任務同時運行,其中總有一些看起來更緊急,更需要優先執行。線程的優先級可以通過Thread類Priority屬性設置,Priority屬性是一個ThreadPriority型枚舉,包含5個優先等級:Highest、AboveNormal、Normal BelowNormal、Lowest。應先設置線程優先級,再執行線程,并且,任何一個程序的Main()方法將占用一個主線程。
//改變線程優先級
threadA.Priority = ThreadPriority.AboveNormal;
threadB.Priority = ThreadPriority.BelowNormal;
//啟動線程
threadA.Start();
threadB.Start();
4.線程的插入
Thread類的Join()方法能夠將兩個原本交替執行的線程變為順序執行。
Using System.Threading;
Static void Main(string [] args)
{ //線程A
Thread threadA=new Thread(delegate()
? {? for(int i=0;i<=10000000;i++)
??? {?? if(i%1000000==0)
??????? {Console.Write(‘A’); }
???? }
});
//線程B
Thread threadB=new Thread(delegate()
{? for (int i=0;i<=50000000;i++)
{ if(i%1000000==0)
??????? {Console.Write(‘B’);}
??? }
?? //在這里插入線程A
?? threadA.Join();
?? for(int i=0;i<=50000000;i++)
?? {? if(i%10000000==0)
????? { Console.Write(‘b’);}
?? }
?});
//啟動線程
threadA.Start();
threadB.Start();
}
一開始兩個線程交替進行,當線程 B 執行到語句“threadA.Join()”時,線程A 中剩余的代碼插入到線程B 之中,從此刻起,停止執行線程B,專門執行線程A,直到執行完線程A 中的所有語句,才去執行線程B 中剩余的語句。從線程 B 的角度看,在線程B 中調用threadA.Join(),相當于在在線程B 中調用了一個方法,只有線程threadA 執行完畢之后該方法才會返回,Join()方法還可以接受一個表示毫秒數的參數,當達到指定時間后,即使線程A 還沒運行完畢, Join()方法也返回,這時線程A 和線程B 再次處于交替運行中。
5.線程的狀態
線程的狀態由Thread類的ThreadState屬性表示:
?
當一個線程被創建后,它就處于Unstarted 狀態,直到調用了Start()方法為止。但處于Running 狀態的線程也不是一定正在被CPU 執行,可能該線程的時間片剛剛用完,CPU 正在處理其他線程,過一段時間后才會處理它。
有三種方法使線程由 Running 狀態變為WaitSleepJoin 狀態。第一種情況是為了保持線程間的同步而使之處于等待狀態,這一點將在下一節講到。第二種情況是線程調用了Sleep()方法而處于睡眠狀態,當達到指定的睡眠時間以后,線程將會回到Running 狀態。第三種情況是調用了Join()方法,比如在線程A 的代碼中調用了線程B.Join()方法,線程A 將處于WaitSleepJoin 狀態,直到線程B 結束,開始繼續執行線程A 時為止。如果當線程處于 Running 狀態時調用線程的Suspend()方法,線程將由Running 狀態變為SuspendedRequested 狀態(請求掛起狀態),線程一般會再繼續執行幾個指令,當確保線程在安全的狀態下時,掛起線程,這時線程變為Suspended 狀態(掛起狀態)。調用線程的Resume()方法,可使線程回到Running 狀態。
線程的狀態是由操作系統的調度程序決定的,所以除了在一些調試方案中,一般不使用線程的狀態。但線程的Background 狀態除外,可以通過Thread 類的IsBackground 屬性把線程設置為Background 狀態。其實后臺線程跟前臺線程只有一個區別,那就是后臺線程不妨礙程序的終止。一旦一個應用程序的所有前臺線程都終止后,CLR 就通過調用任意一個存活中的后臺線程的Abort()方法來徹底終止應用程序。另外 Thread 類還有一個IsAlive 屬性,這是個只讀屬性,用來說明線程已經啟動,還沒有結束。
6.線程的同步?
(1)線程同步的概念
同上運行的線程,有的相互間沒有任何聯系,稱為無關線程,而有些線程之間則是有聯系的,例如一個線程等待另一個線程的運算結果,兩個線程共享一個資源等,這種線程稱為相關線程。例如在網上觀看在線視頻,一個線程下載視頻,另一個線程播放視頻,兩個線程相互合作,才能得到較佳的觀看體驗。線程的相關性集中體現在對同一資源的訪問上,把這種多個線程共享的資源稱為臨界資源,它可以是內存中的一個變量,也可以是一個文件,也可以是一臺打印機等。
系統中往往有多個線程交替執行,它們被執行的時間是不確定,當需要兩個線程精確協同工作才能共同完成好一項任務的情況稱為線程同步(Synchronization)。如何保證兩個線程同步?.NET框架提供了一系列的同步類,最常用的包括Interlocked(互鎖)、Monitor(管程)和Mutex(互斥體)。
(2)互鎖(Interlocked類)
通過Interlocked類來控制線程的同步,為多個線程共享的變量提供原子操作,所謂原子操作是指不會被線程調度機制打斷的操作;這種操作一旦開始,就一直運行到結束。例如:
using System.Threading;
class Program
{? private static char buffer; //緩沖區,只能容納一個字符
//標識量(緩沖區中已使用的空間,初始值為0 )
private static long numberOfUsedSpace = 0;
static void Main(string[] args)
{ Thread writer = new Thread(delegate()
{
string sentence = "無可奈何花落去,似曾相識燕歸來,小園香徑獨徘徊。";
for(int i = 0; i < 24; i++)
{
//寫入數據前檢查緩沖區是否已滿
while(Interlocked.Read(ref numberOfUsedSpace) == 1)
{Thread.Sleep(10);}
buffer = sentence[i]; //向緩沖區寫入數據
Interlocked.Increment(ref numberOfUsedSpace);
}
});
Thread reader = new Thread(delegate()
{for(int i = 0; i < 24; i++)
{//讀取數據前檢查緩沖區是否為空
while(Interlocked.Read(ref numberOfUsedSpace) == 0)
{Thread.Sleep(10);}
char ch = buffer;
Console.Write(ch);
//讀取數據后把緩沖區標記為空(由1 變為0)
Interlocked.Decrement(ref numberOfUsedSpace);
}
});
//啟動線程
writer .Start();
reader.Start(); }
}
(3)Monitor類
另一種同步方法使用Monitor類,它使用獨占鎖的方式控制線程同步,只有獲得獨占鎖的線程才能訪問臨界資源。當一個線程進入臨界區時,首先調用Monitor 類的Enter()方法,嘗試獲取臨界資源的獨占鎖,若獨占鎖已被其他線程占用,就進入等待狀態,睡眠在臨界資源上,直到獨占鎖沒有被其他線程占用,該線程就會獲取獨占鎖,執行操作臨界資源的代碼。Monitor 會紀錄所有睡眠在臨界資源上的線程,當線程退出臨界區時,需要通過調用Monitor 類的Pulse()方法喚醒睡眠在臨界資源的線程。Monitor 類的部分方法如下表所示:
using System.Threading;
class Program
{private static char buffer;
//用于同步的對象(獨占鎖)
private static object lockForBuffer = new object();
static void Main(string[] args)
{Thread writer = new Thread(delegate()
{ string sentence = "無可奈何花落去,似曾相識燕歸來,小園香徑獨徘徊。";
for (int i = 0; i < 24; i++)
{? lock(lockForBuffer)
{ buffer = sentence[i];
Monitor.Pulse(lockForBuffer); //喚醒睡眠在此臨界資源上的其它線程
Monitor.Wait(lockForBuffer); //讓當前線程睡眠在臨界資源上 }
}
});
Thread reader = new Thread(delegate()
{ for (int i = 0; i < 24; i++)
{ lock (lockForBuffer)
{ char ch = buffer;
Console.Write(ch);
Monitor.Pulse(lockForBuffer); //喚醒睡眠在臨界資源上的線程
Monitor.Wait(lockForBuffer); //讓當前線程睡眠在臨界資源上}
}
});
//啟動線程
writer .Start();
reader.Start();
} }
實際上,舊版的C#中其編碼方式如下左圖,新版的C#對其進行了簡化,設計了lock(object o){}的方式。
?
在線程 writer 中,因為當緩沖區中的數據被讀走以后還要繼續向緩沖區中寫數據,所以當寫完數據后調用了Monitor.Wait()方法,讓線程writer 睡眠在臨界資源上,直到線程reader 讀完數據后把它喚醒。出于同樣的理由,當線程reader 讀完數據后,也調用了Monitor.Wait()方法。為了確保退出臨界區時臨界資源得到釋放,使用 Monitor 類的代碼應該放在try 語句中,并在finally 塊中調用Monitor.Exit()方法。而簡化版的lock 語句,執行完畢后會自動執行Monitor.Exit()方法,釋放臨界資源,二者功能完全等價。
需要注意的是,Monitor 類只能鎖定引用類型對象。
當一個線程以獨占鎖的方式訪問資源時,其他線程就不能訪問該資源,只有lock 語句結束后其他線程才能訪問。lock 語句的相當于臨時禁用了應用程序的多線程功能。一般情況下,當有多個線程對同一個資源進行寫操作時,就應當進行同步操作。但是如果一個線程在某資源上放置了一把鎖,其他訪問該資源的線程就只能暫停,使程序的效率大打折扣。所以只有必要的時候才設置獨占鎖。
(4)互斥體(Mutex類)
在操作系統中,許多線程常常需要共享資源,而這些資源往往要求排他性的使用,即一次只能為一個線程服務。比如打印機一次只能打印一個文檔,一個文件一次只允許一個線程寫入數據等。這種排他性地使用共享資源稱為線程間的互斥(Mutual Exclusion)。線程互斥實質上也是同步,可以看做一種特殊的線程同步。線程的互斥常用Mutex 類(互斥體)實現,利用它可以對資源進行獨占性訪問。與Monitor 類相似,只有獲取Mutex 對象的所屬權的線程才能進入臨界區,未獲得Mutex 對象所屬權的線程只能在臨界區外等待。使用Mutex 類要比使用Monitor 類消耗更多的系統資源,但它可以跨越應用程序邊界,在多個應用程序之間進行同步。Mutex 類的部分方法如下表所示:
?
?互斥體有兩種類型:局部互斥體和系統互斥體。局部互斥體只能在創建它的程序中使用,而系統互斥體則能被系統中不同的應用程序共享。創建系統互斥體,只需在構造函數中為互斥體對象起一個“系統名稱”即可。?
操作系統根據互斥體的系統名稱辨別互斥體,不管互斥體對象創建于哪個應用程序中,只要具有相同的系統名稱,就被認為是同一個系統互斥體。下面分別創建兩個程序,它們都每隔一秒鐘向文件 TimeRecord.txt 中寫入一條包含當前系統時間的記錄。顯然,為了保證每條記錄的完整性,當一個程序向文件中寫入記錄時,另一個程序必須等待。因此需要用Mutex 進行同步。
static void Main(string[] args)
{ Thread threadA=new Thread(delegate()
{ Mutex fileMutex = new Mutex(false, "MutexForTimeRecordFile");
string fileName = @"D:\ TimeRecord.txt";
for (int i = 1; i <= 10; i++)
{try{
//請求互斥體的所屬權,若成功,則進入臨界區,若不成功,則等待
fileMutex.WaitOne();
//在臨界區中操作臨界資源,即向文件中寫入數據
File.AppendAllText(fileName, "threadA: " + DateTime.Now + "\r\n");
}
catch (System.Threading.ThreadInterruptedException)
{Console.WriteLine("線程A 被中斷。");}
finally
{fileMutex.ReleaseMutex(); //釋放互斥體的所屬權}
Thread.Sleep(1000);
}
});
threadA.Start();
}
//創建第二個程序"MutecB",在主函數中輸入下面的代碼
static void Main(string[] args)
{
Thread threadB = new Thread(delegate()
{//創建互斥體
Mutex fileMutex = new Mutex(false, "MutexForTimeRecordFile");
string fileName = @"D:\ TimeRecord.txt";
for (int i = 1; i <= 10; i++)
{
try
{//請求互斥體的所屬權,若成功,則進入臨界區,若不成功,則等待
fileMutex.WaitOne();
//在臨界區中操作臨界資源,即向文件中寫入數據
File.AppendAllText(fileName, "threadB: " + DateTime.Now + "\r\n");
}
catch (System.Threading.ThreadInterruptedException)
{Console.WriteLine("線程B 被中斷。");}
finally
{fileMutex.ReleaseMutex(); //釋放互斥體的所屬權}
Thread.Sleep(1000);
}
});
threadB.Start();
System.Diagnostics.Process.Start("MutexA.exe"); //啟動程序MutexA.exe
}
上面兩個程序中,我們分別創建了一個互斥體對象,因為它們的系統名稱都是“MutexForTimeRecordFile”,所以操作系統認為它們是同一個Mutex 對象,從而實現兩個應用程序互斥地訪問同一個文件。用【生成】菜單中的命令分別生成程序 MutexA.exe 和程序MutexB.exe,然后把它們復制到同一個文件夾中。雙擊程序 MutexB.exe,因為程序MutexB.exe 中包含有啟動程序MutexA.exe 的代碼,所以兩個程序都被啟動。兩個程序運行完畢后,打開TimeRecord.txt 文件,觀察結果:兩個程序實現了交替的向同一文件中寫入時間記錄。當一個程序向文件中寫入文件記錄時,另一個程序只能處于等待狀態,從而保證了每條記錄的完整性。
5.死鎖
多個線程間的同步如果設計不當,就會造成死鎖(Deadlock)。死鎖是指多個線程共享某些資源時,都占用一部分資源,而且都在等待對方釋放另一部分資源,從而導致程序停滯不前的情況。
下面的程序演示了一種典型的死鎖情形。一對情侶共吃一份西餐,并且共用一副刀叉。只有同時獲得刀子和叉子時,才可以吃東西,吃完以后就放下刀叉,供對方使用。如果刀子或叉子正好被對方拿起,就只能等待,直到對方放下為止。
class Program
{ private static object knife = new object(); //臨界資源:刀子
private static object fork = new object(); //臨界資源:叉子
static void Main(string[] args)
{//線程:女孩的行為
Thread girlThread = new Thread(delegate()
{//女孩和男孩聊天
Console.WriteLine("今天的月亮好美啊~~~");
//過了一會兒,女孩餓了,就去拿刀子和叉子
lock (knife)
{ GetKnife();
//*(待會兒會在這里添加一條語句)
lock (fork)
{ GetFork();
Eat(); //同時拿到刀子和叉子后開始吃東西
Console.WriteLine("女孩放下叉子");
Monitor.Pulse(fork); }
Console.WriteLine("女孩放下刀子");
Monitor.Pulse(knife); }
});
girlThread.Name = "女孩"; //定義線程的名稱
//線程:男孩的行為
Thread boyThread = new Thread(delegate()
{ //男孩和女孩聊天
Console.WriteLine("\n 你更美!");
lock (fork)
{ GetFork();
lock (knife)
{ GetKnife();
Eat(); //同時拿到刀子和叉子后開始吃東西
Console.WriteLine("男孩放下刀子");
Monitor.Pulse(knife); }
Console.WriteLine("男孩放下叉子");
Monitor.Pulse(fork);
}
});
boyThread.Name = "男孩"; //定義線程的名稱
//啟動線程
girlThread .Start();
boyThread.Start();
}
//方法:拿起刀子
static void GetKnife()
{Console.WriteLine(Thread.CurrentThread.Name + "拿起刀子。");}
//方法:拿起叉子
static void GetFork()
{Console.WriteLine(Thread.CurrentThread.Name + "拿起叉子。");}
//方法:吃東西
static void Eat()
{Console.WriteLine(Thread.CurrentThread.Name + "吃東西。");}
}
一般情況下,程序可以正常運行,結果如下:
但在某些特殊情況下就會出現死鎖現象。假設女孩剛好拿起了刀子,正要拿叉子時,操作系統把線程切換到了男孩,這時男孩也想吃飯,于是拿起了叉子,但隨即發現刀子已被女孩占有,所以男孩線程進入睡眠狀態,等待女孩釋放刀子。過一會兒,線程再次切換到女孩,當女孩試圖拿起叉子時發現叉子已被男孩占有,于是女孩線程也進入睡眠狀態,等待男孩釋放叉子。最終男孩等女孩釋放刀子,女孩等男孩釋放叉子,雙方都在無休止的等待對方,進入了死鎖狀態。
出現死鎖的前提條件是兩個線程出現交替,交替過程中各占有一部分資源(而每個線程運行都需要獲得整個資源)由于例子中的兩個線程都很小,多數情況下都能在一個時間片內完成,所以出現死鎖的概率還是很小的。線程運行時間的越長,出現交替的情況越多,出現死鎖的概率越大。
死鎖會造成程序停滯不前,所以我們在編寫多線程程序時一定要注意避免死鎖現象的發生。其實上面的問題很好解決,只要兩個線程以相同的順序訪問臨界資源即可。
7.線程池
一般情況下我們都使用Thread類創建線程,因為通過Thread對象可以對線程進行靈活的控制。但創建線程和銷毀線程代價不菲,過多的線程會消耗掉大量的內存和CPU資源,假如某段時間內突然爆發了100 個短小的線程,創建和銷毀這些線程就會消耗很多時間,可能比線程本身運行的時間還長。為了改善這種狀況,.NET提供了一種稱之為線程池Thread Pool)的技術。線程池提供若干個固定線程輪流為大量的任務服務,比如用10 個線程輪流執行100 個任務,當一個線程完成任務時,并不馬上銷毀,而是接手另一個任務,從而減少創建和銷毀線程的消耗。線程池由System.Threading 命名空間中的ThreadPool 類實現,其部分方法如下表所示:
ThreadPool 是一個靜態類,不必創建實例就可以使用它。一個應用程序最多只有一個線程池,它會在首次向線程池中排入工作函數時自動創建。
namespace ThreadPoolTest
{
class Program
{
public delegate void WaitCallback(Object dataForFunction);
public static void ThreadPoolTest()
{//向線程池中添加100個工作線程
for (int i = 1; i <= 100; i++)
{ThreadPool.QueueUserWorkItem(new WaitCallback(WorkFunction), i);}
}
//工作函數
public static void WorkFunction(object n)
{ Console.Write(n + "\t");}
static void Main(string[] args)
{ThreadPoolTest();
Console.ReadKey(); //按下任意鍵結束程序}
}}
結果如下圖所示:
下面,研究一下線程池運行過程中線程數目的變化情況,從而加深對線程池的理解。為了敘述方便,假設下限為10,上限為30。
①當線程池被創建后,里面就會創建10 個空線程(和下限值相同)。
②當向線程池中排入一個任務后,就會有一個空線程接手該任務,然后運行起來。隨著不斷向線程池中排入任務,線程池中的空線程逐一運行起來。
③隨任務不斷增加,某一時刻任務數量會超出下限,這時線程數量不夠用了,但線程池并不會立即創建新線程,而是等待500 毫秒左右,看看在這段時間是否有其它線程完成任務并接手這個請求,避免因創建新線程而造成的消耗。如果這段時間沒有線程完成任務,就創建一個新線程去執行新任務。
④在任務數量超過下限后,隨著新任務的不斷排入,線程池中線程數量持續增加,直至達到上限值為止。
⑤當線程數量達到上限時,繼續增加任務,線程數量將不再增加。多余的任務就線程池外排隊等待。線程池某個線程完成任務后,就從等待隊列中選擇一個任務繼續執行。
⑥隨著任務逐步完成,線程池外部等候的任務被逐步調入線程池,任務的數量逐步減少,但線程的總數保持恒定,始終為30(和上限值相同)。
⑦隨著任務的逐漸減少,某一時刻任務數量會小于上限值,這時線程池內多余的線程會在空閑2 分鐘后被釋放并回收相關資源。線程數目逐步減少,直到達到下限值。
⑧當任務數量減小到下限值之下時,線程池中的線程數目保持不變(始終和下限值相同),其中一部分在執行任務,另一部分處于空運行狀態。
⑨當所有任務都完成后,線程池恢復初始狀態,運行10 個空線程。
由上面的論述可以看出線程池提高效率的關鍵是一個線程完成任務后可以繼續為其他任務服務,這樣就可以使用有限的幾個固定線程輪流為大量的任務服務,從而減少了因頻繁創建和銷毀線程所造成的消耗。ThreadPool 中的線程不用手動開始,也不能手動取消,你要做的只是把工作函數排入線程池,剩下的工作將由系統自動完成。如果想對線程進行更多的控制,那么就不適合使用線程池。在以下情況中不宜使用ThreadPool類而應該使用單獨的Thread 類:
①線程執行需要很長時間(如果有些線程長期占用線程池,那么對在外面排隊的任務說就是災難);
②需要為線程指定詳細的優先級;
③在執行過程中需要對線程進行操作,比如睡眠,掛起等。
所以 ThreadPool 適合于并發運行若干個運行時間不長且互不干擾的函數。?
?