手把手教你作中間件開發(分佈式緩存篇)-藉助redis已有的網絡相關.c和.h文件,半小時快速實現一個epoll異步網絡框架,程序demo

     本文檔配合主要對以下demo進行配合說明: 藉助redis已有的網絡相關.c和.h文件,半小時快速實現一個epoll異步網絡框架,程序demolinux

0. 手把手教你作中間件、高性能服務器、分佈式存儲技術交流羣nginx

手把手教你作中間件、高性能服務器、分佈式存儲等(redis、memcache、nginx、大容量redis pika、rocksdb、mongodb、wiredtiger存儲引擎、高性能代理中間件),git地址以下:git

git地址:https://github.com/y123456yz/middleware_development_learninggithub

 

1. epoll出現背景redis

    epoll 是 linux 內核爲處理大批量文件描述符(網絡文件描述符主要是socket返回的套接字fd和accept處理的新鏈接fd)而做了改進的 poll,是 linux 下多路複用 io接口 select/poll 的加強版本。在 linux 的網絡編程中,很長時間都在使用 select 來作事件觸發。在 2.6 內核中,有一種替換它的機制,就是 epoll。epoll替換select和poll的主要緣由以下:算法

  1. select最多處理1024(內核代碼fd_setsize宏定義)個鏈接。
  2. select和poll採用輪訓方式檢測內核網絡事件,算法事件複雜度爲o(n),n爲鏈接數,效率低下。

    epoll克服了select和poll的缺點,採用回調方式來檢測就緒事件,算法時間複雜度o(1),相比於select和poll,效率獲得了很大的提高。mongodb

    藉助epoll的事件回調通知機制,工做線程能夠在沒有網絡事件通知的時候作其餘工做,這樣能夠最大限度的利用系統cpu資源,服務端不用再阻塞等待客戶端網絡事件,而是依賴epoll事件通知機制來避免同步等待。編程

 

2. epoll系統調用接口

2.1 epoll_create函數

函數聲明:int epoll_create(int size)數組

    該函數生成一個epoll專用的文件描述符,其中的參數是指定生成描述符的最大範圍。在linux-2.4.32內核中根據size大小初始化哈希表的大小,在linux2.6.10內核中該參數無用,使用紅黑樹管理全部的文件描述符,而不是hash。緩存

    重點:該函數返回的fd將做爲其餘epoll系統接口的參數。

2.2 epoll_ctl函數

函數聲明:int epoll_ctl(int epfd, int op, int fd, struct epoll_event event)

    該函數用於控制某個文件描述符上的事件,能夠註冊事件,修改事件,刪除事件。epoll_wait個參數說明以下:

     epfd: epoll事件集文件描述符,也就是epoll_create返回值

   op: 對fd描述符進行的操做類型,能夠是添加註冊事件、修改事件、刪除事件,分別對應宏定義: EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL

     fd: 操做的文件描述符。

     event: 須要操做的fd對應的epoll_event事件對象,對象數據來源爲fd和op。

2.3 epoll_wait函數

函數聲明:int epoll_wait(int epfd, struct epoll_event events, int maxevents, int timeout),該函數用於輪詢i/o事件的發生,改函數參數說明以下:

    epfd: epoll事件集文件描述符,也就是epoll_create返回值。

    Events:該epoll時間集上的全部epoll_event信息,每一個fd對應的epoll_event都存入到該數組中,數組每一個成員對應一個fd描述符。

    Maxevents: 也就是events數組長度。

    Timeout: 超時時間,若是在這個超時時間內內核沒有I/O網絡事件通知,則會超時返回,若是在超時時間內有時間通知,則立馬返回

 

3. epoll I/O對路複用主要代碼實現流程

代碼實現主要由如下幾個階段組成:

  1. 建立套接字獲取sd,bind而後listen該套接字sd。
  2. 把步驟1中的sd添加到epoll事件集中,epoll只關注sd套接字上的新鏈接請求,新鏈接對應的事件爲讀事件AE_READABLE (EPOLLIN)。並設置新鏈接事件到來時對應的回調函數爲MainAcceptTcpHandler。
  3. 在新鏈接回調函數MainAcceptTcpHandler中,獲取新鏈接,並返回該新鏈接對應的文件描述符fd,同時把新鏈接的fd添加到epoll事件集中,該fd開始關注epoll讀事件,若是檢測到該fd對應的讀事件(客戶端發送的數據服務端收到後,內核會觸發該fd對應的epoll讀事件),則觸發讀數據回調函數MainReadFromClient。多個鏈接,每一個鏈接有各自的文件描述符事件結構(該結構記錄了各自的私有數據、讀寫回調函數等),而且每一個鏈接fd有各自的已就緒事件結構。不一樣鏈接有不一樣的結構信息,最終藉助epoll實現I/O多路複用。
  4. 進入aeMain事件循環函數中,循環檢測步驟2中的新鏈接事件和步驟3中的數據讀事件。若是有對應的epoll事件,則觸發epoll_wait返回,並執行對應事件的回調。
  5.  

