C++ 高性能服務器網絡框架設計細節(節選)

前言

這篇文章我們將介紹服務器的開發,並從多個方面探究如何開發一款高性能高併發的服務器程序。需要注意的是一般大型服務器,其複雜程度在於其業務,而不是在於其代碼工程的基本框架。

大型服務器一般有多個服務組成,可能會支持CDN,或者支持所謂的“分佈式”等,這篇文章不會介紹這些東西,因爲不管結構多麼複雜的服務器,都是由單個服務器組成的。所以這篇文章的側重點是討論單個服務程序的結構,而且這裏的結構指的也是單個服務器的網絡通信層結構,如果你能真正地理解了我所說的,那麼在這個基礎的結構上面開展任何業務都是可以的,也可以將這種結構擴展成複雜的多個服務器組,例如“分佈式”服務。

文中的代碼示例雖然是以C++爲例,但同樣適合Java(我本人也是Java開發者),原理都是一樣的,只不過Java可能在基本的操作系統網絡通信API的基礎上用虛擬機包裹了一層接口而已(Java甚至可能基於一些常用的網絡通信框架思想提供了一些現成的API,例如NIO)。有鑑於此,這篇文章不討論那些大而空、泛泛而談的技術術語,而是講的是實實在在的能指導讀者在實際工作中實踐的編碼方案或優化已有編碼的方法。另外這裏討論的技術同時涉及windows和linux兩個平臺。

所謂高性能就是服務器能流暢地處理各個客戶端的連接並儘量低延遲地應答客戶端的請求;所謂高併發,不僅指的是服務器可以同時支持多的客戶端連接,而且這些客戶端在連接期間內會不斷與服務器有數據來往。網絡上經常有各種網絡庫號稱單個服務能同時支持百萬甚至千萬的併發,然後我實際去看了下,結果發現只是能同時支持很多的連接而已。

如果一個服務器能單純地接受n個連接(n可能很大),但是不能有條不紊地處理與這些連接之間的數據來往也沒有任何意義,這種服務器框架只是“玩具型”的,對實際生產和應用沒有任何意義。

這篇文章將從兩個方面來介紹,一個是服務器中的基礎的網絡通信部件;另外一個是,如何利用這些基礎通信部件整合成一個完整的高效的服務器框架。注意:本文以下內容中的客戶端是相對概念,指的是連接到當前討論的服務程序的終端,所以這裏的客戶端既可能是我們傳統意義上的客戶端程序,也可能是連接該服務的其他服務器程序。

一、網絡通信部件

按上面介紹的思路,我們先從服務程序的網絡通信部件開始介紹。

需要解決的問題

既然是服務器程序肯定會涉及到網絡通信部分,那麼服務器程序的網絡通信模塊要解決哪些問題?目前,網絡上有很多網絡通信框架,如libevent、boost asio、ACE,但都網絡通信的常見的技術手段都大同小異,至少要解決以下問題:

  • 如何檢測有新客戶端連接?
  • 如何接受客戶端連接?
  • 如何檢測客戶端是否有數據發來?
  • 如何收取客戶端發來的數據?
  • 如何檢測連接異常?發現連接異常之後,如何處理?
  • 如何給客戶端發送數據?
  • 如何在給客戶端發完數據後關閉連接?

稍微有點網絡基礎的人,都能回答上面說的其中幾個問題,比如接收客戶端連接用socket API的accept函數,收取客戶端數據用recv函數,給客戶端發送數據用send函數,檢測客戶端是否有新連接和客戶端是否有新數據可以用IO multiplexing技術(IO複用)的select、poll、epoll等socket API。確實是這樣的,這些基礎的socket API構成了服務器網絡通信的地基,不管網絡通信框架設計的如何巧妙,都是在這些基礎的socket API的基礎上構建的。但是如何巧妙地組織這些基礎的socket API,纔是問題的關鍵。我們說服務器很高效,支持高併發,實際上只是一個技術實現手段,不管怎樣,從軟件開發的角度來講無非就是一個程序而已,所以,只要程序能最大可能地滿足“儘量減少等待或者不等待”這一原則就是高效的,也就是說高效不是“忙的忙死,閒的閒死”,而是大家都可以閒着,但是如果有活要幹,大家儘量一起幹,而不是一部分忙着依次做事情123456789,另外一部分閒在那裏無所事事。說的可能有點抽象,下面我們來舉一些例子具體來說明一下。

