[轉貼]體驗.NET Multithreading的快感 --- 以VB.NET開發Thread Pool式網路芳鄰掃瞄

包含 c#, asp.net, vb.net, delphi.net 等 .net framework 的開發討論區
回覆文章
頭像
tim
文章: 1380
註冊時間: 2008年 11月 26日, 00:49

[轉貼]體驗.NET Multithreading的快感 --- 以VB.NET開發Thread Pool式網路芳鄰掃瞄

文章 tim »

轉貼自: http://www.asp.com.tw/news/info_tc1.htm

體驗.NET Multithreading的快感 --- 以VB.NET開發Thread Pool式網路芳鄰掃瞄程式

作者: 李明儒

前言

Multithreading(多執行緒)的程式,對大部份的VB/ASP程式設計師來說,向來是門莫測高深的武林絕學。雖然知道它威力驚人,也在一些FTP之類小小網路共享軟體上見識過它的高效率,但一般大眾多不得其門而入。行走江湖久一點的老鳥都知道每隻漂亮的Multithreading程式的背後,都隱藏著長年的VC++修練以及無數個Debug到天明的夜晚,而Java門派對此亦有其獨門密法。此時,VB門徒們不禁想起自己的幫派在江湖上雖然人多勢眾,所習武功卻不時被人當成家家酒,現在又驗證了一項VB辦不到的事實,心中一絲自卑開始隱隱作痛…

微軟的.NET Framework正試圖改變這個事實,VB被大幅改版,成為一套全新的VB.NET語言。原有的VB開發者的確得重新學習許多新的概念與設計思維,花費較長的時間來接受與熟悉VB.NET。但陣痛的代價是從此便可以擺脫長久以來VB易學但功能受限的魔咒。在.NET Framework中,程式將不再受限於使用的語言。換句話說,C#能做的事,VB.NET當然也可以。同時.NET Framework還提供了豐富的共用類別,將許多常用但複雜的系統功能封裝起來,開發人員可以更輕易上手運用它們發展系統,不必再與難纏的Win32 API打交道,其中也包含了令人動心的Multithreading。

在本文中,將會一個有意思的題材做為我們測試Multithreading威力的對象—經由掃瞄LAN中的IP,得到網路芳鄰的清單,包含了IP、機器名稱與使用者。這份清單可以用在網路管理上,例如: 每個人是否都使用被分配的IP、網路中是否出現不明的機器、各使用者使用機器的狀況…等等,應用的方式挺多的,但本文的重點將會著重如何使用 VB.NET,透過Thread Pool的機制來加速這項工作的執行。

關於 Multithreading的理論與設計實務其實是相當精深與複雜的,用整本書來討論亦不為過。雖然.NET提供了簡便的方法讓開發人員快速上手,並不意味就此改變了Multithreading程式難以撰寫的事實。面對複雜的情境,要保證程式強韌、穩定而正確,依然需要相當的設計技巧與知識。但為了避免過多深澀的討論使本文成為一篇床邊故事(睡前看上一兩段即可快速入眠),我們將會著重在基本的應用介紹,跳過一些繁瑣的細節,希望帶領大家用 VB.NET體驗一下Multithreading的快感。

網路芳鄰的背景知識

Windows 中所用的網路芳鄰,其實是基於所謂的NetBIOS協定在運作。NetBIOS是由微軟與IBM合作開發的一套區域網路通訊的基本介面標準,就如同PC上的BIOS一般,因此IBM將其命名為NetBIOS。而NetBIOS協定的底層可以是TCP/IP、NetBEUI、IPX、DecNet等,然而現在網路應用幾乎都是TCP/IP的天下。當然,Windows也內建了對NetBIOS的支援。

在 NetBIOS的設計中,不像DNS要由管理者設定各IP所對應的機器名稱,而是由各機器間互相以廣播通告方式來維持一份動態的名稱對應。機器開機時宣告註冊自己的機器名稱、在機器關機時將稱釋放掉。而微軟也設計了Browser Master、WINS Server等各種機制來暫存機器名稱的資訊,同時在解析名稱的過程中,亦有多種的流程原則(例如: B-node、P-node、M-node、H-node等),在此不針對這些細節深入多談。(若讀者想進一步了解網路芳鄰運作原理的細節,李忠憲先生曾於Run! PC所發表的”NAT虛擬網路實務”一文,有淺顯而詳盡的介紹,讀者亦可在Internet上找到該篇文章。)

在 Windows中有個內建的程式nbtstat,應該不少人都用過,前三個字NBT代表的是NetBIOS over TCP/IP。nbtstat有個-A的參數,可以由IP位址解析出機器名稱,正是我們這次所需要的功能。下圖中,列出nbtstat的使用語法供各位參考:

例如筆者所使用機器的IP是192.168.1.24,試試nbtstat –A 192.168.1.24,可以得到如下的結果:


 

