乐趣区

关于redis:Redis-多线程网络模型全面揭秘

博客原文

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

导言

在目前的技术选型中,Redis 俨然曾经成为了零碎高性能缓存计划的事实标准,因而当初 Redis 也成为了后端开发的基本技能树之一,Redis 的底层原理也牵强附会地成为了必须学习的常识。

Redis 从实质上来讲是一个网络服务器,而对于一个网络服务器来说,网络模型是它的精髓,搞懂了一个网络服务器的网络模型,你也就搞懂了它的实质。

本文通过层层递进的形式,介绍了 Redis 网络模型的版本变更历程,分析了其从单线程进化到多线程的工作原理,此外,还一并剖析并解答了 Redis 的网络模型的很多抉择背地的思考,帮忙读者能更粗浅地了解 Redis 网络模型的设计。

Redis 有多快?

依据官网的 benchmark,通常来说,在一台一般硬件配置的 Linux 机器上跑单个 Redis 实例,解决简略命令(工夫复杂度 O(N) 或者 O(log(N))),QPS 能够达到 8w+,而如果应用 pipeline 批处理性能,则 QPS 至高能达到 100w。

仅从性能层面进行评判,Redis 齐全能够被称之为高性能缓存计划。

Redis 为什么快?

Redis 的高性能得益于以下几个根底:

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

Redis 为何抉择单线程?

Redis 的外围网络模型抉择用单线程来实现,这在一开始就引起了很多人的不解,Redis 官网的对于此的答复是:

It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

外围意思就是,对于一个 DB 来说,CPU 通常不会是瓶颈,因为大多数申请不会是 CPU 密集型的,而是 I/O 密集型。具体到 Redis 的话,如果不思考 RDB/AOF 等长久化计划,Redis 是齐全的纯内存操作,执行速度是十分快的,因而这部分操作通常不会是性能瓶颈,Redis 真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输提早,因而 Redis 抉择了单线程的 I/O 多路复用来实现它的外围网络模型。

下面是比拟抽象的官网答案,实际上更加具体的抉择单线程的起因能够归纳如下:

防止过多的上下文切换开销

多线程调度过程中必然须要在 CPU 之间切换线程上下文 context,而上下文的切换又波及程序计数器、堆栈指针和程序状态字等一系列的寄存器置换、程序堆栈重置甚至是 CPU 高速缓存、TLB 快表的汰换,如果是过程内的多线程切换还好一些,因为繁多过程内多线程共享过程地址空间,因而线程上下文比之过程上下文要小得多,如果是跨过程调度,则须要切换掉整个过程地址空间。

如果是单线程则能够躲避过程内频繁的线程切换开销,因为程序始终运行在过程中单个线程内,没有多线程切换的场景。

防止同步机制的开销

如果 Redis 抉择多线程模型,又因为 Redis 是一个数据库,那么势必波及到底层数据同步的问题,则必然会引入某些同步机制,比方锁,而咱们晓得 Redis 不仅仅提供了简略的 key-value 数据结构,还有 list、set 和 hash 等等其余丰盛的数据结构,而不同的数据结构对同步拜访的加锁粒度又不尽相同,可能会导致在操作数据过程中带来很多加锁解锁的开销,减少程序复杂度的同时还会升高性能。

简略可保护

Redis 的作者 Salvatore Sanfilippo (别称 antirez) 对 Redis 的设计和代码有着近乎偏执的简洁性理念,你能够在浏览 Redis 的源码或者给 Redis 提交 PR 的之时感触到这份偏执。因而代码的简略可维护性必然是 Redis 晚期的外围准则之一,而引入多线程必然会导致代码的复杂度回升和可维护性降落。

事实上,多线程编程也不是那么尽如人意,首先多线程的引入会使得程序不再放弃代码逻辑上的串行性,代码执行的程序将变成不可预测的,稍不留神就会导致程序呈现各种并发编程的问题;其次,多线程模式也使得程序调试更加简单和麻烦。网络上有一幅很有意思的图片,活泼形象地形容了并发编程面临的困境。

你冀望的多线程编程 VS 实际上的多线程编程:

后面咱们提到引入多线程必须的同步机制,如果 Redis 应用多线程模式,那么所有的底层数据结构都必须实现成线程平安的,这无疑又使得 Redis 的实现变得更加简单。

总而言之,Redis 抉择单线程能够说是多方博弈之后的一种衡量:在保障足够的性能体现之下,应用单线程放弃代码的简略和可维护性。

Redis 真的是单线程?

在探讨这个问题之前,咱们要先明确『单线程』这个概念的边界:它的覆盖范围是外围网络模型,抑或是整个 Redis?如果是前者,那么答案是必定的,在 Redis 的 v6.0 版本正式引入多线程之前,其网络模型始终是单线程模式的;如果是后者,那么答案则是否定的,Redis 早在 v4.0 就曾经引入了多线程。

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

  1. Redis v4.0(引入多线程解决异步工作)
  2. Redis v6.0(正式在网络模型中实现 I/O 多线程)

单线程事件循环