4. epoll I/O多路複用主要數據結構及函數說明

4.1 主要數據結構

struct aeEventLoop結構用於記錄整個epoll事件的各類信息,主要成員以下:

typedef struct aeEventLoop {

    // 目前已註冊的最大描述符

    int maxfd;   /* highest file descriptor currently registered */

    // 目前已追蹤的最大描述符

    int setsize; /* max number of file descriptors tracked */

    // 用於生成時間事件 id

    long long timeEventNextId;

    // 最後一次執行時間事件的時間

    time_t lastTime;     /* Used to detect system clock skew */

    // 已註冊的文件事件,每一個fd對應一個該結構,events其實是一個數組

    aeFileEvent *events; /* Registered events */

    // 已就緒的文件事件,參考aeApiPoll,數組結構

    aeFiredEvent *fired; /* Fired events */

    // 時間事件,全部的定時器時間都添加到該鏈表中

aeTimeEvent *timeEventHead;

}

      該結構主要由文件描述符事件(即網絡I/O事件,包括socket/bind/listen對應的sd文件描述符和accept獲取到的新鏈接文件描述符)和定時器事件組成,其中文件事件主要由events、fired、maxfd、setsize,其中events和fired爲數組類型,數組大小爲setsize。

      events數組: 成員類型爲aeFileEvent,每一個成員表明一個註冊的文件事件,文件描述符與數組遊標對應,例如若是fd=10,則該fd對應的文件事件爲event數組的第十個成員Events[10]。

      fired數組: 成員類型爲aeFiredEvent,每一個成員表明一個就緒的文件事件,文件描述符和數組遊標對應,例如若是fd=10,則該fd對應的已就緒的文件事件爲fired數組的第十個成員fired [10]。

    Setsize: 爲events文件事件數組和fired就緒事件數組的長度,初始值爲REDIS_MAX_CLIENTS + REDIS_EVENTLOOP_FDSET_INCR。aeCreateEventLoop中提早分配好events和fired數組空間。

      maxfd: 爲全部文件描述符中最大的文件描述符,該描述符的做用是調整setsize大小來擴大events和fired數組長度,從而保證存儲全部的事件,不會出現數組越界。

什麼時候擴大events和fireds數組長度?

      例如redis最開始設置的默認最大鏈接數爲REDIS_MAX_CLIENTS,若是程序運行一段時間後,咱們想調大最大鏈接數,這時候就須要調整數組長度。

爲何events和fireds數組長度須要加REDIS_EVENTLOOP_FDSET_INCR?

      由於redis程序中除了網絡相關accept新鏈接的描述符外,程序中也會有普通文件描述符,例如套接字socket描述符、日誌文件、rdb文件、aof文件、syslog等文件描述符,確保events和fireds數組長度大於配置的最大鏈接數,從而避免數組越界。

4.2 主要函數實現

主要函數功能請參考如下幾個函數:

aeCreateFileEvent

aeDeleteFileEvent

aeProcessEvents

aeApiAddEvent

aeApiDelEvent

aeApiPoll

 

5. 定時器實現原理

5.1 定時器主要代碼流程

      Redis的定時器其實是藉助epoll_wait實現的,epoll_wait的超時時間參數timeout是定時器鏈表中距離當前時間最少的時間差,例如如今是8點1分,咱們有一個定時器須要8點1分5秒執行,那麼這裏epoll_wait的timeout參數就會設置爲5s。

     epoll_wait函數默認等待網絡I/O事件,若是8點1分到8點1分5秒這段時間內沒有網絡I/O事件到來,那麼到了8點1分5秒的時候,epoll_wait就會超時返回。Epoll_wait返回後就會在aeMain循環體中遍歷定時器鏈表,獲取到定時器到達時間比當前時間少的定時器,運行該定時器的對應回調函數。

      若是在8點1分3秒過程當中有網絡事件到達,epoll_wait會在3秒鐘返回,返回後處理完對應的網絡事件回調函數,而後繼續aeMain循環體中遍歷定時器鏈表,獲取離當前時間最近的定時器時間爲5-3=2秒,也就是還有2秒該定時器纔會到期,因而在下一個epoll_wait中,設置timeout超時時間爲2秒,以此循環。

