1.概述
这里我们先给出问题的全面回答:Redis
到底是多线程还是单线程程序要看是针对哪个功能而言,对于核心业务功能部分(命令操作处理数据),Redis是单线程的,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程,所以一般我们认为Redis是个单线程程序。但是从整个框架层面出发严格来说Redis是多线程的。
在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:
Redis v4.0
:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink,异步持久化等等Redis v6.0
:在核心网络模型中引入多线程,进一步提高对于多核CPU的利用率
2.Redis为什么采用单线程处理命令操作?
Redis
的大部分操作都在内存中完成的,执行速度非常快,所以它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。并且多线程会导致过多的上下文切换,带来不必要的性能开销。
同时多线程编程模式面临共享资源并发访问控制问题。并发访问控制一直是多线程开发中的一个难点问题,如果没有精细的设计,比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。而且采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。
3.Redis采用单线程为什么还那么快?
通常来说,单线程的处理能力要比多线程差很多,但是 Redis
却能使用单线程模型达到每秒数十万级别的处理能力,这是为什么呢?其实,这是 Redis
多方面设计选择的一个综合结果。一方面,Redis
的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。另一方面,就是Redis
采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率,这也是Redis单线程如何处理那么多的并发客户端连接的核心所在。
在讲Redis网络模型之前先来看看应用是怎么和系统硬件进行交互的?用户应用如Redis,MySQL
等其实是没有办法直接访问我们操作系统硬件的,只能先访问内核linux,再通过内核去访问计算机硬件。计算机硬件包括cpu,内存,网卡等等,内核(通过寻址空间)可以操作硬件的,但是内核需要不同设备的驱动,有了这些驱动之后,内核就可以去对计算机硬件去进行内存管理,文件系统的管理,进程的管理等等。
我们想要用户的应用来访问,计算机就必须要通过对外暴露的一些接口,才能访问到,从而简单的实现对内核的操控,但是内核本身上来说也是一个应用,所以他本身也需要一些内存,cpu等设备资源,用户应用本身也在消耗这些资源,如果不加任何限制,用户去操作随意的去操作我们的资源,就有可能导致一些冲突,甚至有可能导致我们的系统出现无法运行的问题,因此我们需要把用户和内核隔离开
进程的寻址空间划分成两部分:内核空间、用户空间
Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:
写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
针对这个操作:我们的用户在读读数据时,会去向内核态申请,想要读取内核的数据,而内核数据要去等待驱动程序从硬件上读取数据,当从磁盘上加载到数据之后,内核会将数据写入到内核的缓冲区中,然后再将数据拷贝到用户态的buffer中,然后再返回给应用程序,整体而言速度慢,我们希望read也好,还是wait for data也最好都不要等待,或者时间尽量的短。
当我们发送请求调用网络套接字socket的读写方法,默认它们是阻塞的,比如 read 方法要传递进去一个参数n,表示读取这么多字节后再返回,如果没有读够线程就会卡在那里,直到新的数据到来或者连接关闭了,read 方法才可以返回,线程才能继续处理。而 write 方法一般来说不会阻塞,除非内核为套接字分配的写缓冲区已经满了,write 方法就会阻塞,直到缓存区中有空闲空间挪出来了。这就导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低。不过,幸运的是,socket 网络模型本身支持非阻塞模式,这时候IO多路复用事件驱动机制就闪亮登场了。
在单线程情况下,依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。这里先来举个生动形象的例子来便于大家理解:众多客户端访问Redis服务,就好比去一家餐厅吃饭柜台就一个服务员点餐,每个顾客都要想一下吃什么(等待数据就绪),想好之后开始点餐(读取数据),这个过程后面的顾客(客户端)即使想好了点什么也只能白白干等着,可见这种方式能快起来吗?所以采取了另一种方式进餐厅告诉想吃饭不用排队,谁想好了吃什么(数据就绪了),就通知服务员给谁点餐(用户应用就去读取数据)。这样就有效提高了资源利用率。
本质来说,事件驱动是一种思想(事实上它不仅仅局限于编程) ,事件驱动思想是实现 异步非阻塞特性 的一个重要手段。对于web服务器来说,造成性能拉胯不支持高并发的常见原因就是由于使用了传统的I/O模型造成在内核没有可读/可写事件(或者说没有数据可供用户进程读写)时
,用户线程 一直在等待
(其他事情啥也干不了就是干等等待内核上的数据可读/可写),这样的话其实是一个线程(ps:线程在Linux系统也是进程)对应一个请求,请求是无限的,而线程是有限的从而也就形成了并发瓶颈。而大佬们为了解决此类问题,运用了事件驱动思想来对传统I/O模型做个改造,即在客户端发起请求后,用户线程不再阻塞等待内核数据就绪
,而是立即返回
(可以去执行其他业务逻辑或者继续处理其他请求)。当内核的I/O操作完成后,内核系统
会向用户线程发送一个事件通知
,用户线程才来处理这个读/写操作,之后拿到数据再做些其他业务后响应给客户端,从而完成一次客户端请求的处理。事件驱动的I/O模型中,程序不必阻塞等待I/O操作的完成,也无需为每个请求创建一个线程,从而提高了系统的并发处理能力和响应速度。事件驱动型的I/O模型通常也被被称为I/O多路复用
,即这种模型可以在一个线程中,处理多个连接(复用就是指多个连接复用一个线程,多路也即所谓的 多个连接),通过这种方式避免了线程间切换的开销,同时也使得用户线程不再被阻塞,提高了系统的性能和可靠性。Redis支持事件驱动是因为他利用了操作系统提供的I/O多路复用接口,如Linux系统中,常用的I/O多路复用接口有select/poll,epoll。这些接口可以监视多个文件描述符FD的状态变化,当文件描述符可读或可写时,就会向用户线程发送一个事件通知。用户线程通过事件处理机制(读取/写入数据)来处理这个事件,之后进行对应的业务逻辑完了进行响应。简单一句话概括: 事件驱动机制就是指当有读/写/连接事件就绪时 再去做读/写/接受连接这些事情,而不是一直在那里傻傻的等,也正应了他的名词: 【事件驱动!】,基于事件驱动思想设计的多路复用I/O(如select/poll,epoll),相对于传统I/O模型,达到了异步非阻塞的效果!
linux采用的epoll机制,接下来就让我们详细看看。
文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
epoll
是 Linux 上高效的多路复用机制,它与传统的 select
和 poll
相比,有更好的性能。
注册事件: 程序通过
epoll_create
创建一个epoll
对象,然后使用epoll_ctl
向其中注册需要监视的文件描述符和关注的事件,如读或写事件。int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
等待事件: 使用
epoll_wait
等待文件描述符上的事件发生。epoll_wait
会阻塞,直到注册的文件描述符中的事件发生或者超时。int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
处理事件: 当
epoll_wait
返回时,程序可以迭代处理发生的事件,执行相应的读或写操作。for (int i = 0; i < nfds; ++i) { if (events[i].events & EPOLLIN) { // 处理读事件 } if (events[i].events & EPOLLOUT) { // 处理写事件 } }
epoll
采用了事件通知的机制,只有真正发生事件时才进行处理,避免了轮询的开销,因此在处理大量并发连接时性能更好。
总的来说,多路复用通过允许单一进程或线程同时监视多个文件描述符,提高了 I/O 操作的效率,特别适用于高并发的网络应用场景
select模式存在的三个问题:能监听的FD最大不超过1024。每次select都需要把所有要监听的FD都拷贝到内核空间每次都要遍历所有FD来判断就绪状态。
poll模式的问题:poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降epoll模式中如何解决这些问题的?
基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降。
下面就来看看Redis
基于IO多路复用事件驱动机制实现的网络模型:
当我们的客户端想要去连接我们服务器,会去先到IO多路复用模型去进行排队,会有一个连接应答处理器,他会去接受读请求,然后又把读请求注册到具体模型中去,此时这些建立起来的连接,如果是客户端请求处理器去进行执行命令时,他会去把数据读取出来,然后把数据放入到client中, clinet去解析当前的命令转化为redis认识的命令,接下来就开始处理这些命令,从redis中的command中找到这些命令,然后就真正的去操作对应的数据了,当数据操作完成后,会去找到命令回复处理器,再由他将数据写出。大体流程如下:
1.Redis服务启动之后,就会创建一个server Socket服务器套接字得到对应文件描述符FD,调用epoll机制进行注册监听
2.客户端client进行连接,服务器套接字的FD会进入就绪,进行回调事件tcpAccepthandler
处理,调用accept()
接收客户端socket,得到对应FD进行注册监听。
3.当客户端socket FD就绪,会调用相应的可读readQueryFromClient
读取请求数据,或者可写事件sendReplyToClient
写出相应数据。
项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用
Github地址:https://github.com/plasticene/plasticene-boot-starter-parent
Gitee地址:https://gitee.com/plasticene3/plasticene-boot-starter-parent
微信公众号:Shepherd进阶笔记
交流探讨qun:Shepherd_126
4.Redis6.0为什么采用了多线程
上文说的单线程指的是从网络 IO 处理到实际的读写命令处理,都是由单个线程完成的。
随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 IO 的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度。采用多线程 I/O 可以让 Redis 在一个单一进程内同时处理多个客户端的请求,充分利用多核处理器的优势,提高系统的并发性能,提升系统吞吐量。
Redis 的多线程 I/O类似于一个餐厅,只有一名服务员负责接受客人点餐(处理命令的执行),而多名厨师则负责烹饪和准备食物(处理 I/O 操作,如读取和写入)。在这个场景中,服务员负责与客人直接交互,接受点餐信息,这相当于 Redis 的主线程负责处理命令。而厨师在后厨专注于将点餐信息转化为实际的菜品,这就类似于 Redis 的工作线程专注于处理 I/O 操作,如读取和写入数据。
我们来看下,在 Redis 6.0 中,主线程和 IO 线程具体是怎么协作完成请求处理的。掌握了具体原理,你才能真正地会用多线程。为了方便你理解,我们可以把主线程和多 IO 线程的协作分成四个阶段。
阶段一:服务端和客户端建立 Socket 连接,并分配处理线程
首先,主线程负责接收建立连接请求。当有客户端请求和实例建立 Socket 连接时,主线程会创建和客户端的连接,并把 Socket 放入全局等待队列中。紧接着,主线程通过轮询方法把 Socket 连接分配给 IO 线程。
阶段二:IO 线程读取并解析请求主线程一旦把 Socket 分配给 IO 线程,就会进入阻塞状态,等待 IO 线程完成客户端请求读取和解析。因为有多个 IO 线程在并行处理,所以,这个过程很快就可以完成。
阶段三:主线程执行请求操作等到 IO 线程解析完请求,主线程还是会以单线程的方式执行这些命令操作。下面这张图显示了刚才介绍的这三个阶段,你可以看下,加深理解。
阶段四:IO 线程回写 Socket 和主线程清空全局队列当主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后,主线程会阻塞等待 IO 线程把这些结果回写到 Socket 中,并返回给客户端。和 IO 线程读取和解析请求一样,IO 线程回写 Socket 时,也是有多个线程在并发执行,所以回写 Socket 的速度也很快。等到 IO 线程回写 Socket 完毕,主线程会清空全局队列,等待客户端的后续请求。我也画了一张图,展示了这个阶段主线程和 IO 线程的操作,你可以看下。
了解了 Redis 主线程和多线程的协作方式,我们该怎么启用多线程呢?在 Redis 6.0 中,多线程机制默认是关闭的,如果需要使用多线程功能,需要在 redis.conf 中完成两个设置。
- 设置 io-threads-do-reads 配置项为 yes,表示启用多线程。
io-threads-do-reads yes
2.设置线程个数。一般来说,线程个数要小于 Redis 实例所在机器的 CPU 核个数,例如,对于一个 8 核的机器来说,Redis 官方建议配置 6 个 IO 线程。
io-threads 6
如果你在实际应用中,发现 Redis 实例的 CPU 开销不大,吞吐量却没有提升,可以考虑使用 Redis 6.0 的多线程机制,加速网络处理,进而提升实例的吞吐量。
5.总结
Redis 的网络模型主要使用 epoll(在 Linux 上)作为事件通知机制,并且在版本 6.0 中引入了多线程 I/O 模型。
epoll:
- 设计目标: 主要用于实现单线程的事件驱动模型,处理大量的并发连接。
- 机制: Redis 使用 epoll 机制,通过单个线程监听多个文件描述符上的事件,实现非阻塞 I/O。当某个连接有数据到达时,通过事件通知机制触发相应的处理。
- 适用场景: 适用于 I/O 密集型的场景,其中大量的连接需要同时被高效处理,例如网络通信密集型的应用场景。
多线程 I/O:
- 设计目标: 引入多线程用于更好地利用多核处理器,提高系统的并发性能。
- 机制: Redis 6.0 引入了多线程 I/O 模型,其中一个线程负责处理命令的执行,而其他的工作线程则负责处理 I/O 操作,如读取和写入。这样可以同时处理多个连接的 I/O 操作。
- 适用场景: 适用于需要更好地利用多核处理器、同时处理大量 I/O 操作的场景。特别在 CPU 密集型的情况下,通过多线程可以提高性能。
总体来说:epoll负责从网络接收数据到内核,多线程io从epoll记录的socket中将数据从内核读取到用户态