咱们首先来分析一下 Redis 的外围网络模型,从 Redis 的 v1.0 到 v6.0 版本之前,Redis 的外围网络模型始终是一个典型的单 Reactor 模型:利用 epoll/select/kqueue 等多路复用技术,在单线程的事件循环中一直去处理事件(客户端申请),最初回写响应数据到客户端:

这里有几个外围的概念须要学习:

  • client:客户端对象,Redis 是典型的 CS 架构(Client <—> Server),客户端通过 socket 与服务端建设网络通道而后发送申请命令,服务端执行申请的命令并回复。Redis 应用构造体 client 存储客户端的所有相干信息,包含但不限于 封装的套接字连贯 -- *conn以后抉择的数据库指针 -- *db读入缓冲区 -- querybuf写出缓冲区 -- buf写出数据链表 -- reply等。
  • aeApiPoll:I/O 多路复用 API,是基于 epoll_wait/select/kevent 等零碎调用的封装,监听期待读写事件触发,而后解决,它是事件循环(Event Loop)中的外围函数,是事件驱动得以运行的根底。
  • acceptTcpHandler:连贯应答处理器,底层应用零碎调用 accept 承受来自客户端的新连贯,并为新连贯注册绑定命令读取处理器,以备后续解决新的客户端 TCP 连贯;除了这个处理器,还有对应的 acceptUnixHandler 负责解决 Unix Domain Socket 以及 acceptTLSHandler 负责解决 TLS 加密连贯。
  • readQueryFromClient:命令读取处理器,解析并执行客户端的申请命令。
  • beforeSleep:事件循环中进入 aeApiPoll 期待事件到来之前会执行的函数,其中蕴含一些日常的工作,比方把 client->buf 或者 client->reply(前面会解释为什么这里须要两个缓冲区)中的响应写回到客户端,长久化 AOF 缓冲区的数据到磁盘等,绝对应的还有一个 afterSleep 函数,在 aeApiPoll 之后执行。
  • sendReplyToClient:命令回复处理器,当一次事件循环之后写出缓冲区中还有数据残留,则这个处理器会被注册绑定到相应的连贯上,等连贯触发写就绪事件时,它会将写出缓冲区残余的数据回写到客户端。

Redis 外部实现了一个高性能的事件库 — AE,基于 epoll/select/kqueue/evport 四种事件驱动技术,实现 Linux/MacOS/FreeBSD/Solaris 多平台的高性能事件循环模型。Redis 的外围网络模型正式构筑在 AE 之上,包含 I/O 多路复用、各类处理器的注册绑定,都是基于此才得以运行。

至此,咱们能够描绘出客户端向 Redis 发动申请命令的工作原理:

  1. Redis 服务器启动,开启主线程事件循环(Event Loop),注册 acceptTcpHandler 连贯应答处理器到用户配置的监听端口对应的文件描述符,期待新连贯到来;
  2. 客户端和服务端建设网络连接;
  3. acceptTcpHandler 被调用,主线程应用 AE 的 API 将 readQueryFromClient 命令读取处理器绑定到新连贯对应的文件描述符上,并初始化一个 client 绑定这个客户端连贯;
  4. 客户端发送申请命令,触发读就绪事件,主线程调用 readQueryFromClient 通过 socket 读取客户端发送过去的命令存入 client->querybuf 读入缓冲区;
  5. 接着调用 processInputBuffer,在其中应用 processInlineBuffer 或者 processMultibulkBuffer 依据 Redis 协定解析命令,最初调用 processCommand 执行命令;
  6. 依据申请命令的类型(SET, GET, DEL, EXEC 等),调配相应的命令执行器去执行,最初调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->replyclient->buf 是首选的写出缓冲区,固定大小 16KB,一般来说能够缓冲足够多的响应数据,然而如果客户端在工夫窗口内须要响应的数据十分大,那么则会主动切换到 client->reply 链表下来,应用链表实践上可能保留无限大的数据(受限于机器的物理内存),最初把 client 增加进一个 LIFO 队列 clients_pending_write
  7. 在事件循环(Event Loop)中,主线程执行 beforeSleep –> handleClientsWithPendingWrites,遍历 clients_pending_write 队列,调用 writeToClientclient 的写出缓冲区里的数据回写到客户端,如果写出缓冲区还有数据遗留,则注册 sendReplyToClient 命令回复处理器到该连贯的写就绪事件,期待客户端可写时在事件循环中再持续回写残余的响应数据。

对于那些想利用多核优势晋升性能的用户来说,Redis 官网给出的解决方案也非常简单粗犷:在同一个机器上多跑几个 Redis 实例。事实上,为了保障高可用,线上业务个别不太可能会是单机模式,更加常见的是利用 Redis 分布式集群多节点和数据分片负载平衡来晋升性能和保障高可用。

多线程异步工作

以上便是 Redis 的外围网络模型,这个单线程网络模型始终到 Redis v6.0 才革新成多线程模式,但这并不意味着整个 Redis 始终都只是单线程。

Redis 在 v4.0 版本的时候就曾经引入了的多线程来做一些异步操作,此举次要针对的是那些十分耗时的命令,通过将这些命令的执行进行异步化,防止阻塞单线程的事件循环。