例如:

  • 默認情況下,recv函數如果沒有數據的時候,線程就會阻塞在那裏;
  • 默認情況下,send函數,如果tcp窗口不是足夠大,數據發不出去也會阻塞在那裏;
  • connect函數默認連接另外一端的時候,也會阻塞在那裏;
  • 又或者是給對端發送一份數據,需要等待對端回答,如果對方一直不應答,當前線程就阻塞在這裏。

以上都不是高效服務器的開發思維方式,因爲上面的例子都不滿足“儘量減少等待”的原則,爲什麼一定要等待呢?有沒用一種方法,這些過程不需要等待,最好是不僅不需要等待,而且這些事情完成之後能通知我。這樣在這些本來用於等待的cpu時間片內,我就可以做一些其他的事情。有,也就是我們下文要討論的IO Multiplexing技術(IO複用技術)。

幾種IO複用機制的比較

目前windows系統支持select、WSAAsyncSelect、WSAEventSelect、完成端口(IOCP),linux系統支持select、poll、epoll。這裏我們不具體介紹每個具體的函數的用法,我們來討論一點深層次的東西,以上列舉的API函數可以分爲兩個層次:

層次一: select和poll

層次二: WSAAsyncSelect、WSAEventSelect、完成端口(IOCP)、epoll

爲什麼這麼分呢?先來介紹第一層次,select和poll函數本質上還是在一定時間內主動去查詢socket句柄(可能是一個也可能是多個)上是否有事件,比如可讀事件,可寫事件或者出錯事件,也就是說我們還是需要每隔一段時間內去主動去做這些檢測,如果在這段時間內檢測出一些事件來,我們這段時間就算沒白花,但是倘若這段時間內沒有事件呢?我們只能是做無用功了,說白了,還是在浪費時間,因爲假如一個服務器有多個連接,在cpu時間片有限的情況下,我們花費了一定的時間檢測了一部分socket連接,卻發現它們什麼事件都沒有,而在這段時間內我們卻有一些事情需要處理,那我們爲什麼要花時間去做這個檢測呢?把這個時間用在做我們需要做的事情不好嗎?所以對於服務器程序來說,要想高效,我們應該儘量避免花費時間主動去查詢一些socket是否有事件,而是等這些socket有事件的時候告訴我們去處理。這也就是層次二的各個函數做的事情,它們實際相當於變主動查詢是否有事件爲當有事件時,系統會告訴我們,此時我們再去處理,也就是“好鋼用在刀刃”上了。只不過層次二的函數通知我們的方式是各不相同,比如WSAAsyncSelect是利用windows窗口消息隊列的事件機制來通知我們設定的窗口過程函數,IOCP是利用GetQueuedCompletionStatus返回正確的狀態,epoll是epoll_wait函數返回而已。

例如,connect函數連接另外一端,如果用於連接socket是非阻塞的,那麼connect雖然不能立刻連接完成,但是也是會立刻返回,無需等待,等連接完成之後,WSAAsyncSelect會返回FD_CONNECT事件告訴我們連接成功,epoll會產生EPOLLOUT事件,我們也能知道連接完成。甚至socket有數據可讀時,WSAAsyncSelect產生FD_READ事件,epoll產生EPOLLIN事件,等等。所以有了上面的討論,我們就可以得到網絡通信檢測可讀可寫或者出錯事件的正確姿勢。這是我這裏提出的第二個原則:儘量減少做無用功的時間。這個在服務程序資源夠用的情況下可能體現不出來什麼優勢,但是如果有大量的任務要處理,這裏就成了性能的一個瓶頸。

檢測網絡事件的正確姿勢

