鎖定目標:單機5千
多大叫大,1千還是1萬?好吧,暫定爲5000或以上。帶寬不夠?千兆網。硬盤太慢?SSD。
本文不考慮IO的限制,只討論結構和模式。
開源世界Voip領域最響亮的牌子應該是FreeSwitch,使用者衆多,它能實現如此大規模的單機併發嗎?我認爲:不行。
爲什麼不行?因爲它線程太多,一個通道一個線程,上5000個線程,玩不轉:“CPU忙着切換線程上下文了,哪有時間幹正事”(《GO語言併發之道》)。
爲什麼多線程不行?
說起來,我曾經也是多線程的擁躉,在2003年設計的藍星際語音平臺時,按1通道對應1線程的方式。那時候幾十線正常,上百線就算規模較大的應用了。(注意:FreeSwitch 1.0在2008年纔出現)
我們先來說說多線程的好處。
我爲語音平臺設計了一種叫Koodoo的腳本語言,每個通道運行各自獨立的腳本,相當於在每個線程上跑應用,比如要跑一個IVR:
WaitRing(); // 堵塞等待一個來電
Play("welcome.wav"); // 播放歡迎詞,放完了才執行下一個語句
k = "";
Getkeys(k, 1, 20); // 最多等20秒接收一個按鍵
if( k=="1" )
Play("menu1.wav");
else if( k=="2" )
Play("menu2.wav");
else
Sleep(5); // 延時5秒鐘
多線程的優勢體現出來了,一個通道一個線程,允許隨便堵塞,寫流程變得很簡單,因爲不用關心狀態機,程序員不用轉換編程的模式,寫多通道的應用就像寫單通道程序一樣。
除了開發應用帶來的便捷,一個通道一個線程讓系統的結構變得清晰,Bug更少,更穩定。
帶來的這種優勢並非沒有代價:操作系統線程的開銷太大了。以內存爲例,Windows下默認一個線程的堆棧是1M,就算什麼也不幹,跑5千個空線程就得佔用5G內存。
內存還不是主要問題,內存不足可以擴,主要問題在CPU:通道和線程同步增長,而邏輯CPU單元是有限的,一般4到8,很強的服務器32個,跑5千以上的線程,假設每個CPU上運行1千個線程,
則每個CPU需要在在上1千個線程之間來回切換,導致CPU的執行效率極低,很快所有的CPU負荷上升到100%,幾乎癱瘓,別的什麼也幹不了。
因爲Koodoo語言是我自己設計的,辦法來了。
解決之道:協程
大家知道GO語言有所謂“協程”,這個概念應該沿襲自Erlang,是解決大併發IO的一把鑰匙。
所謂協程,就是在語言級別將函數執行劃分成時間片,需要在語言級別實現調度器來分配這些協程的時間片。
在一個操作系統線程運行一個調度器,可以執行數千個協程,因爲協程是語言級別實現的,上下文開銷極小,因此將極大提升CPU的效率。
我來改造下Koodoo語言,也實現一個協程版本,這而且要做到源代碼兼容,開發者不需要做什麼,僅僅改一下配置,就可以得到性能的極大提升。
Koodoo語言的每一條語句,對應C++的一個對象,每個對象有個指針nextObj,指向下一條語句(對象),顯然我們的調度粒度是這一個個語句對象。我們很容易做一個調度器,在通道之間進行切換。
我們還要考慮堵塞問題,但很多IO類的操作是堵塞的,比如:等某個通道放完一個音再切換到下一個通道十幾秒過去了,這顯然不行。當然,Koodoo的IO類函數基本都有非堵塞版本,偷懶方法是要求開發者使用非堵塞版本,這違背了源代碼兼容的原則。另外有些語句沒辦法替換,比如Sleep(延時秒數)。
只能在語句對象上動手術了。
對於堵塞型的語句對象,增加1個成員變量runCount,用來記錄運行狀態,初值爲0,僞代碼如下:
語句對象::Run()
{
if( runCount==0 ){
此處執行啓動IO,比如開始放音
runCount = 1;
}
else{
if( IO已經完成 ){
關閉IO
runCount = 0;
}
}
}
同時,改造一下得到下一個語句的函數:
Obj* 語句對象::GetNext()
{
return(runCount ? this : nextObj);
}
如果IO沒有完成就還是執行本語句對象,執行完就正常跳到下一條。
因爲打開IO和判斷IO是否完成執行時間很短,可以看成是非堵塞的,用這種方法來進行調度,完全可行。
調度器的靈活配置
每個調度器運行在一個操作系統線程上。可以指定調度器(線程)的個數。
上述調度器的調度方式實際上是將CPU時間平均分配,這是默認配置。還可以對某些通道專門綁定到特定分配器,有一些執行特殊任務的通道,比如來電隊列的自動分配(ACD),調度器運行的協程更少,可以有更高的優先級。
對比實測:令人驚歎
測試環境:
MacBook Pro 15,2017版 MacOS Catalina 10.15.3
Paralles Desktop虛擬機下的windows10 64位家庭版,分配了2個處理器,6G內存。
運行結果:
傳統模式(1通道1線程),配置2000通道,CPU:100%,系統非常卡。5000通道就不用試了。
協程模式(2個調度器),配置2000通道,CPU:6-10%之間,系統順暢。
協程模式(2個調度器),配置5000通道,CPU:26-37%之間,系統順暢。