咱们晓得 Redis 的 DEL 命令是用来删除掉一个或多个 key 贮存的值,它是一个阻塞的命令,大多数状况下你要删除的 key 里存的值不会特地多,最多也就几十上百个对象,所以能够很快执行完,然而如果你要删的是一个超大的键值对,外面有几百万个对象,那么这条命令可能会阻塞至多好几秒,又因为事件循环是单线程的,所以会阻塞前面的其余事件,导致吞吐量降落。

Redis 的作者 antirez 为了解决这个问题进行了很多思考,一开始他想的方法是一种渐进式的计划:利用定时器和数据游标,每次只删除一小部分的数据,比方 1000 个对象,最终革除掉所有的数据,然而这种计划有个致命的缺点,如果同时还有其余客户端往某个正在被渐进式删除的 key 里持续写入数据,而且删除的速度跟不上写入的数据,那么将会无止境地耗费内存,尽管起初通过一个奇妙的方法解决了,然而这种实现使 Redis 变得更加简单,而多线程看起来仿佛是一个瓜熟蒂落的解决方案:简略、易了解。于是,最终 antirez 抉择引入多线程来实现这一类非阻塞的命令。更多 antirez 在这方面的思考能够浏览一下他发表的博客:Lazy Redis is better Redis。

于是,在 Redis v4.0 之后减少了一些的非阻塞命令如 UNLINKFLUSHALL ASYNCFLUSHDB ASYNC

UNLINK 命令其实就是 DEL 的异步版本,它不会同步删除数据,而只是把 key 从 keyspace 中临时移除掉,而后将工作增加到一个异步队列,最初由后盾线程去删除,不过这里须要思考一种状况是如果用 UNLINK 去删除一个很小的 key,用异步的形式去做反而开销更大,所以它会先计算一个开销的阀值,只有当这个值大于 64 才会应用异步的形式去删除 key,对于根本的数据类型如 List、Set、Hash 这些,阀值就是其中存储的对象数量。

Redis 多线程网络模型

后面提到 Redis 最后抉择单线程网络模型的理由是:CPU 通常不会成为性能瓶颈,瓶颈往往是 内存 网络,因而单线程足够了。那么为什么当初 Redis 又要引入多线程呢?很简略,就是 Redis 的网络 I/O 瓶颈曾经越来越显著了。

随着互联网的飞速发展,互联网业务零碎所要解决的线上流量越来越大,Redis 的单线程模式会导致系统耗费很多 CPU 工夫在网络 I/O 上从而升高吞吐量,要晋升 Redis 的性能有两个方向:

  • 优化网络 I/O 模块
  • 进步机器内存读写的速度

后者依赖于硬件的倒退,临时无解。所以只能从前者下手,网络 I/O 的优化又能够分为两个方向:

  • 零拷贝技术或者 DPDK 技术
  • 利用多核优势

零拷贝技术有其局限性,无奈齐全适配 Redis 这一类简单的网络 I/O 场景,更多网络 I/O 对 CPU 工夫的耗费和 Linux 零拷贝技术,能够浏览我的另一篇文章:Linux I/O 原理和 Zero-copy 技术全面揭秘。而 DPDK 技术通过旁路网卡 I/O 绕过内核协定栈的形式又太过于简单以及须要内核甚至是硬件的反对。

因而,利用多核优势成为了优化网络 I/O 性价比最高的计划。

6.0 版本之后,Redis 正式在外围网络模型中引入了多线程,也就是所谓的 I/O threading,至此 Redis 真正领有了多线程模型。前一大节,咱们理解了 Redis 在 6.0 版本之前的单线程事件循环模型,实际上就是一个十分经典的 Reactor 模型:

目前 Linux 平台上支流的高性能网络库 / 框架中,大都采纳 Reactor 模式,比方 netty、libevent、libuv、POE(Perl)、Twisted(Python)等。

Reactor 模式实质上指的是应用 I/O 多路复用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O) 的模式。

更多对于 Reactor 模式的细节能够参考我之前的文章:Go netpoller 原生网络模型之源码全面揭秘,Reactor 网络模型那一大节,这里不再赘述。

Redis 的外围网络模型在 6.0 版本之前,始终是单 Reactor 模式:所有事件的解决都在单个线程内实现,尽管在 4.0 版本中引入了多线程,然而那个更像是针对特定场景(删除超大 key 值等)而打的补丁,并不能被视作外围网络模型的多线程。

通常来说,单 Reactor 模式,引入多线程之后会进化为 Multi-Reactors 模式,根本工作模式如下:

区别于单 Reactor 模式,这种模式不再是单线程的事件循环,而是有多个线程(Sub Reactors)各自保护一个独立的事件循环,由 Main Reactor 负责接管新连贯并分发给 Sub Reactors 去独立解决,最初 Sub Reactors 回写响应给客户端。

Multiple Reactors 模式通常也能够等同于 Master-Workers 模式,比方 Nginx 和 Memcached 等就是采纳这种多线程模型,尽管不同的我的项目实现细节略有区别,但总体来说模式是统一的。

设计思路

