【图解源码】Redis到底是不是单线程的?

4,327 阅读22分钟

本文所有代码都将以伪代码或者图片的形式展示,请各位观众老爷放心阅读(C读起来还是有一丢丢费劲哈)

本文基于的 redis5.0 写作

开始之前

有一说一,在看Redis源码之前,我的C语言水平只能写写大学生的课设以及刷OJ题目。在开始阅读代码之前,一度担心自己会中途放弃,但是,年初立的目标不能再放弃了(已经失败一个了,一周一更实现不了),便坚持下来了。所幸,学有所得在此分享给大家。

从小到大我都是一个胆小而又害羞的男孩子,所以,帮忙点个赞呗

编程思维的精髓

如果我问你编程思维的精髓是什么?今天丰富的软件生态大厦又是得益于什么思想而落成的?(欢迎大家在评论区分享自己的观点)

就我而言,是抽象和封装这两个思想所构成的。举例来说不论是Java系的JVM虚拟机还是Node.js的V8引擎本质上都是对操作系统硬件资源的进一步抽象封装,并且提供了统一的API接口,使得在该引擎上开发的应用可以在不同平台上运行。

问题来了,这跟redis有什么关系?redis不是用C语言开发的吗?C语言不是面向过程的吗?怎么抽象怎么封装?

有种观点很正常的, 大部分人的经历和我一样,顶多用C语言来写五子棋、刷刷算法题而已,少有接触到生产级别的代码。回想一下唯一和面向对象有关系的就是结构体,没错,已经很接近答案了。

为什么要讨论抽象和封装呢?就像悬疑电影中从来不会出现无用的角色一样(安利一下《误杀》)。redis的目标是在多种类型的操作系统上运行,如今操作系统厂商大都各自为战,就地球而言目前还没出现一个统一天下的操作系统,可能有些小伙伴已经明白我要问什么, 那就是redis是如何做到一套代码处处编译的?虽然不是本文的重点,但用C语言实现类似面向对象的功能还是给我幼小的心灵造成了震撼,至于是如何实现的下文会提到,让我们回归重点

Redis/IO 模型

事件驱动与I/O多路复用

在开始之前,有必要问一下自己真的了解事件驱动机制吗?

不知各位读者大爷是否有过在windows系统用C++开发windows应用程序的经验,一个win32程序通常会在一个while循环里面不断取来自用户大爷产生的事件,比如正在阅读本文的读者大爷,不论是处于职业习惯按下F12,亦或者滑动鼠标滑轮都会产生一个事件,通常来说操作系统会提供相应API函数以便我们可以程序可以获取到用户行为所产生的事件。

其伪代码如下,想要体验一下具体代码的可以点击your-first-windows-program

while(true){
    //获取事件
    let msg = getMessage(); 
    //翻译消息
    translateMessage(msg);
    //分发消息
    dispatchMessage(msg);
}

如上代码所示,可以发现事件驱动机制程序的特点如下

  • 一般都会有一个容器用于存放产生的事件
  • 一个事件循环
  • 有一种获取事件的方法
  • 获取到事件之后需要进行处理(不理它也可以视为一种处理方式)

了解完事件驱动机制之后,我们再来看看I/O多路复用,这可是大热门哈!

为了对比区别,咱先回顾经典C/S结构程序。代码如下所示:

while(true){
    Socket client = server.accept();
    ClientThread thread = new ClientThread(client);
    thread.start();
}

用一个例子来说就是,孙悟空打妖怪,每遇见一个妖怪都会创建一个分身去和妖怪玩,而孙悟空本人就负责不断地拔毛创建分身以及保护唐僧。

那么,问题来了,假设孙悟空是程序员的话,唐僧该怎么办?

毕竟唐僧是主角,挂了还怎么玩,因此咱们可以随便向某位大仙要一个“镇妖列表”的法宝,该法宝会将所有的小妖怪存入虚空,大师兄每次都可以从该法宝中获取感兴趣的妖怪(你说猴子对啥妖怪感兴趣呢?)与之对线。

