CPU 性能優化手段 - 運作時指令重排序
編譯器生成指令的次序,可以不同于源代碼所暗示的“顯然”版本。
重排後的指令,對于優化執行以及成熟的全局寄存器配置設定算法的使用,都是大有脾益的,它使得程式在計算性能上有了很大的提升。
指令重排的場景
當CPU寫緩存時發現緩存區塊正被其他CPU占用,為了提高CPU處理性能, 可能将後面的讀緩存指令優先執行。
- 比如:
-
Java的volatile到底該如何了解?(中)CPU 性能優化手段 - 運作時指令重排序 - 并非随便重排,需要遵守
-
as-if-serial語義
不管怎麼重排序(編譯器和處理器為了提高并行度),(單線程)程式的執行結果不能被改變。
編譯器,runtime 和處理器都必須遵守as-if- serial語義。
也就是說:編譯器和處理器不會對存在資料依賴關系的操作做重排
重排序類型
包括如下:
- 處理器可以亂序或者并行的執行指令。
- 緩存會改變寫入送出到主記憶體的變量的次序。
問題
CPU執行指令重排序優化下有一個問題:
雖然遵守了as-if-serial語義,單僅在單CPU自己執行的情況下能保證結果正确。
多核多線程中,指令邏輯無法分辨因果關聯,可能出現亂序執行,導緻程式運作結果錯誤。
有序性:即程式執行的順序按照代碼的先後順序執行
使用volatile變量的第二個語義是 禁止指令重排序優化
禁止指令重排序優化
普通變量僅保證該方法執行過程所有依賴指派結果的地方能擷取到正确結果,而不保證變量指派操作的順序與代碼執行順序一緻
因為在一個線程的方法執行過程中無法感覺到這一點,這也就是JMM中描述的所謂的
線程内表現為串行的語義(Within-Thread As-If-Serial Sematics)
執行個體
Map configOptions;
char[] configText;
//此變量必須定義為volatile
volatile boolean initialized = false;
//假設以下代碼線上程A中執行
//模拟讀取配置資訊,當讀取完成後
//将initialized設定為true來通知其它線程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假設以下代碼線上程B中執行
// 等線程A待initialized為true,代表線程A已經把配置資訊初始化完成
while(!initialized) {
sleep();
}
//使用線程A中初始化好的配置資訊
doSomethingWithConfig();
如果定義
initialized
時沒有使用
volatile
,就可能會由于指令重排序優化,導緻位于線程A中最後一行的代碼
initialized = true
被提前執行,這樣線上程B中使用配置資訊的代碼就可能出現錯誤,而
volatile
關鍵字則可完美避免。
volatile變量讀操作性能消耗與普通變量幾乎無差,但寫操作則可能稍慢,因為它需要在代碼中插入許多記憶體屏障指令保證處理器不亂序執行。即便如此,大多數場景下volatile的總開銷仍然要比鎖小,在volatile與鎖之中選擇的唯一依據僅僅是volatile的語義能否滿足使用場景的需求:
volatile修飾的變量,指派後(前面mov %eax,0x150 (%esi) 這句便是指派操作) 多執行了一個1ock add1 $ 0x0,(%esp),這相當于一個記憶體屏障(Memory Barrier/Fence,指重排序時不能把後面的指令重排序到記憶體屏障之前的位置),隻有一個CPU 通路記憶體時,并不需要記憶體屏障
但如果有兩個或更多CPU 通路同一塊記憶體,且其中有一個在觀測另一個,就需要記憶體屏障來保證一緻性了
這句指令中的add1 $0x0, (%esp)(把ESP 寄存器的值加0) 顯然是一個空操作(采用這個空操作而不是空操作指令nop 是因為IA32手冊規定lock字首不允許配合nop 指令使用),關鍵在于lock 字首,查詢IA32 手冊,它的作用是使得本CPU 的Cache寫入記憶體,該寫入動作也會引起别的CPU 或者别的核心無效化(Inivalidate) 其Cache,這種操作相當于對Cache 中的變量做了一次store和write。是以通過這樣一個空操作,可讓前面volatile 變量的修改對其他CPU 立即可見。
那為何說它禁止指令重排序呢?
硬體架構上,指令重排序指CPU 采用了允許将多條指令不按程式規定的順序分開發送給各相應電路單元處理。
但并非說指令任意重排,CPU需要能正确處理指令依賴情況以保障程式能得出正确的執行結果。
譬如指令1把位址A中的值加10,指令2把位址A 中的值乘以2,指令3把位址B 中的值減去了,這時指令1和指令2是有依賴的,它們之間的順序不能重排,(A+10) 2 與A2+10顯然不等,但指令3 可以重排到指令i、2之前或者中間,隻要保證CPU 執行後面依賴到A、B值的操作時能擷取到正确的A 和B 值即可。是以在本CPU 中,重排序看起來依然是有序的。是以lock add1 $0x0,(%esp) 指令把修改同步到記憶體時,意味着所有之前的操作都已經執行完成,這樣便形成了“指令重排序無法越過記憶體屏障”的效果
舉個例子
int i = 0;
boolean flag = false;
i = 1; //語句1
flag = true; //語句2
從代碼順序上看,語句1在2前,JVM在真正執行這段代碼的時候會保證**語句1一定會在語句2前面執行嗎?不一定,為什麼呢?這裡可能會發生指令重排序(Instruction Reorder)
比如上面的代碼中,語句1/2誰先執行對最終的程式結果并無影響,就有可能在執行過程中,語句2先執行而1後雖然處理器會對指令進行重排序,但是它會保證程式最終結果會和代碼順序執行結果相同,**靠什麼保證?資料依賴性
編譯器和處理器在重排序時,會遵守資料依賴性,編譯器和處理器不會改變存在資料依賴關系的兩個操作的執行順序
舉例
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
A和C之間存在資料依賴關系,同時B和C之間也存在資料依賴關系。
是以在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程式的結果将會被改變)。
但A和B之間沒有資料依賴關系,編譯器和處理器可以重排序A和B之間的執行順序
這裡所說的資料依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,在單線程程式中,對存在控制依賴的操作重排序,不會改變執行結果
但在多線程程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。這是就需要記憶體屏障來保證可見性了
回頭看一下JMM對volatile 變量定義的特殊規則
假定T 表示一個線程,V 和W 分别表示兩個volatile變量,那麼在進行read, load, use,assign,store,write時需要滿定如下規則
隻有當線程T 對變量V 執行的前一個動作是load ,線程T 方能對變量V 執行use;并且,隻有當線程T 對變量V 執行的後一個動作是use,線程T才能對變量V執行load.線程T 對變量V 的use可認為是和線程T對變量V的load,read相關聯,必須連續一起出現(這條規則要求在工作記憶體中,每次使用V前都必須先從主記憶體重新整理最新的值語,用于保證能看見其他線程對變量V所做的修改後的值)
隻有當線程T 對變量V 執行的前一個動作是 assign ,線程T才能對變量V 執行store
并且,隻有當線程T對變量V執行的後一個動作是store ,線程T才能對變量V執行assign
線程T對變量V的assign可以認為是和線程T對變量V的store,write相關聯,必須連續一起出現(這條規則要求在工作記憶體中,每次修改V 後都必須立刻同步回主記憶體中,用于保證其他線程可以看到自己對變量V所做的修改)
假定動作A 是線程T 對變量V實施的use或assign,假定動作F 是和動作A 相關聯的load或store,假定動作P 是和動作F 相應的對變量V 的read 或write
類似的,假定動作B 是線程T 對變量W 實施的use或assign 動作,假定動作G是和動作B 相關聯的load或store,假定動作Q 是和動作G 相應的對變量W的read或write
如果A 先于B,那麼P先于Q (這條規則要求volatile修飾的變量不會被指令重排序優化,保證代碼的執行順序與程式的順序相同)