1. 程式人生 > >跟著大彬讀原始碼 - Redis 4 - 伺服器的事件驅動有什麼含義?(上)

跟著大彬讀原始碼 - Redis 4 - 伺服器的事件驅動有什麼含義?(上)

眾所周知,Redis 伺服器是一個事件驅動程式。那麼事件驅動對於 Redis 而言有什麼含義?原始碼中又是如何實現事件驅動的呢?今天,我們一起來認識下 Redis 伺服器的事件驅動。

對於 Redis 而言,伺服器需要處理以下兩類事件:

  • 檔案事件(file event):Redis 伺服器通過套接字與客戶端進行連線,而檔案事件就是伺服器對套接字操作的抽象。伺服器與客戶端的通訊會產生相應的檔案事件,而伺服器則通過監聽並處理這些事件來完成一系列的網路通訊操作。
  • 時間時間(time event):Redis 伺服器中的一些操作(比如 serverCron 函式)需要在給定的時間點執行,而時間事件就是伺服器對這類定時操作的抽象。

接下來,我們先來認識下檔案事件。

Redis 基於 Reactor 模式開發了自己的網路事件處理器,這個處理器被稱為檔案事件處理器(file event handler):

  • 檔案事件處理器使用 IO 多路複用程式來同時監聽多個套接字,並根據套接字目前執行的任務來為套接字關聯不同的事件處理器。
  • 當被監聽的套接字準備好執行連線應答(accept)、讀取(read)、寫入(write)、關閉(close)等操作時,與操作相對應的檔案事件就會產生,這時檔案事件處理器就會呼叫套接字之前關聯好的事件處理器來處理這些事件。

雖然檔案處理器以單執行緒方式執行,但通過 IO 多路複用程式監聽多個套接字,既實現了高效能的網路通訊模型,又可以很好的與 Redis 伺服器中其它同樣以單執行緒執行的模組進行對接,保持了 Redis 內部單執行緒設計的簡潔。

1 檔案事件處理器的構成

圖 1 展示了檔案事件處理器的四個組成部分:

  • 套接字;
  • IO 多路複用程式;
  • 檔案事件分派器(dispatcher);
  • 事件處理器;

檔案事件是對套接字的抽象。每當一個套接字準備好執行連線應答(accept)、寫入、讀取、關閉等操作時,就好產生一個檔案事件。因為一個伺服器通常會連線多個套接字,所以多個檔案事件有可能會併發的出現。

而 IO 多了複用程式負責監聽多個套接字,並向檔案事件分派器分發那些產生事件的套接字。

儘管多個檔案事件可能會併發的出現,但 IO 多路複用程式總是會將所有產生事件的套接字都放到一個佇列裡面,然後通過這個佇列,以有序、同步的方式,把每一個套接字傳輸給檔案事件分派器。當上一個套接字產生的事件被處理完畢之後(即,該套接字為事件所關聯的事件處理器執行完畢),IO 多路複用程式才會繼續向檔案事件分派器傳送下一個套接字。如圖 2 所示:

檔案事件分派器接收 IO 多路複用程式傳來的套接字,並根據套接字產生的事件型別,呼叫相應的事件處理器。

伺服器會為執行不同任務的套接字關聯不同的事件處理器。這些處理器本質上就是一個個函式。它們定義了某個事件發生時,伺服器應該執行的動作。

2 IO 多路複用程式的實現

Redis 的 IO 多路複用程式的所有功能都是通過包裝常見的 select、epoll、evport 和 kqueue 這些 IO 多路複用函式庫來實現的。每個 IO 多路複用函式庫在 Redis 原始碼中都對應一個單獨的檔案,比如 ae_select.c、ae_poll.c、ae_kqueue.c 等。

由於 Redis 為每個 IO 多路複用函式庫都實現了相同的 API,所以 IO 多路複用程式的底層實現是可以互換的,如圖 3 所示:

