高性能IO模型:为何单线程Redis能那么快?

Redis是单线程,主要是指Redis的网络IO和键值对读写是由一个线程来完成的,这也是Redis对外提供键值存储服务的主要流程。但Redis的其余功能,好比持久化、异步删除、集群数据同步等,实际上是由额外的线程执行的。编程

一、Redis为何用单线程?

平常写程序时,咱们常常会听到一种说法:“使用多线程,能够增长系统吞吐率,或是能够增长系统扩展性。”的确,对于一个多线程的系统来讲,在有合理的资源分配的状况下,能够增长系统中处理请求操做的资源实体,进而提高系统可以同时处理的请求数,即吞吐率。下面的左图是咱们采用多线程时所期待的结果。markdown

可是,请你注意,一般状况下,在咱们采用多线程后,若是没有良好的系统设计,实际获得的结果,实际上是右图所展现的那样。咱们刚开始增长线程数时,系统吞吐率会增长,可是,再进一步增长线程时,系统吞吐率就增加迟缓了,有时甚至还会出现降低的状况。网络

为何会出现这种状况呢?一个关键的瓶颈在于,系统中一般会存在被多线程同时访问的共享资源,好比一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就须要有额外的机制进行保证,而这个额外的机制,就会带来额外的开销。数据结构

拿Redis来讲,Redis有List的数据类型,并提供出队(LPOP)和入队(LPUSH)操做。假设Redis采用多线程设计,以下图所示,如今有两个线程A和B,线程A对一个List作LPUSH操做,并对队列长度加1。同时,线程B对该List执行LPOP操做,并对队列长度减1。为了保证队列长度的正确性,Redis须要让线程A和B的LPUSH和LPOP串行执行,这样一来,Redis能够无误地记录它们对List长度的修改。不然,咱们可能就会获得错误的长度结果。这就是多线程编程模式面临的共享资源的并发访问控制问题。多线程

并发访问控制一直是多线程开发中的一个难点问题,若是没有精细的设计,好比说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即便增长了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并无随着线程的增长而增长。并发

并且,采用多线程开发通常会引入同步原语来保护共享资源的并发访问,这也会下降系统代码的易调试性和可维护性。为了不这些问题,Redis直接采用了单线程模式。框架

二、单线程Redis为何那么快?

一般来讲,单线程的处理能力要比多线程差不少,可是Redis却能使用单线程模型达到每秒数十万级别的处理能力,这是为何呢?其实,这是Redis多方面设计选择的一个综合结果。异步

一方面,Redis的大部分操做在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要缘由。另外一方面,就是Redis采用了多路复用机制,使其在网络IO操做中能并发处理大量的客户端请求,实现高吞吐率。接下来,咱们就重点学习下多路复用机制。 首先,咱们要弄明白网络操做的基本IO模型和潜在的阻塞点。毕竟,Redis采用单线程进行IO,若是线程被阻塞了,就没法进行多路复用了。socket

基本IO模型与阻塞点

以Get请求为例,为了处理一个Get请求,须要监听客户端请求(bind/listen),和客户端创建链接(accept),从socket中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向socket中写回数据(send)。函数

下图显示了这一过程,其中,bind/listen、accept、recv、parse和send属于网络IO处理,而get属于键值数据操做。既然Redis是单线程,那么,最基本的一种实现是在一个线程中依次执行上面说的这些操做。

可是,在这里的网络IO操做中,有潜在的阻塞点,分别是accept()和recv()。当Redis监听到一个客户端有链接请求,但一直未能成功创建起链接时,会阻塞在accept()函数这里,致使其余客户端没法和Redis创建链接。相似的,当Redis经过recv()从一个客户端读取数据时,若是数据一直没有到达,Redis也会一直阻塞在recv()。

这就致使Redis整个线程阻塞,没法处理其余客户端请求,效率很低。不过,幸运的是,socket网络模型自己支持非阻塞模式。

非阻塞模式

