用PHP構建高效能的TCP/UDP伺服器

來源:互聯網
上載者:User
如果web server直連db,那麼當web server被攻破以後,駭客可以在代碼中找到db的使用者名稱和密碼,可能會造成被拖庫的危險。並且對於db來說,其串連數是有上限的,當多個cgi都需要串連db的時候很有可能會因為db串連數達到上限而拒絕服務。因此在webserver和db之間增加一個中介層變得很必要,中介層和db是保持長串連的。當有資料請求時,web server和中介層server用私人協議(非SQL)來互動,從而提高安全性和效能。這就是中介層server的雛形。

隨著web業務的不斷多樣化,中介層server的作用已經遠遠不止轉寄db資料這麼簡單,它已經提供完整的TCP和UDP服務。下面就介紹一下架構。

1. TCP Server

與大多數server的架構類似,整個TCP server由master進程,Listener進程和worker進程組成。master進程負責監聽訊號和listener/worker進程的健康情況,在進程意外終止時將其重新拉起。Listener進程負責hold住用戶端的串連,而worker進程來做真正的商務邏輯。由於listener只是簡單地負責路由和轉包,不涉及任何阻塞的調用,所以總是不會被阻塞。listener和worker之間選用unix域socket作為通訊機制,由於通訊被限定在親戚進程之間,所以我們選取了無名unix域socket的一種實現--socketpair來完成這件事情。

一般來說listener的數量要小於worker的數量,為了便於繪圖和描述,在下面這個例子當中,我們假定listener數量為2,worker數量為5。

1.1 Master進程