接著由微軟的文件查查這些資訊的意義:

表一 NetBIOS Names Used by Microsoft Components
(資料來源: Microsoft Windows 2000 Server Resource Kit Online Books)

Unique Name
Service

<computer_name>[00] (space filled)1
Workstation Service

<computer_name>[03] (space filled)
Messenger Service

<computer_name>[06] (space filled)
RAS Server Service

<computer_name>[1F] (space filled)
NetDDE Service

<computer_name>[20] (space filled)
Server Service

<computer_name>[21] (space filled)
RAS Client Service

<computer_name>[BE] (0xBE filled)
Network Monitor Agent

<computer_name>[BF] (0xBF filled)
Network Monitor Application

<user_name>[03] (space filled)
Messenger Service

<domain_name>[1D] (space filled)
Master Browser

<domain_name>[1B] (space filled)
Domain Master Browser

1 The number in brackets is a hexadecimal number. (space filled) means that if the computer or domain name is not 15 characters long, the name is filled with spaces up to 15 characters.

表二 Group NetBIOS Names Used by Microsoft Components
(資料來源: Microsoft Windows 2000 Server Resource Kit Online Books)

Group Name
Service

<domain_name>[00] (space filled)
Domain Name

<domain_name>[1C] (space filled)
Domain Controllers

<domain_name>[1E] (space filled)
Browser Service Elections

[01h][01h]__MSBROWSE__[01h][01h]
Master Browser

參考這兩張表,我們設法由nbtstat的結果中找出我們需要的資訊項目--機器名稱與使用者名稱,其中機器名稱可以使用<00> UNIQUE作為識別,原則上所有連上網路的Windows平台都有Windows Network Client (在NT/2000下就是所謂的Workstation Service)。唯一要克服的是有安裝的IIS的機器上會有另一個<00> UNIQUE,稱為Internet Information Server Unique Name,但由於它一定是”IS~”開頭,並不難排除。

至於使用者名稱,就不是每台Windows都會註冊的。首先,使用者必須登入系統(否則系統會使用機器名稱作為Messenger識別用的使用者名稱),且 NT/2000下有個Messenger Service也必須在執行狀態,不過預設應是啟動的。但Windows 98/ME就必須執行額外的程式來執行Messenger Service的功能,在Windows 98/ME的安裝選項包含了有一個WinPopup的程式,啟動之後才有能力送出或接受Net Send的短訊。基於這個理由,在程式邏輯中,我們允許查不到使用者名稱的情況。

另外,再做個nbtstat –A測試,但這次把對象設成一個不存在的IP位址(例如: nbtstat –A 10.10.10.10),會發現系統等待回應到傳回找不到訊息的時間相當長,大約得花上近10秒,而IP存在且Windows正常回應時,同樣的指令執行時間不到1秒。試想,如果若一個192.168.1.1-192.168.1.254的子網路有一半的IP是空的,掃瞄起來豈不是得花上1270秒。所以,我們決定在進行nbtstat –A的耗時工作前,先使用Ping做一下測試(大約只需要一秒就可得知IP位址是否存在),如果IP不存在就不進行nbtstat –A解析,省去一堆虛工。標準的Ping會測4次,使用-n 1參數指定只測試一次來節省時間,因為在LAN中,絕大多數的情況只要Ping一次就可得到正確結果。在前述C Classl IP只有一半是使用中的例子,至少可以節省1000秒以上的時間。

基本功能函數的建立

好了! 有了這些背景知識,我們開始在VB.NET中將事情兜起來。在這之前,先做個說明,其實ping/nbtstat的功能也是可以透過呼叫Win32 API或實作出該網路協定的方式用VB.NET寫出來,但既然現成的Ping與Nbtstat已經如此好用,同時為了喝牛奶而養頭牛實在也有點糢糊焦點,所以我們決定站在巨人的肩上,由VB.NET程式叫用ping, nbtstat這兩個外部程式,再解析外部程式執行結果來達成這兩項功能。

VB裡有個Shell函數可以呼叫外部程式,在.NET中則要藉助System.Diagnositcs Namespace中一些好用的類別,如同以下這個範例:

Imports System.Diagnostics

Imports System.IO

Imports System.Text

Module Module1

Sub Main()

Console.WriteLine(Shell("ping.exe", "-n 1 192.168.1.23"))

Console.ReadLine()

End Sub

Function Shell(ByVal sExeFile As String, ByVal sArgument As String) As String

Dim pShell As Process

pShell = New Process()

'設定執行檔及參數

pShell.StartInfo.FileName = sExeFile

pShell.StartInfo.Arguments = sArgument

'必須要設定以下兩個屬性才可將輸出結果導向

pShell.StartInfo.UseShellExecute = False

pShell.StartInfo.RedirectStandardOutput = True

'不顯示任何視窗

pShell.StartInfo.CreateNoWindow = True

'開始執行

