一個無鎖消息隊列引發的血案(五)——RingQueue(中) 休眠的藝術


目錄

(一)起因 (二)混合自旋鎖 (三)q3.h 與 RingBuffer 

(四)RingQueue(上) 自旋鎖  (五)RingQueue(中) 休眠的藝術

(六)RingQueue(中) 休眠的藝術 [續]

開篇

  這幾天研究了一下 disruptor .Net版,由於.Net版跟進不及時,網上只有 v2.10 版。沒仔細研究,但可以肯定的是跟最新的Java版 disruptor 3.30 是有不少區別的。我也用這個 2.10 的.Net版本寫了跟我們的問題相似的測試程序,得到的結果跟 Java 版的 disruptor 3.30 差不多。我還下載了 C++ 版的,不過看了一下,就扔一旁了,一個原因是版本太低,另一個原因是動不動就 boost,動不動就C++11,我是崇尚輕便、依賴小的,真要用我還不如自己寫一個,所以我也懶得用他們來測,我已經在着手把 disruptor 3.3 的原理搬到 C++ 上來。

  為了方便,我把修改的 disruptor .Net 版上傳到了github:https://github.com/shines77/Disruptor.Net,其中 VS2013-Net 4.5 是VS 2013使用 .Net Framework 4.5的版本,因為原版使用的是 .Net 4.0,但升級到 4.5 某些文件要修改一下,所以分成了兩個版本。經過我的調整,.Net 4.5的版本更細致一些,x64 和 x86 是嚴格分開的,本質上並沒有大的區別。disruptor .Net 2.10 原版的 github 地址是:https://github.com/disruptor-net/Disruptor-net。如果你不想下載完整的項目,也可以在這里 nPnCRingQueueTest.cs 查看測試代碼,在 RingQueue\disruptor\csharp 目錄下面。

RingQueue

   我們先來看 RingQueue,在上一篇里其實有些 RingQueue 測試的截圖,不知道有沒有細心的讀者發現誰快誰慢,快多少?我們來看一下截圖:

 

我們可以看到,混合自旋鎖 spin2_push() 最快,而使用操作系統的互斥鎖版本 mutex_push() 最慢,前者是后者的 3.5 倍左右。

經過多次測試我們發現,其實 spin_push(),spin1_push(),spin2_push() 速度差不多,但 spin2_push() 相對穩定一些,由於多線程程序受影響的因素很多,所以實際測試的時候,會發現每次結果都不太一樣,但總體來看,spin2_push() 是里面最穩定的。

那么,我們來看看 q3.h 的測試結果:

我們發現,q3.h 比 操作系統的互斥鎖版本還要慢一些,細心的讀者肯定會問,為什么 q3.h 測試的時候只用了4個線程?而前面卻用了8個線程。這是由於 q3.h 本身代碼的限制,當總線程數大於實際CPU核心數時會很慢很慢,因為我的CPU是4核的,所以它只能用 PUSH_CNT = 2, POP_CNT = 2 來測試,實際上,即使前面那些測試也用2個 push 線程和2個 pop 線程,結果還是要比 q3.h 快的,有興趣的朋友可以自己試一下。此外,q3.h 通過修改是可以解決這個限制的,這是后話。

綜上可得,我們的混合自旋鎖 spin2_push() 的效率還是不錯的,是 q3.h4.18 倍左右,spin2_push() 的實際用時的最小值其實比 716 毫秒要小很多,甚至最快的時候可以達到 580 毫秒左右。

這里重新說明一下我的測試環境:

CPU:    Intel Q8200 2.4G / 4核

系統:    Windows 7 sp1 / 64bit

內存:    4G / DDR2 1066 (雙通道)

編譯平台: Visual Studio 2013 Ultimate update 2 (自帶的 cl.exe)

以上的結果都是在 x86 模式下測得的(64位系統是兼容x86模式的),由於 x64 模式下 Sleep(1) 效率有問題,這將導致系統互斥鎖版本變得非常慢(要十幾秒或幾十秒),估計系統互斥鎖也用了類似 Sleep(1) 的代碼。我們用到了 Sleep(1) 代碼的 spin_push(),spin1_push(),spin2_push() 也會受一點影響,不過影響很小很小。至於為什么會這樣還不太清楚,所以沒用 x64 模式來測。

spin2_push()

   為什么 spin2_push() 這么快呢?通過這些天的研究和比較,我分別測試了 Java 版的 disruptor 3.30 和 .Net 版的 disruptor 2.10,下面給出兩者的測試結果:

Java 版的 disruptor 3.30:

.Net 版的 disruptor 2.10:

這里你一定想知道為什么 disruptor 這么慢?其實我也很想知道。。。

  我們注意到,disruptor 給我們帶來了新的視野,多種多樣的場景(1P + 1C,1P + 多C,多P + 1C),以及各式各樣的消息處理方式,是值得我們學習的一個好東西。尤其是在單生產者或單消費者模式下,是可以簡化的,這是我們原來沒考慮過的,這里也暫不考慮,我們還是以多生產者+多消費者模式來討論,因為單生產者或單消費者模式相對簡單很多。

  disruptor 在單生產者+單消費者(1 producer + 1 comsumer)的時候,還是非常快的,這個時候的確是 lock-free 的,其實此時不僅僅是 lock-free 的,而且還是 wait-free 的,除了隊列滿了或空了的時候要等待以外(這個每種方法都是必須等待的)。不過,由於 disruptor 並未完全為 1P + 1C 模式特別優化,只是考慮了 1P + 多C,所以其消費者並不是 wait-free 的。雖然 disruptor 的 1P + 1C 模式很快,但其實還可以更快。而實際測試來看,1P + nC 模式(單生產者+多消費者)也不見得快,實測中 2P + 2C 倒是比 1P + 3C 還要快一些,原因不明。

  這里可以參考《Disruptor使用指南》一文,disruptor 為我們提供了很多場景,最常用的是 EventHandler,EventHandler 是讓每個 EventProcessor 從隊列中取出消息並都處理一遍,即消息是被重復處理的,由於我們要實現的是多生產者、多消費者的 FIFO(先入先出)的消息隊列,所以 EventHandler 不符合。參考該文的敘述,我們知道只有 WorkerPool + WorkHandler 模式才符合我們的場景:

 

這里還要指出的一點是,就在寫本文的同時,我在看單生產者 SingleProducerSequencer.java 的 publish() 函數時(其中的 cursor 是一個Sequence類),發現:

轉到 Sequence.java 里,Sequence::set() 函數定義如下:(其實之前我一直以為 set 就是單純的一個寫入操作而已,而且也不懂 putOrderLong() 具體是什么意思)

通過網上的搜索,我意外的發現,其實 UNSAFE.putOrderedLong(this, VALUE_OFFSET, value); 這個函數內部使用了一個全局自旋鎖來保證寫入/存儲的順序,可參考《源碼剖析之sun.misc.Unsafe》。

不僅僅如此,Java 所有 Unsafe 的原子操作(CompareAndSwap等)里面都使用了這個spinlock,具體可以參考該文。這個spinlock是一個 static 的靜態變量,所以所有這些操作都可能會導致一定的自旋,雖然這個自旋等待的時間也許很短很短,但這跟單一的CAS局部循環是不一樣的,因為單一的CAS等原子操作只要處理的地址不一樣,鎖住的 Cache Line 就跟別的地方的原子操作的地址很可能是不一樣的,被阻塞而互相影響的可能性比較小。而 spinlock 鎖住的是同一條 Cache Line,要阻塞都一起阻塞。不過這個影響很難評估,但至少這不是一個好的做法。

spinlock 的代碼是這樣的:

