面试题:redis 是单线程还是双线程

前言

"redis是单线程的" 这句话我们耳熟能详。但它有一定的前提,redis整个服务不可能只用到一个线程完成所有工作,它还有持久化、key过期删除、集群管理等其它模块,redis会通过fork子进程或开启额外的线程去处理。所谓的单线程是指从网络连接(accept) -> 读取请求内容(read) -> 执行命令 -> 响应内容(write),这整个过程是由一个线程完成的,至于为什么redis要设计为单线程,主要有以下原因:

  1. 基于内存。redis命令操作主要都是基于内存,这已经足够快,不需要借助多线程。
  2. 高效的数据结构。redis底层提供了动态简单动态字符串(SDS)、跳表(skiplist)、压缩列表(ziplist)等数据结构来高效访问数据。
  3. 保持简单。引入多线程会使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多路复用,另外两个在使用上有一些局限性,实际应用并不多。

首先介绍基本概念

基础概念

一、阻塞和非阻塞
当线程访问资源时,该资源是否准备就绪的一种处理方式。若线程访问时,资源未准备就绪,线程什么也不做,就一直等待着资源就绪,这种处理方法就叫阻塞。但如果资源是不一直等待该资源,而是去做其他事情,那就是非阻塞。

二、同步和的异步
同步和异步是访问数据的机制。

同步:调用者一旦开始调用方法,则必须等待调用方法的结果返回后,才能去做其他事。

异步:调用更像一个消息传递,调用一开始,方法调用马上就会返回,让调用者可以继续后续的操作。而异步方法通常会在另外一个线程中,“真实”地执行着。整个过程,不会阻碍调用者的工作,异步方法完成后,再通知调用者。(一般用回调函数实现)

简单例子:

你去商城买东西,你看上了一款手机,能和店家说你一个这款手机,他就去仓库拿货,你得在店里等着,不能离开,这叫做同步。

现在你买手机赶时髦直接去京东下单,下单完成后你就可用做其他时间(追剧、打王者、lol)等货到了去签收就ok了.这就叫异步。

三、阻塞非阻塞和同步异步的结合
阻塞/非阻塞 和 同步/异步 的概念不同把它看作一样。

阻塞/非阻塞:关注的是程序(线程)等待消息通知时的状态。

同步/异步:关注的是消息通知的机制。

通过 阻塞/非阻塞 和 同步/异步结合,可以产生:同步阻塞、同步非阻塞、异步阻塞、异步非阻塞

下面通过例子来具体说明:

同步阻塞:
小明一直盯着下载进度条,到100%的时候完成。

同步体现在:小明关注下载进度条并等待完成通知。(可以看成同步是我主动关注任务完成的通知)

阻塞体现在:在等待过程中,小明不去做别的东西。(可以看成异步是被动的,任务完成后再通知我)

同步非阻塞:
小明提交下载任务后,就去干别的事了,但每过一段时间就去瞄一眼进度条,看到100%就完成。

同步体现在:小明关注下载进度条并等待完成通知。

非阻塞体现在:等待下载完成通知过程中,去干别的任务了,只是时不时会瞄一眼进度条;【小明必须要在两个任务间切换,关注下载进度】

这种方式是效率低下的,因为程序需要在不同任务的线程中频繁切换。

异步阻塞:
小明换了个有下载完成通知功能的软件,下载完成就“叮”一声,

异步体现在:小明不用时刻关注进度条,在下载完成后,消息通知机制是由“叮”一声去通知小明的。

阻塞体现在:小明在等待“叮”的时候,不能去做其他事情。

异步非阻塞:
小明仍然使用那个下载完会“叮”一声的软件,小明在提交下载任务后,就不管了,转而去做其他事情。而当下载完成后,下载软件会通过“叮”去主动通知小明。

异步体现在:小明不用时刻关注下载任务,而是让下载软件下载完成之后通过“叮”来通知他。

非阻塞体现在:小明在下载过程中,并非什么都不做,而是去做其他事情了。【软件处理下载任务,小明处理其他任务,不需关注进度,只需接收软件“叮”声通知,即可】

二、BIO模型  (Blocking IO)同步阻塞IO 
同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。这里使用那个经典的烧开水例子,这里假设一个烧开水的场景,有一排水壶在烧开水,BIO的工作模式就是, 叫一个线程停留在一个水壶那,直到这个水壶烧开,才去处理下一个水壶。但是实际上线程在等待水壶烧开的时间段什么都没有做。

