第二十章 多任務和多線程(多任務的各種模式)

多任務是一個操作系統可以同時執行多個程序的能力。基本上,操作系統使用一個硬件時鐘爲同時執行的每個程序配置「時間片段」。如果時間片段夠小,並且機器也沒有由於太多的程序而超出負荷時,那麼在使用者看來,所有的這些程序似乎在同時執行着。

多任務並不是什麼新的東西。在大型計算機上,多任務是必然的。這些大型主機通常有幾十甚至幾百個終端機和它連結,而每個終端機使用者都應該感覺到他或者她獨佔了整個計算機。另外,大型主機的操作系統通常允許使用者「提交工作到背景」,這些背景作業可以在使用者進行其它工作時,由機器執行完成。

個人計算機上的多任務花了更長的時間才普及化。但是現在PC多任務也被認爲是很正常的了。我馬上就會討論到,Microsoft Windows的16位版本支持有限度的多任務,Windows的32位版本支持真正的多任務,而且,還多了一種額外的優點,多線程。

多線程是在一個程序內部實作多任務的能力。程序可以把它自己分隔爲各自獨立的「線程」,這些線程似乎也同時在執行着。這一概念初看起來似乎沒有什麼用處,但是它可以讓程序使用多執行緒在背景執行冗長作業,從而讓使用者不必長時間地無法使用其計算機進行其它工作(有時這也許不是人們所希望的,不過這種時候去沖沖涼或者到冰箱去看看總是很不錯的)!但是,即使在計算機繁忙的時候,使用者也應該能夠使用它。

多任務的各種模式

在PC的早期,有人曾經提倡未來應該朝多任務的方向前進,但是大多數的人還是很迷惑:在一個單使用者的個人計算機上,多任務有什麼用呢?好了,最後事實表示即使是不知道這一概念的使用者也都需要多任務的。

DOS下的多任務

在最初PC上的Intel 8088微處理器並不是爲多任務而設計的。部分原因(我在 上一章中討論過)是內存管理不夠強。當啓動和結束多個程序時,多任務的操作系統通常需要移動內存塊以收集空閒內存。在8088上是不可能透明於應用系統來做到這一點的。

DOS本身對多任務沒有太大的幫助,它的設計目的是儘可能小巧,並且與獨立於應用程序之外,因此,除了加載程序以及對程序提供文件系統的存取功能,它幾乎沒有提供任何支持。

不過,有創意的程序寫作者仍然在DOS的早期就找到了一種克服這些缺陷的方法,大多數是使用常駐(TSR:terminate-and-stay-resident)程序。有些TSR,比如背景打印隊列程序等,透過攔截硬件時鐘中斷來執行真正的背景處理。其它的TSR,諸如SideKick等彈出式工具,可以執行某種型態的工作切換-暫停目前的應用程序,執行彈出式工具。DOS也逐漸有所增強以便提供對TSR的支持。

一些軟件廠商試圖在DOS之上架構出工作切換或者多任務的外殼程序(shell)(諸如Quarterdeck的DesqView),但是在這些環境中,僅有其中一個佔據了大部分市場,當然,這就是Windows。

非優先權式的多任務

當Microsoft在1985年發表Windows 1.0時,它是最成熟的解決方案,目的是突破DOS的侷限。Windows在實際模式下執行。但是即使這樣,它已可以在物理內存中移動內存塊。這是多任務的前提,雖然移動的方法尚未完全透明於應用程序,但是幾乎可以忍受了。

在圖形窗口環境中,多任務比在一種命令列單使用者操作系統中顯得更有意義。例如,在傳統的命令列UNIX中,可以在命令列之外執行程序,讓它們在背景執行。然而,程序的所有顯示輸出必須被重新轉向到一個文件中,否則輸出將和使用者正在做的事情混在一起。

窗口環境允許多個程序在相同屏幕上一起執行,前後切換非常容易,並且還可以快速地將數據從一個程序移動到另一個程序中。例如,將繪圖程序中建立的圖片嵌入由文書處理程序編輯的文本文件中。在Windows中,以多種方式支持數據轉移,首先是使用剪貼簿,後來又使用動態數據交換(DDE),而現在則是透過對象連結和嵌入(OLE)。

