無鎖環形隊列,volatile和亂序執行

這裏說的環形隊列是一種內存通訊機制,本身這個機制和語言沒有什麼關係,不過上篇提到了volatile語法和acquire/release語義,就以這個機制做一個例子,C語言實現。這方面的內容涉及到一些現有的語言實現的東西 

環形隊列的數據結構是一個數組,簡單起見我們認爲通訊內容就是一個個int,則定義一個int數組和頭尾索引: 
int queue[SIZE]; 
int head; 
int tail; 
方便起見可以約定:head表示隊列首元素的索引,tail表示隊列尾元素後面的空位的索引,隊列中最多可容納SIZE-1個元素,因爲這樣可以head==tail表示隊列空,head==(tail+1)%SIZE表示隊列滿,如果能容納SIZE個元素,就不能區分這兩種情況了 

然後是讀寫: 
ErrorType read(int *v) 
{ 
    if (head == tail) 
    { 
        return EMPTY; 
    } 
    *v = queue[head]; 
    head = (head + 1) % SIZE; 
    return SUCCESS; 
} 
ErrorType write(const int *v) 
{ 
    if (head == (tail + 1) % SIZE) 
    { 
        return FULL; 
    } 
    queue[tail] = *v; 
    tail = (tail + 1) % SIZE; 
    return SUCCESS; 
} 

假如有多個線程讀寫隊列,則一般需要鎖來實現同步,但是做一點點約束和修改,就能實現無鎖: 
1 隊列只能單讀單寫 
2 共享的數據用volatile修飾 

可以就上面的代碼簡單分析下,read操作只會讀寫head,讀tail和queue,反之write操作讀寫tail,讀head,寫queue,而head和tail都是正向增長的。這裏的關鍵在於,head和tail使用int,它們的讀寫用一條機器指令即可完成,是原子的,在這個前提下,上述兩個函數分別在兩個線程執行時,無論怎麼調度穿插,都不會產生衝突。例如,在讀的時候,另一個線程正在寫,由於是內容寫完再修改tail的值,因此不會讀到寫了一半的數據,最多就是返回一個EMPTY錯誤,下次輪詢的時候還能讀到,反之寫的時候如果有人在讀,因爲是讀完內容才修改head,因此不會衝亂正在讀的數據(當然,由於上面舉例中queue的元素是int,所以不會出現單個元素不一致的情況,不過如果是結構體或一段數據就可能)。但若不是單讀單寫,就可能出問題了 

然後分析下爲什麼數據要加volatile,估計很多人都會說,因爲如果不加,編譯器會優化變量到寄存器,比如write修改了tail的值,而read把tail優化到寄存器裏,就一直以爲隊列還是空的。的確volatile能阻止編譯器做這類優化,每次讀取都會保證從內存重新讀取到寄存器,但是就上面這個例子而言,不存在這個問題,read函數在被調用的時候,還是要從內存讀一下tail,因爲一般來說不可能在read退出後還給它把tail值保存在寄存器裏,事實上只有當在一個函數的代碼段中重複使用一個變量的時候,才做上面這種優化 

這個例子裏面用volatile,是爲了執行的順序性,根據上面的分析可以看到,除int的讀寫是原子外,這個無鎖機制依賴於執行順序,即讀的時候先讀,再改head,寫的時候先寫,再改tail。不少人可能覺得,我代碼既然這麼寫了,那應該是按這個順序執行,可惜這只是天真的想法,舉例: 
static int a; 
static int b = 1; 
int main() 
{ 
    a = b + 1; 
    b = 0; 
    return 0; 
} 
這段代碼,如果編譯器優化級別很低,比如vs的debug或g++的O0和O1,編譯出來的執行序列是和語句一樣的,但是在優化編譯下會指令亂序(gcc): 
movl b, %eax 
movl $0, b 
addl $1, %eax 
movl %eax, a 
可以看到,將b加載到eax後,立即執行了b=0,然後纔對eax+1,再複製給a,相當於把b=0提前執行了,假如我們在另一個線程判斷b的值是否爲0,然後訪問a的值,就可能和預期不符,因爲可能還沒執行到寫a的內存就訪問了,出現了不一致的情況(vs2008下也亂序了) 
P.S.這個亂序的原因,個人猜測是將對b的存取聚在一起,減少cpu cache miss 

這裏,先給出acquire和release的語義: 
acquire:禁止read-acquire之後所有的內存讀寫操作,被提前到read-acquire操作之前進行 
release:禁止write-release之前所有的內存讀寫操作,被推遲到write-release操作之後進行 
具體到volatile變量,就是說,對於一個volatile變量的讀操作,所有代碼中在它後面的指令不得提前到它之前執行,反之對於寫操作,所有在它之前的代碼不得延遲到它之後執行 

很明顯上面的例子中,我們需要release語義,因此可以把b修飾爲volatile,根據release語義,b=3之前的所有語句不得亂序到它後面執行,在vs下測試時,的確起作用了: 
00401000 mov eax,dword ptr [___defaultmatherr+8 (40301Ch)] //load b 
00401005 inc eax //+1 
00401006 mov dword ptr [__fmode+4 (403378h)],eax //store a 
0040100B mov dword ptr [___defaultmatherr+8 (40301Ch)],0 //store b 