以上就是I/O多路复用模型。开发操作系统大佬们早就为我们提供了API可以获取自己感兴趣的事件,再结合事件驱动模型就是I/O多路复用了。

一个比较严格,且学术的描述如下:

I/O 多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己地逻辑流。逻辑流别模型化状态机,数据到达文件描述符后,主程序显示地从一个状态转换到另一个状态因为程序是一个单独地进程,所以所有地流都共享一个地址空间。(《CSAPP》)

换句话说我们可以用一个状态图来描述I/O多路复用程序。

问题又来了? I/O多路复用非得是单线程的吗?

确定以及肯定的回答:不是

I/O模型

不难看出redis使用了Reactor的设计模式,换句话说就是使用操作系统提供给我们的API,使得我们不需要再为每个客户端都创建新的线程,也就是说redis采用的是单线程的Reactor设计模式,但是那个I/O线程是什么鬼?

所谓I/O线程,就是负责读取来自客户端数据和将响应数据输出给客户端的线程。

为什么会有I/O线程以及I/O线程什么时候会启动?

首先需要明确的一点是redis虽然可以采用轮询的方式获取数据,但是读取客户端数据和往客户端输出数据时所调用的函数仍然会产生阻塞(阻塞时间一般超级短,短到你无法察觉),但是,凡事总有个但是,假设你在一家非常穷的公司,只有一台redis服务器(且数据很多),某天一个临时工往redis里面塞了一部512MB的学习资料set studyresouces 学习资料, 如果继续采用单线程的模式,不难想象整个redis服务都将被短暂阻塞住。所以此时如果我们如果有多个I/O线程,核心业务线程可以将输入输出的外包给I/O线程来完成,至于什么时候启动I/O线程,咱下边聊。

因此一个比较合理定义如下

redis负责操作数据的线程确实只有一个,但是负责I/O线程并不只有一个, 此外redis在执行序列化操作的时候还会开启线程。

问题又又来了, 为什么redis负责操作数据的线程只有一个?

  • redis的所有操作的数据都是内存数据

  • redis数据操作一般在常数时间内可以完成

  • 单线程数据操作可以避免多线程操作所带来的数据安全性问题(不用加锁)

主流程

正如你所看到的, redis核心业务线程就一循环在不断的调用的beforeSleep以及processEvents方法。

aeProcessEvents

首先来看一下aeProcessEvents, 其代码如下所示。

由于redis有定时任务需要执行, 如果在轮询事件时进入长时间的阻塞状态(redis称之为sleep),将导致定时任务长时间无法得到执行,因此有必要计算处最大的等待时间。

aeApiPoll() 会使线程进入阻塞状态,直到有I/O事件产生, 可以传入最大阻塞时间,如果超过这个时间之后即使没有I/O事件也会立即返回

在轮询到事件之后, 并没有立即处理I/O事件,而是执行钩子函数afterSleep, 至于afterSleep做了什么,咱下边聊。

之后便是处理aeApiPoll轮询到的事件了。

如果你阅读了代码不难发现有一个奇怪的变量invert,此变量与配置参数相关AE_BARRIER, 决定了读写函数执行顺序。

连接到redis客户端(如redis-cli)的读写事件处理器都会指向connection.c中的connSocketEventHandlerconnSocketEventHandler,此函数会根据情况决定调用读写事件调用的顺序。(invert参数以及轮询到事件类型都会传给此函数)

观察变量fired我们得出以下结论在一次循环中redis不会同时调用读写事件处理函数。且如果

  • AE_BARRIER = 1(即invenrt = true) redis会先处理写事件,再处理读事件
  • AE_BARRIER = 0(即invenrt = false) redis会先处理读事件,再处理写事件

问题又又又来了 AE_BARRIER此参数到底有什么用呢?

