1. 程式人生 > >從死循環說起

從死循環說起

system clu 資源 原來 但是 有關 一份 否則 不知道

此文能夠加強讀者對於cpu和cache的工作原理的理解,這是實現高性能編程必備的知識點。文章不長,讓我們從一個簡單的問題說起,為什麽一個程序死循環時它的cpu占用會達到100%?

這個問題雖然簡單,但不一定人人都能答得出來。我們直接從問題說起,程序的CPU占用達到100%,說明在它的時間片內,CPU一直在運行指令。
這仿佛是句廢話,但是反過來看,CPU有哪些時候不運行指令呢?進程阻塞的時候?上下文切換還真能讓CPU空閑一會!不過這沒有說到點子上。

讓我們先想一下,CPU能和哪些存儲單元通信?有基礎的同學肯定都清楚,寄存器和L1 cache。也就是說,CPU要訪問內存中的數據中的話,必須先將數據從內存中加載進緩存才行,在將數據從內存載入cpu緩存這個時間段中,CPU其實是一直都處於空轉狀態的(因為不知道要幹啥...),這可是對CPU資源的浪費,為了充分利用CPU資源,人們創造了超線程技術,如單核2個線程,當一個線程緩存缺失時,運行另一個線程,基於此也才有了CPU物理核與邏輯核之分。

那麽上面的問題,程序死循環時的CPU占用達到100%的原因就已經出來了,因為該程序的指令和數據都已經在cache中,因此在其時間片內,CPU能一直進行運算,那其CPU占用自然就是100%了。此點恰好是我們編寫高性能程序的基礎。也就是說,我們要盡可能的讓CPU接下來要執行的指令和數據都恰好落在cache中(除了死循環還真的很難找到CPU占用能到100%的程序...)。

上面那句話其實隱含的信息量非常大。我接下來將針對指令和數據分別舉例。在這之前,我們需要了解一下cache的基本結構。cpu cache是以cache line(緩存段)為單位來進行管理的,即當cpu要執行的指令或者數據不在cache中時,都會通過總線從內存中加載一個緩存段大小的數據(訪問一次內存足夠cpu執行幾十到幾百條指令了)。常見的緩存段大小有32byte,64byte,128byte,因cpu差異而定。緩存段又分為數據緩存段和指令緩存段(為什麽這麽分呢?其實拍腦袋也知道肯定是為了提高緩存命中,在這裏先不解釋為什麽,讀者看完這篇文章自然就明白了),下面我們可以講講如何利用cache工作原理了。

針對指令cache line

由於每次內存加載進cache時都是以一個緩存段為單位的,因此如果cpu接下來要執行的指令也位於這次加載進來的緩存段中的話,很明顯這將提高緩存命中率,減少內存訪問次數,進一步提升cpu的利用率。那麽對於編程而言,如果程序的邏輯分支很少,那麽其指令緩存命中率必然相應也會較高。道理很簡單,實際上經常不經意就忽略了,我們看下面的例子

for(...){
    if(...)
        ...
    else
        ...
}

上面這種代碼我們可以視為有2*N個邏輯分支,其中N為循環次數,2的來由是if-else判斷。我們對其簡單修改:

if(...)
    for(...){
        ...
    }
else
    for(...){
        ...
    }

這個代碼的邏輯分支只有2條,即剛開始的if-else,其指令緩存命中率相對於第一個例子將會得到很大提升。
相信讀者都知道c/cpp的內聯函數,在學習內聯函數的過程中,我們常看到這樣一句話,內聯函數的代碼不能太長,否則可能反而會降低程序的性能。這是為何?在編譯過程中內聯函數會被展開,即少了壓棧,跳轉(是的,這還可能導致cache缺失),出棧等操作,當代碼很短時自然能加快程序速度。但是當內聯函數的代碼很長時,由於每一處調用,其都將展開,這種冗余將會增加整體的指令數目(而以函數形式調用時,該函數將位於獨立的緩存段中),因此當內聯函數代碼太長時,可能導致程序整體的指令緩存命中率下降,進而拉低程序的性能。

針對數據cache line

同樣還是由於每次內存加載進cache時都是以一個緩存段為單位的,因此如果cpu接下來要訪問的數據也位於這次加載進來的緩存段中的話,很明顯這將提高緩存命中率,減少內存訪問次數,進一步提升cpu的利用率。比如對於下面的代碼

代碼一:
int elts[1000][1000];
...//初始化 
int i,j,sum = 0;
for(i = 0;i < 1000;i++)
    for(j = 0;j < 1000;j++){
        sum += elts[j][i];
    }

代碼二:
int elts[1000][1000];
...//初始化 
int i,j,sum = 0;
for(i = 0;i < 1000;i++)
    for(j = 0;j < 1000;j++){
        sum += elts[i][j];
    }

這是一個經常被拿來考的題目,在我校招筆試那年碰到過這樣一個題目,上面兩份代碼哪份的運行效率更高呢?自然是第二份,因為二維數組的內存是連續的,第二份代碼訪問的是連續的內存,由於讀取內存是以緩存段為單位進行讀取的,這樣大部分接下來要讀寫的內存都會落在cache中,從而減少了內存訪問次數。這個例子雖然比較笨,但是最容易理解。

