参考

小林:Redis 线程模型

@panjf2000 Redis 多线程网络模型全面揭秘

pgnozxzkp4mgq Redis 6 的多线程

盼盼编程 redis源码客户端和服务端通信过程

公众号:堆栈futureRedis6.0 多线程无锁I/O设计精髓

 


Redis 有多快?

根据官方的 benchmark,通常来说,在一台普通硬件配置的 Linux 机器上跑单个 Redis 实例,处理简单命令(时间复杂度 O(N) 或者 O(log(N))),QPS 可以达到 8w+,而如果使用 pipeline 批处理功能,则 QPS 至高能达到 100w。 仅从性能层面进行评判,Redis 完全可以被称之为高性能缓存方案。


Redis 为什么快?

Redis 的高性能得益于以下几个基础:

  • C 语言实现,虽然 C 对 Redis 的性能有助力,但语言并不是最核心因素。
  • 纯内存存取数据,相较于其他基于磁盘的 DB,Redis 的纯内存操作有着天然的性能优势。
  • I/O 多路复用,基于 epoll/select/kqueue 等 I/O 多路复用技术,实现高吞吐的网络 I/O。
  • 单线程模型,单线程无法利用多核,但是从另一个层面来说则避免了多线程频繁上下文切换,以及同步机制如锁带来的开销。 

关于什么是 IO 多路复用,什么是同步非阻塞 IO ,以及单Reactor单线程 的详解请看这一篇

https://www.cnblogs.com/suBlog/p/16854347.html

我们常说的 Redis 单线程,只是指它在 Redis 6.0 之前,IO上采用了 单Reactor 单线程,「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的

Redis 6.0 之前单线程

单 Reactor 单线程「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。

Redis 初始化的时候,会做下面这几件事情:

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

此外

  • 创建 aeAiPoll 事件处理器,一个全局的 RedisServer 对象,里面有 list *clients(所有客户端连接状态信息 RedisCLient),clients_pending_write ((向客户端的响应)写发送队列)

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

  • 首先,先调用处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
  • 接着,调用 epoll_wait 函数等待事件的到来:
    • 如果是连接事件到来,则会 调用「连接事件」acceptTcpHandler 处理函数 到用户配置的监听端口对应的文件描述符,等待新连接到来,该函数会做这些事情:
      • 调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 为这个新连接创建一个唯一 redisClient 对象 (为每个客户端 socket 连接维持一些状态信息, 相当于Reactor模型中的Handler),
      •  注册「读事件」readQueryFromClient 处理函数,把创建的 redisClient 对象保存在 redisServer 里面的 list *clients里面。
    • 如果是(客户端的命令)读事件到来,则会调用「读事件」readQueryFromClient 处理函数 ,该函数会做这些事情:
      • 获取客户端发送的数据 -> processInputBuffer 解析命令 -> processCommand 处理命令 -> addReply 将客户端添加到发送队列,并将执行结果写到发送缓存区等待发送 -> prepareClientToWrite 
      • 注册「回复客户端的函数」sendReplyToClient。
    • 如果是(命令执行完成后给客户端的回复)写事件到来,则会 调用「回复客户端的函数」sendReplyToClient。该函数会做这些事情:
      • 通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续 注册「回复客户端的函数」sendReplyToClient,等待 epoll_wait 发现可写后再处理 。

(回复客户端)写发送队列是什么样的?

每个连接对应的  RedisClient 对象中都有一个回复链表 list *reply ,回复缓冲区 char buf[REDIS_REPLY_CHUNK_BYTES]

 RedisServer 对象的 server.clients_pending_write 就是我们说的(回复客户端)写任务队列,队列中的每一个元素都是有待写返回数据的 client 对象。在 prepareClientToWrite 函数中,把 client 添加到任务队列 server.clients_pending_write 里就算完事。

可以看到,每一个阶段都关联一个回调函数,当事件触发后走回调函数。

 

Redis 6.0 之后多线程

可能有同学会问,主线程和多个I/O线程,都同时处理图中的“队列”,是不是会存在锁竞争的关系呢?

这里有个巧妙的设计,就是当epoll获取socket链接时,会将该事件先全部扔进队列中,比如扔了N个事件,这时主线程就会处于忙等(spinlock自旋锁的效果)状态。然后多个I/O线程开始去并行进行网络I/O,并对数据进行协议解析,当队列全部处理完毕后,主线程会对队列中请求串行““执行Redis命令”,然后清空该队列。

所以整个执行流程总结下来:主线程执行请求入队列 -> I/O线程并行进行网络读 -> 主线程串行执行Redis命令 -> I/O线程并行进行网络写 -> 主线程清空队列并接收下一批请求。

