2. 事件
在讲Redis的事件之前我们必须要聊一个话题 —— 面试问到Redis基本上必问这样一个问题:Redis是个单线程的程序,为什么还能这么快?很多同学就会卡在这了......事实上我们不应该有思维定式,单线程就一定要比多线程慢吗?如果三个开发写一个有很多耦合业务代码的项目,这三个开发一定苦不堪言,为什么?因为各自写完一套代码后,在合并的时候发现有很多conflict需要处理,处理的时候发现思路并不统一,最后导致代码需要重新搭建结构。除了时间成本的消耗,这样写的代码极有可能出错,最终不一定比一个人写这个项目来的快、来的准确。多线程处理的时候也有很多“思路不统一”的情况出现,比如说两个线程并行各自对同一个数加1,一直加100次,最后的结果你会发现这个数并没有加200,一般情况都是加了100多,这就是线程安全问题。单线程就很好的避免了这种问题,并且也不会有线程切换所带来的额外开支。当然除了线程安全问题和切换线程的额外开支,Redis使用单线程还那么快是有原因的,就是我们常说的I/O多路复用。
2.1 I/O多路复用
这部分内容基本都取自这篇文章
2.1.1 Linux文件
程序员使用I/O最终都逃不过文件这个概念。
在Linux世界中文件是一个很简单的概念,作为程序员我们只需要将其理解为一个N byte的序列就可以了:
b1, b2, b3, b4, ....... bN
实际上所有的I/O设备都被抽象为了文件这个概念,一切皆文件,Everything is File,磁盘、网络数据、终端,甚至进程间通信工具管道pipe等都被当做文件对待。
所有的I/O操作也都可以通过文件读写来实现,这一非常优雅的抽象可以让程序员使用一套接口就能对所有外设I/O操作。
常用的I/O操作接口一般有以下几类:
打开文件,open
改变读写位置,seek
文件读写,read、write
关闭文件,close
程序员通过这几个接口几乎可以实现所有I/O操作,这就是文件这个概念的强大之处。
2.1.2 文件描述符
如果周末你去比较火的餐厅吃饭应该会有体会,一般周末人气高的餐厅都会排队,然后服务员会给你一个排队序号,通过这个序号服务员就能找到你,这里的好处就是服务员无需记住你是谁、你的名字是什么、来自哪里、喜好是什么、是不是保护环境爱护小动物等等,这里的关键点就是服务员对你一无所知,但依然可以通过一个号码就能找到你。
同样的,在Linux世界要想使用文件,我们也需要借助一个号码,根据“弄不懂原则”,这个号码就被称为了文件描述符,file descriptors,在Linux世界中鼎鼎大名,其道理和上面那个排队号码一样。
因此,文件描述仅仅就是一个数字而已,但是通过这个数字我们可以操作一个打开的文件,这一点要记住。
有了文件描述符,进程可以对文件一无所知,比如文件在磁盘的什么位置、加载到内存中又是怎样管理的等等,这些信息统统交由操作系统打理,进程无需关心,操作系统只需要给进程一个文件描述符就足够了。
2.1.3 多路复用技术实现
当我们文件描述符巨多的时候,如果每个文件的I/O操作都用单线程阻塞的方式来处理,那么这个处理能力就极大地被限制了,所以Linux就提供了三种机制来帮助我们以非阻塞的方式来对文件进行I/O操作。这就是select、poll、epoll
-
select
在select这种I/O多路复用机制下,我们需要把想监控的文件描述集合通过函数参数的形式告诉select,然后select会将这些文件描述符集合拷贝到内核中,我们知道数据拷贝是有性能损耗的,因此为了减少这种数据拷贝带来的性能损耗,Linux内核对集合的大小做了限制,并规定用户监控的文件描述集合不能超过1024个,同时当select返回后我们仅仅能知道有些文件描述符可以读写了,但是我们不知道是哪一个,因此程序员必须再遍历一边找到具体是哪个文件描述符可以读写了。
因此,总结下来select有这样几个特点:
我能照看的文件描述符数量有限,不能超过1024个
用户给我的文件描述符需要拷贝的内核中
我只能告诉你有文件描述符满足要求了,但是我不知道是哪个,你自己一个一个去找吧(遍历)
我们可以看到,select机制的这些特性在高并发网络服务器动辄几万几十万并发链接的场景下无疑是低效的。
-
poll
poll和select是非常相似的,poll相对于select的优化仅仅在于解决了文件描述符不能超过1024个的限制,select和poll都会随着监控的文件描述数量增加而性能下降,因此不适合高并发场景。
-
epoll
在select面临的三个问题中,文件描述数量限制已经在poll中解决了,剩下的两个问题呢?
针对拷贝问题,epoll使用的策略是各个击破与共享内存。
实际上文件描述符集合的变化频率比较低,select和poll频繁的拷贝整个集合,内核都快被烦死了,epoll通过引入epoll_ctl很体贴的做到了只操作那些有变化的文件描述符,同时epoll和内核还成为了好朋友,共享了同一块内存,这块内存中保存的就是那些已经可读或者可写的的文件描述符集合,这样就减少了内核和程序的拷贝开销。
针对需要遍历文件描述符才能知道哪个可读可写这一问题,epoll使用的策略是“当小弟”。
在select和poll机制下,进程要亲自下场去各个文件描述符上等待,任何一个文件描述符可读或者可写就唤醒进程,但是进程被唤醒后也是一脸懵逼并不知道到底是哪个文件描述符可读或可写,还要再从头到尾检查一遍。
但epoll就懂事多了,主动找到进程要当小弟替大哥出头。
在这种机制下,进程不需要亲自下场了,进程只要等待在epoll上,epoll代替进程去各个文件描述符上等待,当哪个文件描述符可读或者可写的时候就告诉epoll,epoll用小本本认真记录下来然后唤醒大哥:“进程大哥,快醒醒,你要处理的文件描述符我都记下来了”,这样进程被唤醒后就无需自己从头到尾检查一遍,因为epoll小弟都已经记下来了。
因此我们可以看到,在epoll这种机制下,实际上利用的就是“不要打电话给我,有需要我会打给你”这种策略,进程不需要一遍一遍麻烦的问各个文件描述符,而是翻身做主人了,“你们这些文件描述符有哪个可读或者可写了主动报上来”,这种机制实际上就是大名鼎鼎的事件驱动,Event-driven,实际上在Linux平台,epoll基本上就是高并发的代名词。
2.2 文件事件
前面之所以先讲了I/O多路复用就是因为Redis的文件事件就是用这个方法来监听套接字。
文件事件是对套接字操作的抽象,每当套接字发生连接应答、写入、读取、关闭等操作时,就会产生一个文件事件。
Redis的文件事件处理器是一个基于Reactor模式实现的网络通信程序,它由四个部分组成,分别是:套接字、I/O多路复用程序、文件事件分派器、事件处理器。它的运行流程如下:I/O多路复用程序负责监听多个套接字,当有文件事件产生的时候,I/O多路复用程序就会将文件事件按顺序放入一个队列。文件事件分派器会从这个队列中取到文件事件并根据不同的事件来关联不同的事件处理器去执行相应的处理。
常见的事件处理器:
-
连接应答处理器
Redis服务器初始化的时候会将连接应答处理器和服务器监听套接字的AE_READABLE事件关联起来,当有客户端主动连接的时候就会触发套接字的AE_READABLE事件,连接应答处理器就会执行相应的套接字应答操作。
-
命令请求处理器
客户端完成连接之后,客户端套接字的AE_READABLE事件就会和命令请求处理器关联起来,当有命令请求的时候,就会调用命令请求处理器来处理具体的命令。
-
命令回复处理器
服务器产生命令回复的时候,就会将客户端套接字的AE_WRITEABLE事件和命令回复处理器关联起来,客户端套接字AE_WRITEABLE事件产生后,就会执行命令回复处理器相关的代码。当命令回复完毕,就会断开客户端套接字的AE_WRITEABLE事件和命令回复处理器之间的关联。
2.3 时间事件
Redis的时间事件目前只有一个,那就是周期任务serverCron,就像linux的crontab一样,每100ms执行一次。
来看一下时间事件的数据结构:
typedef struct aeTimeEvent {
long long id; /* time event identifier. */
monotime when;
aeTimeProc *timeProc;
aeEventFinalizerProc *finalizerProc;
void *clientData;
struct aeTimeEvent *prev;
struct aeTimeEvent *next;
int refcount; /* refcount to prevent timer events from being
* freed in recursive time event calls. */
} aeTimeEvent;
看到前后指针我们就知道时间事件的数据结构是个双向链表结构,且相对于when字段来说是个无序链表。如果想要获取最近一个任务的时间,需要遍历整个链表也就是O(n)的时间复杂度。不过对于Redis(非benchmark模式)来说,目前只有serverCron一个任务,即使我们用Redis cluster,也不过是在serverCron里再调用clusterCron函数,所以实际上每次获取最近一个任务时间的复杂度是O(1)。
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...
/* Run the Redis Cluster cron. */
run_with_period(100) {
if (server.cluster_enabled) clusterCron();
}
...
}
那为什么要获取最近一个任务的时间呢?我们将Redis的main函数用伪代码表示一下,你就知道了。
import time
def main():
# 初始化服务器
init_server()
while True:
# 获取最近一个任务的时间
remaind_ms = time_event.when - (time.time() * 1000)
if remaind_ms < 0:
remaind_ms = 0
timeval = create_timeval_with_ms(remaind_ms)
aeApiPoll(timeval) # I/O多路复用程序,remaind_ms的值就是它阻塞的时间
processFileEvents() # 处理文件事件
processTimeEvents() # 处理时间事件
看这段伪代码我们就知道了,实际上获取最近一个任务的时间是为了计算I/O多路复用程序阻塞多长时间。