不過,早期Windows的多任務實作還不是多使用者操作系統中傳統的優先權式的分時多任務。這些操作系統使用系統時鐘週期性地中斷一個工作並開始另一個工作。Windows的這些16位版本支持一種被稱爲「非優先權式的多任務」,由於Windows消息驅動的架構而使這種型態的多任務成爲可能。通常情況下,一個Windows程序將在內存中睡眠,直到它收到一個消息爲止。這些消息通常是使用者的鍵盤或鼠標輸入的直接或間接結果。當處理完消息之後,程序將控制權返回給Windows。

Windows的16位版本不會絕對地依據一個timer tick將控制權從一個Windows程序切換到另一個,任何的工作切換都發生在當程序完成對消息的處理後將控制權返回給Windows時。這種非優先權式的多任務也被稱爲「合作式的多任務」,因爲它要求來自應用程序方面的一些合作。一個Windows程序可以佔用整個系統,如果它要花很長一段時間來處理消息的話。

雖然非優先權式的多任務是16位Windows的一般規則,但仍然出現了某些形式的優先權式多任務。Windows使用優先權式多任務來執行DOS程序,而且,爲了實作多媒體,還允許動態鏈接庫接收硬件時鐘中斷。

16位Windows包括幾個功能特性來幫助程序寫作者解決(或者,至少可以說是對付)非優先權式多任務中的侷限,最顯著的當然是時鐘式鼠標光標。當然,這並非一種解決方案,而僅僅是讓使用者知道一個程序正在忙於處理一件冗長作業,因而讓使用者在一段時間內無法使用系統。另一種解決方案是Windows定時器,它允許程序週期性地接收消息並完成一些工作。定時器通常用於時鐘應用和動畫。

針對非優先權式多任務的另一種解決方案是PeekMessage函數呼叫,我們曾在第五章中的RANDRECT程序裏看到過。一個程序通常使用GetMessage呼叫從它的消息隊列中找尋下一個消息,不過,如果在消息隊列中沒有消息,那麼GetMessage不會傳回,一直到出現一個消息爲止。而另一方面,PeekMessage將控制權傳回程序,即使沒有等待的消息。這樣,一個程序可以執行一個冗長作業,並在程序代碼中混入PeekMessage呼叫。只要沒有這個程序或其它任何程序的消息要處理,那麼這個冗長作業將繼續執行。

Presentation Manager和序列化的消息隊列

Microsoft在一種半DOS/半Windows的環境下實作多任務的第一個嘗試(和IBM合作)是OS/2和Presentation Manager(縮寫成PM )。雖然OS/2明確地支持優先權式多任務,但是這種多任務方式似乎並未在Presentation Manager中得以落實。問題在於PM序列化來自鍵盤和鼠標的使用者輸入消息。這意味着,在前一個使用者輸入消息被完全處理以前,PM不會將一個鍵盤或者鼠標消息傳送給程序。

儘管鍵盤和鼠標消息只是一個PM(或者Windows)程序可以接收的許多消息中的幾個,大多數的其它消息都是鍵盤或者鼠標事件的結果。例如,菜單命令消息是使用者使用鍵盤或者鼠標進行菜單選擇的結果。在處理菜單命令消息時,鍵盤或者鼠標消息並未完全被處理。

序列化消息隊列的主要原因是允許使用者的預先「鍵入」鍵盤按鍵和預先「按入」鼠標按鈕。如果一個鍵盤或者鼠標消息導致輸入焦點從一個窗口切換到另一個窗口,那麼接下來的鍵盤消息應該進入擁有新的輸入焦點的窗口中去。因此,系統不知道將下一個使用者輸入消息發送到何處,直到前一個消息被處理完爲止。

目前的共識是不應該讓一個應用系統有可能佔用整個系統,而這需要非序列化的消息隊列,32位版本的Windows支持這種消息隊列。如果一個程序正在忙着處理一項冗長作業,那麼您可以將輸入焦點切換到另一個程序中。

多線程解決方案

我討論OS/2的Presentation Manager,只是因爲它是第一個爲早期的Windows程序寫作者(比如我自己)介紹多線程的環境。有趣的是,PM實作多線程的侷限爲程序寫作者提供了應該如何架構多線程程序的必要線索。即使這些限制在32位的Windows中已經大幅減少,但是從更有限的環境中學到的經驗仍然是非常有效的。因此,讓我們繼續討論下去。

