产生线程安全的原因(4)(操作系统)
3.4 指令緩存
其實,不光處理器使用的數據被緩存,它們執行的指令也是被緩存的。只不過,指令緩存的問題相對來說要少得多,因為:
- 執行的代碼量取決于代碼大小。而代碼大小通常取決于問題復雜度。問題復雜度則是固定的。
- 程序的數據處理邏輯是程序員設計的,而程序的指令卻是編譯器生成的。編譯器的作者知道如何生成優良的代碼。
- 程序的流向比數據訪問模式更容易預測。現如今的CPU很擅長模式檢測,對預取很有利。
- 代碼永遠都有良好的時間局部性和空間局部性。
有一些準則是需要程序員們遵守的,但大都是關于如何使用工具的,我們會在第6節介紹它們。而在這里我們只介紹一下指令緩存的技術細節。
隨著CPU的核心頻率大幅上升,緩存與核心的速度差越拉越大,CPU的處理開始管線化。也就是說,指令的執行分成若干階段。首先,對指令進行解碼,隨后,準備參數,最后,執行它。這樣的管線可以很長(例如,Intel的Netburst架構超過了20個階段)。在管線很長的情況下,一旦發生延誤(即指令流中斷),需要很長時間才能恢復速度。管線延誤發生在這樣的情況下: 下一條指令未能正確預測,或者裝載下一條指令耗時過長(例如,需要從內存讀取時)。
為了解決這個問題,CPU的設計人員們在分支預測上投入大量時間和芯片資產(chip real estate),以降低管線延誤的出現頻率。
在CISC處理器上,指令的解碼階段也需要一些時間。x86及x86-64處理器尤為嚴重。近年來,這些處理器不再將指令的原始字節序列存入L1i,而是緩存解碼后的版本。這樣的L1i被叫做“追蹤緩存(trace cache)”。追蹤緩存可以在命中的情況下讓處理器跳過管線最初的幾個階段,在管線發生延誤時尤其有用。
前面說過,L2以上的緩存是統一緩存,既保存代碼,也保存數據。顯然,這里保存的代碼是原始字節序列,而不是解碼后的形式。
在提高性能方面,與指令緩存相關的只有很少的幾條準則:
這些準則一般會由編譯器的代碼生成階段強制執行。至于程序員可以參與的部分,我們會在第6節介紹。
3.4.1 自修改的代碼
在計算機的早期歲月里,內存十分昂貴。人們想盡千方百計,只為了盡量壓縮程序容量,給數據多留一些空間。其中,有一種方法是修改程序自身,稱為自修改代碼(SMC)。現在,有時候我們還能看到它,一般是出于提高性能的目的,也有的是為了攻擊安全漏洞。
一般情況下,應該避免SMC。雖然一般情況下沒有問題,但有時會由于執行錯誤而出現性能問題。顯然,發生改變的代碼是無法放入追蹤緩存(追蹤緩存放的是解碼后的指令)的。即使沒有使用追蹤緩存(代碼還沒被執行或有段時間沒執行),處理器也可能會遇到問題。如果某個進入管線的指令發生了變化,處理器只能扔掉目前的成果,重新開始。在某些情況下,甚至需要丟棄處理器的大部分狀態。
最后,由于處理器認為代碼頁是不可修改的(這是出于簡單化的考慮,而且在99.9999999%情況下確實是正確的),L1i用到并不是MESI協議,而是一種簡化后的SI協議。這樣一來,如果萬一檢測到修改的情況,就需要作出大量悲觀的假設。
因此,對于SMC,強烈建議能不用就不用。現在內存已經不再是一種那么稀缺的資源了。最好是寫多個函數,而不要根據需要把一個函數改來改去。也許有一天可以把SMC變成可選項,我們就能通過這種方式檢測入侵代碼。如果一定要用SMC,應該讓寫操作越過緩存,以免由于L1i需要L1d里的數據而產生問題。更多細節,請參見6.1節。
在Linux上,判斷程序是否包含SMC是很容易的。利用正常工具鏈(toolchain)構建的程序代碼都是寫保護(write-protected)的。程序員需要在鏈接時施展某些關鍵的魔術才能生成可寫的代碼頁。現代的Intel x86和x86-64處理器都有統計SMC使用情況的專用計數器。通過這些計數器,我們可以很容易判斷程序是否包含SMC,即使它被準許運行。
3.5 緩存未命中的因素
我們已經看過內存訪問沒有命中緩存時,那陡然猛漲的高昂代價。但是有時候,這種情況又是無法避免的,因此我們需要對真正的代價有所認識,并學習如何緩解這種局面。
?
3.5.1 緩存與內存帶寬
為了更好地理解處理器的能力,我們測量了各種理想環境下能夠達到的帶寬值。由于不同處理器的版本差別很大,所以這個測試比較有趣,也因為如此,這一節都快被測試數據灌滿了。我們使用了x86和x86-64處理器的SSE指令來裝載和存儲數據,每次16字節。工作集則與其它測試一樣,從1kB增加到512MB,測量的具體對象是每個周期所處理的字節數。
圖3.24展示了一顆64位Intel Netburst處理器的性能圖表。當工作集能夠完全放入L1d時,處理器的每個周期可以讀取完整的16字節數據,即每個周期執行一條裝載指令(moveaps指令,每次移動16字節的數據)。測試程序并不對數據進行任何處理,只是測試讀取指令本身。當工作集增大,無法再完全放入L1d時,性能開始急劇下降,跌至每周期6字節。在218工作集處出現的臺階是由于DTLB緩存耗盡,因此需要對每個新頁施加額外處理。由于這里的讀取是按順序的,預取機制可以完美地工作,而FSB能以5.3字節/周期的速度傳輸內容。但預取的數據并不進入L1d。當然,真實世界的程序永遠無法達到以上的數字,但我們可以將它們看作一系列實際上的極限值。
更令人驚訝的是寫操作和復制操作的性能。即使是在很小的工作集下,寫操作也始終無法達到4字節/周期的速度。這意味著,Intel為Netburst處理器的L1d選擇了寫通(write-through)模式,所以寫入性能受到L2速度的限制。同時,這也意味著,復制測試的性能不會比寫入測試差太多(復制測試是將某塊內存的數據拷貝到另一塊不重疊的內存區),因為讀操作很快,可以與寫操作實現部分重疊。最值得關注的地方是,兩個操作在工作集無法完全放入L2后出現了嚴重的性能滑坡,降到了0.5字節/周期!比讀操作慢了10倍!顯然,如果要提高程序性能,優化這兩個操作更為重要。
再來看圖3.25,它來自同一顆處理器,只是運行雙線程,每個線程分別運行在處理器的一個超線程上。
圖3.25采用了與圖3.24相同的刻度,以方便比較兩者的差異。圖3.25中的曲線抖動更多,是由于采用雙線程的緣故。結果正如我們預期,由于超線程共享著幾乎所有資源(僅除寄存器外),所以每個超線程只能得到一半的緩存和帶寬。所以,即使每個線程都要花上許多時間等待內存,從而把執行時間讓給另一個線程,也是無濟于事——因為另一個線程也同樣需要等待。這里恰恰展示了使用超線程時可能出現的最壞情況。
再來看Core 2處理器的情況。看看圖3.26和圖3.27,再對比下P4的圖3.24和3.25,可以看出不小的差異。Core 2是一顆雙核處理器,有著共享的L2,容量是P4 L2的4倍。但更大的L2只能解釋寫操作的性能下降出現較晚的現象。
當然還有更大的不同。可以看到,讀操作的性能在整個工作集范圍內一直穩定在16字節/周期左右,在220處的下降同樣是由于DTLB的耗盡引起。能夠達到這么高的數字,不但表明處理器能夠預取數據,并且按時完成傳輸,而且還意味著,預取的數據是被裝入L1d的。
寫/復制操作的性能與P4相比,也有很大差異。處理器沒有采用寫通策略,寫入的數據留在L1d中,只在必要時才逐出。這使得寫操作的速度可以逼近16字節/周期。一旦工作集超過L1d,性能即飛速下降。由于Core 2讀操作的性能非常好,所以兩者的差值顯得特別大。當工作集超過L2時,兩者的差值甚至超過20倍!但這并不表示Core 2的性能不好,相反,Core 2永遠都比Netburst強。
在圖3.27中,啟動雙線程,各自運行在Core 2的一個核心上。它們訪問相同的內存,但不需要完美同步。從結果上看,讀操作的性能與單線程并無區別,只是多了一些多線程情況下常見的抖動。
有趣的地方來了——當工作集小于L1d時,寫操作與復制操作的性能很差,就好像數據需要從內存讀取一樣。兩個線程彼此競爭著同一個內存位置,于是不得不頻頻發送RFO消息。問題的根源在于,雖然兩個核心共享著L2,但無法以L2的速度處理RFO請求。而當工作集超過L1d后,性能出現了迅猛提升。這是因為,由于L1d容量不足,于是將被修改的條目刷新到共享的L2。由于L1d的未命中可以由L2滿足,只有那些尚未刷新的數據才需要RFO,所以出現了這樣的現象。這也是這些工作集情況下速度下降一半的原因。這種漸進式的行為也與我們期待的一致:?由于每個核心共享著同一條FSB,每個核心只能得到一半的FSB帶寬,因此對于較大的工作集來說,每個線程的性能大致相當于單線程時的一半。
由于同一個廠商的不同處理器之間都存在著巨大差異,我們沒有理由不去研究一下其它廠商處理器的性能。圖3.28展示了AMD家族10h Opteron處理器的性能。這顆處理器有64kB的L1d、512kB的L2和2MB的L3,其中L3緩存由所有核心所共享。
大家首先應該會注意到,在L1d緩存足夠的情況下,這個處理器每個周期能處理兩條指令。讀操作的性能超過了32字節/周期,寫操作也達到了18.7字節/周期。但是,不久,讀操作的曲線就急速下降,跌到2.3字節/周期,非常差。處理器在這個測試中并沒有預取數據,或者說,沒有有效地預取數據。
另一方面,寫操作的曲線隨幾級緩存的容量而流轉。在L1d階段達到最高性能,隨后在L2階段下降到6字節/周期,在L3階段進一步下降到2.8字節/周期,最后,在工作集超過L3后,降到0.5字節/周期。它在L1d階段超過了Core 2,在L2階段基本相當(Core 2的L2更大一些),在L3及主存階段比Core 2慢。
復制的性能既無法超越讀操作的性能,也無法超越寫操作的性能。因此,它的曲線先是被讀性能壓制,隨后又被寫性能壓制。
圖3.29顯示的是Opteron處理器在多線程時的性能表現。
讀操作的性能沒有受到很大的影響。每個線程的L1d和L2表現與單線程下相仿,L3的預取也依然表現不佳。兩個線程并沒有過渡爭搶L3。問題比較大的是寫操作的性能。兩個線程共享的所有數據都需要經過L3,而這種共享看起來卻效率很差。即使是在L3足夠容納整個工作集的情況下,所需要的開銷仍然遠高于L3的訪問時間。再來看圖3.27,可以發現,在一定的工作集范圍內,Core 2處理器能以共享的L2緩存的速度進行處理。而Opteron處理器只能在很小的一個范圍內實現相似的性能,而且,它僅僅只能達到L3的速度,無法與Core 2的L2相比。
3.5.2 關鍵字加載
內存以比緩存線還小的塊從主存儲器向緩存傳送。如今64位可一次性傳送,緩存線的大小為64或128比特。這意味著每個緩存線需要8或16次傳送。
DRAM芯片可以以觸發模式傳送這些64位的塊。這使得不需要內存控制器的進一步指令和可能伴隨的延遲,就可以將緩存線充滿。如果處理器預取了緩存,這有可能是最好的操作方式。
?
如果程序在訪問數據或指令緩存時沒有命中(這可能是強制性未命中或容量性未命中,前者是由于數據第一次被使用,后者是由于容量限制而將緩存線逐出),情況就不一樣了。程序需要的并不總是緩存線中的第一個字,而數據塊的到達是有先后順序的,即使是在突發模式和雙倍傳輸率下,也會有明顯的時間差,一半在4個CPU周期以上。舉例來說,如果程序需要緩存線中的第8個字,那么在首字抵達后它還需要額外等待30個周期以上。
當然,這樣的等待并不是必需的。事實上,內存控制器可以按不同順序去請求緩存線中的字。當處理器告訴它,程序需要緩存中具體某個字,即「關鍵字(critical word)」時,內存控制器就會先請求這個字。一旦請求的字抵達,雖然緩存線的剩余部分還在傳輸中,緩存的狀態還沒有達成一致,但程序已經可以繼續運行。這種技術叫做關鍵字優先及較早重啟(Critical Word First & Early Restart)。
現在的處理器都已經實現了這一技術,但有時無法運用。比如,預取操作的時候,并不知道哪個是關鍵字。如果在預取的中途請求某條緩存線,處理器只能等待,并不能更改請求的順序。
在關鍵字優先技術生效的情況下,關鍵字的位置也會影響結果。圖3.30展示了下一個測試的結果,圖中表示的是關鍵字分別在線首和線尾時的性能對比情況。元素大小為64字節,等于緩存線的長度。圖中的噪聲比較多,但仍然可以看出,當工作集超過L2后,關鍵字處于線尾情況下的性能要比線首情況下低0.7%左右。而順序訪問時受到的影響更大一些。這與我們前面提到的預取下條線時可能遇到的問題是相符的。
3.5.3 緩存設定
緩存放置的位置與超線程,內核和處理器之間的關系,不在程序員的控制范圍之內。但是程序員可以決定線程執行的位置,接著高速緩存與使用的CPU的關系將變得非常重要。
這里我們將不會深入(探討)什么時候選擇什么樣的內核以運行線程的細節。我們僅僅描述了在設置關聯線程的時候,程序員需要考慮的系統結構的細節。
超線程,通過定義,共享除去寄存器集以外的所有數據。包括 L1 緩存。這里沒有什么可以多說的。多核處理器的獨立核心帶來了一些樂趣。每個核心都至少擁有自己的 L1 緩存。除此之外,下面列出了一些不同的特性:
- 早期多核心處理器有獨立的 L2 緩存且沒有更高層級的緩存。
- 之后英特爾的雙核心處理器模型擁有共享的L2 緩存。對四核處理器,則分對擁有獨立的L2 緩存,且沒有更高層級的緩存。
- AMD 家族的 10h 處理器有獨立的 L2 緩存以及一個統一的L3 緩存。
關于各種處理器模型的優點,已經在它們各自的宣傳手冊里寫得夠多了。在每個核心的工作集互不重疊的情況下,獨立的L2擁有一定的優勢,單線程的程序可以表現優良。考慮到目前實際環境中仍然存在大量類似的情況,這種方法的表現并不會太差。不過,不管怎樣,我們總會遇到工作集重疊的情況。如果每個緩存都保存著某些通用運行庫的常用部分,那么很顯然是一種浪費。
如果像Intel的雙核處理器那樣,共享除L1外的所有緩存,則會有一個很大的優點。如果兩個核心的工作集重疊的部分較多,那么綜合起來的可用緩存容量會變大,從而允許容納更大的工作集而不導致性能的下降。如果兩者的工作集并不重疊,那么則是由Intel的高級智能緩存管理(Advanced Smart Cache management)發揮功用,防止其中一個核心壟斷整個緩存。
即使每個核心只使用一半的緩存,也會有一些摩擦。緩存需要不斷衡量每個核心的用量,在進行逐出操作時可能會作出一些比較差的決定。我們來看另一個測試程序的結果。
這次,測試程序兩個進程,第一個進程不斷用SSE指令讀/寫2MB的內存數據塊,選擇2MB,是因為它正好是Core 2處理器L2緩存的一半,第二個進程則是讀/寫大小變化的內存區域,我們把這兩個進程分別固定在處理器的兩個核心上。圖中顯示的是每個周期讀/寫的字節數,共有4條曲線,分別表示不同的讀寫搭配情況。例如,標記為讀/寫(read/write)的曲線代表的是后臺進程進行寫操作(固定2MB工作集),而被測量進程進行讀操作(工作集從小到大)。
圖中最有趣的是220到223之間的部分。如果兩個核心的L2是完全獨立的,那么所有4種情況下的性能下降均應發生在221到222之間,也就是L2緩存耗盡的時候。但從圖上來看,實際情況并不是這樣,特別是背景進程進行寫操作時尤為明顯。當工作集達到1MB(220)時,性能即出現惡化,兩個進程并沒有共享內存,因此并不會產生RFO消息。所以,完全是緩存逐出操作引起的問題。目前這種智能的緩存處理機制有一個問題,每個核心能實際用到的緩存更接近1MB,而不是理論上的2MB。如果未來的處理器仍然保留這種多核共享緩存模式的話,我們唯有希望廠商會把這個問題解決掉。
推出擁有雙L2緩存的4核處理器僅僅只是一種臨時措施,是開發更高級緩存之前的替代方案。與獨立插槽及雙核處理器相比,這種設計并沒有帶來多少性能提升。兩個核心是通過同一條總線(被外界看作FSB)進行通信,并沒有什么特別快的數據交換通道。
未來,針對多核處理器的緩存將會包含更多層次。AMD的10h家族是一個開始,至于會不會有更低級共享緩存的出現,還需要我們拭目以待。我們有必要引入更多級別的緩存,因為頻繁使用的高速緩存不可能被許多核心共用,否則會對性能造成很大的影響。我們也需要更大的高關聯性緩存,它們的數量、容量和關聯性都應該隨著共享核心數的增長而增長。巨大的L3和適度的L2應該是一種比較合理的選擇。L3雖然速度較慢,但也較少使用。
對于程序員來說,不同的緩存設計就意味著調度決策時的復雜性。為了達到最高的性能,我們必須掌握工作負載的情況,必須了解機器架構的細節。好在我們在判斷機器架構時還是有一些支援力量的,我們會在后面的章節介紹這些接口。
3.5.4 FSB的影響
FSB在性能中扮演了核心角色。緩存數據的存取速度受制于內存通道的速度。我們做一個測試,在兩臺機器上分別跑同一個程序,這兩臺機器除了內存模塊的速度有所差異,其它完全相同。圖3.32展示了Addnext0測試(將下一個元素的pad[0]加到當前元素的pad[0]上)在這兩臺機器上的結果(NPAD=7,64位機器)。兩臺機器都采用Core 2處理器,一臺使用667MHz的DDR2內存,另一臺使用800MHz的DDR2內存(比前一臺增長20%)。
圖上的數字表明,當工作集大到對FSB造成壓力的程度時,高速FSB確實會帶來巨大的優勢。在我們的測試中,性能的提升達到了18.5%,接近理論上的極限。而當工作集比較小,可以完全納入緩存時,FSB的作用并不大。當然,這里我們只測試了一個程序的情況,在實際環境中,系統往往運行多個進程,工作集是很容易超過緩存容量的。
如今,一些英特爾的處理器,支持前端總線(FSB)的速度高達1,333 MHz,這意味著速度有另外60%的提升。將來還會出現更高的速度。速度是很重要的,工作集會更大,快速的RAM和高FSB速度的內存肯定是值得投資的。我們必須小心使用它,因為即使處理器可以支持更高的前端總線速度,但是主板的北橋芯片可能不會。使用時,檢查它的規范是至關重要的。
------------------------------------------------------------------------------------------------------------------------------------------
對于jvm 層面:
所有線程共享主內存 每個線程有自己的工作內存
refreshing local memory to/from main memory must? comply to JMM rules
產生線程安全的原因
線程的working?memory是cpu的寄存器和高速緩存的抽象描述:現在的計算機,cpu在計算的時候,并不總是從內存讀取數據,它的數據讀取順序優先級?是:寄存器-高速緩存-內存。線程耗費的是CPU,線程計算的時候,原始的數據來自內存,在計算過程中,有些數據可能被頻繁讀取,這些數據被存儲在寄存器和高速緩存中,當線程計算完后,這些緩存的數據在適當的時候應該寫回內存。當多個線程同時讀寫某個內存數據時,就會產生多線程并發問題,涉及到三個特?性:原子性,有序性,可見性。?支持多線程的平臺都會面臨?這種問題,運行在多線程平臺上支持多線程的語言應該提供解決該問題的方案。
JVM是一個虛擬的計算機,它也會面臨多線程并發問題,java程序運行在java虛擬機平臺上,java程序員不可能直接去控制底層線程對寄存器高速緩存內存之間的同步,那么java從語法層面,應該給開發人員提供一種解決方案,這個方案就是諸如?synchronized,?volatile,鎖機制(如同步塊,就緒隊?列,阻塞隊列)等等。這些方案只是語法層面的,但我們要從本質上去理解它;
每個線程都有自己的執行空間(即工作內存),線程執行的時候用到某變量,首先要將變量從主內存拷貝的自己的工作內存空間,然后對變量進行操作:讀取,修改,賦值等,這些均在工作內存完成,操作完成后再將變量寫回主內存;
各個線程都從主內存中獲取數據,線程之間數據是不可見的;打個比方:主內存變量A原始值為1,線程1從主內存取出變量A,修改A的值為2,在線程1未將變量A寫回主內存的時候,線程2拿到變量A的值仍然為1。
這便引出“可見性”的概念:當一個共享變量在多個線程的工作內存中都有副本時,如果一個線程修改了這個共享變量的副本值,那么其他線程應該能夠看到這個被修改后的值,這就是多線程的可見性問題。
普通變量情況:如線程A修改了一個普通變量的值,然后向主內存進行寫回,另外一條線程B在線程A回寫完成了之后再從主內存進行讀取操作,新變量的值才會對線程B可見。
如何保證線程安全?
編寫線程安全的代碼,本質上就是管理對狀態(state)的訪問,而且通常都是共享的、可變的狀態。這里的狀態就是對象的變量(靜態變量和實例變量)?
線程安全的前提是該變量是否被多個線程訪問,?保證對象的線程安全性需要使用同步來協調對其可變狀態的訪問;若是做不到這一點,就會導致臟數據和其他不可預期的后果。無論何時,只要有多于一個的線程訪問給定的狀態變量,而且其中某個線程會寫入該變量,此時必須使用同步來協調線程對該變量的訪問。Java中首要的同步機制是synchronized關鍵字,它提供了獨占鎖。除此之外,術語“同步”還包括volatile變量,顯示鎖和原子變量的使用。?
在沒有正確同步的情況下,如果多個線程訪問了同一個變量,你的程序就存在隱患。有3種方法修復它:?
(1)不要跨線程共享變量
(2)使狀態變量為不可變的
(3)或者?在任何訪問狀態變量的時候使用同步
volatile要求程序對變量的每次修改,都寫回主內存,這樣便對其它線程課件,解決了可見性的問題,但是不能保證數據的一致性;特別注意:原子操作:根據Java規范,對于基本類型的賦值或者返回值操作,是原子操作。但這里的基本數據類型不包括long和double,?因為JVM看到的基本存儲單位是32位,而long?和double都要用64位來表示。所以無法在一個時鐘周期內完成?
通俗的講一個對象的狀態就是它的數據,存儲在狀態變量中,比如實例域或者靜態域;無論何時,只要多于一個的線程訪問給定的狀態變量。而且其中某個線程會寫入該變量,此時必須使用同步來協調線程對該變量的訪問。
同步鎖:每個JAVA對象都有且只有一個同步鎖,在任何時刻,最多只允許一個線程擁有這把鎖。
當一個線程試圖訪問帶有synchronized(this)標記的代碼塊時,必須獲得?this關鍵字引用的對象的鎖,在以下的兩種情況下,本線程有著不同的命運。
1、?假如這個鎖已經被其它的線程占用,JVM就會把這個線程放到本對象的鎖池中。本線程進入阻塞狀態。鎖池中可能有很多的線程,等到其他的線程釋放了鎖,JVM就會從鎖池中隨機取出一個線程,使這個線程擁有鎖,并且轉到就緒狀態。
2、?假如這個鎖沒有被其他線程占用,本線程會獲得這把鎖,開始執行同步代碼塊。
(一般情況下在執行同步代碼塊時不會釋放同步鎖,但也有特殊情況會釋放對象鎖
如在執行同步代碼塊時,遇到異常而導致線程終止,鎖會被釋放;在執行代碼塊時,執行了鎖所屬對象的wait()方法,這個線程會釋放對象鎖,進入對象的等待池中)
Synchronized關鍵字保證了數據讀寫一致和可見性等問題,但是他是一種阻塞的線程控制方法,在關鍵字使用期間,所有其他線程不能使用此變量,這就引出了一種叫做非阻塞同步的控制線程安全的需求。
總結
以上是生活随笔為你收集整理的产生线程安全的原因(4)(操作系统)的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: 房贷还款逾期一天补救办法 房贷逾期一天怎
- 下一篇: 每个程序员都应该了解的内存知识【第一部分
