一、多線程程式設計的基本概念
下面的一些基本概念可能和.NET的聯系并不大,但對于掌握.NET中的多線程開發來說卻十分重要。我們在開始嘗試多線程開發前,應該對這些基礎知識有所掌握,并且能夠在作業系統層面了解多線程的運作方式。
1.1 作業系統層面的程序和線程
(1)程序
程序代表了作業系統上運作着的一個應用程式。程序擁有自己的程式塊,擁有獨占的資源和資料,并且可以被作業系統排程。But,即使是同一個應用程式,當被強制啟動多次時,也會被安放到不同的程序之中單獨運作。
直覺地了解程序最好的方式就是通過程序管理器浏覽,其中每條記錄就代表了一個活動着的程序:
(2)線程
線程有時候也被稱為輕量級程序,它的概念和程序十分相似,是一個可以被排程的單元,并且維護自己的堆棧和上下文環境。線程是附屬于程序的,一個程序可以包含1個或多個線程,并且同一程序内的多個線程共享一塊記憶體塊和資源。
由此看來,一個線程是一個作業系統可排程的基本單元,但是它的排程受限于該線程所屬的程序,也就是說作業系統首先決定執行下一個執行的程序,進而才會排程該程序内的線程。一個線程的基本生命周期如下圖所示:
(3)程序和線程的差別
最大的差別在于,每個程序都會被單獨隔離(程序擁有自己的記憶體、資源和運作資料,一個程序的崩潰不會影響到其他程序,是以程序間的互動也相對困難),而同一程序内的所有線程則共享記憶體和資源,并且一個線程可以通路和結束同一程序内的其他線程。
1.2 多線程程式在作業系統中是并行執行的嗎?
(1)線程的排程
在計算機系統發展的早期,作業系統層面不存在并行的概念,所有的應用程式都在排隊等候一個單線程的隊列之中,每個程式都必須等到前面的程式都安全執行完畢之後才能獲得執行的權利,一個小小的錯誤将會導緻作業系統上的所有程式的阻塞。在後來的作業系統中,逐漸産生了分時和程序、線程的概念。
多個線程由作業系統進行排程控制,決定何時運作哪個線程。所謂線程排程,是指作業系統決定如何安排線程執行順序的算法。按正常分類,線程排程可以分為以下兩種:
①搶占式排程
搶占式排程是指每個線程都隻有極少的運作時間(在Windows NT核心模式下這個時間不會超過20ms),而當時間片用完時該線程就會被強制暫停,儲存上下文并把運作權利交給下一個線程。這樣排程的結果就是:。
②非搶占式排程
非搶占式排程是指某個線程在運作時不會被作業系統強制暫停,它可以持續地運作直到運作告一段落并主動交出運作權。在這樣的排程方式之下,線程的運作就是單隊列的,并且可能産生惡意程式長期霸占運作權的情況。
PS:現在很多的作業系統(包括Windows在内),都同時采用了搶占式和非搶占式模式。對于那些優先級較高的線程,OS采用非搶占式來給予充分的時間運作,而對于普通的線程,則采用搶占式模式來快速地切換執行。
(2)線程的并行問題
在單核單CPU的硬體架構上,線程的并行運作完全是使用者的主觀體驗。事實上,在任一時刻隻可能存在一個處于運作狀态的線程。但在多CPU或多核的架構上,情況則略有不同。多CPU多核的架構則允許系統完全并行地運作兩個或多個無其他資源争用的線程,理論上這樣的架構可以使運作性能整數倍地提高。
PS:微軟公司曾經提出超線程技術,簡單說來這是一種邏輯上模拟多CPU的技術,但實際上它們卻共享實體處理器和緩存,超線程對性能的提高相當有限。
1.3 神馬是纖程?
(1)纖程的概念
纖程是微軟公司在Windows上提出的一個概念,其設計目的是用來友善地移植其他作業系統上的應用程式。。But,。
PS:事實上,Windows作業系統核心是不知道纖程的存在的,它隻負責排程所有的線程,而纖程之是以成為作業系統的概念,是因為Windows提供了關于線程操作的Win32函數,能夠友善地幫助程式員進行線程程式設計。
(2)纖程和線程的差別
纖程和線程最大的差別在于:線程的排程受作業系統的管理,程式員無法進行完全幹涉。但纖程卻完全受控于程式員本身,允許程式員對多任務進行自定義的排程和控制,是以纖程帶給程式員很大的靈活性。
下圖展示了程序、線程以及纖程三者之間的關系:
(3)纖程在.NET中的地位
需要謹記是的一點是:.NET運作架構沒有做出關于線程真實性的保證!也就是說,我們在.NET程式中建立的線程并不一定是作業系統層面上産生的一個真正線程。在.NET架構寄宿的情況下,一個程式中的線程很可能對應某個纖程。
PS:所謂CLR寄宿,就是指CLR運作在某個應用程式而非作業系統内。常見的寄宿例子是微軟公司的SQL Server 2005。
二、.NET中的多線程程式設計
.NET為多線程程式設計提供了豐富的類型和機制,程式員需要做的就是掌握這些類型和機制的使用方法和運作原理。
2.1 如何在.NET程式中手動控制多個線程?
.NET中提供了多種實作多線程程式的方法,但最直接且靈活性最大的,莫過于主動建立、運作、結束所有線程。
(1)第一個多線程程式
.NET提供了非常直接的控制線程類型的類型:System.Threading.Thread類。使用該類型可以直覺地建立、控制和結束線程。下面是一個簡單的多線程程式:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("進入多線程工作模式:");
for (int i = 0; i < 10; i++)
{
Thread newThread = new Thread(Work);
// 開啟新線程
newThread.Start();
}
Console.ReadKey();
}
static void Work()
{
Console.WriteLine("線程開始");
// 模拟做了一些工作,耗費1s時間
Thread.Sleep(1000);
Console.WriteLine("線程結束");
}
}
在主線程中,該代碼建立了10個新的線程,這個10個線程的工作互不幹擾,宏觀上來看它們應該是并行運作的,執行的結果也證明了這一點:
PS:這裡再次強調一點,當new了一個Thread類型對象并不意味着生成了一個線程,事實上線程的生成是在調用Thread的Start方法的時候。另外在之前的介紹中,這裡的線程并不一定是作業系統層面上産生的一個真正線程!
(2)控制線程的狀态
很多時候,我們需要主動關心線程目前所處的狀态。在任意時刻,.NET中的線程都會處于如下圖所示的幾個狀态中的某一個狀态上,該圖也直覺地展示了一個線程可能經過的狀态轉換過程(該圖并沒有列出所有的狀态轉換途徑/原因):
下面的示例代碼則展示了我們如何手動地檢視和控制一個線程的狀态:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("開始測試線程1");
// 初始化一個線程 thread1
Thread thread1 = new Thread(Work1);
// 這時狀态:UnStarted
PrintState(thread1);
// 啟動線程
Console.WriteLine("現在啟動線程");
thread1.Start();
// 這時狀态:Running
PrintState(thread1);
// 讓線程飛一會 3s
Thread.Sleep(3 * 1000);
// 讓線程挂起
Console.WriteLine("現在挂起線程");
thread1.Suspend();
// 給線程足夠的時間來挂起,否則狀态可能是SuspendRequested
Thread.Sleep(1000);
// 這時狀态:Suspend
PrintState(thread1);
// 繼續線程
Console.WriteLine("現在繼續線程");
thread1.Resume();
// 這時狀态:Running
PrintState(thread1);
// 停止線程
Console.WriteLine("現在停止線程");
thread1.Abort();
// 給線程足夠的時間來終止,否則的話可能是AbortRequested
Thread.Sleep(1000);
// 這時狀态:Stopped
PrintState(thread1);
Console.WriteLine("------------------------------");
Console.WriteLine("開始測試線程2");
// 初始化一個線程 thread2
Thread thread2 = new Thread(Work2);
// 這時狀态:UnStarted
PrintState(thread2);
// 啟動線程
thread2.Start();
Thread.Sleep(2 * 1000);
// 這時狀态:WaitSleepJoin
PrintState(thread2);
// 給線程足夠的時間結束
Thread.Sleep(10 * 1000);
// 這時狀态:Stopped
PrintState(thread2);
Console.ReadKey();
}
// 普通線程方法:一直在運作從未被超越
private static void Work1()
{
Console.WriteLine("線程運作中...");
// 模拟線程運作,但不改變線程狀态
// 采用忙等狀态
while (true) { }
}
// 文藝線程方法:運作10s就結束
private static void Work2()
{
Console.WriteLine("線程開始睡眠:");
// 睡眠10s
Thread.Sleep(10 * 1000);
Console.WriteLine("線程恢複運作");
}
// 列印線程的狀态
private static void PrintState(Thread thread)
{
Console.WriteLine("線程的狀态是:{0}", thread.ThreadState.ToString());
}
}
上述代碼的執行結果如下圖所示:
PS:為了示範友善,上述代碼刻意地使線程處于各個狀态并列印出來。在.NET Framework 4.0 及之後的版本中,已經不再鼓勵使用線程的挂起狀态,以及Suspend和Resume方法了。
2.2 如何使用.NET中的線程池?
(1).NET中的線程池是神馬
我們都知道,線程的建立和銷毀需要很大的性能開銷,在Windows NT核心的作業系統中,每個程序都會包含一個線程池。而在.NET中呢,也有自己的線程池,它是由CLR負責管理的。
線程池相當于一個緩存的概念,在該池中已經存在了一些沒有被銷毀的線程,而當應用程式需要一個新的線程時,就可以從線程池中直接擷取一個已經存在的線程。相對應的,當一個線程被使用完畢後并不會立刻被銷毀,而是放入線程池中等待下一次使用。
.NET中的線程池由CLR管理,管理的政策是靈活可變的,是以線程池中的線程數量也是可變的,使用者隻需向線程池送出需求即可,下圖則直覺地展示了CLR是如何處理線程池需求的:
PS:線程池中運作的線程均為背景線程(即線程的 IsBackground 屬性被設為true),所謂的背景線程是指這些線程的運作不會阻礙應用程式的結束。相反的,應用程式的結束則必須等待所有前台線程結束後才能退出。
(2)在.NET中使用線程池
在.NET中通過 System.Threading.ThreadPool 類型來提供關于線程池的操作,ThreadPool 類型提供了幾個靜态方法,來允許使用者插入一個工作線程的需求。常用的有以下三個靜态方法:
① static bool QueueUserWorkItem(WaitCallback callback)
② static bool QueueUserWorkItem(WaitCallback callback, Object state)
③ static bool UnsafeQueueUserWorkItem(WaitCallback callback, Object state)
有了這幾個方法,我們隻需要将線程要處理的方法作為參數傳入上述方法即可,随後的工作都由CLR的線程池管理程式來完成。其中,WaitCallback 是一個委托類型,該委托方法接受一個Object類型的參數,并且沒有傳回值。下面的代碼展示了如何使用線程池來編寫多線程的程式:
class Program
{
static void Main(string[] args)
{
string taskInfo = "運作10秒";
// 插入一個新的請求到線程池
bool result = ThreadPool.QueueUserWorkItem(DoWork, taskInfo);
// 配置設定線程有可能會失敗
if (!result)
{
Console.WriteLine("配置設定線程失敗");
}
else
{
Console.WriteLine("按Enter鍵結束程式");
}
Console.ReadKey();
}
private static void DoWork(object state)
{
// 模拟做了一些操作,耗時10s
for (int i = 0; i < 10; i++)
{
Console.WriteLine("工作者線程的任務是:{0}", state);
Thread.Sleep(1000);
}
}
}
上述代碼執行後,如果不輸入任何字元,那麼會得到如下圖所示的執行結果:
PS:事實上,UnsafeQueueWorkItem方法實作了完全相同的功能,二者的差别在于UnsafeQueueWorkItem方法不會将調用線程的堆棧傳遞給輔助線程,這就意味着主線程的權限限制不會傳遞給輔助線程。UnsafeQueueWorkItem由于不進行這樣的傳遞,是以會得到更高的運作效率,但是潛在地提升了輔助線程的權限,也就有可能會成為一個潛在的安全漏洞。
2.3 如何檢視和設定線程池的上下限?
線程池的線程數是有限制的,通常情況下,我們無需修改預設的配置。但在一些場合,我們可能需要了解線程池的上下限和剩餘的線程數。線程池作為一個緩沖池,有着其上下限。。
PS:在.NET Framework 4.0中,每個CPU預設的工作者線程數量最大值為250個,最小值為2個。而IO線程的預設最大值為1000個,最小值為2個。
在.NET中,通過 ThreadPool 類型提供的5個靜态方法可以擷取和設定線程池的上限和下限,同時它還額外地提供了一個方法來讓程式員獲知目前可用的線程數量,下面是這五個方法的簽名:
① static void GetMaxThreads(out int workerThreads, out int completionPortThreads)
② static void GetMinThreads(out int workerThreads, out int completionPortThreads)
③ static bool SetMaxThreads(int workerThreads, int completionPortThreads)
④ static bool SetMinThreads(int workerThreads, int completionPortThreads)
⑤ static void GetAvailableThreads(out int workerThreads, out int completionPortThreads)
下面的代碼示例示範了如何查詢線程池的上下限門檻值和可用線程數量:
class Program
{
static void Main(string[] args)
{
// 列印門檻值和可用數量
GetLimitation();
GetAvailable();
// 使用掉其中三個線程
Console.WriteLine("此處申請使用3個線程...");
ThreadPool.QueueUserWorkItem(Work);
ThreadPool.QueueUserWorkItem(Work);
ThreadPool.QueueUserWorkItem(Work);
Thread.Sleep(1000);
// 列印門檻值和可用數量
GetLimitation();
GetAvailable();
// 設定最小值
Console.WriteLine("此處修改了線程池的最小線程數量");
ThreadPool.SetMinThreads(10, 10);
// 列印門檻值
GetLimitation();
Console.ReadKey();
}
// 運作10s的方法
private static void Work(object o)
{
Thread.Sleep(10 * 1000);
}
// 列印線程池的上下限門檻值
private static void GetLimitation()
{
int maxWork, minWork, maxIO, minIO;
// 得到門檻值上限
ThreadPool.GetMaxThreads(out maxWork, out maxIO);
// 得到門檻值下限
ThreadPool.GetMinThreads(out minWork, out minIO);
// 列印門檻值上限
Console.WriteLine("線程池最多有{0}個工作者線程,{1}個IO線程", maxWork.ToString(), maxIO.ToString());
// 列印門檻值下限
Console.WriteLine("線程池最少有{0}個工作者線程,{1}個IO線程", minWork.ToString(), minIO.ToString());
Console.WriteLine("------------------------------------");
}
// 列印可用線程數量
private static void GetAvailable()
{
int remainWork, remainIO;
// 得到目前可用線程數量
ThreadPool.GetAvailableThreads(out remainWork, out remainIO);
// 列印可用線程數量
Console.WriteLine("線程池中目前有{0}個工作者線程可用,{1}個IO線程可用", remainWork.ToString(), remainIO.ToString());
Console.WriteLine("------------------------------------");
}
}
該執行個體的執行結果如下圖所示:
PS:上面代碼示例在不同的計算機上運作可能會得到不同的結果,線程池中的可用數位不會再初始時達到最大值,事實上CLR會嘗試以一定的時間間隔來逐一地建立新線程,但這個時間間隔非常短。
2.4 如何定義線程獨享的全局資料?
線程和程序最大的一個差別就在于線程間可以共享資料和資源,而程序則充分地隔離。在很多場合,即使同一程序的多個線程之間擁有相同的記憶體空間,也需要在邏輯上為某些線程配置設定獨享的資料。例如,在實際開發中往往會針對一些ORM如EF一類的上下文實體做線程内唯一執行個體的設定,這時就需要用到下面提到的技術。
(1)線程本地存儲(Thread Local Storage,TLS)
很多時候,程式員可能會希望擁有線程内可見的變量,而不希望其他線程對其進行通路和修改(傳統方式中的靜态變量是對整個應用程式域可見的),這就需要用到TLS的概念。。
(2)定義和使用TLS變量
在.NET中提供了下列連個方法來存取線程獨享的資料,它們都定義在System.Threading.Thread類型中:
① object GetData(LocalDataStoreSlot slot)
② void SetData(LocalDataStoreSlot slot, object data)
下面的代碼示例則展示了這個機制的使用方法:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("開始測試資料插槽:");
// 建立五個線程來同時運作,但是這裡不适合用線程池,
// 因為線程池内的線程會被反複使用導緻線程ID一緻
for (int i = 0; i < 5; i++)
{
Thread thread = new Thread(ThreadDataSlot.Work);
thread.Start();
}
Console.ReadKey();
}
}
/// <summary>
/// 包含線程方法和資料插槽
/// </summary>
public class ThreadDataSlot
{
// 配置設定一個資料插槽,注意插槽本身是全局可見的,因為這裡的配置設定是在所有線程
// 的TLS内建立資料塊
private static LocalDataStoreSlot localSlot = Thread.AllocateDataSlot();
// 線程要執行的方法,操作資料插槽來存放資料
public static void Work()
{
// 将線程ID注冊到資料插槽中,一個應用程式内線程ID不會重複
Thread.SetData(localSlot, Thread.CurrentThread.ManagedThreadId);
// 檢視一下剛剛插入的資料
Console.WriteLine("線程{0}内的資料是:{1}",Thread.CurrentThread.ManagedThreadId.ToString(),Thread.GetData(localSlot).ToString());
// 這裡線程休眠1秒
Thread.Sleep(1000);
// 檢視其他線程的運作是否幹擾了目前線程資料插槽内的資料
Console.WriteLine("線程{0}内的資料是:{1}", Thread.CurrentThread.ManagedThreadId.ToString(), Thread.GetData(localSlot).ToString());
}
}
該執行個體的執行結果如下圖所示,從下圖可以看出多線程的并行運作并沒有破壞每個線程插槽内的資料,這就是TLS所提供的功能。
PS:LocalDataStoreSlot對象本身并不是線程共享的,初始化一個LocalDataStoreSlot對象意味着在應用程式域内的每個線程上都配置設定了一個資料插槽。
(3)ThreadStaticAttribute特性的使用
除了使用上面說到的資料槽之外,我們還有另一種方式,即ThreadStaticAttribute特性。申明了該特性的變量,會被.NET作為線程獨享的資料來使用。我們可以将其了解為一種被.NET封裝了的TLS機制,本質上,它仍然使用了線程環境塊來存放資料。
下面的示例代碼展示了ThreadStaticAttribute特性的使用:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("開始測試資料插槽:");
// 建立五個線程來同時運作,但是這裡不适合用線程池,
// 因為線程池内的線程會被反複使用導緻線程ID一緻
for (int i = 0; i < 5; i++)
{
Thread thread = new Thread(ThreadStatic.Work);
thread.Start();
}
Console.ReadKey();
}
}
/// <summary>
/// 包含線程靜态資料
/// </summary>
public class ThreadStatic
{
// 值類型的線程靜态資料
[ThreadStatic]
private static int threadId = 0;
// 引用類型的線程靜态資料
private static Ref refThreadId = new Ref();
/// <summary>
/// 線程執行的方法,操作線程靜态資料
/// </summary>
public static void Work()
{
// 存儲線程ID,一個應用程式域内線程ID不會重複
threadId = Thread.CurrentThread.ManagedThreadId;
refThreadId.Id = Thread.CurrentThread.ManagedThreadId;
// 檢視一下剛剛插入的資料
Console.WriteLine("[線程{0}]:線程靜态值變量:{1},線程靜态引用變量:{2}", Thread.CurrentThread.ManagedThreadId.ToString(), threadId, refThreadId.Id.ToString());
// 睡眠1s
Thread.Sleep(1000);
// 檢視其他線程的運作是否幹擾了目前線程靜态資料
Console.WriteLine("[線程{0}]:線程靜态值變量:{1},線程靜态引用變量:{2}", Thread.CurrentThread.ManagedThreadId.ToString(), threadId, refThreadId.Id.ToString());
}
}
/// <summary>
/// 簡單引用類型
/// </summary>
public class Ref
{
private int id;
public int Id
{
get
{
return id;
}
set
{
id = value;
}
}
}
該執行個體的執行結果如下圖所示,正如我們所看到的,對于使用了ThreadStatic特性的字段,.NET會将其作為線程獨享的資料來處理,當某個線程對一個使用了ThreadStatic特性的字段進行指派後,這個值隻有這個線程自己可以看到并通路修改,該值對于其他線程時不可見的。相反,沒有标記該特性的,則會被多個線程所共享。
2.5 如何使用異步模式讀取一個檔案?
異步模式是在處理流類型時經常采用的一種方式,其應用的領域相當廣闊,包括讀寫檔案、網絡傳輸、讀寫資料庫,甚至可以采用異步模式來做任何計算工作。相對于手動編寫線程代碼,異步模式是一個高效的程式設計模式。
(1)所謂異步模式是個什麼鬼?
所謂的異步模式,是指在啟動一個操作之後可以繼續執行其他工作而不會發生阻塞。以讀取檔案為例,在同步模式下,當程式執行到Read方法時,需要等到讀取動作結束後才能繼續往下執行。而異步模式則可以簡單地通知開始讀取任務之後,繼續其他的操作。 異步模式的優點就在于不需要使目前線程等待,而可以充分地利用CPU時間。
PS:異步模式差別于線程池機制的地方在于其允許程式檢視操作的執行狀态,而如果利用線程池的背景線程,則無法确切地知道操作的進行狀态以及其是否已經結束。
使用異步模式可以通過一些異步聚集技巧來檢視異步操作的結果,所謂的聚集技巧是指檢視操作是否結束的方法,常用的方式是:在調用BeingXXX方法時傳入操作結束後需要執行的方法(又稱為回調方法),同時把執行異步操作的對象傳入以便執行EndXXX方法。
(2)使用異步模式讀取一個檔案
下面的示例代碼中:
① 主線程中負責開始異步讀取并傳入聚集時需要使用的方法和狀态對象:
partial class Program
{
// 測試檔案
private const string testFile = @"C:\AsyncReadTest.txt";
private const int bufferSize = 1024;
static void Main(string[] args)
{
// 删除已存在檔案
if (File.Exists(testFile))
{
File.Delete(testFile);
}
// 寫入一些東西以便後面讀取
using (FileStream stream = File.Create(testFile))
{
string content = "我是檔案具體内容,我是不是帥得掉渣?";
byte[] contentByte = Encoding.UTF8.GetBytes(content);
stream.Write(contentByte, 0, contentByte.Length);
}
// 開始異步讀取檔案具體内容
using (FileStream stream = new FileStream(testFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, FileOptions.Asynchronous))
{
byte[] data = new byte[bufferSize];
// 将自定義類型對象執行個體作為參數
ReadFileClass rfc = new ReadFileClass(stream, data);
// 開始異步讀取
IAsyncResult result = stream.BeginRead(data, 0, data.Length, FinshCallBack, rfc);
// 模拟做了一些其他的操作
Thread.Sleep(3 * 1000);
Console.WriteLine("主線程執行完畢,按Enter鍵退出程式");
}
Console.ReadKey();
}
}
② 定義了完成異步操作讀取之後需要調用的方法,其邏輯是簡單地列印出檔案的内容:
partial class Program
{
/// <summary>
/// 完成異步操作後的回調方法
/// </summary>
/// <param name="result">狀态對象</param>
private static void FinshCallBack(IAsyncResult result)
{
ReadFileClass rfc = result.AsyncState as ReadFileClass;
if (rfc != null)
{
// 必須的步驟:讓異步讀取占用的資源被釋放掉
int length = rfc.stream.EndRead(result);
// 擷取讀取到的檔案内容
byte[] fileData = new byte[length];
Array.Copy(rfc.data, 0, fileData, 0, fileData.Length);
string content = Encoding.UTF8.GetString(fileData);
// 列印讀取到的檔案基本資訊
Console.WriteLine("讀取檔案結束:檔案長度為[{0}],檔案内容為[{1}]", length.ToString(), content);
}
}
}
③ 定義了作為狀态對象傳遞的類型,這個類型對所有需要傳遞的資料包進行打包:
/// <summary>
/// 傳遞給異步操作的回調方法
/// </summary>
public class ReadFileClass
{
// 以便回調方法中釋放異步讀取的檔案流
public FileStream stream;
// 檔案内容
public byte[] data;
public ReadFileClass(FileStream stream,byte[] data)
{
this.stream = stream;
this.data = data;
}
}
下圖展示了該執行個體的執行結果:
如上面的執行個體,使用回調方法的異步模式需要花費一點額外的代碼量,因為它需要将異步操作的對象及操作的結果資料都打包到一個類型裡以便能夠傳遞回給回調的委托方法,這樣在委托方法中才能夠有機會處理操作的結果,并且調用EndXXX方法以釋放資源。
2.6 如何阻止線程執行上下文的傳遞?
(1)何為線程的執行上下文
在.NET中,每一個線程都會包含一個執行上下文,執行上下文是指線程運作中某時刻的上下文概念,類似于一個動态過程的(SnapShot)。在.NET中,System.Threading中的ExecutionContext類型代表了一個執行上下文,該執行上下文會包含:安全上下文、調用上下文、本地化上下文、事務上下文和CLR宿主上下文等等。通常情況下,我們将所有這些綜合成為線程的上下文。
(2)執行上下文的流動
當程式中建立一個線程時,執行上下文會自動地從目前線程流入到建立的線程之中,這樣做可以保證建立的線程天生就就有和主線程相同的安全設定和文化等設定。下面的示例代碼通過修改安全上下文來展示線程上下文的流動性,主要使用到ExecutionContext類的Capture方法來捕獲目前想成的執行上下文。
① 首先定義一些輔助犯法,封裝了檔案的建立、删除和檔案通路權限檢查:
partial class Program
{
private static void CreateTestFile()
{
if (!File.Exists(testFile))
{
FileStream stream = File.Create(testFile);
stream.Dispose();
}
}
private static void DeleteTestFile()
{
if (File.Exists(testFile))
{
File.Delete(testFile);
}
}
// 嘗試通路測試檔案來測試安全上下文
private static void JudgePermission(object state)
{
try
{
// 嘗試通路檔案
File.GetCreationTime(testFile);
// 如果沒有異常則測試通過
Console.WriteLine("權限測試通過");
}
catch (SecurityException)
{
// 如果出現異常則測試通過
Console.WriteLine("權限測試沒有通過");
}
finally
{
Console.WriteLine("------------------------");
}
}
}
② 其次在入口方法中使主線程和建立的子線程通路指定檔案來檢視權限上下文流動到子線程中的情況:(這裡需要注意的是由于在.NET 4.0及以上版本中FileIOPermission的Deny方法已過時,為了友善測試,将程式的.NET版本調整為了3.5)
partial class Program
{
private const string testFile = @"C:\TestContext.txt";
static void Main(string[] args)
{
try
{
CreateTestFile();
// 測試目前線程的安全上下文
Console.WriteLine("主線程權限測試:");
JudgePermission(null);
// 建立一個子線程 subThread1
Console.WriteLine("子線程權限測試:");
Thread subThread1 = new Thread(JudgePermission);
subThread1.Start();
subThread1.Join();
// 現在修改安全上下文,阻止檔案通路
FileIOPermission fip = new FileIOPermission(FileIOPermissionAccess.AllAccess, testFile);
fip.Deny();
Console.WriteLine("已成功阻止檔案通路");
// 測試目前線程的安全上下文
Console.WriteLine("主線程權限測試:");
JudgePermission(null);
// 建立一個子線程 subThread2
Console.WriteLine("子線程權限測試:");
Thread subThread2 = new Thread(JudgePermission);
subThread2.Start();
subThread2.Join();
// 現在修改安全上下文,允許檔案通路
SecurityPermission.RevertDeny();
Console.WriteLine("已成功恢複檔案通路");
// 測試目前線程安全上下文
Console.WriteLine("主線程權限測試:");
JudgePermission(null);
// 建立一個子線程 subThread3
Console.WriteLine("子線程權限測試:");
Thread subThread3 = new Thread(JudgePermission);
subThread3.Start();
subThread3.Join();
Console.ReadKey();
}
finally
{
DeleteTestFile();
}
}
}
該執行個體的執行結果如下圖所示,從圖中可以看出程式中通過FileIOPermission對象來控制對主線程對檔案的通路權限,并且通過建立子線程來檢視主線程的安全上下文的改變是否會影響到子線程。
正如剛剛說到,主線程的安全上下文将作為執行上下文的一部分由主線程傳遞給子線程。
(3)阻止上下文的流動
有的時候,系統需要子線程擁有新的上下文。抛開功能上的需求,執行上下文的流動确實使得程式的執行效率下降很多,線程上下文的包裝是一個成本較高的工作,而有的時候這樣的包裝并不是必須的。在這種情況下,我們如果需要手動地防止線程上下文的流動,常用的有下列兩種方法:
① System.Threading.ThreadPool類中的UnsafeQueueUserWorkItem方法
② ExecutionContext類中的SuppressFlow方法
下面的代碼示例展示了如何使用上面兩種方法阻止執行上下文的流動:
partial class Program
{
private const string testFile = @"C:\TestContext.txt";
static void Main(string[] args)
{
try
{
CreateTestFile();
// 現在修改安全上下文,阻止檔案通路
FileIOPermission fip = new FileIOPermission(FileIOPermissionAccess.AllAccess, testFile);
fip.Deny();
Console.WriteLine("已成功阻止檔案通路");
// 主線程權限測試
Console.WriteLine("主線程權限測試:");
JudgePermission(null);
// 使用UnsafeQueueUserWorkItem方法建立一個子線程
Console.WriteLine("子線程權限測試:");
ThreadPool.UnsafeQueueUserWorkItem(JudgePermission, null);
Thread.Sleep(1000);
// 使用SuppressFlow方法
using (var afc = ExecutionContext.SuppressFlow())
{
// 測試目前線程安全上下文
Console.WriteLine("主線程權限測試:");
JudgePermission(null);
// 建立一個子線程 subThread1
Console.WriteLine("子線程權限測試:");
Thread subThread1 = new Thread(JudgePermission);
subThread1.Start();
subThread1.Join();
}
// 現在修改安全上下文,允許檔案通路
SecurityPermission.RevertDeny();
Console.WriteLine("已成功恢複檔案通路");
// 測試目前線程安全上下文
Console.WriteLine("主線程權限測試:");
JudgePermission(null);
// 建立一個子線程 subThread2
Console.WriteLine("子線程權限測試:");
Thread subThread2 = new Thread(JudgePermission);
subThread2.Start();
subThread2.Join();
Console.ReadKey();
}
finally
{
DeleteTestFile();
}
}
}
該執行個體的執行結果如下圖所示,可以看出,通過前面的兩種方式有效地阻止了主線程的執行上下文流動到建立的線程之中,這樣的機制對于性能的提高有一定的幫助。
三、多線程程式設計中的線程同步
3.1 了解同步塊和同步塊索引
同步塊是.NET中解決對象同步問題的基本機制,該機制為每個堆内的對象(即引用類型對象執行個體)配置設定一個同步索引,該索引中隻儲存一個表明數組内索引的整數。具體過程是:。下圖展現了這一機制的實作:
同步塊機制包含以下幾點:
① 在.NET被加載時初始化同步塊數組;
② 每一個被配置設定在堆上的對象都會包含兩個額外的字段,其中一個存儲類型指針,而另外一個就是同步塊索引,初始時被指派為-1;
③ 當一個線程試圖使用該對象進入同步時,會檢查該對象的同步索引:
如果同步索引為負數,則會在同步塊數組中建立一個同步塊,并且将該同步塊的索引值寫入該對象的同步索引中;
如果同步索引不為負數,則找到該對象的同步塊并檢查是否有其他線程在使用該同步塊,如果有則進入等待狀态,如果沒有則申明使用該同步塊;
④ 當一個對象退出同步時,該對象的同步索引被修改為-1,并且相應的同步塊數組中的同步塊被視為不再使用。
3.2 C#中的lock關鍵字有啥作用?
lock關鍵字可能是我們在遇到線程同步的需求時最常用的方式,但lock隻是一個文法糖,為什麼這麼說呢,下面慢慢道來。
(1)lock的等效代碼其實是Monitor類的Enter和Exit兩個方法
private object locker = new object();
public void Work()
{
lock (locker)
{
// 做一些需要線程同步的工作
}
}
事實上,lock關鍵字時一個友善程式員使用的文法糖,它等效于安全地使用System.Threading.Monitor類型,它直接等效于下面的代碼:
private object locker = new object();
public void Work()
{
// 避免直接使用私有成員locker(直接使用有可能會導緻線程不安全)
object temp = locker;
Monitor.Enter(temp);
try
{
// 做一些需要線程同步的工作
}
finally
{
Monitor.Exit(temp);
}
}
(2)System.Threading.Monitor類型的作用和使用
Monitor類型的Enter和Exit方法用來實作進入和退出對象的同步,當Enter方法被調用時,對象的同步索引将被檢查,并且.NET将負責一系列的後續工作來保證對象通路時的線程同步,而Exit方法的調用則保證了目前線程釋放該對象的同步塊。
下面的代碼示例示範了如何使用lock關鍵字來實作線程同步:
class Program
{
static void Main(string[] args)
{
// 多線程測試靜态方法的同步
Console.WriteLine("開始測試靜态方法的同步:");
for (int i = 0; i < 5; i++)
{
Thread thread = new Thread(Lock.StaticIncrement);
thread.Start();
}
// 這裡等待線程執行結束
Thread.Sleep(5 * 1000);
Console.WriteLine("-------------------------------");
// 多線程測試執行個體方法的同步
Console.WriteLine("開始測試執行個體方法的同步:");
Lock l = new Lock();
for (int i = 0; i < 6; i++)
{
Thread thread = new Thread(l.InstanceIncrement);
thread.Start();
}
Console.ReadKey();
}
}
public class Lock
{
// 靜态方法同步鎖
private static object staticLocker = new object();
// 執行個體方法同步鎖
private object instanceLocker = new object();
// 成員變量
private static int staticNumber = 0;
private int instanceNumber = 0;
// 測試靜态方法的同步
public static void StaticIncrement(object state)
{
lock (staticLocker)
{
Console.WriteLine("目前線程ID:{0}", Thread.CurrentThread.ManagedThreadId.ToString());
Console.WriteLine("staticNumber的值為:{0}", staticNumber.ToString());
// 這裡可以制造線程并行執行的機會,來檢查同步的功能
Thread.Sleep(200);
staticNumber++;
Console.WriteLine("staticNumber自增後為:{0}", staticNumber.ToString());
}
}
// 測試執行個體方法的同步
public void InstanceIncrement(object state)
{
lock (instanceLocker)
{
Console.WriteLine("目前線程ID:{0}",Thread.CurrentThread.ManagedThreadId.ToString());
Console.WriteLine("instanceNumber的值為:{0}", instanceNumber.ToString());
// 這裡可以制造線程并行執行的機會,來檢查同步的功能
Thread.Sleep(200);
instanceNumber++;
Console.WriteLine("instanceNumber自增後為:{0}", instanceNumber.ToString());
}
}
}
下圖是該執行個體的執行結果:
PS:線程同步本身違反了多線程并行運作的原則,是以我們在使用線程同步時應該盡量做到将lock加在最小的程式塊上。對于靜态方法的同步,一般采用靜态私有的引用對象成員,而對于執行個體方法的同步,一般采用私有的引用對象成員。
3.3 可否使用值類型對象來實作線程同步嗎?
前面已經說到,在.NET中每個堆内的對象都會有一個同步索引字段,用以指向同步塊的位置。但是,對于值類型來說,它們的對象是配置設定在堆棧上的,也就是說值類型是沒有同步索引這一字段的,是以直接使用值類型對象無法實作線程同步。
如果在程式中對于lock關鍵字使用了值類型對象,會直接導緻一個編譯錯誤:
3.4 可否使用引用類型對象自身進行同步?
引用類型的對象是配置設定在堆上的,必然會包含同步索引,也可以配置設定同步塊,是以原則上可以在對象的方法内對自身進行同步。而事實上,這樣的代碼也确實能有效地保證線程同步。But,這樣的代碼健壯性存在一定問題。
(1)lock(this)
回顧lock(this)的設計,就可以看出問題來:this代表了執行代碼的目前對象,可以預見該對象可以被任何使用者通路,這就導緻了不僅對象内部的代碼在争用同步塊,連類型的使用者也可以有意無意地進入到争用的隊伍中→這顯然不符合設計意圖。
下面通過一個代碼示例展示了一個惡意的使用者是如何導緻類型死鎖的:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("開始使用");
SynchroThis st = new SynchroThis();
// 模拟惡意的使用者
Monitor.Enter(st);
// 正常的使用者會收到惡意使用者的影響
// 下面的代碼完全正确,但卻被死鎖
Thread thread = new Thread(st.Work);
thread.Start();
thread.Join();
// 程式不會執行到這裡
Console.WriteLine("使用結束");
Console.ReadKey();
}
}
public class SynchroThis
{
private int number = 0;
public void Work(object state)
{
lock (this)
{
Console.WriteLine("number現在的值為:{0}", number.ToString());
number++;
// 模拟做了其他工作
Thread.Sleep(200);
Console.WriteLine("number自增後值為:{0}", number.ToString());
}
}
}
運作這個示例,我們發現程式完全被死鎖,這是因為一個惡意的使用者在使用了同步塊之後卻沒有對其進行釋放,導緻了SynchroThis類型的方法被組織。
(2)lock(typeof(類型名))
這樣的設計有時候會被用來在靜态方法中實作線程同步,因為靜态方法的通路需要通過類型來進行,但它也和lock(this)一樣,缺乏健壯性。下面展示了常見的錯誤使用代碼示例:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("開始使用");
SynchroThis st = new SynchroThis();
// 模拟惡意的使用者
Monitor.Enter(typeof(SynchroThis));
// 正常的使用者會收到惡意使用者的影響
// 下面的代碼完全正确,但卻被死鎖
Thread thread = new Thread(SynchroThis.Work);
thread.Start();
thread.Join();
// 程式不會執行到這裡
Console.WriteLine("使用結束");
Console.ReadKey();
}
}
public class SynchroThis
{
private static int number = 0;
public static void Work(object state)
{
lock (typeof(SynchroThis))
{
Console.WriteLine("number現在的值為:{0}", number.ToString());
number++;
// 模拟做了其他工作
Thread.Sleep(200);
Console.WriteLine("number自增後值為:{0}", number.ToString());
}
}
}
可以發現,當一個惡意的使用者對type對象進行同步時,也會造成所有的使用者被死鎖。
PS:應該完全避免使用this對象和目前類型對象作為同步對象,而應該在類型中定義私有的同步對象,同時應該使用lock而不是Monitor類型,這樣可以有效地減少同步塊不被釋放的情況。
3.5 互斥體是個什麼鬼?Mutex和Monitor兩個類型的功能有啥差別?
(1)什麼是互斥體?
在作業系統中,互斥體(Mutex)是指某些代碼片段在任意時間内隻允許一個線程進入。例如,正在進行一盤棋,任意時刻隻允許一個棋手往棋盤上落子,這和線程同步的概念基本一緻。
(2).NET中的互斥體
Mutex類是.NET中為我們封裝的一個互斥體類型,和Mutex類似的還有Semaphore(信号量)等類型。下面的示例代碼展示了Mutext類型的使用:
class Program
{
const string testFile = "C:\\TestMutex.txt";
/// <summary>
/// 這個互斥體保證所有的程序都能得到同步
/// </summary>
static Mutex mutex = new Mutex(false, "TestMutex");
static void Main(string[] args)
{
//留出時間來啟動其他程序
Thread.Sleep(3000);
DoWork();
mutex.Close();
Console.ReadKey();
}
/// <summary>
/// 往檔案裡寫連續的内容
/// </summary>
static void DoWork()
{
long d1 = DateTime.Now.Ticks;
mutex.WaitOne();
long d2 = DateTime.Now.Ticks;
Console.WriteLine("經過了{0}個Tick後程序{1}得到互斥體,進入臨界區代碼。", (d2 - d1).ToString(), Process.GetCurrentProcess().Id.ToString());
try
{
if (!File.Exists(testFile))
{
FileStream fs = File.Create(testFile);
fs.Dispose();
}
for (int i = 0; i < 5; i++)
{
// 每次都保證檔案被關閉再重新打開
// 确定有mutex來同步,而不是IO機制
using (FileStream fs = File.Open(testFile, FileMode.Append))
{
string content = "【程序" + Process.GetCurrentProcess().Id.ToString() +
"】:" + i.ToString() + "\r\n";
Byte[] data = Encoding.Default.GetBytes(content);
fs.Write(data, 0, data.Length);
}
// 模拟做了其他工作
Thread.Sleep(300);
}
}
finally
{
mutex.ReleaseMutex();
}
}
}
模拟多個使用者,執行上述代碼,下圖就是在我的計算機上的執行結果:
現在打開C槽目錄下的TestMutext.txt檔案,将看到如下圖所示的結果:
(3)Mutex和Monitor的差別
這兩者雖然都用來進行同步的功能,但實作方法不同,其最顯著的兩個差别如下:
① Mutex使用的是作業系統的核心對象,而Monitor類型的同步機制則完全在.NET架構之下實作,這就導緻了;
② 。
3.6 如何使用信号量Semaphore?
這裡首先借用阮一峰的《程序與線程的一個簡單解釋》中的介紹來說一下Mutex和Semaphore:
一個防止他人進入的簡單方法,就是門口加一把鎖。先到的人鎖上門,後到的人看到上鎖,就在門口排隊,等鎖打開再進去。這就叫"互斥鎖"(Mutual exclusion,縮寫 Mutex),防止多個線程同時讀寫某一塊記憶體區域。
還有些房間,可以同時容納n個人,比如廚房。也就是說,如果人數大于n,多出來的人隻能在外面等着。這好比某些記憶體區域,隻能供給固定數目的線程使用。
這時的解決方法,就是在門口挂n把鑰匙。進去的人就取一把鑰匙,出來時再把鑰匙挂回原處。後到的人發現鑰匙架空了,就知道必須在門口排隊等着了。這種做法叫做"信号量"(Semaphore),用來保證多個線程不會互相沖突。
不難看出,mutex是semaphore的一種特殊情況(n=1時)。也就是說,完全可以用後者替代前者。但是,因為mutex較為簡單,且效率高,是以在必須保證資源獨占的情況下,還是采用這種設計。
現在我們知道了Semaphore是幹啥的了,再把目光放到.NET中的Sempaphore上。Semaphore 繼承自WaitHandle(Mutex也繼承自WaitHandle),它用于鎖機制,與Mutex不同的是,它。Semaphore很适合應用于Web伺服器這樣的高并發場景,可以限制對資源通路的線程數。此外,Sempaphore不需要一個鎖的持有者,通常也将Sempaphore聲明為靜态的。
下面的示例代碼示範了4條線程想要同時執行ThreadEntry()方法,但同時隻允許2條線程進入:
class Program
{
// 第一個參數指定目前有多少個“空位”(允許多少條線程進入)
// 第二個參數指定一共有多少個“座位”(最多允許多少個線程同時進入)
static Semaphore sem = new Semaphore(2, 2);
const int threadSize = 4;
static void Main(string[] args)
{
for (int i = 0; i < threadSize; i++)
{
Thread thread = new Thread(ThreadEntry);
thread.Start(i + 1);
}
Console.ReadKey();
}
static void ThreadEntry(object id)
{
Console.WriteLine("線程{0}申請進入本方法", id);
// WaitOne:如果還有“空位”,則占位,如果沒有空位,則等待;
sem.WaitOne();
Console.WriteLine("線程{0}成功進入本方法", id);
// 模拟線程執行了一些操作
Thread.Sleep(100);
Console.WriteLine("線程{0}執行完畢離開了", id);
// Release:釋放一個“空位”
sem.Release();
}
}
上面示例的執行結果如下圖所示:
如果将資源比作“座位”,Semaphore接收的兩個參數中:第一個參數指定目前有多少個“空位”(允許多少條線程進入),第二個參數則指定一共有多少個“座位”(最多允許多少個線程同時進入)。WaitOne()方法則表示如果還有“空位”,則占位,如果沒有空位,則等待;Release()方法則表示釋放一個“空位”。
感歎一下:人生中有很多人在你的城堡中進進出出,城中的人想出去,城外的人想沖進來。But,一個人身邊的位置隻有那麼多,你能給的也隻有那麼多,在這個狹小的圈子裡,有些人要進來,就有一些人不得不離開。