详解redis网络IO模型
前言
"redis是单线程的" 这句话我们耳熟能详。但它有一定的前提,redis整个服务不可能只用到一个线程完成所有工作,它还有持久化、key过期删除、集群管理等其它模块,redis会通过fork子进程或开启额外的线程去处理。所谓的单线程是指从网络连接(accept) -> 读取请求内容(read) -> 执行命令 -> 响应内容(write),这整个过程是由一个线程完成的,至于为什么redis要设计为单线程,主要有以下原因:
- 基于内存。redis命令操作主要都是基于内存,这已经足够快,不需要借助多线程。
- 高效的数据结构。redis底层提供了动态简单动态字符串(SDS)、跳表(skiplist)、压缩列表(ziplist)等数据结构来高效访问数据。
- 保持简单。引入多线程会使redis变得复杂,例如需要考虑多线程并发访问资源竞争问题,数据结构也会变得复杂,hash就不能是单纯的hash,需要像java一样设计一个ConcurrentHashMap。还需要考虑线程切换带来的性能损耗,基于第一点,当程序执行已经足够快,多线程并不能带来正面收益。
按照redis官方介绍,单个节点的redis qps可以达到10w+,已经非常优秀,如果有更高的要求,则可以通过部署主从、集群方式进一步提升。
单线程不是没有缺点的,我们需要辩证的看待问题,不然所有的组件都可以使用redis替代了。首先是基于内存的操作有丢失数据的风险,尽管你可以配置appendfsync always每次将执行请求通过aof文件持久化,但这也会带来性能的下降。另外单线程的执行意味着所有的请求都需要排队执行,如果有一个命令阻塞了,其它命令也都执行不了,可以与之比较的是mysql,如果有一条sql语句执行比较慢,只要它不完全拖垮数据库,其它请求的sql语句还是可以执行。最后,从上面可以看到从接收网络连接到写回响应内容,对于网络请求部分的处理其实是可以多线程执行来提升网络IO效率的。
redis 6.0
从redis 6.0开始,网络连接(accept) -> 读取请求内容(read) -> 执行命令 -> 响应内容(write) 这个过程中的“执行命令”这个步骤依然保持单线程执行,而对于网络IO读写是多线程执行的了。原因是这部分是网络IO的解析、响应处理,已经不是单纯的内存操作,可以充分利用多核CPU的优势提升性能,对于这部分的性能需求其实一直都存在,社区也有KeyDB这样的产品,其核心就是在redis的基础上对多线程的支持,这多redis来说无疑是一种挑战,所有redis6.0开始在网络IO处理支持多线程就显得非常必要了。
我们知道redis客户端连接是可以有很多个的,最多可以有maxclients参数配置的数量,默认是10000个,那么redis是如何高效处理这么多连接的呢?以及6.0和之前的版本是如何具体处理从接收连接到响应整个过程的,或者说redis线程模型是怎么样的,清楚的了解这些有助于我们更好的学习redis,其中的知识在以后学习其它中间件也可以很好的借鉴。
linux IO模型
在学习redis网络IO模型之前我们必须先了解一下linux的IO模型,以为redis也是基于操作系统去设计的。I/O是Input/Output的缩写,是指操作系统与外部设备进行读取、输出的交互过程,外部设备可以是网卡、磁盘等。操作系统一般都分为内核和用户空间两部分,内核负责与底层硬件交互,用户程序读写数据都需要经过内核空间,也就是数据会不断的在内核-用户空间进行复制,不同的IO模型在这个复制过程用户线程有不同的表现,有的是阻塞,有的是非阻塞,有的是同步,有的是异步。
以linux为例,常见的IO模型有阻塞IO、非阻塞IO、IO多路复用、信号驱动IO、异步IO 5种,这次我们主要关注前3个,重点是IO多路复用,另外两个在使用上有一些局限性,实际应用并不多。这5种IO模型我们在这一篇已经有详细的介绍,这里简单再复习一遍。
以一个最简单例子,现在有两个客户端需要连接、发送数据到我们的服务端,看下服务端在各种IO模型下是如何接收、读取请求的。
阻塞IO(Blocking IO)
假设服务端只开启一个线程处理请求,第一个请求到来,开始调用内核read函数,然后就会发生阻塞,第二个请求到来时服务端将无法处理,只能等第一个请求读取完成。这种方式的缺点很明显,每次只能处理一个请求,无法发挥cpu多核优势,性能低下。
为了解决这个问题,我们可以引入多线程,这样就可以同时处理多个请求了,但服务端可能同时有成千上万的请求需要处理,随之而来的是线程数膨胀,频繁创建、销毁线程带来的性能影响,当然我们可以使用线程池,但服务能处理的总体数量就会受限于线程池线程数量。
非阻塞IO(NON-Blocking IO)
相比阻塞IO,非阻塞IO会立即返回,调用者不会阻塞,此时可以做一些其它事情,例如处理其它请求。但是非阻塞IO需要主动轮询是否有数据需要处理,且这种轮询需要从用户态切换到内核态这,假如没有数据产生就会有很多空轮询,白白浪费cpu资源。
阻塞IO、非阻塞IO,要么需要开启更多线程去处理IO,要么需要从用户态切换到内核态轮询IO事件,那么有没有一种机制,用户程序只需要将请求提交给内核,由内核用少量的线程去监听,有事件就通知用户程序呢?这就是IO多路复用。
IO多路复用(IO Multiplexing)
IO多路复用机制是指一个线程处理多个IO流,多路是指网络连接,复用指的是同一个线程。
如果简单从图上看IO多路复用相比阻塞IO似乎并没有什么高明之处,假设服务只处理少量的连接,那么相比阻塞IO确实没有太大的提升,但如果连接数非常多,差距就会立竿见影。
首先IO多路复用会提交一批需要监听的文件句柄(socket也是一种文件句柄)到内核,由内核开启一个线程负责监听,把轮询工作交给内核,当有事件发生时,由内核通知用户程序。这不需要用户程序开启更多的线程去处理连接,也不需要用户程序切换到内核态去轮询,用一个线程就能处理大量网络IO请求。
redis底层采用的就是IO多路复用模型,实际上基本所有中间件在处理网络IO这一块都会使用到IO多路复用,如kafka,rocketmq等,所以本次学习之后对其它中间件的理解也是很有帮助的。
select/poll/epoll
这三个函数是实现linux io多路复用的内核函数,我们简单了解下。
linux最开始提供的是select函数,方法如下:
select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)
该方法需要传递3个集合,r,e,w分别表示读、写、异常事件集合。集合类型是bitmap,通过0/1表示该位置的fd(文件描述符,socket也是其中一种)是否关心对应读、写、异常事件。例如我们对fd为1和2的读事件关心,r参数的第1,2个bit就设置为1。
用户进程调用select函数将关心的事件传递给内核系统,然后就会阻塞,直到传递的事件至少有一个发生时,方法调用会返回。内核返回时,同样把发生的事件用这3个参数返回回来,如r参数第1个bit为1表示fd为1的发生读事件,第2个bit依然为0,表示fd为2的没有发生读事件。用户进程调用时传递关心的事件,内核返回时返回发生的事件。
select存在的问题:
- 大小有限制。为1024,由于每次select函数调用都需要在用户空间和内核空间传递这些参数,为了提升拷贝效率,linux限制最大为1024。
- 这3个集合有相应事件触发时,会被内核修改,所以每次调用select方法都需要重新设置这3个集合的内容。
- 当有事件触发select方法返回,需要遍历集合才能找到就绪的文件描述符,例如传1024个读事件,只有一个读事件发生,需要遍历1024个才能找到这一个。
- 同样在内核级别,每次需要遍历集合查看有哪些事件发生,效率低下。
poll函数对select函数做了一些改进
poll(struct pollfd *fds, int nfds, int timeout)
struct pollfd {
int fd;
short events;
short revents;
}
poll函数需要传一个pollfd结构数组,其中fd表示文件描述符,events表示关心的事件,revents表示发生的事件,当有事件发生时,内核通过这个参数返回回来。
poll相比select的改进:
- 传不固定大小的数组,没有1024的限制了(问题1)
- 将关心的事件和实际发生的事件分开,不需要每次都重新设置参数(问题2)。例如poll数组传1024个fd和事件,实际只有一个事件发生,那么只需要重置一下这个fd的revent即可,而select需要重置1024个bit。
poll没有解决select的问题3和4。另外,虽然poll没有1024个大小的限制,但每次依然需要在用户和内核空间传输这些内容,数量大时效率依然较低。
这几个问题的根本实际很简单,核心问题是select/poll方法对于内核来说是无状态的,内核不会保存用户调用传递的数据,所以每次都是全量在用户和内核空间来回拷贝,如果调用时传给内核就保存起来,有新增文件描述符需要关注就再次调用增量添加,有事件触发时就只返回对应的文件描述符,那么问题就迎刃而解了,这就是epoll做的事情。
epoll对应3个方法
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_create负责创建一个上下文,用于存储数据,底层是用红黑树,以后的操作就都在这个上下文上进行。
epoll_ctl负责将文件描述和所关心的事件注册到上下文。
epoll_wait用于等待事件的发生,当有有事件触发,就只返回对应的文件描述符了。
reactor模式
前面我们介绍的IO多路复用是操作系统的底层实现,借助IO多路复用我们实现了一个线程就可以处理大量网络IO请求,那么接收到这些请求后该如何高效的响应,这就是reactor要关注的事情,reactor模式是基于事件的一种设计模式。在reactor中分为3中角色:
Reactor:负责监听和分发事件
Acceptor:负责处理连接事件
Handler:负责处理请求,读取数据,写回数据
从线程角度出发,reactor又可以分为单reactor单线程,单reactor多线程,多reactor多线程3种。
单reactor单线程
处理过程:reactor负责监听连接事件,当有连接到来时,通过acceptor处理连接,得到建立好的socket对象,reactor监听scoket对象的读写事件,读写事件触发时,交由handler处理,handler负责读取请求内容,处理请求内容,响应数据。
可以看到这种模式比较简单,读取请求数据,处理请求内容,响应数据都是在一个线程内完成的,如果整个过程响应都比较快,可以获得比较好的结果。缺点是请求都在一个线程内完成,无法发挥多核cpu的优势,如果处理请求内容这一块比较慢,就会影响整体性能。
单reactor多线程
既然处理请求这里可能由性能问题,那么这里可以开启一个线程池来处理,这就是单reactor多线程模式,请求连接、读写还是由主线程负责,处理请求内容交由线程池处理,相比之下,多线程模式可以利用cpu多核的优势。单仔细思考这里依然有性能优化的点,就是对于请求的读写这里依然是在主线程完成的,如果这里也可以多线程,那效率就可以进一步提升。
多reactor多线程
多reactor多线程下,mainReactor接收到请求交由acceptor处理后,mainReactor不再读取、写回网络数据,直接将请求交给subReactor线程池处理,这样读取、写回数据多个请求之间也可以并发执行了。
redis网络IO模型
redis网络IO模型底层使用IO多路复用,通过reactor模式实现的,在redis 6.0以前属于单reactor单线程模式。如图:
在linux下,IO多路复用程序使用epoll实现,负责监听服务端连接、socket的读取、写入事件,然后将事件丢到事件队列,由事件分发器对事件进行分发,事件分发器会根据事件类型,分发给对应的事件处理器进行处理。我们以一个get key简单命令为例,一次完整的请求如下:
请求首先要建立TCP连接(TCP3次握手),过程如下:
redis服务启动,主线程运行,监听指定的端口,将连接事件绑定命令应答处理器。
客户端请求建立连接,连接事件触发,IO多路复用程序将连接事件丢入事件队列,事件分发器将连接事件交由命令应答处理器处理。
命令应答处理器创建socket对象,将ae_readable事件和命令请求处理器关联,交由IO多路复用程序监听。
连接建立后,就开始执行get key请求了。如下:
客户端发送get key命令,socket接收到数据变成可读,IO多路复用程序监听到可读事件,将读事件丢到事件队列,由事件分发器分发给上一步绑定的命令请求处理器执行。
命令请求处理器接收到数据后,对数据进行解析,执行get命令,从内存查询到key对应的数据,并将ae_writeable写事件和响应处理器关联起来,交由IO多路复用程序监听。
客户端准备好接收数据,命令请求处理器产生ae_writeable事件,IO多路复用程序监听到写事件,将写事件丢到事件队列,由事件分发器发给命令响应处理器进行处理。
命令响应处理器将数据写回socket返回给客户端。
reids 6.0以前网络IO的读写和请求的处理都在一个线程完成,尽管redis在请求处理基于内存处理很快,不会称为系统瓶颈,但随着请求数的增加,网络读写这一块存在优化空间,所以redis 6.0开始对网络IO读写提供多线程支持。需要知道的是,redis 6.0对多线程的默认是不开启的,可以通过 io-threads 4 参数开启对网络写数据多线程支持,如果对于读也要开启多线程需要额外设置 io-threads-do-reads yes 参数,该参数默认是no,因为redis认为对于读开启多线程帮助不大,但如果你通过压测后发现有明显帮助,则可以开启。
redis 6.0多线程模型思想上类似单reactor多线程和多reactor多线程,但不完全一样,这两者handler对于逻辑处理这一块都是使用线程池,而redis命令执行依旧保持单线程。如下:
可以看到对于网络的读写都是提交给线程池去执行,充分利用了cpu多核优势,这样主线程可以继续处理其它请求了。
开启多线程后多redis进行压测结果可以参考这里,如下图可以看到,对于简单命令qps可以达到20w左右,相比单线程有一倍的提升,性能提升效果明显,对于生产环境如果大家使用了新版本的redis,现在7.0也出来了,建议开启多线程。
总结
本篇我们学习redis单线程具体是如何单线程以及在不同版本的区别,通过网络IO模型知道IO多路复用如何用一个线程处理监听多个网络请求,并详细了解3种reactor模型,这是在IO多路复用基础上的一种设计模式。最后学习了redis单线程、多线程版本是如何基于reactor模型处理请求。其中IO多路复用和reactor模型在许多中间件都有使用到,后续再接触到就不陌生了。
欢迎关注我的github:https://github.com/jmilktea/jtea