Redis 尽管也实现了多线程,然而却不是规范的 Multi-Reactors/Master-Workers 模式,这其中的原因咱们前面会剖析,当初咱们先看一下 Redis 多线程网络模型的总体设计:

  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] 和主线程本人,I/O 线程通过 socket 读取客户端的申请命令,存入 client->querybuf 并解析第一个命令,但不执行命令,主线程忙轮询,期待所有 I/O 线程实现读取工作;
  6. 主线程和所有 I/O 线程都实现了读取工作,主线程完结忙轮询,遍历 clients_pending_read 队列,执行所有客户端连贯的申请命令,先调用 processCommandAndResetClient 执行第一条曾经解析好的命令,而后调用 processInputBuffer 解析并执行客户端连贯的所有命令,在其中应用 processInlineBuffer 或者 processMultibulkBuffer 依据 Redis 协定解析命令,最初调用 processCommand 执行命令;
  7. 依据申请命令的类型(SET, GET, DEL, EXEC 等),调配相应的命令执行器去执行,最初调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->replyclient->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] 和主线程本人,I/O 线程通过调用 writeToClientclient 的写出缓冲区里的数据回写到客户端,主线程忙轮询,期待所有 I/O 线程实现写出工作;
  9. 主线程和所有 I/O 线程都实现了写出工作,主线程完结忙轮询,遍历 clients_pending_write 队列,如果 client 的写出缓冲区还有数据遗留,则注册 sendReplyToClient 到该连贯的写就绪事件,期待客户端可写时在事件循环中再持续回写残余的响应数据。

这里大部分逻辑和之前的单线程模型是统一的,变动的中央仅仅是把读取客户端申请命令和回写响应数据的逻辑异步化了,交给 I/O 线程去实现,这里须要特地留神的一点是:I/O 线程仅仅是读取和解析客户端命令而不会真正去执行命令,客户端命令的执行最终还是要在主线程上实现

源码分析

以下所有代码基于目前最新的 Redis v6.0.10 版本。

多线程初始化

void initThreadedIO(void) {
    server.io_threads_active = 0; /* We start with threads not active. */

    // 如果用户只配置了一个 I/O 线程,则不会创立新线程(效率低),间接在主线程里解决 I/O。if (server.io_threads_num == 1) return;

    if (server.io_threads_num > IO_THREADS_MAX_NUM) {
        serverLog(LL_WARNING,"Fatal: too many I/O threads configured."
                             "The maximum number is %d.", IO_THREADS_MAX_NUM);
        exit(1);
    }

    // 依据用户配置的 I/O 线程数,启动线程。for (int i = 0; i < server.io_threads_num; i++) {
        // 初始化 I/O 线程的本地工作队列。io_threads_list[i] = listCreate();
        if (i == 0) continue; // 线程 0 是主线程。// 初始化 I/O 线程并启动。pthread_t tid;
        // 每个 I/O 线程会调配一个本地锁,用来休眠和唤醒线程。pthread_mutex_init(&io_threads_mutex[i],NULL);
        // 每个 I/O 线程调配一个原子计数器,用来记录以后遗留的工作数量。io_threads_pending[i] = 0;
        // 主线程在启动 I/O 线程的时候会默认先锁住它,直到有 I/O 工作才唤醒它。pthread_mutex_lock(&io_threads_mutex[i]);
        // 启动线程,进入 I/O 线程的主逻辑函数 IOThreadMain。if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");
            exit(1);
        }
        io_threads[i] = tid;
    }
}

initThreadedIO 会在 Redis 服务器启动时的初始化工作的开端被调用,初始化 I/O 多线程并启动。

Redis 的多线程模式默认是敞开的,须要用户在 redis.conf 配置文件中开启:

io-threads 4
io-threads-do-reads yes

读取申请

当客户端发送申请命令之后,会触发 Redis 主线程的事件循环,命令处理器 readQueryFromClient 被回调,在以前的单线程模型下,这个办法会间接读取解析客户端命令并执行,然而多线程模式下,则会把 client 退出到 clients_pending_read 工作队列中去,前面主线程再调配到 I/O 线程去读取客户端申请命令:

void readQueryFromClient(connection *conn) {client *c = connGetPrivateData(conn);
    int nread, readlen;
    size_t qblen;

    // 查看是否开启了多线程,如果是则把 client 退出异步队列之后返回。if (postponeClientRead(c)) return;
    
    // 省略代码,上面的代码逻辑和单线程版本简直是一样的。... 
}

int postponeClientRead(client *c) {
    // 当多线程 I/O 模式开启、主线程没有在解决阻塞工作时,将 client 退出异步队列。if (server.io_threads_active &&
        server.io_threads_do_reads &&
        !ProcessingEventsWhileBlocked &&
        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
    {
        // 给 client 打上 CLIENT_PENDING_READ 标识,示意该 client 须要被多线程解决,// 后续在 I/O 线程中会在读取和解析完客户端命令之后判断该标识并放弃执行命令,让主线程去执行。c->flags |= CLIENT_PENDING_READ;
        listAddNodeHead(server.clients_pending_read,c);
        return 1;
    } else {return 0;}
}

