redis原理之事件

Redis服務器是一個事件驅動程序,服務器需要處理以下兩類事件:
■ 文件事件( le event):Redis服務器通過套接字與客戶端(或者其他Redis服務器)進行連接,而文件事件就是服務器對套接字操作的抽象。服務器與客戶端(或者其他服務器)的通信會產生相應的文件事件,而服務器則通過監(jiān)聽并處理這些事件來完成一系列網絡通信操作。
■ 時間事件(time event):Redis服務器中的一些操作(比如serverCron函數)需要在給定的時間點執(zhí)行,而時間事件就是服務器對這類定時操作的抽象。

12.1 文件事件
Redis基于Reactor模式開發(fā)了自己的網絡事件處理器:這個處理器被稱為文件事件處理器( le event handler):

■ 文件事件處理器使用I/O多路復用(multiplexing)程序來同時監(jiān)聽多個套接字,并根據套接字目前執(zhí)行的任務來為套接字關聯(lián)不同的事件處理器。
■ 當被監(jiān)聽的套接字準備好執(zhí)行連接應答(accept)、讀?。╮ead)、寫入(write)、關閉(close)等操作時,與操作相對應的文件事件就會產生,這時文件事件處理器就會調用套接字之前關聯(lián)好的事件處理器來處理這些事件。

雖然文件事件處理器以單線程方式運行,但通過使用I/O多路復用程序來監(jiān)聽多個套接字,文件事件處理器既實現(xiàn)了高性能的網絡通信模型,又可以很好地與Redis服務器中其他同樣以單線程方式運行的模塊進行對接,這保持了Redis內部單線程設計的簡單性。

12.1.1 文件事件處理器的構成

圖12-1展示了文件事件處理器的四個組成部分,它們分別是套接字、I/O多路復用程序、文件事件分派器(dispatcher),以及事件處理器。

image.png

文件事件是對套接字操作的抽象,每當一個套接字準備好執(zhí)行連接應答(accept)、寫入、讀取、關閉等操作時,就會產生一個文件事件。因為一個服務器通常會連接多個套接字,所以多個文件事件有可能會并發(fā)地出現(xiàn)。

I/O多路復用程序負責監(jiān)聽多個套接字,并向文件事件分派器傳送那些產生了事件的套接字。

盡管多個文件事件可能會并發(fā)地出現(xiàn),但I/O多路復用程序總是會將所有產生事件的套接字都放到一個隊列里面,然后通過這個隊列,以有序(sequentially)、同步(synchronously)、每次一個套接字的方式向文件事件分派器傳送套接字。當上一個套接字產生的事件被處理完畢之后(該套接字為事件所關聯(lián)的事件處理器執(zhí)行完畢),I/O多路復用程序才會繼續(xù)向文件事件分派器傳送下一個套接字,如圖12-2所示。


image.png

文件事件分派器接收I/O多路復用程序傳來的套接字,并根據套接字產生的事件的類型,調用相應的事件處理器。

服務器會為執(zhí)行不同任務的套接字關聯(lián)不同的事件處理器,這些處理器是一個個函數,它們定義了某個事件發(fā)生時,服務器應該執(zhí)行的動作。

12.1.2 I/O多路復用程序的實現(xiàn)
Redis的I/O多路復用程序的所有功能都是通過包裝常見的select、epoll、evport和kqueue這些I/O多路復用函數庫來實現(xiàn)的,每個I/O多路復用函數庫在Redis源碼中都對應一個單獨的文件,比如ae_select.c、ae_epoll.c、ae_kqueue.c,諸如此類。

因為Redis為每個I/O多路復用函數庫都實現(xiàn)了相同的API,所以I/O多路復用程序的底層實現(xiàn)是可以互換的,如圖12-3所示


image.png

Redis在I/O多路復用程序的實現(xiàn)源碼中用#include宏定義了相應的規(guī)則,程序會在編譯時自動選擇系統(tǒng)中性能最高的I/O多路復用函數庫來作為Redis的I/O多路復用程序的底層實現(xiàn):

