鎖與無鎖

LOCK

當我們在編寫多線程程序時,常常會涉及到多個線程對共享數據的訪問。如果不對這種訪問加以限制,往往會導致程序運行結果與預期不符

編寫代碼時,我們以及習慣了用鎖去保護數據。那麼,這裏的鎖是什麼?爲什麼它能滿足我們的要求?它存在於哪裏?

讓我們從一個最簡單的例子出發—多個線程併發修改一個全局變量:

/* 全局變量 */
int g_sum = 0;

/* 每個線程入口 */
void *thread(void* arg)
{
	for(int i = 0; i < 100; i++)  
	{
		g_sum++;
	}
	
	return NULL;
}

多核處理器上,如果有兩個線程同時執行上面的累加操作,最終的g_sum幾乎不可能是預期的200(每個線程累加100次),而更傾向於是一個接近200的隨機值。

這是因爲CPUg_sum進行累加時,它們都會:1.從內存中讀取 2.修改它的值 3.將新值寫回內存。由於CPU之間是獨立的,而內存是共享的,所以就有可能存在一種時序:兩個CPU先後從內存中讀取了g_sum的值,並各自對它進行了遞增,最終將新的值寫入g_sum,這時。兩個線程的兩次累加最終只讓g_sum增加了1

臨界區

要解決上面的問題,一個很自然的想法同一時間段內,要想辦法只讓一個線程對全局變量進行讀-修改-寫。我們可以用去保護臨界區

這裏引入了臨界區的概念。臨界區是指訪問共用資源的程序片段(比如上面的例子中的"g_sum++")。線程在進入臨界區時加鎖,退出臨界區時解鎖。也就是說,鎖將臨界區"保護"了起來。

critical-section

臨界區是人們爲一段代碼片段強加上的概念,但加鎖解鎖不一樣,它必須實打實地存在於代碼中。那麼問題來了,應該如何實現 ?

爲了回答這個問題,我們先將需要具有的特性列出來:

1. 它需要支持加鎖(lock)和解鎖(unlock)兩種操作。
2. 它需要是有狀態(State)的,它需要記錄當前這把鎖處於Locked還是Unlocked狀態。
3. 鎖的狀態變化必須是原子(Atomic)的
4. 當它處於Locked狀態時,對其進行加鎖(lock)的操作,不會成功。

1條,對實現者來說,一是要提供兩個API分別對應這兩種操作。

2條,需要一個地方能記錄鎖的狀態,對計算機系統來說,這個地方只能是內存

3條,將的狀態記錄在內存中有個和全局變量一樣的問題,那就是如何避免多個線程同時去改變鎖的狀態 ? 總不能用去保護吧 ? 好在各個體系的CPU都提供了這種原子操作的原語, 對x86來說,就是指令的LOCK前綴, 它可以在執行指令時控制住總線,直到指令執行完成。這也就保證了的狀態修改是通過原子操作完成的。

4條,加鎖操作成功的前提是的狀態是處於"Unlocked",如果該條件不滿足,則本次加鎖操作失敗,那麼失敗以後的行爲呢?不同的鎖有不同的實現,一般來說有三種可選擇的行爲:1.立即返回失敗 2.不斷嘗試再加鎖,直到成功. 3. 睡眠線程自己,直到可以獲得鎖。

典型實現

當然,我們並不需要去重複造的輪子。

在用戶空間,glibc提供了諸如spinlocksemaphorerwlockmutex類型的鎖的實現,我們只要使用API就行。

int sem_wait(sem_t *sem);
int sem_post(sem_t *sem);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
.......

在內核空間,Linux也有類似的實現.

性能損失

在剛纔的例子中,如果我們使用了去保護g_sum,那麼最終一定能得到200。但是,我們在得到準確結果的同時也會付出性能的代價。

race

如果把臨界區比作成一個獨木橋,那麼線程就是需要過獨木橋的人。 顯然,如果過橋的人(併發訪問臨界區的線程)越多,獨木橋越長(鎖保護的臨界區的範圍越大),那麼其他人等地就越久(性能就下降地越厲害)。

下面這是在一臺8CPU虛擬機環境下,測試程序的運行結果。

raw Vs mutex

橫座標是併發運行的線程的數目,縱座標是完成相同任務(累加一定次數)時的運行時間。越多的線程會帶來越多的衝突,因此,總的運行時間會逐漸增大。

如果增加臨界區的長度呢(在每次循環中增加一些額外指令),則會得到下面的結果:

raw Vs mutex2

橫座標表示額外的指令,縱座標依然表示時間。

可見,線程的併發越多臨界區越大都會造成程序性能下降。這也是爲什麼追求性能的程序會選擇使用每cpu變量(或者每線程變量),並且儘量減小鎖保護的粒度。

Futex

