自从我写过Redis内部数据结构详解的一系列文章之后,有不少读者前来阅读和讨论。其中也有人问起阅读Redis源码的方法。本文我们就集中讨论这样一个话题:如果你现在想阅读Redis源码,那么从哪里入手?算是对之前系列文章的一个补充。
Redis是用C语言实现的,首先,你当然应该从main函数开始读起。但我们在读的时候应该抓住一条主线,也就是当我们向Redis输入一条命令的时候,代码是如何一步步执行的。这样我们就可以先从外部观察,尝试执行一些命令,在了解了这些命令执行的外部表现之后,再钻进去看对应的源码是如何实现的。要想读懂这些代码,首先我们需要理解Redis的事件机制。而且,一旦理解了Redis的事件循环(Event Loop)的机制,我们还会搞明白一个有趣的问题:为什么Redis是单线程执行却能同时处理多个请求?(当然严格来说Redis运行起来并非只有一个线程,但除了主线程之外,Redis的其它线程只是起辅助作用,它们是一些在后台运行做异步耗时任务的线程)
从main函数开始,沿着代码执行路径,实际上我们可以一直追下去。但为了让本文不至于太过冗长,我们还是限定一下范围。本文的目标就定为:引领读者从main函数开始,一步步追踪下去,最终到达任一Redis命令的执行入口。这样接下来就可以与Redis内部数据结构详解的一系列文章衔接上了。或者,你也可以自己去完成剩下的探索了。
为了表述清楚,本文按照如下思路进行:
根据这样几部分的划分,如果你只想粗读大致的处理流程,那么只需要阅读前两个部分就可以了。而后两部分则会深入到某些值得关注的细节。
注:本文的分析基于Redis源码的5.0分支。
Redis源码的main函数在源文件server.c中。main函数开始执行后的逻辑可以分为两个阶段:
这两个执行阶段可以用下面的流程图来表达(点击看大图):
首先,我们看一下初始化阶段中的各个步骤:
redisServer
类型的全局变量来表示的(变量名就叫server
),这一步的初始化主要就是对于这个全局变量进行初始化。在整个初始化过程中,有一个需要特别关注的函数:populateCommandTable
。它初始化了Redis命令表,通过它可以由任意一个Redis命令的名字查找该命令的配置信息(比如该命令接收的命令参数个数、执行函数入口等)。在本文的第二部分,我们将会一起来看一看如何从接收一个Redis命令的请求开始,一步步执行到来查阅这个命令表,从而找到该命令的执行入口。另外,这一步中还有一个值得一提的地方:在对全局的redisServer
结构进行了初始化之后,还需要从配置文件(redis.conf)中加载配置。这个过程可能覆盖掉之前初始化过的redisServer
结构中的某些参数。换句话说,就是先经过一轮初始化,保证Redis的各个内部数据结构以及参数都有缺省值,然后再从配置文件中加载自定义的配置。aeEventLoop
的struct来表示的。「创建事件循环」这一步主要就是创建一个aeEventLoop
结构,并存储到server
全局变量(即前面提到的redisServer
类型的结构)中。另外,事件循环的执行依赖系统底层的I/O多路复用机制(I/O multiplexing),比如Linux系统上的epoll机制[1]。因此,这一步也包含对于底层I/O多路复用机制的初始化(调用系统API)。server
全局变量中。对于TCP的监听来说,由于监听的IP地址和端口可以绑定多个,因此获得的用于监听TCP连接的文件描述符也可以包含多个。后面,程序就可以拿这一步获得的文件描述符去注册I/O事件回调了。serverCron
。由于Redis只有一个主线程,因此这个函数周期性的执行也是在这个线程内,它由事件循环来驱动(即在合适的时机调用),但不影响同一个线程上其它逻辑的执行(相当于按时间分片了)。serverCron
函数到底做了什么呢?实际上,它除了周期性地执行过期key的回收动作,还执行了很多其它任务,比如主从重连、Cluster节点间的重连、BGSAVE和AOF rewrite的触发执行,等等。这个不是本文的重点,这里就不展开描述了。acceptTcpHandler
和acceptUnixHandler
。对于来自Redis客户端的请求的处理,就会走到这两个函数中去。我们在下一部分就会讨论到这个处理过程。另外,其实Redis在这里还会注册一个I/O事件,用于通过管道(pipe[6])机制与module进行双向通信。这个也不是本文的重点,我们暂时忽略它。serverCron
函数,按说后台线程执行的这些任务似乎也可以放在serverCron
中去执行。因为serverCron
函数也是可以用来执行后台任务的。实际上这样做是不行的。前面我们已经提到过,serverCron
由事件循环来驱动,执行还是在Redis主线程上,相当于和主线程上执行的其它操作(主要是对于命令请求的执行)按时间进行分片了。这样的话,serverCron
里面就不能执行过于耗时的操作,否则它就会影响Redis执行命令的响应时间。因此,对于耗时的、并且可以被延迟执行的任务,就只能放到单独的线程中去执行了。注意:Redis服务器的初始化其实还要完成很多很多事,比如加载数据到内存,Cluster集群的初始化,module的初始化,等等。但为了简化,上面讨论的初始化流程,只列出了我们当前关注的步骤。本文关注的是由事件驱动的整个运行机制以及跟命令执行直接相关的部分,因此我们暂时忽略掉其它不太相关的步骤。
现在,我们继续去讨论上面流程图中的第二个阶段:事件循环。
我们先想一下为什么这里需要一个循环。
一个程序启动后,如果没有循环,那么它从第一条指令一直执行到最后一条指令,然后就只能退出了。而Redis作为一个服务端程序,是要等着客户端不停地发来请求然后做相应的处理,不能自己执行完就退出了。因此,Redis启动后必定要进入一个无限循环。显然,程序在每一次的循环执行中,如果有事件(包括客户端请求的I/O事件)发生,就会去处理这些事件。但如果没有事件发生呢?程序显然也不应该空转,而是应该等待,把整个循环阻塞住。这里的等待,就是上面流程图里的「等待事件发生」这个步骤。那么,当整个循环被阻塞住之后,什么时候再恢复执行呢?自然是等待的事件发生的时候,程序被重新唤醒,循环继续下去。这里需要的等待和唤醒操作,怎么实现呢?它们都需要依赖系统的能力才能做到(我们在文章第三部分会详细介绍)。
实际上,这种事件循环机制,对于开发过手机客户端的同学来说,是非常常见且基础的机制。比如跑在iOS/Android上面的App,这些程序都有一个消息循环,负责等待各种UI事件(点击、滑动等)的发生,然后进行处理。同理,对应到服务端,这个循环的原理可以认为差不多,只是等待和处理的事件变成是I/O事件了。另外,除了I/O事件,整个系统在运行过程中肯定还需要根据时间来调度执行一些任务,比如延迟100毫秒再执行某个操作,或者周期性地每隔1秒执行某个任务,这就需要等待和处理另外一种事件——timer事件。
timer事件和I/O事件是两种截然不同的事件,如何由事件循环来统一调度呢?假设事件循环在空闲的时候去等待I/O事件的发生,那么有可能一个timer事件先发生了,这时事件循环就没有被及时唤醒(仍在等待I/O事件);反之,如果事件循环在等待timer事件,而一个I/O事件先发生了,那么同样没能够被及时唤醒。因此,我们必须有一种机制能够同时等待这两种事件的发生。而恰好,一些系统的API可以做到这一点(比如我们前面提到的epoll机制)。
前面流程图的第二阶段已经比较清楚地表达出了事件循环的执行流程。在这里我们对于其中一些步骤需要关注的地方做一些补充说明:
acceptTcpHandler
和acceptUnixHandler
,就是在这一步被调用的。serverCron
,就是在这一步被调用的。一般情况下,一个timer事件被处理后,它就会被从队列中删除,不会再次执行了。但serverCron
却是被周期性调用的,这是怎么回事呢?这是因为Redis对于timer事件回调的处理设计了一个小机制:timer事件的回调函数可以返回一个需要下次执行的毫秒数。如果返回值是正常的正值,那么Redis就不会把这个timer事件从事件循环的队列中删除,这样它后面还有机会再次执行。例如,按照默认的设置,serverCron
返回值是100,因此它每隔100毫秒会执行一次(当然这个执行频率可以在redis.conf中通过hz
变量来调整)。至此,Redis整个事件循环的轮廓我们就清楚了。Redis主要的处理流程,包括接收请求、执行命令,以及周期性地执行后台任务(serverCron
),都是由这个事件循环驱动的。当请求到来时,I/O事件被触发,事件循环被唤醒,根据请求执行命令并返回响应结果;同时,后台异步任务(如回收过期的key)被拆分成若干小段,由timer事件所触发,夹杂在I/O事件处理的间隙来周期性地运行。这种执行方式允许仅仅使用一个线程来处理大量的请求,并能提供快速的响应时间。当然,这种实现方式之所以能够高效运转,除了事件循环的结构之外,还得益于系统提供的异步的I/O多路复用机制(I/O multiplexing)。事件循环使得CPU资源被分时复用了,不同代码块之间并没有「真正的」并发执行,但I/O多路复用机制使得CPU和I/O的执行是真正并发的。而且,使用单线程还有额外的好处:避免了代码的并发执行,在访问各种数据结构的时候都无需考虑线程安全问题,从而大大降低了实现的复杂度。
我们在前面讨论「注册I/O事件回调」的时候提到过,Redis对于来自客户端的请求的处理,都会走到acceptTcpHandler
或acceptUnixHandler
这两个回调函数中去。实际上,这样描述还过于粗略。
Redis客户端向服务器发送命令,其实可以细分为两个过程:
上述第一个过程,「连接建立」,对应到服务端的代码,就是会走到acceptTcpHandler
或acceptUnixHandler
这两个回调函数中去。换句话说,Redis服务器每收到一个新的连接请求,就会由事件循环触发一个I/O事件,从而执行到acceptTcpHandler
或acceptUnixHandler
回调函数的代码。
接下来,从socket编程的角度,服务器应该调用accept
系统API[7]来接受连接请求,并为新的连接创建出一个socket。这个新的socket也就对应着一个新的文件描述符。为了在新的连接上能接收到客户端发来的命令,接下来必须在事件循环中为这个新的文件描述符注册一个I/O事件回调。这个过程的流程图如下:
从上面流程图可以看出,新的连接注册了一个I/O事件回调,即readQueryFromClient
。也就是说,对应前面讲的第二个过程,「命令发送、执行和响应」,当服务器收到命令数据的时候,也会由事件循环触发一个I/O事件,执行到readQueryFromClient
回调。这个函数的实现就是在处理命令的「执行和响应」了。因此,下面我们看一下这个函数的执行流程图:
上述流程图有几个需要注意的点:
read
系统API[8]来读入数据的。虽然调用read
时我们可以指定期望读取的字节数,但它并不会保证一定能返回期望长度的数据。比如我们想读100个字节,但可能只能读到80个字节,剩下的20个字节可能还在网络传输中没有到达。这种情况给接收Redis命令的过程造成了很大的麻烦:首先,可能我们读到的数据还不够一个完整的命令,这时我们应该继续等待更多的数据到达。其次,我们可能一次性收到了大量的数据,里面包含不止一个命令,这时我们必须把里面包含的所有命令都解析出来,而且要正确解析到最后一个完整命令的边界。如果最后一个完整命令后面还有多余的数据,那么这些数据应该留在下次有更多数据到达时再处理。这个复杂的过程一般称为「粘包」。populateCommandTable
初始化的命令表,这个命令表存储在server.c的全局变量redisCommandTable
当中。命令表中存有各个Redis命令的执行入口。在本文第一部分,我们提到过,我们必须有一种机制能够同时等待I/O和timer这两种事件的发生。这一机制就是系统底层的I/O多路复用机制(I/O multiplexing)。但是,在不同的系统上,存在多种不同的I/O多路复用机制。因此,为了方便上层程序实现,Redis实现了一个简单的事件驱动程序库,即ae.c的代码,它屏蔽了系统底层在事件处理上的差异,并实现了我们前面一直在讨论的事件循环。
在Redis的事件库的实现中,目前它底层支持4种I/O多路复用机制:
select
系统调用[9]。这应该是最早出现的一种I/O多路复用机制了,于1983年在4.2BSD Unix中被首次使用[10]。它是POSIX规范的一部分。另外,跟select
类似的还有一个poll
系统调用[11],它是1986年在SVR3 Unix系统中首次使用的[10],也遵循POSIX规范。只要是遵循POSIX规范的操作系统,它就能支持select
和poll
机制,因此在目前我们常见的系统中这两种I/O事件机制一般都是支持的。select
更新的一种I/O多路复用机制,最早出现在Linux内核的2.5.44版本中[12]。它被设计出来是为了代替旧的select
和poll
,提供一种更高效的I/O机制。注意,epoll是Linux系统所特有的,它不属于POSIX规范。kqueue
机制[13]。kqueue
最早是2000年在FreeBSD 4.1上被设计出来的,后来也支持NetBSD、OpenBSD、DragonflyBSD和macOS系统[14]。它和Linux系统上的epoll是类似的。既然在不同系统上有不同的事件机制,那么Redis在不同系统上编译时采用的是哪个机制呢?由于在上面四种机制中,后三种是更现代,也是比select
和poll
更高效的方案,因此Redis优先选择使用后三种机制。
通过上面对各种I/O机制所适用的操作系统的总结,我们很容易看出,如果你在macOS上编译Redis,那么它底层会选用kqueue
;而如果在Linux上编译则会选择epoll,这也是Redis在实际运行中比较常见的情况。
需要注意的是,这里所依赖的I/O事件机制,与如何实现高并发的网络服务关系密切。很多技术同学应该都听说过C10K问题[16]。随着硬件和网络的发展,单机支撑10000个连接,甚至单机支撑百万个连接,都成为可能[17]。高性能网络编程与这些底层机制息息相关。这里推荐几篇blog,有兴趣的话可以去仔细阅读(访问链接请参见文末参考文献):
现在我们回过头来再看一下底层的这些I/O事件机制是如何支持了Redis的事件循环的(下面的描述是对本文前面第一部分中事件循环流程的细化):
aeCreateFileEvent
的代码。aeCreateTimeEvent
的代码。epoll_wait
API。这个等待操作一般可以指定预期等待的事件列表(事件用文件描述符来表示),并同时可以指定一个超时时间(即最大等待多长时间)。在事件循环中需要等待事件发生的时候,就调用这个等待操作,传入之前注册过的所有I/O事件,并把最近的timer事件所对应的时刻转换成这里需要的超时时间。具体参见函数aeProcessEvents
的代码。最后,关于事件机制,还有一些信息值得关注:业界已经有一些比较成熟的开源的事件库了,典型的比如libevent[20]和libev[21]。一般来说,这些开源库屏蔽了非常复杂的底层系统细节,并对不同的系统版本实现做了兼容,是非常有价值的。那为什么Redis的作者还是自己实现了一套呢?在Google Group的一个帖子上,Redis的作者给出了一些原因。帖子地址如下:
原因大致总结起来就是:
对于本文前面分析的各个代码处理流程,包括初始化、事件循环、接收命令请求、执行命令、返回响应结果等等,为了方便大家查阅,下面用一个树型图展示了部分关键函数的调用关系(图比较大,点击可以看大图)。再次提醒:下面的调用关系图基于Redis源码的5.0分支,未来很可能随着Redis代码库的迭代而有所变化。
这个树型结构的含义,首先介绍一下:
上图中添加了部分注释,应该可以很清楚地和本文前面介绍过的一些流程对应上。另外,图中一些可能需要注意的细节,如下列出:
aeSetBeforeSleepProc
和aeSetAfterSleepProc
,注册了两个回调函数,这在本文前面没有提到过。一个用于在事件循环每轮开始时调用,另一个会在每轮事件循环的阻塞等待后(即aeApiPoll
返回后)调用。图中下面第5个调用流程的入口beforeSleep
,就是由这里的aeSetBeforeSleepProc
来注册到事件循环中的。serverCron
周期性地执行,就是指的在processTimeEvents
这个调用分支中调用的timeProc
这个函数。readQueryFromClient
中,通过lookupCommand
来查询Redis命令表,这个命令表也就是前面初始化时由populateCommandTable
初始化的redisCommandTable
全局结构。查找命令入口后,调用server.c的call
函数来执行命令。图中call
函数的下一层,就是调用各个命令的入口函数(图中只列出了几个例子)。以get
命令的入口函数getCommand
为例,它执行完的执行结果,最终会调用addReply
存入到输出buffer中,即client
结构的buf
或reply
字段中(根据执行结果的大小不同)。需要注意的是,就像前面「Redis命令请求的处理流程」最后讨论的一样,这里只是把执行结果存到了一个输出buffer中,并没有真正输出给客户端。真正把响应结果发送给客户端的执行逻辑,在后面的beforeSleep
和sendReplyToClient
流程中。beforeSleep
来触发。它检查输出buffe中有没有需要发送给客户端的执行结果数据,如果有的话,会调用writeToClient
尝试进行发送。如果一次性没有把数据发送完毕,那么还需要再向事件循环中注册一个写I/O事件回调sendReplyToClient
,在恰当的时机再次调用writeToClient
来尝试发送。如果还是有剩余数据没有发送完毕,那么后面会由beforeSleep
回调来再次触发这个流程。简单总结一下,本文系统地记录了如下几个执行流程:
要顺利读懂Redis源码,需要掌握一些在Linux下进行C语言编程的经验,也需要掌握一些Linux系统层面的知识。对于很多人来说,这些可能会是一种障碍。因此,本文根据作者自己阅读代码的过程,以及在这个过程中对于碰到的重点疑难问题的调研,系统地记录下来,并提供了一些参考文献,希望对于那些想阅读Redis源代码,又不知道从哪里入手的技术同学,会多少有些帮助。
抛开本文的很多细节,也许你至少可以记住Redis的命令表这个全局变量:redisCommandTable
,它就定义在server.c源文件的开头。这里面记录了每一种Redis命令的执行入口,你也可以从这里出发直接去研究Redis内部各个数据结构和相关操作的实现,就像Redis内部数据结构详解系列文章所做的一样。
祝源码阅读愉快!
(完)
其它精选文章: