編程基礎:如何從CPU指令角度理解多線程並發編程的代碼重排問題

2019-07-16     道以致遠

編程基礎

前面我們用了兩篇文章專門說了計算機的內部存儲系統,也就是我們常說的內存,本文繼續說一下計算機的中央處理器CPU的相關基礎知識。

因為我們編寫的所有程序最終都是由它來完成調度執行的,所以要想學好編程,對於底層CPU的結構和特點有一個大致的了解,將非常有助於我們編寫高效的應用程式。同時也能夠促進我們理解各類程式語言中出現的那些概念和規則定義。

其實作為一個沒接觸過計算機和編程的人,我們對計算機和程序的猜想就是,一個黑盒子,將一堆數據輸進去,它會經過它加工處理,輸出結果來。

那麼這個過程應該是裡面有東西在接著我們給它的內容,然後交給能夠處理的部分,然後將處理結果發還給我們。其實計算機的整體架構跟我們的猜測的一樣。了解了其內部存儲體系後,我們就可以說計算機其實就是我們有一個存儲內容的箱子,加上一個處理內容的部分,就構成了計算機。

現在我們就詳細說一下這個負責處理內容的部分,中央處理器單元,CPU。其實最開始的時候,我們的計算機不具備存儲功能,只具備運算功能,通過卡片打孔來記錄要處理的數據和處理的結果。這種情況現在想來肯定很扯,但計算機真的經歷過這個階段。

我們不得不承認,其實計算機就是一個內部存儲系統加上一個負責對存儲器里的數進行搬入搬出以及累計計算的中央處理器構成,很簡單是吧!

我們還知道:

程序= 數據結構 + 算法

這個經典的概括描述,數據結構就是我們內存中保存數據的樣式,算法就是我們操作這些數的邏輯順序和行為,前面我們知道我們的CPU和內存系統中存儲空間都是易失性存儲,長期保存必須通過外部磁性設備。

也就是說這些數據結構和操作步驟行為必須先放入到內部存儲中,然後由CPU讀取完成運算然後給出結果,下一個運算開始之前同樣執行這樣的過程,也就意味著CPU和內存不會永久存儲任何內容。

我們在內存中會專門開闢出空間對每一個程序的數據和邏輯指令進行存儲,然後由CPU一條指令一條數據的讀取運算返回結果的執行。CPU為此配備了數據寄存器和指令寄存器用於跟主內存交互。

學習

CPU操作指令分類

一類是哪些將值從內存加載到寄存器以及將值從寄存器存儲到內存的函數。另一類是對寄存器中存儲的值進行操作的指令。比如兩個寄存器中值的加減乘除,執行按位邏輯操作,或者其他數學運算等。

分支

CPU除了從內存加載數據將數據存儲會內存外,其另外一個重要的操作就是分支了。

在內部CPU會保存一個下一條要執行的指令的記錄的指令指針,通常,指令指針按順序遞增以指向下一條指令;分支指令通常會檢查特定寄存器是否為零,或者是否設置了標誌,如果是,將修改指針到另一個地址。

由此,指令的執行將不再按照原來的順序去執行下一條,而是要執行來自程序的其它不同部分的指令;這就是我們程式語言中設計的循環和決策語句的工作原理。

例如,if (x==0)這樣的語句可以通過查找兩個寄存器值來實現,一個寄存器保存x,另一個寄存器保存0; 如果x結果為零,則比較為真,並且語句的主體應該被接受,否則分支將替代主體代碼。

循環

我們都熟悉計算機的速度,單位為兆赫或千兆赫,意味著每秒百萬或成千上萬個循環。這就是我們經常說的頻率,或者說是系統時鐘速度。

因為它是計算機內部時鐘脈衝的速度。

這個脈衝在處理器內部用以保持內部同步。每一次滴答或脈衝都可以啟動另一個操作;我們可以把時鐘想像成我們划龍舟時前面坐著的那個敲鼓的人,他的作用就是讓全舟的劃手的划槳動作保持同步。

獲取、解碼、執行、存儲

執行有一個事件周期構成的單一指令,獲取,解碼,執行和存儲。