前面說過,是有狀態的,並且這個狀態需要保存在內存中。那麼?具體到Linux平臺,對象是保存在內核空間還是用戶空間呢? 在比較早的內核(2.5.7)中,這個對象是保存在內核中的,這是很自然的做法。因爲當一個線程(task)去等待獲得一個互斥鎖時,如果獲取不到,那麼它需要將積極睡眠,直到可用後再被喚醒。

這個過程具體來說,就是將自己的task_struct掛到對象的等待鏈表上。當的持有者unlock時,內核就可以從該等待列表上找到並喚醒鏈表上所有task

lock-obj

可見,每次用戶的加鎖解鎖操作都必須陷入內核(即使現在沒有其他線程持有這把鎖)。陷入內核意味着幾百個時鐘就消耗了。在衝突不大的場景中,這種消耗就白白浪費了。

因此,從2.5.7版本開始,Linux引入了Futex(Fast Userspace muTEXes),即快速的用戶態互斥機制,這個機制是用戶態和內核態共同協作完成的,它將保存狀態的對象放在用戶態。如果用戶在加鎖時發現處於(Unlocked)狀態,那麼就直接修改狀態就好了(fast path),不需要陷入內核。當然,如果此時鎖處於(Locked)狀態,還是需要陷入內核(slow path)。

fast_path slow

那麼我們如何使用Futex機制呢?答案是我們完全不需要顯示地使用,glibc庫中的semaphoremutex底層就是使用的Futex

無鎖

是通過一個狀態的原子操作來保證共享數據的訪問互斥。而無鎖的意思就是不需要這樣一個狀態。

CAS

說到無鎖,必須提到的就是CAS指令(也可以叫CSW)。CASCompareAndSwap的縮寫,即比較-交換。不同體系的CPU有不同的CAS的指令實現。在x86上,就是帶LOCK前綴的CMPXCHG指令。所以,CAS操作是原子的

它的功能用僞代碼描述就是下面這樣(僅爲理解,實際是一條原子指令):

bool compare_and_swap(int *src, int *dest, int newval)
{
  if (*src == *dest) {
      *src = newval;
      return true;
  } else {
      return false;
  }
}

第一個操作數的內容與第二個操作數的內容相比較, 如果相同,則將第三個操作數賦值給第一個操作數,返回TRUE, 否則返回FALSE

較新版本的gcc已經內置了CAS操作的API(如下)。其他編譯器也提供了類似的API,不過這不是本文的重點。

bool __sync_bool_comware_and_swap(type *ptr, type oldval, type newval);

基於鏈表的無鎖隊列

無鎖通常構建無鎖隊列(Lock-Free Queue)。顧名思義,無鎖隊列就是指使用鎖結構來控制多線程併發互斥的隊列。

我們知道,隊列是一個典型的先入先出(FIFO)的數據結構,具有入隊(Enqueue)和出隊(Dequeue)兩種操作。併發條件下,多個線程可能在入隊或出隊時會產生競爭。

單向鏈表爲基礎實現的隊列如下圖所示(有一個Dummy鏈表頭),線程1和線程2都希望自己能完成入隊操作

enqueue

通常來說,入隊要完成兩件事:

  • 更新尾節點(Node 2)的Next指向新節點
  • 更新Tail指向的節點到新入隊的節點

如果可以使用,我們可以通過將以上兩件事放到一個的保護範圍內就能完成線程的互斥,那麼對於無鎖呢?

John D.Valois 在《Implemeting Lock-Free Queues》中提出的無鎖隊列的入隊列算法如下(僞代碼):

EnQueue(x)
{
    /* 創建新的節點 n */
    n = new node();
    n->value = x;
	n->next = NULL;
	
	do {
	    t = tail;                          // 取得尾節點
		succ = CAS(t->next, NULL, n)       // 嘗試更新尾節點的Next指向新的節點
		if succ != TRUE
			CAS(tail, t, t->next)          // 更新失敗,嘗試將tail向後走
	}while(succ != TRUE);  
	
	CAS(tail, t, n);  // 更新隊列的Tail指針,使它指向新的節點
}

這裏的Enqueue算法中使用了三次CAS操作。

1. 第一次CAS操作更新尾節點的Next指向新的節點。如果在單線程環境中,這個操作必定成功。但在多線程環境,如果有多個線程都在進行Enqueue操作,那麼在線程T1取得尾節點後,線程T2可能已經完成了新節點的入隊,此時T1CAS操作就會失敗,因爲此時t->Next已經不爲NULL了,而變成了T2新插入的節點。

thread 1 VS thread 2

再強調一遍,CAS操作會鎖住總線!因此T1T2只有一個線程會成功,成功的線程會更新尾節點的Next,另一個線程會因爲CAS失敗而重新循環。

如果CAS操作成功,鏈表會變成下面這樣,此時的Tail指針還沒有更新

notupdate

2. 如果第一個CAS失敗,說明有其他線程在壞事(進行了元素入隊),這個時候第二個CAS操作會嘗試推進Tail指針。這樣做是爲了防止第一個CAS成功的線程突然掛掉而導致不更新Tail指針

3. 第三個CAS操作更新尾節點的Next