根據上面的介紹,第一,爲了避免無意義的等待時間,第二,不採用主動查詢各個socket的事件,而是採用等待操作系統通知我們有事件的狀態的策略。我們的socket都要設置成非阻塞的。在此基礎上我們回到欄目(一)中提到的七個問題:

1. 如何檢測有新客戶端連接?

2. 如何接受客戶端連接?

默認accept函數會阻塞在那裏,如果epoll檢測到偵聽socket上有EPOLLIN事件,或者WSAAsyncSelect檢測到有FD_ACCEPT事件,那麼就表明此時有新連接到來,這個時候調用accept函數,就不會阻塞了。當然產生的新socket你應該也設置成非阻塞的。這樣我們就能在新socket上收發數據了。

3. 如何檢測客戶端是否有數據發來?

4. 如何收取客戶端發來的數據?

同理,我們也應該在socket上有可讀事件的時候纔去收取數據,這樣我們調用recv或者read函數時不用等待,至於一次性收多少數據好呢?我們可以根據自己的需求來決定,甚至你可以在一個循環裏面反覆recv或者read,對於非阻塞模式的socket,如果沒有數據了,recv或者read也會立刻返回,錯誤碼EWOULDBLOCK會表明當前已經沒有數據了。示例:

 1bool CIUSocket::Recv()
 2{
 3    int nRet = 0;
 4
 5    while(true)
 6    {
 7        char buff[512];
 8        nRet = ::recv(m_hSocket, buff, 512, 0);
 9        if(nRet == SOCKET_ERROR)                //一旦出現錯誤就立刻關閉Socket
10        {
11            if (::WSAGetLastError() == WSAEWOULDBLOCK)
12               break; 
13            else
14                return false;
15        }
16        else if(nRet < 1)
17            return false;
18
19            m_strRecvBuf.append(buff, nRet);
20
21        ::Sleep(1);
22    } 
23
24    return true;
25}

5. 如何檢測連接異常?發現連接異常之後,如何處理?

同樣當我們收到異常事件後例如EPOLLERR或關閉事件FD_CLOSE,我們就知道了有異常產生,我們對異常的處理一般就是關閉對應的socket。另外,如果send/recv或者read/write函數對一個socket進行操作時,如果返回0,那說明對端已經關閉了socket,此時這路連接也沒必要存在了,我們也可以關閉對應的socket。

6. 如何給客戶端發送數據?

這也是一道常見的網絡通信面試題,某一年的騰訊後臺開發職位就問到過這樣的問題。給客戶端發送數據,比收數據要稍微麻煩一點,也是需要講點技巧的。首先我們不能像註冊檢測數據可讀事件一樣一開始就註冊檢測數據可寫事件,因爲如果檢測可寫的話,一般情況下只要對端正常收取數據,我們的socket就都是可寫的,如果我們設置監聽可寫事件,會導致頻繁地觸發可寫事件,但是我們此時並不一定有數據需要發送。所以正確的做法是:如果有數據要發送,則先嚐試着去發送,如果發送不了或者只發送出去部分,剩下的我們需要將其緩存起來,然後再設置檢測該socket上可寫事件,下次可寫事件產生時,再繼續發送,如果還是不能完全發出去,則繼續設置偵聽可寫事件,如此往復,一直到所有數據都發出去爲止。一旦所有數據都發出去以後,我們要移除偵聽可寫事件,避免無用的可寫事件通知。不知道你注意到沒有,如果某次只發出去部分數據,剩下的數據應該暫且存起來,這個時候我們就需要一個緩衝區來存放這部分數據,這個緩衝區我們稱爲“發送緩衝區”。發送緩衝區不僅存放本次沒有發完的數據,還用來存放在發送過程中,上層又傳來的新的需要發送的數據。爲了保證順序,新的數據應該追加在當前剩下的數據的後面,發送的時候從發送緩衝區的頭部開始發送。也就是說先來的先發送,後來的後發送。

7. 如何在給客戶端發完數據後關閉連接?