Socket网络模型的非阻塞模式设置,主要体如今三个关键的函数调用上,若是想要使用socket非阻塞模式,就必需要了解这三个函数的调用返回类型和设置模式。接下来,咱们就重点学习下它们。

在socket模型中,不一样操做调用后会返回不一样的套接字(看作是不一样主机之间的进程进行双向通讯的端点,简单的说就是通讯的两方的一种约定,用套接字中的相关函数来完成通讯过程。)类型。socket()方法会返回主动套接字,而后调用listen()方法,将主动套接字转化为监听套接字,此时,能够监听来自客户端的链接请求。最后,调用accept()方法接收到达的客户端链接,并返回已链接套接字。

针对监听套接字,咱们能够设置非阻塞模式:当Redis调用accept()但一直未有链接请求到达时,Redis线程能够返回处理其余操做,而不用一直等待。可是,你要注意的是,调用accept()时,已经存在监听套接字了。

虽然Redis线程能够不用继续等待,可是总得有机制继续在监听套接字上等待后续链接请求,并在有请求时通知Redis。

相似的,咱们也能够针对已链接套接字设置非阻塞模式:Redis调用recv()后,若是已链接套接字上一直没有数据到达,Redis线程一样能够返回处理其余操做。咱们也须要有机制继续监听该已链接套接字,并在有数据达到时通知Redis。

这样才能保证Redis线程,既不会像基本IO模型中一直在阻塞点等待,也不会致使Redis没法处理实际到达的链接请求或数据。

基于多路复用的高性能I/O模型

IO多路复用机制是指一个线程处理多个IO流,就是咱们常常听到的select/epoll机制。简单来讲,在Redis只运行单线程的状况下,该机制容许内核中,同时存在多个监听套接字和已链接套接字。内核会一直监听这些套接字上的链接请求或数据请求。一旦有请求到达,就会交给Redis线程处理,这就实现了一个Redis线程处理多个IO流的效果。

下图就是基于多路复用的Redis IO模型。图中的多个FD就是刚才所说的多个套接字。Redis网络框架调用epoll机制,让内核监听这些套接字。此时,Redis线程不会阻塞在某一个特定的监听或已链接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正由于此,Redis能够同时和多个客户端链接并处理请求,从而提高并发性。

为了在请求到达时能通知到Redis线程,select/epoll提供了基于事件的回调机制,即针对不一样事件的发生,调用相应的处理函数。

那么,回调机制是怎么工做的呢?其实,select/epoll一旦监测到FD上有请求到达时,就会触发相应的事件。

这些事件会被放进一个事件队列,Redis单线程对该事件队列不断进行处理。这样一来,Redis无需一直轮询是否有请求实际发生,这就能够避免形成CPU资源浪费。同时,Redis在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。由于Redis一直在对事件队列进行处理,因此能及时响应客户端请求,提高Redis的响应性能。

为了方便你理解,我再以链接请求和读数据请求为例,具体解释一下。

这两个请求分别对应Accept事件和Read事件,Redis分别对这两个事件注册accept和get回调函数。当Linux内核监听到有链接请求或读数据请求时,就会触发Accept事件和Read事件,此时,内核就会回调Redis相应的accept和get函数进行处理。

这就像病人去医院瞧病。在医生实际诊断前,每一个病人(等同于请求)都须要先分诊、测体温、登记等。若是这些工做都由医生来完成,医生的工做效率就会很低。因此,医院都设置了分诊台,分诊台会一直处理这些诊断前的工做(相似于Linux内核监听请求),而后再转交给医生作实际诊断。这样即便一个医生(至关于Redis单线程),效率也能提高。

不过,须要注意的是,即便你的应用场景中部署了不一样的操做系统,多路复用机制也是适用的。由于这个机制的实现有不少种,既有基于Linux系统下的select和epoll实现,也有基于FreeBSD的kqueue实现,以及基于Solaris的evport实现,这样,你能够根据Redis实际运行的操做系统,选择相应的多路复用实现。