什么是 Reactor 模式 ?

“反应”器名字中”反应“的由来:
“反应”即“倒置”,“控制逆转”,具体事件处理程序不调用反应器,而向反应器注册一个事件处理器,表示自己对某些事件感兴趣,有时间来了,具体事件处理程序通过事件处理器对某个指定的事件发生做出反应;这种控制逆转又称为“好莱坞法则”(不要调用我,让我来调用你)

单线程 Reactor 模式流程

服务器端的 Reactor 是一个线程对象,该线程会启动事件循环,并使用Acceptor 事件处理器关注 ACCEPT 事件,这样 Reactor 会监听客户端向服务器端发起的连接请求事件(ACCEPT 事件)。

客户端向服务器端发起一个连接请求,Reactor 监听到了该 ACCEPT 事件的发生并将该 ACCEPT 事件派发给相应的 Acceptor 处理器来进行处理。建立连接后关注的 READ 事件,这样一来 Reactor 就会监听该连接的 READ 事件了。

当 Reactor 监听到有读 READ 事件发生时,将相关的事件派发给对应的处理器进行处理。比如,读处理器会通过读取数据,此时 read()操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。

在目前的单线程 Reactor 模式中,不仅 I/O 操作在该 Reactor 线程上,连非I/O 的业务操作也在该线程上进行处理了,这可能会大大延迟 I/O 请求的响应。所以我们应该将非 I/O 的业务逻辑操作从 Reactor 线程上卸载,以此来加速Reactor 线程对 I/O 请求的响应。

redis reactor 模式结构 redis的reactor_redis

单线程 Reactor,工作者线程池

与单线程 Reactor 模式不同的是,添加了一个工作者线程池,并将非 I/O 操作从 Reactor 线程中移出转交给工作者线程池来执行。这样能够提高 Reactor 线程的 I/O 响应,不至于因为一些耗时的业务逻辑而延迟对后面 I/O 请求的处理。
但是对于一些小容量应用场景,可以使用单线程模型,对于高负载、大并发或大数据量的应用场景却不合适,主要原因如下:
① 一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,即便 NIO 线程的 CPU 负荷达到 100%,也无法满足海量消息的读取和发送;
② 当 NIO 线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时之后往往会进行重发,这更加重了 NIO 线程的负载,最终会导致大量消息积压和处理超时,成为系统的性能瓶颈;

redis reactor 模式结构 redis的reactor_服务器_02

多Reactor线程模式

Reactor 线程池中的每一 Reactor 线程都会有自己的 Selector、线程和分发的事件循环逻辑。

mainReactor 可以只有一个,但 subReactor 一般会有多个。mainReactor 线程主要负责接收客户端的连接请求,然后将接收到的 SocketChannel 传递给subReactor,由 subReactor 来完成和客户端的通信。

多 Reactor 线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个 Reactor 线程来完成。mainReactor 完成接收客户端连接请求的操作,它不负责与客户端的通信,而是将建立好的连接转交给 subReactor 线程来完成与客户端的通信,这样一来就不会因为 read()数据量太大而导致后面的客户端连接请求得不到即时处理的情况。并且多 Reactor 线程模式在海量的客户端并发请求的情况下,还可以通过实现 subReactor 线程池来将海量的连接分发给多个subReactor 线程,在多核的操作系统中这能大大提升应用的负载和吞吐量。

redis reactor 模式结构 redis的reactor_服务器_03

Redis 中的线程和 IO 概述

Redis 基于 Reactor 模式开发了自己的网络事件处理器 - 文件事件处理器(file event handler,后文简称为 FEH),而该处理器又是单线程的,所以 redis设计为单线程模型。

采用 I/O 多路复用同时监听多个 socket,根据 socket 当前执行的事件来为socket 选择对应的事件处理器。

当被监听的 socket 准备好执行 accept、read、write、close 等操作时,和操作对应的文件事件就会产生,这时 FEH 就会调用 socket 之前关联好的事件处理器来处理对应事件。

所以虽然 FEH 是单线程运行,但通过 I/O 多路复用监听多个 socket,不仅实现高性能的网络通信模型,又能和 Redis 服务器中其它同样单线程运行的模块交互,保证了 Redis 内部单线程模型的简洁设计。

下面来看文件事件处理器的几个组成部分

redis reactor 模式结构 redis的reactor_redis_04

socket

文件事件就是对 socket 操作的抽象, 每当一个 socket 准备好执行连接accept、read、write、close 等操作时, 就会产生一个文件事件。一个服务器通常会连接多个 socket, 多个 socket 可能并发产生不同操作,每个操作对应不同文件事件。

I/O 多路复用程序

I/O 多路复用程序会负责监听多个 socket。尽管文件事件可能并发出现, 但 I/O 多路复用程序会将所有产生事件的socket 放入队列, 通过该队列以有序、同步且每次一个 socket 的方式向文件事件分派器传送 socket。当上一个 socket 产生的事件被对应事件处理器执行完后, I/O 多路复用程序才会向文件事件分派器传送下个 socket, 如下:

redis reactor 模式结构 redis的reactor_服务器_05


I/O 多路复用程序的实现:

Redis 的 I/O 多路复用程序的所有功能都是通过包装常见的 select、epoll、evport 和 kqueue 这些 I/O 多路复用函数库实现的。

每个 I/O 多路复用函数库在 Redis 源码中都对应一个单独的文件:

因为 Redis 为每个 I/O 多路复用函数库都实现了相同的 API , 所以 I/O 多路复用程序的底层实现是可以互换的。Redis 在 I/O 多路复用程序的实现源码ae.c文件中宏定义了相应规则,使得程序在编译时自动选择系统中性能最高的I/O 多路复用函数库作为 Redis 的 I/O 多路复用程序的底层实现:性能降序排列。

redis reactor 模式结构 redis的reactor_redis_06


注:

evport = Solaris 10

epoll = Linux

kqueue = OS X,FreeBSD

select =通常作为 fallback 安装在所有平台上

Evport,Epoll 和 KQueue 具有 O(1)描述符选择算法复杂度,并且它们都使用内部内核空间内存结构.他们还可以提供很多(数十万个)文件描述符.

除其他外,select 最多只能提供 1024 个描述符,并且对描述符进行完全扫描(因此每次迭代所有描述符以选择一个可使用的描述符),因此复杂性是 O(n)

文件事件分派器

文件事件分派器接收 I/O 多路复用程序传来的 socket, 并根据 socket 产生的事件类型, 调用相应的事件处理器。

文件事件处理器

服务器会为执行不同任务的套接字关联不同的事件处理器, 这些处理器是一个个函数, 它们定义了某个事件发生时, 服务器应该执行的动作。
Redis 为各种文件事件需求编写了多个处理器,若客户端连接 Redis,对连接服务器的各个客户端进行应答,就需要将 socket 映射到连接应答处理器写数据到 Redis,接收客户端传来的命令请求,就需要映射到命令请求处理器从 Redis读数据,向客户端返回命令的执行结果,就需要映射到命令回复处理器当主服务器和从服务器进行复制操作时, 主从服务器都需要映射到特别为复制功能编写的复制处理器。

文件事件的类型

I/O 多路复用程序可以监听多个 socket 的 ae.h/AE_READABLE 事件和ae.h/AE_WRITABLE 事件, 这两类事件和套接字操作之间的对应关系如下:
当 socket 可读(比如客户端对 Redis 执行 write/close 操作),或有新的可应答的 socket 出现时(即客户端对 Redis 执行 connect 操作),socket 就会产生一个 AE_READABLE 事件。
当 socket 可写时(比如客户端对 Redis 执行 read 操作),socket 会产生一个AE_WRITABLE 事件。
I/O 多路复用程序可以同时监听 AE_REABLE 和 AE_WRITABLE 两种事件,要是一个 socket 同时产生这两种事件,那么文件事件分派器优先处理 AE_REABLE 事件。即一个 socket 又可读又可写时, Redis 服务器先读后写 socket。

总结

最后,让我们梳理一下客户端和 Redis 服务器通信的整个过程:
Redis 启动初始化时,将连接应答处理器跟 AE_READABLE 事件关联。若一个客户端发起连接,会产生一个 AE_READABLE 事件,然后由连接应答处理器负责和客户端建立连接,创建客户端对应的 socket,同时将这个 socket的 AE_READABLE 事件和命令请求处理器关联,使得客户端可以向主服务器发送命令请求。
当客户端向 Redis 发请求时(不管读还是写请求),客户端 socket 都会产生一个 AE_READABLE 事件,触发命令请求处理器。处理器读取客户端的命令内容,然后传给相关程序执行。
当 Redis 服务器准备好给客户端的响应数据后,会将 socket 的 AE_WRITABLE事件和命令回复处理器关联,当客户端准备好读取响应数据时,会在 socket 产生一个 AE_WRITABLE 事件,由对应命令回复处理器处理,即将准备好的响应数据写入 socket,供客户端读取。
命令回复处理器全部写完到 socket 后,就会删除该 socket 的 AE_WRITABLE事件和命令回复处理器的映射。

Redis6 中的多线程

1. Redis6.0 之前的版本真的是单线程吗?

Redis 在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。但如果严格来讲从 Redis4.0 之后并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。