pShell.Start()

'將StdOUT的結果轉為字串, 其中StandardOutput屬性類別為StreamReader

Shell = pShell.StandardOutput.ReadToEnd()

pShell.WaitForExit()

End Function

End Module

下一步,我們將Ping -> Nbtstat -> 找出機器名稱與使用者名稱的程序組合在一起,成為一個新的函數NetBIOSDetect,傳入IP位址為參數後,可能傳回四種結果: IP位址不合法、IP位址不存在、NetBIOS host not found或”IP位址,機器名稱,使用者名稱”的結果字串。

Function NetBIOSDetect(ByVal IP As String) As String

'檢查使用者傳入的是否為合法IPAddress, 藉用System.Net.IPAddress

Try

Dim ipValid As System.Net.IPAddress

ipValid = System.Net.IPAddress.Parse(IP)

Catch '使用者傳入的非合法的IP位址格式

NetBIOSDetect = "ERROR: Invalid IP Address"

Exit Function

End Try

'Ping測試, 當IP不存在時, ping的傳回結果會出現timed out字眼, 以此判別IP存在與否

If InStr(Shell("ping.exe", "-n 1 " & IP), "timed out") > 0 Then

NetBIOSDetect = "ERROR: IP doesn't exists!"

Exit Function

Else 'IP存在, 進行nbtstat -A解析

Dim sTemp As String

sTemp = Shell("nbtstat.exe", "-A " & IP)

'傳回結果若不包含任何UNIQUE字樣表示未查到相關的NetBIOS名稱資料

If InStr(sTemp, "UNIQUE") = 0 Then

NetBIOSDetect = "ERROR: Host not found."

Exit Function

Else

'將傳回結果解析成為多行

Dim sLines() As String

sLines = Split(sTemp, vbCrLf)

Dim sUsername As String = "", sMachineName As String = ""

Dim I As Integer, sNetBIOSName As String

For I = 0 To UBound(sLines)

'以<00> UNIQUE識別機器名稱, 排除IS~開頭者

If InStr(sLines(I), "<00> UNIQUE") > 0 Then

sNetBIOSName = Trim(Left(sLines(I), InStr(sLines(I), "<00>") - 1))

If Left(sNetBIOSName, 3) <> "IS~" Then

sMachineName = sNetBIOSName

End If

ElseIf InStr(sLines(I), "<03> UNIQUE") > 0 Then '由<03> UNIQUE識別使用者名稱

sUsername = Trim(Left(sLines(I), InStr(sLines(I), "<03>") - 1))

End If

Next

NetBIOSDetect = IP & "," & sMachineName & "," & sUsername

Exit Function

End If

End If

End Function

這個重要函數完成之後! 我們可以開始進行掃描工程了。首先,先用傳統單一Thread的寫法,由192.168.1.1執行至192,168.1.254,同時使用 Environment.TickCount看看需要花多少時間,稍後才能突顯出Multithread的威力。我們將Sub Main改寫一下:

Sub Main()

Dim I As Integer

Dim lStart As Long, lEnd As Long

Dim iCount As Integer = 0

Dim sResult As String

lStart = Environment.TickCount

For I = 1 To 254

sResult = NetBIOSDetect("192.168.1." & I)

Console.WriteLine(sResult)

If InStr(sResult, "ERROR") = 0 Then iCount = iCount + 1

Next I

lEnd = Environment.TickCount

Console.WriteLine("共計花費" & (lEnd - lStart) & "ms...")

Console.WriteLine("找到" & iCount & "台Windows主機")

Console.ReadLine()

End Sub

在筆者所在的辦公室環境中,從192.168.1.1-192.168.1.254整個C Class中共找到96台Windows主機,花費了304秒才完成。一般來說,未使用的IP愈多或非Windows主機愈多(nbtstat –A約5秒後才會得到Host not found的回應),就要花愈長的時間來確認IP不存在或Host不存在。

下一步,我們就要引進Multithread來強化這個的程式效能。

多工的基本觀念

在導入Multithreading之前,我們花一點時間來看作業系統中的多工概念。自從Windows NT作業系統中導入先佔式多工(Preemptive Multitasking)以來,PC的使用者進入了另外一個紀元。古早時代的Windows 3.1,也作出同一時間多個視窗並行運作的效果,但用的是協調式多工(Cooperative Multitasking,也稱為非先佔式多工Non-Preemptive Multitasking),所有並行的程式們必須有良好的自律與公德心,在執行一段時間之後,自願將CPU的控制權釋出,依一定的排程規則交給其他程式輪流執行。可以想見的,如果其中一隻程式設計不良或是出了問題,發了瘋似的繞迴圈而遲遲不將CPU控制權交給下一隻程式,整個作業系統就被拖垮了。

