我们通常说Redis是单线程,主要是指Redis的网络IO和键值对读写是由一个线程来完成的。这也是Redis对外提供键值存储服务的主要流程。
但redis的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
Redis为什么用单线程?
- 多线程的开销会比较大,针对一些复杂的操作,需要考虑:多线程编程模式面临的共享资源并发访问控制问题。
- 对于并发访问控制,如果只是采用粗粒度的互斥锁,那么即时增大了线程,也会导致大部分线程在等待获取访问共享资源的互斥锁,并行边串行,系统吞吐率并没有随着线程的增加而增加。
- 而且采用多线程开发一般会引入原语来保护共享资源的并发访问,这也会降低代码的易调试性和可维护性。
为了避免这些问题。redis直接采用了单线程模式。
单线程Redis为什么那么快
redis能使用单线程模型达到每秒数十万级别的处理能力,这是因为Redis多方面设计选择的一个综合结果:
- redis大部分操作在内存上完成
- 采用高效的数据结构,例如哈希表和跳表,他是实现高性能的一个重要原因。
- 采用多路复用机制,使其在网络IO操作中能并发处理大量的客户端请求,实现高吞吐率。
在学习多路复用之前,要弄明白网络操作的基本IO模型和潜在的阻塞点。毕竟redis是采用单线程进行IO,如果线程被阻塞了,就无法进行多路复用了。
基本IO模型与阻塞点
simpleKV,最基本的一种实现是在一个线程中依次执行一系列操作。
以get请求为例,SimpleKV为了处理gei请求,需要监听客户端请求(bind/listen),和客户端建立连接(accept),从socket中读取请求(recv),解析客户端发送的请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向socket中写回数据(send)。
redis基本IO模型
在这里的网络IO操作中,有潜在的阻塞点,分别是:accept()和recv()。当Redis监听到一个客户端有连接请求,但一直未能成功建立连接时,会阻塞在accept()函数这里,导致其他客户端无法和Redis建立连接,同样,当Redis通过recv()从一个客户端读取数据时,如果数据一直没有到达,redis也会一直阻塞在recv()。
这就导致redis整个线程阻塞,无法处理其他客户端请求,效率很低。幸运的是,socket网络模型本身支持非阻塞模式。
非阻塞模式
想要使用socket的非阻塞模式,就必须要了解三个函数的调用返回类型和设置模式。
在socket模型中,不同操作调用后会返回不同的套接字类型。socket()方法会返回主动套接字,然后调用listen()方法,将主动套接字转化为监听套接字,此时可以监听来自客户端的了解请求。最后,调用accept()方法接收到达的客户端连接,并返回已连接套接字。
redis套接字类型与非阻塞设置
针对监听套接字,可以设置非阻塞模式:当redis调用accept()但一直未有连接请求到达时,redis线程可以返回处理其他操作,而不用一直等待。但要注意的是,调用accept()时,已经存在监听套接字了。
虽然redis线程可以不用一直等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求是通知redis。
类似的我们可以针对已连接套接字设置非阻塞模式:redis调用recv()后,如果已连接套接字上一直没有数据到达,redis同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知redis。
这样才会保证redis线程,既不会像基本IO模型中一直在阻塞点等待,也不会导致redis无法处理实际到达的连接请求或数据。
到此,可以继续了解linux上的IO多路复用机制。
基于多路复用的高性能IO模型
Linux中IO多路复用机制是指一个线程处理多个IO流,就是我们经常听到的select/epoll机制。简单来说,在redis只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给redis线程处理,这就实现了一个redis线程处理多个IO流的效果。
下图是基于多路复用的Redis IO模型。图中的多个FD就是刚才所说的多个套接字。Redis网络框架调用epoll机制,让内核监听这些套接字。此时,redis线程不会阻塞在某一个特定的监听或者已连接套接字上。也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为如此,Redis可以和多个客户端连接并处理请求,从而提升并发性。
基于多路复用的Redis高性能IO模型
为了在请求到达时能通知到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 实际运行的操作系统,选择相应的多路复用实现。
小结
重点三个问题:redis真的只有单线程吗?、为什么用单线程?、单线程为什么这么快?
现在我们知道了,redis单线程是指它对网络IO和数据读写的操作采用了单线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。单线程的redis能高性能,跟多路复用的IO模型密切相关,因为这避免了accept()和send()/resc()潜在的网络IO操作阻塞点。
另外redis的单线程是针对redis 5.0版本之前的。redis6.0中提出了多线程模型。
资料:03 高性能IO模型:为什么单线程Redis能那么快?.md