5.2 兩種不一樣的定時器(週期性定時器、一次性定時器)

    週期性定時器: 指的是定時器到期對應的回調函數執行後,須要從新設置該定時器的超時時間,以備下一個週期繼續執行。

    一次性定時器: 本次定時時間到執行完對應的回調函數後,把該定時器從定時器鏈表刪除。

    兩種定時器代碼主要代碼流程區別以下:

      

5.3 主要數據結構及函數實現

主要數據結構以下:

typedef struct aeEventLoop {

     // 時間事件,全部的定時器時間都添加到該鏈表中

   aeTimeEvent *timeEventHead;

}

主要函數實現參考:

aeCreateTimeEvent

aeDeleteTimeEvent

aeSearchNearestTimer

processTimeEvents

 

6. 經常使用套接字選項設置

套接字選項能夠經過setsockopt函數進行設置,函數聲明以下:

 int setsockopt( int socket, int level, int option_name, const void *option_value, size_t option_len);

setsockopt參數說明以下:

    socket: 能夠是bind/listen對應的sd,也能夠是accept獲取到的新鏈接fd。

    level: 參數level是被設置的選項的級別,套接字級別對應 SOL_SOCKET,tcp網絡設置級別對應IPPROTO_TCP.

    option_name: 選項類型。

    optval:指針,指向存放選項待設置的新值的緩衝區

    optlen:optval緩衝區長度。

6.1 SOL_SOCKET級別套接字選項

    Level級別爲SOL_SOCKET的option_name經常使用類型以下(說明:網絡I/O的文件描述符句柄有兩類,一類是針對socket()/bind/listen對應的sd,一類是新鏈接到來後accept返回的新的鏈接句柄fd):

    SO_REUSEADDR: 複用地址,針對socket()/bind/listen對應的sd,避免服務端進程退出再重啓後出現error:98,Address already in use。

    SO_RECVBUF: 設置鏈接fd對應的內核網絡協議棧接收緩衝區buf大小,每一個鏈接都會有一個recv buf來接收客戶端發送的數據。實際應用中,使用默認值就能夠,但若是鏈接過多,負載過大,內存可能吃不消,這時候能夠調小該值。

    SO_SNDBUF:設置鏈接fd對應的內核網絡協議棧發送緩衝區buf大小,每一個鏈接都會有一個send buf來緩存須要發送的鏈接數據。實際應用中,使用默認值就能夠,但若是鏈接過多,負載過大,內存可能吃不消,這時候能夠調小該值。

    SO_KEEPALIVE:針對socket()/bind/listen對應的sd,設置TCP的keepalive機制,由內核網絡協議棧實現鏈接保活。經過該設置能夠判斷對端異常斷電、網絡不通的鏈接問題(如網線鬆動)。由於客戶端異常斷電或者網線鬆動,服務端是不會有epoll異常事件通知的。若是沒有設計應用層保活超時報文,則能夠依賴協議棧keepalive來檢測鏈接是否異常。

    SO_LINGER:決定關閉鏈接fd的方式,由於關閉鏈接的時候,該fd對應的內核協議棧buf可能數據尚未發送出去,若是強制當即關閉可能會出現丟數據的狀況。能夠根據傳入optval參數決定當即關閉鏈接(可能丟數據),仍是等待數據發送完畢後關閉釋放鏈接或者超時關閉鏈接。

    SO_RCVTIMEO:針對sd和新鏈接fd,接收數據超時時間,這個針對阻塞讀方式。若是read超過這麼多時間尚未獲取到內核協議棧數據,則超時返回。

    SO_SNDTIMEO:針對sd和新鏈接fd,發送數據超時時間,這個針對阻塞寫方式。若是write超過這麼多時間尚未把數據成功寫入到內核協議棧,則超時返回。

6.2 IPPROTO_TCP級別套接字選項

Level級別爲IPPROTO_TCP的option_name經常使用類型以下:

    TCP_NODELAY:針對鏈接fd,是否啓用naggle算法,通常禁用,這樣能夠保證服務端快速回包,下降時延。

 

7. 阻塞、非阻塞

