03_高性能 IO 模型:为什么单线程 Redis 能那么快?
我们通常说,Redis
是单线程,主要是指 Redis
的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis
对外提供键值存储服务的主要流程。但是 Redis
的其他功能,比如说持久化、异步删除、集群数据同步等,其实是由额外的线程执行的
所以严格来说,Redis
并不是单线程,但是我们一般把 Redis
称为单线程高性能
这也促使我们提问:为什么用单线程?为什么单线程能这么快?
要弄明白这个问题,我们需要深入学习 Redis
的单线程设计机制以及多路复用机制。在我们以后调优 Redis
时,也能更有针对性地避免会导致 Redis
单线程阻塞的操作,例如执行复杂度高的命令
Redis 为什么使用单线程?
多线程的开销
我们经常会听到一种说法,"使用多线程,可以增加系统吞吐率,或是可以增加系统扩展性"
的确,对于一个多线程的系统来说,在有合理的资源分配的情况下,可以增加系统中处理请求操作的资源实体,进而提升系统能够同时处理的请求数,即吞吐率
但是,通常情况下,在我们采用多线程后,如果没有良好的系统设计,实际得到的结果,其实并没有那么良好。在一开始增加线程数时,系统吞吐率会增加,但是再进一步增加线程时,系统吞吐率就增长迟缓了,有时还会出现下降的情况

线程数与系统吞吐率
为什么会出现这种情况呢?一个关键的瓶颈在于,系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要有额外的机制进行保证,而这个额外的机制,就会带来额外的开销
举个例子,Redis
有 List 的数据类型,并提供 LPOP 和 LPUSH 操作
假设 Redis
采用多线程设计,如下图所示,为了保证队列长度的正确性,Redis
需要让线程 A 和 B 的 LPOP、LPUSH 操作串行执行,否则,就可能得到错误的长度结果
这就是多线程编程模式面临的共享资源的并发访问控制问题
多线程并发访问 Redis
并发访问控制一直是多线程开发中的一个难点问题,如果没有精细的设计,比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加
而且采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性
为了避免这些问题,Redis
直接采用了单线程模式
单线程 Redis 为什么这么快
通常来说,单线程的处理能力要比多线程差很多,但是 Redis
却能使用单线程模型达到每秒数十万级别的处理能力,这是为什么呢
- 一方面,Redis 的操作大部分在内存中完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因
- 另一方面,Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率
我们需要弄明白网络操作的基本 IO 模型和潜在的阻塞点
毕竟,Redis 采用单线程进行 IO,如果线程被阻塞了,就无法进行多路复用了
基本 IO 模型与阻塞点
以 GET 请求为例,键值数据库为了处理一个 GET 请求,需要监听客户端请求(bind/listen),和客户端建立连接(accept),从 socket 中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向 socket 中写回数据(send)

既然 Redis 是单线程,那么最基本的一种实现是在一个线程中一次执行上面说的这些操作
但是,在这里的网络 IO 操作中,有潜在的阻塞点,分别是 accept 和 recv
当 Redis 监听到一个客户端有连接请求,但一直未能建立起连接时,会阻塞在 accept 函数这里,导致其他客户端无法和 Redis 建立连接;类似的,当 Redis 通过 recv 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv
这就导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低
不过幸运的是,socket 网络模式本身支持非阻塞模式
非阻塞模式
Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用
在 Socket 模型中,不同操作调用后会返回不同的套接字类型
socket () 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept () 方法接收到达的客户端连接,并返回已连接套接字

针对监听套接字,我们可以设置非阻塞模式
当 Redis 调用 accept 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待
但是需要注意的是,调用 accept 时,已经存在监听套接字了
虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis
类似的,我们也可以针对已连接套接字设置非阻塞模式
Redis 调用 recv 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作(同样的,我们也需要一个机制继续监听该已连接套接字,并在有数据达到时通知 Redis)
这样才能保证 Redis 线程,既不会像基本 IO 模型中一直在阻塞点等待,也不会导致 Redis 无法处理实际到达的连接请求或数据
基于多路复用的高性能 IO 模型
Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是常说的 select/epoll 机制
简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字
内核会一直监听这些套接字上的连接请求或数据请求,一旦有请求到达,就会交给 Redis 处理,这就实现了一个 Redis 线程处理多个 IO 流的效果
图中的多个 FD 就是刚才所说的多个套接字
Redis 网络框架调用 epoll 机制,让内核监听这些套接字
此时,Redis 线程不会阻塞在某一个特定的客户端请求处理上,因此可以同时和多个客户端连接并处理请求,从而提高并发性
为了在请求到达时能够通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即处理不同时间的发生,调用相应的处理函数
当 select/epoll 一旦检测到 FD 上有请求到达时,就会触发相应事件
这些事件会被放进一个事件列表,Redis 单线程对该事件队列不断进行处理
这样一来,Redis 无需一直轮训是否有请求实际发生,避免 CPU 资源浪费
同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的性能
以连接请求和读数据请求为例,具体解释一下
这两个请求分别对应 Accept 事件和 Read 事件,Redis 分别对这两个事件注册 accept 和 get 回调函数。当 Linux 内核监听到有连接请求或读数据请求时,就会触发 Accept 事件和 Read 事件,此时,内核就会回调 Redis 相应的 accept 和 get 函数进行处理。