線程間同步方式
引言
不同線程間對臨界區(qū)資源的訪問可能會引起常見的并發(fā)問題蔽挠,我們希望線程原子式的執(zhí)行一系列指令,但由于單處理器上的中斷瓜浸,我們必須想一些其他辦法以同步各線程澳淑,本文就來介紹一些線程間的同步方式。
互斥鎖
互斥鎖(又名互斥量)插佛,強調(diào)的是資源的訪問互斥:互斥鎖是用在多線程多任務(wù)互斥的杠巡,當(dāng)一個線程占用了某一個資源,那么別的線程就無法訪問雇寇,直到這個線程unlock氢拥,其他的線程才開始可以利用這個資源。
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
注意理解trylock函數(shù)锨侯,這與普通的lock不一樣嫩海,普通的lock函數(shù)在資源被鎖住時會被堵塞,直到鎖被釋放识腿。
trylock函數(shù)是非阻塞調(diào)用模式出革,也就是說如果互斥量沒被鎖住,trylock函數(shù)將把互斥量加鎖渡讼,并獲得對共享資源的訪問權(quán)限; 如果互斥量被鎖住了骂束,trylock函數(shù)將不會阻塞等待而直接返回EBUSY,表示共享資源處于忙狀態(tài)成箫,這樣就可以避免死鎖或餓死等一些極端情況發(fā)生展箱。
探究底層,實現(xiàn)一個鎖
實現(xiàn)一個鎖必須需要硬件的支持蹬昌,因為我們必須要保證鎖也是并發(fā)安全的混驰,這就需要硬件支持以保證鎖內(nèi)部是原子實現(xiàn)的。
很容易想到維護一個全局變量flag,當(dāng)該變量為0時栖榨,允許線程加鎖昆汹,并設(shè)置flag為1;否則婴栽,線程必須掛起等待满粗,直到flag為0.
typedef struct lock_t {
int flag;
}lock_t;
void init(lock_t &mutex) {
mutex->flag = 0;
}
void lock(lock_t &mutex) {
while (mutex->flag == 1) {;} //自旋等待變量為0才可進入
mutex->flag = 1;
}
void unlock(lock_t &mutex) {
mutex->flag = 0;
}
這是基于軟件的初步實現(xiàn),初始化變量為0愚争,線程自旋等待變量為0才可進入映皆,這看上去似乎并沒有什么毛病,但是仔細思考轰枝,這是有問題的:
當(dāng)線程恰好通過while判定時陷入中斷捅彻,此時并未設(shè)置flag為1,另一個線程闖入鞍陨,此時flag仍然為0步淹,通過while判定進入臨界區(qū),此時中斷湾戳,回到原線程贤旷,原線程繼續(xù)執(zhí)行广料,也進入臨界區(qū)砾脑,這就造成了同步問題。
在while循環(huán)中艾杏,僅僅設(shè)置mutex->flag == 1是不夠的韧衣,盡管他是一個原語,我們必須有更多的代碼购桑,同時畅铭,當(dāng)我們引入更多代碼時,我們必須保證這些代碼也是原子的勃蜘,這就意味著我們需要硬件的支持硕噩。
我們思考上面代碼為什么會失敗缭贡?原因是當(dāng)退出while循環(huán)時炉擅,在這一時刻flag仍然為0,這就給了其他線程搶入臨界區(qū)的機會阳惹。
解決辦法也很直觀 —— 在退出while時谍失,借助硬件支持保證flag被設(shè)置為1。
測試并加鎖(TAS)
我們編寫如下函數(shù):
int TestAndSet(int *old_ptr, int new) {
int old = *old_ptr;
*old_ptr = new;
return old;
}
同時重新設(shè)置while循環(huán):
void lock(lock_t &mutex) {
while (TestAndSet(mutex->flag莹汤, 1) == 1) {;} //自旋等待變量為0才可進入
mutex->flag = 1;
}
這里快鱼,我們借助硬件,保證TestAndSet函數(shù)是原子執(zhí)行的,現(xiàn)在鎖可以正確的使用了抹竹。當(dāng)flag為0時线罕,我們通過while測試時已經(jīng)將flag設(shè)置為1了,其他線程已經(jīng)無法進入臨界區(qū)窃判。
比較并交換(CAS)
我們編寫如下函數(shù):
int CompareAndSwap(int *ptr, int expected, int new) {
int actual = *ptr;
if (actual == expected) {
*ptr = new;
}
return actual;
}
同樣的闻坚,硬件也應(yīng)該支持CAS原語以保證CAS內(nèi)部也是安全的,現(xiàn)在重新設(shè)置while:
void lock(lock_t &mutex) {
while (CompareAndSwap(mutex->flag兢孝, 0窿凤, 1) == 1) {;} //自旋等待變量為0才可進入
mutex->flag = 1;
}
現(xiàn)在鎖可以正確的使用了,當(dāng)flag為0時跨蟹,我們通過while測試時已經(jīng)將flag設(shè)置為1了雳殊,其他線程已經(jīng)無法進入臨界區(qū)。
此外你可能發(fā)現(xiàn)CAS所需要更多的寄存器窗轩,在將來研究synchronozation
時夯秃,你會發(fā)現(xiàn)它的妙處。
另一個問題痢艺,過多的自旋仓洼?
你可能發(fā)現(xiàn)了,盡管一個線程未能獲得鎖堤舒,其仍然在不斷while循環(huán)以占用CPU資源色建,一個辦法就是當(dāng)線程未能獲得鎖,進入休眠以釋放CPU資源(條件變量)舌缤,當(dāng)一個線程釋放鎖時箕戳,喚醒一個正在休眠的線程。不過這樣也有缺點国撵,進入休眠與喚醒一個鎖也是需要時間的陵吸,當(dāng)一個線程很快就能釋放鎖時,多等等是比陷入休眠更好的選擇介牙。
Linux下采用兩階段鎖壮虫,第一階段線程自旋一定時間或次數(shù)等待鎖的釋放,當(dāng)達到一定時間或一定次數(shù)時环础,進入第二階段囚似,此時線程進入休眠。
回到互斥鎖
互斥鎖提供了并發(fā)安全的基本保證喳整,互斥鎖用于保證對臨界區(qū)資源的安全訪問谆构,但何時需要訪問資源并不是互斥鎖應(yīng)該考慮的事情,這可能是條件變量該考慮的事情框都。
如果線程頻繁的加鎖和解鎖搬素,效率是非常低效的呵晨,這也是我們必須要考慮到的一個點。
信號量
信號量并不用來傳送資源熬尺,而是用來保護共享資源摸屠,理解這一點是很重要的,信號量 s 的表示的含義為同時允許訪問資源的最大線程數(shù)量粱哼,它是一個全局變量季二。
在進程中也可以使用信號量,對于信號量的理解進程中與線程中并無太大差異揭措,都是用來保護資源胯舷,關(guān)于更多信號量的理解參見這篇文章: JavaLearningNotes/進程間通信方式。
來考慮一個上面簡單的例子:兩個線程同時修改而造成錯誤绊含,我們不考慮讀者而僅僅考慮寫者進程桑嘶,在這個例子中共享資源最多允許一個線程修改資源,因此我們初始化 s 為1躬充。
開始時逃顶,A率先寫入資源,此時A調(diào)用P(s)充甚,將 s 減一以政,此時 s = 0,A進入共享區(qū)工作伴找。
此時盈蛮,線程B也想進入共享區(qū)修改資源,它調(diào)用P(s)發(fā)現(xiàn)此時s為0疆瑰,于是掛起線程眉反,加入等待隊列。
A工作完畢穆役,調(diào)用V(s),它發(fā)現(xiàn)s為0并檢測到等待隊列不為空梳凛,于是它隨機喚醒一個等待線程耿币,并將s加1,這里喚醒了B韧拒。
B被喚醒淹接,繼續(xù)執(zhí)行P操作,此時s不為0叛溢,B成功執(zhí)行將s置為0并進入工作區(qū)塑悼。
此時C想要進入工作區(qū)......
可以發(fā)現(xiàn),在無論何時只有一個線程能夠訪問共享資源楷掉,這就是信號量做的事情厢蒜,他控制進入共享區(qū)的最大進程數(shù)量,這取決于初始化s的值。此后斑鸦,在進入共享區(qū)之前調(diào)用P操作愕贡,出共享區(qū)后調(diào)用V操作,這就是信號量的思想巷屿。
有名信號量
有名信號量以文件的形式存在固以,即時是不同進程間的線程也可以訪問該信號量,因此可以用于不同進程間的多線程間的互斥與同步嘱巾。
創(chuàng)建打開有名信號量
sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
//成功返回信號量指針憨琳;失敗返回SEM_FAILED,設(shè)置errno
name是文件路徑名旬昭,value設(shè)置為信號量的初始值栽渴。
關(guān)閉信號量,進程終止時稳懒,會調(diào)用它
int sem_close(sem_t *sem); //成功返回0闲擦;失敗返回-1,設(shè)置errno
刪除信號量场梆,立即刪除信號量名字墅冷,當(dāng)其他進程都關(guān)閉它時,銷毀它
int sem_unlink(const char *name);
等待信號量或油,測試信號量的值寞忿,如果其值小于或等于0,那么就等待(阻塞)顶岸;一旦其值變?yōu)榇笥?就將它減1腔彰,并返回
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
//成功返回0;失敗返回-1辖佣,設(shè)置errno
當(dāng)信號量的值為0時霹抛,sem_trywait立即返回,設(shè)置errno為EAGAIN卷谈。如果被某個信號中斷杯拐,sem_wait會過早地返回,設(shè)置errno為EINTR
發(fā)出信號量世蔗,給它的值加1端逼,然后喚醒正在等待該信號量的進程或線程
int sem_post(sem_t *sem);
成功返回0;失敗返回-1污淋,不會改變它的值顶滩,設(shè)置errno,該函數(shù)是異步信號安全的寸爆,可以在信號處理程序里調(diào)用它
無名信號量
無名信號量存在于進程內(nèi)的虛擬空間中礁鲁,對于其他進程是不可見的盐欺,因此無名信號量用于一個進程體內(nèi)各線程間的互斥和同步,使用如下API:
(1)sem_init 功能:用于創(chuàng)建一個信號量,并初始化信號量的值救氯。 函數(shù)原型:
int sem_init (sem_t* sem, int pshared, unsigned int value);
函數(shù)傳入值: sem:信號量找田。pshared:決定信號量能否在幾個進程間共享。由于目前LINUX還沒有實現(xiàn)進程間共享信息量着憨,所以這個值只能取0墩衙。
(2)其他函數(shù)
int sem_wait (sem_t* sem);
int sem_trywait (sem_t* sem);
int sem_post (sem_t* sem);
int sem_getvalue (sem_t* sem);
int sem_destroy (sem_t* sem);
功能:
sem_wait和sem_trywait相當(dāng)于P操作,它們都能將信號量的值減一甲抖,兩者的區(qū)別在于若信號量的值小于零時漆改,sem_wait將會阻塞進程,而sem_trywait則會立即返回准谚。
sem_post相當(dāng)于V操作挫剑,它將信號量的值加一,同時發(fā)出喚醒的信號給等待的線程柱衔。
sem_getvalue 得到信號量的值樊破。
sem_destroy 摧毀信號量。
如果某個基于內(nèi)存的信號量是在不同進程間同步的唆铐,該信號燈必須存放在共享內(nèi)存區(qū)中哲戚,這要只要該共享內(nèi)存區(qū)存在,該信號燈就存在艾岂。
總結(jié)
無名信號量存在于內(nèi)存中顺少,有名信號量是存在于磁盤上的,因此無名信號量的速度更快王浴,但只適用于一個獨立進程內(nèi)的各線程脆炎;有名信號量可以速度欠缺,但可以使不同進程間的線程同步氓辣,這是通過共享內(nèi)存實現(xiàn)的秒裕,共享內(nèi)存是進程間的一種通信方式。
你可能發(fā)現(xiàn)了筛婉,當(dāng)信號量的值s為1時架谎,信號量的作用于互斥鎖的作用是一樣的唾糯,互斥鎖只能允許一個線程進入臨界區(qū),而信號量允許更多的線程進入臨界區(qū)归敬,這取決于信號量的值為多少响蓉。
條件變量
什么是條件變量硕勿?
在互斥鎖中,線程等待flag為0才能進入臨界區(qū)枫甲;信號量中P操作也要等待s不為0......在多線程中源武,一個線程等待某個條件是很常見的扼褪,互斥鎖實現(xiàn)一節(jié)中,我們采用自旋是否有一個更專門粱栖、更高效的方式實現(xiàn)條件的等待话浇?
它就是條件變量!條件變量(condition variable)是利用線程間共享的全局變量進行同步的一種機制闹究,主要包括兩個動作:一個線程等待某個條件為真幔崖,而將自己掛起;另一個線程設(shè)置條件為真渣淤,并通知等待的線程繼續(xù)赏寇。
由于某個條件是全局變量,因此條件變量常使用互斥鎖以保護(這是必須的价认,是被強制要求的)嗅定。
條件變量與互斥量一起使用時,允許線程以無競爭的方式等待特定的條件發(fā)生用踩。
線程可以使用條件變量來等待某個條件為真渠退,注意理解并不是等待條件變量為真,條件變量(cond)是在多線程程序中用來實現(xiàn)"等待-->喚醒"邏輯常用的方法脐彩,用于維護一個條件(與是條件變量不同的概念)碎乃,并不是說等待條件變量為真或為假。條件變量是一個顯式的隊列丁屎,當(dāng)條件不滿足時荠锭,線程將自己加入等待隊列,同時釋放持有的互斥鎖晨川;當(dāng)一個線程改變條件時证九,可以喚醒一個或多個等待線程(注意此時條件不一定為真)。
在條件變量上有兩種基本操作:
- 等待(wait):一個線程處于等待隊列中休眠共虑,此時線程不會占用互斥量愧怜,當(dāng)線程被喚醒后,重新獲得互斥鎖(可能是多個線程競爭)妈拌,并重新獲得互斥量拥坛。
- 通知(signal/notify):當(dāng)條件更改時,另一個線程發(fā)送通知以喚醒等待隊列中的線程尘分。
相關(guān)函數(shù)
1. 初始化
條件變量采用的數(shù)據(jù)類型是pthread_cond_t,猜惋,在使用之前必須要進行初始化,,這包括兩種方式:
靜態(tài): 直接設(shè)置條件變量cond為常量PTHREAD_COND_INITIALIZER培愁。
動態(tài): pthread_cond_init函數(shù), 是釋放動態(tài)條件變量的內(nèi)存空間之前, 要用pthread_cond_destroy對其進行清理著摔。
int pthread_cond_init(pthread_cond_t *restrict cond, pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
//成功則返回0, 出錯則返回錯誤編號.
注意:條件變量占用的空間并未被釋放。
cond:要初始化的條件變量定续;attr:一般為NULL谍咆。
2. 等待條件
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restric mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict timeout);
//成功則返回0, 出錯則返回錯誤編號.
這兩個函數(shù)分別是阻塞等待和超時等待禾锤,堵塞等到進入等待隊列休眠直到條件修改而被喚醒;超時等待在休眠一定時間后自動醒來摹察。
進入等待時線程釋放互斥鎖恩掷,而在被喚醒時線程重新獲得鎖。
3. 通知條件
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
//成功則返回0, 出錯則返回錯誤編號.
這兩個函數(shù)用于通知線程條件已被修改供嚎,調(diào)用這兩個函數(shù)向線程或條件發(fā)送信號黄娘。
用法與思考
條件變量用法模板:
pthread_cond_t cond; //條件變量
mutex_t mutex; //互斥鎖
int flag; //條件
//A線程
void threadA() {
Pthread_mutex_lock(&mutex); //保護臨界資源,因為線程會修改全局條件flag
while (flag == 1) //等待某條件成立
Pthread_cond_wait(&cond, &mutex); //不成立則加入隊列休眠查坪,并釋放鎖
....dosomthing
....change flag //條件被修改
Pthread_cond_signal(&cond); //發(fā)送信號通知條件被修改
Pthread_mutex_unlock(&mutex); //放松信號后盡量快速釋放鎖寸宏,因為被喚醒的線程會嘗試獲得鎖
}
//B線程
void threadB() {
Pthread_mutex_lock(&mutex); //保護臨界資源
while (flag == 0) //等待某條件成立
Pthread_cond_wait(&cond, &mutex); //不成立則加入隊列休眠,并釋放鎖
....dosomthing
....change flag //條件被修改
Pthread_cond_signal(&cond); //放松信號后盡量快速釋放鎖偿曙,因為被喚醒的線程會嘗試獲得鎖
Pthread_mutex_unlock(&mutex);
}
通過上面的一個例子氮凝,應(yīng)該很好理解條件變量與條件的區(qū)別,條件變量是一個邏輯望忆,它并不是while循環(huán)里的bool語句罩阵,我相信很多初學(xué)者都有這么一個誤區(qū),即條件變量就是線程需要等待的條件启摄。條件是條件稿壁,線程等待條件而不是等待條件變量,條件變量使得線程更高效的等待條件成立歉备,是一組等待 — 喚醒 的邏輯傅是。
注意這里仍然要使用while循環(huán)等待條件,你可能會認(rèn)為明明已經(jīng)上鎖了別的線程無法強入蕾羊。事實上當(dāng)線程A陷入休眠時會釋放鎖喧笔,而當(dāng)其被喚醒時,會嘗試獲得鎖龟再,而正在其嘗試獲得鎖時书闸,另一個線程B現(xiàn)在嘗試獲得鎖,并且搶到鎖進入臨界區(qū)利凑,然后修改條件浆劲,使得線程A的條件不再成立,線程B返回哀澈,此時線程A終于獲得鎖了牌借,并進入臨界區(qū),但此時線程A的條件根本已經(jīng)不成立割按,他不該進入臨界區(qū)走哺!
此外,被喚醒也不代表條件成立了哲虾,例如上述代碼線程B修改flag = 3丙躏,并且喚醒線程A,這里線程A的條件根本不符合束凑,所以必須重復(fù)判定條件晒旅。互斥鎖和條件變量的例子告訴我們:在等待條件時汪诉,總是使用while而不是if废恋!
陷入休眠的線程必須釋放鎖也是有意義的,如果不釋放鎖扒寄,其他線程根本無法修改條件鱼鼓,休眠的線程永遠都不會醒過來!
實踐——讀寫者鎖
讀取鎖——共享该编;寫入鎖——獨占迄本。即:讀線程可以加多個,而寫線程只能有一個课竣,并且讀者和寫者不能同時工作嘉赎。
這種情況下由于允許多個讀者共享臨界區(qū)效率會高效,我們來考慮實現(xiàn)的問題:只允許一個寫者工作于樟,那么一定需要一個互斥量或二值信號量來維護公条,我們稱為寫者鎖;由于讀者和寫者不能同時工作迂曲,第一個讀者必須嘗試獲取寫者鎖靶橱,而一旦讀者數(shù)量大于1,則后續(xù)讀者無須嘗試獲取寫者鎖而可直接進入路捧,注意到這里存在全局讀者數(shù)量變量关霸,因此讀者也需要一個鎖以維護全局讀者數(shù)量,最后一個退出的讀者必須負責(zé)釋放讀者鎖鬓长。
知曉原理谒拴,快去自己動手實現(xiàn)一個讀寫者鎖把!
Linux下通過pthread_rwlock
函數(shù)族實現(xiàn)涉波。