/* Include the best multiplexing layer supported by this system.
  * The following should be ordered by performances, descending. */
# ifdef HAVE_EVPORT
# include "ae_evport.c"
# else
    # ifdef HAVE_EPOLL
    # include "ae_epoll.c"
    # else
        # ifdef HAVE_KQUEUE
        # include "ae_kqueue.c"
        # else
        # include "ae_select.c"
        # endif
    # endif
# endif

12.1.3 事件的類型
I/O多路復用程序可以監(jiān)聽多個套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,這兩類事件和套接字操作之間的對應關系如下:

■ 當套接字變得可讀時(客戶端對套接字執(zhí)行write操作,或者執(zhí)行close操作),或者有新的可應答(acceptable)套接字出現(xiàn)時(客戶端對服務器的監(jiān)聽套接字執(zhí)行connect操作),套接字產生AE_READABLE事件。
■ 當套接字變得可寫時(客戶端對套接字執(zhí)行read操作,server維護的client wirte時),套接字產生AE_WRITABLE事件。

I/O多路復用程序允許服務器同時監(jiān)聽套接字的AE_READABLE事件和AE_WRITABLE事件,如果一個套接字同時產生了這兩種事件,那么文件事件分派器會優(yōu)先處理AE_READABLE事件,等到AE_READABLE事件處理完之后,才處理AE_WRITABLE事件。

這也就是說,如果一個套接字又可讀又可寫的話,那么服務器將先讀套接字,后寫套接字。

ae.c/aeCreateFileEvent函數接受一個套接字描述符、一個事件類型,以及一個事件處理器作為參數,將給定套接字的給定事件加入到I/O多路復用程序的監(jiān)聽范圍之內,并對事件和事件處理器進行關聯(lián)。

ae.c/aeDeleteFileEvent函數接受一個套接字描述符和一個監(jiān)聽事件類型作為參數,讓I/O多路復用程序取消對給定套接字的給定事件的監(jiān)聽,并取消事件和事件處理器之間的關聯(lián)。

ae.c/aeGetFileEvents函數接受一個套接字描述符,返回該套接字正在被監(jiān)聽的事件類型:
■ 如果套接字沒有任何事件被監(jiān)聽,那么函數返回AE_NONE。
■ 如果套接字的讀事件正在被監(jiān)聽,那么函數返回AE_READABLE。
■ 如果套接字的寫事件正在被監(jiān)聽,那么函數返回AE_WRITABLE。
■如果套接字的讀事件和寫事件正在被監(jiān)聽,那么函數返回AE_READABLE | AE_WRITABLE。

ae.c/aeWait函數接受一個套接字描述符、一個事件類型和一個毫秒數為參數,在給定的時間內阻塞并等待套接字的給定類型事件產生,當事件成功產生,或者等待超時之后,函數返回。

ae.c/aeApiPoll函數接受一個sys/time.h/struct timeval結構為參數,并在指定的時間內,阻塞并等待所有被aeCreateFileEvent函數設置為監(jiān)聽狀態(tài)的套接字產生文件事件,當有至少一個事件產生,或者等待超時后,函數返回

ae.c/aeProcessEvents函數是文件事件分派器,它先調用aeApiPoll函數來等待事件產生,然后遍歷所有已產生的事件,并調用相應的事件處理器來處理這些事件。
ae.c/aeGetApiName函數返回I/O多路復用程序底層所使用的I/O多路復用函數庫的名稱:返回"epoll"表示底層為epoll函數庫,返回"select"表示底層為select函數庫,諸如此類。

12.1.5 文件事件的處理器
Redis為文件事件編寫了多個處理器,這些事件處理器分別用于實現(xiàn)不同的網絡通信需求,比如說:

■ 為了對連接服務器的各個客戶端進行應答,服務器要為監(jiān)聽套接字關聯(lián)連接應答處理器。
■ 為了接收客戶端傳來的命令請求,服務器要為客戶端套接字關聯(lián)命令請求處理器。
■ 為了向客戶端返回命令的執(zhí)行結果,服務器要為客戶端套接字關聯(lián)命令回復處理器。
■ 當主服務器和從服務器進行復制操作時,主從服務器都需要關聯(lián)特別為復制功能編寫的復制處理器。