而先佔式多工好多了,所有程式碼的執行以Thread(執行緒)為單位,由作業系統統一排程與管理所有的Thread。分配的執行時間結束時,作業系統會主動中斷Thread的執行,強制收回CPU的椌制權,但會以一組Thread Context記下Thread被中斷時的狀態,以備下次再輪到該Thread執行時,Thread能繼續執行下去,完全感受不到自己曾被中斷過。

補充說明,一個應用程式擁有一個Process(程序),享有自己的記憶體空間,其中可能同時存在多個Thread(執行緒)。然而Thread並沒有自己的記憶體空間,只有專屬的Stack空間,因此Thread們必須共享Process的記憶體空間。這意味著可能發生多個Thread同時存取一個變數或資源的情況,這往往是Multithreading程式設計時最嚴苛的挑戰。尤其是Multithreading程式的Bug常具有一種令人毛骨悚然的特性--往往程式在絕大部分的情況下都執行正常,只有在某些特定場合或情境下才會出鎚,例如: Demo給大老闆看的時候、正式上線的第一天… 有過類似經驗的朋友應該都會心有戚戚焉吧!

Threading Model

談到Multithreading,還是得介紹一下在Win32作業環境下的三種基本Threading Model: Single Threading、Apartment Threading、Free Threading。我引用一個搬家打包的比喻來讓它們更容易被理解:

l Single Threading
大部分的Windows應用程式都屬於這種模式。應用程式會以Process形式存在於系統中,擁有自己的記憶體空間,其中有一條Thread做完所有的工作。
好比你一個人負責所有的搬家打包工作,認命地帶著紙箱、膠帶、剪刀,一個房間一個房間的收拾、裝箱。

l Apartment Threading
稍微複雜一點,若一段程式碼被標為Apartment Threading,則可以在同一時間有多個Thread執行這段Code。但是每個Thread都被關在一個稱為Apartment的空間中。 Thread所屬的Process會指定一小段記憶體空間供Thread專用,所以雖然同時有多條Thread正在執行,卻是彼此完全獨立的。這就是在 VB時代唯一可以建立的Multithreading模式。每個Apartment中只存在一條Thread的模式稱為Single Threaded Apartment(STA),另外有一種Apartment中可存在多條Thread的MTA(Multi Threaded Apartment),但較不常見。
對應到搬家的例子,你打電話找來幾個好朋友幫忙打包,但一個房間只由一個人負責,所以你需要多準備些紙箱、膠帶與剪刀,大家也沒法互相幫忙。但比起一個人蠻幹,大家一起做的確速度快一些。

l Free Threading
最複雜但也是威力最強大的模式,多條Thread共處於相同的記憶體空間,可以同時呼叫某段函數、叫用某個物件,沒有任何限制,擁有最大的彈性。但可以預見的,由於各Thread間需要同時存取某些資源或變數,彼此的干擾甚至可能導致可怕的災難。在VB6時代,VB程式師倒不會有這種煩惱,因為VB6根本不支援Free Threading! 再一次,VB門徒的胸口又開始隱隱作痛…
再回到搬家的例子,你找來的幾個好朋友中有三個是無話不說的姐妹淘,堅持一起幫你打包房間,三人可共用一組膠帶與剪刀。由於三人可以分工合作,有人收書、有人收CD、有人收衣服,效率果然驚人。
合作固然愉快,但難免會有些小小的混亂,Teresa原本用來裝CD的紙箱、被Audrey改標成裝書、接著Joyce馬上將手上的一堆書給塞進箱子裡… 結果,你在三個月之後才找到心愛的”樹技孤鳥”。

在.NET,.NET Framework提供了的Multithreading程式開發所需的基本類別,讓所有的.NET開發人員能以較簡便的方式開發出 Multithreading的程式。由於此一支援特性屬於.NET Framework而非特定的語言,對VB開發者的另一層意義是,邁向VB.NET之後,可以擺脫VB時代這樣不行、那個沒辦法的魔咒,從此可以昂首闊步,哇哈哈哈!!

看一個很簡單的Multithreading VB.NET程式範例,在程式裡會建立兩條Thread,同步執行一小段時間。

Imports System.Threading

Module Module1

Sub Main()

Dim threadA, threadB As Thread

Dim Snoopy As CDog

Dim Garfield As CCat

Snoopy = New CDog()

Garfield = New CCat()

'建立一條新的Thread

threadA = New Thread(AddressOf Snoopy.Bark)

Console.WriteLine("Starting Thead A...")

'開始執行Thread

threadA.Start()

threadB = New Thread(AddressOf Garfield.Meow)

Console.WriteLine("Starting Thead B...")

threadB.Start()

'暫停Main Thread, 等待Thread A結束

threadA.Join()

Console.WriteLine("Thread A is finished!")

threadB.Join()

Console.WriteLine("Thread B is finished!")

Console.WriteLine("Job Done!!")

Console.ReadLine()

End Sub

Class CDog

Public Sub Bark()

