【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等文件針對不一樣操做系統實現了若干原子性操做函數。fetch

內聯彙編

可經過內聯彙編向C代碼中嵌入彙編語言。原子操做函數內部都使用到了內聯彙編,所以這裏須要作簡要介紹;
內聯彙編格式以下,須要瞭解如下6個概念:優化

asm ( 
彙編指令
: 輸出操做數(可選)
: 輸入操做數(可選)
: 寄存器列表(代表哪些寄存器被修改,可選)
);

1)寄存器一般有一些簡稱;ui

  • 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)彙編指令;atom

" popl %0 "
" movl %1, %%esi "
" movl %2, %%edi "

3)輸入操做數,一般格式爲——"寄存器簡稱/內存簡稱"(值);這種稱爲寄存器約束或者內存約束,代表輸入或者輸出須要藉助寄存器或者內存實現。spa

: "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中鎖的實現原理,多核高速緩存衝突問題,內聯彙編簡單語法,以及原子比較-交換操做和原子累加操做的實現。才疏學淺,若有錯誤或者不足,請指出。

相關文章
相關標籤/搜索