要想搞清楚这个问题,先搞清楚一个问题什么叫落盘?

假设正在幼儿园入园考试的你遇到了计算题1+1=,你已经想出了答案是2,但是由于时间紧迫你没有写上去,被人扣了10分与梦想的幼儿园失之交臂。

可见,你想出来了是一回事,但你有没有写答案涂答题卡又是另外一回事。

类比到操作系统中,也会有这情况,你以为你调用了write函数就把数据保存到硬盘中了,实际上数据会在内存中停留一会,等待一个合适的时机将数据保存到硬盘中,假设数据在内存中停留的期间突然断电,那数据岂不是就没了吗?

为了避免这种情况,操作系统(Linux)提供了fsync函数来确保数据写到硬盘上,即确保数据落盘,调用此函数时会产生阻塞,直到数据成功写到硬盘上。

基于以上情况的考虑如果redis配置了appednfsync=always, 并且开启了AOF(AOF是redis数据的一种持久化机制),且满足一定条件的情况就会使invert=true生效。

什么条件的情况下呢?

首先我们明确一点,一般情况下输出数据的地方并不是在写处理器中输出的,而是在beforeSleep中响应数据输出给客户端的。我们来观察一下输出数据时的调用栈验证一下。

原始截图如下所示

此外,在一般情况下,接收到来自客户端的连接之后, redis只在此连接上注册的感兴趣事件只有读事件,只有当安装写处理器时才会注册对写事件感兴趣。

现在,小朋友你是否有很多问号?我也是。问题是既然在beforeSleep中都已经把数据输出去了,为啥还要反置读写的数据顺序,先写再读?

排除所有可能性,剩下的即使再不合理也是真相了。

只有一个可能 -> 数据没输出完。😂

观察以下代码, 位置在networking.c1373行处

不难看出,在开启appednfsync=always以及客户端仍然有待输出数据的情况,会为此客户端安装一个写处理器,并且将此客户端的invert置为true。在此情况下,发生的事件如下所示

  • 1.读取来自客户端的命令并处理(aeProcessEvents)
  • 2.执行AOF操作(beforeSleep)
  • 3.输出响应数据给客户端,发现数据还有剩余且appednfsync=always,开启AE_BARRIER(即invert=true),并安装写处理器(beforeSleep)
  • 4.调用写处理器输出数据(aeProcessEvents)
  • 5.已输出完数据移除写处理器(aeProcessEvents)

一般来说在redis客户端发出指令之后会阻塞等待来自服务端的响应,在此期间,客户端不会出其他数据操作指令(仅限于RESP2协议及以下的协议,采用RESP3协议的客户端可以这样做)

移除写处理器的代码在writeToClient中,咱下边再聊

有必要说明以下一点,以避免误解。之前提到过processReadEvent以及processWriteEvent都指向了connSocketEventHandler。但是,此处connSetWriteHandlerWithBarrier设置的写处理器sendReplyToClient并不是将processWriteEvent指向sendReplyToClient,而是注册connSocketEventHandler中所调用的写处理器。看一下代码可能会更直观一点。

代码位于connection.c中

beforeSleep 之睡觉之前你在干嘛?

在之前的aeMain的代码可以看到,在每次进入事件循环时都会调用一下beforeSleep,让我们康康redis在睡觉之前都做了啥。

总得来说按照顺序来说beforeSleep完成了以下工作:

  • 处理采用安全传输层协议(TLS)的客户端中待处理数据
  • 如果了开启了集群功能,则调用clusterBeforeSleep
  • 执行一次快速扫描对数据库清除已过期的键
  • 处理集群相关的任务
  • 处理因执行阻塞命令陷入阻塞状态的客户端(如执行subscribe命令的客户端)
  • 执行AOF操作
  • 检查是否需要开启I/O线程并将数据输出给客户端(handleClientsWithPendingWritesUsingThreads)
  • 异步关闭需要关闭的客户端