比如,要在CPU上面執行某條指令,必須執行經過如下步驟

  • 獲取: 從內存沖將指令取出放到處理器的寄存器中
  • 解碼: 內部解碼
  • 執行:從寄存器中獲取值,並執行操作
  • 存儲:將結果存回另外一個寄存器。

當然,有時還有重試步驟。

編碼類型

CPU內部如何操作內存

接下來我們看一下CPU內存到底發生了什麼?

在CPU內部,有許多不同的子組件執行上述每個步驟,並且通常它們都可以彼此獨立地發生。

這類似於我們工廠里的生產線,其中有許多工作環節分工,每個步驟都有特定的任務要執行。

一旦完成,它可以將結果傳遞到下一環節,並接受新的輸入進行工作。

一般在CPU中,輸入的指令首先被處理器解碼,CPU有兩種主要的寄存器類型,一種用於整數計算,另一種用於浮點計算

浮點數是一種以二進位形式表示小數點位數的數字的方法,在CPU中有各自不同的處理方。我們經常遇到的多媒體擴展(MMX)和單指令多數據流(SSE)或Altivec寄存器都類似於浮點寄存器。

這裡有個名詞叫寄存器文件,其實它就是CPU內寄存器的集合名稱。我們說過,處理器要麼將一個值加載到寄存器中,要麼將一個值存儲到內存中,要麼對寄存器中的值執行一些操作。

算術邏輯單元(ALU)是CPU操作的核心。它接受寄存器中的值,並執行CPU能夠執行的多種操作中的任何一種。

其實我們都知道,所有現代處理器都有數個算術邏輯單元,也就是我們的多核處理器,因此每個算術邏輯單元都可以獨立工作。

前面我們說過速度快的內存更小,所以我們可以在CPU上安裝更多內存,但它只能執行的智能是最常見的操作; 雖然速度慢的內存可以執行所有操作,但體積更大,靠近不了CPU。

所以現代計算機會將地址生成單元(AGU)處理與緩存和主內存的通信,將值放入寄存器中,以便算術邏輯單元操作,並將值從寄存器中取出返回到主內存。

指令管線

CPU指令管線

正如我們在上面看到的,當ALU將寄存器加在一起時,它與AGU是完全分開的,AGU將值寫回內存,所以CPU沒有理由不能同時做這兩件事。

我們系統中還有多個算術邏輯單元,每個都可以處理單獨的指令。結果是,CPU可以用它的浮點邏輯做一些浮點運算,同時整數指令也在運行。

這被稱為指令管線,能夠做到這一點的處理器稱為超標量體系結構。所有現代處理器都是超標量的。

我們可以將另一類指令執行的方式想像成裝滿彈珠的軟管,只不過彈珠是CPU的指令。理想情況下,我們將把彈珠放在一端,一個接一個地(每個時鐘脈衝一個),填滿管道。一旦彈珠滿了,我們推進去的每一個彈珠指令都會移動到下一個位置,最後一個結果彈珠會掉出來。

然而,CPU的分支指令會破壞這個模型,因為它們可能會導致執行從不同的地方開始,也可能不會導致執行從不同的地方開始。

如果我們要使用管道操作,那麼就必須去提前預測指令分支將走向,這樣才知道將哪些指令壓入管道。如果預測正確,一切都會很好,相反,如果處理器預測錯誤,則會浪費大量時間,並且必須清除管道並重新啟動。這個過程通常被稱為管道沖洗,類似於必須停止並清空軟管中的所有彈珠!

分支預測包括管道刷新,預測發生,預測不發生,分支延遲插槽。

指令重排序

事實上,如果CPU是軟管,它就可以自由地重新排列軟管內的玻璃球,只要它們的末端和我們放入它們的順序一樣。

這個順序我們稱之為程序順序,因為這是指令在電腦程式中指定的順序。

舉個例子,下面的代碼定義順序如下:

1: r3 = r1 * r2
2: r4 = r2 + r3
3: r7 = r5 * r6
4: r8 = r1 + r7

上面示例顯示,指令2必須等待指令1完成,這意味著管道在等待計算值時必須停止。然而,指令2和指令3之間根本沒有依賴關係; 也就是說它們可以在完全獨立的寄存器上運行。

如果我們交換指令2和指令3,我們可以得到更好的管線順序,因為處理器可以做有用的工作,而不是等待管線完成,以獲得前一條指令的結果。

然而,當編寫非常低級別的代碼時,一些指令可能需要一些關於操作如何排序的安全性。我們稱之為需求內存語義

如果我們需要獲取語義,這意味著對於該指令,我們必須確保前面所有指令的結果都已完成。如果我們需要發布語義結果,那麼意思是在此之後的所有指令都必須看到當前結果。

另一個更嚴格的語義是內存屏障內存圍牆,它要求在指令操作繼續之前將之前的操作提交到主內存中。

在某些體系結構上,這些語義由處理器為我們提供保證,而在另一些體系結構上,則需要我們顯式地指定它們。

其實我們平時開發過程中大多時候根本不需要直接擔心這些,儘管我們可能會遇到barrier,latch等此類的術語。

複雜指令集計算機(CISC)與 簡化指令集計算機(RISC)

在指令集方面通常現在的計算機結構被劃分為複雜指令集計算機和簡化指令集計算機兩種。

比如,我們顯式地將值加載到寄存器中,執行某種算術運算操作並將結果值存儲在另一個寄存器中,最後拷貝回到主內存中。這種操作就是典型的RISC計算方法,因為這個過程只對寄存器中的值執行操作,並顯式地從內存加載數值並將值存儲回內存。

如果換成CISC方法,可能只是一條從內存中提取值、在內部執行加工操作並將結果寫回的指令。

這意味著這條指令可能需要經過許多環節來完成,但最終兩種方法都實現了相同的目標。

其實所有現代的計算體系結構都被認為是RISC體系結構的,原因有很多:

雖然RISC使彙編編程變得越來越複雜,因為幾乎現在所有程式設計師都使用高級語言,並將生成程序集代碼的繁重工作留給了編譯器,所以該計算指令集雖然複雜,但是都給了計算機自己搞定。

同時由於RISC處理器中的指令要簡單得多,因此晶片內部有更多的空間用於數據寄存器。從前面我們說過的內存層次結構中知道,寄存器是最快的內存類型,不管什麼操作最終所有指令都必須在寄存器中保存的值上執行,因此在其他條件相同的情況下,更多的寄存器會帶來更高的性能。

由於所有指令都在同一時間執行,因此可以使用管線。我們知道管線需要不斷地將指令流輸入處理器,因此,如果一些指令花費很長時間,而另一些則不需要,那麼管線就會變得非常複雜,難以發揮作用。

顯式並行指令運算(EPIC)

我們已經討論了超大規模處理器的管道是如何在處理器的不同部分同時運行許多指令的。

顯然,為了使這一切正常工作,處理器應該按照能夠充分利用CPU可用元素的順序得到儘可能多的指令。

傳統上,組織傳入指令流一直是硬體的工作。指令由程序按順序發出;處理器必須向前看,設法決定如何組織輸入指令。

EPIC背後的理論是,在更高的級別上有更多的可用信息,可以比處理器更好地做出這些決策。

與當前處理器一樣,分析彙編語言指令流會丟失許多程式設計師可能在原始原始碼中提供的信息。

因此,排序指令的邏輯可以從處理器轉移到編譯器。這意味著編譯器作者需要更聰明地嘗試為處理器找到最佳的代碼順序。

處理器也得到了顯著的簡化,因為它的許多工作已經轉移到編譯器中。

這也是為什麼像Java這類語言在編寫程序時必須考慮編譯器重排問題。

總結一下:

本文我們簡單講了一下CPU在執行運算時的大致分類和流程,主要講了CPU在執行指令時的基本操作分類,從分支,循環到獲取,解碼,執行和存儲。

以及指令的複雜度和管線模型。以及計算機的計算指令集分類,複雜指令集和簡明指令集特點。由於我們現在編程大多使用高級語言,所以不用再在乎指令集的複雜度,因為高級語言的編譯器可以幫我們搞定它,但是由於從CPU到編譯器的這種指令優化和重排,造成了我們在用高級語言編寫應用程式時必須去注意發生在編譯階段和執行階段的代碼重排問題。

相信理解了上面我說的內容後,你再回過頭來看Java語言中JMM模型的那些規則和語法定義,應該就能找到一點思路了。

文章來源: https://twgreatdaily.com/zh-cn/kOgcEGwBmyVoG_1ZyWl0.html