产生线程安全的原因(1)(操作系统)
這是Ulrich Drepper寫“程序員都該知道存儲器”的第二部。那些沒有讀過第一部?的讀者可能希望從這一部開始。這本書寫的非常好,并且感謝Ulrich授權我們出版。
現在的CPU比25年前要精密得多了。在那個年代,CPU的頻率與內存總線的頻率基本在同一層面上。內存的訪問速度僅比寄存器慢那么一點點。但是,這一局面在上世紀90年代被打破了。CPU的頻率大大提升,但內存總線的頻率與內存芯片的性能卻沒有得到成比例的提升。并不是因為造不出更快的內存,只是因為太貴了。內存如果要達到目前CPU那樣的速度,那么它的造價恐怕要貴上好幾個數量級。
如果有兩個選項讓你選擇,一個是速度非???、但容量很小的內存,一個是速度還算快、但容量很多的內存,如果你的工作集比較大,超過了前一種情況,那么人們總是會選擇第二個選項。原因在于輔存(一般為磁盤)的速度。由于工作集超過主存,那么必須用輔存來保存交換出去的那部分數據,而輔存的速度往往要比主存慢上好幾個數量級。
好在這問題也并不全然是非甲即乙的選擇。在配置大量DRAM的同時,我們還可以配置少量SRAM。將地址空間的某個部分劃給SRAM,剩下的部分劃給DRAM。一般來說,SRAM可以當作擴展的寄存器來使用。
上面的做法看起來似乎可以,但實際上并不可行。首先,將SRAM內存映射到進程的虛擬地址空間就是個非常復雜的工作,而且,在這種做法中,每個進程都需要管理這個SRAM區內存的分配。每個進程可能有大小完全不同的SRAM區,而組成程序的每個模塊也需要索取屬于自身的SRAM,更引入了額外的同步需求。簡而言之,快速內存帶來的好處完全被額外的管理開銷給抵消了。 基于以上的原因,我們不將SRAM放在OS或用戶的控制下,而是將它交由處理器來使用和管理。在這種模式下,SRAM用于對存儲在主存中、即將使用的數據進行臨時拷貝(換句話說,緩存)。這種做法的依據是程序代碼和數據具有時間局部性和空間局部性。也就是說,在一段較短的時間內,同一份代碼和數據有很大的可能被重復使用。對代碼來說,是循環,即同一段代碼被反復執行(完美的?空間局部性)。對數據來說,是反復訪問某一小片區域中的數據。即使在短時間內對內存的訪問并不連續,但仍有很大可能在不長的時間內重復訪問同一份數據(?空間局部性)。這兩個局部性是我們理解CPU高速緩存的關鍵。
我們先用一個簡單的計算來展示一下高速緩存的效率。假設,訪問主存需要200個周期,而訪問高速緩存需要15個周期。如果使用100個數據元素100次,那么在沒有高速緩存的情況下,需要2000000個周期,而在有高速緩存、而且所有數據都已被緩存的情況下,只需要168500個周期。節約了91.5%的時間。
用作高速緩存的SRAM容量比主存小得多。以我的經驗來說,高速緩存的大小一般是主存的千分之一左右(目前一般是4GB主存、4MB緩存)。這一點本身并不是什么問題。只是,計算機一般都會有比較大的主存,因此工作集的大小總是會大于緩存。特別是那些運行多進程的系統,它的工作集大小是所有進程加上內核的總和。
處理高速緩存大小的限制需要制定一套很好的策略來決定在給定的時間內什么數據應該被緩存。由于不是所有數據的工作集都是在完全相同的時間段內被使用的,我們可以用一些技術手段將需要用到的數據臨時替換那些當前并未使用的緩存數據。這種預取將會減少部分訪問主存的成本,因為它與程序的執行是異步的。所有的這些技術將會使高速緩存在使用的時候看起來比實際更大。我們將在3.3節討論這些問題。?我們將在第6章討論如何讓這些技術能很好地幫助程序員,讓處理器更高效地工作。
3.1 高速緩存的位置
在深入介紹高速緩存的技術細節之前,有必要說明一下它在現代計算機系統中所處的位置。
圖3.1展示了最簡單的高速緩存配置。早期的一些系統就是類似的架構。在這種架構中,CPU核心不再直連到主存。{在一些更早的系統中,高速緩存像CPU與主存一樣連到系統總線上。那種做法更像是一種hack,而不是真正的解決方案。}數據的讀取和存儲都經過高速緩存。CPU核心與高速緩存之間是一條特殊的快速通道。在簡化的表示法中,主存與高速緩存都連到系統總線上,這條總線同時還用于與其它組件通信。我們管這條總線叫“FSB”——就是現在稱呼它的術語,參見第2.2節。在這一節里,我們將忽略北橋。
在過去的幾十年,經驗表明使用了馮諾伊曼結構的?計算機,將用于代碼和數據的高速緩存分開是存在巨大優勢的。自1993年以來,Intel?并且一直堅持使用獨立的代碼和數據高速緩存。由于所需的代碼和數據的內存區域是幾乎相互獨立的,這就是為什么獨立緩存工作得更完美的原因。近年來,獨立緩存的另一個優勢慢慢顯現出來:常見處理器解碼?指令的步驟?是緩慢的,尤其當管線為空的時候,往往會伴隨著錯誤的預測或無法預測的分支的出現,?將高速緩存技術用于?指令?解碼可以加快其執行速度。
在高速緩存出現后不久,系統變得更加復雜。高速緩存與主存之間的速度差異進一步拉大,直到加入了另一級緩存。新加入的這一級緩存比第一級緩存更大,但是更慢。由于加大一級緩存的做法從經濟上考慮是行不通的,所以有了二級緩存,甚至現在的有些系統擁有三級緩存,如圖3.2所示。隨著單個CPU中核數的增加,未來甚至可能會出現更多層級的緩存。
圖3.2展示了三級緩存,并介紹了本文將使用的一些術語。L1d是一級數據緩存,L1i是一級指令緩存,等等。請注意,這只是示意圖,真正的數據流并不需要流經上級緩存。CPU的設計者們在設計高速緩存的接口時擁有很大的自由。而程序員是看不到這些設計選項的。
另外,我們有多核CPU,每個核心可以有多個“線程”。核心與線程的不同之處在于,核心擁有獨立的硬件資源({早期的多核CPU甚至有獨立的二級緩存。})。在不同時使用相同資源(比如,通往外界的連接)的情況下,核心可以完全獨立地運行。而線程只是共享資源。Intel的線程只有獨立的寄存器,而且還有限制——不是所有寄存器都獨立,有些是共享的。綜上,現代CPU的結構就像圖3.3所示。
在上圖中,有兩個處理器,每個處理器有兩個核心,每個核心有兩個線程。線程們共享一級緩存。核心(以深灰色表示)有獨立的一級緩存,同時共享二級緩存。處理器(淡灰色)之間不共享任何緩存。這些信息很重要,特別是在討論多進程和多線程情況下緩存的影響時尤為重要。
3.2 高級的緩存操作
了解成本和節約使用緩存,我們必須結合在第二節中講到的關于計算機體系結構和RAM技術,以及前一節講到的緩存描述來探討。
默認情況下,CPU核心所有的數據的讀或寫都存儲在緩存中。當然,也有內存區域不能被緩存的,但是這種情況只發生在操作系統的實現者對數據考慮的前提下;對程序實現者來說,這是不可見的。這也說明,程序設計者可以故意繞過某些緩存,不過這將是第六節中討論的內容了。
如果CPU需要訪問某個字(word),先檢索緩存。很顯然,緩存不可能容納主存所有內容(否則還需要主存干嘛)。系統用字的內存地址來對緩存條目進行標記。如果需要讀寫某個地址的字,那么根據標簽來檢索緩存即可。這里用到的地址可以是虛擬地址,也可以是物理地址,取決于緩存的具體實現。
標簽是需要額外空間的,用字作為緩存的粒度顯然毫無效率。比如,在x86機器上,32位字的標簽可能需要32位,甚至更長。另一方面,由于空間局部性的存在,與當前地址相鄰的地址有很大可能會被一起訪問。再回憶下2.2.1節——內存模塊在傳輸位于同一行上的多份數據時,由于不需要發送新CAS信號,甚至不需要發送RAS信號,因此可以實現很高的效率?;谝陨系脑?#xff0c;緩存條目并不存儲單個字,而是存儲若干連續字組成的“線”。在早期的緩存中,線長是32字節,現在一般是64字節。對于64位寬的內存總線,每條線需要8次傳輸。而DDR對于這種傳輸模式的支持更為高效。
當處理器需要內存中的某塊數據時,整條緩存線被裝入L1d。緩存線的地址通過對內存地址進行掩碼操作生成。對于64字節的緩存線,是將低6位置0。這些被丟棄的位作為線內偏移量。其它的位作為標簽,并用于在緩存內定位。在實踐中,我們將地址分為三個部分。32位地址的情況如下:
如果緩存線長度為2O,那么地址的低O位用作線內偏移量。上面的S位選擇“緩存集”。后面我們會說明使用緩存集的原因?,F在只需要明白一共有2S個緩存集就夠了。剩下的32 – S – O = T位組成標簽。它們用來區分別名相同的各條線{有相同S部分的緩存線被稱為有相同的別名。}用于定位緩存集的S部分不需要存儲,因為屬于同一緩存集的所有線的S部分都是相同的。
當某條指令修改內存時,仍然要先裝入緩存線,因為任何指令都不可能同時修改整條線(只有一個例外——第6.1節中將會介紹的寫合并(write-combine))。因此需要在寫操作前先把緩存線裝載進來。如果緩存線被寫入,但還沒有寫回主存,那就是所謂的“臟了”。臟了的線一旦寫回主存,臟標記即被清除。
為了裝入新數據,基本上總是要先在緩存中清理出位置。L1d將內容逐出L1d,推入L2(線長相同)。當然,L2也需要清理位置。于是L2將內容推入L3,最后L3將它推入主存。這種逐出操作一級比一級昂貴。這里所說的是現代AMD和VIA處理器所采用的獨占型緩存(exclusive cache)。而Intel采用的是包容型緩存(inclusive cache),{并不完全正確,Intel有些緩存是獨占型的,還有一些緩存具有獨占型緩存的特點。}L1d的每條線同時存在于L2里。對這種緩存,逐出操作就很快了。如果有足夠L2,對于相同內容存在不同地方造成內存浪費的缺點可以降到最低,而且在逐出時非常有利。而獨占型緩存在裝載新數據時只需要操作L1d,不需要碰L2,因此會比較快。
處理器體系結構中定義的作為存儲器的模型只要還沒有改變,那就允許多CPU按照自己的方式來管理高速緩存。這表示,例如,設計優良的處理器,利用很少或根本沒有內存總線活動,并主動寫回主內存臟高速緩存行。這種高速緩存架構在如x86和x86-64各種各樣的處理器間存在。制造商之間,即使在同一制造商生產的產品中,證明了的內存模型抽象的力量。
在對稱多處理器(SMP)架構的系統中,CPU的高速緩存不能獨立的工作。在任何時候,所有的處理器都應該擁有相同的內存內容。保證這樣的統一的內存視圖被稱為“高速緩存一致性”。如果在其自己的高速緩存和主內存間,處理器設計簡單,它將不會看到在其他處理器上的臟高速緩存行的內容。從一個處理器直接訪問另一個處理器的高速緩存這種模型設計代價將是非常昂貴的,它是一個相當大的瓶頸。相反,當另一個處理器要讀取或寫入到高速緩存線上時,處理器會去檢測。
如果CPU檢測到一個寫訪問,而且該CPU的cache中已經緩存了一個cache line的原始副本,那么這個cache line將被標記為無效的cache line。接下來在引用這個cache line之前,需要重新加載該cache line。需要注意的是讀訪問并不會導致cache line被標記為無效的。
更精確的cache實現需要考慮到其他更多的可能性,比如第二個CPU在讀或者寫他的cache line時,發現該cache line在第一個CPU的cache中被標記為臟數據了,此時我們就需要做進一步的處理。在這種情況下,主存儲器已經失效,第二個CPU需要讀取第一個CPU的cache line。通過測試,我們知道在這種情況下第一個CPU會將自己的cache line數據自動發送給第二個CPU。這種操作是繞過主存儲器的,但是有時候存儲控制器是可以直接將第一個CPU中的cache line數據存儲到主存儲器中。對第一個CPU的cache的寫訪問會導致本地cache line的所有拷貝被標記為無效。
隨著時間的推移,一大批緩存一致性協議已經建立。其中,最重要的是MESI,我們將在第3.3.4節進行介紹。以上結論可以概括為幾個簡單的規則:
- 一個臟緩存線不存在于任何其他處理器的緩存之中。
- 同一緩存線中的干凈拷貝可以駐留在任意多個其他緩存之中。
如果遵守這些規則,處理器甚至可以在多處理器系統中更加有效的使用它們的緩存。所有的處理器需要做的就是監控其他每一個寫訪問和比較本地緩存中的地址。在下一節中,我們將介紹更多細節方面的實現,尤其是存儲開銷方面的細節。
最后,我們至少應該關注高速緩存命中或未命中帶來的消耗。下面是英特爾奔騰 M 的數據:
這是在CPU周期中的實際訪問時間。有趣的是,對于L2高速緩存的訪問時間很大一部分(甚至是大部分)是由線路的延遲引起的。這是一個限制,增加高速緩存的大小變得更糟。只有當減小時(例如,從60納米的Merom到45納米Penryn處理器),可以提高這些數據。
表格中的數字看起來很高,但是,幸運的是,整個成本不必須負擔每次出現的緩存加載和緩存失效。某些部分的成本可以被隱藏?,F在的處理器都使用不同長度的內部管道,在管道內指令被解碼,并為準備執行。如果數據要傳送到一個寄存器,那么部分的準備工作是從存儲器(或高速緩存)加載數據。如果內存加載操作在管道中足夠早的進行,它可以與其他操作并行發生,那么加載的全部發銷可能會被隱藏。對L1D常??赡苋绱?#xff1b;某些有長管道的處理器的L2也可以。
提早啟動內存的讀取有許多障礙。它可能只是簡單的不具有足夠資源供內存訪問,或者地址從另一個指令獲取,然后加載的最終地址才變得可用。在這種情況下,加載成本是不能隱藏的(完全的)。
對于寫操作,CPU并不需要等待數據被安全地放入內存。只要指令具有類似的效果,就沒有什么東西可以阻止CPU走捷徑了。它可以早早地執行下一條指令,甚至可以在影子寄存器(shadow register)的幫助下,更改這個寫操作將要存儲的數據。
圖3.4展示了緩存的效果。關于產生圖中數據的程序,我們會在稍后討論。這里大致說下,這個程序是連續隨機地訪問某塊大小可配的內存區域。每個數據項的大小是固定的。數據項的多少取決于選擇的工作集大小。Y軸表示處理每個元素平均需要多少個CPU周期,注意它是對數刻度。X軸也是同樣,工作集的大小都以2的n次方表示。
圖中有三個比較明顯的不同階段。很正常,這個處理器有L1d和L2,沒有L3。根據經驗可以推測出,L1d有213字節,而L2有220字節。因為,如果整個工作集都可以放入L1d,那么只需不到10個周期就可以完成操作。如果工作集超過L1d,處理器不得不從L2獲取數據,于是時間飄升到28個周期左右。如果工作集更大,超過了L2,那么時間進一步暴漲到480個周期以上。這時候,許多操作將不得不從主存中獲取數據。更糟糕的是,如果修改了數據,還需要將這些臟了的緩存線寫回內存。
看了這個圖,大家應該會有足夠的動力去檢查代碼、改進緩存的利用方式了吧?這里的性能改善可不只是微不足道的幾個百分點,而是幾個數量級呀。在第6節中,我們將介紹一些編寫高效代碼的技巧。而下一節將進一步深入緩存的設計。雖然精彩,但并不是必修課,大家可以選擇性地跳過。
3.3 CPU 緩存實現細節
高速緩存的實現者遇到這樣的難題:巨大的主內存中每一個單元都潛在的需要緩存。如果程序的工作集足夠大,這意味著很多主內存單元競爭高速緩存的每一個地方。先前有過提示,主存和高速緩存的大小比是1000:1,這是不常見的。
3.3.1 關聯性
可以這樣實現一個高速緩存,每個高速緩存段(高速緩存行:cache line)都可以容納任何內存位置的一個副本。這就是所謂的全關聯。要訪問一個緩存段,處理器核心不得不用所有緩存段的標簽和請求地址的標簽一一做比較。標簽將包含除去緩存段的偏移量全部的地址,(譯注:也就是去除3.2節中圖的O)(這意味著,S在3.2節的圖中是零)
高速緩存有類似這樣的實現,但是,看看在今天使用的L2的數目,表明這是不切實際的。給定4MB的高速緩存和64B的高速緩存段,高速緩存將有65,536個項。為了達到足夠的性能,緩存邏輯必須能夠在短短的幾個時鐘周期內,從所有這些項中,挑一個匹配給定的標簽。實現這一點的工作將是巨大的。
對于每個高速緩存行,比較器是需要比較大標簽(注意,S是零)。每個連接旁邊的字母表示位的寬度。如果沒有給出,它是一個單比特線。每個比較器都要比較兩個T-位寬的值。然后,基于該結果,適當的高速緩存行的內容被選中,并使其可用。這需要合并多套O數據線,因為他們是緩存桶(譯注:這里類似把O輸出接入多選器,所以需要合并)。實現僅僅一個比較器,需要晶體管的數量就非常大,特別是因為它必須非???。沒有迭代比較器是可用的。節省比較器的數目的唯一途徑是通過反復比較標簽,以減少它們的數目。這是不適合的,出于同樣的原因,迭代比較器不可用:它的時間太長。
全關聯高速緩存對?小緩存是實用的(例如,在某些Intel處理器的TLB緩存是全關聯的),但這些緩存都很小,非常小的。我們正在談論的最多幾十項。
對于L1i,L1d和更高級別的緩存,需要采用不同的方法。可以做的就是是限制搜索。最極端的限制是,每個標簽映射到一個明確的緩存條目。計算很簡單:給定的4MB/64B緩存有65536項,我們可以使用地址的bit6到bit21(16位)來直接尋址高速緩存的每一個項。地址的低6位作為高速緩存段的索引。
在圖3.6中可以看出,這種直接映射的高速緩存,速度快,比較容易實現。它只是需要一個比較器,一個多路復用器(在這個圖中有兩個,標記和數據是分離的,但是對于設計這不是一個硬性要求),和一些邏輯來選擇只是有效的高速緩存行的內容。由于速度的要求,比較器是復雜的,但是現在只需要一個,結果是可以花更多的精力,讓其變得快速。這種方法的復雜性在于在多路復用器。一個簡單的多路轉換器中的晶體管的數量增速是O(log N)的,其中N是高速緩存段的數目。這是可以容忍的,但可能會很慢,在某種情況下,速度可提升,通過增加多路復用器晶體管數量,來并行化的一些工作和自身增速。晶體管的總數只是隨著快速增長的高速緩存緩慢的增加,這使得這種解決方案非常有吸引力。但它有一個缺點:只有用于直接映射地址的相關的地址位均勻分布,程序才能很好工作。如果分布的不均勻,而且這是常態,一些緩存項頻繁的使用,并因此多次被換出,而另一些則幾乎不被使用或一直是空的。
可以通過使高速緩存的組關聯來解決此問題。組關聯結合高速緩存的全關聯和直接映射高速緩存特點,在很大程度上避免那些設計的弱點。圖3.7顯示了一個組關聯高速緩存的設計。標簽和數據存儲分成不同的組并可以通過地址選擇。這類似直接映射高速緩存。但是,小數目的值可以在同一個高速緩存組緩存,而不是一個緩存組只有一個元素,用于在高速緩存中的每個設定值是相同的一組值的緩存。所有組的成員的標簽可以并行比較,這類似全關聯緩存的功能。
其結果是高速緩存,不容易被不幸或故意選擇同屬同一組編號的地址所擊敗,同時高速緩存的大小并不限于由比較器的數目,可以以并行的方式實現。如果高速緩存增長,只(在該圖中)增加列的數目,而不增加行數。只有高速緩存之間的關聯性增加,行數才會增加。今天,處理器的L2高速緩存或更高的高速緩存,使用的關聯性高達16。 L1高速緩存通常使用8。
給定我們4MB/64B高速緩存,8路組關聯,相關的緩存留給我們的有8192組,只用標簽的13位,就可以尋址緩集。要確定哪些(如果有的話)的緩存組設置中的條目包含尋址的高速緩存行,8個標簽都要進行比較。在很短的時間內做出來是可行的。通過一個實驗,我們可以看到,這是有意義的。
?
表3.1顯示一個程序在改變緩存大小,緩存段大小和關聯集大小,L2高速緩存的緩存失效數量(根據Linux內核相關的方面人的說法,GCC在這種情況下,是他們所有中最重要的標尺)。在7.2節中,我們將介紹工具來模擬此測試要求的高速緩存。
萬一這還不是很明顯,所有這些值之間的關系是高速緩存的大小為:
地址被映射到高速緩存使用
在第3.2節中的圖顯示的方式。
圖3.8表中的數據更易于理解。它顯示一個固定的32個字節大小的高速緩存行的數據。對于一個給定的高速緩存大小,我們可以看出,關聯性,的確可以幫助明顯減少高速緩存未命中的數量。對于8MB的緩存,從直接映射到2路組相聯,可以減少近44%的高速緩存未命中。組相聯高速緩存和直接映射緩存相比,該處理器可以把更多的工作集保持在緩存中。
在文獻中,偶爾可以讀到,引入關聯性,和加倍高速緩存的大小具有相同的效果。在從4M緩存躍升到8MB緩存的極端的情況下,這是正確的。關聯性再提高一倍那就肯定不正確啦。正如我們所看到的數據,后面的收益要小得多。我們不應該完全低估它的效果,雖然。在示例程序中的內存使用的峰值是5.6M。因此,具有8MB緩存不太可能有很多(兩個以上)使用相同的高速緩存的組。從較小的緩存的關聯性的巨大收益可以看出,較大工作集可以節省更多。
在一般情況下,增加8以上的高速緩存之間的關聯性似乎對只有一個單線程工作量影響不大。隨著介紹一個使用共享L2的多核處理器,形勢發生了變化?,F在你基本上有兩個程序命中相同的緩存, 實際上導致高速緩存減半(對于四核處理器是1/4)。因此,可以預期,隨著核的數目的增加,共享高速緩存的相關性也應增長。一旦這種方法不再可行(16 路組關聯性已經很難)處理器設計者不得不開始使用共享的三級高速緩存和更高級別的,而L2高速緩存只被核的一個子集共享。
從圖3.8中,我們還可以研究緩存大小對性能的影響。這一數據需要了解工作集的大小才能進行解讀。很顯然,與主存相同的緩存比小緩存能產生更好的結果,因此,緩存通常是越大越好。
上文已經說過,示例中最大的工作集為5.6M。它并沒有給出最佳緩存大小值,但我們可以估算出來。問題主要在于內存的使用并不連續,因此,即使是16M的緩存,在處理5.6M的工作集時也會出現沖突(參見2路集合關聯式16MB緩存vs直接映射式緩存的優點)。不管怎樣,我們可以有把握地說,在同樣5.6M的負載下,緩存從16MB升到32MB基本已沒有多少提高的余地。但是,工作集是會變的。如果工作集不斷增大,緩存也需要隨之增大。在購買計算機時,如果需要選擇緩存大小,一定要先衡量工作集的大小。原因可以參見圖3.10。
我們執行兩項測試。第一項測試是按順序地訪問所有元素。測試程序循著指針n進行訪問,而所有元素是鏈接在一起的,從而使它們的被訪問順序與在內存中排布的順序一致,如圖3.9的下半部分所示,末尾的元素有一個指向首元素的引用。而第二項測試(見圖3.9的上半部分)則是按隨機順序訪問所有元素。在上述兩個測試中,所有元素都構成一個單向循環鏈表。
總結
以上是生活随笔為你收集整理的产生线程安全的原因(1)(操作系统)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 微软 Teams 引入离线会议功能
- 下一篇: 产生线程安全的原因(2)(操作系统)