本文檔配合主要對以下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的主要緣由以下:算法
epoll克服了select和poll的缺點,採用回調方式來檢測就緒事件,算法時間複雜度o(1),相比於select和poll,效率獲得了很大的提高。mongodb
藉助epoll的事件回調通知機制,工做線程能夠在沒有網絡事件通知的時候作其餘工做,這樣能夠最大限度的利用系統cpu資源,服務端不用再阻塞等待客戶端網絡事件,而是依賴epoll事件通知機制來避免同步等待。編程
函數聲明: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。
函數聲明: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網絡事件通知,則會超時返回,若是在超時時間內有時間通知,則立馬返回
代碼實現主要由如下幾個階段組成:
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數組長度大於配置的最大鏈接數,從而避免數組越界。
主要函數功能請參考如下幾個函數:
aeCreateFileEvent
aeDeleteFileEvent
aeProcessEvents
aeApiAddEvent
aeApiDelEvent
aeApiPoll
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秒,以此循環。
週期性定時器: 指的是定時器到期對應的回調函數執行後,須要從新設置該定時器的超時時間,以備下一個週期繼續執行。
一次性定時器: 本次定時時間到執行完對應的回調函數後,把該定時器從定時器鏈表刪除。
兩種定時器代碼主要代碼流程區別以下:
主要數據結構以下:
typedef struct aeEventLoop {
// 時間事件,全部的定時器時間都添加到該鏈表中
aeTimeEvent *timeEventHead;
}
主要函數實現參考:
aeCreateTimeEvent
aeDeleteTimeEvent
aeSearchNearestTimer
processTimeEvents
套接字選項能夠經過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: 選項類型。
optlen:optval緩衝區長度。
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超過這麼多時間尚未把數據成功寫入到內核協議棧,則超時返回。
Level級別爲IPPROTO_TCP的option_name經常使用類型以下:
TCP_NODELAY:針對鏈接fd,是否啓用naggle算法,通常禁用,這樣能夠保證服務端快速回包,下降時延。
7. 阻塞、非阻塞
服務端和網絡I/O相關相關的幾個系統函數主要是:accept()接收客戶端鏈接、read()從內核網絡協議棧讀取客戶端發送來的數據、write()寫數據到內核協議棧buf,而後由內核調度發送出去。請參考阻塞demo和非阻塞demo。
網上相關說明不少,可是都比較抽象。這裏以服務端調用accept爲例說明:accept()函數會進行系統調用,從應用層走到內核空間,最終調用內核函數SYSCALL_DEFINE3(),若是accept對應的sd(socket/bind/listen對應的文件描述符)是阻塞調用(若是不進行設置,默認就是阻塞調用),SYSCALL_DEFINE3()對應的函數會判斷內核是否收到客戶端新鏈接,若是沒有則一直等待,直到有新鏈接到來或者超時纔會返回。
從上面的描述能夠看出,若是是阻塞方式,accept()所在的現場會一直等待,整個線程不能作其餘事情,這就是阻塞。Accept()阻塞超時時間能夠經過上面的SO_RCVTIMEO設置。
Read()和write()阻塞操做過程和acept()相似,只有在接收到數據和寫數據到協議棧成功纔會返回,或者超時返回,超時時間分別能夠經過SO_RCVTIMEO和SO_SNDTIMEO設置。
下面以以下demo爲例,來體驗阻塞和非阻塞,如下是阻塞操做例子,分別對應服務端和客戶端代碼:
從上面的程序,服務端建立套接字後,綁定地址後開始監聽,而後阻塞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超時時間纔會返回。
非阻塞經過系統調用fcntl設置,函數代碼以下:
函數中的fd文件描述符能夠是socket/bind/listen對應的sd,也能夠是accept獲取到的新鏈接fd。非阻塞程序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來講明。
上面的服務端阻塞操做程序demo github地址和服務端非阻塞操做程序demo github地址實際上都是同步調用的過程。這兩個demo都是單線程的,以accept()調用爲例,不論是阻塞操做仍是非阻塞操做,因爲服務端不知道客戶端合適發起鏈接,所以只能阻塞等待,或者非阻塞輪訓查詢。不論是阻塞等待仍是輪訓查詢,效率都很是低下,整個線程不能作其餘工做,CPU徹底利用不起來。
藉助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資源。