如果你有在任何編程語言下的多線程編程經驗的話,你肯定已經非常熟悉一些典型的範例。通常,多線程編程與基於用戶界面的應用聯繫在一起,它們需要在不影響終端用戶的情況下,執行一些耗時的操作。取出任何一本參考書,打開有關線程這一章:你能找到一個能在你的用戶界面中並行執行數學運算的多線程示例嗎?
我的目的不是讓你扔掉你的書,不要這樣做!多線程編程技術使基於用戶界面的應用更完美。實際上, Microsoft .NET 框架支持在任何語言編寫的窗口下應用多線程編程技術,允許開發人員設計非常豐富的界面,提供給終端用戶一個更好的體驗。但是,多線程編程技術不僅僅是爲了用戶界面的應用,在沒有任何用戶界面的應用中,一樣會出現多個執行流的情況。
我們用一個“硬件商店”的客戶 / 服務器應用系統作爲例子。客戶端是收銀機,服務端是運行在倉庫裏一臺獨立的機器上的應用系統。你可以想象一下,服務器沒有任何的用戶界面,如果不用多線程技術你將如何去實現?
服務端通過通道( http, sockets, files 等等)接收來自客戶端的請求並處理它們,然後發送一個應答到客戶端。圖 1 顯示了它是如何運作的。
圖 1 : 單線程的服務端應用系統
爲了讓客戶端的請求不會遺漏,服務端應用系統實現了某種隊列來存放這些請求。圖 1 顯示了三個請求同時到達,但只有其中的一個被服務端處理。當服務端開始執行 "Decrease stock of monkey wrench," 這個請求時,其它兩個必須在隊列中等待。當第一個執行完成後,接着是第二個,以此類推。這種方法普遍用於許多現有的系統,但是這樣做系統的資源利用率很低。假設 “ decreasing the stock ”請求修改磁盤上的一個文件,而這個文件正在被修改中, CPU 將不會被使用,即使這個請求正處在待處理階段。這類系統的一個普遍特徵就是低 CPU 利用時間導致出現很長的響應時間,甚至是在訪問壓力很大的環境裏也這樣。
另外一個策略就是在當前的系統中爲每一個請求創建不同的線程。當一個新的請求到達之後,服務端爲進入的請求創建一個新線程,執行結束時,再銷燬它。下圖說明了這個過程:
圖 2 :多線程服務端應用系統
就像如圖 2 所示的那樣。我們有了較高的 CPU 利用率。即使它已經不再像原來的那樣慢了,但創建線和銷燬程也不是最恰當的方法。假設線程的執行操作不復雜,由於需要花額外的時間去創建和銷燬線程,所以最終會嚴重影響系統的響應時間。另外一點就是在壓力很大的環境下,這三個線程會給系統帶來很多的衝擊。多個線程同時執行請求處理將導致 CPU 的利用率達到 100% ,而且大多數時間會浪費在上下文切換過程中,甚至會超過處理請求的本身。這類系統的典型特徵是大量的訪問會導致響應時間呈指數級增長和很高的 CUP 使用時間。
一個最優的實現是綜合前面兩種方案而提出的觀點 ---- 線程池( Thread Pool ),當一個請求達到時,應用系統把置入接收隊列,一組的線程從隊列提取請求並處理之。這個方案如下圖所示:
圖 3 :啓用線程池的服務端應用系統
在這個例子中,我們用了一個含有兩個線程的線程池。當三個請求到達時,它們立刻安排到隊列等待被處理,因爲兩個線程都是空閒的,所以頭兩個請求開始執行。當其中任何一個請求處理結束後,空閒的線程就會去提取第三個請求並處理之。在這種場景中,系統不需要爲每個請求創建和銷燬線程。線程之間能互相利用。而且如果線程池的執行高效的話,它能增加或刪除線程以獲得最優的性能。例如當線程池在執行兩個請求時,而 CPU 的利用率才達到 50% ,這表明執行請求正等待某個事件或者正在做某種 I/O 操作。線程池可以發現這種情況,並增加線程的數量以使系統能在同一時間處理更多的請求。相反的,如果 CPU 利用率達到 100% ,線程池可以減少線程的數量以獲得更多的 CPU 時間,而不要浪費在上下文切換上面。
.NET 中的線程池
基於上面的例子,在企業級應用系統中有一個高效執行的線程池是至關重要的。 Microsoft 在 .NET 框架的開發環境中已經實現了這個,該系統的核心提供了一個現成可用的最優線程池。
這個線程池不僅對應用程序可用,而且還融合到框架中的多數類中。 .NET 建立在同一個池上是一個很重要的功能特性。比如 .NET Remoting 用它來處理來自遠程對象的請求。
當一個託管應用程序開始執行時,運行時環境( runtime )提供一個線程池,它將在代碼第一次訪問時被創建。這個池與應用程序所在運行的物理進程關聯在一起,當你用 .NET 框架下的同一進程中運行多個應用程序的功能特性時(稱之爲應用程序域),這將是一個很重要的細節。在這種情況下,由於它們都使用同樣的線程池,一個壞的應用程序會影響進程中的其它應用程序。
你可以通過 System.Threading 名稱空間的 Thread Pool 類來使用線程池,如果你查看一下這個類,就會發現所有的成員都是靜態的,而且沒有公開的構造函數。這是有理由這樣做的,因爲每個進程只有一個線程池,並且我們不能創建新的。這個限制的目的是爲了把所有的異步編程技術都集中到同一個池中。所以我們不能擁有一個通過第三方組建創建的無法管理的線程池。
ThreadPool.QueueUserWorkItem 方法運行我們在系統線程池上啓動一個函數,它的聲明如下:
public static bool QueueUserWorkItem (WaitCallback callBack, object state)
第一個參數指明我們將在池中執行的函數,它的聲明必須與WaitCallback
代理(delegate
)互相匹配:public delegate void WaitCallback (object state);
State 參數允許任何類型的信息傳遞到該方法中,它在調用 QueueUserWorkItem 時傳入。
讓我們結合這些新概念,看看“硬件商店”的另一個實現。
using System;
using System.Threading;
namespace ThreadPoolTest
{
class MainApp
{
static void Main()
{
WaitCallback callBack;
callBack = new WaitCallback(PooledFunc);
ThreadPool.QueueUserWorkItem(callBack,
"Is there any screw left?");
ThreadPool.QueueUserWorkItem(callBack,
"How much is a 40W bulb?");
ThreadPool.QueueUserWorkItem(callBack,
"Decrease stock of monkey wrench");
Console.ReadLine();
}
static void PooledFunc(object state)
{
Console.WriteLine("Processing request '{0}'", (string)state);
// Simulation of processing time
Thread.Sleep(2000);
Console.WriteLine("Request processed");
}
}
}
爲了簡化例子,我們在 Main 類中創建一個靜態方法用於處理請求。由於代理的靈活性,我們可以指定任何實例方法去處理請求,只要這些方法的聲明與代理相同。在這裏範例中,通過調用 Thread.Sleep ,實現延遲兩秒以模擬處理時間。
你如果編譯和執行這個範例,將會看到下面的輸出:
Processing request 'Is there any screw left?'
Processing request 'How much is a 40W bulb?'
Processing request 'Decrease stock of monkey wrench'
Request processed
Request processed
Request processed
注意,所有的請求都被不同的線程並行處理了。
我們可以通過在兩個方法中加入如下的代碼,以此看到更多的信息。
// Main method
Console.WriteLine("Main thread. Is pool thread: {0}, Hash: {1}",
Thread.CurrentThread.IsThreadPoolThread,
Thread.CurrentThread.GetHashCode());
// Pool method
Console.WriteLine("Processing request '{0}'." +
" Is pool thread: {1}, Hash: {2}",
(string)state, Thread.CurrentThread.IsThreadPoolThread,
Thread.CurrentThread.GetHashCode());
我們增加了一個 Thread.CurrentThread.IsThreadPoolThread 的調用。如果目標線程屬於線程池,這個屬性將返回 True 。另外,我們還顯示了用 GetHashCode 方法從當前線程返回的結果。它是唯一標識當前執行線程的值。現在看一看這個輸出結果:
Main thread. Is pool thread: False, Hash: 2
Processing request 'Is there any screw left?'. Is pool thread: True, Hash: 4
Processing request 'How much is a 40W bulb?'. Is pool thread: True, Hash: 8
Processing request 'Decrease stock of monkey wrench '. Is pool thread: True, Hash: 9
Request processed
Request processed
Request processed
你可以看到所有的請求都被系統線程池中的不同線程執行。再次運行這個例子,注意系統 CPU 的利用率,如果你沒有任何其它應用程序在後臺運行的話,它幾乎是 0% 。因爲系統唯一正在做的是每執行 2 秒後就掛起的處理。
我們來修改一下這個應用,這次我們不掛起處理請求的線程,相反我們會一直讓系統忙,爲了做到這點,我們用 Environment.TickCount . 構建一個每隔兩秒就對請求執行一次的循環。
int ticks = Environment.TickCount;
while(Environment.TickCount - ticks < 2000);
現在打開任務管理器,看一看 CPU 的使用率,你將看到應用程序佔有了 CPU 的 100 %的使用率。再看一下我們程序的輸出結果:
Processing request 'Is there any screw left?'. Is pool thread: True, Hash: 7
Processing request 'How much is a 40W bulb?'. Is pool thread: True, Hash: 8
Request processed
Processing request 'Decrease stock of monkey wrench '. Is pool thread: True, Hash: 7
Request processed
Request processed
注意第三個請求是在第一個請求處理結束之後執行的,而且線程的號碼仍然用原來的 7 ,這個原因是線程池檢測到 CPU 的使用率已經達到 100 %,一直等待某個線程空閒。它並不會重新創建一個新的線程,這樣就會減少線程間的上下文切換開銷,以使總體性能更佳。
假如你曾經開發過 Microsoft Win32 的應用程序,你知道 SetTimer 函數是 API 之一,通過這個函數可以指定的一個窗口接收到來自系統時間週期的 WM_TIMER 消息。用這個方法遇到的第一個問題是你需要一個窗口去接收消息,所以你不能用在控制檯應用程序中。另外,基於消息的實現並不是非常精確,假如你的應用程序正在處理其它消息,情況有可能更糟糕。
相對基於 Win32 的定時器來說, .NET 中一個很重要的改進就是創建不同的線程,該線程阻塞指定的時間,然後通知一個回調函數。這裏的定時器不需要 Microsoft 的消息系統,所以這樣就更精確,而且還能用於控制檯應用程序中。以下代碼顯示了這個技術的一種實現:
class MainApp
{
static void Main()
{
MyTimer myTimer = new MyTimer(2000);
Console.ReadLine();
}
}
class MyTimer
{
int m_period;
public MyTimer(int period)
{
Thread thread;
m_period = period;
thread = new Thread(new ThreadStart(TimerThread));
thread.Start();
}
void TimerThread()
{
Thread.Sleep(m_period);
OnTimer();
}
void OnTimer()
{
Console.WriteLine("OnTimer");
}
}
這個代碼一般用於 Wn32 應用中。每個定時器創建獨立的線程,並且等待指定的時間,然後呼叫回調函數。猶如你看到的那樣,這個實現的成本會非常高。如果你的應用程序使用了多個定時器,相對的線程數量也會隨着使用定時器的數量而增長。
現在我們有 .NET 提供的線程池,我們可以從池中改變請求的等待函數,這樣就十分有效,而且會提升系統的性能。我們會遇到兩個問題:
n 假如線程池已滿(所有的線程都在運行中),那麼這個請求排到隊列中等待,而且定時器不在精確。
n 假如創建了多個定時器,線程池會因爲等待它們時間片失效而非常忙。
爲了避免這些問題, .NET 框架的線程池提供了獨立於時間的請求。用了這個函數,我們可以不用任何線程就可以擁有成千上萬個定時器,一旦時間片失效,這時,線程池將會處理這些請求。
這些特色出現在兩個不同的類中:
System.Threading.Timer
定時器的簡單版本,它運行開發人員向線程池中的定期執行的程序指定一個代理( delegate ) .
System.Timers.Timer
System.Threading.Timer 的組件版本,允許開發人員把它拖放到一個窗口表單( form )中,可以把一個事件作爲執行的函數。
這非常有助於理解上述兩個類與另外一個稱爲 System.Windows.Forms.Timer . 的類。這個類只是封裝了 Win32 中消息機制的計數器,如果你不準備開發多線程應用,那麼就可以用這個類。
在下面的例子中,我們將用 System.Threading.Timer 類,定時器的最簡單實現,我們只需要如下定義的構造方法
public Timer(TimerCallback callback,
object state,
int dueTime,
int period);
對於第一個參數( callback ),我們可以指定定時執行的函數;第二個參數是傳遞給函數的通用對象;第三個參數是計時器開始執行前的延時;最後一個參數 period ,是兩個執行之間的毫秒數。
下面的例子創建了兩個定時器, timer1 和 timer2 :
class MainApp
{
static void Main()
{
Timer timer1 = new Timer(new TimerCallback(OnTimer), 1, 0, 2000);
Timer timer2 = new Timer(new TimerCallback(OnTimer), 2, 0, 3000);
Console.ReadLine();
}
static void OnTimer(object obj)
{
Console.WriteLine("Timer: {0} Thread: {1} Is pool thread: {2}",
(int)obj,
Thread.CurrentThread.GetHashCode(),
Thread.CurrentThread.IsThreadPoolThread);
}
}
輸出:
Timer: 1 Thread: 2 Is pool thread: True
Timer: 2 Thread: 2 Is pool thread: True
Timer: 1 Thread: 2 Is pool thread: True
Timer: 2 Thread: 2 Is pool thread: True
Timer: 1 Thread: 2 Is pool thread: True
Timer: 1 Thread: 2 Is pool thread: True
Timer: 2 Thread: 2 Is pool thread: True
猶如你看到的那樣,兩個定時器中的所有函數調用都在同一個線程中執行( ID = 2 ),應用程序使用的資源最小化了。
相對於定時器, .NET 線程池允許在執行函數上同步對象,爲了在多線程環境中的各線程之間共享資源,我們需要用 .NET 同步對象。
如果我們沒有線程,或者線程必須阻塞直到事件收到信號,就像我前面提到一樣,這會增加應用程序中總的線程數量,結果導致系統需要更多的資源和 CPU 時間。
線程池允許我們把請求進行排隊,直到某個特殊的同步對象收到信號後執行。如果這個信號沒有收到,請求函數將不需要任何線程,所以可以保證系統性能最優化。 ThreadPool 類提供了下面的方法:
public static RegisteredWaitHandle RegisterWaitForSingleObject(
WaitHandle waitObject,
WaitOrTimerCallback callBack,
object state,
int millisecondsTimeOutInterval,
bool executeOnlyOnce);
第一個參數 ,waitObject 可以是任何繼承於 WaitHandle 的對象:
Mutex
ManualResetEvent
AutoResetEvent
就像你看到的那樣,只有系統的同步對象才能用在這裏,就是繼承自 WaitHandle 的對象。你不能用其它任何的同步機制,比如 moniter 或者 read-write 鎖。 剩餘的參數允許我們指明當一個對象收到信號後執行的函數( callBack ) ;一個傳遞給函數的狀態 (state ); 線程池等待對象的最大時間 ( millisecondsTimeOutInterval ) 和一個標識表明對象收到信號時函數只能執行一次, (executeOnlyOnce ). 下面的代理聲明目的是用在函數的回調:
delegate void WaitOrTimerCallback(
object state,
bool timedOut);
如果參數 timeout
設置的最大時間已經失效,但是沒有同步對象收到信號的花,這個函數就會被調用。
下面的例子用了一個手工事件和一個互斥量來通知線程池中的執行函數:
class MainApp
{
static void Main(string[] args)
{
ManualResetEvent evt = new ManualResetEvent(false);
Mutex mtx = new Mutex(true);
ThreadPool.RegisterWaitForSingleObject(evt,
new WaitOrTimerCallback(PoolFunc),
null, Timeout.Infinite, true);
ThreadPool.RegisterWaitForSingleObject(mtx,
new WaitOrTimerCallback(PoolFunc),
null, Timeout.Infinite, true);
for(int i=1;i<=5;i++)
{
Console.Write("{0}...", i);
Thread.Sleep(1000);
}
Console.WriteLine();
evt.Set();
mtx.ReleaseMutex();
Console.ReadLine();
}
static void PoolFunc(object obj, bool TimedOut)
{
Console.WriteLine("Synchronization object signaled, Thread: {0} Is pool: {1}",
Thread.CurrentThread.GetHashCode(),
Thread.CurrentThread.IsThreadPoolThread);
}
}
結束顯示兩個函數都在線程池的同一線程中執行:
1...2...3...4...5...
Synchronization object signaled, Thread: 6 Is pool: True
Synchronization object signaled, Thread: 6 Is pool: True
異步 I/O 操作
線程池常見的應用場景就是 I/O 操作。多數應用系統需要讀磁盤,數據發送到 Sockets ,因特網連接等等。所有的這些操作都有一些特徵,直到他們執行操作時,才需要 CPU 時間。 .NET 框架爲所有這些可能執行的異步操作提供了 I/O 類。當這些操作執行完後,線程池中特定的函數會執行。尤其是在服務器應用程序中執行多線程異步操作,性能會更好。
在第一個例子中,我們將把一個文件異步寫到硬盤中。看一看 FileStream 的構造方法是如何使用的:
public FileStream(
string path,
FileMode mode,
FleAccess access,
FleShare share,
int bufferSize,
bool useAsync);
最後一個參數非常有趣,我們應該對異步執行文件的操作設置 useAsync 爲 True 。如果我們沒有這樣做,即使我們用了異步函數,它們的操作仍然會被主叫線程阻塞。
下面的例子說明了用一旦 FileStream BeginWrite 方法寫文件操作結束,線程池中的一個回調函數將會被執行。注意我們可以在任何時候訪問 IAsyncResult 接口,它可以用來了解當前操作的狀態。我們可以用 CompletedSynchronously 屬性指示一個異步操作是否完成,而當一個操作結束時, IsCompleted 屬性會設上一個值。 IAsyncResult 提供了很多有趣的屬性,比如: AsyncWaitHandle ,一旦操作完成,一個異步對象將會被通知。
class MainApp
{
static void Main()
{
const string fileName = "temp.dat";
FileStream fs;
byte[] data = new Byte[10000];
IAsyncResult ar;
fs = new FileStream(fileName,
FileMode.Create,
FileAccess.Write,
FileShare.None,
1,
true);
ar = fs.BeginWrite(data, 0, 10000,
new AsyncCallback(UserCallback), null);
Console.WriteLine("Main thread:{0}",
Thread.CurrentThread.GetHashCode());
Console.WriteLine("Synchronous operation: {0}",
ar.CompletedSynchronously);
Console.ReadLine();
}
static void UserCallback(IAsyncResult ar)
{
Console.Write("Operation finished: {0} on thread ID:{1}, is pool: {2}",
ar.IsCompleted,
Thread.CurrentThread.GetHashCode(),
Thread.CurrentThread.IsThreadPoolThread);
}
}
輸出的結果顯示了操作是異步執行的,一旦操作結束後,用戶的函數就在線程池中執行。
Main thread:9
Synchronous operation: False
Operation finished: True on thread ID:10, is pool: True
在應用 Sockets 的場景中,由於 I/O 操作通常比磁盤操作慢,這時用線程池就顯得尤爲重要。過程跟前面提到的差不多, Socket 類提供了多個方法用於執行異步操作:
BeginRecieve
BeginSend
BeginConnect
BeginAccept
假如你的服務器應用使用了 Socket 來與客戶端通訊,一定會用到這些方法。這種方法取代了對每個客戶端連接都啓用一個線程的做法,所有的操作都在線程池中異步執行。
下面的例子用另外一個支持異步操作的類, HttpWebRequest 。 用這個類,我們可以建立一個到 Web 服務器的連接。這個方法叫 BeginGetResponse , 但在這個例子中有一個很重要的區別。在上面最後一個示例中,我們沒有用到從操作中返回的結果。但是,我們現在需要當一個操作結束時從 Web 服務器返回的響應,爲了接收到這個信息, .NET 中所有提供異步操作的類都提供了成對的方法。在 HttpWebRequest 這個類中,這個成對的方法就是: BeginGetResponse 和 EndGetResponse 。 用了 End 版本,我們可以接收操作的結果。在我們的示例中, EndGetResponse 會從 Web 服務器接收響應。
雖然可以在任何時間調用 EndGetResponse 方法,但在我們的例子中是在回調函數中做的。僅僅是因爲我們想知道已經做了異步請求。如果我們在之前調用 EndGetResponse , 這個調用將一直阻塞到操作完成。
在下面的例子中,我們發送一個請求到 Microsoft Web ,然後顯示了接收到響應的大小。
class MainApp
{
static void Main()
{
HttpWebRequest request;
IAsyncResult ar;
request = (HttpWebRequest)WebRequest.CreateDefault(
new Uri("http://www.microsoft.com"));
ar = request.BeginGetResponse(new AsyncCallback(PoolFunc), request);
Console.WriteLine("Synchronous: {0}", ar.CompletedSynchronously);
Console.ReadLine();
}
static void PoolFunc(IAsyncResult ar)
{
HttpWebRequest request;
HttpWebResponse response;
Console.WriteLine("Response received on pool: {0}",
Thread.CurrentThread.IsThreadPoolThread);
request = (HttpWebRequest)ar.AsyncState;
response = (HttpWebResponse)request.EndGetResponse(ar);
Console.WriteLine("
Response size: {0}",
response.ContentLength);
}
}
下面剛開始結果信息表明,異步操作正在執行:
Synchronous: False
過了一會兒,響應接收到了。下面的結果顯示:
Response received on pool: True
Response size: 27331
就像你看到的那樣,一旦收到響應,線程池的異步函數就會執行。
ThreadPool
類提供了兩個方法用來查詢線程池的狀態。第一個是我們可以從線程池獲取當前可用的線程數量:
public static void GetAvailableThreads(
out int workerThreads,
out int completionPortThreads);
從方法中你可以看到兩種不同的線程:
WorkerThreads
工作線程是標準系統池的一部分。它們是被 .NET 框架託管的標準線程,多數函數是在這裏執行的。顯式的用戶請求( QueueUserWorkItem 方法),基於異步對象的方法( RegisterWaitForSingleObject )和定時器( Timer 類)
CompletionPortThreads
這種線程常常用來 I/O 操作, Windows NT, Windows 2000 和 Windows XP 提供了一個步執行的對象,叫做 IOCompletionPort 。 把 API 和異步對象關聯起來,用少量的資源和有效的方法,我們就可以調用系統線程池的異步 I/O 操作。但是在 Windows 95, Windows 98, 和 Windows Me 有一些侷限。比如: 在某些設備上,沒有提供 IOCompletionPorts 功能和一些異步操作,如磁盤和郵件槽。在這裏你可以看到 .NET 框架的最大特色:一次編譯,可以在多個系統下運行。根據不同的目標平臺, .NET 框架會決定是否使用 IOCompletionPorts API ,用最少的資源達到最好的性能。
這節包含一個使用 Socket 類的例子。在這個示例中,我們將異步建立一個連接到本地的 Web 服務器,然後發送一個 Get 請求。通過這個例子,我們可以很容易地鑑別這兩種不同的線程。
using System;
using System.Threading;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace ThreadPoolTest
{
class MainApp
{
static void Main()
{
Socket s;
IPHostEntry hostEntry;
IPAddress ipAddress;
IPEndPoint ipEndPoint;
hostEntry = Dns.Resolve(Dns.GetHostName());
ipAddress = hostEntry.AddressList[0];
ipEndPoint = new IPEndPoint(ipAddress, 80);
s = new Socket(ipAddress.AddressFamily,
SocketType.Stream, ProtocolType.Tcp);
s.BeginConnect(ipEndPoint, new AsyncCallback(ConnectCallback),s);
Console.ReadLine();
}
static void ConnectCallback(IAsyncResult ar)
{
byte[] data;
Socket s = (Socket)ar.AsyncState;
data = Encoding.ASCII.GetBytes("GET //n");
Console.WriteLine("Connected to localhost:80");
ShowAvailableThreads();
s.BeginSend(data, 0,data.Length,SocketFlags.None,
new AsyncCallback(SendCallback), null);
}
static void SendCallback(IAsyncResult ar)
{
Console.WriteLine("Request sent to localhost:80");
ShowAvailableThreads();
}
static void ShowAvailableThreads()
{
int workerThreads, completionPortThreads;
ThreadPool.GetAvailableThreads(out workerThreads,
out completionPortThreads);
Console.WriteLine("WorkerThreads: {0}," +
" CompletionPortThreads: {1}",
workerThreads, completionPortThreads);
}
}
}
如果你在 Microsoft Windows NT, Windows 2000, or Windows XP 下運行這個程序,你將會看到如下結果:
Connected to localhost:80
WorkerThreads: 24, CompletionPortThreads: 25
Request sent to localhost:80
WorkerThreads: 25, CompletionPortThreads: 24
如你所看到地那樣,連接用了工作線程,而發送數據用了一個完成端口( CompletionPort ),接着看下面的順序:
1. 我們得到一個本地 IP 地址,然後異步連接到那裏。
2. Socket 在工作線程上執行異步連接操作,因爲在 Socket 上,不能用 Windows 的 IOCompletionPorts 來建立連接。
3. 一旦連接建立了, Socket 類調用指明的函數 ConnectCallback ,這個回調函數顯示了線程池中可用的線程數量。我們可以看到這些是在工作線程中執行的。
4. 在用 ASCII 碼對 Get 請求進行編碼後,我們用 BeginSend 方法從同樣的函數 ConnectCallback 中發送一個異步請求。
5. Socket 上的發送和接收操作可以通過 IOCompletionPort 來執行異步操作,所以當請求做完後,回調函數就會在一個 CompletionPort 類型的 線程中執行。因爲函數本身顯示了可用的線程數量,所以我們可以通過這個來查看,對應的完成端口數已經減少了多少。
如果我們在 Windows 95, Windows 98, 或者 Windows Me 平臺上運行相同的代碼,會出現相同的連接結果,請求將被髮送到工作線程,而非完成端口。你應該知道的很重要的一點就是, Socket 類總是會利用最優的可用機制,所以你在開發應用時,可以不用考慮目標平臺是什麼。
你已經看到在上面的例子中每種類型的線程可用的最大數是 25 。我們可以用 GetMaxThreads 返回這個值:
public static void GetMaxThreads(
out int workerThreads,
out int completionPortThreads);
一旦到了最大的數量,就不會創建新線程,所有的請求都將被排隊。假如你看過 ThreadPool 類的所有方法,你將發現沒有一個允許我們更改最大數的方法。就像我們前面提到的那樣,線程池是每個處理過程的唯一共享資源。這就是爲什麼不可能讓應用程序域去更改這個配置的原因。想象一下出現這種情況的後果,如果有第三方組件把線程池中線程的最大數改爲 1 ,整個應用都會停止工作,甚至在進程中其它的應用程序域都將受到影響。同樣的原因,公共語言運行時的宿主也有可能去更改這個配置。比如: ASP.NET 允許系統管理員更改這個數字。
在你的應用程序使用線程池之前,還有一個東西你應該知道:死鎖。在線程池中執行一個實現不好的異步對象可能導致你的整個應用系統中止運行。
設想你的代碼中有個方法,它需要通過 Socket 連接到一個 Web 服務器上。一個可能的實現就是用 Socket 類中的 BeginConnect 方法異步打開一個連接,然後用 EndConnect 方法等待連接的建立。代碼如下:
class ConnectionSocket
{
public void Connect()
{
IPHostEntry ipHostEntry = Dns.Resolve(Dns.GetHostName());
IPEndPoint ipEndPoint = new IPEndPoint(ipHostEntry.AddressList[0],
80);
Socket s = new Socket(ipEndPoint.AddressFamily, SocketType.Stream,
ProtocolType.Tcp);
IAsyncResult ar = s.BeginConnect(ipEndPoint, null, null);
s.EndConnect(ar);
}
}
多快,多好。調用 BeginConnect 使異步操作在線程池中執行,而 EndConnect 一直阻塞到連接被建立。
如果線程池中的一個執行函數中用了這個類的方法,將會發生什麼事情呢?設想線程池的大小隻有兩個線程,然後用我們的連接類創建了兩個異步對象。當這兩個函數同時在池中執行時,線程池已經沒有用於其它請求的空間了,除非直到某個函數結束。問題是這些函數調用了我們類中的 Connect 方法,這個方法在線程池中又發起了一個異步操作。但線程池一直是滿的,所以請求就一直等待任何空閒線程的出現。不幸的是,這將永遠不會發生,因爲使用線程池的函數正等待隊列函數的結束。結論就是:我們的應用系統已經阻塞了。
我們以此推斷 25 個線程的線程池的行爲。假如 25 個函數都等待異步對象操作的結束。結果將是一樣的,死鎖一樣會出現。
在下面的代碼片斷中,我們使用了這個類來說明問題:
class MainApp
{
static void Main()
{
for(int i=0;i<30;i++)
{
ThreadPool.QueueUserWorkItem(new WaitCallback(PoolFunc));
}
Console.ReadLine();
}
static void PoolFunc(object state)
{
int workerThreads,completionPortThreads;
ThreadPool.GetAvailableThreads(out workerThreads,
out completionPortThreads);
Console.WriteLine("WorkerThreads: {0}, CompletionPortThreads: {1}",
workerThreads, completionPortThreads);
Thread.Sleep(15000);
ConnectionSocket connection = new ConnectionSocket();
connection.Connect();
}
}
如果你運行這個例子,你將看到池中的線程是如何把線程的可用數量減少到零的,接着應用中止,死鎖出現了。
如果你想在你的應用中避免出現死鎖,永遠不要阻塞正在等待線程池中的其它函數的線程。這看起來很容易,但記住這個規則意味着有兩條:
n 不要創建這樣的類,它的同步方法在等待異步函數。因爲這種類可能被線程池中的線程調用。
n 不要在任何異步函數中使用這樣的類,如果它正等待着這個異步函數。
如果你想檢測到應用中的死鎖情況,那麼就當你的系統掛起時,檢查線程池中的線程可用數。線程的可用數量已經沒有並且 CPU 的使用率爲 0 ,這是很明顯的死鎖症狀。你應該檢查你的代碼,以確定哪個在線程中執行的函數正在等待異步操作,然後刪除它。
如果你再看看 ThreadPool 類,你會看到有兩個方法我們沒有用到, UnsafeQueueUserWorkItem 和 UnsafeRegisterWaitForSingleObject 。 爲了完全理解這些方法,首先,我們必須回憶 .NET 框架中安全策略是怎麼運作的。
Windows 安全機制是關注資源。操作系統本身允許對文件,用戶,註冊表鍵值和任何其它的系統資源設定權限。這種方法對應用系統的用戶認證非常有效,但當出現用戶對他使用的系統產生 不信任的情況時,這就會有些侷限性。例如這些程序是從 Internet 下載的。在這種情況下,一旦用戶安裝了這個程序,它就可以執行用戶權限範圍內的任何操作。舉個例子,假如用戶可以刪除他公司內的任何共享文件,任何從 Internet 下載的程序也都可以這樣做。
.NET 提供了應用到程序的安全性策略,而不是用戶。這就是說,在用戶權限的範圍內,我們可以限制任何執行單元(程序集)使用的資源。通過 MMC ,我們可以根據條件定義一組程序集,然後爲每組設置不同的策略,一個典型的例子就是限制從 Internet 下載的程序訪問磁盤的權限。
爲了讓這個功能運轉起來, .NET 框架必須維護一個不同程序集之間的調用棧。假設一個應用沒有權限訪問磁盤,但是它調用了一個對整個系統都可以訪問的類庫,當第二個程序集執行一個磁盤的操作時,設置到這個程序集的權限允許這樣做,但是權限不會被應用到主叫程序集, .NET 不僅要檢查當前程序集的權限,而且會檢查整個調用棧的權限。這個棧已經被高度優化了,但是它們給兩個不同程序集之間的調用增加了額外的負擔。
UnsafeQueueUserWorkItem , UnsafeRegisterWaitForSingleObject 與 QueueUserWorkItem , RegisterWaitForSingleObject 兩個方法 類似。由於是非安全版本不會維護它們執行函數之間的調用棧,所以非安全版本運行的更快些。但是回調函數將只在當前程序集的安全策略下執行,它就不能應用權限到整個調用棧中的程序集。
我的建議是僅在性能非常重要的、安全已經控制好的極端情況下才用非安全版本。例如,你構建的應用程序不會被其它的程序集調用,或者僅被很明確清楚的程序集使用,那麼你可以用非安全版本。如果你開發的類庫會被第三方應用程序中使用,那麼你就不應該用這些方法,因爲它們可能用你的庫獲取訪問系統資源的權限。
在下面例子中,你可以看到用 UnsafeQueueUserWorkItem 方法的風險。我們將構建兩個單獨的程序集,在第一個程序集中我們將在線程池中創建一個文件,然後我們將導出一個類以使這個操作可以被其它的程序集執行。
using System;
using System.Threading;
using System.IO;
namespace ThreadSecurityTest
{
public class PoolCheck
{
public void CheckIt()
{
ThreadPool.QueueUserWorkItem(new WaitCallback(UserItem), null);
}
private void UserItem(object obj)
{
FileStream fs = new FileStream("test.dat", FileMode.Create);
fs.Close();
Console.WriteLine("File created");
}
}
}
第二個程序集引用了第一個,並且用了 CheckIt 方法去創建一個文件:
using System;
namespace ThreadSecurityTest
{
class MainApp
{
static void Main()
{
PoolCheck pc = new PoolCheck();
pc.CheckIt();
Console.ReadLine();
}
}
}
編譯這兩個程序集,然後運行 main 應用。默認情況下,你的應用被配置爲允許執行磁盤操作,所以系統成功生成文件。
File created
現在,打開 .NET 框架的配置。爲了簡化這個例子,我們僅創建一個代碼組關聯到 main 應用。接着展開 運行庫安全策略 / 計算機 / 代碼組 / All_Code / ,增加一個叫 ThreadSecurityTest 的組。在嚮導中,選擇 Hash 條件並導入 Hash 到我們的應用中,設置爲 Internet 級別,並選擇“該策略級別將只具有與此代碼組關聯的權限集中的權限”選項。
運行應用程序,看看會發生什麼情況:
Unhandled Exception: System.Security.SecurityException: Request for the
permission of type System.Security.Permissions.FileIOPermission,
mscorlib, Version=1.0.3300.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089 failed.
我們的策略開始工作,系統已經不能創建文件了。這是因爲 .NET 框架爲我們維護了一個調用棧才使它成爲了可能,雖然創建文件的庫有權限去訪問系統。
現在把庫中的 QueueUserWorkItem 替換爲 UnsafeQueueUserWorkItem , 再次編譯程序集,然後運行 Main 程序。現在的結果是:
File created
即使我們的系統沒有足夠的權限去訪問磁盤,但我們已經創建了一個向整個系統公開它的功能的庫,卻沒有維護它的調用棧。記住一個金牌規則: 僅在你的代碼不允許讓其它的應用系統調用,或者當你想要嚴格限制訪問很明確清楚的 程序集,才使用非安全的函數。
在這篇文章中,我們知道了爲什麼在我們的服務器應用中需要使用線程池來優化資源和 CPU 的利用。我們學習了一個線程池是如何實現的,需要考慮多個因素如: CPU 使用的百分比,隊列請求或者系統的處理器數量。
.NET 提供了豐富的線程池的功能以讓我們的應用程序使用, 並且與 .NET 框架的類緊密地集成在一起。這個線程池是高度優化了的,它只需要最少的 CPU 時間和資源,而且總能適應目標平臺。
因爲與框架集成在一起,所以框架中的大部分類都提供了使用線程池的內在功能,給開發人員提供了集中管理和監視應用中的線程池的功能。鼓勵第三方組件使用線程池,這樣它們的客戶就可以享受 .NET 所提供的全部功能。允許執行用戶函數,定時器, I/O 操作和同步對象。
假如你在開發服務器應用系統,只要有可能就在你的請求處理系統中使用線程池。或者你開發了一個讓服務器程序使用的庫,那麼儘可能提供系統線程池的異步對象處理。