2. Redis6.0 之前为什么一直不使用多线程?

官方曾做过类似问题的回复:使用 Redis 时,几乎不存在 CPU 成为瓶颈的情况, Redis 主要受限于内存和网络。例如在一个普通的 Linux 系统上,Redis 通过使用 pipelining 每秒可以处理 100 万个请求,所以如果应用程序主要使用 O(N)或O(log(N))的命令,它几乎不会占用太多 CPU。
使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。Redis通过 AE 事件模型以及 IO 多路复用等技术,处理性能非常高,因此没有必要使用多线程。单线程机制使得 Redis 内部实现的复杂度大大降低,Hash 的惰性Rehash、Lpush 等等 “线程不安全” 的命令都可以无锁进行。

3. Redis6.0 为什么要引入多线程呢?

Redis 将所有数据放在内存中,内存的响应时长大约为 100 纳秒,对于小数据包,Redis 服务器可以处理 80,000 到 100,000 QPS,这也是 Redis 处理的极限了,对于 80%的公司来说,单线程的 Redis 已经足够使用了。
但随着越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的 QPS。常见的解决方案是在分布式架构中对数据进行分区并采用多个服务器,但该方案有非常大的缺点,例如要管理的 Redis 服务器太多,维护代价大;某些适用于单个 Redis 服务器的命令不适用于数据分区;数据分区无法解决热点读/写问题;数据偏斜,重新分配和放大/缩小变得更加复杂等等。
从 Redis 自身角度来说,因为读写网络的 read/write 系统调用占用了 Redis执行期间大部分 CPU 时间,瓶颈主要在于网络的 IO 消耗, 优化主要有两个方向:

• 提高网络 IO 性能,典型的实现比如使用 DPDK 来替代内核网络栈的方式
• 使用多线程充分利用多核,典型的实现比如 Memcached。

协议栈优化的这种方式跟 Redis 关系不大,支持多线程是一种最有效最便捷的操作方式。所以总结起来,redis 支持多线程主要就是两个原因:
• 可以充分利用服务器 CPU 资源,目前主线程只能利用一个核
• 多线程任务可以分摊 Redis 同步 IO 读写负荷

4.Redis6.0 默认是否开启了多线程?

Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改redis.conf 配置文件:io-threads-do-reads yes

redis reactor 模式结构 redis的reactor_服务器_07


开启多线程后,还需要设置线程数,否则是不生效的。

关于线程数的设置,官方有一个建议:4 核的机器建议设置为 2 或 3 个线程,8 核的建议设置为 6 个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了 8 个基本就没什么意义了。

5.Redis6.0 采用多线程后,性能的提升效果如何?

Redis 作者 antirez 在 RedisConf 2019 分享时曾提到:Redis 6 引入的多线程IO 特性对性能提升至少是一倍以上。国内也有大牛曾使用 unstable 版本在阿里云 esc 进行过测试,GET/SET 命令在 4 线程 IO 时性能相比单线程是几乎是翻倍了。如果开启多线程,至少要 4 核的机器,且 Redis 实例已经占用相当大的 CPU耗时的时候才建议采用,否则使用多线程没有意义。

6.Redis6.0 多线程的实现机制?

流程简述如下:

  1. 主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列
  2. 主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程
  3. 主线程阻塞等待 IO 线程读取 socket 完毕
  4. 主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行回写 socket
  5. 主线程阻塞等待 IO 线程将数据回写 socket 完毕
  6. 解除绑定,清空等待队列

该设计有如下特点:

  1. IO 线程要么同时在读 socket,要么同时在写,不会同时读或写
  2. IO 线程只负责读写 socket 解析命令,不负责命令处理

7.开启多线程后,是否会存在线程并发安全问题?

从上面的实现机制可以看出,Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。所以我们不需要去考虑控制key、lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。

8.Redis6.0 的多线程和 Memcached 多线程模型进行对比

Memcached 服务器采用 master-woker 模式进行工作,服务端采用 socket 与客户端通讯。主线程、工作线程 采用 pipe 管道进行通讯。主线程采用 libevent 监听 listen、accept 的读事件,事件响应后将连接信息的数据结构封装起来,根据算法选择合适的工作线程,将连接任务携带连接信息分发出去,相应的线程利用连接描述符建立与客户端的 socket 连接 并进行后续的存取数据操作。

  • 相同点:都采用了 master 线程-worker 线程的模型
  • 不同点:Memcached 执行主逻辑也是在 worker 线程里,模型更加简单,实现了真正的线程隔离,符合我们对线程隔离的常规理解。而 Redis 把处理逻辑交还给 master 线程,虽然一定程度上增加了模型复杂度,但也解决了线程并发安全等问题