歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux基礎 >> Linux技術 >> 《LINUX3.0內核源代碼分析》第三章:內核同步(1)

《LINUX3.0內核源代碼分析》第三章:內核同步(1)

日期:2017/3/3 12:55:43   编辑:Linux技術
摘要:本文主要講述linux如何處理ARM cortex A9多核處理器的內核同步部分。主要包括其中的內存屏障、原子變量、每CPU變量。
自旋鎖、信號量、complete、讀寫自旋鎖、讀寫信號量、順序鎖、RCU放在後文介紹。
法律聲明:《LINUX3.0內核源代碼分析》系列文章由謝寶友([email protected])發表於http://xiebaoyou.blog.chinaunix.net,文章中的LINUX3.0源代碼遵循GPL協議。除此以外,文檔中的其他內容由作者保留所有版權。謝絕轉載。
本連載文章並不是為了形成一本適合出版的書籍,而是為了向有一定內核基本的讀者提供一些linux3.0源碼分析。因此,請讀者結合《深入理解LINUX內核》第三版閱讀本連載。
1 內核同步
1.1 內存屏障
Paul曾經講過:在建造大橋之前,必須得明白力學的原理。要理解內存屏障,首先得明白計算機硬件體系結構,特別是硬件是如何管理緩存的。緩存在多核上的一致性問題是如何產生的。
要深入理解內存屏障,建議大家首先閱讀以下資料:
1、《深入理解並行編程》,下載地址是:http://xiebaoyou.download.csdn.net.
2、內核自帶的文檔documentation/memory-barriers.txt.
內存屏障是如此難此理解也難以使用,為什麼還需要它呢?硬件工程師為什麼不給軟件開發者提供一種程序邏輯一致性的內存視圖呢?歸根結底,這個問題受到光速的影響。在1.8G的主頻系統中,在一個時鐘周期內,光在真空中的傳播距離只有幾厘米,電子的傳播距離更短,根本無法傳播到整個系統中。
Linux為開發者實現了以下內存屏障:
名稱
函數名
作用
讀寫屏障
mb
在多核和IO內存、緩存之間設置一個完全讀寫屏障
讀屏障
rmb
在多核和IO內存、緩存之間設置一個讀屏障
寫屏障
wmb
在多核和IO內存、緩存之間設置一個寫屏障
讀依賴屏障
read_barrier_depends
在多核和IO內存、緩存之間設置一個讀依賴屏障
多核讀寫屏障
Smp_mb
在多核之間設置一個完全讀寫屏障
多核讀屏障
Smp_rmb
在多核之間設置一個讀屏障
多核寫屏障
Smp_wmb
在多核之間設置一個寫屏障
多核讀依賴屏障
Smp_read_barrier_depends
在多核之間設置一個讀依賴屏障
按照linux設計,mb、rmb、wmb、read_barrier_depends主要用於CPU與外設IO之間。在arm及其他一些RISC系統中,通常將外設IO地址映射為一段內存地址。雖然這樣的內存是非緩存的,但是仍然受到內存讀寫亂序的影響。例如,我們要讀寫一個外部IO端口的數據時,可能會先向某個寄存器寫入一個要讀寫的端口號,再讀取另一個端口得到其值。如果要讀取值之前,設置的端口號還沒有到達外設,那麼通常讀取的數據是不可靠的,有時甚至會損壞硬件。這種情況下,需要在讀寄存器前,設置一個內存屏障,保證二次操作外部端口之間沒有亂序。
Smp_mb、smp_rmb、smp_wmb僅僅用於SMP系統,它解決的是多核之間內存亂序的問題。其具體用法及原理,請參閱《深入理解並行編程》。
read_barrier_depends和smp_ read_barrier_depends是讀依賴屏障。除了在DEC alpha架構外,linux支持的其他均不需要這個屏障。Alpha需要它,是因為alpha架構中,使用的緩存是split cache.所謂split cache,簡單的說就是一個核的緩存不止一個.在arm架構下,我們可以簡單的忽略這個屏障。
雖然linux分讀寫屏障、讀屏障、寫屏障,但是在ARM中,它們的實現都是一樣的,沒有嚴格區別不同的屏障。
內存屏障也隱含了編譯屏障的作用。所謂編譯屏障,是為了解決編譯亂序的問題。這個問題的根源在於:在發明編譯器的時候,多核還未出現。編譯器開發者認為編譯出來的二進制代碼只要在單核上運行正確就可以了。甚至,只要保證單線程內的程序邏輯正確性即可。例如,我們有兩句賦值語句:
A = 1;
B = 2;
編譯器並不保證生成的匯編是按照C語句的順序。為了效率或者其他原因,它生成的匯編語句可能與下面的C代碼是一致的:
B = 2;
A = 1;
要防止編譯亂序,可以使用編譯屏障指令barrier();
1.2 不是題外話的題外話
在描述原子變量和每CPU變量、其他內核同步方法之前,我們先看一段代碼。假設有兩個線程A和線程B,它們的執行代碼分別是foo_a、foo_b,它們都操作一個全局變量g_a,如下:
Unsigned long g_a;
Int stoped = 0;
Void foo_a(void *unused)
{
While (stopped == 0)
{
G_a++;
}
}
Void foo_b(void *unused)
{
While (stopped == 0)
{
G_a++;
}
}
假設當stopped被設置為1後,線程A和線程B執行了count_a、count_b次,您會認為g_a的值等於count_a + count_b嗎?
恩,當您在一台真實的計算上測試這個程序的時候,也許您的直覺是對的,g_a的值確實等於count_a + count_b。
但是,請您:
1、將測試程序運行的時間運行得久一點
2、或者將程序放到arm、powerpc或者mips上運行
3、或者找一台運行linux的多核x86機器運行。
g_a的值還會等於count_a + count_b嗎?
答案是不會。
原因是什麼呢?
產生這個問題的根本原因是:
1、 在多核上,一個CPU在向內存寫入數據時,它並不知道其他核在向同樣的內存地址寫入。某一個核寫入的數據可能會覆蓋其他核寫入的數據。假說g_a當前值是0,那麼線程A和線程B同時讀取它的值,當內存中的值放入總線上後,兩個線程都認為其值是0.並同時將其值加1後提交給總線並向內存中寫入1.其中一個線程對g_a的遞增被丟失了。
2、 Arm、powerpc、mips這些體系結構都是存儲/加載體系結構,它們不能直接對內存中的值進行操作。而必須將內存中的值加載到寄存器中後,將寄存器中的值加1後,再存儲到內存中。如果兩個線程都讀取0值到寄存器中,並將寄存器的值遞增為1後存儲到內存,那麼也會丟失一次遞增。
3、 即使在x86體系結構中,允許直接對內存進行遞增操作。也會由於編譯器的原因,將內存中的值加載到內存,同第二點,也可能造成丟失一次遞增。
怎麼解決這個問題呢?
聰明的讀者會說了:是不是需要這樣聲明g_a?
Unsigned long volatile g_a;
更聰明的讀者會說,在寫g_a時還需要鎖住總線,使用匯編語句並在匯編前加lock前綴。
鎖總線是正確的,但是也必須將g_a聲明為valatile類型的變量。可是,在我們分析的ARM多核上,應該怎麼辦?
1.3 原子變量
原子變量就是為了解決我們遇到的問題:如果在共享內存的多核系統上正確的修改共享變量的計數值。
首先,我們看一下老版本是如何定義原子變量的:
/**
* 將counter聲明成volatile是為了防止編譯器優化,強制從內存中讀取counter的值
*/
typedef struct { volatile int counter; } atomic_t;
在linux3.0中,已經有所變化:
typedef struct {
int counter;
} atomic_t;
已經沒有volatile來定義counter了。難道不需要禁止編譯優化了嗎?答案不是的。這是因為linux3.0已經修改了原子變量相關的函數。
Linux中的基本原子操作
宏或者函數
說明
Atomic_read
返回原子變量的值
Atomic_set
設置原子變量的值。
Atomic_add
原子的遞增計數的值。
Atomic_sub
原子的遞減計數的值。
atomic_cmpxchg
原子比較並交換計數值。
atomic_clear_mask
原子的清除掩碼。
除此以外,還有一組操作64位原子變量的變體,以及一些位操作宏及函數。這裡不再羅列。
/**
* 返回原子變量的值。
* 這裡強制將counter轉換為volatile int並取其值。目的就是為了避免編譯優化。
*/
#define atomic_read(v) (*(volatile int *)&(v)->counter)
/**
* 設置原子變量的值。
*/
#define atomic_set(v,i) (((v)->counter) = (i))
原子遞增的實現比較精妙,理解它的關鍵是需要明白ldrex、strex這一對指令的含義。
/**
* 原子的遞增計數的值。
*/
static inline void atomic_add(int i, atomic_t *v)
{
unsigned long tmp;
int result;
/**
* __volatile__是為了防止編譯器亂序。與"#define atomic_read(v) (*(volatile int *)&(v)->counter)"中的volatile類似。
*/
__asm__ __volatile__("@ atomic_add\n"
/**
* ldrex是arm為了支持多核引入的新指令,表示"排它性"加載。與mips的ll指令一樣的效果。
* 它與"排它性"存儲配對使用。
*/
"1: ldrex %0, [%3]\n"
/**
* 原子變量的值已經加載到寄存器中,這裡對寄存器中的值減去指定的值。
*/
" add %0, %0, %4\n"
/**
* strex是"排它性"的存儲寄存器的值到內存中。類似於mips的sc指令。
*/
" strex %1, %0, [%3]\n"
/**
* 關鍵代碼是這裡的判斷。如果在ldrex和strex之間,其他核沒有對原子變量變量進行加載存儲操作,
* 那麼寄存器中值就是0,否則非0.
*/
" teq %1, #0\n"
/**
* 如果其他核與本核沖突,那麼寄存器值為非0,這裡跳轉到標號1處,重新加載內存的值並遞增其值。
*/
" bne 1b"
: "=&r" (result), "=&r" (tmp), "+Qo" (v->counter)
: "r" (&v->counter), "Ir" (i)
: "cc");
}
atomic_add_return遞增原子變量的值,並返回它的新值。它與atomic_add的最大不同,在於在原子遞增前後各增加了一句:smp_mb();
這是由linux原子操作函數的語義規定的:所有對原子變量的操作,如果需要向調用者返回結果,那麼就需要增加多核內存屏障的語義。通俗的說,就是其他核看到本核對原子變量的操作結果時,本核在原子變量前的操作對其他核也是可見的。
理解了atomic_add,其他原子變量的實現也就容易理解了。這裡不再詳述。
1.4 每CPU變量
原子變量是不是很棒?無論有多少個核,每個核都可以修改共享內存變量,並且這樣的修改可以被其他核立即看到。多核編程原來so easy!
不過還是不能太高興了,原子變量雖然不是毒瘤,但是也差不多了。我曾經遇到一個兄弟,工作十多年了吧,得意的吹噓:“我寫的代碼精細得很,統計計數都是用的匯編實現的,匯編加法指令還用了lock前綴。”嗚呼,這個兄弟完全沒有意識到在x86體系結構中,這個lock前綴對性能的影響。
不管哪種架構,原子計數(包含原子比較並交換)都是極耗CPU的。與單純的加減計數指令相比,它消耗的CPU周期要高一到兩個數量級。原因是什麼呢?還是光信號(電信號)的傳播速度問題。要讓某個核上的修改被其他核發現,需要信號在整個系統中進行傳播。這在幾個核的系統中,可能還不是大問題,但是在1024個核以上的系統中呢?比如我們熟知的天河系統。
為了解決這個問題,內核引用入了每CPU變量。
可以將它理解為數據結構的數組。系統的每個CPU對應數組中的一個元素。每個CPU都只訪問本CPU對應的數組元素。
每CPU數組中,確保每一個數組元素都位於不同的緩存行中。假如您有一個int型的每CPU數組,那麼每個int型都會占用一個緩存行(很多系統中一個緩存行是32個字節),這看起來有點浪費。這樣做的原因是:
ü 對每CPU數組的並發訪問不會導致高速緩存行的失效。避免在各個核之間引起緩存行的抖動。
ü 這也是為了避免出現多核之間數據覆蓋的情況。對這一點,可能您暫時不能理解。也許您在內核領域實際工作幾年,也會覺得這有點難於理解。不過,現在您只需要知道有這麼一個事實存在就行了。
關於第二個原因,您可以參考一個內核補丁:
99dcc3e5a94ed491fbef402831d8c0bbb267f995。據提交補丁的兄弟講,這個補丁表面是一個性能優化的措施。但是,它實際上是一個BUG。該故障會引起內核內存分配子系統的一個BUG,最終會引起內存分配子系統陷入死循環。我實際的遇到了這個故障,可憐了我的兩位兄弟,為了解決這個故障,花了近兩個月時間,今天終於被我搞定了。
每CPU變量的主要目的是對多CPU並發訪問的保護。但是它不能防止同一核上的中斷的影響。我們曾經講過,在arm、mips等系統中,++、--這樣的簡單計數操作,都需要幾條匯編語句來完成。如果在從內存中加載數據到寄存器後,還沒有將數據保存到內存中前,有中斷將操作過程打斷,並在中斷處理函數中對同樣的計數值進行操作,那麼中斷中的操作將被覆蓋。
不管在多CPU還是單CPU中,內核搶占都可能象中斷那樣破壞我們對計數的操作。因此,應當在禁用搶占的情況下訪問每CPU變量。內核搶占是一個大的話題,我們在講調度的時候再提這個事情。
相關宏和函數:
宏或者函數
說明
DEFINE_PER_CPU
靜態定義一個每CPU變量數組
per_cpu
獲得每CPU數組中某個CPU對應的元素
__this_cpu_ptr
獲得當前CPU在數組中的元素的指針。
__get_cpu_var
獲得當前CPU在數組中的元素的值。
get_cpu_ptr
關搶占,並獲得CPU對應的元素指針。
put_cpu_var
開搶占,與get_cpu_ptr配對使用。
看到這裡,也許大家會覺得,用每CPU變量來代替原子變量不是很好麼?不過,存在的東西就必然在存在的理由,因為每CPU變量用於計數有一個致使的弊端:它是不精確的。我們設想:有32個核的系統,每個核更新自己的CPU計數,如果有一個核想知道計數總和怎麼辦?簡單的用一個循環將計數加起來嗎?這顯然是不行的。因為某個核修改了自己的計數變量時,其他核不能立即看到它對這個核的計數進行的修改。這會導致計數總和不准。特別是某個核對計數進行了大的修改的時候,總計數看起來會嚴重不准。
為了使總和大致可信,內核又引入了另一種每CPU變量:percpu_counter。
percpu_counter的詳細實現在percpu_counter.c中。有興趣的同學可以研究一下。下面我們講一個主要的函數,希望起個拋磚引玉的作用:
/**
* 增加每CPU變量計數
* fbc: 要增加的每CPU變量
* amount: 本次要增加的計數值
* batch: 當本CPU計數超過此值時,要確保其他核能及時看到。                                     
*/
void __percpu_counter_add(struct percpu_counter *fbc, s64 amount, s32 batch)
{
s64 count;
/**
* 為了避免當前任務飄移到其他核上,或者被其他核搶占,導致計數丟失
* 這裡需要關搶占。
*/
preempt_disable();
/**
* 獲得本CPU計數值並加上計數值。
*/
count = __this_cpu_read(*fbc->counters) + amount;
if (count >= batch || count <= -batch) {/* 本次修改的值較大,需要同步到全局計數中 */
spin_lock(&fbc->lock);/* 獲得自旋鎖,這樣可以避免多核同時更新全局計數。 */
fbc->count += count;/* 修改全局計數,並將本CPU計數清0 */
__this_cpu_write(*fbc->counters, 0);
spin_unlock(&fbc->lock);
} else {
__this_cpu_write(*fbc->counters, count);/* 本次修改的計數較小,僅僅更新本CPU計數。 */
}
preempt_enable();/* 打開搶占 */
}
大家現在覺得多核編程有那麼一點難了吧?一個簡單的計數都可以搞得這麼復雜。
復雜的東西還在後面。接下來我們新開一帖,討論內核同步的其他技術:自旋鎖、信號量、RCU、無鎖編程。
上文來自:http://blog.chinaunix.net/uid-25002135-id-3012953.html
Copyright © Linux教程網 All Rights Reserved