所以,不管 disruptor 想出多么好的 lock-free, wait-free 算法,其內部的實現里必然包含了“鎖”, 所以永遠不可能是“無鎖”的。其實在 disruptor 3.30 (Java版) 里,即使是在多生產者+多消費者模式里,disruptor 還真的實現了 lock-free 的方法(如果不算Unsafe 的鎖的話),但是多用了一個跟 BUFFER_SIZE 一樣大小的數組來記錄 Flag,然后每次生產者還要在一個包含所有消費者的序號數組(記錄每個消費者已讀取的序號)里找出一個最小的來,這個消耗也不見得是很小的,雖然可能會比非 lock-free 的方法要快一些。

至於為什么這么慢,還在進一步研究中,可能有一部分還是跟語言本身有點關系。而且 disruptor 還存在一個問題,就是線程越多越慢,而 spin2_push() 線程越多則越快,不過也有個極限,但是這符合我們常說的線程數最好設置為 CPU總核心數 的兩倍左右的經驗值。此外,關於 disruptor 我會另外寫一篇文章詳細討論。

跟 q3.h 比

  我們在第四篇 (四)RingQueue(上) 自旋鎖 里面有提到,我們之所以選擇自旋鎖是因為 q3.h 可以認為是由兩個 lock-free 結構和兩個 “鎖” 構成的,所以我們不妨直接用一個 “自旋鎖” 。比如 push(),CAS循環領取序號的時候,所有 push() 線程都會在這上面競爭,競爭主要在 q->head.first 所在的 Cache Line 的被鎖而緩存失效的問題,而 head = q->head.first; tail = q->tail.second; 也會受其他地方寫入該值時導致緩存失效。然后是確認提交成功后的序列化過程,這個過程是一個真正的“鎖”,會導致別的線程被阻塞。而且 q3.h 的失誤就在於沒在這個地方做合理的休眠,這就導致了當總的線程數超過核心數的時候,某個線程阻塞了別的線程,而這個線程又得不到時間片,從而導致livelock,雖然最終該線程還是可以獲得時間片,但是其他線程已經白白的等了很久了,而且沒有休眠,CPU占用一直是很高的,整個過程又很慢。所以解決這個問題,就是在這個“鎖”的過程中,適當的自旋、yield()或休眠一下。這個“鎖”本身不會造成緩存行失效,但是如果 q->head.second 被寫入新值的時候,還是會導致偽共享(False Sharing)的問題。同理,pop() 的分析也類似。這里跟 spin2_push() 來比的話,有點是產生競爭的線程數少了一半(假設生產者和消費者線程是一樣多的),缺點是競爭的點多了,自旋鎖競爭的點只有一個,就是鎖的循環上,只要進了鎖,由於只有一個線程能獲得寫入和讀取的權力,此時對RingBuffer內部的操作並不會產生偽共享(False Sharing)問題,因為“一夫當關,萬夫莫開”。

  q3.h 的另一個問題是,在第三篇文章里也說過了,就是 head.first,head.second,tail.first,tail.second 四個變量應該在 4 條不同的Cache Line(緩存行)上,以減少緩存失效的問題(即偽共享)。這點 disruptor 是做得很好的,也許 q3.h 解決了這些毛病以后會快一點,但不知道會快多少,以后有時間我會加進去試一下,不過肯定是比 disruptor 現在的版本改成 C++ 是要慢的,這可以肯定。

spin2_push()

  那么,現在我們來看看我們的混合自旋鎖 spin2_push(),可查閱 RingQueue.h 中的 spin2_push_() 函數:

  在上一篇的末尾,我提到了 Thread.Yield(),yield 是讓步、低頭、出讓的意思,也就是說把CPU的主動權交給別的線程。前面也說到 DengHe.Net (老鄧) 曾經貼過的用反射工具查看的 C# 源碼,其實不是 Thread.Yield(),而應該是 System.Threading 下面的 SpinWait() 類。這里注意,不是 Thread.SpinWait(n),Thread.SpinWait(n) 其實是一個自旋 n 次的循環,而 Thread.Yield() 被定義為 Windows API SwitchToThread(),spin2_push() 里面的 jimi_yield() 在 Windows 上就是定義為 SwitchToThread(),該函數是一個很特殊的函數,在上圖的 jimi_yield() 的注釋里我已經寫了,它的功能是讓步給該線程所在的CPU核心上的別的等待線程(但不能切換到別的CPU核心上的等待線程),后面我們也會詳細討論。

  而我們提到的 SpinWait() 類則跟我們這個 spin2_push() 長得很像,其實我們是模仿它的。SpinWait.cs 的源碼可以在網上查到,我已經上傳至 github 上:SpinWait.cs,在 RingQueue 項目里的 \douban 目錄下面。

  我們來看一下 SpinWait.cs 長什么樣子?我們來看兩個比較重要的地方:

還有 SpinOne() :

 

  你可以看到,我對 SpinOne() 做了幾點改進,SLEEP_0_EVERY_HOW_MANY_TIMES 改為了4,SLEEP_1_EVERY_HOW_MANY_TIMES 改為了64,這是執行 Sleep(0) 和 Sleep(1) 的間隔值,這是為了計算 % 是使用位運算效率高一點,其實這個地方用 % 也沒多大區別,即用 5 和 20 也是可以的,不過根據我的經驗 20 可以稍微設大一點,尤其是在 x64 模式下,前面我也稍微提到過原因,這是一個經過多次測試后得出的經驗值,具體的可自己試情況調整。

  其實關鍵的地方是自旋次數的設定,你可以看到 SpinWait.cs 用的閥值是 10,而 spin2_push() 里面有的是 1(設置成2也可以),其實這是一個很關鍵的地方,這個地方決定了混合自旋鎖的性能,它是由你要鎖的區域進行的操作持續多長時間而決定的,鎖持有的時間短的話,我們自旋的次數就應該要少,而持有的時間長,則可以設大一點,但是不能太大,太大的化就會一直自旋而浪費 CPU 時間。因為我們與其讓它自旋很久,不如看看別的線程有沒有需要,有需要的話,我們先切換到別的線程,把時間片讓給它。如果別的線程也不需要時間片,重復一點的次數后,我們就讓線程進入休眠狀態,即Sleep(1)。這里 Sleep(0) 並不是休眠,而是切換到跟自己相同優先級或更高優先級的線程,后面我們會講,spin2_push() 的函數的截圖里也有詳細的注釋和說明。

  我們把 SpinWait.cs 中 SpinOne() 函數的這個策略稱之為 “休眠策略”,而如何更好的進行休眠,則是一種“休眠的藝術”。

  我們先來研究一下操作系統的 進程/線程 調度原理。

 

進程/線程調度

操作系統中,CPU 進程/線程調度有很多種策略,Unix 系統使用的是時間片算法,而 Windows 則屬於搶先式的。

Linux

在時間片算法中,所有的進程排成一個隊列。操作系統按照他們的順序,給每個進程分配一段時間,即該進程允許運行的時間。如果在時間片結束時進程還在運行,則CPU將被剝奪並分配給另一個進程。如果進程在時間片結束前掛起或結束,則CPU當即進行切換。調度程序所要做的就是維護一張就緒進程列表,當進程用完它的時間片后,它將被移到隊列的末尾。

Windows

所謂搶先式操作系統,就是說如果一個進程得到了 CPU 時間,除非它自己主動放棄使用 CPU,否則將完全霸占 CPU 。因此可以看出,在搶先式操作系統中,操作系統假設所有的進程都是“人品很好”的,會主動讓出 CPU 。