Redis 在 IO 多路複用程式的實現原始碼中用 #include 巨集定義了相應的規則,**程式會在編譯時自動選擇系統中效能最高的 IO 多路複用函式庫來作為 Redis 的 IO 多路複用程式的底層實現,這保證了 Redis 在各個平臺的相容性和高效能。對應原始碼如下:

/* 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

3 事件的型別

IO 多路複用程式可以監聽多個套接字的 ae.h/AE_READABLEae.h/AE_WRITABLE 事件,這兩類事件和套接字操作之間有以下對應關係:

  • 當伺服器套接字變得可讀時,套接字會產生 AE_READABLE 事件。此處的套接字可讀,是指客戶端對套接字執行 write、close 操作,或者有新的可應答(acceptable)套接字出現時(客戶端對伺服器的監聽套接字執行 connect 操作),套接字會產生 AE_READABLE 事件。
  • 當伺服器套接字變得可寫時,套接字會產生 AE_WRITABLE 事件。

IO 多路複用程式允許伺服器同時監聽套接字的 AR_READABLE 事件和 AE_WRITABLE 事件。如果一個套接字同時產生了兩個事件,那麼檔案分派器會優先處理 AE_READABLE 事件,然後再處理 AE_WRITABLE 事件。簡單來說,如果一個套接字既可讀又可寫,那麼伺服器將先讀套接字,後寫套接字。

4 檔案事件處理器

Redis 為檔案事件編寫了多個處理器,這些事件處理器分別用於實現不同的網路通訊需求。比如說:

  • 為了對連線伺服器的各個客戶端進行應答,伺服器要為監聽套接字關聯連線應答處理器。
  • 為了接收客戶端傳了的命令請求,伺服器要為客戶端套接字關聯命令請求處理器。
  • 為了向客戶端返回命令執行結果,伺服器要為客戶端套接字關聯命令回覆處理器。
  • 當主伺服器和從伺服器進行復制操作時,主從伺服器都需要關聯複製處理器。

在這些事件處理器中,伺服器最常用的是與客戶端進行通訊的連線應答處理器、命令請求處理器和命令回覆處理器。

1)連線應答處理器

networking.c/acceptTcpHandle 函式是 Redis 的連線應答處理器,這個處理器用於對連線伺服器監聽套接字的客戶端進行應答,具體實現為 sys/socket.h/accept 函式的包裝。

當 Redis 伺服器進行初始化的時候,程式會將這個連線應答處理器和伺服器監聽套接字的 AE_READABLE 事件關聯。對應原始碼如下

# server.c/initServer
...
/* Create an event handler for accepting new connections in TCP and Unix
 * domain sockets. */
for (j = 0; j < server.ipfd_count; j++) {
    if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
        acceptTcpHandler,NULL) == AE_ERR)
        {
            serverPanic(
                "Unrecoverable error creating server.ipfd file event.");
        }
}
...

當有客戶端用 sys/scoket.h/connect 函式連線伺服器監聽套接字時,套接字就會產生 AE_READABLE 事件,引發連線應答處理器執行,並執行相應的套接字應答操作。如圖 4 所示:

2)命令請求處理器
networking.c/readQueryFromClient 函式是 Redis 的命令請求處理器,這個處理器負責從套接字中讀入客戶端傳送的命令請求內容,具體實現為 unistd.h/read 函式的包裝。

當一個客戶端通過連線應答處理器成功連線到伺服器之後,伺服器會將客戶端套接字的 AE_READABLE 事件和命令請求處理器關聯起來(networking.c/acceptCommonHandler 函式)。

當客戶端向伺服器傳送命令請求的時候,套接字就會產生 AR_READABLE 事件,引發命令請求處理器執行,並執行相應的套接字讀入操作,如圖 5 所示:

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

3)命令回覆處理器
networking.c/sendReplToClient 函式是 Redis 的命令回覆處理器,這個處理器負責將伺服器執行命令後得到的命令回覆通過套接字返回給客戶端。

當伺服器有命令回覆需要發給客戶端時,伺服器會將客戶端套接字的 AE_WRITABLE 事件和命令回覆處理器關聯(networking.c/handleClientsWithPendingWrites 函式)。

