2. 事件
? 在講Redis的事件之前我們必須要聊一個話題 —— 面試問到Redis基本上必問這樣一個問題:Redis是個單線程的程序,為什么還能這么快?很多同學(xué)就會卡在這了......事實(shí)上我們不應(yīng)該有思維定式,單線程就一定要比多線程慢嗎?如果三個開發(fā)寫一個有很多耦合業(yè)務(wù)代碼的項(xiàng)目,這三個開發(fā)一定苦不堪言,為什么?因?yàn)楦髯詫懲暌惶状a后,在合并的時候發(fā)現(xiàn)有很多conflict需要處理,處理的時候發(fā)現(xiàn)思路并不統(tǒng)一,最后導(dǎo)致代碼需要重新搭建結(jié)構(gòu)。除了時間成本的消耗,這樣寫的代碼極有可能出錯,最終不一定比一個人寫這個項(xiàng)目來的快、來的準(zhǔn)確。多線程處理的時候也有很多“思路不統(tǒng)一”的情況出現(xiàn),比如說兩個線程并行各自對同一個數(shù)加1,一直加100次,最后的結(jié)果你會發(fā)現(xiàn)這個數(shù)并沒有加200,一般情況都是加了100多,這就是線程安全問題。單線程就很好的避免了這種問題,并且也不會有線程切換所帶來的額外開支。當(dāng)然除了線程安全問題和切換線程的額外開支,Redis使用單線程還那么快是有原因的,就是我們常說的I/O多路復(fù)用。
2.1 I/O多路復(fù)用
這部分內(nèi)容基本都取自這篇文章
2.1.1 Linux文件
? 程序員使用I/O最終都逃不過文件這個概念。
? 在Linux世界中文件是一個很簡單的概念,作為程序員我們只需要將其理解為一個N byte的序列就可以了:
? b1, b2, b3, b4, ....... bN
? 實(shí)際上所有的I/O設(shè)備都被抽象為了文件這個概念,一切皆文件,Everything is File,磁盤、網(wǎng)絡(luò)數(shù)據(jù)、終端,甚至進(jìn)程間通信工具管道pipe等都被當(dāng)做文件對待。
? 所有的I/O操作也都可以通過文件讀寫來實(shí)現(xiàn),這一非常優(yōu)雅的抽象可以讓程序員使用一套接口就能對所有外設(shè)I/O操作。
? 常用的I/O操作接口一般有以下幾類:
打開文件,open
改變讀寫位置,seek
文件讀寫,read、write
關(guān)閉文件,close
? 程序員通過這幾個接口幾乎可以實(shí)現(xiàn)所有I/O操作,這就是文件這個概念的強(qiáng)大之處。
2.1.2 文件描述符
? 如果周末你去比較火的餐廳吃飯應(yīng)該會有體會,一般周末人氣高的餐廳都會排隊(duì),然后服務(wù)員會給你一個排隊(duì)序號,通過這個序號服務(wù)員就能找到你,這里的好處就是服務(wù)員無需記住你是誰、你的名字是什么、來自哪里、喜好是什么、是不是保護(hù)環(huán)境愛護(hù)小動物等等,這里的關(guān)鍵點(diǎn)就是服務(wù)員對你一無所知,但依然可以通過一個號碼就能找到你。
? 同樣的,在Linux世界要想使用文件,我們也需要借助一個號碼,根據(jù)“弄不懂原則”,這個號碼就被稱為了文件描述符,file descriptors,在Linux世界中鼎鼎大名,其道理和上面那個排隊(duì)號碼一樣。
? 因此,文件描述僅僅就是一個數(shù)字而已,但是通過這個數(shù)字我們可以操作一個打開的文件,這一點(diǎn)要記住。
? 有了文件描述符,進(jìn)程可以對文件一無所知,比如文件在磁盤的什么位置、加載到內(nèi)存中又是怎樣管理的等等,這些信息統(tǒng)統(tǒng)交由操作系統(tǒng)打理,進(jìn)程無需關(guān)心,操作系統(tǒng)只需要給進(jìn)程一個文件描述符就足夠了。
2.1.3 多路復(fù)用技術(shù)實(shí)現(xiàn)
? 當(dāng)我們文件描述符巨多的時候,如果每個文件的I/O操作都用單線程阻塞的方式來處理,那么這個處理能力就極大地被限制了,所以Linux就提供了三種機(jī)制來幫助我們以非阻塞的方式來對文件進(jìn)行I/O操作。這就是select、poll、epoll
-
select
? 在select這種I/O多路復(fù)用機(jī)制下,我們需要把想監(jiān)控的文件描述集合通過函數(shù)參數(shù)的形式告訴select,然后select會將這些文件描述符集合拷貝到內(nèi)核中,我們知道數(shù)據(jù)拷貝是有性能損耗的,因此為了減少這種數(shù)據(jù)拷貝帶來的性能損耗,Linux內(nèi)核對集合的大小做了限制,并規(guī)定用戶監(jiān)控的文件描述集合不能超過1024個,同時當(dāng)select返回后我們僅僅能知道有些文件描述符可以讀寫了,但是我們不知道是哪一個,因此程序員必須再遍歷一邊找到具體是哪個文件描述符可以讀寫了。
? 因此,總結(jié)下來select有這樣幾個特點(diǎn):
? 我能照看的文件描述符數(shù)量有限,不能超過1024個
? 用戶給我的文件描述符需要拷貝的內(nèi)核中
? 我只能告訴你有文件描述符滿足要求了,但是我不知道是哪個,你自己一個一個去找吧(遍歷)
? 我們可以看到,select機(jī)制的這些特性在高并發(fā)網(wǎng)絡(luò)服務(wù)器動輒幾萬幾十萬并發(fā)鏈接的場景下無疑是低效的。
-
poll
? poll和select是非常相似的,poll相對于select的優(yōu)化僅僅在于解決了文件描述符不能超過1024個的限制,select和poll都會隨著監(jiān)控的文件描述數(shù)量增加而性能下降,因此不適合高并發(fā)場景。
-
epoll
? 在select面臨的三個問題中,文件描述數(shù)量限制已經(jīng)在poll中解決了,剩下的兩個問題呢?
? 針對拷貝問題,epoll使用的策略是各個擊破與共享內(nèi)存。
? 實(shí)際上文件描述符集合的變化頻率比較低,select和poll頻繁的拷貝整個集合,內(nèi)核都快被煩死了,epoll通過引入epoll_ctl很體貼的做到了只操作那些有變化的文件描述符,同時epoll和內(nèi)核還成為了好朋友,共享了同一塊內(nèi)存,這塊內(nèi)存中保存的就是那些已經(jīng)可讀或者可寫的的文件描述符集合,這樣就減少了內(nèi)核和程序的拷貝開銷。
? 針對需要遍歷文件描述符才能知道哪個可讀可寫這一問題,epoll使用的策略是“當(dāng)小弟”。
? 在select和poll機(jī)制下,進(jìn)程要親自下場去各個文件描述符上等待,任何一個文件描述符可讀或者可寫就喚醒進(jìn)程,但是進(jìn)程被喚醒后也是一臉懵逼并不知道到底是哪個文件描述符可讀或可寫,還要再從頭到尾檢查一遍。
? 但epoll就懂事多了,主動找到進(jìn)程要當(dāng)小弟替大哥出頭。
? 在這種機(jī)制下,進(jìn)程不需要親自下場了,進(jìn)程只要等待在epoll上,epoll代替進(jìn)程去各個文件描述符上等待,當(dāng)哪個文件描述符可讀或者可寫的時候就告訴epoll,epoll用小本本認(rèn)真記錄下來然后喚醒大哥:“進(jìn)程大哥,快醒醒,你要處理的文件描述符我都記下來了”,這樣進(jìn)程被喚醒后就無需自己從頭到尾檢查一遍,因?yàn)閑poll小弟都已經(jīng)記下來了。
? 因此我們可以看到,在epoll這種機(jī)制下,實(shí)際上利用的就是“不要打電話給我,有需要我會打給你”這種策略,進(jìn)程不需要一遍一遍麻煩的問各個文件描述符,而是翻身做主人了,“你們這些文件描述符有哪個可讀或者可寫了主動報上來”,這種機(jī)制實(shí)際上就是大名鼎鼎的事件驅(qū)動,Event-driven,實(shí)際上在Linux平臺,epoll基本上就是高并發(fā)的代名詞。
2.2 文件事件
? 前面之所以先講了I/O多路復(fù)用就是因?yàn)镽edis的文件事件就是用這個方法來監(jiān)聽套接字。
? 文件事件是對套接字操作的抽象,每當(dāng)套接字發(fā)生連接應(yīng)答、寫入、讀取、關(guān)閉等操作時,就會產(chǎn)生一個文件事件。
? Redis的文件事件處理器是一個基于Reactor模式實(shí)現(xiàn)的網(wǎng)絡(luò)通信程序,它由四個部分組成,分別是:套接字、I/O多路復(fù)用程序、文件事件分派器、事件處理器。它的運(yùn)行流程如下:I/O多路復(fù)用程序負(fù)責(zé)監(jiān)聽多個套接字,當(dāng)有文件事件產(chǎn)生的時候,I/O多路復(fù)用程序就會將文件事件按順序放入一個隊(duì)列。文件事件分派器會從這個隊(duì)列中取到文件事件并根據(jù)不同的事件來關(guān)聯(lián)不同的事件處理器去執(zhí)行相應(yīng)的處理。
? 常見的事件處理器:
-
連接應(yīng)答處理器
Redis服務(wù)器初始化的時候會將連接應(yīng)答處理器和服務(wù)器監(jiān)聽套接字的AE_READABLE事件關(guān)聯(lián)起來,當(dāng)有客戶端主動連接的時候就會觸發(fā)套接字的AE_READABLE事件,連接應(yīng)答處理器就會執(zhí)行相應(yīng)的套接字應(yīng)答操作。
-
命令請求處理器
客戶端完成連接之后,客戶端套接字的AE_READABLE事件就會和命令請求處理器關(guān)聯(lián)起來,當(dāng)有命令請求的時候,就會調(diào)用命令請求處理器來處理具體的命令。
-
命令回復(fù)處理器
服務(wù)器產(chǎn)生命令回復(fù)的時候,就會將客戶端套接字的AE_WRITEABLE事件和命令回復(fù)處理器關(guān)聯(lián)起來,客戶端套接字AE_WRITEABLE事件產(chǎn)生后,就會執(zhí)行命令回復(fù)處理器相關(guān)的代碼。當(dāng)命令回復(fù)完畢,就會斷開客戶端套接字的AE_WRITEABLE事件和命令回復(fù)處理器之間的關(guān)聯(lián)。
2.3 時間事件
? Redis的時間事件目前只有一個,那就是周期任務(wù)serverCron,就像linux的crontab一樣,每100ms執(zhí)行一次。
? 來看一下時間事件的數(shù)據(jù)結(jié)構(gòu):
typedef struct aeTimeEvent {
long long id; /* time event identifier. */
monotime when;
aeTimeProc *timeProc;
aeEventFinalizerProc *finalizerProc;
void *clientData;
struct aeTimeEvent *prev;
struct aeTimeEvent *next;
int refcount; /* refcount to prevent timer events from being
* freed in recursive time event calls. */
} aeTimeEvent;
? 看到前后指針我們就知道時間事件的數(shù)據(jù)結(jié)構(gòu)是個雙向鏈表結(jié)構(gòu),且相對于when字段來說是個無序鏈表。如果想要獲取最近一個任務(wù)的時間,需要遍歷整個鏈表也就是O(n)的時間復(fù)雜度。不過對于Redis(非benchmark模式)來說,目前只有serverCron一個任務(wù),即使我們用Redis cluster,也不過是在serverCron里再調(diào)用clusterCron函數(shù),所以實(shí)際上每次獲取最近一個任務(wù)時間的復(fù)雜度是O(1)。
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...
/* Run the Redis Cluster cron. */
run_with_period(100) {
if (server.cluster_enabled) clusterCron();
}
...
}
? 那為什么要獲取最近一個任務(wù)的時間呢?我們將Redis的main函數(shù)用偽代碼表示一下,你就知道了。
import time
def main():
# 初始化服務(wù)器
init_server()
while True:
# 獲取最近一個任務(wù)的時間
remaind_ms = time_event.when - (time.time() * 1000)
if remaind_ms < 0:
remaind_ms = 0
timeval = create_timeval_with_ms(remaind_ms)
aeApiPoll(timeval) # I/O多路復(fù)用程序,remaind_ms的值就是它阻塞的時間
processFileEvents() # 處理文件事件
processTimeEvents() # 處理時間事件
? 看這段偽代碼我們就知道了,實(shí)際上獲取最近一個任務(wù)的時間是為了計算I/O多路復(fù)用程序阻塞多長時間。