當服務啟動時,首先由當前進程(master進程)

  • 建立worker數量(5個)的socketpair,放在靜態變數中(用於發包給worker)
  • 建立listener數量(2個)的socketpair,放在靜態變數中(用於從worker收包)
  • 建立一個網路socket,bind,listen用於和使用者間的通訊
  • 接下來就是fork()啦,同時有些細節問題需要處理

  • 改變子進程的身份
  • 開啟子進程的CPU親和選項
  • 由於所有的socket是放在靜態變數裡面的,即master進程的資料區段,因此fork()以後,在子進程中依然可以訪問這些socket。也正是因為這些socket在多個進程之間兩兩配對,使得listener和worker可以實現通訊。

    unix域socket是用於同一台機器上運行時進程間的通訊,雖然它和INET域socket被封裝成了同樣的介面,但是內部實現完全不一樣。其僅僅複製資料,並不執行協議處理,不需要添加和刪除網路前序,計算檢驗和,產生順序號,發送確認報文等等因此其效率更高。unix域socket提供了TCP和UDP兩種介面,我們應該選用哪一種呢?當然是UDP了,作為無串連狀態的協議,不需要保留串連態,這樣可以做到純非同步。但問題是UDP協議會不會導致丟包呢?是不是不保證順序呢?答案是不會,原因很明顯,unix域socket是基於管道實現的,因此是可靠的,既不會丟失訊息,也不會傳遞出錯。

    現在進程結構變成了這樣

    master進程到此就完成了初始化的工作,它接下來就進入了監聽訊號和處理訊號的主迴圈當中。其主要作用就是監控所有子進程的健康狀態,做出相應地處理,並且接收系統訊號便於處理管理員的reload,restart,stop和上報運行狀態等需求,同時,master可以動態配置子進程的個數(TODO)。

    如果發現子進程狀態發生變化(SIGCHLD),則將其重新拉起,如果是系統的退出訊號,則設定標誌位,待其他訊號都處理好以後再平滑的退出。

    1.2 Listener和Worker進程

    由於所有的listener和worker都是master進程的子進程,擁有master建立的1+5+2的sock,所以當lisener和worker進程啟動以後,做的第一件事情就是要把自己需要關心的sock告訴核心,即放在epoll中。

    我們會發現這張圖和前面剛剛fock出來的圖不太一樣,感覺少了些東西。原因是,這張圖裡面的sock是放在epoll中的sock,對於每個listener和worker來說,其關心的sock並不相同。例如,n號worker只需要將socketpair_n放入epoll中(中同一顏色的pair),用於接收listener發過來的資料。

    將所有的sock放在epoll中以後,我們只需要在主loop中調用epoll_wait得到需要處理的事件就可以了,從而實現了純非同步。

    此時所有的lisener進程都在監聽統一連接埠,當使用者發起串連請求時,只有一個lisener可以accept成功。

    一旦accept成功以後,需要將新的sock(中的紅色方塊)放在epoll中,用以接收使用者的資料。

    當有使用者資料到達時,Listener通過round rolling的方法選定一個socketpair,轉包給一個特定的worker。

    epoll提供了兩種事件觸發的機制,一種是ET(邊緣觸發),一種是LT(高電平觸發)。兩者的區別是,對於ET模式,當緩衝區裡第一次出現資料,核心會通知我們socket可讀事件,如果此時沒有及時的將資料讀完,後續核心將不再繼續通知。而LT模式中,只要緩衝區中有資料,就會觸發socket可讀事件。從核心層面來說,ET模式的效率更高,因為系統只需要做一次通知,NGINX就是使用了epoll的這種模式,因此每次有可讀事件觸發時,nginx的worker需要一次性的將緩衝區中的資料讀完。但是在實現中,由於我們的epoll使用的是LT模式,一方面原因是編碼比較方便,還有一個重要的原因是libevent的php擴充只支援LT模式。所以每次在核心通知我們sock可讀事件發生時,listener讀取8k的資料再做轉寄即可。

    由於TCP包是無邊界的,而中間我們卻用了UDP協議來轉包,那麼就涉及到了幾個問題:

  • 同一個client發過來的包要被轉到同一個worker去處理,這樣worker才能拼出正確的有意義的請求
  • worker收到包以後可以將其按照不同的client來分類,因為有可能多個client的包發給了同一個worker
  • 包處理好以後的回包要找到正確的listener
  • listener拿到回包以後能定位到正確的回包socket(紅色的方塊),即正確的串連
  • 我們的解決方案是在未經處理資料的基礎上為每個包加一個包頭,包頭的內容就是一個使用者的標籤(具體實現中用ip+port標記),圖中用顏色(黃,紫,橙)來標記。同時listener需要維護2個串連池,第一個是用標籤定位client-listener間的sock,第二個是用標籤定位listener-worker間的sock。同時,listener在發包給worker時還需要表明自己的身份,方便worker選擇正確的sock回包,因此listener向worker發包時,包頭包括使用者的ip,port和listener的唯一ID(編號)。

    這樣一個簡單地非同步tcp server就構建好了。在業務使用的時候,只需要去實現worker的process方法就可以輕鬆搞定了。

    2. UDP Server

    由於UDP是無串連狀態的,並且單獨的每個包都是有意義的,所以設計起來就很容易啦。我們選用msgqueue作為listener和worker通訊媒介。這個msgqueue就是named的了,所有的listener和worker可以根據msgqueue的唯一key對其進行使用。

    同理,建立一個網路socket,用於接受client發過來的包,得到訊息佇列的描述符,用於listener和worker之間的通訊。在fork出一個listener和若干個worker後,master進程就進入了監聽訊息的主迴圈當中了。

    Lisener啟動時,將使用者串連的sock放在epoll中。當有使用者的請求時,核心會通知其狀態的變化。與TCP Server不一樣的是,這一次epoll中只有listener的sock。由於IPC訊息佇列是純記憶體維護,在通用檔案系統中並沒有對應的映射,所以不支援epoll,因此msg_queue資料的讀取是由worker在閑時主動去read的。

    每個worker就進入了讀msg_queue -> 處理資料的迴圈當中。

    worker在處理好資料以後,重用了master的sock,根據每個包打上的標記(ip,port)直接回包給client.

    所以當系統擁塞的時候,首先溢出的是訊息佇列。

    3. 一些細節

    如果你只是看一下結構的話,到這裡就可以了,後面就是一些實現上的細節了

    3.1 改變進程的身份

    master進程涉及到很多privileged系統調用,所以是以root身份來運行。我們知道,在fork()後,worker和listener繼承了父進程的身份,即具有root的許可權,這顯然是不符合最小特權(least privilege)的原則(即我們的程式應該只具有為完成給定任務所需的最小特權,這減小了安全性受到損害的可能性)。因此在fork以後需要改變子進程的身份。

    當查看系統的API我們會發現,有setuid()和seteuid()兩種方法,到底應該使用哪一個呢?

    我們知道當一個進程試圖access一個檔案的時候,核心會根據進程的身份和檔案的許可權位來判斷是否可以做相應的操作。而對於一個進程來說,核心為其維護了三個身份,分別為:

  • 真實身份: real UID, real GID
  • 有效身份: effective UID, effective GID
  • 儲存身份:saved UID, saved GID
  • 其中,用來校正許可權的其實是有效使用者身份。所以直觀上來說,master進程需要調用seteuid()來改變子進程的有效身份。但這樣並不能解決問題,因為核心之所以為進程維護三套身份的原因是進程在啟動並執行過程當中可能會需要用到其他使用者的許可權,因此多套身份的設定就是為了協助進程在運行時臨時提權。

    在進程啟動並執行過程當中,進程可以選擇將真實身份或者儲存身份複製到有效身份,以擁有真實身份或者儲存身份的許可權。因此僅僅設定listener和worker的有效身份還是使得其有可能擷取root許可權。

    所以答案就比較明顯了,這裡需要將子進程的三個身份均改變成nobody。

    我們再來看看setuid()

  • 若進程擁有root許可權,setuid函數將real UID, effective UID和saved UID設定為uid。
  • 若進程沒有root許可權,但是uid等於real UID 或者saved UID,則setuid只將effective UID設定為uid,不改變另外兩個
  • 若以上兩個條件都不滿足,則直接返回錯誤。
  • 說了這麼多,我們發現當前剛好屬於第一種情況,因此在master進程當中,將所有的子進程做setuid()和setgid()操作。

    我們看到一切已經符合預期了。那麼問題就來了,ps aux顯示的第一列USER到底顯示的是進程的什麼身份?

    3.2 master和listener/worker之間的通訊機制

    從上文可知,TCP server中listener和worker之間的通訊是通過unix socket來實現的,而UDP server中則由訊息佇列來完成。但是始終沒有提到master和其子進程(listener & worker)之間的通訊機制。

    首先看一下通訊的需求

    (1) 當管理員需要stop,reload,restart時,需要由master來通知子進程

    (2) listener和worker狀態發生變化時(例如意外退出),需要通知到master進程

    對於第一點來說,master只需要很小的包就可以通知到子進程這些操作,這個包可以小到只包含一個整數就可以了,因此我們自然想到了訊號。用核心提供給我們的USER1和USER2訊號就可以解決這個問題啦。這裡做一個對比,由於NGINX中間涉及到得狀態比較多,因此其使用socketpair來完成第一點需求。

    對於第二點來說,由於listener和worker均為master的子進程,所以核心已經幫我們完成了這件事情,當子進程狀態發生變化時,核心會將SIGCHLD訊號發給父進程。因此解決方案無非兩種:一種是註冊SIGCHLD的handler函數,一種就是在master進程中wait或者waitpid來捕獲事件。

    3.3 CPU親和

    在多處理器的機器上面,一般核心對cpu的調度都是當cpu0的負載即將達到上限的時候啟用cpu1,這樣順序執行下去。進程間切換的記憶體複製造成了對資源的浪費。因此對於一個高效的服務,我們希望所有的worker,listener進程都可以concurrent的執行,而非所有的進程都在核心的調度下使用同一個cpu來運行。

    好在linux通過 sched_setaffinity 將核心對cpu的調度部分的暴露了出來,使得可以將一個進程綁定在一組cpu上面。

    因此在實現中,我們將listener和worker用index對cpu數取餘的方式綁定在了一個特定的cpu上面用以提高效能。

    4. 需要改進的問題

    solar server一直在成長的過程當中,我們也一直在學習優秀的架構,在solar服役的過程當中,發現其實其有很多方面需要去發展和改進。

    4.1 負載平衡

    我們看到在listener選擇worker的方法是通過簡單的RR演算法實現的,即從0號worker開始輪下去。

    也就是說請求包是均勻的發送到各個worker上面的,如果worker處理所有的請求的時間均一致,那麼這種演算法是沒有什麼問題的。但是,worker處理請求的時間長度是不可控的,所以這樣的結構很有可能造成有的worker間的負載完全不均衡。

    由於worker的process方法是由業務方來實現的,因此需要一個listener和worker之間的通訊機制,將worker的繁忙程度回包給listener,以便listener可以選擇當前負載較低的worker。

    我們來看一下NGINX是如何解決負載平衡的的問題的。nginx的結構比較簡單,只有master和worker進程,master進程和Solar的master功能類似,都是只負責接收訊號和負責worker進程的健康狀態。所有的串連和幹活都是由worker進程來handle的。

    其實NGINX解決負載平衡的方法很粗暴,就是看當前worker的串連數(統計串連池的已用串連數),是否為最大串連數(配置)的7/8以上,如果大於這個閾值,則不可以接收新的串連。

    4.2 驚群問題

    什麼是驚群問題?就是當你扔了一塊麵包在廣場上,所有的鴿子都會過來搶,但是最終只有一隻能夠搶成功,對於沒有搶成功的鴿子來說白白浪費了體力。

    對於linux伺服器來說,就是多個進程同時監聽(listen)一個連接埠,當有新串連請求發送到這個連接埠時,核心會通知所有開啟這個連接埠的進程,但是最終只有一個進程可以accept成功,這就造成了系統資源的浪費。

    問題是,如何做到多個進程監聽同一個連接埠呢?難道不會bind失敗嗎?

  • 先fork再bind
  • 先bind,listen再fork
  • 如果是兩個獨立的進程試圖去bind同一個連接埠,這兩個進程中的socket在檔案系統中是兩個獨立的檔案,而如果他們試圖和同一個網卡去綁定的時候,必定會產生衝突。所以bind時會直接返回錯誤。但如果是先bind, listen再去fork的話,對於每個進程的sock在檔案系統中只有一份鏡像,所以就不會產生衝突,但是會發生前面所提到的驚群問題。(感謝gexiaobaoHelloWorld的圖)

    一個比較經典的解決驚群問題的方法就是鎖了,只有拿到鎖的進程才可以去listen,這樣就保證同時只有一個進程在listen,也只有這個進程可以accept。

    NGINX就是利用鎖來保證只有一個進程在listen socket的。NGINX使用的是自旋鎖機制,原因是,每一個worker都是綁定在一個CPU上的,為了更好地exploit系統的效能,應盡量的使得每個worker進程不處於阻塞態。NGINX根據不同的CPU architecture實現了各自的自旋鎖,保證當worker無法獲得鎖時處於ready狀態而不會進入阻塞態,從而減少無謂的環境切換。但是使用自旋鎖的一個基本規範是進程佔用鎖的時間一定要短,否則wait鎖的進程會佔用大量的系統資源。因此如何儘快的釋放鎖就成了一個問題。NGINX的解決方案是,當進程擷取鎖成功以後,接下來並不是把所有epoll中的事件全部處理掉,而是將事件分成兩種,一種是新串連事件,一種是普通事件,分別在記憶體中用兩個鏈表對其進行維護。在一次迴圈中,worker首先將新串連事件處理掉,釋放鎖,然後再去處理普通事件。用這種機制就可以保證鎖被及時的釋放掉。

    5. more

    目前中介層server現在還在不斷的發展當中,還有很多feature正在開發當中,例如對定時事件的支援,worker和後端server,db之間互動的進一步非同步,以及逾時保護等等。

  • 聯繫我們

    該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

    如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

    A Free Trial That Lets You Build Big!

    Start building with 50+ products and up to 12 months usage for Elastic Compute Service

    • Sales Support

      1 on 1 presale consultation

    • After-Sales Support

      24/7 Technical Support 6 Free Tickets per Quarter Faster Response

    • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.