相信大家在面试过程中经常会被问到:“单线程的Redis为啥这么快?”
哈哈,反正我在面试时候经常会问候选人这个问题,这个问题其实是对redis内部机制的一个考察,可以牵扯出好多涉及底层深入原理的一些列问题。
回到问题本身,基本的回答就两点:
- 完全基于内存
- IO多路复用
1、关于第1点比较好理解。Redis 绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,查找和操作的时间复杂度都是O(1)。
2、关于第2点IO多路复用,有些同学看到概念后感觉一头雾水,到底什么是IO多路复用?本文从IO并发性能提升来整体思考,来逐步剖析IO多路复用的原理。
一、如何快速理解IO多路复用?
那得从IO并发性能提升来考虑:
- 多进程
- 多线程
- 基于单进程的IO多路复用(select/poll/epoll)
多进程
对于并发情况,假如一个进程不行,那搞多个进程不就可以同时处理多个客户端连接了么?
多进程这种方式的确可以解决了服务器在同一时间能处理多个客户端连接请求的问题,但是仍存在一些缺点:
- fork()等系统调用会使得进程上下文进行切换,效率较低
- 进程创建的数量随着连接请求的增加而增加。比如10w个请求,就要fork 10w个进程,开销太大
- 进程与进程之间的地址空间是私有、独立的,使得进程之间的数据共享变得困难
多线程
线程是运行在进程上下文的逻辑流,一个进程可以包含多个线程,多个线程运行在同一进程上下文中,因此可共享这个进程地址空间的所有内容,解决了进程与进程之间通信难的问题。
同时,由于一个线程的上下文要比一个进程的上下文小得多,所以线程的上下文切换,要比进程的上下文切换效率高得多。
IO多路复用
简单理解就是:一个服务端进程可以同时处理多个套接字描述符。
- 多路:多个客户端连接(连接就是套接字描述符)
- 复用:使用单进程就能够实现同时处理多个客户端的连接
以上是通过增加进程和线程的数量来并发处理多个套接字,免不了上下文切换的开销,而IO多路复用只需要一个进程就能够处理多个套接字,从而解决了上下文切换的问题。
其发展可以分select->poll→epoll三个阶段来描述。
二、如何简单理解select/poll/epoll呢?
按照以往惯例,还是联系一下我们日常中的现实场景,这样更助于大家理解。
举栗说明:
领导分配员工开发任务,有些员工还没完成。如果领导要每个员工的工作都要验收check,那在未完成的员工那里,只能阻塞等待,等待他完成之后,再去check下一位员工的任务,造成性能问题。
那如何解决这个问题呢?
select
举栗说明:
领导找个Team Leader(后文简称TL),负责代自己check每位员工的开发任务。
TL 的做法是:遍历问各个员工“完成了么?”,完成的待CR check无误后合并到Git分支,对于其他未完成的,休息一会儿后再去遍历....
这样存在什么问题呢?
- 这个TL存在能力短板问题,最多只能管理1024个员工
- 很多员工的任务没有完成,而且短时间内也完不成的话,TL还是会不停的去遍历问询,影响效率。
select函数:
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
select具有良好的跨平台支持,其缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024。
poll
举栗说明:
换一个能力更强的New Team Leader(后文简称NTL),可以管理更多的员工,这个NTL可以理解为poll。
poll函数:
intpoll(structpollfd*fds, nfds_t nfds,int timeout); typedef struct pollfd{ int fd; // 需要被检测或选择的文件描述符 short events; // 对文件描述符fd上感兴趣的事件 short revents; // 文件描述符fd上当前实际发生的事件 } pollfd_t;
poll改变了文件描述符集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的文件描述符集合限制远大于select的1024。
epoll
举栗说明:
在上一步poll方式的NTL基础上,改进一下NTL的办事方法:遍历一次所有员工,如果任务没有完成,告诉员工待完成之后,其应该做xx操作(制定一些列的流程规范)。这样NTL只需要定期check指定的关键节点就好了。这就是epoll。
Linux中提供的epoll相关函数如下:
intepoll_create(int size); intepoll_ctl(int epfd,int op,int fd,struct epoll_event *event); intepoll_wait(int epfd,struct epoll_event * events,int maxevents,int timeout);
epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。
小结
- select就是轮询,在Linux上限制个数一般为1024个
- poll解决了select的个数限制,但是依然是轮询
- epoll解决了个数的限制,同时解决了轮询的方式
三、IO多路复用在Redis中的应用
Redis 服务器是一个事件驱动程序, 服务器处理的事件分为时间事件和文件事件两类。
- 文件事件:Redis主进程中,主要处理客户端的连接请求与相应。
- 时间事件:fork出的子进程中,处理如AOF持久化任务等。
由于Redis的文件事件是单进程,单线程模型,但是确保持着优秀的吞吐量,IO多路复用起到了主要作用。
文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答、写入、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。
IO多路复用程序负责监听多个套接字并向文件事件分派器传送那些产生了事件的套接字。文件事件分派器接收IO多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。示例如图所示:
文件处理器的四个组成部分
Redis的IO多路复用程序的所有功能都是通过包装常见的select、poll、evport和kqueue这些IO多路复用函数库来实现的,每个IO多路复用函数库在Redis源码中都有对应的一个单独的文件。
Redis为每个IO多路复用函数库都实现了相同的API,所以IO多路复用程序的底层实现是可以互换的。如图:
多个IO复用库实现可选
Redis把所有连接与读写事件、还有我们没提到的时间事件一起集中管理,并对底层IO多路复用机制进行了封装,最终实现了单进程能够处理多个连接以及读写事件。这就是IO多路复用在redis中的应用。
四、总结
Redis 6.0 之后的版本开始选择性使用多线程模型。
Redis 选择使用单线程模型处理客户端的请求主要还是因为 CPU 不是 Redis 服务器的瓶颈,使用多线程模型带来的性能提升并不能抵消它带来的开发成本和维护成本,系统的性能瓶颈也主要在网络 I/O 操作上;
而 Redis 引入多线程操作也是出于性能上的考虑,对于一些大键值对的删除操作,通过多线程非阻塞地释放内存空间也能减少对 Redis 主线程阻塞的时间,提高执行的效率。
凡事不能有绝对,寻找到适中的平衡点最重要!