服務器編程之路:進無止境(下)

(接上文)

爲了找到第二個命題的解決方法,我們可以再回過頭來看看本文中第一版的服務器程序。前面也說了,第一版程序的問題在於,一條線程服務一個連接,而OS切換線程的開銷很大,所以造成性能上不去。但第一版程序絕對是愉快的順序編程。如果我們想保留順序編程,那應該怎麼克服性能方面的缺陷呢?

問題被直接導向爲:既然OS調度線程很吃力,那是否存在一種“用戶態線程”,由程序自己調度,讓OS一邊玩兒去?

先拋出答案,所謂的“用戶態線程”,我們一般的實現就是“協程(coroutine)”。

在教科書上,協程的定義恐怕是這樣的:“協程就是協作的子例程”。啥啥?“協作”是什麼意思?“子例程”又是什麼鬼?這個定義真是讓人云裏霧裏。

我覺得協程很難用一句簡短的語句定義。解釋協程這個概念,必須說明以下兩點:

1、協程本質上是一種算法。一個函數如果能夠實現中斷,後續恢復時能夠從斷點繼續照常執行,那麼這個函數就可以稱爲協程。

2、協程與線程的本質區別在於:線程之間是競爭cpu的;而協程之間是相互協作的,互相“禪讓”cpu。協程有一個關鍵的原語“yield”,表示主動把cpu退讓出去。yield是由用戶在協程中自行調用的,理論上你可以不調用,那麼這個協程就獨佔了整條線程的cpu資源。協程相互yield,就是協程協作的本意。

很多編程語言從語言級別上就天然支持了協程,如C#、Go、Lua。但可惜的是,作爲偏底層的C/C++卻不在語言中支持。

當然,C/C++可以自己實現協程。Windows有協程API(稱爲fiber),Linux提供了ucontext.h頭文件,它允許使用者保存並回復斷點上下文(當前寄存器狀態與棧內存),從而實現協程。如果你想了解更多的細節,可以閱讀我實現的協程代碼:http://github.com/xphh/coroutine

有了協程,離我們的命題“順序編程”還是有一段距離。事情還只講了一半。

因爲協程畢竟還是在一個線程內的,所以某一個協程阻塞了,別的協程也運行不下去了。也就是說,協程還不能等價於“用戶態線程”。想要把協程當做線程用,必須考慮如何把在協程中阻塞的操作變成線程中不阻塞的操作。

“把在協程中阻塞的操作變成線程中不阻塞的操作”,這句話很拗口也很矛盾,很難直接闡釋,我只能講怎麼做。

在第一版服務器程序中,我們處理一個連接,接收時往往需要阻塞在recv函數上。但實際上,recv所做的處理,無非是在等IO上的數據,在等待的過程中,cpu是被浪費掉的。所以我們要做的第一件事,就是在recv阻塞之前,yield到一個epoll協程。這個epoll協程監聽所有IO,當某個IO(比方剛纔那個協程中的IO)有數據時,再resume回到那個IO所在的協程。

在上面我們提到了協程的resume原語。是這樣的,協程有兩種編程模型,一種叫“對稱協程”,其中所有的協程都可以任意yield到另一個協程;另一種叫“非對稱協程”,所有協程只能yield到主協程,由主協程利用resume調用某一個協程。一般情況下我們推薦非對稱協程,因爲對稱協程在編程上是混亂的。

這樣,整個程序由一個線程的N+1個協程組成。N個協程處理N條連接,一個主協程做IO複用(epoll)。在這N個協程上我們的處理的確是阻塞的,但實際上線程並沒有阻塞,線程大部分時間都在主協程的epoll上而已。

也就是說,使用協程順序編程,我們必須提前處理掉所有的阻塞調用。所有socket的接口(connect、sendto、send、recvfrom、recv)以及sleep函數,都需要自己實現爲協程版本,即:阻塞前yield出去,在主協程中等待事件觸發後resume回來。

至此,命題二實現!

結束了嗎?還沒有呢。如果你開始想用協程進行服務器編程,那以下這些事一定得知道:

1、協程只不過是有獨立棧空間的線程,如果你在協程中阻塞了,其他協程也阻塞了。

2、關於上一點,我相信大家都清楚了。你肯定會說,不是可以改寫阻塞函數嗎?的確可以。不過你得明白,C/C++編程發展至今,更多的是一種生態體系。可惜這種生態對協程並不友好。我們幾乎必用的服務器開發相關的第三方庫,如mysqlclient,libcurl等,項目中用的話,是不可能直接改源碼的。所以協程運用的邊界,你得考慮清楚。

3、對於多核服務器,提升性能最終還是要靠多線程或多進程。如果你想在多線程中使用協程,記住不要在多個線程中調度協程。也不是說不可以,而是這種做法和協程的出發點是相悖的。

多線程 -> 事件模型 -> 協程,這就是服務器編程之路。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章