实际应用如下图:

当调用系统调用read时,用户线程会一直阻塞到内核空间有数据到来为止,否则就一直阻塞。

举个栗子,发起一个blocking socket的read读操作系统调用,流程大概是这样:

(1)当用户线程调用了read系统调用,内核(kernel)就开始了IO的第一个阶段:准备数据。很多时候,数据在一开始还没有到达(比如,还没有收到一个完整的Socket数据包),这个时候kernel就要等待足够的数据到来。

(2)当kernel一直等到数据准备好了,它就会将数据从kernel内核缓冲区,拷贝到用户缓冲区(用户内存),然后kernel返回结果。

(3)从开始IO读的read系统调用开始,用户线程就进入阻塞状态。一直到kernel返回结果后,用户线程才解除block的状态,重新运行起来。

BIO特点
BIO优点:程序简单,在阻塞等待数据期间,用户线程挂起。用户线程基本不会占用 CPU 资源。

BIO缺点:

一般情况下,服务端会为每个客户端连接配套一条独立的线程,或者说一条线程维护一个连接成功的IO流的读写。在并发量小的情况下,这个没有什么问题。但是,当在高并发的场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。因此,基本上,BIO模型在高并发场景下是不可用的。

三、NIO模型(Non-blocking IO)
(注意这里说的NIO和Java库的NIO是有区别的,Java的NIO库表示的是New IO的意思。这里我们说的NIO是同步非阻塞IO)

那么什么叫做同步非阻塞?如果还拿烧开水来说,NIO的做法是叫一个线程不断的轮询每个水壶的状态,看看是否有水壶的状态发生了改变,从而进行下一步的操作。

在应用中,NIO是如何做到非阻塞地监控每个IO的呢?

NIO 模型中应用程序在一旦开始IO系统调用,会出现以下两种情况:

(1)在内核缓冲区没有数据的情况下,系统调用会立即返回,返回一个调用失败的信息。

(2)在内核缓冲区有数据的情况下,是阻塞的,直到数据从内核缓冲复制到用户进程缓冲。复制完成后,系统调用返回成功,应用进程开始处理用户空间的缓存数据。

如下图:

多次调用不同socket的read,当内核缓冲区没有数据,则马上返回,直到第N次调用read,才发现内核空间有数据,然后才开始真正读数据

举个栗子。发起一个non-blocking socket的read读操作系统调用,流程是这个样子:

(1)在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。用户线程需要不断地发起IO系统调用。

(2)内核数据到达后,用户线程发起系统调用,用户线程阻塞。内核开始复制数据。它就会将数据从kernel内核缓冲区,拷贝到用户缓冲区(用户内存),然后kernel返回结果。

(3)用户线程才解除block的状态,重新运行起来。经过多次的尝试,用户线程终于真正读取到数据,继续执行。

NIO特点:
NIO优点:每次发起的 IO 系统调用,在内核的等待数据过程中可以立即返回。用户线程不会阻塞,则表示用户线程不用呆呆地等待数据到来,而是可以去干其他活了。

NIO缺点:需要不断的重复发起IO系统调用,这种不断的轮询,将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低。而且任务完成(处理到来数据)的响应延迟增大了,因为每过一段时间才去轮询一次 read 操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。

总之,NIO模型在高并发场景下,也是不可用的。一般 Web 服务器不使用这种 IO 模型。一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。java的实际开发中,也不会涉及这种IO模型。

再次说明,Java NIO(New IO) 不是IO模型中的NIO模型,而是另外的一种模型,叫做IO多路复用模型( IO multiplexing )。

四、IO多路复用模型
IO多路复用模型,就是通过一种新的系统调用,一个进程可以监视多个文件描述符(如socket),一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核kernel能够通知程序进行相应的IO系统调用。

目前支持IO多路复用的系统调用,有 select,epoll等等。select系统调用,是目前几乎在所有的操作系统上都有支持,具有良好跨平台特性。epoll是在linux 2.6内核中提出的,是select系统调用的linux增强版本。而Java NIO库中的 selector 底层就是IO多用复用技术。

