Windows核心编程 第八章 用户方式中线程的同步(上)
第8章?用戶方式中線程的同步
????當所有的線程在互相之間不需要進行通信的情況下就能夠順利地運行時, M i c r o s o f t?Wi n d o w s的運行性能最好。但是,線程很少能夠在所有的時間都獨立地進行操作。通常情況下,要生成一些線程來處理某個任務。當這個任務完成時,另一個線程必須了解這個情況。
????系統中的所有線程都必須擁有對各種系統資源的訪問權,這些資源包括內存堆棧,串口,文件,窗口和許多其他資源。如果一個線程需要獨占對資源的訪問權,那么其他線程就無法完成它們的工作。反過來說,也不能讓任何一個線程在任何時間都能訪問所有的資源。如果在一個線程從內存塊中讀取數據時,另一個線程卻想要將數據寫入同一個內存塊,那么這就像你在讀一本書時另一個人卻在修改書中的內容一樣。這樣,書中的內容就會被搞得亂七八糟,結果什么也看不清楚。
????線程需要在下面兩種情況下互相進行通信:
????? 當有多個線程訪問共享資源而不使資源被破壞時。
????? 當一個線程需要將某個任務已經完成的情況通知另外一個或多個線程時。
線程的同步包括許多方面的內容,下面幾章將分別對它們進行介紹。值得高興的是,Wi n d o w s提供了許多方法,可以非常容易地實現線程的同步。但是,要想隨時了解一連串的線程想要做什么,那是非常困難的。我們的頭腦的工作不是異步的,我們希望以一種有序的方式來思考許多事情,每次前進一步。不過多線程環境不是這樣運行的。
8.1 原子訪問:互鎖的函數家族
? ?線程同步問題在很大程度上與原子訪問有關,所謂原子訪問,是指線程在訪問資源時能夠確保所有其他線程都不在同一時間內訪問相同的資源。讓我們來看一看下面這個簡單例子:
?
? ? 因為++會被翻譯成多條對應的匯編語句,同時這多條語句又不是一個原子操作的集合,所以最終結果無法確定是多少。
? ? 為了解決上面的問題,需要某種比較簡單的方法。我們需要一種手段來保證值的遞增能夠以原子操作方式來進行,也就是不中斷地進行。互鎖的函數家族提供了我們需要的解決方案。互鎖的函數盡管用處很大,而且很容易理解,卻有些讓人望而生畏,大多數軟件開發人員用得很少。所有的函數都能以原子操作方式對一個值進行操作。讓我們看一看下面這個 I n t e r l o c k e dE x c h a n g e A d d函數:
LONG InterlockedExchangeAdd(PLONG plAddend ,LONG lIncrement);
這是個最簡單的函數了。只需調用這個函數,傳遞一個長變量地址,并指明將這個值遞增多少即可。但是這個函數能夠保證值的遞增以原子操作方式來完成。因此可以將上面的代碼重新編寫為下面的形式:
?
? 互鎖函數是如何運行的呢?答案取決于運行的是何種 C P U平臺。對于x 8 6家族的C P U來說,互鎖函數會對總線發出一個硬件信號,防止另一個 C P U訪問同一個內存地址。
對于互鎖函數,需要了解的另一個重要問題是,它們運行的速度極快。調用一個互鎖函數通常會導致執行幾個C P U周期(通常小于5 0) ,并且不會從用戶方式轉換為內核方式(通常這需要執行1 0 0 0個C P U周期) 。
? ? 當然,可以使用I n t e r l o c k e d E x c h a n g e A d d減去一個值 — 只要為第二個參數傳遞一個負值。I n t e r l o c k e d E x c h a n g e A d d將返回在* p l A d d e n d中的原始值。
下面是另外兩個互鎖函數:
?
? ? I n t e r l o c k e d E x c h a n g e和I n t e r l o c k e d E x c h a n g e P o i n t e r能夠以原子操作方式用第二個參數中傳遞的值來取代第一個參數中傳遞的當前值。如果是 3 2位應用程序,兩個函數都能用另一個3 2位值取代一個3 2位值。但是,如果是個6 4位應用程序,那么I n t e r l o c k e d E x c h a n g e能夠取代一個3 2位值,而I n t e r l o c k e d E x c h a n g e P o i n t e r則取代6 4位值。兩個函數都返回原始值。當實現一個循環鎖時,I n t e r l o c k e d E x c h a n g e是非常有用的:
?
????w h i l e循環是循環運行的,它將g _ f R e s o u r c e I n U s e中的值改為T R U E,并查看它的前一個值,以了解它是否是T R U E。如果這個值原先是FA L S E,那么該資源并沒有在使用,而是調用線程將它設置為在用狀態并退出該循環。如果前一個值是T R U E,那么資源正在被另一個線程使用,w h i l e循環將繼續循環運行。
????如果另一個線程要執行類似的代碼,它將在 w h i l e循環中運行,直到g _ f R e s o u r c e I n U s e重新改為FA L S E。調用函數結尾處的I n t e r l o c k e d E x c h a n g e,可顯示應該如何將g _ f R e s o u r c e I n U s e重新設置為FA L S E。
????當使用這個方法時必須格外小心,因為循環鎖會浪費 C P U時間。C P U必須不斷地比較兩個值,直到一個值由于另一個線程而“奇妙地”改變為止。另外,該代碼假定使用循環鎖的所有線程都以相同的優先級等級運行。也可以把執行循環鎖的線程的優先級提高功能禁用(通過調用S e t P r o c e s s P r i o r i t y B o o s t或s e t T h r e a d P r i o r i t y B o o s t函數來實現之) 。
? ? 此外,應該保證將循環鎖變量和循環鎖保護的數據維護在不同的高速緩存行中(本章后面部分介紹) 。如果循環鎖變量與數據共享相同的高速緩存行,那么使用該資源的 C P U將與試圖訪問該資源的任何C P U爭用高速緩存行。
應該避免在單個C P U計算機上使用循環鎖。如果一個線程正在循環運行,它就會浪費前一個C P U時間,這將防止另一個線程修改該值。我在上面的 w h i l e循環中使用了S l e e p ,從而在某種程度上解決了浪費C P U時間的問題。如果使用 S l e e p,你可能想睡眠一個隨機時間量;每次請求訪問該資源均被拒絕時,你可能想進一步延長睡眠時間。這可以防止線程浪費 C P U時間。根據情況,最好是全部刪除對S l e e p的調用。或者使用對S w i t c h To T h r e a d(Windows 98中沒有這個函數)的調用來取代它。勇于試驗和不斷糾正錯誤,是學習的最好方法。
循環鎖假定,受保護的資源總是被訪問較短的時間。這使它能夠更加有效地循環運行,然后轉為內核方式并進入等待狀態。許多編程人員循環運行一定的次數(比如 4 0 0次) ,如果對資源的訪問仍然被拒絕,那么該線程就轉為內核方式,在這種方式下,它要等待(不消耗 C P U時間) ,直到該資源變為可供使用為止。這就是關鍵部分實現的方法。
? ? 循環鎖在多處理器計算機上非常有用,因為當一個線程循環運行的時候,另一個線程可以在另一個C P U上運行。但是,即使在這種情況下,也必須小心。不應該讓線程循環運行太長的時間,也不能浪費更多的C P U時間。本章后面將進一步介紹循環鎖。第1 0章將介紹如何使用循環鎖。
下面是最后兩個互鎖函數:
? ? 這兩個函數負責執行一個原子測試和設置操作。如果是 3 2位應用程序,那么兩個函數都在3 2位值上運行,但是,如果是6 4位應用程序,I n t e r l o c k e d C o m p a r e E x c h a n g e函數在3 2位值上運行,而I n t e r l o c k e d C o m p a r e E x c h a n g e P o i n t e r函數則在6 4位值上運行。在偽代碼中,它的運行情況如下面所示:
?
? ? 該函數對當前值(p l D e s t i n a t i o n參數指向的值)與l C o m p a r a n d參數中傳遞的值進行比較。如果兩個值相同,那么 * p l D e s t i n a t i o n改為l E x c h a n g e參數的值。如果 * p l D e s t i n a t i o n中的值與l E x c h a n g e的值不匹配,* p l D e s t i n a t i o n保持不變。該函數返回* p l D e s t i n a t i o n中的原始值。記住,所有這些操作都是作為一個原子執行單位來進行的。
? ? 沒有任何互鎖函數僅僅負責對值進行讀取操作(而不改變這個值) ,因為這樣的函數根本是不需要的。如果線程只是試圖讀取值的內容,而這個值始終都由互鎖函數來修改,那么被讀取的值總是一個很好的值。雖然你不知道你讀取的是原始值還是更新值,但是你知道它是這兩個值中的一個。對于大多數應用程序來說,這一點很重要。此外,當要對共享內存區域(比如內存映象文件)中的值的訪問進行同步時,互鎖函數也可以供多進程中的線程使用(第 9章中包含了幾個示例應用程序,以顯示如何正確地使用互鎖函數) 。
雖然Wi n d o w s還提供了另外幾個互鎖函數,但是上面介紹的這些函數能夠實現其他函數能做的一切功能,甚至更多。下面是兩個其他的函數:
?
? ? I n t e r l o c k e d E x c h a n g e A d d函數能夠取代這些較老的函數。新函數能夠遞增或遞減任何值,老的函數只能加1或減1。
8.2 高速緩存行
? ? 如果想創建一個能夠在多處理器計算機上運行的高性能應用程序,必須懂得 C P U的高速緩存行。當一個C P U從內存讀取一個字節時,它不只是取出一個字節,它要取出足夠的字節來填入高速緩存行。高速緩存行由 3 2或6 4個字節組成(視C P U而定) ,并且始終在第3 2個字節或第6 4個字節的邊界上對齊。高速緩存行的作用是為了提高 C P U運行的性能。通常情況下,應用程序只能對一組相鄰的字節進行處理。如果這些字節在高速緩存中,那么 C P U就不必訪問內存總線,而訪問內存總線需要多得多的時間。
? ? 但是,在多處理器環境中,高速緩存行使得內存的更新更加困難,下面這個例子就說明了這一點:
1) CPU1讀取一個字節,使該字節和它的相鄰字節被讀入C P U 1的高速緩存行。
2) CPU2讀取同一個字節,使得第一步中的相同的各個字節讀入C P U 2的高速緩存行。
3) CPU1修改內存中的該字節,使得該字節被寫入C P U 1的高速緩存行。但是該信息尚未寫入R A M。
4) CPU2再次讀取同一個字節。由于該字節已經放入C P U 2的高速緩存行,因此它不必訪問內存。但是C P U 2將看不到內存中該字節的新值。
? ? 這種情況會造成嚴重的后果。當然,芯片設計者非常清楚這個問題,并且設計它們的芯片來處理這個問題。尤其是,當一個C P U修改高速緩存行中的字節時,計算機中的其他 C P U會被告知這個情況,它們的高速緩存行將變為無效。因此,在上面的情況下, C P U 2的高速緩存在C P U 1修改字節的值時變為無效。在第 4步中,C P U 1必須將它的高速緩存內容迅速轉入內存,C P U 2必須再次訪問內存,重新將數據填入它的高速緩存行。如你所見,高速緩存行能夠幫助提高運行的速度,但是它們也可能是多處理器計算機上的一個不利因素。
這一切意味著你應該將高速緩存行存儲塊中的和高速緩存行邊界上的應用程序數據組合在一起。 這樣做的目的是確保不同的C P U能夠訪問至少由高速緩存行邊界分開的不同的內存地址。還有,應該將只讀數據(或不常讀的數據)與讀寫數據分開。同時,應該將同一時間訪問的數據組合在一起。
下面是設計得很差的數據結構的例子:
?
下面是該結構的改進版本:
?
? ? 上面定義的C A C H E _ A L I G N宏是不錯的,但是并不很好。問題是必須手工將每個成員變量的字節值輸入該宏。如果增加、移動或刪除數據成員,也必須更新對 C A C H E _ PA D宏的調用。將來,M i c r o s o f t的C / C + +編譯器將支持一種新句法,該句法可以更容易地調整數據成員。它的形式類似_ _ d e c l s p e c ( a l i g n ( 3 2 ) )。
注意 最好是始終都讓單個線程來訪問數據(函數參數和局部變量是確保做到這一點的最好方法) ,或者始終讓單個C P U訪問這些數據(使用線程親緣性) 。如果采取其中的一種方法,就能夠完全避免高速緩存行的各種問題。
8.3 高級線程同步
? ? 當必須以原子操作方式來修改單個值時,互鎖函數家族是相當有用的。你肯定應該先試試它們。 但是大多數實際工作中的編程問題要解決的是比單個3 2位或6 4位值復雜得多的數據結構。為了以原子操作方式使用更加復雜的數據結構,必須將互鎖函數放在一邊,使用 Wi n d o w s提供的其他某些特性。
? ? 前面強調了不應該在單處理器計算機上使用循環鎖,甚至在多處理器計算機上,也應該小心地使用它們。原因是C P U時間非常寶貴,決不應該浪費。因此需要一種機制,使線程在等待訪問共享資源時不浪費C P U時間。
? ? 當線程想要訪問共享資源,或者得到關于某個“特殊事件”的通知時,該線程必須調用一個操作系統函數,給它傳遞一些參數,以指明該線程正在等待什么。如果操作系統發現資源可供使用,或者該特殊事件已經發生,那么函數就返回,同時該線程保持可調度狀態(該線程可以不必立即執行,它處于可調度狀態,可以使用前一章介紹的原則將它分配給一個 C P U) 。
? ? 如果資源不能使用,或者特殊事件還沒有發生,那么系統便使該線程處于等待狀態,使該線程無法調度。這可以防止線程浪費 C P U時間。當線程處于等待狀態時,系統作為一個代理,代表你的線程來執行操作。系統能夠記住你的線程需要什么,當資源可供使用的時候,便自動使該線程退出等待狀態,該線程的運行將與特殊事件實現同步。
? ? 從實際情況來看,大多數線程幾乎總是處于等待狀態。當系統發現所有線程有若干分鐘均處于等待狀態時,系統的強大的管理功能就會發揮作用。
? ? 要避免使用的一種方法
? ? 如果沒有同步對象,并且操作系統不能發現各種特殊事件,那么線程就不得不使用下面要介紹的一種方法使自己與特殊事件保持同步。不過,由于操作系統具有支持線程同步的內置特性,因此決不應該使用這種方法。
運用這種方法時,一個線程能夠自己與另一個線程中的任務的完成實現同步,方法是不斷查詢多個線程共享或可以訪問的變量的狀態。下面的代碼段說明了這個情況:
?
? ? 如你所見,當主線程(執行Wi n M a i n)必須使自己與R e c a l c F u n c函數的完成運行實現同步時,它并沒有使自己進入睡眠狀態。由于主線程沒有進入睡眠狀態,因此操作系統繼續為它調度C P U時間,這就要占用其他線程的寶貴時間周期。
? ? 前面代碼段中使用的查詢方法存在的另一個問題是, B O O L變量g_f FinishedCalculation從來沒有被設置為T R U E。當主線程的優先級高于執行 R e c a l c F u n c函數的線程時,就會發生這種情況。在這種情況下,系統決不會將任何時間片分配給 R e c a l c F u n c線程。如果執行Wi n M a i n函數的線程被置于睡眠狀態,而不是進行查詢,那么這就不是已調度的時間。系統可以將時間調度給低優先級的線程,如R e c a l c F u n c線程,使它們得以運行。
應該說,有時查詢遲早都可以進行,畢竟是循環鎖執行的操作。不過有些方法進行這項操作是恰當的,而有些方法是不恰當的。一般來說,應該調用一些函數,使線程進入睡眠狀態,直到線程需要的資源可供使用為止。下一節將介紹一種正確的方法。
? ? 首先,在前面介紹的代碼段的開頭,你會發現它使用了 v o l a t i l e一詞。為了使這個代碼段更加接近工作狀態,必須有一個v o l a t i l e類型的限定詞。它告訴編譯器,變量可以被應用程序本身以外的某個東西進行修改,這些東西包括操作系統,硬件或同時執行的線程等。尤其是,v o l a t i l e限定詞會告訴編譯器,不要對該變量進行任何優化,并且總是重新加載來自該變量的內存單元的值。比如,編譯器為前面的代碼段中的w h i l e語句生成了下面的偽代碼:
?
? ? 如果不使布爾變量具備易變性,編譯器就能像上面所示的那樣優化你的 C代碼。為了實現這樣的優化,編譯器只需將B O O L變量的值裝入一個C P U寄存器一次。然后,它對該C P U寄存器反復進行測試。這樣得出的性能當然要比不斷地重復讀取內存地址中的值并對它進行重復測試要好,因此,優化編譯器能夠編寫上面所示的那種代碼。但是,如果編譯器進行這樣的操作,線程就會進入一個無限循環,永遠無法喚醒。另外,使一個結構具備易變性,可以確保它的所有成員都具有易變性,當它們被引用時,總是可以從內存中讀取它們。
? ? 你也許會問,循環變量g _ f R e s o u r c e I n U s e是否應該聲明為v o l a t i l e變量。答案是不必,因為我們將該變量的地址傳遞給各個不同的互鎖函數,而不是傳遞變量值本身。當將一個變量地址傳遞給一個函數時,該函數必須從內存讀取該值。優化程序不會對它產生任何影響。
?
總結
以上是生活随笔為你收集整理的Windows核心编程 第八章 用户方式中线程的同步(上)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: svchost服务(DLL服务)
- 下一篇: Windows核心编程 第八章 用户方式