接着主线程会在事件循环的 beforeSleep() 办法中,调用 handleClientsWithPendingReadsUsingThreads

int handleClientsWithPendingReadsUsingThreads(void) {if (!server.io_threads_active || !server.io_threads_do_reads) return 0;
    int processed = listLength(server.clients_pending_read);
    if (processed == 0) return 0;

    if (tio_debug) printf("%d TOTAL READ pending clients\n", processed);

    // 遍历待读取的 client 队列 clients_pending_read,// 通过 RR 轮询平均地调配给 I/O 线程和主线程本人(编号 0)。listIter li;
    listNode *ln;
    listRewind(server.clients_pending_read,&li);
    int item_id = 0;
    while((ln = listNext(&li))) {client *c = listNodeValue(ln);
        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }

    // 设置以后 I/O 操作为读取操作,给每个 I/O 线程的计数器设置调配的工作数量,// 让 I/O 线程能够开始工作:只读取和解析命令,不执行。io_threads_op = IO_THREADS_OP_READ;
    for (int j = 1; j < server.io_threads_num; j++) {int count = listLength(io_threads_list[j]);
        io_threads_pending[j] = count;
    }

    // 主线程本人也会去执行读取客户端申请命令的工作,以达到最大限度利用 CPU。listRewind(io_threads_list[0],&li);
    while((ln = listNext(&li))) {client *c = listNodeValue(ln);
        readQueryFromClient(c->conn);
    }
    listEmpty(io_threads_list[0]);

    // 忙轮询,累加所有 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;
    }
    if (tio_debug) printf("I/O READ All threads finshed\n");

    // 遍历待读取的 client 队列,革除 CLIENT_PENDING_READ 和 CLIENT_PENDING_COMMAND 标记,// 而后解析并执行所有 client 的命令。while(listLength(server.clients_pending_read)) {ln = listFirst(server.clients_pending_read);
        client *c = listNodeValue(ln);
        c->flags &= ~CLIENT_PENDING_READ;
        listDelNode(server.clients_pending_read,ln);

        if (c->flags & CLIENT_PENDING_COMMAND) {
            c->flags &= ~CLIENT_PENDING_COMMAND;
            // client 的第一条命令曾经被解析好了,间接尝试执行。if (processCommandAndResetClient(c) == C_ERR) {
                /* If the client is no longer valid, we avoid
                 * processing the client later. So we just go
                 * to the next. */
                continue;
            }
        }
        processInputBuffer(c); // 持续解析并执行 client 命令。// 命令执行实现之后,如果 client 中有响应数据须要回写到客户端,则将 client 退出到待写出队列 clients_pending_write
        if (!(c->flags & CLIENT_PENDING_WRITE) && clientHasPendingReplies(c))
            clientInstallWriteHandler(c);
    }

    /* Update processed count on server */
    server.stat_io_reads_processed += processed;

    return processed;
}

这里的外围工作是:

  • 遍历待读取的 client 队列 clients_pending_read,通过 RR 策略把所有任务分配给 I/O 线程和主线程去读取和解析客户端命令。
  • 忙轮询期待所有 I/O 线程实现工作。
  • 最初再遍历 clients_pending_read,执行所有 client 的命令。

写回响应

实现命令的读取、解析以及执行之后,客户端命令的响应数据曾经存入 client->buf 或者 client->reply 中了,接下来就须要把响应数据回写到客户端了,还是在 beforeSleep 中,主线程调用 handleClientsWithPendingWritesUsingThreads

int handleClientsWithPendingWritesUsingThreads(void) {int processed = listLength(server.clients_pending_write);
    if (processed == 0) return 0; /* Return ASAP if there are no clients. */

    // 如果用户设置的 I/O 线程数等于 1 或者以后 clients_pending_write 队列中待写出的 client
    // 数量有余 I/O 线程数的两倍,则不必多线程的逻辑,让所有 I/O 线程进入休眠,// 间接在主线程把所有 client 的相应数据回写到客户端。if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) {return handleClientsWithPendingWrites();
    }

    // 唤醒正在休眠的 I/O 线程(如果有的话)。if (!server.io_threads_active) startThreadedIO();

    if (tio_debug) printf("%d TOTAL WRITE pending clients\n", processed);

    // 遍历待写出的 client 队列 clients_pending_write,// 通过 RR 轮询平均地调配给 I/O 线程和主线程本人(编号 0)。listIter li;
    listNode *ln;
    listRewind(server.clients_pending_write,&li);
    int item_id = 0;
    while((ln = listNext(&li))) {client *c = listNodeValue(ln);
        c->flags &= ~CLIENT_PENDING_WRITE;

        /* Remove clients from the list of pending writes since
         * they are going to be closed ASAP. */
        if (c->flags & CLIENT_CLOSE_ASAP) {listDelNode(server.clients_pending_write, ln);
            continue;
        }

        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }

    // 设置以后 I/O 操作为写出操作,给每个 I/O 线程的计数器设置调配的工作数量,// 让 I/O 线程能够开始工作,把写出缓冲区(client->buf 或 c->reply)中的响应数据回写到客户端。io_threads_op = IO_THREADS_OP_WRITE;
    for (int j = 1; j < server.io_threads_num; j++) {int count = listLength(io_threads_list[j]);
        io_threads_pending[j] = count;
    }

    // 主线程本人也会去执行读取客户端申请命令的工作,以达到最大限度利用 CPU。listRewind(io_threads_list[0],&li);
    while((ln = listNext(&li))) {client *c = listNodeValue(ln);
        writeToClient(c,0);
    }
    listEmpty(io_threads_list[0]);

    // 忙轮询,累加所有 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;
    }
    if (tio_debug) printf("I/O WRITE All threads finshed\n");

    // 最初再遍历一次 clients_pending_write 队列,查看是否还有 client 的写出缓冲区中有残留数据,// 如果有,那就为 client 注册一个命令回复器 sendReplyToClient,期待客户端写就绪再持续把数据回写。listRewind(server.clients_pending_write,&li);
    while((ln = listNext(&li))) {client *c = listNodeValue(ln);

        // 查看 client 的写出缓冲区是否还有遗留数据。if (clientHasPendingReplies(c) &&
                connSetWriteHandler(c->conn, sendReplyToClient) == AE_ERR)
        {freeClientAsync(c);
        }
    }
    listEmpty(server.clients_pending_write);

    /* Update processed count on server */
    server.stat_io_writes_processed += processed;

    return processed;
}