在這些事件處理器里面,服務器最常用的要數與客戶端進行通信的連接應答處理器、命令請求處理器和命令回復處理器。

1.連接應答處理器
networking.c/acceptTcpHandler函數是Redis的連接應答處理器,這個處理器用于對連接服務器監(jiān)聽套接字的客戶端進行應答,具體實現(xiàn)為sys/socket.h/accept函數的包裝。

當Redis服務器進行初始化的時候,程序會將這個連接應答處理器和服務器監(jiān)聽套接字的AE_READABLE事件關聯(lián)起來,當有客戶端用sys/socket.h/connect函數連接服務器監(jiān)聽套接字的時候,套接字就會產生AE_READABLE事件,引發(fā)連接應答處理器執(zhí)行,并執(zhí)行相應的套接字應答操作,如圖12-4所示。


image.png
  1. 命令請求處理器
    networking.c/readQueryFrom
    Client函數是Redis的命令請求處理器,這個處理器負責從套接字中讀入客戶端發(fā)送的命令請求內容,具體實現(xiàn)為unistd.h/read函數的包裝。

當一個客戶端通過連接應答處理器成功連接到服務器之后,服務器會將客戶端套接字的AE_READABLE事件和命令請求處理器關聯(lián)起來,當客戶端向服務器發(fā)送命令請求的時候,套接字就會產生AE_READABLE事件,引發(fā)命令請求處理器執(zhí)行,并執(zhí)行相應的套接字讀入操作,如圖12-5所示。

image.png

在客戶端連接服務器的整個過程中,服務器都會一直為客戶端套接字的AE_READABLE事件關聯(lián)命令請求處理器。

  1. 命令回復處理器
    networking.c/sendReplyToClient函數是Redis的命令回復處理器,這個處理器負責將服務器執(zhí)行命令后得到的命令回復通過套接字返回給客戶端,具體實現(xiàn)為unistd.h/write函數的包裝。
    當服務器有命令回復需要傳送給客戶端的時候,服務器會將客戶端套接字的AE_WRITABLE事件和命令回復處理器關聯(lián)起來,當客戶端準備好接收服務器傳回的命令回復時,就會產生AE_WRITABLE事件,引發(fā)命令回復處理器執(zhí)行,并執(zhí)行相應的套接字寫入操作,如圖12-6所示。
image.png

當命令回復發(fā)送完畢之后,服務器就會解除命令回復處理器與客戶端套接字的AE_WRITABLE事件之間的關聯(lián)。

  1. 一次完整的客戶端與服務器連接事件示例

讓我們來追蹤一次Redis客戶端與服務器進行連接并發(fā)送命令的整個過程,看看在過程中會產生什么事件,而這些事件又是如何被處理的。

假設一個Redis服務器正在運作,那么這個服務器的監(jiān)聽套接字的AE_READABLE事件應該正處于監(jiān)聽狀態(tài)之下,而該事件所對應的處理器為連接應答處理器

如果這時有一個Redis客戶端向服務器發(fā)起連接,那么監(jiān)聽套接字將產生AE_READABLE事件,觸發(fā)連接應答處理器執(zhí)行。處理器會對客戶端的連接請求進行應答,然后創(chuàng)建客戶端套接字,以及客戶端狀態(tài),并將客戶端套接字的AE_READABLE事件與命令請求處理器進行關聯(lián),使得客戶端可以向主服務器發(fā)送命令請求。

之后,假設客戶端向主服務器發(fā)送一個命令請求,那么客戶端套接字將產生AE_READABLE事件,引發(fā)命令請求處理器執(zhí)行,處理器讀取客戶端的命令內容,然后傳給相關程序去執(zhí)行。