在搶先式操作系統中,假設有若干進程,操作系統會根據他們的優先級、飢餓時間(已經多長時間沒有使用過 CPU 了),給他們算出一個總的優先級來。操作系統就會把 CPU 交給總優先級最高的這個進程。當進程執行完畢或者自己主動掛起后,操作系統就會重新計算一次所有進程的總優先級,然后再挑一個優先級最高的把 CPU 控制權交給他。

分蛋糕

我們用分蛋糕的場景來描述這兩種算法。假設有源源不斷的蛋糕(源源不斷的時間),一副刀叉(一個CPU),10個等待吃蛋糕的人(10 個進程)。

如果是 Unix/Linux 操作系統來負責分蛋糕,那么他會這樣定規矩:每個人上來吃 1 分鍾,時間到了換下一個。最后一個人吃完了就再從頭開始。於是,不管這10個人是不是優先級不同、飢餓程度不同、飯量不同,每個人上來的時候都可以吃 1 分鍾。當然,如果有人本來不太餓,或者飯量小,吃了30秒鍾之后就吃飽了,那么他可以跟操作系統說:我已經吃飽了(掛起)。於是操作系統就會讓下一個人接着來,而剛吃飽的會被安排到隊伍的最后面。

如果是 Windows 操作系統來負責分蛋糕,那么場面就很有意思了。他會這樣定規矩:我會根據你們的優先級、飢餓程度去給你們每個人計算一個優先級。優先級最高的那個人,可以上來吃蛋糕——吃到你不想吃為止。等這個人吃完了,我再重新根據優先級、飢餓程度來計算每個人的優先級,然后再分給優先級最高的那個人。

這樣看來,這個場面就有意思了——有的人是年輕MM,而且長得漂亮,因此天生就擁有高優先級,於是她就可以經常來吃蛋糕。而另外一個人可能是個窮屌絲,而且長得也挫,所以優先級特別低,於是好半天了才輪到他一次(因為隨着時間的推移,他會越來越飢餓,因此算出來的總優先級就會越來越高,因此總有一天會輪到他的)。而且,如果一不小心讓一個大胖子得到了刀叉,因為他飯量大,可能他會霸占着蛋糕連續吃很久很久,導致旁邊的人在那里咽口水……

而且,還可能會有這種情況出現:操作系統現在計算出來的結果,5號漂亮MM總優先級最高,而且高出別人一大截。因此就叫5號來吃蛋糕。5號吃了一小會兒,覺得沒那么餓了,於是說“我不吃了”(掛起)。因此操作系統就會重新計算所有人的優先級。因為5號剛剛吃過,因此她的飢餓程度變小了,於是總優先級變小了;而其他人因為多等了一會兒,飢餓程度都變大了,所以總優先級也變大了。不過這時候仍然有可能5號的優先級比別的都高,只不過現在只比其他的高一點點——但她仍然是總優先級最高的啊。因此操作系統就會說:5號MM上來吃蛋糕……(5號MM心里郁悶,這不剛吃過嘛……人家要減肥……誰叫你長那么漂亮,獲得了那么高的優先級)。

以上參考自:《理解 Thread.Sleep 函數http://www.cnblogs.com/ILove/archive/2008/04/07/1140419.html