Dim I As Integer

For I = 1 To 6

'Thread.CurrentThread.GetHashCode()可以取得目前執行中Thread的代碼

Console.WriteLine("Thread " & Thread.CurrentThread.GetHashCode() & " - 汪 - " & I)

Thread.Sleep(500) '命令目前所在的Thread放棄所分配到的CPU控制權500ms

Next

End Sub

End Class

Class CCat

Public Sub Meow()

Dim I As Integer

For I = 1 To 6

Console.WriteLine("Thread " & Thread.CurrentThread.GetHashCode() & " - 喵 - " & I)

Thread.Sleep(1000)

Next

End Sub

End Class

End Module


由程式碼來看,夠簡單了吧? 執行結果如我們所預期的。還可試著把threadA.Join, threadB.Join的順序顛倒一下看看有何不同。

Starting Thead A...

Starting Thead B...

Thread 17 - 汪 - 1

Thread 18 - 喵 - 1

Thread 17 - 汪 - 2

Thread 18 - 喵 - 2

Thread 17 - 汪 - 3

Thread 17 - 汪 - 4

Thread 18 - 喵 - 3

Thread 17 - 汪 - 5

Thread 17 - 汪 - 6

Thread 18 - 喵 - 4

Thread A is finished!

Thread 18 - 喵 - 5

Thread 18 - 喵 - 6

Thread B is finished!

Job Done!!

或許你已注意到了,程式碼中用了兩個類別。將Thread要執行的函數用一個類別包裝起來,是設計Multithreading程式時常見的寫法,但不是唯一的設計方式。事實上直接要求Thread執行某個函數也是正確可行的,只是將函數封裝於類別中,每個Thread得以保有一些自己的變數值,可以減少不同Thread間共用變數與資源的機會,的確能避免掉不少不必要的麻煩。在不少技術文件中,都可以看到這種設計方式,在此我們也選擇這種作法。

那麼我們的下一步是否就是將先前的函數封裝於類別,再建立254個Thread來執行就大功告成了? 看來怪怪的對不對?

的確,此種設計不能說不行,卻存在一個很大的問題。如果我們把掃瞄範圍放大為一個B Class,例如: 172.28.1.1 – 172.28.254.254,是不是程式就得建立超過6萬個Thread? 姑且不論.NET程式或作業系統是否容許這種多Thread同時存在,即使可以,想像一下CPU被迫不斷地在6萬多個Thread間快速切換的慘烈戰況。先前提到作業系統在暫停Thread收回CPU控制權時,要為每個Thread保存狀態(Thread Context),待Thread繼續執行時再依據Thread Context復原成其暫停時點時的狀態。這個Context Switching的動作也得耗費一些CPU資源,當Thread數量過多時,會演變成CPU幾乎所有的時間都花在處理Context Switching上,根本無暇交由各Thread辦正事。因此,過多的Thread不會提升效能,反而會因Context Switching的Overhead而降低效能。

Thread Pool的概念

基於前述的考量,可以獲得一個結論,Multithreading在設計時,Thread的數量並非100%與效能成正比,而是必須予以適當的限制以求能發揮最大的效能。因此就有所謂Thread Pool的設計概念,試圖解決這樣的問題。

Thread Pool的概念如下圖,程式建立有限個數Thread成為一個Thread Pool(執行緒集區)來因應大量的Request。但由於Request的數量常遠大於Thread Pool,因此暫時無法處理的Request就使用一個Queue的機制先保存起來。Thread Pool的Thread會待手上的工作處理完畢,自Queue中取出待處理的Request。

要自己動手設計Thread Pool不是簡單的工程,Queue的機制、Thread的狀況掌控,最好能視系統的閒忙程度調節Thread數,飇出系統的效能極限。這些要求光用想的就讓人頭皮發麻,是的! 回答你正想問的問題,.NET提供了應用Thread Pool Model的簡單方式。

在.NET Framework的設計中,每個.NET應用程式在執行階段,CLR(Common Language Runtime)會為應用程式所在的Process提供一個Thread Pool,其中包含了25條可用Thread(在多CPU的機器上,Thread Pool的Thread數等於CPU數乘上25,例如: .NET程式在4顆CPU主機上執行時,Thread Pool中有100條Thread可用)。Runtime提供了Queue的管理機制,也會主動安排Pool中空閒的Thread去處理Queue中等待執行的程式碼,讓開發Thread Pool程式甚至對初學都不再是件遙不可及的事。

要使用Thread Pool,要依賴System.Threading Namespace中的ThreadPool類別。查一下說明文件,你會發現ThreadPool的所有Member都是Shared(Static) 的,也沒有Constructor讓你去New一個新的ThreadPool,因為每個Process只有也只能有一個由系統所主管Thread Pool。

以下這段程式碼示範基本的Thread Pool程式寫法:

Imports System.Threading

Module Module1

Sub Main()