优点 VS 缺点

  • 优点:a. 提高响应速度,充分使用CPU
  • 缺点:a. 增加了代码复杂性

  1. Redis 服务器启动,开启主线程事件循环(Event Loop),注册 acceptTcpHandler 链接应答处理器到用户配置的监听端口对应的文件描述符,等待新链接到来;
  2. 客户端和服务端创建网络链接;
  3. acceptTcpHandler 被调用,主线程使用 AE 的 API 将 readQueryFromClient 命令读取处理器绑定到新链接对应的文件描述符上,并初始化一个 client 绑定这个客户端链接;
  4. 客户端发送请求命令,触发读就绪事件,服务端主线程不会经过 socket 去读取客户端的请求命令,而是先将 client 放入一个 LIFO 队列 clients_pending_read;
  5. 在事件循环(Event Loop)中,主线程执行 beforeSleep -->handleClientsWithPendingReadsUsingThreads,利用 Round-Robin 轮询负载均衡策略,把 clients_pending_read 队列中的链接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程本身,并且用 io_threads_pending[id] 来记录每个线程的分配任务数量,因为线程需要读取这个io_threads_pending[id]这个数量来消费任务,消费完成会初始化为0。I/O 线程经过 socket 读取客户端的请求命令(是通过io_threads_op这个变量来判断是读(IO_THREADS_OP_READ)还是写(IO_THREADS_OP_WRITE), 这里是io_threads_op == IO_THREADS_OP_READ),存入 client->querybuf 并解析第一个命令,但不执行命令,主线程忙轮询,等待全部 I/O 线程完成读取任务;
  6. 主线程和全部 I/O 线程都完成了读取任务(通过遍历io_threads_pending[id],把每个线程的分配任务数量累加起来如果和等于0代表多线程已经消费完了任务),主线程结束忙轮询,遍历 clients_pending_read 队列,执行全部客户端链接的请求命令,先调用 processCommandAndResetClient 执行第一条已经解析好的命令,而后调用 processInputBuffer 解析并执行客户端链接的全部命令,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根据 Redis 协议解析命令,最后调用 processCommand 执行命令;
  7. 根据请求命令的类型(SET, GET, DEL, EXEC 等),分配相应的命令执行器去执行,最后调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->reply ,client->buf 是首选的写出缓冲区,固定大小 16KB,通常来讲能够缓冲足够多的响应数据,可是若是客户端在时间窗口内须要响应的数据很是大,那么则会自动切换到 client->reply 链表上去,使用链表理论上可以保存无限大的数据(受限于机器的物理内存),最后把 client 添加进一个 LIFO 队列 clients_pending_write;
  8. 在事件循环(Event Loop)中,主线程执行 beforeSleep --> handleClientsWithPendingWritesUsingThreads,利用 Round-Robin 轮询负载均衡策略,把 clients_pending_write 队列中的链接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程本身,并且用io_threads_pending[id]来记录每个线程的分配任务数量,因为线程需要读取这个io_threads_pending[id]这个数量来消费任务,消费完成会置为0。I/O 线程经过调用 writeToClient(io_threads_op == IO_THREADS_OP_WRITE)把 client 的写出缓冲区里的数据回写到客户端,主线程忙轮询,等待全部 I/O 线程完成写出任务;
  9. 主线程和全部 I/O 线程都完成了写出任务(通过遍历io_threads_pending[id],把每个线程的分配任务数量累加起来如果和等于0代表多线程已经消费完了任务), 主线程结束忙轮询,遍历 clients_pending_write 队列,若是 client 的写出缓冲区还有数据遗留,则注册 sendReplyToClient 到该链接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。

这里大部分逻辑和以前的单线程模型是一致的,变更的地方仅仅是把读取客户端请求命令回写响应数据的逻辑异步化了,交给 I/O 线程去完成,这里须要特别注意的一点是:I/O 线程仅仅是读取和解析客户端命令而不会真正去执行命令,客户端命令的执行最终仍是要回到主线程单线程完成。

 

Redis 版本变更

 

我们要先明确『单线程』这个概念的边界:

它的覆盖范围是核心网络模型,抑或是整个 Redis?

如果是前者,那么答案是肯定的,在 Redis 的 v6.0 版本正式引入多线程之前,其网络模型一直是单线程模式的;

如果是后者,那么答案则是否定的,Redis 早在 v4.0 就已经引入了多线程。

因此,当我们讨论 Redis 的多线程之时,有必要对 Redis 的版本划出两个重要的节点:

  • Redis v4.0(引入多线程处理异步任务)
  • Redis v6.0(正式在网络模型中实现 I/O 多线程)

非网络模型中的多线程:

  • Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
  • Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步 释放 Redis 内存,也就是 lazyfree 线程。
    • unlink key :异步删除key指令。Redis 为了解决删除大 key 的卡顿问题而引入的。当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大key。
    • flushdb async :用来清空数据库的异步命令,当数据量很大时,同步容易阻塞Redis主线程。
    • flushall async :用来清空数据库的异步命令,当数据量很大时,同步容易阻塞Redis主线程。

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

redis 6.0 版本之后,引入了多线程 IO。所以总的来说,Redis 6.0 在启动的时候,默认情况下会额外创建 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(减去一个主线程)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。

 

Redis 6.0 之前网络模型为什么使用单线程?

官方给出的 FAQ 是

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

除了上面的官方回答,选择单线程的原因也有下面的考虑。

使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在【线程切换】、甚至【加锁解锁】、【死锁】造成的性能损耗。

 

Redis 6.0 之后网络模型为什么引入了多线程?

在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上

所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。

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。