这里的外围工作是:

  • 查看当前任务负载,如果以后的工作数量不足以用多线程模式解决的话,则休眠 I/O 线程并且间接同步将响应数据回写到客户端。
  • 唤醒正在休眠的 I/O 线程(如果有的话)。
  • 遍历待写出的 client 队列 clients_pending_write,通过 RR 策略把所有任务分配给 I/O 线程和主线程去将响应数据写回到客户端。
  • 忙轮询期待所有 I/O 线程实现工作。
  • 最初再遍历 clients_pending_write,为那些还残留有响应数据的 client 注册命令回复处理器 sendReplyToClient,期待客户端可写之后在事件循环中持续回写残余的响应数据。

I/O 线程主逻辑

void *IOThreadMain(void *myid) {/* The ID is the thread number (from 0 to server.iothreads_num-1), and is
     * used by the thread to just manipulate a single sub-array of clients. */
    long id = (unsigned long)myid;
    char thdname[16];

    snprintf(thdname, sizeof(thdname), "io_thd_%ld", id);
    redis_set_thread_title(thdname);
    // 设置 I/O 线程的 CPU 亲和性,尽可能将 I/O 线程(以及主线程,不在这里设置)绑定到用户配置的
    // CPU 列表上。redisSetCpuAffinity(server.server_cpulist);
    makeThreadKillable();

    while(1) {
        // 忙轮询,100w 次循环,期待主线程调配 I/O 工作。for (int j = 0; j < 1000000; j++) {if (io_threads_pending[id] != 0) break;
        }

        // 如果 100w 次忙轮询之后如果还是没有任务分配给它,则通过尝试加锁进入休眠,// 期待主线程分配任务之后调用 startThreadedIO 解锁,唤醒 I/O 线程去执行。if (io_threads_pending[id] == 0) {pthread_mutex_lock(&io_threads_mutex[id]);
            pthread_mutex_unlock(&io_threads_mutex[id]);
            continue;
        }

        serverAssert(io_threads_pending[id] != 0);

        if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id]));


        // 留神:主线程分配任务给 I/O 线程之时,// 会把工作退出每个线程的本地工作队列 io_threads_list[id],// 然而当 I/O 线程开始执行工作之后,主线程就不会再去拜访这些工作队列,防止数据竞争。listIter li;
        listNode *ln;
        listRewind(io_threads_list[id],&li);
        while((ln = listNext(&li))) {client *c = listNodeValue(ln);
            // 如果以后是写出操作,则把 client 的写出缓冲区中的数据回写到客户端。if (io_threads_op == IO_THREADS_OP_WRITE) {writeToClient(c,0);
              // 如果以后是读取操作,则 socket 读取客户端的申请命令并解析第一条命令。} else if (io_threads_op == IO_THREADS_OP_READ) {readQueryFromClient(c->conn);
            } else {serverPanic("io_threads_op value is unknown");
            }
        }
        listEmpty(io_threads_list[id]);
        // 所有工作执行完之后把本人的计数器置 0,主线程通过累加所有 I/O 线程的计数器
        // 判断是否所有 I/O 线程都曾经实现工作。io_threads_pending[id] = 0;

        if (tio_debug) printf("[%ld] Done\n", id);
    }
}

I/O 线程启动之后,会先进入忙轮询,判断原子计数器中的工作数量,如果是非 0 则示意主线程曾经给它调配了工作,开始执行工作,否则就始终忙轮询一百万次期待,忙轮询完结之后再查看计数器,如果还是 0,则尝试加本地锁,因为主线程在启动 I/O 线程之时就曾经提前锁住了所有 I/O 线程的本地锁,因而 I/O 线程会进行休眠,期待主线程唤醒。

