前言
我的上一篇博客的案例中,請求鎖的線程如果發現鎖已經被其他線程占用,它是通過自旋的方式來等待的,也就是不斷地嘗試直到成功。本篇就討論一下另一種方式,那就是掛起以等待喚醒。
注:相關代碼都來自《Operating System: Three Easy Pieces》這本書。
自旋哪里不好?
先說明一下,自旋也有它的好處,不過這里先不講,我們先講它可能存在哪些問題。
我們考慮一個極端的場景,某個電腦只有一個CPU,這時候有2個線程競爭鎖,線程A獲得了鎖,進入臨界區,開始執行臨界區的代碼(由於只有一個CPU,線程A在執行的時候,線程B只能在就緒隊列中等待)。結果線程A還沒執行完臨界區的代碼,時間片就用完了,於是發生上下文切換,線程A被換了出去,現在開始執行線程B,線程B就開始嘗試獲取鎖。
這時候尷尬的事情就來了,擁有鎖的線程沒在運行,也就不能釋放鎖。而占據CPU的線程由於獲取不到鎖,就只能自旋直到用完它的時間片。
這還只是2個線程的情況,如果等待的線程有100多個呢,那在輪詢調度器的場景下,線程A是不是要等到這100多個線程全部空轉完才能運行,這浪費可就大了!
用yield()讓出CPU怎么樣?
yield()方法是把調用線程之間切出,放回就緒隊列。這個方法與前面的不同就在於,當線程B恢復執行的時候,它只會嘗試一次,如果失敗,則直接退出,而不會用完它的整個時間片。也就是說被調度的線程最多只會嘗試一次。這樣雖然會比自旋好一點。但是開銷還是不小,對於100多個等待線程的情況,每個都要進行一遍run-and-yield操作。上下文切換的開銷也是不容小覷的。
直接掛起,等待喚醒
前面有之所以還會有過多的上下文切換,就是因為等待的線程還是會不斷嘗試,只是沒之前那么頻繁罷了。
那不讓這些等待線程執行不就好了?
可以啊,只需要將這些線程移出就緒隊列,它們就不會被OS調度,也就不會被運行。
掛起是可以了,還得想想誰來喚醒,怎么喚醒?
喚醒操作肯定由釋放鎖的線程處理。另一方面,我們把線程掛起的時候,肯定得用一個數據結構把這個線程的信息記錄下來,不然要喚醒的時候都不知道該喚醒誰。而這個數據結構肯定得跟鎖對象關聯起來,這樣釋放鎖的線程也就知道該從哪里拿這些數據。
typedef struct __lock_t { int flag; //標識,鎖是否被占用 int guard; //守護字段 queue_t *q; //等待隊列,用於存儲等待的線程信息 } lock_t; void lock_init(lock_t *m) { m->flag = 0; m->guard = 0; queue_init(m->q); } void lock(lock_t *m) { while(TestAndSet(&m->guard, 1) == 1) ;//通過自旋獲得guard if (m->flag == 0) { m->flag = 1; m->guard = 0; } else { queue_add(m->q, gettid()); m->guard = 0; //注意:在park()之前調用 park(); //park()調用之前,線程已經成功加入隊列 } } void unlock(lock_t *m) { while(TestAndSet(&m->guard, 1) == 1) ;//通過自旋獲取guard if(queue_empty(m->q)) //如果沒有等待的線程,則將鎖標識為“空閑” m->flag = 0; else unpark(queue_remove(m->q)); //喚醒一個等待線程,此時鎖標識仍為“已占用” m->guard = 0; }
park()與unpark(threadID)
park()與unpark(threadID)是Solaris系統提供的原語,用於掛起和恢復線程。其他系統一般也會提供,但是細節可能有所不同。
park() => 將當前調用線程掛起
uppark(threadID) => 根據線程ID喚醒指定線程。
guard字段的用途
我在看這段代碼的時候有一個疑問,那就是這個queue_t是在哪里定義的,它到底是什么樣子?這個隊列內部是不是要做同步操作?不同步的話, 多個線程同時訪問,隊列的數據結構就可能被破壞。實際上,仔細看代碼就會發現,在操作隊列的時候,線程需要先獲得guard。也就是說,同一時刻只能有一個線程能夠訪問隊列。所以這個隊列是安全的,它自身並不需要提供同步。所以,書上才沒有貼出源碼。隨便一個隊列實現就可以了。
實際上guard字段用於控制多線程對lock對象的訪問,同一時刻只能有一個線程能夠對lock對象的其他信息(除guard字段外)進行修改。
上述代碼存在的問題
由代碼可知,當guard被釋放的時候,其他線程就能訪問Lock對象了。那就可能出現一種情況,即釋放了guard,但還沒來得及執行park()就發生了上下文切換。這個時候存在什么問題呢,我們來看下圖:
由於上下文切換的緣故,Thread A 已經加入了等待隊列,但並沒有執行掛起操作。結果占有鎖的線程釋放的時候,剛好從隊列中取出Thread A,Thread A被喚醒,放入就緒隊列,等到下次調度的時候執行。Thread A恢復,繼續向下執行,調用park()方法。結果就是Thead A被永久地掛起!!!。因為這個時候它已經從等待隊列中移除了,誰也不知道它被掛起了。
OS提供的解決方法
OS提供一個setpark()函數來標識某個線程將要執行park()操作。如果在這個線程(比如Thread A)執行park()操作之前,其他線程(如Thread B)對其執行了unpark(threadID)方法,則該線程(Thread A)在執行park()會立即返回。更改如下:
... queue_add(m->q, gettid()); setpark(); m->guard=0; park(); ...
PS:實際上這個setpark()函數應該也只是在底層的Thread對象中設置了一個flag,park()函數內會查看一下這個flag。只不過這個底層的Thread對象我們訪問不到罷了。