當客戶端準備好接收伺服器傳回的命令回覆時,就會產生 AE_WRITABLE 事件,引發命令回覆處理器執行,並執行相應的套接字寫入操作。如圖 6 所示:

當命令回覆傳送完畢之後,伺服器就會解除命令回覆處理器與客戶端套接字的 AE_WRITABLE 事件的關聯。對應原始碼如下:

# networking.c/writeToClient
...
if (!clientHasPendingReplies(c)) {
    c->sentlen = 0;
    # buffer 緩衝區命令回覆已傳送,刪除套接字和事件的關聯
    if (handler_installed) aeDeleteFileEvent(server.el,c->fd,AE_WRITABLE);

    /* Close connection after entire reply has been sent. */
    if (c->flags & CLIENT_CLOSE_AFTER_REPLY) {
        freeClient(c);
        return C_ERR;
    }
}
...

5 客戶端與伺服器連線事件

之前我們通過 debug 的形式大致認識了客戶端與伺服器的連線過程。現在,我們站在檔案事件的角度,再一次來追蹤 Redis 客戶端與伺服器進行連線併發送命令的整個過程,看看在過程中會產生什麼事件,這些事件又是如何被處理的。

先來看客戶端與伺服器建立連線的過程:

  1. 先啟動我們的 Redis 伺服器(127.0.0.1-8379)。成功啟動後,伺服器套接字(127.0.0.1-8379) AE_READABLE 事件正處於被監聽狀態,而該事件對應連線應答處理器。(server.c/initServer())。
  2. 使用 redis-cli 連線伺服器。這是,伺服器套接字(127.0.0.1-8379)將產生 AR_READABLE 事件,觸發連線應答處理器執行(networking.c/acceptTcpHandler())。
  3. 對客戶端的連線請求進行應答,建立客戶端套接字,儲存客戶端狀態資訊,並將客戶端套接字的 AE_READABLE 事件與命令請求處理器(networking.c/acceptCommonHandler())進行關聯,使得伺服器可以接收該客戶端發來的命令請求。

此時,客戶端已成功與伺服器建立連線了。上述過程,我們仍然可以用 gdb 除錯,檢視函式的執行過程。具體除錯過程如下:

gdb ./src/redis-server
(gdb) b acceptCommonHandler    # 給 acceptCommonHandler 函式設定斷點
(gdb) r redis-conf --port 8379 # 啟動伺服器

另外開一個視窗,使用 redis-cli 連線伺服器:redis-cli -p 8379

回到伺服器視窗,我們會看到已進入 gdb 除錯模式,輸入:info stack,可以看到如圖 6 所示的堆疊資訊。

現在,我們再來認識命令的執行過程:

  1. 客戶端向伺服器傳送一個命令請求,客戶端套接字產生 AE_READABLE 事件,引發命令請求處理器(readQueryFromClient)執行,讀取客戶端的命令內容;
  2. 根據客戶端傳送命令內容,格式化客戶端 argc、argv 等相關值屬性值;
  3. 根據命令名稱查詢對應函式。server.c/processCommad()lookupCommand 函式呼叫;
  4. 執行與命令名關聯的函式,獲得返回結果,客戶端套接字產生 。server.c/processCommad()call 函式呼叫。
  5. 返回命令回覆,刪除客戶端套接字與 AE_WRITABLE 事件的關聯。network.c/writeToClient() 函式。

圖 7 展示了命令執行過程的堆疊資訊。圖 8 則展示了命令回覆過程的堆疊資訊。

總結

  1. Redis 伺服器是一個事件驅動程式,伺服器處理的事件分為時間事件和檔案事件兩類。
  2. 檔案事件是對套接字操作的抽象。**每次套接字變得可應答(acceptable)、可寫(writable)或者可讀(readable)時,相應的檔案事件就會產生。
  3. 檔案事件分為 AE_READABLE 事件(讀事件)和 AE_WRITABLE 事件(寫事件)兩類。