主线程会在每次事件循环中尝试调用 startThreadedIO 唤醒 I/O 线程去执行工作,如果接管到客户端申请命令,则 I/O 线程会被唤醒开始工作,依据主线程设置的 io_threads_op 标识去执行命令读取和解析或者回写响应数据的工作,I/O 线程在收到主线程告诉之后,会遍历本人的本地工作队列 io_threads_list[id],取出一个个 client 执行工作:

  • 如果以后是写出操作,则调用 writeToClient,通过 socket 把 client->buf 或者 client->reply 里的响应数据回写到客户端。
  • 如果以后是读取操作,则调用 readQueryFromClient,通过 socket 读取客户端命令,存入 client->querybuf,而后调用 processInputBuffer 去解析命令,这里最终只会解析到第一条命令,而后就完结,不会去执行命令。
  • 在全副工作执行完之后把本人的原子计数器置 0,以告知主线程本人曾经实现了工作。
void processInputBuffer(client *c) {
// 省略代码
...

    while(c->qb_pos < sdslen(c->querybuf)) {
        /* Return if clients are paused. */
        if (!(c->flags & CLIENT_SLAVE) && clientsArePaused()) break;

        /* Immediately abort if the client is in the middle of something. */
        if (c->flags & CLIENT_BLOCKED) break;

        /* Don't process more buffers from clients that have already pending
         * commands to execute in c->argv. */
        if (c->flags & CLIENT_PENDING_COMMAND) break;
        /* Multibulk processing could see a <= 0 length. */
        if (c->argc == 0) {resetClient(c);
        } else {
            // 判断 client 是否具备 CLIENT_PENDING_READ 标识,如果是处于多线程 I/O 的模式下,// 那么此前曾经在 readQueryFromClient -> postponeClientRead 中为 client 打上该标识,// 则立即跳出循环完结,此时第一条命令曾经解析实现,然而不执行命令。if (c->flags & CLIENT_PENDING_READ) {
                c->flags |= CLIENT_PENDING_COMMAND;
                break;
            }

            // 执行客户端命令
            if (processCommandAndResetClient(c) == C_ERR) {
                /* If the client is no longer valid, we avoid exiting this
                 * loop and trimming the client buffer later. So we return
                 * ASAP in that case. */
                return;
            }
        }
    }

...
}

这里须要额定关注 I/O 线程首次启动时会设置以后线程的 CPU 亲和性,也就是绑定以后线程到用户配置的 CPU 上,在启动 Redis 服务器主线程的时候同样会设置 CPU 亲和性,Redis 的外围网络模型引入多线程之后,加上之前的多线程异步工作、多过程(BGSAVE、AOF、BIO、Sentinel 脚本工作等),Redis 现如今的零碎并发度曾经很大了,而 Redis 自身又是一个对吞吐量和提早极度敏感的零碎,所以用户须要 Redis 对 CPU 资源有更细粒度的管制,这里次要思考的是两方面:CPU 高速缓存和 NUMA 架构。

首先是 CPU 高速缓存(这里探讨的是 L1 Cache 和 L2 Cache 都集成在 CPU 中的硬件架构),这里设想一种场景:Redis 主过程正在 CPU-1 上运行,给客户端提供数据服务,此时 Redis 启动了子过程进行数据长久化(BGSAVE 或者 AOF),系统调度之后子过程抢占了主过程的 CPU-1,主过程被调度到 CPU-2 下来运行,导致之前 CPU-1 的高速缓存里的相干指令和数据被汰换掉,CPU-2 须要从新加载指令和数据到本人的本地高速缓存里,节约 CPU 资源,升高性能。

因而,Redis 通过设置 CPU 亲和性,能够将主过程 / 线程和子过程 / 线程绑定到不同的核隔离开来,使之互不烦扰,能无效地晋升零碎性能。

其次是基于 NUMA 架构的思考,在 NUMA 体系下,内存控制器芯片被集成到处理器外部,造成 CPU 本地内存,拜访本地内存只需通过内存通道而无需通过系统总线,拜访时延大大降低,而多个处理器之间通过 QPI 数据链路互联,跨 NUMA 节点的内存拜访开销远大于本地内存的拜访:

因而,Redis 通过设置 CPU 亲和性,让主过程 / 线程尽可能在固定的 NUMA 节点上的 CPU 上运行,更多地应用本地内存而不须要跨节点拜访数据,同样也能大大地晋升性能。

对于 NUMA 相干常识请读者自行查阅,篇幅所限这里就不再开展,当前有工夫我再独自写一篇文章介绍。

最初还有一点,浏览过源码的读者可能会有疑难,Redis 的多线程模式下,仿佛并没有对数据进行锁爱护,事实上 Redis 的多线程模型是全程无锁(Lock-free)的,这是通过原子操作 + 交织拜访来实现的,主线程和 I/O 线程之间共享的变量有三个:io_threads_pending 计数器、io_threads_op I/O 标识符和 io_threads_list 线程本地工作队列。

