Redis线程模型

一、前言

Redis是一个开源的远程内存型数据库(Remote Dictionary Server(远程字典服务器)),它不仅性能强劲,而且提供了5 种不同类型的数据结构,我们项目实践中可能会遇到的大部分问题都可以很自然地映射到这些数据结构上。除此之外,Redis通过复制、持久化(persistence)、Redis Sentinel、客户端分片(client-side sharding)等特性,让用户可以很方便地将Redis 扩展成一个高可用能够包含数百GB 数据、每秒处理上百万次请求的系统。

本节我们讨论下Redis的单线程、多线程网络模型,以及多线程异步任务模型。

二、Redis 6.0版本前的单线程模型(网络线程模型)

Redis的核心网络模型选择用单线程来实现。正如redis官网上说,对于一个 DB 来说,CPU 通常不会是瓶颈,因为大多数请求不会是 CPU 密集型的,而是 I/O 密集型。具体到 Redis 的话,如果不考虑 RDB/AOF 等持久化方案,Redis 是完全的纯内存操作,执行速度是非常快的,因此这部分操作通常不会是性能瓶颈,Redis 真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输延迟,因此 Redis 6.0版本前选择了单线程的 I/O 多路复用来实现它的核心网络模型。

使用单线程网络模型好处:

  • 避免过多的上下文切换开销
    多线程调度过程中必然需要在 CPU 之间�切换线程上下文 context,而上下文的切换是有开销的。单线程则可以规避进程内频繁的线程切换开销,因为程序始终运行在进程中单个线程内,没有多线程切换的场景。

  • 避免同步机制的开销
    如果 Redis 选择多线程模型,势必涉及到底层数据同步的问题,必然会引入某些同步机制,比如锁,而我们知道 Redis 不仅仅提供了简单的 key-value 数据结构,还有 list、set 和 hash 等等其他丰富的数据结构,而不同的数据结构对同步访问的加锁粒度又不尽相同,可能会导致在操作数据过程中带来很多加锁解锁的开销,增加程序复杂度的同时还会降低性能。

  • 简单可维护
    Redis 的作者 Salvatore Sanfilippo (别称 antirez) 对 Redis 的设计和代码有着近乎偏执的简洁性理念。因此代码的简单可维护性必然是 Redis 早期的核心准则之一,而引入多线程必然会导致代码的复杂度上升和可维护性下降。前面我们提到引入多线程必须的同步机制,如果 Redis 使用多线程模式,那么所有的底层数据结构都必须实现成线程安全的,这无疑又使得 Redis 的实现变得更加复杂。总而言之,Redis 选择单线程可以说是多方博弈之后的一种权衡:在保证足够的性能表现之下,使用单线程保持代码的简单和可维护性。

在 v6.0 版本之前,Redis 的核心网络模型一直是一个典型的单 Reactor 模型:利用 epoll/select/kqueue 等多路复用技术,在单线程的事件循环中不断去处理事件(客户端请求),最后回写响应数据到客户端:


image.png
  • aeEventLoop:这是 Redis 自己实现的一个高性能事件库,里面封装了适配各个系统的 I/O多路复用(I/O multiplexing),EventLoop除了 处理socket 的读写事件外,还要处理一些定时任务。aeEventLoop本质是一个线程,服务启动时就一直循环,调用 aeProcessEvent 处理文件(网络)或者时间事件;等价于Java中NIO的select线程

  • client :代表一个客户端连接。Redis 是典型的 CS 架构(Client <---> Server),客户端通过 socket 与服务端建立网络通道然后发送请求命令,服务端执行请求的命令并回复。Redis 使用结构体 client 存储客户端的所有相关信息,包括但不限于封装的套接字连接 – conn,当前选择的数据库指针 –db,读入缓冲区 – querybuf,写出缓冲区 – buf,写出数据链表 – reply等;

  • acceptTcpHandler:角色 Acceptor 的实现,当有新的客户端连接时会调用这个方法,它会调用系统 accept 创建一个 socket 对象,同时创建 client 对象,并将 socket 添加到 EventLoop 的监听列表中(等价于NIO中注册socket到select上),并注册当对应的读事件发生时的回调函数 readQueryFromClient:即绑定 Handler,这样当该客户端发起请求时,就会调用对应的回调函数处理请求;

  • readQueryFromClient:角色 Handler 的实现,主要负责解析并执行客户端的命令请求,并将结果写到对应的 client->buf 或者 client->reply 中;

  • beforeSleep:事件循环之前的操作,主要执行一些常规任务,比如将 client 中的数据写会给客户端、进行一些持久化任务(AOF 或者RDB操作,主从同步)等。

下面我们描绘一下 客户端client 与 Redis server 建立连接、发起请求到接收到返回的整个过程:

  • 首先Redis 服务器启动,开启主线程事件循环 aeMain,注册 acceptTcpHandler 连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接到来;
  • 客户端和服务端建立网络连接,acceptTcpHandler 被调用,主线程将 readQueryFromClient 命令读取处理器绑定到新连接对应的文件描述符上作为对应事件发生时的回调函数,并初始化一个 client 绑定这个客户端连接;
  • 客户端发送请求命令,触发读就绪事件,主线程调用 readQueryFromClient 通过 socket 读取客户端发送过来的命令存入 client->querybuf 读入缓冲区;
  • 接着调用 processInputBuffer,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根据 Redis 协议解析命令,最后调用 processCommand 执行命令;
  • 根据请求命令的类型(SET, GET, DEL, EXEC 等),分配相应的命令执行器去执行,最后调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->reply ,client->buf 是首选的写出缓冲区,固定大小 16KB,一般来说可以缓冲足够多的响应数据,但是如果客户端在时间窗口内需要响应的数据非常大,那么则会自动切换到 client->reply链表上去,使用链表理论上能够保存无限大的数据(受限于机器的物理内存),最后把 client 添加进一个 LIFO 队列 clients_pending_write;
  • 在事件循环 aeMain 中,主线程执行 beforeSleep --> handleClientsWithPendingWrites,遍历 clients_pending_write 队列,调用 writeToClient 把 client 的写出缓冲区里的数据回写到客户端,如果写出缓冲区还有数据遗留,则注册 sendReplyToClient 命令回复处理器到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。

