【nginx原始碼】nginx中的鎖與原子操作
問題引入
多執行緒或者多程序程式訪問同一個變數時,需要加鎖才能實現變數的互斥訪問,否則結果可能是無法預期的,即存在併發問題。解決併發問題通常有兩種方案:
1)加鎖:訪問變數之前加鎖,只有加鎖成功才能訪問變數,訪問變數之後需要釋放鎖;這種通常稱為悲觀鎖,即認為每次變數訪問都會導致併發問題,因此每次訪問變數之前都加鎖。
2)原子操作:只要訪問變數的操作是原子的,就不會導致併發問題。那表示式麼i++是不是原子操作呢?
nginx通常會有多個worker處理請求,多個worker之間需要通過搶鎖的方式來實現監聽事件的互斥處理,由函式ngx_shmtx_trylock實現搶鎖邏輯,程式碼如下:
ngx_uint_t ngx_shmtx_trylock(ngx_shmtx_t *mtx) { return (*mtx->lock == 0 && ngx_atomic_cmp_set(mtx->lock, 0, ngx_pid)); }
變數mtx->lock指向的是一塊共享記憶體地址(所有worker都可以訪問);worker程序會嘗試設定變數mtx->lock的值為當前程序號,如果設定成功,則說明搶鎖成功,否則認為搶鎖失敗。
注意ngx_atomic_cmp_set設定變數mtx->lock的值為當前程序號並不是無任何條件的,而是隻有當變數mtx->lock值為0時才設定,否則不予設定。ngx_atomic_cmp_set是典型的比較-交換操作,且必須加鎖或者是原子操作才行,函式實現方式下節分析。
nginx有一些全域性統計變數,比如說變數ngx_connection_counter,此類變數由所有worker程序共享,併發執行累加操作,由函式ngx_atomic_fetch_add實現;而該累加操作需要加鎖或者時原子操作才行,函式實現方式下節分析。
上面說的mtx->lock和ngx_connection_counter都是共享變數,所有worker程序都可以訪問,這些變數在ngx_event_core_module模組的ngx_event_module_init函式建立,且該函式在fork worker程序之前執行。
/* cl should be equal to or greater than cache line size */ cl = 128; size = cl /* ngx_accept_mutex */ + cl /*ngx_connection_counter */ + cl; /* ngx_temp_number */ if (ngx_shm_alloc(&shm) != NGX_OK) { return NGX_ERROR; } shared = shm.addr; if (ngx_shmtx_create(&ngx_accept_mutex, (ngx_shmtx_sh_t *) shared,cycle->lock_file.data)!= NGX_OK) { return NGX_ERROR; } ngx_connection_counter = (ngx_atomic_t *) (shared + 1 * cl);
這裡需要重點思考這麼幾個問題:
1)cache_line_size是什麼?我們都知道CPU與主存之間還存在著快取記憶體,快取記憶體的訪問速率高於主存訪問速率,因此主存中部分資料會被快取在快取記憶體中,CPU訪問資料時會先從快取記憶體中查詢,如果沒有命中才會訪問主從。需要注意的是,主存中的資料並不是一位元組一位元組載入到快取記憶體中的,而是每次載入一個數據塊,該資料塊的大小就稱為cache_line_size,快取記憶體中的這塊儲存空間稱為一個快取行。cache_line_size32位元組,64位元組不等,通常為64位元組。
2)此處cl取值128位元組,可是cl為什麼一定要大於等於cache_line_size?待下一節分析了原子操作函式實現方式後自然會明白的。
3)函式ngx_shm_alloc是通過系統呼叫mmap分配的記憶體空間,首地址為shared;
4)這裡建立了三個共享變數ngx_accept_mutex、ngx_connection_counter和ngx_temp_number;函式ngx_shmtx_create使得ngx_accept_mutex->lock變數指向shared;ngx_connection_counter指向shared+128位元組位置處,ngx_temp_number指向shared+256位元組位置處。
原子操作函式實現方式
據說gcc某版本以後內建了一些原子性操作函式(沒有驗證),如:
//原子加
type __sync_fetch_and_add (type *ptr, type value);
//原子減
type __sync_fetch_and_sub (type *ptr, type value);
//原子比較-交換,返回true
bool __sync_bool_compare_and_swap(type* ptr, type oldValue, type newValue, ....);
//原子比較交換,返回之前的值
type __sync_val_compare_and_swap(type* ptr, type oldValue, type newValue, ....);
通過這些函式很容易解決上面說的多個worker搶鎖,統計變數併發累計問題。nginx會檢測系統是否支援上述方法,如果不支援會自己實現類似的原子性操作函式。
原始碼目錄下src/os/unix/ngx_gcc_atomic_amd64.h、src/os/unix/ngx_gcc_atomic_x86.h等檔案針對不同作業系統實現了若干原子性操作函式。
內聯彙編
可通過內聯彙編向C程式碼中嵌入組合語言。原子操作函式內部都使用到了內聯彙編,因此這裡需要做簡要介紹;
內聯彙編格式如下,需要了解以下6個概念:
asm (
彙編指令
: 輸出運算元(可選)
: 輸入運算元(可選)
: 暫存器列表(表明哪些暫存器被修改,可選)
);
1)暫存器通常有一些簡稱;
- r:表示使用一個通用暫存器,由GCC在%eax/%ax/%al, %ebx/%bx/%bl, %ecx/%cx/%cl, %edx/%dx/%dl中選取一個GCC認為合適的。
- a:表示使用%eax / %ax / %al
- b:表示使用%ebx / %bx / %bl
- c:表示使用%ecx / %cx / %cl
- d:表示使用%edx / %dx / %dl
- m: 表示記憶體地址
- 等
2)彙編指令;
" popl %0 "
" movl %1, %%esi "
" movl %2, %%edi "
3)輸入運算元,通常格式為——“暫存器簡稱/記憶體簡稱”(值);這種稱為暫存器約束或者記憶體約束,表明輸入或者輸出需要藉助暫存器或者記憶體實現。
: "m" (*lock), "a" (old), "r" (set)
4)輸出運算元;
//+號表示既是輸入引數又是輸出引數
:"+r" (add)
//將暫存器%eax / %ax / %al儲存到變數res中
:"=a" (res)
5)暫存器列表,如
: "cc", "memory"
cc表示會修改標誌暫存器中的條件標誌,memory表示會修改記憶體。
6)佔位符與volatile關鍵字
__asm__ volatile (
" xaddl %0, %1; "
: "+r" (add) : "m" (*value) : "cc", "memory");
volatile表明禁止編譯器優化;%0和%1順序對應後面的輸出或輸入運算元,如%0對應"+r" (add),%1對應"m" (*value)。
比較-交換原子實現
現代處理器都提供了比較-交換匯編指令cmpxchgl r, [m],且是原子操作。其含義如下為,如果eax暫存器的內容與[m]記憶體地址內容相等,則設定[m]記憶體地址內容為r暫存器的值。虛擬碼如下(標誌暫存器zf位):
if (eax == [m]) {
zf = 1;
[m] = r;
} else {
zf = 0;
eax = [m];
}
因此利用指令cmpxchgl可以很容易實現原子性的比較-交換功能。
但是想想這樣有什麼問題呢?對於單核CPU來說沒任何問題,多核CPU則無法保證。(參考深入理解計算機系統第六章)以Intel Core i7處理器為例,其有四個核,且每個核都有自己的L1和L2快取記憶體。
前面提到,主存中部分資料會被快取在快取記憶體中,CPU訪問資料時會先從快取記憶體中查詢;那假如同一塊記憶體地址同時被快取在核0與核1的L2級快取記憶體呢?此時如果核0與核1同時修改該地址內容,則會造成衝突。
目前處理器都提供有lock指令;其可以鎖住匯流排,其他CPU對記憶體的讀寫請求都會被阻塞,直到鎖釋放;不過目前處理器都採用鎖快取替代鎖匯流排(鎖匯流排的開銷比較大),即lock指令會鎖定一個快取行。當某個CPU發出lock訊號鎖定某個快取行時,其他CPU會使它們的快取記憶體該快取行失效,同時檢測是對該快取行中資料進行了修改,如果是則會寫所有已修改的資料;當某個快取記憶體行被鎖定時,其他CPU都無法讀寫該快取行;lock後的寫操作會及時會寫到記憶體中。
以檔案src/os/unix/ngx_gcc_atomic_x86.h為例。
檢視ngx_atomic_cmp_set函式實現如下:
#define NGX_SMP_LOCK "lock;"
static ngx_inline ngx_atomic_uint_t
ngx_atomic_cmp_set(ngx_atomic_t *lock, ngx_atomic_uint_t old,
ngx_atomic_uint_t set)
{
u_char res;
__asm__ volatile (
NGX_SMP_LOCK
" cmpxchgl %3, %1; "
" sete %0; "
: "=a" (res) : "m" (*lock), "a" (old), "r" (set) : "cc", "memory");
return res;
}
cmpxchgl即為上面說的原子比較-交換指令;sete取標誌暫存器中ZF位的值,並存儲在%0對應的運算元。函式最後返回標誌暫存器zf位。
累加指令格式為xaddl r [m],含義如下:
temp = [m];
[m] += r;
r = temp;
檢視ngx_atomic_fetch_add函式實現:
static ngx_inline ngx_atomic_int_t
ngx_atomic_fetch_add(ngx_atomic_t *value, ngx_atomic_int_t add)
{
__asm__ volatile (
NGX_SMP_LOCK
" xaddl %0, %1; "
: "+r" (add) : "m" (*value) : "cc", "memory");
return add;
}
指令xaddl實現了加法功能,其將%0對應運算元加到%1對應運算元,函式最後返回累加之前的舊值。
這裡再回到第一小節,cl取值128位元組,且註釋表明cl一定要大於等於cache_line_size。cl是什麼?三個共享變數之間的偏移量。那假如去掉這個限制,由於每個變數只佔8位元組,所以三個變數總共佔24位元組,假設cache_line_size即快取行大小為64位元組,即這三個共享變數可能屬於同一個快取行。
那麼當使用lock指令鎖定ngx_accept_mutex->lock變數時,會鎖定該變數所在的快取行,從而導致對共享變數ngx_connection_counter和ngx_temp_number同樣執行了鎖定,此時其他CPU是無法訪問這兩個共享變數的。因此這裡會限制cl大於等於快取行大小。
總結
本文簡要介紹了nginx中鎖的實現原理,多核快取記憶體衝突問題,內聯彙編簡單語法,以及原子比較-交換操作和原子累加操作的實現。
才疏學淺,如有錯誤或者不足,請指出。