讀寫鎖設計策略與效能詳細分析

2021-09-21 01:30:31 字數 3901 閱讀 9977

很多時候,對共享變數的訪問有以下特點:大多數情況下執行緒只是讀取共享變數的值,並不修改,只有極少數情況下,執行緒才會真正地修改共享變數的值。

對於這種情況,讀請求之間之間是無需同步的,他們之間的併發訪問是安全的。但是必須互斥寫請求和其他讀請求。

這種情況在實際中是存在的,比如配置項。大多數時間內,配置是不會發生變化的,偶爾會出現修改配置的情況。如果使用互斥量,完全阻止讀請求併發,則會造成效能的損失。

處於這種考慮,posix引入了讀寫鎖。

ntpl提供了pthread_rwlock_t型別來表示讀寫鎖。和互斥量一樣,它也提供了兩種初始化(靜態和動態兩種)的方法:

#include int pthread_rwlock_init(pthread_rwlock_t *rwlock,

const pthread_rwlockattr_t *attr);

int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

pthread_rwlock_t rwlock=pthread_rwlock_initializer;

對於靜態變數,可以採用pthread_rwlock_initializer賦值的方式初始化,對於動態分配的讀寫鎖,或者非預設屬性的讀寫鎖,需要用pthread_rwlock_init函式進行初始化。如果第二個屬性的引數為null,那麼採用預設屬性。

讀寫鎖的屬性:屬性

值說明競爭範圍

pthread_process_private

程序內部競爭讀寫鎖

策略pthread_rwlock_prefer_reader_np

讀者優先

從表面上看,讀寫鎖介紹到此處就可以了,其實不然,讀寫鎖是兩種型別的鎖,當它們都存在時,它們之間的競爭關係如何?如果同時到來一大波讀鎖和寫鎖請求,它們之間的影響又有什麼特點?事實上這些是由讀寫鎖的策略決定的。

讀寫鎖的屬性是pthread_rwlockattr_t型別,屬性中有兩個部分:lockkind和pshared。

所謂lockkind,表示讀寫鎖表現出什麼樣的行為藝術。對於讀寫鎖,目前有兩種策略,一是讀者優先,一是讀者優先。

glibc引入了如下介面來查詢和改變讀寫鎖的型別:

int pthread_rwlockattr_getkind_np(const pthread_rwlockattr_t * attr, int * pref);

int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t * attr, int * pref);

其中,讀寫鎖型別的可能值有如下幾種:

enum

;

可以看到,只有pthread_rwlock_prefer_writer_nonrecursive_np是寫者優先,其他一律都是讀者優先。讀寫鎖的預設行為是讀者優先。

如果當前鎖的狀態是讀鎖,並存在寫鎖請求被阻塞,那麼在寫鎖後面到來的讀鎖請求該如何處理就成為了問題的關鍵。

如果在寫鎖請求後面到來的讀鎖請求不被寫鎖請求阻塞,就可以立即響應,寫鎖的下場可能會比較悲慘。如果讀鎖請求前仆後繼源源不斷地到來,只要有乙個讀鎖沒完成,寫鎖就沒分。這就是所謂的讀者優先,總結來就是:讀鎖的優先順序高於寫鎖的優先順序。

較早到的寫鎖請求容易被餓死。

所謂寫者優先是指,如果當前是讀鎖,有很多執行緒在共享讀鎖,這是允許的,但是一旦執行緒申請寫鎖,在寫鎖請求後面到來的讀鎖請求就會統統被阻塞,不能先於寫請求拿到鎖。

glibc是如何做到這點的?如下:

變數說明

__lock

管理讀寫鎖全域性競爭的鎖,無論是讀鎖寫鎖還是解鎖,都會互斥

__writer

寫鎖持有者的執行緒id,如果為0則表示當前無線程持有寫鎖

__nr_readers

讀鎖持有執行緒的個數

__nr_readers_queued

讀鎖的派對等待執行緒的個數

__nr_writers_queued

寫鎖的排隊等待執行緒的個數

無論是申請讀鎖還是申請寫鎖,還是解鎖,都至少會做一次全域性互斥鎖(對應__lock)的加鎖和解鎖,若不考慮阻塞,蛋蛋考慮操作本身的開銷,讀寫鎖的加解鎖開銷是互斥鎖的兩倍。當然,函式結束前或進入阻塞之前,會將全域性的互斥鎖釋放。

對於讀鎖請求而言,如果:

無線程持有寫鎖,即__writer==0

採用的是讀者優先策略或沒有寫鎖的等待者(__nr_writers_queued==0)