'QueueUserWorkItem只接受WaitCallback類別

Dim callBack As WaitCallback

Dim I As Integer

'宣告101忠狗陣列

Dim Dog(101) As CDog

For I = 1 To 101

Dog(I) = New CDog()

'以要執行函數的位址為參數建立WaitCallback物件

callBack = New WaitCallback(AddressOf Dog(I).Bark)

'在QueueUserWorkItem時, 還可傳入一個Object作為呼叫該函數的參數

'當然, 透過類別的Property也是另一種指定參數的作法, 而且更有彈性

ThreadPool.QueueUserWorkItem(callBack, I)

Next

Console.ReadLine()

End Sub

Class CDog

Public Sub Bark(ByVal state As Object)

'GetHashCode可以識別程式碼是被那一條Thread所執行

Console.WriteLine("Dog No." & CType(state, String) & " is barking in Thread[" & Thread.CurrentThread.GetHashCode() & "]")

'等待500ms, 讓Thread的手腳慢一點, 否則一條Thread就可以打通關

'等待時間愈長, 會用到的Thread數就愈多

Thread.Sleep(500)

End Sub

End Class

End Module

執行結果如下:

Dog No.1 is barking in Thread[118]

Dog No.2 is barking in Thread[120]

Dog No.3 is barking in Thread[118]

Dog No.4 is barking in Thread[120]

Dog No.5 is barking in Thread[121]

Dog No.6 is barking in Thread[118]

Dog No.7 is barking in Thread[121]

Dog No.8 is barking in Thread[120]

Dog No.9 is barking in Thread[122]

Dog No.10 is barking in Thread[118]

… 省略 …

Dog No.95 is barking in Thread[120]

Dog No.96 is barking in Thread[121]

Dog No.97 is barking in Thread[122]

Dog No.98 is barking in Thread[123]

Dog No.99 is barking in Thread[124]

Dog No.100 is barking in Thread[125]

Dog No.101 is barking in Thread[126]

讀者可以調整Thread.Sleep的時間看看,就可以發現等待時間愈長,用到的Thread數就愈多,同時這也與每台電腦的效能有些關係。

資料共享問題

到目前為止,我們都還在逃避一個令人頭痛的問題—Thread在程式碼完成後,如何將結果傳回呼叫端? 之所以令人頭痛是因為此時免不了要考慮多個Thread同時存取相同資源的棘手狀況,但若不能取回各Thread的執行結果,即使效能再好也是白忙一場。

不過幸好,在我們的現階段遇到的各項實例中,存取共用資源的情境都算是十分單純,借助一些簡單的.NET語法及技巧就可克服。這裡先說明幾個常用到的做法:

1. 避免共享資料
先前曾提到常使用類別來將每件工作的相關資料封裝在一個物件內的作法,就具有避免共享資料的效果,除此以外物件化的設計也讓程式的結構更易於維護,但並非所有的情境都能做到完全不共享任何資料。

