# 事件
[TOC=2,3]
事件是 Redis 服務器的核心,它處理兩項重要的任務:
1. 處理文件事件:在多個客戶端中實現多路復用,接受它們發來的命令請求,并將命令的執行結果返回給客戶端。
1. 時間事件:實現服務器常規操作(server cron job)。
本文以下內容就來介紹這兩種事件,以及它們背后的運作模式。
### 文件事件
Redis 服務器通過在多個客戶端之間進行多路復用,從而實現高效的命令請求處理:多個客戶端通過套接字連接到 Redis 服務器中,但只有在套接字可以無阻塞地進行讀或者寫時,服務器才會和這些客戶端進行交互。
Redis 將這類因為對套接字進行多路復用而產生的事件稱為文件事件(file event),文件事件可以分為讀事件和寫事件兩類。
### 讀事件
讀事件標志著客戶端命令請求的發送狀態。
當一個新的客戶端連接到服務器時,服務器會給為該客戶端綁定讀事件,直到客戶端斷開連接之后,這個讀事件才會被移除。
讀事件在整個網絡連接的生命期內,都會在等待和就緒兩種狀態之間切換:
- 當客戶端只是連接到服務器,但并沒有向服務器發送命令時,該客戶端的讀事件就處于等待狀態。
- 當客戶端給服務器發送命令請求,并且請求已到達時(相應的套接字可以無阻塞地執行讀操作),該客戶端的讀事件處于就緒狀態。
作為例子,下圖展示了三個已連接到服務器、但并沒有發送命令的客戶端:
![digraph e { node [style = filled]; edge [style = "dotted, bold"]; rankdir = BT; server [label = "服務器", shape=circle, fillcolor = "#95BBE3"]; cx [label = "客戶端 X", fillcolor = "#A8E270"]; cy [label = "客戶端 Y", fillcolor = "#A8E270"]; cz [label = "客戶端 Z", fillcolor = "#A8E270"]; cx -> server [dir=none, style=dotted, label="等待命令請求"]; cy -> server [dir=none, style=dotted, label="等待命令請求"]; cz -> server [dir=none, style=dotted, label="等待命令請求"];}](https://box.kancloud.cn/2015-09-13_55f4effebf9d0.svg)
這三個客戶端的狀態如下表:
| 客戶端 | 讀事件狀態 | 命令發送狀態 |
|-----|-----|-----|
| 客戶端 X | 等待 | 未發送 |
| 客戶端 Y | 等待 | 未發送 |
| 客戶端 Z | 等待 | 未發送 |
之后,當客戶端 X 向服務器發送命令請求,并且命令請求已到達時,客戶端 X 的讀事件狀態變為就緒:
![digraph e { node [style = filled]; edge [style = "dotted, bold"]; rankdir = BT; server [label = "服務器", shape=circle, fillcolor = "#95BBE3"]; cx [label = "客戶端 X", fillcolor = "#A8E270"]; cy [label = "客戶端 Y", fillcolor = "#A8E270"]; cz [label = "客戶端 Z", fillcolor = "#A8E270"]; cx -> server [style= "dashed, bold" , label="發送命令請求", color = "#B22222"]; cy -> server [dir=none, style=dotted, label="等待命令請求"]; cz -> server [dir=none, style=dotted, label="等待命令請求"];}](https://box.kancloud.cn/2015-09-13_55f4effec77aa.svg)
這時,三個客戶端的狀態如下表(只有客戶端 X 的狀態被更新了):
| 客戶端 | 讀事件狀態 | 命令發送狀態 |
|-----|-----|-----|
| 客戶端 X | **就緒** | **已發送,并且已到達** |
| 客戶端 Y | 等待 | 未發送 |
| 客戶端 Z | 等待 | 未發送 |
當事件處理器被執行時,就緒的文件事件會被識別到,相應的命令請求會被發送到命令執行器,并對命令進行求值。
### 寫事件
寫事件標志著客戶端對命令結果的接收狀態。
和客戶端自始至終都關聯著讀事件不同,服務器只會在有命令結果要傳回給客戶端時,才會為客戶端關聯寫事件,并且在命令結果傳送完畢之后,客戶端和寫事件的關聯就會被移除。
一個寫事件會在兩種狀態之間切換:
- 當服務器有命令結果需要返回給客戶端,但客戶端還未能執行無阻塞寫,那么寫事件處于等待狀態。
- 當服務器有命令結果需要返回給客戶端,并且客戶端可以進行無阻塞寫,那么寫事件處于就緒狀態。
當客戶端向服務器發送命令請求,并且請求被接受并執行之后,服務器就需要將保存在緩存內的命令執行結果返回給客戶端,這時服務器就會為客戶端關聯寫事件。
作為例子,下圖展示了三個連接到服務器的客戶端,其中服務器正等待客戶端 X 變得可寫,從而將命令的執行結果返回給它:
![digraph e { node [style = filled]; edge [style = "dotted, bold"]; rankdir = BT; server [label = "服務器", shape=circle, fillcolor = "#95BBE3"]; cx [label = "客戶端 X", fillcolor = "#A8E270"]; cy [label = "客戶端 Y", fillcolor = "#A8E270"]; cz [label = "客戶端 Z", fillcolor = "#A8E270"]; cx -> server [dir=none, style=dotted, label="等待將命令結果返回\n等待命令請求"]; cy -> server [dir=none, style=dotted, label="等待命令請求"]; cz -> server [dir=none, style=dotted, label="等待命令請求"];}](https://box.kancloud.cn/2015-09-13_55f4effed0f0e.svg)
此時三個客戶端的事件狀態分別如下表:
| 客戶端 | 讀事件狀態 | 寫事件狀態 |
|-----|-----|-----|
| 客戶端 X | 等待 | 等待 |
| 客戶端 Y | 等待 | 無 |
| 客戶端 Z | 等待 | 無 |
當客戶端 X 的套接字可以進行無阻塞寫操作時,寫事件就緒,服務器將保存在緩存內的命令執行結果返回給客戶端:
![digraph e { node [style = filled]; edge [style = "dotted, bold"]; rankdir = BT; server [label = "服務器", shape=circle, fillcolor = "#95BBE3"]; cx [label = "客戶端 X", fillcolor = "#A8E270"]; cy [label = "客戶端 Y", fillcolor = "#A8E270"]; cz [label = "客戶端 Z", fillcolor = "#A8E270"]; cx -> server [dir=back, style="dashed, bold", label="返回命令執行結果\n等待命令請求", color = "#B22222"]; cy -> server [dir=none, style=dotted, label="等待命令請求"]; cz -> server [dir=none, style=dotted, label="等待命令請求"];}](https://box.kancloud.cn/2015-09-13_55f4effed9356.svg)
此時三個客戶端的事件狀態分別如下表(只有客戶端 X 的狀態被更新了):
| 客戶端 | 讀事件狀態 | 寫事件狀態 |
|-----|-----|-----|
| 客戶端 X | 等待 | **已就緒** |
| 客戶端 Y | 等待 | 無 |
| 客戶端 Z | 等待 | 無 |
當命令執行結果被傳送回客戶端之后,客戶端和寫事件之間的關聯會被解除(只剩下讀事件),至此,返回命令執行結果的動作執行完畢:
![digraph e { node [style = filled]; edge [style = "dotted, bold"]; rankdir = BT; server [label = "服務器", shape=circle, fillcolor = "#95BBE3"]; cx [label = "客戶端 X", fillcolor = "#A8E270"]; cy [label = "客戶端 Y", fillcolor = "#A8E270"]; cz [label = "客戶端 Z", fillcolor = "#A8E270"]; cx -> server [dir=none, style=dotted, label="等待命令請求"]; cy -> server [dir=none, style=dotted, label="等待命令請求"]; cz -> server [dir=none, style=dotted, label="等待命令請求"];}](https://box.kancloud.cn/2015-09-13_55f4effee45e9.svg)
Note
同時關聯寫事件和讀事件
前面提到過,讀事件只有在客戶端斷開和服務器的連接時,才會被移除。
這也就是說,當客戶端關聯寫事件的時候,實際上它在同時關聯讀/寫兩種事件。
因為在同一次文件事件處理器的調用中,單個客戶端只能執行其中一種事件(要么讀,要么寫,但不能又讀又寫),當出現讀事件和寫事件同時就緒的情況時,事件處理器優先處理讀事件。
這也就是說,當服務器有命令結果要返回客戶端,而客戶端又有新命令請求進入時,服務器先處理新命令請求。
### 時間事件
時間事件記錄著那些要在指定時間點運行的事件,多個時間事件以無序鏈表的形式保存在服務器狀態中。
每個時間事件主要由三個屬性組成:
- `when` :以毫秒格式的 UNIX 時間戳為單位,記錄了應該在什么時間點執行事件處理函數。
- `timeProc` :事件處理函數。
- `next` 指向下一個時間事件,形成鏈表。
根據 `timeProc` 函數的返回值,可以將時間事件劃分為兩類:
- 如果事件處理函數返回 `ae.h/AE_NOMORE` ,那么這個事件為單次執行事件:該事件會在指定的時間被處理一次,之后該事件就會被刪除,不再執行。
- 如果事件處理函數返回一個非 `AE_NOMORE` 的整數值,那么這個事件為循環執行事件:該事件會在指定的時間被處理,之后它會按照事件處理函數的返回值,更新事件的 `when` 屬性,讓這個事件在之后的某個時間點再次運行,并以這種方式一直更新并運行下去。
可以用偽代碼來表示這兩種事件的處理方式:
~~~
def handle_time_event(server, time_event):
# 執行事件處理器,并獲取返回值
# 返回值可以是 AE_NOMORE ,或者一個表示毫秒數的非符整數值
retval = time_event.timeProc()
if retval == AE_NOMORE:
# 如果返回 AE_NOMORE ,那么將事件從鏈表中刪除,不再執行
server.time_event_linked_list.delete(time_event)
else:
# 否則,更新事件的 when 屬性
# 讓它在當前時間之后的 retval 毫秒之后再次運行
time_event.when = unix_ts_in_ms() + retval
~~~
當時間事件處理器被執行時,它遍歷所有鏈表中的時間事件,檢查它們的到達事件(`when` 屬性),并執行其中的已到達事件:
~~~
def process_time_event(server):
# 遍歷時間事件鏈表
for time_event in server.time_event_linked_list:
# 檢查事件是否已經到達
if time_event.when <= unix_ts_in_ms():
# 處理已到達事件
handle_time_event(server, time_event)
~~~
Note
無序鏈表并不影響時間事件處理器的性能
在目前的版本中,正常模式下的 Redis 只帶有 `serverCron` 一個時間事件,而在 benchmark 模式下,Redis 也只使用兩個時間事件。
在這種情況下,程序幾乎是將無序鏈表退化成一個指針來使用,所以使用無序鏈表來保存時間事件,并不影響事件處理器的性能。
### 時間事件應用實例:服務器常規操作
對于持續運行的服務器來說,服務器需要定期對自身的資源和狀態進行必要的檢查和整理,從而讓服務器維持在一個健康穩定的狀態,這類操作被統稱為常規操作(cron job)。
在 Redis 中,常規操作由 `redis.c/serverCron` 實現,它主要執行以下操作:
- 更新服務器的各類統計信息,比如時間、內存占用、數據庫占用情況等。
- 清理數據庫中的過期鍵值對。
- 對不合理的數據庫進行大小調整。
- 關閉和清理連接失效的客戶端。
- 嘗試進行 AOF 或 RDB 持久化操作。
- 如果服務器是主節點的話,對附屬節點進行定期同步。
- 如果處于集群模式的話,對集群進行定期同步和連接測試。
Redis 將 `serverCron` 作為時間事件來運行,從而確保它每隔一段時間就會自動運行一次,又因為 `serverCron` 需要在 Redis 服務器運行期間一直定期運行,所以它是一個循環時間事件:`serverCron` 會一直定期執行,直到服務器關閉為止。
在 Redis 2.6 版本中,程序規定 `serverCron` 每秒運行 `10` 次,平均每 `100` 毫秒運行一次。從 Redis 2.8 開始,用戶可以通過修改 `hz` 選項來調整 `serverCron` 的每秒執行次數,具體信息請參考 `redis.conf` 文件中關于 `hz` 選項的說明。
### 事件的執行與調度
既然 Redis 里面既有文件事件,又有時間事件,那么如何調度這兩種事件就成了一個關鍵問題。
簡單地說,Redis 里面的兩種事件呈合作關系,它們之間包含以下三種屬性:
1. 一種事件會等待另一種事件執行完畢之后,才開始執行,事件之間不會出現搶占。
1. 事件處理器先處理文件事件(處理命令請求),再執行時間事件(調用 `serverCron`)
1. 文件事件的等待時間(類 `poll` 函數的最大阻塞時間),由距離到達時間最短的時間事件決定。
這些屬性表明,實際處理時間事件的時間,通常會比時間事件所預定的時間要晚,至于延遲的時間有多長,取決于時間事件執行之前,執行文件事件所消耗的時間。
比如說,以下圖表就展示了,雖然時間事件 `TE 1` 預定在 `t1` 時間執行,但因為文件事件 `FE 1` 正在運行,所以 `TE 1` 的執行被延遲了:
~~~
t1
|
V
time -----------------+------------------->|
| FE 1 | TE 1 |
|<------>|
TE 1
delay
time
~~~
另外,對于像 `serverCron` 這類循環執行的時間事件來說,如果事件處理器的返回值是 `t` ,那么 Redis 只保證:
- 如果兩次執行時間事件處理器之間的時間間隔大于等于 `t` , 那么這個時間事件至少會被處理一次。
- 而并不是說, 每隔 `t` 時間, 就一定要執行一次事件 —— 這對于不使用搶占調度的 Redis 事件處理器來說,也是不可能做到的
舉個例子,雖然 `serverCron` (`sC`)設定的間隔為 `10` 毫秒,但它并不是像如下那樣每隔 `10` 毫秒就運行一次:
~~~
time ----------------------------------------------------->|
|<---- 10 ms ---->|<---- 10 ms ---->|<---- 10 ms ---->|
| FE 1 | FE 2 | sC 1 | FE 3 | sC 2 | FE 4 |
^ ^ ^ ^ ^
| | | | |
file event time event | time event |
handler handler | handler |
run run | run |
file event file event
handler handler
run run
~~~
在實際中,`serverCron` 的運行方式更可能是這樣子的:
~~~
time ----------------------------------------------------------------------->|
|<---- 10 ms ---->|<---- 10 ms ---->|<---- 10 ms ---->|<---- 10 ms ---->|
| FE 1 | FE 2 | sC 1 | FE 3 | FE 4 | FE 5 | sC 2 |
|<-------- 15 ms -------->| |<------- 12 ms ------->|
>= 10 ms >= 10 ms
^ ^ ^ ^
| | | |
file event time event | time event
handler handler | handler
run run | run
file event
handler
run
~~~
根據情況,如果處理文件事件耗費了非常多的時間,`serverCron` 被推遲到一兩秒之后才能執行,也是有可能的。
整個事件處理器程序可以用以下偽代碼描述:
~~~
def process_event():
# 獲取執行時間最接近現在的一個時間事件
te = get_nearest_time_event(server.time_event_linked_list)
# 檢查該事件的執行時間和現在時間之差
# 如果值 <= 0 ,那么說明至少有一個時間事件已到達
# 如果值 > 0 ,那么說明目前沒有任何時間事件到達
nearest_te_remaind_ms = te.when - now_in_ms()
if nearest_te_remaind_ms <= 0:
# 如果有時間事件已經到達
# 那么調用不阻塞的文件事件等待函數
poll(timeout=None)
else:
# 如果時間事件還沒到達
# 那么阻塞的最大時間不超過 te 的到達時間
poll(timeout=nearest_te_remaind_ms)
# 處理已就緒文件事件
process_file_events()
# 處理已到達時間事件
process_time_event()
~~~
通過這段代碼,可以清晰地看出:
- 到達時間最近的時間事件,決定了 `poll` 的最大阻塞時長。
- 文件事件先于時間事件處理。
將這個事件處理函數置于一個循環中,加上初始化和清理函數,這就構成了 Redis 服務器的主函數調用:
~~~
def redis_main():
# 初始化服務器
init_server()
# 一直處理事件,直到服務器關閉為止
while server_is_not_shutdown():
process_event()
# 清理服務器
clean_server()
~~~
### 小結
- Redis 的事件分為時間事件和文件事件兩類。
- 文件事件分為讀事件和寫事件兩類:讀事件實現了命令請求的接收,寫事件實現了命令結果的返回。
- 時間事件分為單次執行事件和循環執行事件,服務器常規操作 `serverCron` 就是循環事件。
- 文件事件和時間事件之間是合作關系:一種事件會等待另一種事件完成之后再執行,不會出現搶占情況。
- 時間事件的實際執行時間通常會比預定時間晚一些。