在一個多線程環境中,程序可以將它們自己分隔爲同時執行的片段(叫做執行緒)。對執行緒的支持是解決PM中存在的序列化消息隊列的最好方法,並且在Windows中線程有更實際的意義。

就程序代碼來說,一個線程簡單地被表示爲可能呼叫程序中其它函數的函數。程序從其主線程開始執行,這個主執行緒是在傳統的C程序中叫做main的函數,而在Windows中是WinMain。一旦執行起來,程序可以通過在系統呼叫CreateThread中指定初始線程函數的名稱來建立新的線程的執行。操作系統在執行緒之間優先權式地切換控件,和它在程序之間切換控制權的方法非常類似。

在OS/2的Presentation Manager中,每個線程可以建立一個消息隊列,也可以不建立。如果希望從線程建立窗口,那麼一個PM線程必須建立消息隊列。否則,如果只是進行許多的數據處理或者圖形輸出,那麼線程不需要建立消息隊列。因爲無消息隊列的程序不處理消息,所以它們將不會當住系統。唯一的限制是一個無消息隊列線程無法向一個消息隊列線程中的窗口發送消息,或者呼叫任何發送消息的函數(不過,它們可以將消息遞送給消息隊列線程)。

這樣,PM程序寫作者學會了如何將它們的程序分隔爲一個消息隊列線程(在其中建立所有的窗口並處理傳送給窗口的消息)和一個或者多個無消息隊列線程,在其中執行冗長的背景工作。PM程序寫作者還瞭解到「1/10秒規則」,大體上,程序寫作者被告知,一個消息隊列線程處理任何消息都不應該超過1/10秒,任何花費更長時間的事情都應該在另一個線程中完成。如果所有的程序寫作者都遵循這一規則,那麼將沒有PM程序會將系統當住超過1/10秒。

多線程架構

我已經說過PM的限制讓程序寫作者理解如何在圖形環境中執行的程序裏頭使用多個執行緒提供了必要的線索。因此在這裏我將爲您的程序建議一種架構:您的主執行緒建立您程序所需要的所有窗口,並在其中包含所有的窗口消息處理程序,以便處理這些窗口的所有消息;所有其它執行緒只進行一些背景處理,除了和主執行緒通訊,它們不和使用者進行交流。

可以把這種架構想象成:主線程處理使用者輸入(和其它消息),並建立程序中的其它線程,這些附加的線程完成與使用者無關的工作。

換句話說,您程序的主線程是一個老闆,而您的其它線程是老闆的職員。老闆將大的工作丟給職員處理,而他自己保持和外界的聯繫。因爲那些線程僅僅是職員,所以其它線程不會舉行它們自己的記者招待會。它們會認真地完成自己的工作,將結果報告給老闆,並等待他們的下一個任務。

一個程序中的線程是同一程序的不同部分,因此他們共享程序的資源,如內存和打開的文件。因爲線程共享程序的內存,所以他們還共享靜態變量。然而,每個線程都有他們自己的堆棧,因此動態變量對每個線程是唯一的。每個線程還有各自的處理器狀態(和數學協處理器狀態),這個狀態在進行線程切換期間被儲存和恢復。

線程間的「爭吵」

正確地設計、寫作和測試一個複雜的多線程應用程序顯然是Windows程序寫作者可能遇到的最困難的工作之一。因爲優先權式多任務系統可以在任何時刻中斷一個線程,並將控制權切換到另一個線程中,在兩個線程之間可能有無法預料的隨機交互作用的情況。

多線程程序中的一個常見的錯誤被稱爲「競爭狀態(race condition)」,這發生在程序寫作者假設一個線程在另一個線程需要某資料之前已經完成了某些處理(如準備數據)的時候。爲了幫助協調線程的活動,操作系統要求各種形式的同步。一種是同步信號(semaphore),它允許程序寫作者在程序代碼中的某一點阻止一個線程的執行,直到另一個執行緒發信號讓它繼續爲止。類似於同步信號的是「臨界區域(critical section)」,它是程序代碼中不可中斷的部分。

但是同步信號還可能產生稱爲「死鎖(deadlock)」的常見線程錯誤,這發生在兩個線程互相阻止了另一個的執行,而繼續執行的唯一辦法又是它們繼續向前執行。

幸運的是,32位程序比16位程序更能抵抗線程所涉及的某些問題。例如,假定一個線程執行下面的簡單敘述:

lCount++ ;

