Redis-----第三天学习笔记(为什么单线程Redis能这么快?)

Redis-----第三天学习笔记(为什么单线程Redis能这么快?)

今天主要来讨论大家都很关心的问题:“为什么单线程的Redis能那么快?”

首先我们要先理清一个事实,我们通常说Redis是单线程,**主要是指Redis的网络IO和键值对读写是由一个线程来完成的,这也是Redis对外提供键值存储服务的主要流程。**但Redis的其他功能,比如说持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

所以,严格的来说,Redis并不算是单线程,但是我们一般把Redis称为单线程高性能。我之前一直好奇“为什么用单线程?为什么单线程可以这么快?”后来我发现要弄明白这个问题,需要深入了解一下Redis的单线程设计机制以及多路复用机制,这样在后面的调优Redis性能时,也可以更有针对性的避免会导致Redis单线程阻塞的操作。

Redis为什么用单线程?
为了更好地理解Redis为什么用单线程,我们需要先简单了解一下多线程的开销。
多线程的开销:
在日常写程序时,我们经常会听到一种说法:“使用多线程,可以增加系统吞吐率,或者可以增加系统扩展性。”确实,对于一个多线程的系统来说,在合理的资源分配的情况下,可以增加系统中处理请求操作的资源实体,进而提升系统能够处理的请求数量,即吞吐率。下图的左图可以表示出我们采用多线程时所期待的结果:
Redis-----第三天学习笔记(为什么单线程Redis能这么快?)
但是需要注意的是,在通常的情况下,在我们采用多线程后,如果没有良好的系统设计,实际得到的结果就会像上图的右边一样。在我们刚刚增加线程数时,系统的吞吐率会增加,但是在进一步增加线程时,系统的吞吐率就会增长迟缓了,有时甚至会出现下降的情况。
为什么会出现这种情况呢?一个关键的瓶颈就在于,系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要额外的机制来保证,而这个额外的机制就会带来额外的开销。
举个例子,我们拿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多方面设计选择的一个综合结果。

一方面,Redis的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。另一方面,就是Redis采用了多路复用机制,使其在网络IO操作中能并发处理大量的客户端请求,实现了高吞吐率。下面我们简单了解下基本IO模型和潜在的阻塞点,毕竟Redis采用单线程进行iO,如果线程被阻塞了,就无法进行多路复用了。

基本IO模型与阻塞点:
这里可以拿我们第一天记录的SimleKV来举例。
以GET请求为例,SimpleKV为了处理一个GET请求,需要监听客户端请求(bind/listen),和客户端建立连接(accpet),从socket中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向socket中写回数据(send).
下图显示了这一过程,其中bind/listen、accpet、recv、parse和send属于网络IO处理,而get属于键值数据操作。既然Redis是单线程,那么最基本的一种实现是在一个线程中依次执行上面说的操作。
Redis-----第三天学习笔记(为什么单线程Redis能这么快?)
但是在网络IO操作中,会有潜在的阻塞点,分别是accpet()和recv()。当Redis监听到一个客户端有连接请求,但一直未连接成功时,会阻塞在accpet()函数这里,导致其他客户端无法和Redis进行连接。类似的,当Redis通过recv()从一个客户端读取数据时,如果数据一直没有到达,Redis也会一直阻塞在recv()。

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

非阻塞模式:
socket网络模型的非阻塞模式,主要体现在三个关键的函数调用上,如果想使用socket非阻塞模式,就必须要了解这三个函数的调用返回类型和设置模式。
在socket模型中,不同操作调用后会返回不同的套接字类型。socket()方法会返回主动套接字,然后调用listen()方法,将主动套接字转化为监听套接字,此时可以监听来自客户端的连接请求。最后,调用accpet()方法接受客户端到达的客户端连接,并返回自己连接套接字。
Redis-----第三天学习笔记(为什么单线程Redis能这么快?)
针对监听套接字,我们可以设置非阻塞模式:当Redis调用accpet()方法但一直未有连接到达时,Redis线程可以返回处理其他操作,而不用一直等待。但是,要注意的是,调用accpet()时,已经存在监听套接字了。
虽然Redis线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知Redis。
类似的,我们也可以针对已连接套接字设置非阻塞模式;Redis调用recv()后,如果已连接套接字上一直没有数据到达,Redis线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据到达时通知Redis。
这样才能保证Redis线程,既不会像基本IO模型中一直在阻塞点等待,也不会导致Redis无法处理实际到达的连接请求或数据。

基于多路复用的高性能I/O模型:
Linux中的IO多路复用机制是指一个线程处理多个IO流,就是我们经常听到的select/epoll机制。简单来说,在Redis只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。
内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给Redis线程处理,这就实现了一个Redis线程处理多个IO流的效果。
下图就是基于多路复用的Redis IO模型。图中的多个FD就是刚刚说的多个套接字。Redis网络框架调用epoll机制,让内核监听这些套接字。此时,Redis线程不会阻塞在某一个特定的监听或已套接连接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis可以同时和多个客户端连接并处理请求,从而提高并发性。
Redis-----第三天学习笔记(为什么单线程Redis能这么快?)
为了请求到达时能够通知到Redis线程,select/epoll提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。那么,回调机制是怎样工作的呢?其实select/epoll一旦监测到FD上有请求到达时,就会触发相应的事件。这些事件会被放进一个队列,Redis单线程对该事件队列不断进行处理。这样一来,Redis无需一直轮询是否有请求实际发生。同时Redis在对事件对列中的事件进行处理时,会调用相应的处理函数,这就是实现了基于事件的回调。因为Redis一直在对事件队列进行处理,所能及时响应客户端请求,提升Redis的响应性能。

为了方便理解,可以用连接请求和读数据请求为例,具体解释一下:
这两个请求分别对应Accpet事件和read事件,Redis分别对这两个事件注册accpet和get回调函数。当Linux内核监听到有连接请求或者读数据请求时,就会触发Accpet事件和Read事件,此时,内核就会回调Redis相应的accpet和get函数进行处理。

这就像病人去医院看病。在医生实际诊断前,每个病人(等同于请求)都要先分诊、测体温、登记等。如果这些工作都由医生来完成,医生的工作效率也会变低。所以医院都设置了分诊台,分诊台会一直处理这些诊断前的工作(类似于Linux内核监听请求),然后再转交给医生做实际诊断。这样医生写的工作效率(相当于Redis单线程)也提升了。
不过需要注意的是,即使你的应用场景中部署了不同的操作系统,多路复用机制也是适用的。因为这个机制的实现有很多种。

小结:
今天主要学习了三个问题:Redis真的是单线程吗?为什么用单线程?单线程为什么那么快?
现在可以知道了,Redis单线程是指它对网络IO和数据读写的操作采用了一个线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。单线程的Redis也能获得高性能,跟多路复用的IO模型密切相关,因为这避免了accpet()和send()和recv()潜在的网络IO阻塞点。