服務端和網絡I/O相關相關的幾個系統函數主要是:accept()接收客戶端鏈接、read()從內核網絡協議棧讀取客戶端發送來的數據、write()寫數據到內核協議棧buf,而後由內核調度發送出去。請參考阻塞demo和非阻塞demo。

7.1阻塞及其demo程序驗證說明

      網上相關說明不少,可是都比較抽象。這裏以服務端調用accept爲例說明:accept()函數會進行系統調用,從應用層走到內核空間,最終調用內核函數SYSCALL_DEFINE3(),若是accept對應的sd(socket/bind/listen對應的文件描述符)是阻塞調用(若是不進行設置,默認就是阻塞調用),SYSCALL_DEFINE3()對應的函數會判斷內核是否收到客戶端新鏈接,若是沒有則一直等待,直到有新鏈接到來或者超時纔會返回。

      從上面的描述能夠看出,若是是阻塞方式,accept()所在的現場會一直等待,整個線程不能作其餘事情,這就是阻塞。Accept()阻塞超時時間能夠經過上面的SO_RCVTIMEO設置。

      Read()和write()阻塞操做過程和acept()相似,只有在接收到數據和寫數據到協議棧成功纔會返回,或者超時返回,超時時間分別能夠經過SO_RCVTIMEO和SO_SNDTIMEO設置。

      下面以以下demo爲例,來體驗阻塞和非阻塞,如下是阻塞操做例子,分別對應服務端和客戶端代碼:

      服務端阻塞操做程序demo github地址

      對應客戶端程序demo github地址

        

        

      從上面的程序,服務端建立套接字後,綁定地址後開始監聽,而後阻塞accept()等待客戶端鏈接。若是客戶端有鏈接,則開始阻塞等待read()讀客戶端發送來的數據,讀到數據後打印返回,程序執行結束。

      客戶端程序建立好套接字,設置好須要鏈接的服務器Ip和端口,先延時10秒鐘纔開始鏈接服務器,鏈接成功後再次延時10秒,而後發送」block test message」字符串給服務端。

      經過CRT開兩個窗口,同時啓動服務端和客戶端程序,服務端打印信息以下:

        [root@localhost block_noblock_demo]# gcc block_server.c -o block_server

        [root@localhost block_noblock_demo]#

        [root@localhost block_noblock_demo]# ./block_server 1234

        begin accept //在這裏阻塞等待客戶端鏈接

        accept successful from client

 

        begin recv message //這裏阻塞等待客戶端發送數據過來

        recv message:block test message from client

        [root@localhost block_noblock_demo]#

        客戶端打印信息以下:

         [root@localhost block_noblock_demo]# gcc block_client.c -o block_client

         [root@localhost block_noblock_demo]# ./block_client 127.0.0.1 1234

         begin connect  //begin和end見有10s延時

         end connect

 

         begin send message //begin和end間有10s延時

         end send message:block test message to server

      從運行服務端程序和客戶端程序的打印能夠看出,若是客戶端不發起鏈接,服務端accept()函數會阻塞等待,知道有新鏈接到來纔會返回。同時啓用服務端和客戶端程序,服務端accept()函數10s纔會返回,由於客戶端我故意作了10s延時。Read()阻塞讀函數過程和accept()相似。

       Write()阻塞驗證過程,服務端設置好該連接對應的內核網絡協議棧發送緩存區大小,而後傳遞很大的一個數據給write函數,指望把這個大數據經過write函數寫入到內核協議棧發送緩存區。若是內核協議棧緩存區可用buf空間比須要write的數據大,則數據經過write函數拷貝到內核發送緩存區後會立馬返回。爲了驗證write的阻塞過程,我這裏故意讓客戶端不去讀數據,這樣服務端write的數據就會緩衝到協議棧發送緩衝區,若是緩衝區空間沒那麼大。Write就會一直等待內核調度把發送緩衝區數據經過網卡發送出去,這樣就會騰出空間,繼續拷貝用戶態write須要寫的數據。因爲這裏我故意讓客戶端不讀數據,該連接對應的發送緩衝區很快就會寫滿,因爲我想要寫的數據比這個buf緩衝區大不少,那麼write函數就須要阻塞等待,直到把指望發送的數據所有寫入到該發送緩存區纔會返回,或者超過系統默認的write超時時間纔會返回。