執(zhí)行命令將產生相應的命令回復,為了將這些命令回復傳送回客戶端,服務器會將客戶端套接字的AE_WRITABLE事件與命令回復處理器進行關聯(lián)。當客戶端嘗試讀取命令回復的時候,客戶端套接字將產生AE_WRITABLE事件(總感覺這里不對,AE_WRITABLE和客戶端有關系? 明明是server在wrtie到buf后手動設置的!!),觸發(fā)命令回復處理器執(zhí)行,當命令回復處理器將命令回復全部寫入到套接字之后,服務器就會解除客戶端套接字的AE_WRITABLE事件與命令回復處理器之間的關聯(lián)。

圖12-7總結了上面描述的整個通信過程,以及通信時用到的事件處理器。


image.png

12.2 時間事件

Redis的時間事件分為以下兩類:
■ 定時事件:讓一段程序在指定的時間之后執(zhí)行一次。比如說,讓程序X在當前時間的30 毫秒之后執(zhí)行一次。
■ 周期性事件:讓一段程序每隔指定時間就執(zhí)行一次。比如說,讓程序Y 每隔30 毫秒就執(zhí)行一次。

一個時間事件主要由以下三個屬性組成:
■ id:服務器為時間事件創(chuàng)建的全局唯一ID(標識號)。ID號按從小到大的順序遞增,新事件的ID號比舊事件的ID號要大。
■ when:毫秒精度的UNIX時間戳,記錄了時間事件的到達(arrive)時間。
■ timeProc:時間事件處理器,一個函數。當時間事件到達時,服務器就會調用相應的處理器來處理事件。

一個時間事件是定時事件還是周期性事件取決于時間事件處理器的返回值:
■ 如果事件處理器返回ae.h/AE_NOMORE,那么這個事件為定時事件:該事件在達到一次之后就會被刪除,之后不再到達。
■ 如果事件處理器返回一個非AE_NOMORE的整數值,那么這個事件為周期性時間:當一個時間事件到達之后,服務器會根據事件處理器返回的值,對時間事件的when屬性進行更新,讓這個事件在一段時間之后再次到達,并以這種方式一直更新并運行下去。比如說,如果一個時間事件的處理器返回整數值30,那么服務器應該對這個時間事件進行更新,讓這個事件在30毫秒之后再次到達。

目前版本的Redis只使用周期性事件,而沒有使用定時事件。

服務器將所有時間事件都放在一個無序鏈表中,每當時間事件執(zhí)行器運行時,它就遍歷整個鏈表,查找所有已到達的時間事件,并調用相應的事件處理器。

圖12-8展示了一個保存時間事件的鏈表的例子,鏈表中包含了三個不同的時間事件:因為新的時間事件總是插入到鏈表的表頭,所以三個時間事件分別按ID逆序排序,表頭事件的ID為3,中間事件的ID為2,表尾事件的ID為1。

image.png

注意,我們說保存時間事件的鏈表為無序鏈表,指的不是鏈表不按ID排序,而是說,該鏈表不按when屬性的大小排序。正因為鏈表沒有按when屬性進行排序,所以當時間事件執(zhí)行器運行的時候,它必須遍歷鏈表中的所有時間事件,這樣才能確保服務器中所有已到達的時間事件都會被處理。

在目前版本中,正常模式下的Redis服務器只使用serverCron一個時間事件,而在benchmark模式下,服務器也只使用兩個時間事件。在這種情況下,服務器幾乎是將無序鏈表退化成一個指針來使用,所以使用無序鏈表來保存時間事件,并不影響事件執(zhí)行的性能。

ae.c/aeCreateTimeEvent函數接受一個毫秒數milliseconds和一個時間事件處理器proc作為參數,將一個新的時間事件添加到服務器,這個新的時間事件將在當前時間的milliseconds毫秒之后到達,而事件的處理器為proc。

例如,如果服務器當前所保存的時間事件如圖12-9所示。


image.png

那么當程序以50毫秒和handler_3處理器為參數,在時間1385877599980(2013年12月1日零時前20毫秒)時調用aeCreateTimeEvent函數,服務器將創(chuàng)建ID為3的時間事件,這時服務器所保存的時間事件將如圖12-8所示。