當滿足這兩個條件時,讀鎖請求都可以立即獲得讀鎖,返回之前執行__nr_readers++,表示增加了乙個執行緒占有讀鎖。

不滿足的話,則執行__nr_readers_queued++,表示增加乙個讀鎖等待者,然後呼叫futex,陷入阻塞。醒來之後,會執行__nr_readers_queued–,然後再次判斷是否同時滿足條件1和2

對於寫鎖請求而言,如果:

無線程持有寫鎖,即__writer==0

沒有執行緒持有讀鎖,即__nr_readers==0

只要滿足上述條件,就會立刻拿到寫鎖,將__writer置為執行緒的id(排程域)

如果不滿足,那麼執行__nr_writers_queued++,表示增加乙個寫鎖等待者執行緒,然後執行futex陷入等待。醒來後,先執行__nr_writers_queued–,然後重新判斷條件1和2。

對於解鎖而言,如果當前鎖是寫鎖,則執行如下操作:

執行__writer=0,表示釋放寫鎖

根據__nr_writers_queued判斷有沒有寫鎖等待者,如果有則喚醒乙個寫鎖等待者

如果沒有寫鎖等待者,則判斷有沒有讀鎖等待者;如果有,則將所有的讀鎖等待者一起喚醒。

如果當前鎖是讀鎖,則執行如下操作:

執行__nr_readers–,表示讀鎖佔有者少了乙個

判斷__nr_readers是否等於0,是的話則表示自己是最後乙個讀鎖佔有者,需要喚醒寫鎖等待者或者讀鎖等待者:

從上面的流程可以看出,寫者優先也存在自私的傾向,因為寫鎖解鎖的時候,優先去解鎖寫鎖等待者,寫鎖等待者優先被解鎖,也就意味著如果寫鎖請求一直存在的情況下,讀鎖請求將會出現長時間的飢餓狀態。

通過上面的分析可以看到,如果存在大量的讀寫請求,競爭非常激烈的條件下,讀寫鎖存在很大的慣性,在不同的優先策略下,都有可能產生大量讀或寫請求的飢餓。

那麼能否實現一款公平的讀寫鎖呢?答案是肯定的。

從巨集觀意義上看,讀寫鎖要比互斥量併發性好,因為讀寫鎖在更多的時間區域內允許併發。

當然讀寫鎖並不是完美的,互斥鎖也有其存在的必要。讀寫鎖會有如下的短處:

·效能:如果臨界區比較大,讀寫鎖高併發的優勢就會顯現出

來,但是如果臨界區非常小,讀寫鎖的效能短板就會暴露出來。由

於讀寫鎖無論是加鎖還是解鎖,首先都會執行互斥操作,加上讀寫

鎖還需要維護當前讀者執行緒的個數、寫鎖等待執行緒的個數、讀鎖等

待執行緒的個數,因此這就決定了讀寫鎖的開銷不會小於互斥量。

·餓死:互斥量雖然不是絕對意義上的公正,但是執行緒不會餓

死。但是如上一小節的討論,讀者優先的策略下,寫執行緒可能會餓

死。寫者優先的情況下,讀執行緒可能會餓死。

·死鎖:讀鎖是可重入的,這就可能會引發死鎖。考慮如下場

景,讀寫鎖採用寫者優先的策略,a執行緒已經持有讀鎖,b執行緒申請

了寫鎖,正處於等待狀態,而持有讀鎖的a執行緒再次申請讀鎖,就

會發生死鎖。

比較適合讀寫鎖的場景是:臨界區的大小比較可觀,絕大多數情況下是讀,只有非常少的寫

APACHE與NGINX 詳細分析

apache是目前最流行的web應用伺服器,佔據了網際網路應用伺服器70 以上的份額。apache能取得如此成功並不足為奇 它免費 穩定且效能卓越 但apache能取得如此佳績的另乙個原因是,當時網際網路剛剛興起時,apache是第乙個可用的web應用伺服器,人們沒有其他的選擇。不可否認,apach...

APACHE與NGINX 詳細分析

apache是目前最流行的web應用伺服器,佔據了網際網路應用伺服器70 以上的份額。apache能取得如此成功並不足為奇 它免費 穩定且效能卓越 但apache能取得如此佳績的另乙個原因是,當時網際網路剛剛興起時,apache是第乙個可用的web應用伺服器,人們沒有其他的選擇。不可否認,apach...

多執行緒詳細分析與介紹

傳統的實現多執行緒的方法有兩種 1.通過繼承thread,並重寫run方法來實現 2.通過實現runnable介面重寫run方法來實現 下面舉例說明兩種實現方法 public class mythread extends thread publicvoid run 要實現的 public class...