7.2 非阻塞及其demo程序驗證說明

    非阻塞經過系統調用fcntl設置,函數代碼以下:

      

    函數中的fd文件描述符能夠是socket/bind/listen對應的sd,也能夠是accept獲取到的新鏈接fd。非阻塞程序demo github地址以下:

    服務端非阻塞程序demo github地址

    對應客戶端非阻塞程序demo github地址

    編譯程序,先啓動服務端程序,而後啓動客戶端程序,驗證方法以下:

       

    對應客戶端:

      

      客戶端啓動後,延遲10秒向服務端發起鏈接,鏈接創建成功後,延遲10秒向服務端發送數據。服務端啓動後,設置sd爲非阻塞,開始accept等待接收客戶端鏈接,若是accept系統調用沒有獲取到客戶端鏈接,則延時1秒鐘,而後繼續accept。因爲客戶端啓動後要延遲10s鍾才發起鏈接,所以accept會有十次accept return打印。read過程和accept相似,能夠查看demo代碼。

      咱們知道write操做是把用戶態向要發送的數據拷貝到內核態鏈接對應的send buf緩衝區,若是是阻塞方式,若是內核緩存區空間不夠,則write會阻塞等待。可是若是咱們把新鏈接的fd設置爲非阻塞,及時內核發送緩衝區空間不夠,write也會立馬返回,並返回本次寫入到內核空間的數據量,不會阻塞等待。能夠經過運行demo本身來體驗這個過程。

     你們應該注意到,則非阻塞操做服務端demo中,accept(),read()若是沒有返回咱們想要的鏈接或者數據,demo中作了sleep延時,爲何這裏要作點延時呢?

      緣由是若是不作延時,這裏會不停的進行accept read系統調用,系統調用過程是個很是消耗性能的過程,會形成CPU的大量浪費。假設咱們不加sleep延時,經過top能夠查看到以下現象:

      

 

8. 同步、異步

    同步和異步是比較抽象的概念,仍是用程序demo來講明。

8.1 同步

    上面的服務端阻塞操做程序demo github地址服務端非阻塞操做程序demo github地址實際上都是同步調用的過程。這兩個demo都是單線程的,以accept()調用爲例,不論是阻塞操做仍是非阻塞操做,因爲服務端不知道客戶端合適發起鏈接,所以只能阻塞等待,或者非阻塞輪訓查詢。不論是阻塞等待仍是輪訓查詢,效率都很是低下,整個線程不能作其餘工做,CPU徹底利用不起來。

8.2 異步

       藉助redis已有的網絡相關.c和.h文件,半小時快速實現一個epoll異步網絡框架,程序demo,這個demo是異步操做。仍是以該demo的accept爲例說明,從這個demo能夠看出,sd設置爲非阻塞,藉助epoll機制,當有新的鏈接事件到來後,觸發epoll_wait返回,並返回全部的文件描述符對應的讀寫事件,這樣就觸發執行對應的新鏈接回調函數MainAcceptTcpHandler。藉助epoll事件通知機制,就避免了前面兩個demo的阻塞等待過程和輪訓查詢過程,整個accept()操做由事件觸發,沒必要輪訓等待。本異步網絡框架demo也是單線程,就不存在前面兩個demo只能作accept這一件事,若是沒有accept事件到來,本異步網絡框架demo線程還能夠處理其餘已有鏈接的讀寫事件,這樣線程CPU資源也就充分利用起來了。

      打個形象的比喻,假設咱們每一年單位都有福利體檢,體檢後一到兩週出體檢結果,想要獲取體檢結果有兩種方式。第一種方式: 體檢結束一週後,你就坐在醫院一直等,直到體檢結果出來,整個過程你是沒法正常去單位上班的(這就至關於前面的服務端阻塞demo方式)。第二種方式:你天天都跑去體檢醫院詢問,個人體檢結果出了嗎,若是沒有,次日有去體檢醫院,以此重複,直到有一天你去體檢醫院拿到體檢結果。在你天天去醫院詢問是否已經出體檢結果的過程當中,你是不能正常上班的(這種方式相似於前面的服務端非阻塞demo方式)。第三種方式:你天天正常上班,等體檢醫院打電話通知你拿體檢結果,你再去拿,電話通知你拿體檢結果的過程就至關於異步事件通知,這樣你就能夠正常上班了。第1、二種方式就是同步操做,第三種方式就是異步操做。

    總結: 同步和異步的區別就是異步操做藉助epoll的事件通知機制,從而能夠充分利用CPU資源。

相關文章
相關標籤/搜索