這個問題比較難處理,因爲這裏的“發送完”不一定是真正的發送完,我們調用send或者write函數即使成功,也只是向操作系統的協議棧裏面成功寫入數據,至於能否被發出去、何時被發出去很難判斷,發出去對方是否收到就更難判斷了。所以,我們目前只能簡單地認爲send或者write返回我們發出數據的字節數大小,我們就認爲“發完數據”了。然後調用close等socket API關閉連接。當然,你也可以調用shutdown函數來實現所謂的“半關閉”。關於關閉連接的話題,我們再單獨開一個小的標題來專門討論一下。

被動關閉連接和主動關閉連接

在實際的應用中,被動關閉連接是由於我們檢測到了連接的異常事件,比如EPOLLERR,或者對端關閉連接,send或recv返回0,這個時候這路連接已經沒有存在必要的意義了,我們被迫關閉連接。

而主動關閉連接,是我們主動調用close/closesocket來關閉連接。比如客戶端給我們發送非法的數據,比如一些網絡攻擊的嘗試性數據包。這個時候出於安全考慮,我們關閉socket連接。

發送緩衝區和接收緩衝區

上面已經介紹了發送緩衝區了,並說明了其存在的意義。接收緩衝區也是一樣的道理,當收到數據以後,我們可以直接進行解包,但是這樣並不好,理由一:除非一些約定俗稱的協議格式,比如http協議,大多數服務器的業務的協議都是不同的,也就是說一個數據包裏面的數據格式的解讀應該是業務層的事情,和網絡通信層應該解耦,爲了網絡層更加通用,我們無法知道上層協議長成什麼樣子,因爲不同的協議格式是不一樣的,它們與具體的業務有關。理由二:即使知道協議格式,我們在網絡層進行解包處理對應的業務,如果這個業務處理比較耗時,比如需要進行復雜的運算,或者連接數據庫進行賬號密碼驗證,那麼我們的網絡線程會需要大量時間來處理這些任務,這樣其它網絡事件可能沒法及時處理。鑑於以上二點,我們確實需要一個接收緩衝區,將收取到的數據放到該緩衝區裏面去,並由專門的業務線程或者業務邏輯去從接收緩衝區中取出數據,並解包處理業務。

說了這麼多,那發送緩衝區和接收緩衝區該設計成多大的容量?這是一個老生常談的問題了,因爲我們經常遇到這樣的問題:預分配的內存太小不夠用,太大的話可能會造成浪費。怎麼辦呢?答案就是像string、vector一樣,設計出一個可以動態增長的緩衝區,按需分配,不夠還可以擴展。

需要特別注意的是,這裏說的發送緩衝區和接收緩衝區是每一個socket連接都存在一個。這是我們最常見的設計方案。

協議的設計

除了一些通用的協議,如http、ftp協議以外,大多數服務器協議都是根據業務制定的。協議設計好了,數據包的格式就根據協議來設置。我們知道tcp/ip協議是流式數據,所以流式數據就是像流水一樣,數據包與數據包之間沒有明顯的界限。比如A端給B端連續發了三個數據包,每個數據包都是50個字節,B端可能先收到10個字節,再收到140個字節;或者先收到20個字節,再收到20個字節,再收到110個字節;也可能一次性收到150個字節。這150個字節可以以任何字節數目組合和次數被B收到。所以我們討論協議的設計第一個問題就是如何界定包的界限,也就是接收端如何知道每個包數據的大小。目前常用有如下三種方法:

1. 固定大小,這種方法就是假定每一個包的大小都是固定字節數目,例如上文中討論的每個包大小都是50個字節,接收端每收氣50個字節就當成一個包。

2. 指定包結束符,例如以一個\r\n(換行符和回車符)結束,這樣對端只要收到這樣的結束符,就可以認爲收到了一個包,接下來的數據是下一個包的內容。

3. 指定包的大小,這種方法結合了上述兩種方法,一般包頭是固定大小,包頭 中有一個字段指定包體或者整個大的大小,對端收到數據以後先解析包頭中的字段得到包體或者整個包的大小,然後根據這個大小去界定數據的界線。