2. 鎖定機制
System.Threading 的Monitor類別有兩個有用的Method: Enter、Exit,在呼叫時需要傳入物件作為參數。Monitor.Enter(object)代表對object物件進行鎖定(若鎖定對象為目前物件中的資料成員,通常就用Me),在呼叫Monitor.Exit(object)解除鎖定之前,其他Thread如果也企圖呼叫 Monitor.Enter(object)時就會被擱置。換句話說,Monitor.Enter至Exit之間的程式碼,在任何情形下都只允許一條 Thread執行。基於此一原理,Enter與Exit呼叫之間的程式碼要愈少愈好,最好只包含必要的存取資料指令,以免影響 Multithreading的效果。同時,要考慮一種情況,當進入鎖定後,若程式出了問題,未再呼叫Exit,則所有被擱置的Thread將長眠不起。
所以一般在使用Monitor.Enter時必須要加上Try … Final …機制來防範意外,例如:
Try
Monitor.Enter(m_Counter)
m_Counter = m_Counter + 1
Finally
Monitor.Exit(m_Counter)
End Try
但每次都得這麼寫未免太麻煩,所以有個指令可做到完全相同的效果(在VB.NET中用SyncLock,在C#中則用lock):

SyncLock m_Counter

m_Counter = m_Counter + 1

End SyncLock

3. 不可中斷的指令
CPU的指令集中通常有一些不允被中斷的指令,而.NET提供了Interlocked類別支援此種功能。當資料型別是Integer或Long時,若只是想做單純的遞增遞減,就很適合使用這種方式。例如:

Interlocked.Increment(intCoutner) 或 Interlocked.Decrement(intCounter)

開始組裝

現在我們開始將學到的東西組合起來,拼湊出支援Thread Pool的網路芳鄰掃瞄程式。

首先,我們決定採用物件化設計的概念,把每個IP的掃瞄工作交給一個CNetBIOSScanner物件來處理,獲得結果就保留在該物件中,最後主程式再透過物件的變數取回結果,這樣可以減少共用資料的狀況。但是主程式端(Main Thread)要如何知道所有的物件都完成它們的工作(之前示範的Join方式並不適合用在Thread Pool中)? 做法其實有很多種,例如: 請物件在工作完成後以委派(Delegate)或事件(Event)非同步方式通知主程式端進行統計。本案中,我們則是在NetBIOSCanner類別中定義2個共享的統計變數iJobTotal、iJobDone,每建立一個物件時iJobTotal加1,掃瞄工作完成之則iJobDone加1,二者相等時則表示所有的物件都完成工作了。

在此分別使用到兩種保護技巧:

1. 當iJobTotal加一時,由於iJobTotal為單純的Integer,所以我們使用Inter.locked.Increment指令進行加1運算即可。

2. 在CNetBIOSCanner類別中,我們宣告了一個Event Done(byval Result as string),用於在於當掃瞄工作完成後,物件可以非同步的方式通知呼叫端,並將執行結果傳回方便進一步處理,這就和按鈕的onClick事件的概念是一樣的。我們寫了一個小函數SetJobDone,做兩件事,將iJobDone計數器加一,並以執行結果字串為參數觸發Done事件。
注意,這裡用的是SyncLock指令,而SyncLock的對象則使用GetType(CNetBIOSScanner)所傳回的 CNetBIOSScanner類別物件,如此能確保所有以CNetBIOSScanner所產生的物件,在多Thread執行多個物件的情況下,只有一條Thread允許被執行而達到保護效果。如果是由多條Thread執行同一個物件時,SyncLock的對象可以使用Me(C#中用lock this)。

補充說明一點,在設計Windows form時,關於物件事件要對應的程式碼大多加註於Sub宣告時,例如:

Private Sub btnBegin_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles btnBegin.Click

由於我們使用了物件陣列,所以改以AddHandler的方式指定物件的事件,如下:

AddHandler NBS(I).Done, AddressOf NBS_onDone

最後,在所有工作都完成後,由一小段程式碼將所得結果給印出來,順便統計一下時間,見識一下Multithreading的威力。組裝好的程式碼如下:

Imports System.Diagnostics

Imports System.IO

Imports System.Text

Imports System.Threading

Module Module1

Sub Main()

Dim I As Integer

Dim lStart As Long, lEnd As Long

Dim iCount As Integer = 0

Dim sResult As String

Dim NBS(254) As CNetBIOSScanner

Dim callBack As WaitCallback

lStart = Environment.TickCount

'將計數值設為0

CNetBIOSScanner.iJobTotal = 0

CNetBIOSScanner.iJobDone = 0

For I = 1 To 254

'建立新物件

NBS(I) = New CNetBIOSScanner()

callBack = New WaitCallback(AddressOf NBS(I).Detect)

'指向事件函數

AddHandler NBS(I).Done, AddressOf NBS_onDone

'以IP位址作為參數

ThreadPool.QueueUserWorkItem(callBack, "211.74.96." & I)

Next I

'每隔0.5秒檢查一次是否所有的物件均已完成?

While (CNetBIOSScanner.iJobDone < CNetBIOSScanner.iJobTotal)

Thread.Sleep(500)

End While

'統計結果

For I = 1 To 254

sResult = NBS(I).Result

If InStr(sResult, "ERROR") = 0 Then iCount = iCount + 1

Next

lEnd = Environment.TickCount

Console.WriteLine("共計花費" & (lEnd - lStart) & "ms...")

Console.WriteLine("找到" & iCount & "台Windows主機")

Console.ReadLine()

End Sub

Sub NBS_onDone(ByVal Result As String)

Console.WriteLine(Result)

End Sub

Class CNetBIOSScanner

'宣告為Shared後, 即使有再多個CNetBIOSScanner物件, 都共用一份變數

Public Shared iJobTotal As Integer

Public Shared iJobDone As Integer

Public Result As String

'使用Event完成非同步呼叫

Public Event Done(ByVal Result As String)

Public Sub New()

Interlocked.Increment(iJobTotal)

End Sub

Private Sub SetJobDone(ByVal sResult As String)

Result = sResult

'為了即時反應, 呼叫Done Event將結果立刻顯示出來

'涉及多Thread共用資料, 加上SyncLock保護

SyncLock GetType(CNetBIOSScanner)

RaiseEvent Done(sResult)

'工作結束, iJobDone計數加一, 但此時多Thread並行, 需使用保護機制

iJobDone = iJobDone + 1

End SyncLock

End Sub

Public Sub Detect(ByVal state As Object)

'檢查使用者傳入的是否為合法IPAddress, 藉用System.Net.IPAddress

Dim IP As String = CType(state, String)

Try

Dim ipValid As System.Net.IPAddress

ipValid = System.Net.IPAddress.Parse(IP)

Catch '使用者傳入的非合法的IP位址格式

SetJobDone("ERROR: Invalid IP Address")

Exit Sub

End Try

'Ping測試, 當IP不存在時, ping的傳回結果會出現timed out字眼, 以此判別IP存在與否

If InStr(Shell("ping.exe", "-n 1 " & IP), "timed out") > 0 Then

SetJobDone("ERROR: IP doesn't exists!")

Exit Sub

Else 'IP存在, 進行nbtstat -A解析

Dim sTemp As String

sTemp = Shell("nbtstat.exe", "-A " & IP)

'傳回結果若不包含任何UNIQUE字樣表示未查到相關的NetBIOS名稱資料

If InStr(sTemp, "UNIQUE") = 0 Then

SetJobDone("ERROR: Host not found.")

Exit Sub

Else

'將傳回結果解析成為多行

Dim sLines() As String

sLines = Split(sTemp, vbCrLf)

Dim sUsername As String = "", sMachineName As String = ""

Dim I As Integer, sNetBIOSName As String

For I = 0 To UBound(sLines)

'以<00> UNIQUE識別機器名稱, 排除IS~開頭者

If InStr(sLines(I), "<00> UNIQUE") > 0 Then

sNetBIOSName = Trim(Left(sLines(I), InStr(sLines(I), "<00>") - 1))

If Left(sNetBIOSName, 3) <> "IS~" Then

sMachineName = sNetBIOSName

End If

ElseIf InStr(sLines(I), "<03> UNIQUE") > 0 Then '由<03> UNIQUE識別使用者名稱

sUsername = Trim(Left(sLines(I), InStr(sLines(I), "<03>") - 1))

End If

Next

SetJobDone(IP & "," & sMachineName & "," & sUsername)

Exit Sub

End If

End If

End Sub

Private Function Shell(ByVal sExeFile As String, ByVal sArgument As String) As String

Dim pShell As Process

pShell = New Process()

'設定執行檔及參數

pShell.StartInfo.FileName = sExeFile

pShell.StartInfo.Arguments = sArgument

'必須要設定以下兩個屬性才可將輸出結果導向

pShell.StartInfo.UseShellExecute = False

pShell.StartInfo.RedirectStandardOutput = True

'不顯示任何視窗

pShell.StartInfo.CreateNoWindow = True

'開始執行

pShell.Start()

'將StdOUT的結果轉為字串, 其中StandardOutput屬性類別為StreamReader

Shell = pShell.StandardOutput.ReadToEnd()

pShell.WaitForExit()

End Function

End Class

End Module

我們用Thread Pool版的程式再做一次192.168.1.1-192.168.1.254的掃瞄測試,一樣是96台Host,但只花了39秒時間。與單Thread 的304秒相比,足足縮短了7.8倍,結果頗讓人滿意! 試想一下,有多少機會你能透過修改程式碼提升7倍以上的效能?

介面設計課題

至此,我們一直都是以Console Application的方式開發程式,讀者或許會考慮建立一個具備UI操作介面,讓它更具有實用性,成為一個可以隨時觀察最新處理進度的圖形化網路芳鄰掃瞄器程式。在設計Multithreading Web form程式時,則有一些額外的考量點,尤其有一條天大的禁忌—UI控制項是不允許被背景Thread直接存取的,開發者必須使用Control的 BeginInvoke或Invoke Method配合Delegate達成,由於仍有許多有趣的議題,就留待日後再來深入探討,此處就不再深入。有興趣的讀者不妨由MSDN中兩篇 Multithreading圖周率計算程式的文章入門,不妨先自已動手做看看。(Safe, Simple Multithreading in Windows forms, by Chris Sells以及A Second Look at Windows forms Multithreading, by Chris Sells)

結論

在本文中,我們介紹了網路上芳鄰的查詢原理,透過VB.NET程式呼叫Ping與Nbtstat兩個好用的工具程式,由程式執行的標準輸出中擷取出機器的 NetBIOS資訊。為了提供整體程式的執行效能,我們引進了Multithreading的設計概念,並使用.NET所提供的Thread Pool類別將程式改為Multithreading的運作模式,由掃瞄一個C Class的實際測試,Multithreading的版本足足比傳統的單Thread版本快了7.8倍。

由這些範例,我們見識了.NET Framework對於開發Multithreading程式時所提供的支援以及Multithreading程式所展現的強大威力。但是,在 Multithreading程式的開發上仍有諸多值得探討與研究的地方,本文鎖定在基本的介紹與體驗,所以並未包含Synchronization、 Deadlock等重要問題的深入討論。要設計優秀的Multithreading程式,仍有賴更多的相關知識與技巧。.NET在 Multithreading入門上提供了很大的協助,至於要成為個中翹楚,就有賴讀者持續不斷的學習與努力了。
多多留言, 整理文章, 把經驗累積下來.....
回覆文章