# 事件
[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` 就是循环事件。
- 文件事件和时间事件之间是合作关系:一种事件会等待另一种事件完成之后再执行,不会出现抢占情况。
- 时间事件的实际执行时间通常会比预定时间晚一些。