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