ae.c/aeDeleteFileEvent函數接受一個時間事件ID作為參數,然后從服務器中刪除該ID所對應的時間事件。
舉個例子,如果服務器當前保存的時間事件如圖12-8所示,那么當程序調用aeDeleteFileEvent(3)之后,服務器保存的時間事件將變成圖12-9所示的樣子。
ae.c/aeSearchNearestTimer函數返回到達時間距離當前時間最接近的那個時間事件。
舉個例子,如果當前時間為1385877599980(2013年12月1日零時前20毫秒),而服務器當前保存的時間事件如圖12-8所示,那么調用aeSearchNearestTimer函數將返回ID為2的事件。

ae.c/processTimeEvents函數是時間事件的執(zhí)行器,這個函數會遍歷所有已到達的時間事件,并調用這些事件的處理器。已到達指的是,時間事件的when屬性記錄的UNIX時間戳等于或小于當前時間的UNIX時間戳。

舉個例子,如果服務器保存的時間事件如圖12-8所示,并且當前時間為1385877600010(2013年12月1日零時之后10毫秒),那么processTimeEvents函數將處理圖中ID為2和1的時間事件,因為這兩個事件的到達時間都大于等于1385877600010。

processTimeEvents函數的定義可以用以下偽代碼來描述

def processTimeEvents():

    # 遍歷服務器中的所有時間事件
    for time_event in all_time_event():

        # 檢查事件是否已經到達
        if time_event.when <= unix_ts_now():

            # 事件已到達
            # 執(zhí)行事件處理器,并獲取返回值
            retval = time_event.timeProc()

            # 如果這是一個定時事件
            if retval == AE_NOMORE:

                # 那么將該事件從服務器中刪除
                delete_time_event_from_server(time_event)

        # 如果這是一個周期性事件
        else:

            # 那么按照事件處理器的返回值更新時間事件的 when 屬性
            # 讓這個事件在指定的時間之后再次到達
            update_when(time_event, retval)

12.2.3 時間事件應用實例:serverCron函數
持續(xù)運行的Redis服務器需要定期對自身的資源和狀態(tài)進行檢查和調整,從而確保服務器可以長期、穩(wěn)定地運行,這些定期操作由redis.c/serverCron函數負責執(zhí)行,它的主要工作包括:
■ 更新服務器的各類統(tǒng)計信息,比如時間、內存占用、數據庫占用情況等。
■ 清理數據庫中的過期鍵值對。
■ 關閉和清理連接失效的客戶端。
■ 嘗試進行AOF或RDB持久化操作。
■ 如果服務器是主服務器,那么對從服務器進行定期同步。
■ 如果處于集群模式,對集群進行定期同步和連接測試。

Redis服務器以周期性事件的方式來運行serverCron函數,在服務器運行期間,每隔一段時間,serverCron就會執(zhí)行一次,直到服務器關閉為止。
在Redis2.6版本,服務器默認規(guī)定serverCron每秒運行10次,平均每間隔100毫秒運行一次。
從Redis2.8開始,用戶可以通過修改hz選項來調整serverCron的每秒執(zhí)行次數,具體信息請參考示例配置文件redis.conf關于hz選項的說明

12.3 事件的調度與執(zhí)行
因為服務器中同時存在文件事件和時間事件兩種事件類型,所以服務器必須對這兩種事件進行調度,決定何時應該處理文件事件,何時又應該處理時間事件,以及花多少時間來處理它們等等。
事件的調度和執(zhí)行由ae.c/aeProcessEvents函數負責,以下是該函數的偽代碼表示:

def aeProcessEvents():

    # 獲取到達時間離當前時間最接近的時間事件
    time_event = aeSearchNearestTimer()

    # 計算最接近的時間事件距離到達還有多少毫秒
    remaind_ms = time_event.when - unix_ts_now()

    # 如果事件已到達,那么remaind_ms的值可能為負數,將它設定為0
    if remaind_ms < 0:
        remaind_ms = 0

    # 根據remaind_ms的值,創(chuàng)建timeval結構
    timeval = create_timeval_with_ms(remaind_ms)

    # 阻塞并等待文件事件產生,最大阻塞時間由傳入的timeval結構決定
    # 如果remaind_ms的值為0,那么aeApiPoll調用之后馬上返回,不阻塞
    aeApiPoll(timeval)

    # 處理所有已產生的文件事件
    processFileEvents()

    # 處理所有已到達的時間事件
    processTimeEvents()