(樓主后記:本來是在寫此文之前搜索一下“C# SpinWait”,沒想到看到這么一篇文章,正好用來解釋操作系統的調度原理,本來我對 Linux 和 Windows 的調度區別並不是特別清楚,看了這篇文章后,正好彌補了這個問題。)

 

Thread.Sleep(n)

我們前面提到5號MM說“我吃飽了,先不吃了”(掛起),這是怎么實現的?在 C# 中,就是使用 Thread.Sleep(n) 實現的,類似的 Windows API 是:Sleep(n); ,Linux API 是:sleep(n); usleep(n); 等,而對於 Java 來說,就是 Thread.sleep(n); 。那么 Thread.Sleep(n) 是什么意思呢?在 Windows 下,意思就是,我先休息 n 毫秒,不參與 CPU 的競爭。投射到分蛋糕的場景上就是,你們先吃,我吃得夠飽了,未來半個小時內我都不想吃了,我讓出位置,先休息 30 分鍾再來。而在 Linux 下,意思也是一樣的,唯一的區別可能就是休眠完成后怎么歸隊的問題。

 

在進程/線程休眠足夠的時候后,重新參與 CPU 競爭的時候,在 Windows 下,是否是立刻把這個時間片分配給這個從休眠狀態重新歸隊進程/線程,還是按照它的飢餓程度(因為休眠了許久)和線程的優先級,即休眠完成后重新計算的總的優先等級,跟其他進程/線程一起參與競爭,以選出一個總的優先級別最高的,再把時間片分配給該總優先級最高的進程/線程,而不一定是該重新被喚醒的進程/線程。不過從合理性的角度,前者更合理,因為后者可能導致即使你結束休眠了,但是由於可能你總的優先級別怎么都搶不過別進程/線程,而導致休眠結束后,可能會被延遲很久才能重新獲得時間片,這看起來不太科學。但是 Windows 可能選擇的是后者,MSDN 里提到,即使是 Sleep(0) ,也可能不一定保證會立刻被執行,進程/線程只是被設置為准備就緒狀態。准備就緒的意思就是聲明我要重新參與 CPU 的競爭了。

 

關於這個細節,我們來看看 MSDN 中 MSDN: Sleep() function 是怎么說的:

中文大意是:

  在 Sleep 的間隔時間結束后,線程准備運行。如果你指定的休眠時間是 0 毫秒,那么線程將放棄剩余的時間片,但保持准備就緒狀態。需要注意的是這個准備就緒的線程不能保證立刻被運行。因此,線程可能不能立刻被運行直到休眠一定的時間間隔以后。想了解更詳細的信息,可以參閱 Scheduling Priorities (調度優先級)

 

 

 

 

Scheduling Priorities (調度優先級) 里提到:

中文大意是:

  操作系統對待相同優先級別的線程是平等的,操作系統以 輪叫循環 的方式分配時間片給最高優先級的線程。如果這個優先級別沒有准備就緒的線程,系統將以 輪叫循環 的方式分配時間片給下一個優先級別的線程。如果一個高優先級別的線程變成可運行的,那么系統將終止低優先級的線程(即不允許它完成本來屬於它的時間片),並且分配一個完整的時間片給更高級別的線程。想了解更多信息,請參閱 Context Switches (上下文切換) 

 

 

 

 

 

名詞解釋:Round-robin scheduling (輪叫調度):以一定的時間間隔,輪流的方式執行相應的任務。

參考:http://en.wikipedia.org/wiki/Round-robin_scheduling

 

由此可知,《理解 Thread.Sleep 函數一文中所說的也不完全正確,其實 Windows 也是有時間片概念的,只不過它不像 Linux 那樣是平均分配的,搶先式的意思是,高優先級的線程如果有需要,是可以叫低優先級的線程讓出時間片的,比較“霸道”。而整個系統調度依然是按某個時間片間隔來輪詢(輪循)的,來決定選擇那個線程來運行。一般來說,Windows 這個時間片大約是 10-15 ms 左右。這也是 Sleep() 函數默認設置下的最小精度,可以通過 timeBeginPeriod() 函數來修改這個最小精度。

 

Scheduling Priorities (調度優先級) 末段也提到:

中文大意是:

  然而,如果有一個線程在等待其他低優先級的線程來完成某些任務,則一定要阻塞那些處於等待中的高優先級線程的運行。為了實現這個目的,可以使用 Wait Function (等待系列函數),critical section (臨界區),或者 Sleep() 函數,SleepEx() 函數,或者 SwitchToThread() 函數。這些對於線程運行一個循環來說是一些可優選的方案。否則,處理器可能成為死鎖狀態(deadlocked),因為低優先級的線程可能永遠都不會被調度到。

 

 

 

 

 

在這一段話里,微軟暗示了我們,要進行良好的休眠和線程切換管理,是要分別使用 Sleep() 和 SwitchToThread() 函數的,而 jimi_yield() 在 Windows 下就等價於 SwitchToThread()。這跟我們前面提到的 SpinWait.cs 里是如出一轍的,我們分別使用了 Sleep(0),Sleep(1) 和 SwitchToThread()。而 Wait Functions 和 臨界區,則沒有研究過,臨界區沒什么好研究的,倒是 WaitForSingleObject() 這些函數跟 Sleep() 之間的差別倒是值得研究一下,不過好像沒有那個地方用到這個技巧,所以暫時不予考慮。

 

Sleep(0)、Sleep(1) 和 SwitchToThread() 不得不說的故事

  由於上了首頁,我就寫簡單點,力求完整。

 

  • Sleep(0):從前面的一些描述,我們已經大概知道在 Windows 上 Sleep(0) 是一個很特殊的東西,關於這點,MSDN上的 Sleep() function 是這么說的:

  

  請注意深紅色框住的部分,dwMilliseconds 的值為 0 時,會使線程放棄剩余的時間片,把時間片讓給與該線程擁有相同優先級的其他已准備就緒的線程。如果沒有相同優先級的線程沒有已經准備就緒的,則 Sleep(0) 立刻返回,並且繼續運行。這個行為從 Windows Server 2003 開始改變(以前的行為是,讓步給所有准備就緒的線程,而不是僅僅讓步給跟該線程優先級相同的線程)。同時,我們看到,這段文字上面還寫着粗體的 Windows XP,我的猜想,這個改變是從 Win2003 開始,但是 WinXP 晚期的版本,比如SP2,SP3,可能也跟着改了。

  綜上,Sleep(0) 的行為應該是這樣的,會放棄當前的時間片,讓步給相同優先級的其他准備就緒的線程,這個線程可以是任何CPU核心上的線程,當然也可以讓步給優先級比它高的線程(其實這個時候不是讓步,而是被硬搶的,因為“搶先式”的原則),這跟很多文章所描述的也一致,不然你只看 MSDN,會以為只會讓步給相同優先級的其他線程。這里還有一個問題,就是說 Sleep(0) 會立刻返回,但我們最前面翻譯的那一段文字里又說可能不能保證立即被執行,似乎也不矛盾,有能被讓步的線程則讓步,沒有的話就立即返回。

  用一句概括就是:切換到任何 CPU 核心上的相同或更高優先級的等候線程,如果沒有這樣的線程則立刻返回

 

  • Sleep(1)Sleep(n):這個比較好理解,這是真正的掛起線程,進入休眠狀態,並切換至內核,把自己置為准備就緒狀態,然后至少 n 毫秒之后再喚醒,由於 Windows 系統調度的最小精度有可能大於 1 毫秒,所以真正的休眠時間由該最小精度決定,這個精度據說可以用 timeBeginPeriod() 函數修改,否則一般為 10-15 毫秒,即使 timeBeginPeriod() 修改為 1 毫秒,也可能因為時間片的關系,實際的休眠時間還是有可能大於 1 毫秒。跟 Sleep(0) 不同的地方是,Sleep(1) 是真正的放棄了時間片,而前者只是讓步而已。

 

  • SwitchToThread():這個函數更為特殊,如果說 Sleep(0) 是 Linux 沒有的功能(Linux的usleep(0)據說沒有這個效果,而且非常非常慢,由於我沒有裝 Linux 真機,也只能從網上的文章來推斷),那么跟 SwitchToThread() 行為類似的函數在 Linux 上壓根就沒有,我們來看一下 MSDN :

  

  大意是:使當前線程讓步給當前 CPU 核心上的其他准備就緒的線程,由操作系統來選擇下一個被運行的線程。這里提到的是 “current processor”,就是當前線程所在的 CPU 處理器,一般情況下,一個線程被分配到一個 CPU 核心上之后,不到萬不得已,一般是不會隨意切換 CPU 核心的,因為切換之后,很可能導致緩存失效,需要重新加載,所以除非有必要,不然一般是不會隨意更換的。所以,這里 SwitchToThread() 切換到的線程就是原本就在這個 CPU 核心上等候的線程。

  我們再來看一下后邊的備注:

  

  切換(讓步)執行會在當前線程的 CPU 核心上持續長達一個線程調度時間片,操作系統不會讓別的 CPU 核心上的線程遷移到該核心上運行,即使這個 CPU 核處於空閑或者正在運行一個低等級的線程。

  在切換到的線程時間片結束后,操作系統重新安排執行的線程。重新調度取決於切換到的線程的優先級以及其他可運行線程的狀態。

  總結,SwitchToThread():讓步給當前 CPU 核心上等待的其他線程但只持續一個線程調度時間片,此階段不會運行別的核心上的等待線程。時間片結束后有當前的狀態和優先級選擇一個線程繼續運行。

 

  該函數還有一個特點,就是如果沒有合適的線程來切換時,會返回 0 值,並且繼續當前線程的執行。如果有合適的線程來切換,則返回非 0 值。

  另一個很重要的特性是,它會讓步給比它線程優先級低的線程,這也是唯一一個可以這么做的函數,Sleep(0) 只能切換到相同或更高優先級的線程。

 

相互關系圖

  我們來看一張圖:

  

  事實上,不要以為有了他們就萬事大吉了,Sleep(1) + Sleep(0) + SwitchToThread() 依然還是有些缺陷的,這在 Sleep() 和 SwitchToThread() 的 MSND 說明里都有提到。例如 SwitchToThread():如果一個 IOCP 程序運行在一個四核的CPU上,我們一般會設置 (CPU * 2 + 2) 個工作線程,即 8 至 10 個。現在每個 CPU 核心都有線程在跑,現在線程 X 在核心 A 上調用了 SwitchToThread(),可是它要等的資源在線程 Y 那里,而線程 Y 屬於核心 D,並且被休眠了。此時,你調用 SwitchToThread(),知道切換到原本就是核心 A 上的線程 Z,卻切換不到線程 Y,而工作線程優先級是相同的,一個線程沒跑完之前,也不主動掛起的話,別的線程是得不到運行的,那么線程 X 等待的線程 Y 所持有的資源將得不到釋放,所以有可能造成“死鎖”(deadlock)。造成這樣的原因是 SwitchToThread() 不能讓別的核心上的等待線程切換到本核心上,如果它允許線程 Y 遷移過來,那么線程 Y 得到運行,持有的資源就可能得到釋放,從而避免“死鎖”。

  可是,另一方面,Sleep(0) 就很好嗎?也不,同樣的場景,線程 X 在核心 A 上調用 Sleep(0),它要等的資源依然是在線程 Y 那里,可是這次不同的是,線程 Y 的優先級要比線程 X 低,線程 X 調用了 Sleep(0) 以后,的確是把 CPU 的控制權交出去了,也可以執行別的核心上的等待線程,可是 Sleep(0) 有一個問題,它只能切換到跟當前線程相同或更高優先級的線程,而線程 Y 的優先級比它低。就這樣,雖然線程 X 掛起了,可是線程 Y 始終得不到運行,線程 X 想要的資源也釋放不了,最終造成“死鎖”。

  當然這種情況可能不一定會完全死鎖,因為操作系統會出來干預,干預的辦法有兩種,一種是適當的時間改變一下某些線程的優先級,從而讓情況得到改變;另 一種是我們前面說過的,通過一個根據飢餓程度和線程優先級綜合考慮的綜合優先級,當這個綜合優先級變得很大時,低優先級的線程也會得到運行。具體是哪一種 不得而知,因為看不到 Windows 的源碼。前者是在一個老外的文章看到的,估計也是猜測,從實際上來看,Windows 的確會偶爾改善一下這種“死鎖”的狀況,但整個過程是很緩慢的,因為這個調整要比較長的時間才會處理一次。沒完全死鎖,但也活不好,相對於“活鎖” (livelock)。

  這時,你可能會說,我們讓線程 Y 的優先級跟線程 X 一樣或更高不就好了嗎?不過問題還是存在的,雖然這樣線程 Y 的確是可能能得到運行的,但由於等待的線程不止一個,分到時間片的線程不一定是線程 Y,而線程 Y 還是要等到其他線程再給機會讓它運行才能釋放相應的資源。這么看來 Sleep(0) 問題沒 SwitchToThread() 那么嚴重,但是 SwitchToThread() 的好處就是如果不切換 CPU 核心的話,那么線程所用到的緩存數據可能還在緩存中,而不需要重新加載,如果遷移到別的核心上,緩存很可能會失效,而從新加載緩存有可能是一個很耗時的過程,各有利弊。

  其實前面講到的 Sleep(0) 和 SwitchToThread() 都會導致線程切換,也就會出現著名的 上下文切換 (Context Switches) 問題,可是我們的程序里為什么沒有因為這個損失很大呢。一方面,當你在循環里自旋 N 次以后,如果還是獲取不了想要的資源,適當的轉去干別的事是基本無害的,只要不跟別的 CPU 發生緩存或者總線競爭即可,甚至你做一次上下文切換的時間,剛好讓這個競爭變小了。我們知道單線程的時候,“單體”的效率是最高的,因為沒有爭搶,所以如果競爭者減少了,甚至減少到一個競爭者,那豈不是最高的效率嗎。可是為什么我們不干脆就用一個線程來干事情就好了?一方面,一個線程釋放了鎖資源以后,還是要做一下善后工作的,這個處理善后工作的時間,如果有多個線程,那么就有可能有線程能在這個空檔時間搶到資源去干事,這樣填補了因為處理善后而空白的時間。如果這個善后,也就是一般所說的邏輯處理時間,越長的話,那么多線程的作用就越明顯。比如一個游戲服務端程序,接受到一個數據包,要解包,解完包要根據收到的數據做相應的邏輯處理,這個過程可能不短,還可能包括一些數據庫操作。如果單線程的話,一方面有些 CPU 核心閑置了,另一方面,在處理這個游戲邏輯的過程,不能讓別的 CPU 去接后面的活來干事。

  放在本文的消息隊列道理也是一樣的,雖然鎖持有的時間非常短,但我們就是要在這沖突和競爭中在適當的時候抓過這個空檔,盡量填滿這個空檔,同時適當的時候讓步或休眠一下,減少競爭,這樣通過的線程會更多。自旋只會讓競爭越來越激烈,而從上面我們得知,如果一個休眠策略只有 Sleep(0) 或只有 SwitchToThread() 都是不完整的,最好把這兩者加上真正的休眠 Sleep(n) 加進來(n >= 1),這樣可以一定程度的防止“死鎖”,也由於有真正的休眠,會讓競爭更平滑一點。

 

sched_yield()

   說完了 Windows 上的休眠策略,我們來看看 Linux 的讓步/Yield方法,這個函數叫做 sched_yield()

 

  這個函數在 Linux 上異常的簡潔,看說明也很清楚:讓當前線程放棄CPU使用權,同時把這個線程移動到它靜態優先級的調度隊列末尾,並且從調度隊列中找一個合適的線程來運行。如果當前線程的線程優先級列表是最高的,那么該函數立刻返回,當前線程將繼續運行。說明也提到,戰略性的使用 sched_yield() 將有助於減少競爭來提高性能,同時也要避免不必要的和不適當的使用 sched_yield() ,從而導致上下文切換,這也會降低系統的性能。

  這樣看來,Linux 也是有優先級概念的(其實不可能沒有,沒有的話就變成三個和尚沒水喝了)。通過查詢相關資料,我們得知 Linux 有三種調度策略,第一種是 SCHED_RR,也就是跟 Windows 相似的 “round-robin” 輪叫策略,實時調度策略,以時間片輪轉。當進程的時間片用完,系統將重新分配時間片,並置於就緒隊列尾部。放在隊列尾保證了所有具有相同優先級的 RR 任務的調度公平性;第二種是 SCHED_FIFO,即 “first-in, first-out”,先進先出策略,實時調度策略,先到先服務,一旦占用 CPU 則一直運行,一直運行到有更高優先級任務到達或自己主動放棄;還有一種是 SCHED_OTHER,分時調度策略,普通線程默認使用這個策略。SCHED_OTHER 是不支持使用優先級的,而 SCHED_FIFO 和 SCHED_RR 支持優先級的使用,他們分別為 1 和 99,數值越大優先級越高。

  跟 Windows 不同的地方是,Linux 是允許為不用的線程指定不同的調度策略的,其中 SCHED_RR 和 SCHED_FIFO 都是實時策略,SCHED_OTHER 是分時策略,在用戶態使用的,如果有實時策略的線程需要運行,那么會從 SCHED_OTHER 的普通線程搶得使用權,這有點類似 Windows 的搶先式,不過區別是只有實時策略的線程才能這么干。

  參考自:《sched_yield()函數 高級進程管理》,《Linux內核的三種調度策略》,《linux內核的三種調度方法》。

 

其他

  其實我們在研究 Sleep(0),Sleep(1),SwitchToThread() 的道路上並不寂寞,在 MSDN:Sleep() Function 的評論中,第二個回復里有一個鏈接,截圖如下:

  當初我為了研究得更透徹一些,搜索了很多資料,幾乎任何一個有用的信息都沒放過,stackoverflow.com 搜集到的很多東西如今都沒有什么用途了,因為我們大體上弄清楚了,都濃縮在前面的描述中。而就是上面這個文章,還是寫得比較有見地的,《Sleep Variation Investigated》,不過這篇文章也引出了他自己寫的另一篇文章,請看圖:

 

  文章提到說最好不要用 Sleep(0) 的文章是:《In Praise of Idleness》,這篇文章有着跟我們類似的觀點,他也分析了 Sleep(0),Sleep(1) 和 YieldProcessor() (這個相當於 _mm_pause() 指令,只對支持超線程的CPU有效)。其實他教我們不要用 Sleep(0),事實上,我們要用,我們要做的是如何更好的使用 Sleep(0),避免使用它帶來的不利情況。我們不僅要用,而且還要用好。不過他的文章很多東西寫的都是正確的,也是細致的,可以拜讀一下,結論稍微偏激了點。我也是通過這篇文章了解到了 Facebook 的開源庫 folly 的 SmallLocks.h,folly 在這方便做得不太好。你也可以通過本文的第二篇可以看到,我本來是想對各個開源庫的 spin-lock 做一個完整的比較的,可以學到一點東西,我的代碼也有借鑒了一小部分。

 

(以下部分內容於 2015/01/30 22:11 新增)

 

歸納

緊接上一篇的末尾,我們把 Windows 和 Linux 下的休眠策略歸納總結一下,如下圖:

 

 

我們可以看到,Linux 下的 sched_yield() 雖然包括了 Windows 下的 Sleep(0) 和 SwitchToThread() 的部分功能(圖中藍色框和虛線框所標注的部分),但缺少了上圖中兩個灰色文字的功能,即  SwitchToOtherCoresLowerThreads() SwitchToLocalCoreLowerThreads()(其實這個函數應該包括相同優先級的情況,但由於名字太長,故省略了Equal)。而 Windows 下,則缺失了 SwitchToOtherCoresLowerThreads() 這個功能。也就是說,Linux 下面沒有切換到低優先級線程的功能,而 Windows 雖然提供了 SwitchToThread(),可以切換到本核心上的低優先級線程,卻也依然缺少了切換到別的核心上低優先級線程的能力,即 SwitchToOtherCoresLowerThreads() 這個功能。

 

也就是說,Windows 和 Linux 的策略即使合起來用,依然是不完整的。可是我仔細的想了一下,上面的說法其實是不太正確的。sched_yield() 其實並不是不能切換到低優先級的線程,根據上篇提到 Linux 上的三種不同調度策略,sched_yiled() 是有可能切換到比自己優先級低的線程上的,比如 SCHED_FIFO(先進先出策略)或者 SCHED_RR(輪叫策略剛好輪到低優先級的線程)。只不過,不能特別指定切換到當前線程所在的核心上的其他線程,也就是類似 Windows 上 SwitchToThread() 的功能。

 

而 Windows 上,雖然看起來好像更完整,卻也真正的缺少了切換到別的核心上的低優先級線程的功能,即 SwitchToOtherCoresLowerThreads()

 

首先來看 Linux 上,缺少了只切換到當前核心的等待線程上的功能,如果切換的線程原來是在別的核心上的,那么有可能會導致切換到的線程所使用的緩存失效,被迫重新加載,導致影響性能。(這個不知道 Linux 的策略是如何選擇的,可能要看下源碼才能明白,但是有一點是可以肯定的,就算 Linux 在某些調度策略里(比如 SCHED_OTHER,這是普通線程所采用的策略),會優先選擇本來就在當前核心上等待的線程,但是當當前核心上沒有適合的等待線程的時候,Windows 的 SwitchToThread() 會立刻返回,而 Linux 應該還是會選擇別的核心上的其他等待線程來執行;而如果是另外兩種調度策略的話,則基本上可能不會優先選擇當前核心上的等待線程,尤其是 SCHED_FIFO 策略)。

但從總體上來看,Linux 使用了三種不同的調度策略,是要比 Windows 單一的輪叫循環策略要好一點,至少你有可能通過不同的策略的組合來實現更有效的調度方案。

而 Windows 上雖然在一定程度上有避免這種線程遷移到別的 CPU 核心上運行的可能,卻由於缺少切換到別的核心上低優先級線程的功能,而導致策略上的不完整,雖然 Windows 可能可以根據線程飢餓程度來決定要不要切換到這些本不能切換過去的低優先級線程,不過這個過程肯定是比較緩慢的,不太可控的,從而在某些特殊情況下可能會造成某種程度上的“死鎖”。

所以 Linux 和 Windows 的調度策略各有千秋,總體上來看,Linux 好像稍微好那么一點,因為畢竟選擇要多一些,只要設計得當,情況可能會好一點。但總體上,兩者都有不足和缺陷,不夠完整。

 

更完整的調度

那么怎樣才能更完整呢?答案可能你也能猜到,通過前面的分析,我們想提供更完整的細節和可控的參數,讓調度更完整和隨心所欲。也就是根據我們的想法,指哪打哪,想切去哪就切去哪,無孔不入。我們需要一個更強大的接口,這個接口應該要考慮幾乎所有可能的情況,功能強大而不失靈活性,甚至有些時候還會有一定的侵略性。我們只是提供一個接口,具體怎么玩,玩成什么樣,我們不管,玩崩了那是程序員自己的事情(崩倒是應該不會,但是可能會混亂)。

 

我們把這個接口暫時定義為 scheduler_switch()(本來想叫 scheduler_switch_thread(),還是短一點好),函數模型大致為:

 

int scheduler_switch(pthread_array_t *threads, pthread_priority_t priority_threshold, int priority_type, cpuset_t cpu_mask, int force_now, int slice_count);

 

threads:     表示一組線程,我們從這一組線程里,通過后面的幾個參數一起來決定最終選擇切換到哪一個線程,該線程必需是處於准備就緒狀態的,即會把已經在運行的線程排除掉。該值為 NULL 時表示從系統所有的等候線程里挑選,即跟 sched_yield() 默認的行為一致。

priority_threshold:表示線程優先級的閥值,由后面的 priority_type 來決定是高於這個閥值、低於這個閥值、等於這個閥值或者大於等於這個閥值,等等。該值為 -1 時表示使用當前線程的優先級。

priority_type:   決定 priority_threshold 的比較類型,可分為:>,<,==,>=,<= 等類型。

cpu_mask:    允許切換到的 CPU 核心的 mask 值,該值為 0 時表示只切換到線程當前所在的核心,不為 0 時,每一個 bit 位表示一個 CPU 核心,該值類似 CPU 親緣性的 cpuset_t 。

force_now:    為 1 時,表示指定的切換線程立刻獲得時間片,而不受系統優先級和調度策略的限制,強制運行的時間片個數由 slice_count 參數決定;為 0 時,表示指定切換的線程會等待系統來決定是否立刻得到時間片運行,可能會被安排到一個很短的等候隊列里。

slice_count:    表示切換過去后運行的時間片個數,如果為 0 時,則由系統決定具體運行多少個時間片。

 

返回值:      如果成功切換返回 0,如果切換失敗返回 -1(即沒有可切換的線程)。

 

我所說的侵略性是,你可以決定切換以后會持續運行多少個時間片而不會被中斷,而且如果你通過 threads 指定的線程組如果沒有立刻得到時間片的權力的時候,可以通過 force_now 參數來強制獲得時間片,即讓系統本來下一個會得到該時間片的線程排在我們指定的線程運行完相應的時間片后再把時間片讓給它。如果你指定的 slice_count 值過大,可能會使得其他線程得不到時間片運行,也許這個值應該設置一個上限,比如 100 個或 256 個時間片之類的。

你可以看到,這個接口函數幾乎囊括了 Windows 和 Linux 已存在的所有調度函數的功能,並且進行了一定程度的增強和擴展,並且有一定的靈活性,如果你覺得還有不夠完整的地方,也可以告訴我。至於如何實現它,我們並不關心,我們只要知道理論上是否可以實現即可,而具體的實現方法可以通過研究 Linux 內核源碼來辦到。也許並不一定很容易實現,但從理論上,我們還是有可能做得到的。Windows 上由於不開源,我們沒什么辦法,也許研究 ReactOS 是一種選擇,但是 ReactOS 是基於 WindowsNT 內核的,技術可能有些陳舊(注:ReactOS 是一個模仿 Windows NT 和 Windows 2000 的開源操作系統項目,請參考 [wikipedia:ReactOS] )。

 

Linux 內核

下載 Linux Kernel 源碼:

https://www.kernel.org/

(建議下載 2.6.32.65 和 最新的穩定版 3.18.4,Android 是在 2.6 的基礎上修改的,有興趣也可以直接拿 Android 的內核來改。)

Ubuntu 14.04 LTS 的內核版本是 3.13 (參考:Ubuntu發行版列表

 

可參考的文章:

Linux內核調度算法(1)-- 快速找到最高優先級進程

Linux內核--進程調度(一)

IBM:Linux 2.6 調度系統分析

 

注:通過閱讀 Linux 內核的源碼,sched_yield() 真正的執行過程並不是我前面描述的那樣的,正確的過程應該是:先在當前線程所在的核心上遍歷合適的任務線程,如果有的話,就切換到該線程;如果沒有的話,目前我還不確定會不會從別的核心上的 RunQueue 遷移任務線程到當前核心來運行,按道理講應該是這樣的。如果是這樣,那么 sched_yield() 跟 Windows 的 Sleep(0) 的唯一區別就是:Sleep(0) 不會切換到比自己的優先級別低的線程,而 sched_yield() 可以,Sleep(0) 的策略也應該跟 sched_yield() 類似,先在被核心上找,沒有再去別的核心上找。但是,這里我就不修正前面的描述了,請自行注意一下

 

因為 3.18.4 的 Linux 改動比較大,也變得復雜了很多,比較難讀懂,找 schedule() 函數的位置都花了很大的力氣……,因此這里以 2.6 版本的為例。

 

通過閱讀 2.6.32.65 版本的 Linux 內核源碼,我們知道實現系統調度的函數是:schedule(void),這跟我們這個接口比較類似,實現的功能也是大致一樣。schedule(void) 的功能是從當前核心上的 RunQueue 里找到下一個可運行的任務線程,並切換MMU、寄存器狀態以及堆棧值,即通常說的上下文切換(Context Switch)。

 

通過 /include/linux/smp.h 和 /arch/x86/include/asm/smp.h 或 /arch/arm/include/asm/smp.h,我們可以得知,smp_processor_id() 返回的是 CPU 核心的一個編號,x86 上是通過 percpu_read(cpu_number),arm 上是通過 current_thread_info()->cpu 獲得。這樣 rq = cpu_rq(cpu); 取得的 RunQueue 就是每個 CPU 核心上獨立的運行(任務線程)隊列。

 

/*
 * schedule() is the main scheduler function.
 */
asmlinkage void __sched schedule(void)
{
    struct task_struct *prev, *next;
    unsigned long *switch_count;
    struct rq *rq;
    int cpu;

need_resched:
    preempt_disable();
    cpu = smp_processor_id();   /* 獲取當前 CPU 核心編號 */
    rq = cpu_rq(cpu);
    rcu_sched_qs(cpu);
    prev = rq->curr;
    switch_count = &prev->nivcsw;

    release_kernel_lock(prev);
need_resched_nonpreemptible:

    schedule_debug(prev);

    if (sched_feat(HRTICK))
        hrtick_clear(rq);

    spin_lock_irq(&rq->lock);
    update_rq_clock(rq);
    clear_tsk_need_resched(prev);

    if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) {
        if (unlikely(signal_pending_state(prev->state, prev)))
            prev->state = TASK_RUNNING;
        else
            deactivate_task(rq, prev, 1);
        switch_count = &prev->nvcsw;
    }

    pre_schedule(rq, prev);     /* 准備調度? */

    if (unlikely(!rq->nr_running))
        idle_balance(cpu, rq);

    put_prev_task(rq, prev);    /* 記錄之前任務線程的運行時間, 更新平均運行時間和平均overlap時間. */
    next = pick_next_task(rq);  /* 從最高優先級的線程類別開始遍歷, 直到找到下一個可運行的任務線程. */

    if (likely(prev != next)) {
        sched_info_switch(prev, next);
        perf_event_task_sched_out(prev, next, cpu);

        rq->nr_switches++;
        rq->curr = next;
        ++*switch_count;

        /* 上下文切換, 同時釋放 runqueue 的自旋鎖. */
        context_switch(rq, prev, next); /* unlocks the rq */
        /*
         * the context switch might have flipped the stack from under
         * us, hence refresh the local variables.
         */
        cpu = smp_processor_id();
        rq = cpu_rq(cpu);
    } else
        spin_unlock_irq(&rq->lock);

    post_schedule(rq);

    if (unlikely(reacquire_kernel_lock(current) < 0))
        goto need_resched_nonpreemptible;

    preempt_enable_no_resched();
    /* 是否需要重新調度 */
    if (need_resched())
        goto need_resched;
}
EXPORT_SYMBOL(schedule);

 

其中有一個很關鍵的函數是 pick_next_task(rq),作用是尋找下一個最高優先級的可運行任務。有一點可以確定的是,是優先在本核心上的 RunQueue 上遍歷的,但看不太出來是否會在本核心上沒有適合的任務線程時,會從別的核心上的 RunQueue 遷移任務過來,這個部分可能要看 struct sched_class 的 pick_next_task() 的實現,這個應該是一個函數指針。pick_next_task(rq) 的源碼如下:↓↓↓

 

/*
 * Pick up the highest-prio task:
 */
static inline struct task_struct *
pick_next_task(struct rq *rq)
{
    const struct sched_class *class;
    struct task_struct *p;

    /*
     * Optimization: we know that if all tasks are in
     * the fair class we can call that function directly:
     */
    /* fair_sched_class 是分時調度策略, 包括 SCHED_NORMAL, SCHED_BATCH, SCHED_IDLE 三種策略, */
    /* 從 __setscheduler() 函數得知. */
    if (likely(rq->nr_running == rq->cfs.nr_running)) {
        p = fair_sched_class.pick_next_task(rq);
        if (likely(p))
            return p;
    }

    /* sched_class_highest = &rt_sched_class; 即實時調度策略, */
    /* 包括 SCHED_FIFO 和 SCHED_RR, 從 __setscheduler() 函數得知. */
    class = sched_class_highest;
    for ( ; ; ) {
        p = class->pick_next_task(rq);
        if (p)
            return p;
        /*
         * Will never be NULL as the idle class always
         * returns a non-NULL p:
         */
        class = class->next;
    }
}

 

這個問題我們先打住,以后再研究,或者交給有興趣的同學去研究,有什么成果的話麻煩告訴我一下。

 

scheduler_switch()

我們回顧一下 scheduler_switch() 的原型,如下:

 

int scheduler_switch(pthread_array_t *threads, pthread_priority_t priority_threshold, int priority_type, cpuset_t cpu_mask, int force_now, int slice_count);

 

這里的 pthread_array_t *threads,表示的一組線程,之所以想設置這個參數,是因為 q3.h 在 push() 和 pop() 的時候,確認提交成功與否的過程是需要序列化的,如果這個過程中的線程調度能夠按我們想要的順序調度,並且加上適當的休眠機制的話,可能可以減少競爭和提高效率。

 

請注意下面代碼里第 22 和 23 行:

 

 1 static inline int
 2 push(struct queue *q, void *m)
 3 {
 4     uint32_t head, tail, mask, next;
 5     int ok;
 6 
 7     mask = q->head.mask;
 8 
 9     do {
10         head = q->head.first;
11         tail = q->tail.second;
12         if ((head - tail) > mask)
13             return -1;
14         next = head + 1;
15         ok = __sync_bool_compare_and_swap(&q->head.first, head, next);
16     } while (!ok);
17 
18     q->msgs[head & mask] = m;
19     asm volatile ("":::"memory");
20 
21     /* 這個地方是一個阻塞的鎖, 對提交的過程進行序列化, 即從序號小的到大的一個個依次放行. */
22     while (unlikely((q->head.second != head)))
23         _mm_pause();
24 
25     q->head.second = next;
26 
27     return 0;
28 }

 

因為這里第 22, 23 行並沒有休眠策略,直接是在原地自旋,在競爭比較激烈,且間隔時間很短的情況下(評價一個競爭的狀況有兩個參數,一個是多少人在競爭,另一個參數是產生競爭的頻率,即間隔多久會產生一次競爭。競爭者越多,而且競爭間隔也很短的話,那么代表競爭是非常激烈的,我們這里的情況就是這樣),沒有休眠策略很可能導致互相激烈的爭搶資源,而且也會讓 q3.h 在 push() 和 pop() 總線程數大於 CPU 核心總數的時候,產生介於 ”活鎖” 和 “死鎖” 之間的這么一種異常狀態的問題。此時,隊列推進非常慢,而且有多慢似乎有點看臉,有時候可能要一分鍾或幾分鍾不等。

如果這個時候,能夠把還沒輪到的線程先休眠,然后按次序依次喚醒(是按照我們自己的 sequence 序號 head 來喚醒,而不是線程自己進入休眠狀態的順序,因為從第 16 行結束到第 22 行,這里的執行順序是不確定的,有可能序號大的線程先執行到第 22 行,而序號小的因為線程被掛起了,因而晚一點才執行到第 22 行,所以喚醒的時候是依據我們自己的序號 head 從小到大依次喚醒)。但是這樣也會出現一個問題,就是當某個核心正在運行的線程(我們設這個CPU 核心為 X,線程為A)執行到第 22 行時,由於序號 head 大於 q->head.second 很多,那么需要切換到別的線程或者進入休眠狀態。如果此時選擇切換,此時 head = q->head.second 的線程(我們設這個線程為B)會被選取且被喚醒,可是問題來了,這個有時間片的 CPU 核心 X 很可能不是 head = q->head.second 這個的線程 B 原來所在的核心(我們設這個核心為 Y),如果要喚醒的話則需要從原來的核心 Y 遷移到當前的核心 X 來,這當然不是我們想看到的,如果可以在核心 Y 上執行線程 B,這是最理想的選擇了。如果可以的話,我們會中斷核心 Y 上正在運行的線程 C,然后把時間片交給線程 B 來運行。依此類推,那么有可能下一個被喚醒的線程也不在當前擁有時間片的 CPU 核心上,那么又將導致類似的中斷。如果這種中斷太多,那么效率自然也會受到一定影響。所以,如果有一個更好的統籌規划方法就再好不過了,可是,似乎這樣的規划策略不太好實現。

要么我們允許線程頻繁的遷移,或者我們可以按某個比例允許兩種情況都出現,例如:0.4的概率允許被喚醒的線程遷移到當前的核心,0.6的概率不遷移,采用打斷要喚醒的線程所在的核心的方式(總的概率為1.0)。或者我們允許一次喚醒兩個相鄰的線程(指序號 head 鄰近),我們設這兩個線程分別為線程 A 和 B,那么當 A 通過之后,緊接着就是該喚醒 B 了,我們讓線程 B 早一點喚醒,然后讓其自旋並等待通過(這其實跟現在的 q3.h 很像,不同的是我們加了休眠策略,是可以應付任意線程數的 push() 和 pop() 的)。而且如果兩個線程原來所在的核心剛好交叉的話,即線程 A 原來是在核心 X 上的,線程 B 原來是在核心 Y 上的,現在核心 Y 的線程在請求 sched_yield(),現在要喚醒的線程是 A 和 B,那么我們讓 B 在核心 Y 上運行,並且打斷核心 X 上的線程,讓其運行線程 A。當然,我們會先執行打斷的過程,讓線程 A 在核心 X 上運行,然后再在核心 Y 上切換到線程 B 運行,如果可以這么安排先后的話,如果不能這樣做,兩者同時進行也是可以的。如果不是交叉的話,也增加了兩個線程中的其中一個可能在它原來的核心上的機率。

 

我們為什么沒有在 pthread_array_t *threads 這一組線程上使用先進先出的隊列呢,一是前面說過的,像 q3.h 這個問題,線程進入休眠的順序不一定是我們想要的順序,第二個原因是,像我們這個問題,本身就是一個 FIFO 隊列問題,里面再套一個 FIFO 隊列似乎也不合理。但是,的確有些時候還是需要這種 FIFO 的 threads 的,也不矛盾,我們讓先進入的序號值比后進入的序號值小即可。為了簡化邏輯,我們可以用固定數組存儲元素(事先必需先指定隊列大小),用兩個單向鏈表來實現插入和遍歷的方法,一個是active_list,另一個是free_list,遍歷的時候使用序號做為選擇的依據,從而變成一個按序號大小順序出列的隊列。

 

警告:其實這個部分感覺有點畫蛇添足,不過我寫了就不准備刪了,有一部分也是我曾經思考過的東西,只是寫出來好像沒有想象中的理想,但可以做為一個思考的方向。

 

其實在 寫完上一篇准備寫這篇文章之前 的某一天,我看到了這么一篇文章,《條件變量的陷阱與思考》,也可以說是及時雨,感覺好像跟我的文章能沾上一點邊,我因此而特意查看了 glibc 源碼里關於 pthread_cond_xxxx() 部分的相關代碼,對條件變量有了進一步的認識。由於沒深入研究,只是覺得條件變量的實現並不是想象中的那么簡單,至少它整個機制跟我前面提到的喚醒進制是不太一樣,我們總想實現一個完美的喚醒機制,事實上可能很難實現我們心中那最完美的方式,因為有些東西在多線程編程中是不可調和的。同時因為 POSIX 規范為了簡化實現,使用的時候跟 Windows 是有很些區別的。看了這篇文章后,才發現我原來對 pthread 的條件變量理解並不到位,原本以為跟 Windows 的 Event (事件) 很類似,雖然我知道一些兩者的區別,主要集中在 CreateEvent() 的手動和自動模式, PulseEvent() 和 pthread_cond_broadcast() 之間的區別。由於我從沒真正的使用過它,所以真正的區別是看了這篇文章才知道的,最大的區別主要來自於實現的邏輯,POSIX 規范為了簡化實現,條件變量必須跟一個 pthread_mutex_t 配合使用才能正確發生作用,這跟 Windows 的 WaitForSingleObject() 和 ResetEvent() 是有些不同的,Windows 上的 Event 相關函數內部已經包含了這個互斥操作了,相當來說比較直觀和易用一些,但 pthread_cond_t 更靈活一些。

 

也許你會說,既然我們可能在內核里用 scheduler_switch(),為什么參數的定義卻用了 pthread 的類型,其實如果原理實現了,改成內核的 kthread,或者內核和 pthread 分別實現兩套函數,也是可以的,這不是大問題。

 

設計 scheduler_switch() 這個東西是啟發式的,我們的目的是想讓休眠策略能夠更完整的為我們服務,如果能改進則更好,實現不了或不好實現也不打緊,也算是拋磚引玉,希望對你有所啟發。

 

待續

  更新記錄:2015/01/30 22:11 更新了從 [歸納] 開始的后面部分,此部分內容也就是 第六篇:休眠的藝術 [續] 的內容。

  感謝你的閱讀,如果你覺得寫得不錯的話,麻煩點一下推薦,或者評論一下,這樣下次你找這個文章就可以從首頁的 “我贊” 和 “我評” 里面找到了。

 

RingQueue

  RingQueue 的GitHub地址是:https://github.com/shines77/RingQueue,也可以下載UTF-8編碼版:https://github.com/shines77/RingQueue-utf8。 我敢說是一個不錯的混合自旋鎖,你可以自己去下載回來看看,支持Makefile,支持CodeBlocks, 支持Visual Studio 2008, 2010, 2013等,還支持CMake,支持Windows, MinGW, cygwin, Linux, Mac OSX等等。

 

目錄

(一)起因 (二)混合自旋鎖 (三)q3.h 與 RingBuffer 

(四)RingQueue(上) 自旋鎖  (五)RingQueue(中) 休眠的藝術

(六)RingQueue(中) 休眠的藝術 [續]

 

上一篇:一個無鎖消息隊列引發的血案(四)——月:RingQueue(上) 自旋鎖

下一篇:一個無鎖消息隊列引發的血案(六)——RingQueue(中) 休眠的藝術 [續]

.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM