04-Redis 单线程 or 多线程

Redis 单线程

Redis 单线程指的是 接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端 这个过程是由一个线程(主线程)来完成的。简单来说,Redis 中只有网络请求模块和数据操作模块是单线程的,而其他的如持久化存储模块、集群支撑模块等是多线程的。这也是我们说 Redis 是单线程的原因。

Redis 在启动的时候,是会启动一些后台线程(BIO)的:

  • Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务。
  • Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key/flushdb async/flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大key。

之所以 Redis 为 关闭文件、AOF 刷盘、释放内存 这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。

后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。

关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:

  • BIO_CLOSE_FILE 关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd),将文件关闭。
  • BIO_AOF_FSYNC AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘。
  • BIO_LAZY_FREE lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象

Redis 6.0 之前为什么使用单线程

官方解释

先看一下 Redis 官方给出的FAQ

核心意思是:CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到 内存大小 和 网络I/O 的限制,所以 Redis 核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核 CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。

个人理解

多线程适用场景

一个计算机程序在执行的过程中,主要需要进行两种操作分别是 读写操作 和 计算 操作。其中读写操作主要涉及到的是 I/O操作,包括 网络I/O 和 磁盘I/O。计算操作主要涉及到 CPU。

多线程的目的,就是通过并发的方式来提升 I/O利用率 和 CPU利用率。 那么,Redis 需不需要通过多线程的方式来提升提升 I/O利用率 和 CPU利用率 呢?

  • 首先,可以肯定的是 Redis 不需要提升 CPU利用率。因为 Redis的操作基本都是基于内存的,CPU 资源根本就不是 Redis 的性能瓶颈。所以,通过多线程来提升 Redis 的 CPU利用率 这一点是没必要的。

  • 那么,是否有必要使用多线程来提升 Redis 的 I/O利用率 呢?Redis 确实是一个 I/O 操作密集的框架,他的数据操作过程中,会有大量的 网络I/O 和 磁盘I/O 的发生。要想提升 Redis 的性能,是一定要提升 Redis 的 I/O利用率 的,这一点毋庸置疑。
    提升I/O利用率,可以采用多线程 和 I/O 多路复用。

多线程的弊端

线程安全:是编程中的术语,指某个函数、函数库在并发环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。

所有支持多线程的编程语言或者框架,都要面对一个问题,就是如何解决多线程编程带来的共享资源并发控制问题。

虽然,多线程可以帮助我们提升 CPU 和 I/O 的利用率,但是多线程也带来了并发读写的一系列问题,如增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。

一次CPU上下文的切换大概在 1500ns 左右。从内存中读取 1MB 的连续数据,耗时大约为 250us, 假设 1MB 的数据由多个线程读取了 1000 次,那么就有1000次时间上下文的切换,那么就有 1500ns * 1000 = 1500us,单线程的读完1MB数据才250us ,但是上下文切换就用了1500us了

所以,在提升 I/O利用率 这个方面上,Redis 先选择了 I/O 多路复用

Redis 的多路复用

参考:IO模型

Linux 多路复用技术,就是多个进程的 I/O 可以注册到同一个管道上,这个管道会统一和内核进行交互。当管道中的某一个请求需要的数据准备好之后,进程再把对应的数据拷贝到用户空间中。

简单来说,就是通过一个线程来处理多个 I/O 流。Linux 提供了三种 I/O 多路复用实现 select、poll、epoll , 他们功能类似,但具体细节各有不同。

Redis 的 I/O多路复用 程序的所有功能都是通过包装 操作系统的 I/O多路复用函数库 来实现的。每个 I/O多路复用 函数库在 Redis 源码中都有对应的一个单独的文件。

Redis 内部使用文件事件处理器 file event handler,这个文件事件处理器是单线程的,所以 redis 才叫做单线程的模型。

文件事件处理器的结构包含 4 个部分:多个 socket,I/O 多路复用程序,文件事件分派器,事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)。

多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 I/O 多路复用程序会同时监听多个 socket,将 socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 I/O 流的效果。

所以,Redis 选择使用 I/O多路复用 来提升 I/O利用率。

Redis 单线程模式具体是怎样的?

Redis 6.0 版本之前的单线模式如下图:

图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。以利用了 epoll 的 I/O 多路复用为例。Redis 初始化的时候,会做下面这几件事情:

  • 首先,调用 epoll_create() 创建一个 epoll 对象和调用 socket() 创建一个服务端 socket
  • 然后,调用 bind() 绑定端口和调用 listen() 监听该 socket
  • 然后,将调用 epoll_ctl()listen socket 加入到 epoll,同时注册「连接事件」处理函数。