beforeSleep函数中做了很多事情,但就我们所关心的I/O模型来说,我们只关心数据的流向,因此重点讨论一下handleClientsWithPendingWritesUsingThreads

简化过的handleClientsWithPendingWritesUsingThreads的代码如下所示

不难看出主线程给I/O线程分配任务的方式主要是通过任务队列以及标志位数组给线程分配任务,并且通过ioThreadOp给线程指示当前任务的类型即IO_THREADS_OP_WRITE执行写任务或者IO_THREADS_OP_READ执行读任务。

那么开启多线程I/O的任务是什么呢?可以看一下stopThreaedIOIfNeed函数。

可以看出如果满足待处理的任务数量 >= I/O线程数 *2 ,则redis 会开启多线程IO

否则就会停止I/O线程让其进入阻塞状态

根据以上代码,不难得出以下结构

问题再一次来,主线程是如何控制I/O线程的状态?这一个咱们需要补充一点点的多线程知识,咱们下边再聊,先来看看睡醒之后redis都干了啥。

afterSleep 睡醒之后做什么?

redis睡醒之后(从aeApiPoll返回)就做了一件事情,调用handleClientsWithPendingReadsUsingThreads此函数与上文所描述的handleClientsWithPendingWritesUsingThreads类似只不过ioThreadOp变成了IO_THREADS_OP_READ即I/O线程只处理读事件。

processTimeEvents 定时任务

processTimeEvents介个兄弟就一循环,遍历定时任务队列,如果达到时间就拿出来执行一下,这些任务一般不会太复杂,因此我们主要关注一下都有哪些定时任务。

注册定时任务可以通过aeCreateTimeEvent向事件循环中注册定时任务

经过定位,你会发现最终只注册了一个定时任务serverCron(此函数位于server.c)

其在事件循环中注册定时任务的代码如下所示,刻意看出serverCron被设置为每1毫秒触发一次。

    if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
        serverPanic("Can't create event loop timers.");
        exit(1);
    }

此外,我们可以通过如下两个参数控制serverCron的行为。

  • server.hz 控制serverCron函数的执行频率,默认为10(1秒内执行10次),最大为500
  • server.cronloops serverCron函数的执行次数

举个例子,redis中有定时统计数据库使用情况功能,其周期为5000,那么redis是如何判断什么时候该调用它的呢?

观察以下函数

function shouldRunWithPeriod(ms){
    return ms <= 1000/server.hz || !(server.cronloops % (ms/(1000/server.hz)));
}

可以得出当server.cronloops = 50 * n(n为整数), 也就是当server.cronloops为50的倍数时,会执行统计功能,还记得咱们刚刚说过server.hz可以控制serverCron的执行频率吗, 且server.hz = 10 即每100毫秒执行一次serverCron, 不难得出以下结论而server.cronloops = 5050 * 100 == 5000

整理出来的定时任务如下表所示。(server.hz = 10)

执行频率指server.cron调用多少次之时执行此任务, *表示每次都执行

任务类型 执行频率 备注
统计内存使用情况 *
处理来自操作系统的退出信号 * 非定时任务
统计所有数据库字典的使用情况 50*n(n=1,2,...) 每个数据库实际上都是一个字典
打印客户端以及从节点信息 50*n(n=1,2,...) 需要redis以哨兵模式运行
处理连接超时客户端以及客户端的缓冲区(clientCron) * 非定时任务
数据库字典渐进式扩容/缩容 * 非定时任务
AOF相关处理 * 非定时任务
如果最近一次AOF写入失败,则开启fsync机制写入AOF文件 10*n(n=1,2,...)
执行主从复制的定时任务 * 需要开启主从复制模式
哨兵的定时任务 * 不在本文讨论范围
如果待处理I/O任务太少就停止I/O线程 * 非定时任务,僧多粥少,你说咋办吗
执行BGSAVE(序列化数据库,与AOF不同) 需要根据配置文件的值,来定时执行
server.cronloops++