IO多路复用和NIO的区别
NIO需要在用户程序的循环语句中不停地检查各个socket是否有数据读入,而IO多路复用在用户程序层面则不需要循环语句,虽然IO多路复用也是轮询,但是IO多路复用是交给内核进行各个socket的监控的。其次,由于NIO多次调用read这种系统调用,因此会频繁造成用户态和内核态的转换,而IO多路复用则是先调用select这个系统调用去查询是否有数据就绪的socket,然后有数据就绪,才调用read这个系统调用来读。所以从性能上来说,IO多路复用会比NIO好。

在一定程度上来说,IO多路复用算是同步阻塞的一种,因为select会阻塞到有socket数据就绪为止。所以在应用上,一般会开一条程序来专门给select查询。

如下图为IO对路复用的过程:

(1)进行select/epoll系统调用,查询可以读的连接。kernel会查询所有select的可查询socket列表,当任何一个socket中的数据准备好了,select就会返回。

当用户进程调用了select,那么整个线程会被block(阻塞掉)。

(2)用户线程获得了目标连接后,发起read系统调用,用户线程阻塞。内核开始复制数据。它就会将数据从kernel内核缓冲区,拷贝到用户缓冲区(用户内存),然后kernel返回结果。

(3)用户线程才解除block的状态,用户线程终于真正读取到数据,继续执行。

多路复用IO的特点
IO多路复用模型,建立在操作系统kernel内核能够提供的多路分离系统调用select/epoll基础之上的。多路复用IO需要用到两个系统调用(system call), 一个select/epoll查询调用,一个是IO的读取调用。

和NIO模型相似,多路复用IO需要轮询。负责select/epoll查询调用的线程,需要不断的进行select/epoll轮询,查找出可以进行IO操作的连接。

另外,多路复用IO模型与前面的NIO模型,是有关系的。对于每一个可以查询的socket,一般都设置成为non-blocking模型。只是这一点,对于用户程序是透明的(不感知。因为是在内核处理的)。

优点:

用select/epoll的优势在于,它可以同时处理成千上万个连接(connection)。与一条线程维护一个连接相比,I/O多路复用技术的最大优势是:系统不必创建线程,也不必维护这些线程,从而大大减小了系统的开销。

缺点:

本质上,select/epoll系统调用,属于同步IO,也是阻塞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存在的问题:

  1. 大小有限制。为1024,由于每次select函数调用都需要在用户空间和内核空间传递这些参数,为了提升拷贝效率,linux限制最大为1024。
  2. 这3个集合有相应事件触发时,会被内核修改,所以每次调用select方法都需要重新设置这3个集合的内容。
  3. 当有事件触发select方法返回,需要遍历集合才能找到就绪的文件描述符,例如传1024个读事件,只有一个读事件发生,需要遍历1024个才能找到这一个。
  4. 同样在内核级别,每次需要遍历集合查看有哪些事件发生,效率低下。

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的改进:

  1. 传不固定大小的数组,没有1024的限制了(问题1)
  2. 将关心的事件和实际发生的事件分开,不需要每次都重新设置参数(问题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也出来了,建议开启多线程。

同样,先亮出一张图,直接对着图来分析:在这里插入图片描述

Redis6引入了多线程机制,但是不是说有多个worker线程同时并发读写, 而是它有 “一个 worker线程+多个IO子线程”,其实就是在 IO 就绪之后使用多线程提升读写解析数据的效率,而在 操作内存数据的时候还是用单线程。 利用这种单线程+多线程共同运作的机制,将CPU的性能显著提升了。

同时,这种机制同样不会产生线程安全问题,因为Redis在针对数据的内存操作时,是在一个公共的worker队列中实现的,先进先出,所以不会有线程安全问题。

Redis6之所以保留worker单主线程是因为单线程机制使得Redis内部实现的复杂度大大降低,而且可以保证操作的线程安全。(如果整个过程全让子线程做了,整个任务处理过程太重,就失去了原来单线程高效处理的优势了)

简单来说,就是 “请求是多线程的,但核心的内存读写操作(或者说读写计算)仍然是单线程的”。

总结

本篇我们学习redis单线程具体是如何单线程以及在不同版本的区别,通过网络IO模型知道IO多路复用如何用一个线程处理监听多个网络请求,并详细了解3种reactor模型,这是在IO多路复用基础上的一种设计模式。最后学习了redis单线程、多线程版本是如何基于reactor模型处理请求。其中IO多路复用和reactor模型在许多中间件都有使用到,后续再接触到就不陌生了。

  • 19
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值