初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:

  • 首先,先调用处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write() 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait() 发现可写后再处理 。
  • 接着,调用 epoll_wait() 函数等待事件的到来:
    • 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数
    • 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送
    • 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait() 发现可写后再处理

以上就是 Redis 单线模式的工作方式,如果你想看源码解析,可以参考这一篇:为什么单线程的 Redis 如何做到每秒数万 QPS

Redis 采用单线程为什么还这么快?

官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒,如下图所示:

之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:

  • Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
  • Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
  • Redis 采用了 I/O 多路复用机制 处理客户端 Socket 请求,I/O 多路复用机制是指一个线程处理多个 I/O 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 I/O 流的效果。
  • 数据结构简单,对数据操作也简单,如哈希表、跳表都有很高的性能。

Redis 6.0 之后为什么引入了多线程?

2020年5月份,Redis 正式推出了 6.0 版本,这个版本中有很多重要的新特性,其中多线程特性引起了广泛关注。

需要注意的是:Redis 6.0 中的多线程,是指 采用了多个 I/O 线程来处理网络请求,而数据的读写命令仍然是单线程处理的。

那我们就会问 Redis 不是号称单线程也有很高的性能么?不是说多路复用技术已经大大的提升了 I/O利用率 了么,为啥还要多线程?

我猜是因为对 Redis 有着更高的要求,毕竟 开发商 也要想尽办法提升性能,来满足市场。根据测算,Redis 将所有数据放在内存中,内存的响应时长大约为 100 纳秒,对于小数据包,Redis 服务器可以处理 80,000 到 100,000 QPS,这么高的对于 80% 的公司来说,单线程的 Redis 已经足够使用了。

但随着越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的 QPS。为了提升 QPS,很多公司的做法是部署 Redis 集群,并且尽可能提升 Redis 机器数。但是这种做法的资源消耗是巨大的。

而经过分析,限制 Redis 的瓶颈出现在 网络I/O 的处理上,虽然之前采用了多路复用技术,但是采用多线程能进一步提升效率。多路复用的I/O模型本质上仍然是同步阻塞型I/O模型。下面是 I/O多路复用 中 select 函数的处理过程:

从上图我们可以看到,在 I/O多路复用 模型中,在处理网络请求时,共有两个阶段:

  • 调用 select(其他函数同理)的过程,这个过程对于 poll/select 这两种 Linux 的实现来说,如果并发量很高,此处会成为瓶颈。
  • 把数据从内核空间复制到用户空间这个过程。如果并发量很高,也会消耗大量的资源,从而影响 Redis 整体的效率。

现在很多服务器都是多 CPU 的,但是 Redis 因为使用了单线程,在一次数据操作的过程中,有很多 CPU 时间片耗费在 网络I/O 的同步处理上,从而没有充分的发挥出多核的优势。

如果能采用多线程,使得网络处理的请求并发进行,就可以大大的提升性能。多线程除了可以减少由于网络 I/O 等待造成的影响,还可以充分利用 CPU 的多核优势。

所以,Redis 6.0 采用多个 I/O 线程来处理网络请求,网络请求的解析可以由其他线程完成,然后把解析后的请求交由主线程进行实际的内存读写。提升网络请求处理的并行度,进而提升整体性能。

但是,Redis 的多 I/O 线程只是用来处理网络请求的,对于读写命令,Redis 仍然使用单线程来处理。

那么,在引入多线程之后,如何解决并发带来的线程安全问题呢?

这就是为什么多次提到的 Redis 6.0 的多线程只用来处理网络请求,而数据的读写还是单线程 的原因。

Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上

Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。

1
io-threads-do-reads yes         //读请求也使用io多线程

同时, Redis.conf 配置文件中提供了 I/O 多线程个数的配置项。

1
io-threads 4                    // io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)

关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。

因此,Redis 6.0 版本之后,默认情况下 Redis 在启动的时候会 额外创建 6 个线程(_这里的线程数不包括主线程_):

  • Redis-server:Redis的主线程,主要负责执行命令;
  • bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
  • io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。

Referenct


04-Redis 单线程 or 多线程
https://flepeng.github.io/041-Redis-41-核心概念-04-Redis-单线程-or-多线程/
作者
Lepeng
发布于
2021年1月1日
许可协议