協議要討論的第二個問題是,設計協議的時候要儘量方便解包,也就是說協議的格式字段應該儘量清晰明瞭

協議要討論的第三個問題是,根據協議組裝的單個數據包應該儘量小,注意這裏指的是單個數據包,這樣有如下好處:第一、對於一些移動端設備來說,其數據處理能力和帶寬能力有限,小的數據不僅能加快處理速度,同時節省大量流量費用;第二、如果單個數據包足夠小的話,對頻繁進行網絡通信的服務器端來說,可以大大減小其帶寬壓力,其所在的系統也能使用更少的內存。試想:假如一個股票服務器,如果一隻股票的數據包是100個字節或者1000個字節,那同樣是10000只股票區別呢?

協議要討論的第四個問題是,對於數值類型,我們應該顯式地指定數值的長度,比如long型,在32位機器上是32位4個字節,但是如果在64位機器上,就變成了64位8個字節了。這樣同樣是一個long型,發送方和接收方可能因爲機器位數的不同會用不同的長度去解碼。所以建議最好,在涉及到跨平臺使用的協議最好顯式地指定協議中整型字段的長度,比如int32、int64等等。下面是一個協議的接口的例子,當然java程序員應該很熟悉這樣的接口:

 1class BinaryReadStream
 2{
 3    private:
 4        const char* const ptr;
 5        const size_t      len;
 6        const char*       cur;
 7        BinaryReadStream(const BinaryReadStream&);
 8        BinaryReadStream& operator=(const BinaryReadStream&);
 9
10    public:
11        BinaryReadStream(const char* ptr, size_t len);
12        virtual const char* GetData() const;
13        virtual size_t GetSize() const;
14        bool IsEmpty() const;
15        bool ReadString(string* str, size_t maxlen, size_t& outlen);
16        bool ReadCString(char* str, size_t strlen, size_t& len);
17        bool ReadCCString(const char** str, size_t maxlen, size_t& outlen);
18        bool ReadInt32(int32_t& i);
19        bool ReadInt64(int64_t& i);
20        bool ReadShort(short& i);
21        bool ReadChar(char& c);
22        size_t ReadAll(char* szBuffer, size_t iLen) const;
23        bool IsEnd() const;
24        const char* GetCurrent() const{ return cur; }
25
26    public:
27        bool ReadLength(size_t & len);
28        bool ReadLengthWithoutOffset(size_t &headlen, size_t & outlen);
29    };
30
31    class BinaryWriteStream
32    {
33    public:
34        BinaryWriteStream(string* data);
35        virtual const char* GetData() const;
36        virtual size_t GetSize() const;
37        bool WriteCString(const char* str, size_t len);
38        bool WriteString(const string& str);
39        bool WriteDouble(double value, bool isNULL = false);
40        bool WriteInt64(int64_t value, bool isNULL = false);
41        bool WriteInt32(int32_t i, bool isNULL = false);
42        bool WriteShort(short i, bool isNULL = false);
43        bool WriteChar(char c, bool isNULL = false);
44        size_t GetCurrentPos() const{ return m_data->length(); }
45        void Flush();
46        void Clear();
47    private:
48        string* m_data;
49    };

其中BinaryWriteStream是編碼協議的類,BinaryReadStream是解碼協議的類。可以按下面這種方式來編碼和解碼。

編碼:

1std::string outbuf;
2BinaryWriteStream writeStream(&outbuf);
3writeStream.WriteInt32(msg_type_register);
4writeStream.WriteInt32(m_seq);
5writeStream.WriteString(retData);
6writeStream.Flush();

解碼:

 1BinaryReadStream readStream(strMsg.c_str(), strMsg.length());
 2int32_t cmd;
 3if (!readStream.ReadInt32(cmd))
 4{
 5return false;
 6}
 7
 8//int seq;
 9if (!readStream.ReadInt32(m_seq))
10{
11        return false;
12}
13
14std::string data;
15size_t datalength;
16if (!readStream.ReadString(&data, 0, datalength))
17{
18        return false;
19}

文章未完。

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