UP | HOME

事件

Table of Contents

事件 是 Redis 服务器的 核心 , 它处理两项重要的任务:

下面就来介绍这两种事件, 以及它们背后的运作模式

文件事件

Redis 服务器通过在 多个 客户端 之间进行 *多路复用*, 从而实现高效的命令请求处理

多个客户端通过套接字连接到 Redis 服务器中, 但只有在套接字可以“无阻塞”地进行读或者写时, 服务器才会和这些客户端进行交互

Redis 将这类因为对套接字进行多路复用而产生的事件称为 文件事件 file event , 文件事件可以分为 读事件写事件 两类

读事件

读事件标志着 客户端命令请求发送 状态。当一个新的客户端连接到服务器时, 服务器会给为该客户端绑定读事件, 直到客户端断开连接之后, 这个读事件才会被移除。读事件在整个网络连接的生命期内, 都会在 等待就绪 两种状态之间切换:

  • 当客户端只是连接到服务器,但并 没有服务器发送命令 时,该客户端的读事件就处于 等待 状态
  • 当客户端给服务器发送命令请求,并且 请求已到达 时(相应的套接字可以 无阻塞 地执行读操作),该客户端的读事件处于 就绪 状态

作为例子, 下图展示了三个已连接到服务器、但并没有发送命令的客户端:

graphviz-b34667a48cb66a804038c7eec759ec0289167da9.svg

这三个客户端的状态如下表:

客户端 读事件状态 命令发送状态
客户端 X 等待 未发送
客户端 Y 等待 未发送
客户端 Z 等待 未发送

之后, 当客户端 X 向服务器发送命令请求, 并且命令请求已到达时, 客户端 X 的读事件状态变为就绪:

graphviz-c7d00af9c8ecdddd84ca97815c785fc1a740fc64.svg

这时, 三个客户端的状态如下表(只有客户端 X 的状态被更新了):

客户端 读事件状态 命令发送状态
客户端 X 就绪 已发送,命令已达到
客户端 Y 等待 未发送
客户端 Z 等待 未发送

当事件处理器被执行时, 就绪的文件事件会被识别到, 相应的 命令请求 会被 发送命令执行器 , 并对命令进行 求值

写事件

写事件标志着 客户端命令结果接收 状态。和客户端自始至终都关联着读事件不同, 服务器 只会有命令结果 要传回给客户端时, 才会为客户端 关联 写事件, 并且在 命令结果传送完毕 之后, 客户端和写事件的关联就会被 移除 。一个写事件会在两种状态之间切换:

  • 当服务器有命令结果需要返回给客户端,但客户端还 未能 执行无阻塞写,那么写事件处于 等待 状态
  • 当服务器有命令结果需要返回给客户端,并且客户端 可以 进行无阻塞写,那么写事件处于 就绪 状态

当客户端向服务器发送命令请求, 并且请求被接受并执行之后, 服务器就需要将保存在缓存内的命令执行结果返回给客户端, 这时服务器就会为客户端关联写事件。作为例子, 下图展示了三个连接到服务器的客户端, 其中服务器正等待客户端 X 变得可写, 从而将命令的执行结果返回给它:

graphviz-3fa5735cd763caa71e083a6bfe45f2f01eb506f9.svg

此时三个客户端的事件状态分别如下表:

客户端 读事件状态 写事件状态
客户端 X 等待 等待
客户端 Y 等待
客户端 Z 等待

当客户端 X 的套接字可以进行无阻塞写操作时, 写事件就绪, 服务器将保存在缓存内的命令执行结果返回给客户端:

graphviz-dc7ecfee261001e09df0232990301cf3dc834de7.svg

此时三个客户端的事件状态分别如下表(只有客户端 X 的状态被更新了):

客户端 读事件状态 写事件状态
客户端 X 等待 就绪
客户端 Y 等待
客户端 Z 等待

当命令执行结果被传送回客户端之后, 客户端和写事件之间的关联会被解除(只剩下读事件), 至此, 返回命令执行结果的动作执行完毕:

graphviz-b34667a48cb66a804038c7eec759ec0289167da9.svg

前面提到过,读事件只有在客户端断开和服务器的连接时,才会被移除。

这也就是说,当客户端关联写事件的时候,实际上它在同时关联读/写两种事件。

因为在同一次文件事件处理器的调用中, 单个客户端只能执行其中一种事件(要么读,要么写,但不能又读又写), 当出现读事件和写事件同时就绪的情况时, 事件处理器优先处理读事件。

这也就是说, 当服务器有命令结果要返回客户端, 而客户端又有新命令请求进入时, 服务器先处理新命令请求

时间事件

时间事件 记录着那些要在 指定时间点运行的事件 , 多个时间事件以 无序链表 的形式保存在服务器状态中。每个时间事件主要由三个属性组成:

  1. when :以毫秒格式的 UNIX 时间戳为单位,记录了应该在什么时间点执行事件处理函数
  2. timeProc :事件处理函数
  3. next 指向下一个时间事件,形成链表
/* Time event structure */
typedef struct aeTimeEvent {
    long long id; /* time event identifier. */ // 定时事件的id
    long when_sec; /* seconds */ // 预定时间 秒
    long when_ms; /* milliseconds */ // 预定时间 纳秒
    aeTimeProc *timeProc; // 定时事件处理函数指针
    aeEventFinalizerProc *finalizerProc; // 定时事件清理函数指针
    void *clientData; // 上面两个函数指针调用所需要的参数
    struct aeTimeEvent *next; //指向下一个定时事件,形成一个单向链表
} aeTimeEvent;

根据 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) # 处理已到达事件
无序链表并不影响时间事件处理器的性能

在目前的版本中, 正常模式下的 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. 一种事件会等待另一种事件执行完毕之后,才开始执行,事件之间不会出现抢占
  2. 事件处理器先处理文件事件(处理命令请求),再执行时间事件(调用 serverCron)
  3. 文件事件的 等待时间 (类 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 设定的间隔为 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:
        poll(timeout=nearest_te_remaind_ms) # 如果时间事件还没到达,那么阻塞的最大时间不超过 te 的到达时间

    process_file_events() # 处理已就绪文件事件
    process_time_event() # 处理已到达时间事件

通过这段代码, 可以清晰地看出:

  • 到达时间最近的时间事件,决定了 poll 的最大阻塞时长
  • 文件事件先于时间事件处理。

将这个事件处理函数置于一个 循环 中, 加上初始化和清理函数, 这就构成了 Redis 服务器的主函数调用:

def redis_main():
    init_server() # 初始化服务器

    while server_is_not_shutdown(): # 一直处理事件,直到服务器关闭为止
        process_event()

    clean_server() # 清理服务器

小结

  • Redis 的事件分为时间事件和文件事件两类
  • 文件事件分为读事件和写事件两类:
    • 读事件实现了命令请求的接收
    • 写事件实现了命令结果的返回
  • 时间事件分为单次执行事件和循环执行事件
    • 服务器常规操作 serverCron 就是循环事件
  • 文件事件和时间事件之间是合作关系:一种事件会等待另一种事件完成之后再执行,不会出现抢占情况
  • 时间事件的实际执行时间通常会比预定时间晚一些

    Next:服务器与客户端 Home:内部机制