Redis奇幻之旅(三)2. 事件

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í)間。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請(qǐng)通過簡信或評(píng)論聯(lián)系作者。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容