1. 程式人生 > >【nginx原始碼】nginx中的鎖與原子操作

【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快取記憶體。

image

前面提到,主存中部分資料會被快取在快取記憶體中,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中鎖的實現原理,多核快取記憶體衝突問題,內聯彙編簡單語法,以及原子比較-交換操作和原子累加操作的實現。

才疏學淺,如有錯誤或者不足,請指出。