其中lCount是由其它線程使用的一個32位的long型態變量,C中的這個敘述被編譯爲兩條機械碼指令,第一條將變量的低16位加1,而第二條指令將任何可能的進位加到高16位上。假定操作系統在這兩個機械碼指令之間中斷了線程。如果lCount在第一條機械碼指令之前是0x0000FFFF,那麼lCount在線程被中斷時爲0,而這正是另一個線程將看到的值。只有當線程繼續執行時,lCount纔會增加到正確的值0x00010000。

這是那些偶爾會導致操作問題的錯誤之一。在16位程序中,解決此問題正確的方法是將敘述包含在一個臨界區域中,在這期間線程不會被中斷。然而,在一個32位程序中,該敘述是正確的,因爲它被編譯爲一條機械碼指令。

Windows的好處

32位Windows版本(包括Windows NT和Windows 98)有一個非序列化的消息隊列。這種實作似乎非常好:如果一個程序正在花費一段長時間處理一個消息,那麼鼠標位於該程序的窗口上時,鼠標光標將呈現爲一個時鐘,但是當將鼠標移到另一個程序的窗口上時,鼠標光標將變爲正常的箭頭形狀。只需按一下就可以將另一個窗口提到前面來。

然而,使用者仍然不能使用正在處理大量工作的那個程序,因爲那些工作會阻止程序接收其它消息,這不是我們所希望的。一個程序應該總是能隨時處理消息的,所以這時就需要使用從屬線程了。

在Windows NT和Windows 98中,沒有消息隊列線程和無消息隊列線程的區別,每個線程在建立時都會有它自己的消息隊列,從而減少了PM程序中關於線程的一些不便規定(然而,在大多數情況下,您仍然想通過一條專門處理消息的線程中的消息程序處理輸入,而將冗長作業交給那些不包含窗口的線程處理,這種結構幾乎總是最容易理解的,我們將看到這一點)。

還有更好的事情:Windows NT和Windows 98中有個函數允許線程殺死同一程序中的另一個線程。當您開始編寫多線程程序代碼時,您將會發現這種功能在有時是很方便的。OS/2的早期版本沒有「殺死線程」的函數。

最後的好消息(至少對這裏的話題是好消息)是Windows NT和Windows 98實作了一些被稱爲「線程區域儲存空間(TLS:thread local storage)」的功能。爲了瞭解這一點,回顧一下我在前面提到過的,靜態變量(對一個函數來說,既是整體又是區域變量)在線程之間是被共享的,因爲它們位於程序的數據儲存空間中。動態變量(對一個函數來說總是區域變量)對每一個線程則是唯一的,因爲它們佔據堆棧上的空間,而每個線程都有它自己的堆棧。

有時讓兩個或多個線程使用相同的函數,而讓這些線程使用唯一於線程的靜態變量,那會帶來很大便利。這就是線程區域儲存空間,其中涉及一些Windows函數呼叫,但是Microsoft還爲C編譯器進行擴展,使線程區域儲存空間的使用更透明於程序寫作者。

新改良過的!支持多線程了!

既然已經介紹了線程的現狀,讓我們來展望一下線程的未來。有時,有人會出現一種使用操作系統所提供的每一種功能特性的衝動。最壞的情況是,當您的老闆走到您的桌前並說:「我聽說這種新功能非常炫,讓我們在自己的程序中用一些這種新功能吧。」然後您將花費一個星期的時間,試圖去了解您的應用程序如何從這種新功能獲益。

應該注意的是,在並不需要多線程的應用系統中加入多線程是沒有任何意義的。如果您的程序顯示沙漏光標的時間太長,或者如果它使用PeekMessage呼叫來避免沙漏光標的出現,那麼請重新規劃您的程序架構,使用多線程可能會是一個好主意。其它情形,您是在爲難您自己,並可能會在程序代碼中產生新的錯誤。

在某些情況下,沙漏光標的出現可能是完全適當的。我在前面提到過「1/10秒規則」,而將一個大文件加載內存可能會花費多於1/10秒的時間,這是否意味着文件加載例程應該在分離的線程中實作呢?沒有必要。當使用者命令一個程序打開文件時,他或者她通常想立即完成該操作。將文件加載例程放在分離的線程中只會增加額外的負擔。即使您想向您的朋友誇耀您在編寫多線程程序,也完全不值得這樣做!

發佈了60 篇原創文章 · 獲贊 1 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章