I/O线程的实现

通过上文,相信给位读者大爷都了解了redis主线程通过给每个线程分配一个任务队列、线程状态标志位以及共享一个任务类型来控制I/O线程行为,那么redis是如何控制线程进入阻塞状态,以避免其空转而消耗系统资源呢?

话不多说咱上伪代码瞅一瞅。

可以看出I/O线程的代码并不复杂,但有些代码着实让人有些迷惑。

比如,我们可以看到线程会执行1000000空负载循环, 仅仅为了判断线程标志位是否不为0

为什么要这样子设计呢?有并发编程经验的同学不难看出,这种行为其实就是自旋,虽然自旋会消耗一定的资源(但不会太多), 如果线程自旋期间分配到任务,那就不用进入阻塞状态,再从阻塞状态恢复过来了,并且自旋的成本小于线程进入阻塞状态再从阻塞状态恢复过来的成本。

继续阅读代码咱们可以发现,再获取到互斥锁又立即释放了,这是为什么呢? 其实这是给主线程一个加锁的机会,毕竟主线程会通过加锁来让线程进入阻塞状态。举个例子

时间 I/O线程 主线程
t1 加锁A 运行状态
t2 尝试加锁A而进入阻塞状态
t3 释放锁A 阻塞
t4 进行下一次循环 加锁A
t5 自旋中 运行状态
t6 尝试加锁A而进入阻塞状态 运行状态
t7 阻塞 运行状态
t8 阻塞 运行状态

输出响应数据的时候发生了什么

阅读上文之后,不难得出以下结论, redis可能不会一次性输出所有响应数据, 而是选择输出一部分数据,然后继续做其他事情呢?这么做的原因,无外乎redis的核心业务线程只有一个,因此不能让其他客户端等太久,如果有个临时工在终端上执行keys *, 那咱是不是就不用玩了?

更具体一点,咱们看看writeToClient中作者写的注释以及代码来分析一下什么时候会发生不一次性输出所有数据的情况。

  • totwritten 指当前已经输出的数据量,NEXT_MAX_WRITES_PER_EVNET的值为64KB64*1024
  • server.maxmemory指redis所可以使用的最大内存数量,默认值为0即64位系统不限制内存,32位系统最多使用3GB内存
  • zmalloc_used_moemory可以获取当前以分配的内存

由此可以看出,当server.maxmemory=0时即默认情况下时redis会将所有响应及时输出给客户端以避免占用内存,如果设置了server.maxmemory的情况下,且满足条件的情况下则对于超过NEXT_MAX_WRITES_PER_EVNET大小的响应数据不会一次性输出,下文中会给出实测。

总之,一条不变的原则就是在内存有限或者没有配置最大内存的情况下,redis会尽可能快的把响应数据输出给客户端(响应数据也要占内存的好吧),如果内存够用,redis会先输出一部分数据,剩余的数据下一次事件循环再输出。

此外,在确认输出完用户数据之后, writeToClient还将清理调原本安装在redis客户端上的写处理器。

除此之外redis还设计两种类型暂存响应数据缓冲区,如下所示

  • replyBuffer 响应数据缓冲区,类型是字节数组, 用于暂存响应数据
  • replyList 响应数据队列,类型是clientReplyBlock链表

那么分配规则是什么呢,咱们可以先看看addReply函数的实现

观察以上代码,可以得出以下结论。

  • 响应数据会被先尝试加进缓冲区中(缓冲区大小为 16 *1024 = 16KB),如果响应缓冲区已满,则将其加入响应队列中
  • 响应数据会在执行beforSleep时或io线程中被输出

事件循环抽象

