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