前面在介紹文件事件API的時候,并沒有講到processFileEvents這個函數,因為它并不存在,在實際中,處理已產生文件事件的代碼是直接寫在aeProcessEvents函數里面的,這里為了方便講述,才虛構了processFileEvents函數。

將aeProcessEvents函數置于一個循環(huán)里面,加上初始化和清理函數,這就構成了Redis服務器的主函數,以下是該函數的偽代碼表示:

def main():

    # 初始化服務器
    init_server()

    # 一直處理事件,直到服務器關閉為止
    while server_is_not_shutdown():
        aeProcessEvents()

    # 服務器關閉,執(zhí)行清理操作
    clean_server()

從事件處理的角度來看,Redis服務器的運行流程可以用流程圖12-10來概括。

image.png

以下是事件的調度和執(zhí)行規(guī)則:
1)aeApiPoll函數的最大阻塞時間由到達時間最接近當前時間的時間事件決定,這個方法既可以避免服務器對時間事件進行頻繁的輪詢(忙等待),也可以確保aeApiPoll函數不會阻塞過長時間。
2)因為文件事件是隨機出現(xiàn)的,如果等待并處理完一次文件事件之后,仍未有任何時間事件到達,那么服務器將再次等待并處理文件事件。隨著文件事件的不斷執(zhí)行,時間會逐漸向時間事件所設置的到達時間逼近,并最終來到到達時間,這時服務器就可以開始處理到達的時間事件了。

3)對文件事件和時間事件的處理都是同步、有序、原子地執(zhí)行的,服務器不會中途中斷事件處理,也不會對事件進行搶占,因此,不管是文件事件的處理器,還是時間事件的處理器,它們都會盡可地減少程序的阻塞時間,并在有需要時主動讓出執(zhí)行權,從而降低造成事件饑餓的可能性。比如說,在命令回復處理器將一個命令回復寫入到客戶端套接字時,如果寫入字節(jié)數超過了一個預設常量的話,命令回復處理器就會主動用break跳出寫入循環(huán),將余下的數據留到下次再寫;另外,時間事件也會將非常耗時的持久化操作放到子線程或者子進程執(zhí)行。
4)因為時間事件在文件事件之后執(zhí)行,并且事件之間不會出現(xiàn)搶占,所以時間事件的實際處理時間,通常會比時間事件設定的到達時間稍晚一些。

表12-1 記錄了一次完整的事件調度和執(zhí)行過程。


image.png

表12-1記錄的事件執(zhí)行過程凸顯了上面列舉的事件調度規(guī)則中的規(guī)則2、3、4:
■ 因為時間事件尚未到達,所以在處理時間事件之前,服務器已經等待并處理了兩次文件事件。
■ 因為處理事件的過程中不會出現(xiàn)搶占,所以實際處理時間事件的時間比預定的100 毫秒慢了30 毫秒。

總結

■ Redis服務器是一個事件驅動程序,服務器處理的事件分為時間事件和文件事件兩類。
■ 文件事件處理器是基于Reactor模式實現(xiàn)的網絡通信程序。
■ 文件事件是對套接字操作的抽象:每次套接字變?yōu)榭蓱穑╝cceptable)、可寫(writable)或者可讀(readable)時,相應的文件事件就會產生。
■ 文件事件分為AE_READABLE事件(讀事件)和AE_WRITABLE事件(寫事件)兩類。
■ 時間事件分為定時事件和周期性事件:定時事件只在指定的時間到達一次,而周期性事件則每隔一段時間到達一次。
■ 服務器在一般情況下只執(zhí)行serverCron函數一個時間事件,并且這個事件是周期性事件。
■ 文件事件和時間事件之間是合作關系,服務器會輪流處理這兩種事件,并且處理事件的過程中也不會進行搶占。
■ 時間事件的實際處理時間通常會比設定的到達時間晚一些。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容