io_threads_pending 是原子变量,不须要加锁爱护,io_threads_opio_threads_list 这两个变量则是通过管制主线程和 I/O 线程交织拜访来躲避共享数据竞争问题:I/O 线程启动之后会通过忙轮询和锁休眠期待主线程的信号,在这之前它不会去拜访本人的本地工作队列 io_threads_list[id],而主线程会在调配完所有工作到各个 I/O 线程的本地队列之后才去唤醒 I/O 线程开始工作,并且主线程之后在 I/O 线程运行期间只会拜访本人的本地工作队列 io_threads_list[0] 而不会再去拜访 I/O 线程的本地队列,这也就保障了主线程永远会在 I/O 线程之前拜访 io_threads_list 并且之后不再拜访,保障了交织拜访。io_threads_op 同理,主线程会在唤醒 I/O 线程之前先设置好 io_threads_op 的值,并且在 I/O 线程运行期间不会再去拜访这个变量。

性能晋升

Redis 将外围网络模型革新成多线程模式谋求的当然是最终性能上的晋升,所以最终还是要以 benchmark 数据见真章:

测试数据表明,Redis 在应用多线程模式之后性能大幅晋升,达到了一倍。更具体的性能压测数据能够参阅这篇文章:Benchmarking the experimental Redis Multi-Threaded I/O。

以下是美图技术团队实测的新旧 Redis 版本性能比照图,仅供参考:

模型缺点

首先第一个就是我后面提到过的,Redis 的多线程网络模型实际上并不是一个规范的 Multi-Reactors/Master-Workers 模型,和其余支流的开源网络服务器的模式有所区别,最大的不同就是在规范的 Multi-Reactors/Master-Workers 模式下,Sub Reactors/Workers 会实现 网络读 -> 数据解析 -> 命令执行 -> 网络写 整套流程,Main Reactor/Master 只负责分派任务,而在 Redis 的多线程计划中,I/O 线程工作仅仅是通过 socket 读取客户端申请命令并解析,却没有真正去执行命令,所有客户端命令最初还须要回到主线程去执行,因而对多核的利用率并不算高,而且每次主线程都必须在调配完工作之后忙轮询期待所有 I/O 线程实现工作之后能力继续执行其余逻辑。

Redis 之所以如此设计它的多线程网络模型,我认为次要的起因是为了放弃兼容性,因为以前 Redis 是单线程的,所有的客户端命令都是在单线程的事件循环里执行的,也因而 Redis 里所有的数据结构都是非线程平安的,当初引入多线程,如果依照规范的 Multi-Reactors/Master-Workers 模式来实现,则所有内置的数据结构都必须重形成线程平安的,这个工作量无疑是微小且麻烦的。

所以,在我看来,Redis 目前的多线程计划更像是一个折中的抉择:既放弃了原零碎的兼容性,又能利用多核晋升 I/O 性能。

其次,目前 Redis 的多线程模型中,主线程和 I/O 线程的通信过于简略粗犷:忙轮询和锁,因为通过自旋忙轮询进行期待,导致 Redis 在启动的时候以及运行期间偶然会有短暂的 CPU 空转引起的高占用率,而且这个通信机制的最终实现看起来十分不直观和不简洁,心愿前面 Redis 能对目前的计划加以改进。

总结

Redis 作为缓存零碎的事实标准,它的底层原理值得开发者去深刻学习,Redis 自 2009 年公布第一版之后,其单线程网络模型的抉择在社区中从未进行过探讨,多年来始终有呼声心愿 Redis 能引入多线程从而利用多核优势,然而作者 antirez 是一个谋求大道至简的开发者,对 Redis 退出任何新性能都异样审慎,所以在 Redis 初版公布的十年后才最终将 Redis 的外围网络模型革新成多线程模式,这期间甚至诞生了一些 Redis 多线程的代替我的项目。尽管 antirez 始终在推延多线程的计划,但却从未进行思考多线程的可行性,Redis 多线程网络模型的革新不是久而久之的事件,这其中牵扯到我的项目的方方面面,所以咱们能够看到 Redis 的最终计划也并不完满,没有采纳支流的多线程模式设计。

让咱们来回顾一下 Redis 多线程网络模型的设计方案:

  • 应用 I/O 线程实现网络 I/O 多线程化,I/O 线程只负责网络 I/O 和命令解析,不执行客户端命令。
  • 利用原子操作 + 交织拜访实现无锁的多线程模型。
  • 通过设置 CPU 亲和性,隔离主过程和其余子过程,让多线程网络模型能施展最大的性能。

通读本文之后,置信读者们应该可能理解到一个优良的网络系统的实现所波及到的计算机领域的各种技术:设计模式、网络 I/O、并发编程、操作系统底层,甚至是计算机硬件。另外还须要对我的项目迭代和重构的审慎,对技术计划的深刻思考,绝不仅仅是写好代码这一个难点。

参考 & 延长浏览

  • Redis v5.0.10
  • Redis v6.0.10
  • Lazy Redis is better Redis
  • An update about Redis developments in 2019
  • How fast is Redis?
  • Go netpoller 原生网络模型之源码全面揭秘
  • Linux I/O 原理和 Zero-copy 技术全面揭秘
  • Benchmarking the experimental Redis Multi-Threaded I/O
  • NUMA DEEP DIVE PART 1: FROM UMA TO NUMA
退出移动版