但是,如果在gcc下測試,給b加volatile是沒有任何效果的,對a的賦值依然像上面一樣被亂序到後面執行,這顯然是有問題的。不過這並不是gcc的bug,而是因爲C語言對於volatile並沒有規定acquire和release語義,gcc就沒有實現,那爲啥vs可以呢,因爲vs實現了這倆語義(windows程序員歡呼吧) 

如果要解決上面這個例子在gcc的問題,只需要把a也聲明爲volatile即可,也就是說,雖然gcc沒有實現對單獨volatile變量操作時release語義,但是多個volatile變量的順序似乎是保證的。說似乎,因爲我還沒有找到權威資料證明,但從經驗來看是沒什麼問題。對於實際問題,比如實現一個無鎖環形隊列,最好還是用-S參數輸出下彙編,確認下沒有亂序比較好 

如果說,我們已經注意到並避免了上述問題,甚至對可執行文件反彙編,並對彙編做了確認,那是不是就沒問題了?可惜這個想法還是太天真了,即便你保證了彙編(機器代碼)級別的有序性,它最終還是要到cpu裏面執行的,而cpu爲了執行速度,也會採用亂序優化,這個是volatile無法控制的領域 

回憶一下大學的計算機組成和體系結構,cpu是由一些硬件組件組成,而硬件組件的工作是可以高度並行的,於是有了最經典的五級流水線,而現在的cpu的流水線是非常複雜的,還有指令亂序和分支預測等技術 

cpu指令亂序是一種統籌規劃,比如小時候都做過統籌相關的題目:小明要做若干菜,其中對每個菜,切菜xx分鐘,煮xx分鐘,醃xx分鐘,其中若干步驟可以並行,問如何安排等 

舉個簡單的例子(寄存器表達式僞代碼): 
eax /= ebx 
eax += eax 
ecx -= edx 
ecx *= ecx 
執行這個序列的時候,如果按最原始的辦法,一個個指令串行執行,則可能很浪費,因爲第一個除法可能要消耗十幾個cpu週期,後面的加減乘法又有幾個週期。如果不用亂序執行,只考慮流水線,則效率提升也不大,因爲只有第二和第三條指令能在流水線同時執行,第二條指令依賴第一條的結果,第四條依賴第三條的結果,流水線會停頓 

如果有了亂序執行技術,則cpu在執行第一條指令時會對後面的若干指令進行分析,找到可以提前執行的指令,具體在上面的例子,由於第二條要等第一條的eax結果,因此加法就停頓住,但是第三條和第一條沒有關係,就提前到cpu執行,由於減法很快,第三條執行完後還可以把第四條提前執行,甚至可能三四都執行完成後,第一條還在除法器裏面循環,然後一直等到第一條執行完,這時候再執行第二條的加法,最後的結果和簡單串行執行一樣,但是總耗時只是一次除法和一次加法而已 

上面是用寄存器運算亂序做個例子,對於volatile變量來說,主要受內存訪問指令亂序的影響,具體的就是load和store兩條指令順序的問題,例如: 
x=x*x 
y=1 
假設我們用y=1來表示x計算完畢,根據上面的討論,x和y都應該是volatile,編譯後可能是(寄存器表達式僞代碼): 
eax = x //load 
eax *= eax 
x = eax //store 
y = 1 //store 
cpu在執行這段時,分析出前三條指令有依賴關係,第四條跟前三條無關,於是可能在算乘法的時候將y的store指令亂序執行,如果另一個線程在另一個核執行,檢測到y的值已經被store,以爲x算完了,可能出問題,因爲這時候可能還在算乘法,沒有store x,這個例子中是將兩次無關(cpu認爲無關,但實際上是有關)的store亂序執行,簡稱SS亂序,對應的還有LL亂序,LS亂序和SL亂序 

很顯然SS亂序會對我們上面討論的volatile變量或無鎖隊列產生影響,可以從acquire和release語義來看這個問題: 
acquire:一個volatile變量的讀行爲之後的所有動作不應該提前到讀之前,因此LL和LS亂序是不可以的 
release:一個volatile變量的寫行爲之前的所有動作不應該推遲到寫之後,因此LS和SS亂序是不可以的 

於是乎,也只剩下SL亂序在這種情況下是安全的,幸運的是,我們常用的x86和amd64架構的cpu都只支持SL亂序,所以只要正確使用volatile和實現代碼,基本不會出什麼問題,各種CPU的亂序支持參考下圖(前四行): 


可以看到,只有四種cpu只支持SL亂序,而其他cpu爲了提高運行效率,支持力度會高一些,大部分四種亂序都支持 

既然亂序執行是cpu本身的特性,那麼在支持各種亂序的cpu上,單純依靠軟件豈不是無法實現併發訪問了?這顯然是不可能的,解鈴還須繫鈴人,可以利用一些特殊的機器指令能實現acquire和release語義,這也是操作系統中各種互斥量實現的基礎
發佈了49 篇原創文章 · 獲贊 19 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章