三、Redis 6.0后的多线程模型(网络线程模型)

image.png

如上图,这种模式不再是单线程的事件循环,而是有多个线程(IO Thread)各自维护一个独立的事件循环。整体模型是由 Main 线程负责接收新连接,并分发给 IO Thread 去独立处理(解析请求命令),但是具体命令的执行还是使用main 线程来执行,最后使用IO 线程回写响应给客户端。

IO线程轮训socket列表读事件,然后解析为redis命令,并把解析好的命令放到全局待执行队列,然后主线程从全局待执行队列读取命令然后具体执行命令,最后把响应结果分配到不同IO线程,由IO线程来具体执行把响应结果写回客户端。

也就是具体命令执行还是由main线程所在的事件循环单线程处理,只是读写socket事件由IO线程来处理。

虽然多线程方案能提升1倍以上的性能,但整个方案仍然比较粗糙:

  • 首先所有命令的执行仍然在主线程中进行,仍然存在性能瓶颈。
  • 另外IO 读写为批处理读写,即所有 IO 线程先读取完请求数据并且解析为redis命令后,主线程才开始执行解析的命令;然后待主线程执行完所有的redis命令后,才让所有 IO 线程再一起回复所有响应;也就是说不同请求需要相互等待,效率不高。
  • 最后在 IO 批处理读写和主线程处理时,使用线程自旋检测等待(如下代码),效率更是低下,即便任务很少,也很容易把 CPU 打满。
//IO线程逻辑
void *IOThreadMain(void *myid) 
    while(1) {
    long id = (unsigned long)myid;

        // 忙轮询100w 次循环,等待主线程分配 I/O 任务。
        for (int j = 0; j < 1000000; j++) {
            if (io_threads_pending[id] != 0) break;
        }
      ...
    }
}

//主线程执行逻辑
int handleClientsWithPendingWritesUsingThreads(void) {
   
    // 忙轮询,累加所有 I/O 线程的原子任务计数器,直到所有计数器的遗留任务数量都是 0。
    // 表示所有任务都已经执行完成,结束轮询。
    while(1) {
        unsigned long pending = 0;
        for (int j = 1; j < server.io_threads_num; j++)
            pending += io_threads_pending[j];
        if (pending == 0) break;
    }
   ...
}

2.3 Redis任务多线程模型(异步任务,非网络线程模型)

Redis 在 v4.0 版本的时就已经引入了的多线程来做一些异步操作,这主要是为了解决一些非常耗时的命令,通过将这些命令的执行进行异步化,避免阻塞单线程网络模型的事件循环。

Redis 启动时,会创建三个任务队列,并对应构建 3 个 BIO 线程,三个 BIO 线程与 3 个任务队列之间一一对应。BIO 线程分别处理如下 3 种任务。

  1. close 关闭文件任务。rewriteaof 完成后,主线程需要关闭旧的 AOF 文件,就向 close 队列插入一个旧 AOF 文件的关闭任务。由 close 线程来处理。
  2. fysnc 任务。Redis 将 AOF 数据缓冲写入文件内核缓冲后,需要定期将系统内核缓冲数据写入磁盘,此时可以向 fsync 队列写入一个同步文件缓冲的任务,由 fsync 线程来处理。
  3. lazyfree 任务。Redis 在需要淘汰元素数大于 64 的聚合类数据类型时,如列表、集合、哈希等,就往延迟清理队列中写入待回收的对象,由 lazyfree 线程后续进行异步回收。

BIO 线程的整个处理流程如图所示。当主线程有慢任务需要异步处理时。就会向对应的任务队列提交任务。提交任务时,首先申请内存空间,构建 BIO 任务。然后对队列锁进行加锁,在队列尾部追加新的 BIO 任务,最后尝试唤醒正在等待任务的 BIO 线程。

img

BIO 线程启动时或持续处理完所有任务,发现任务队列为空后,就会阻塞,并等待新任务的到来。当主线程有新任务后,主线程会提交任务,并唤醒 BIO 线程。BIO 线程随后开始轮询获取新任务,并进行处理。当处理完所有 BIO 任务后,则再次进入阻塞,等待下一轮唤醒。

四、总结

在Redis6.0版本前,其提供单线程网络模型,使用单线程来处理socket的读写事件、命令解析、命令执行工作。

在Redis6.0版本后,提供了多线程模型逻辑,其中socket的读写事件、命令解析使用IO线程来处理,但是具体命令的执行还是使用单线程事件循环来进行处理。但是其实现并不优雅。

最后无论是单线程还是多线程网络模型,命令的具体执行还是靠单线程事件循环来执行的,如果要执行的命令非常耗时,则会阻塞事件循环的执行,使得其他命令得不到及时执行,所以Redis4.0时开始提供异步多线程任务来解决耗时比较长的命令的执行,将其异步化执行,使得主事件循环线程可以及时得到释放。

你可能感兴趣的:(Redis线程模型)