單機百萬并發(fā):協(xié)程開始吊打線程了?
著名的C10K問題——即單機如何支持1萬并發(fā)連接,想必大家都有所了解,這個問題推動了epoll/kqueue等IO多路復用機制的誕生。
圖片
在這場技術(shù)演進中,線程模型的局限性逐漸顯現(xiàn)。。。
這篇文章中我們來分析下為什么只使用線程不容易做到單機百萬并發(fā)。
首先來看下線程模型在高并發(fā)場景下的資源消耗問題。
線程作為操作系統(tǒng)進程內(nèi)的執(zhí)行單元,其開銷主要體現(xiàn)在兩個方面:資源占用和上下文切換成本。
提到資源占用很多資料會講線程的棧是MB級別而協(xié)程的棧是KB級別,因此百萬并發(fā)下線程占據(jù)的內(nèi)存過多,然而這并不完全正確。
因為就棧的物理內(nèi)存占用來說,線程可能并不比協(xié)程多。
所謂線程的棧大小為MB級別僅僅是虛擬內(nèi)存大小,假設Linux內(nèi)核為每個線程默認設置 8MB 虛擬內(nèi)存,那么在32位系統(tǒng)下虛擬地址空間僅 4GB,512 個線程就會耗盡(8MB × 512 = 4GB),而64位系統(tǒng)雖虛擬空間巨大,但線程數(shù)仍受 vm.max_map_count
等內(nèi)核參數(shù)限制。
圖片
現(xiàn)在假設我們放開內(nèi)存參數(shù)的限制。
如果線程的棧在函數(shù)真正運行時僅使用 1KB,那么真正的物理內(nèi)存也就只分配 1KB,剩余虛擬空間僅是地址保留,不占物理內(nèi)存,此時單線程的物理內(nèi)存占用與協(xié)程基本相當。
因此用戶態(tài)棧的內(nèi)存占用并不是關(guān)鍵原因。
既然用戶態(tài)棧的資源占用不是線程的瓶頸,那么線程還有其它問題嗎?
我們知道線程切換依賴操作系統(tǒng)內(nèi)核的搶占式調(diào)度,每次切換涉及陷入內(nèi)核態(tài)以及從內(nèi)核態(tài)恢復到用戶態(tài),這涉及到用戶態(tài)到內(nèi)核態(tài)的上下文切換,需要保存/恢復完整上下文信息,以及內(nèi)核中涉及鎖的競爭,單機百萬并發(fā)(線程)僅僅浪費在這里的CPU時間就足以形成性能瓶頸。
圖片
而協(xié)程則實現(xiàn)在用戶態(tài),協(xié)程的切換無需陷入內(nèi)核,因此不需要保存/恢復完整的上下文信息,僅需保存少量寄存器,全程無需內(nèi)核介入。
傳統(tǒng)線程模型采用的是搶占式調(diào)度,即操作系統(tǒng)可以在任何時刻強制中斷正在執(zhí)行的線程,將CPU時間分配給其他線程。
這種調(diào)度方式對于Linux這種通用操作系統(tǒng)來說是必須的,但缺點是上下文切換成本高。
而協(xié)程采用的是協(xié)作式調(diào)度,它的核心理念是:協(xié)程主動讓出CPU資源,而不是被動等待被搶占,而協(xié)程能做到這一點當然是因為協(xié)程完全是用戶態(tài)的事情,用戶態(tài)程序相信自己。
而對于線程來說,內(nèi)核決不能把CPU的分配權(quán)交給用戶態(tài)程序,因此不是線程做不到協(xié)作式調(diào)度而是不能。
具體來說,協(xié)程會在以下幾種情況下主動讓出執(zhí)行權(quán):
- 執(zhí)行IO操作時(如網(wǎng)絡請求)
- 顯式調(diào)用類
yield
函數(shù)時 - 等待鎖或其他同步原語時
協(xié)作式這種調(diào)度方式的優(yōu)勢在于:
- 切換時機可預測:協(xié)程切換發(fā)生在代碼的明確位置
- 避免了不必要的切換:只有在真正需要等待的時候才會切換,減少了無效切換
- 降低了同步原語的復雜度:許多情況下可以避免使用復雜的鎖機制
這里絕不是說協(xié)作式調(diào)度就比搶占式調(diào)度好,僅僅是在IO密集型應用中,協(xié)作式調(diào)度模式的優(yōu)勢更為明顯,使得利用協(xié)程可以輕易實現(xiàn)單機百萬并發(fā)。
協(xié)作式調(diào)度要比搶占式調(diào)度簡單太多,不是內(nèi)核實現(xiàn)不了而是通用內(nèi)核不能把CPU分配權(quán)交給用戶態(tài)。
因此,不是線程不夠強,而是一些特定場景下協(xié)程重構(gòu)了游戲規(guī)則。