AeEventLoop是redis事件循环的实现,AeApi是对操作系统的I/O多路复用API接口的抽象,并提供了不同操作系统下不同实现。

  • aeMain 是事件循环的主函数,在redis服务器启动启动之后会调用此函数
  • 可以通过aeCreateFileEvent以及aeDeleteFileEvent增加或删除此事件循环中感兴趣的I/O事件(调用AeApi.aeApiAddEventAeApi.aeApiDelEvent)
  • 可以通过aeCreateTimeEvent以及aeDeleteTimeEvent增加或删除定时任务
  • setBeforeSleepHook可以设置在进入事件轮询(即调用AeApi.aeApiPoll)前调用的函数(见上文的beforeSleep)
  • setAfterSleepHook 可以设置在事件轮询完成之后调用的函数(见上文的afterSleep)
  • setDontWait 可以使在执行事件轮询时,不进入等待状态,立即返回当前可处理事件,如果没有事件可以处理也立即返回。

如上图所示,为了适应不同的操作系统生态,redis设计了一套统一的事件轮询API接口AeApi并提供了不同的实现,该API主要提供注册感兴趣的I/O事件、删除感兴趣I/O事件、轮询事件的功能。

不同AeApi之间区别如下表所示。

名称 底层实现 性能 操作系统 描述
AeEpoll epoll Linux 监视的描述符数量(客户端数量)不受限制,IO的效率不会随着监视fd的数量的增长而下降
AeApiEvport evports 不晓得,没用过不下结论 Solaris(sun公司发行的系统,我是没见过😅) 实现比较复杂,还是epoll好用
AeApiKqueue kqueue 不晓得,没用过不下结论 FreeBSD、Unix系统 类似于epoll
AeSelect select 最差 不同操作系统都有实现,作为保底方案 能处理的文件描述符(客户端数量)符存在限制,最大为1024

参考资料

不服跑个分?

单看代码,总是有点干,咱们来当一回临时工,试一下redis在不同环境下的表现。

运行环境如下所示:

  • centos7
  • 1 CPU
  • 2G RAM
  • server.maxmemory = 10485760 即10M

临时工的骚操作

假设在一家比较穷的公司,临时工小柯不小心在线上数据库执行了keys *操作, 那么会发生什么呢?

测试开始之前咱们先打上两个断点,分别是addReply以及writeToClient

开启一个redis-cli执行keys *命令

观察addReply的调用

可以看出由于数据太大响应数据没有加入缓冲区而是加入响应队列,并且由于是执行全表扫描命令而执行了多次的addReply调用,如下图所示。

输出的客户端相同,但响应数据不同

再次观察我们发现writeToClient确实有从响应队列中取出响应数据的行为

接着我们来观察writeToClient的反应,调用栈如下图所示

对于writeClient函数我们主要验证redis的输出数据限制是否会生效。

对于handleClientsWithPendingWrites我们主要验证写处理器是否会被安装。

可以看出由于数据没有输出干净,redis确实为我们的客户端安装了写处理器,接下来我们放行程序不出意外咱们将在writeToClient再次相遇, 而此次调用writeToClient的方法将变为aeProcessEvents即在事件循环中输出数据而不是在beforeSleep中,其调用栈如下图所示。

启发

  • u1s1, js写代码确实爽,不知道啥时候出个多线程版本的JavaScript(本文假设我使用的是多线程版本的js)
  • 不要执行keys *全表扫描操作,在你没有配置I/O线程或者最大使用内存的情况下
  • 该配置的参数都给配上了嗷(虽然运维基本上都会配,但是还是有了解的必要)
  • 输出文章或者教会别人确实有益于整理思路
  • 面试时只需要记住一点redis确实不是单线程的,更确切地说法是redis的核心业务线程只有一个,但是可以配置多个I/O线程除此之外还有执行RDB序列化操作的时候也会开启线程
  • 为啥要js来作为伪代码?潜水掘金多年,发现还是前端大爷们的热情比较高哈😜
  • 所以,给我点赞!!!下次更新redis调试心得,同样用大家看得懂的语言,顺便复习一下C语言呗,毕竟踩了不少坑。

最后,用一张图来描述一条redis命令经过的内存区域和函数。