???

上面的幾點其實還是非常基礎的東西,對於有一定編程經驗的讀者想必都早已清楚。接下來我們講一點更細節一點的東西。在談及細節之前,先談點其它的。我們知道現在我們的機器往往都不止一個CPU核,那麽多核環境下,cache是被多個核共用的嗎?答案是否定的,因為這樣會導致每個指令周期只有一個CPU核能操作cache,其余CPU核必須等待才行(否則就全亂套了),從而使得整個系統都慢了下來。為了避免這種情況的發生,實際情況是,我們的每個CPU核都會有一套cache,但多套cache同樣帶來了一個問題,即如何保證數據的一致性(原來不止是分布式系統會有這個問題!反過來看其實分布式系統又何嘗不是一個更大的CPU呢?)。這裏並不想談及如何保證數據一致性,有興趣的讀者可以可以閱讀緩存一致性(Cache Coherency)入門一文。總之,在這裏,我們只需要明白,當有多個CPU核時,對應的也有多套cache。下面我將從大到小,分別以進程和數據來分析如何優化我們的程序性能。

進程

由於多核的存在,默認創建出來的進程都是會在多個核中運行的。這會導致一個問題,比如在進程A的第一個時間片中在CPU1中運行,而其第二個時間片被調度到CPU2中去了,此時必須再將進程A的指令及數據加載進CPU2中的cache中來,從而帶來無謂的損耗,畢竟我們已經在CPU1的cache中加載過一份了,並且這基本上會導致CPU1中的進程A的cache數據失效!對於此種情況,很明顯,如果進程A一直運行在CPU1中的話,其cache的運用效率會得到大大提升,從而提升程序的性能!那麽有什麽方法能做到嗎?是的,通過設置進程的CPU親緣性,我們可以做到讓某個進程只運行在某個CPU中(盡管有些牽強,但不知讀者是否有聯想到分布式系統中的一致性hash?),關於CPU親緣性,這裏不想多談,有興趣的讀者可以自行搜索。但是,設置完CPU親緣性之後同樣會帶來一個問題!假設現在進程A只在CPU1中執行,如果CPU1總是不幸有其它進程一起競爭的話,那程序的性能豈不是反而減少了(畢竟總運行時間縮短了)。如何處理這種情況呢?

其實也很簡單,我們只需要把我們的服務的各個關鍵進程綁到不同的核上,再將它們的進程優先級設置的高一點即可。進程部分就講到這裏了。下面我們看數據部分。

數據

上面有提到cpu cache的MSEI協議,我們知道Share狀態的緩存數據,是可能在多個cpu核的cache中存在的,一旦有CPU核需要修改時,需要將其變為Exclusive狀態,而當此CPU核對緩存進行修改後,其它cpu核的cache都將失效。這對於我們編程有什麽需要註意的地方嗎?

當然是有的,比如對於如下一個結構

typedef struct{
    char always_read[32];
    char always_write[32];
}example;

假設我們開辟了一片共享內存(註意,共享內存映射到進程空間後的起始地址總是頁面大小的整數倍,這其實是很關鍵的一個設計)用於存儲example結構,系統中有多個進程要對其頻繁進行訪問。系統的各個進程對其進行訪問時,對於always_read成員經常都是讀操作,偶爾才有寫操作,對於always_write成員總是讀寫操作。請問這樣設計有問題嗎?

對於有一定編程經驗的讀者來說,肯定有過類似上面這樣設計的編程經驗,可以大膽的說,這樣當然沒有問題。但卻不是性能最優。回憶一下上面提到的知識點
1 緩存段的大小一般是64字節(32和128的也存在,64的最常見,跟CPU架構有關,可通過cat /sys/devices/system/cpu/cpu1/cache/index0/coherency_line_size 命令進行查看不同級別cache的緩存段大小)
2 每個cpu核都有一組cache
3 如果某個cpu核對某個cache進行修改後,其余cpu核的對於該cache的緩存段都失效了

對於上面的設計,example結構中的always_read和always_write恰好落在一個緩存段中,即每次有cpu核對always_write改寫之後,連帶著其余cpu核中的always_read也一同失效了,當其余cpu核要對其進行讀操作時,必須重新從內存中加載該數據,這會造成很多無意義的緩存缺失情況,因為對於always_read成員的訪問經常都是讀操作而已。

如何解決這種尷尬的情況呢?其實也很簡單,我們直接看代碼

typedef struct{
    char always_read[32];
    char padding[32];
    char always_write[32];
}example;

如上,我們只需要將always_read和always_write劃分到不同的緩存段中即可。

結尾

文章內容差不多就到這裏了。其實道理很簡單,但是真正運用起來,並不是能輕易做到的,有些東西需要真正的理解通透了,且時刻都對程序性能有強烈的敏感度才能真正在編程過程中運用起來。希望每位程序員都能做到如此。

從死循環說起