Redis 是个单线程程序!这点必须铭记。
我们通常说,Redis 是单线程,主要是指 Redis 的网络 IO和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。
单线程 Redis 为什么那么快?
因为它所有的数据都在内存中,所有的运算都是内存级别的运算。正因为 Redis 是单线程,所以要小心使用 Redis 指令,对于那些时间复杂度为 O(n) 级别的指令,一定要谨慎使用,一不小心就可能会导致 Redis 卡顿。
Redis 单线程如何处理那么多的并发客户端连接?多路复用的IO模型。
非阻塞 IO
阻塞模式
以 Get 请求为例,SimpleKV 为了处理一个 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无法处理实际到达的连接请求或数据。
到此,Linux 中的 IO 多路复用机制就要登场了。
基于多路复用的高性能 I/O 模型
Linux 中的 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 实际运行的操作系统,选择相应的多路复用实现。
select API
事件轮询 API 就是用来解决这个问题的,最简单的事件轮询 API 是
select
函数,它是操作系统提供给用户程序的 API。输入是读写描述符列表
read_fds & write_fds
,输出是与之对应的可读可写事件。同时还提供了一个
timeout
参数,如果没有任何事件到来,那么就最多等待timeout
时间,线程处于阻塞状态。一旦期间有任何事件到来,就可以立即返回。时间过了之后还是没有任何事件到来,也会立即返回。拿到事件后,线程就可以继续挨个处理相应的事件。处理完了继续过来轮询。于是线程就进入了一个死循环,我们把这个死循环称为事件循环,一个循环为一个周期。
每个客户端套接字
socket
都有对应的读写文件描述符。read_events, write_events = select(read_fds, write_fds, timeout) for event in read_events: handle_read(event.fd) for event in write_events: handle_write(event.fd) handle_others() # 处理其它事情,如定时任务等
因为我们通过
select
系统调用同时处理多个通道描述符的读写事件,因此我们将这类系统调用称为多路复用 API。现代操作系统的多路复用 API 已经不再使用
select
系统调用,而改用epoll(linux)
和kqueue(freebsd & macosx)
,因为 select 系统调用的性能在描述符特别多时性能会非常差。它们使用起来可能在形式上略有差异,但是本质上都是差不多的,都可以使用上面的伪代码逻辑进行理解。服务器套接字
serversocket
对象的读操作是指调用accept
接受客户端新连接。何时有新连接到来,也是通过select
系统调用的读事件来得到通知的。事件轮询 API 就是 Java 语言里面的 NIO 技术
Java 的 NIO 并不是 Java 特有的技术,其它计算机语言都有这个技术,只不过换了一个词汇,不叫 NIO 而已。
指令队列
Redis 会将每个客户端套接字都关联一个指令队列。客户端的指令通过队列来排队进行顺序处理,先到先服务。
响应队列
Redis 同样也会为每个客户端套接字关联一个响应队列。Redis 服务器通过响应队列来将指令的返回结果回复给客户端。
如果队列为空,那么意味着连接暂时处于空闲状态,不需要去获取写事件,也就是可以将当前的客户端描述符从
write_fds
里面移出来。等到队列有数据了,再将描述符放进去。避免
select
系统调用立即返回写事件,结果发现没什么数据可以写。出这种情况的线程会飙高 CPU。定时任务
服务器处理要响应 IO 事件外,还要处理其它事情。比如定时任务就是非常重要的一件事。如果线程阻塞在 select 系统调用上,定时任务将无法得到准时调度。那 Redis 是如何解决这个问题的呢?
Redis 的定时任务会记录在一个称为
最小堆
的数据结构中。这个堆中,最快要执行的任务排在堆的最上方。在每个循环周期,Redis 都会将最小堆里面已经到点的任务立即进行处理。处理完毕后,将最快要执行的任务还需要的时间记录下来,这个时间就是select
系统调用的timeout
参数。因为 Redis 知道未来timeout
时间内,没有其它定时任务需要处理,所以可以安心睡眠timeout
的时间。Nginx 和 Node 的事件处理原理和 Redis 也是类似的