論文中還給出了另一個版本的入隊算法,如下所示

EnQueue2(x)
{
    /* 創建新的節點 n */
    n = new node();
    n->value = x;
	n->next = NULL;
	
	oldt = t = tail	
	do {
		while(t->next != NULL)              // 不斷向後到達隊列尾部
		    t = t->next
	}while(CAS(t->next, NULL, n) != TRUE);  // 更新尾節點的Next指向新的節點
	
	CAS(tail, oldt, n);  // 更新隊列的Tail指針,使它指向新的節點
}

與前一個的版本相比,新版本在循環內部增加了不斷向後遍歷的過程,也就是如果Tail指針後面已經有被其他線程添加了節點,本線程並不會等待Tail更新,而是直接向後遍歷。

再來看出隊,論文中給出的出隊算法如下:

DeQueue()
{
    do {
	    h = head;
		if h->next = NULL
			error queue_empty;	
	}while (CAS(head, h, h->next)!= TRUE)
	
	return h->next->value;
}

需要特別注意,該出隊算法不是返回隊首的元素,而是返回Head->Next節點。完成出隊後,移動Head指針到剛出隊的元素。算法中使用了一個CAS操作來控制競爭下的Head指針更新。另外,算法中並沒有描述隊列元素的資源釋放。

基於數組的無鎖隊列

以鏈表爲基礎的無鎖隊列有一個缺點就是內存的頻繁申請和釋放,在一些語言實現中,這種申請釋放本身就是帶鎖的。包含有鎖操作的行爲自然稱不上是無鎖。因此,更通用的無鎖隊列是基於數組實現的。論文中描述了一種基於數組的無鎖隊列算法,它具有以下一些特性:

1. 數組預先分配好,也就是能容納的元素個數優先

2. 使用者可以將值填入數組,除此之外,數組有三個特殊值:HEAD, TAILEMPTY。隊列初始化時(下圖),除了有兩個相鄰的位置是填入HEAD, TAIL之外,其他位置都是EMPTY。顯然,用戶數據不能再使用這三個值了。。

queue

  1. 入隊操作:假設用戶希望將一個值x入隊,它會找到TAIL的位置,然後對該位置和之後的位置執行一次Double-Word CAS。該操作將<TAIL, EMPTY>原子地替換爲<x, TAIL>。當然,如果TAIL後面不是EMPTY(而是HEAD`),就說明隊列滿了,入隊失敗。

  2. 出隊操作:找到HEAD的位置,同樣利用Double-Word CAS,將<HEAD,x>替換爲<EMPTY, HEAD>。當然如果HEAD後面是EMPTY,則出隊失敗(此時隊列是空的)。

  3. 爲了快速找到HEADTAIL的位置,算法使用兩個變量記錄入隊和出隊發生的次數,顯然這兩個變量的改變都是原子遞增的。

在某個時刻,隊列可能是下面這個樣子

圖-sometimes

一種實現

我也用CAS操作實現了一個隊列,但是沒有用論文中的算法。而更偏向於DPDK的實現。

struct headtail{
    volatile uint32_t head;
    volatile uint32_t tail;
};

struct Queue{
    struct headtail prod;
    struct headtail cons;
    int array[QUEUE_SIZE];
    int capacity;
};

int CAS_EnQueue(struct Queue* queue, int val)
{
    uint32_t head;
    uint32_t idx;
    bool succ;
    
    do{
        head = queue->prod.head;        
        if (queue->capacity + queue->cons.tail - head < 1)
        {
            /* queue is full */
            return -1;
        }
    
        /* move queue->prod.head */
        succ = CAS(&queue->prod.head, head, head + 1);
    }while(!succ);
    
    idx = head & queue->capacity;
    
    /* set val */    
    queue->array[idx] = val;
    
    /* wait */
    while(unlikely(queue->prod.tail != head))
    {
        _mm_pause();
    }
    
    queue->prod.tail = head + 1;
        
    return 0;
}


int CAS_DeQueue(struct Queue* queue, int* pval)
{
    uint32_t head;
    uint32_t idx;
    bool succ;
    
    do {
        head = queue->cons.head;
        if (queue->prod.tail - head < 1)
        {
            /* Queue is Empty */
            return -1;
        }
        
        /* forward queue->head */
        succ = CAS(&queue->cons.head, head, head + 1);
    }while(!succ);
    
    idx = head & queue->capacity;
    
    *pval = queue->array[idx];
    
    /* wait */
    while(unlikely(queue->cons.tail != head))
    {
        _mm_pause();
    }

        
    /* move cons tail */
    queue->cons.tail = head + 1;
    
    return 0;
}

總結

無論是還是無鎖,其實都是一種多線程環境下的同步方式,的應用更爲廣泛,而無鎖更有一種自旋的味道在裏面,在特定場景下的確能提高性能,比如DPDKring實際就是無鎖隊列的應用

REF

無鎖隊列的實現
Lock-Free 編程

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章