Redis 到底有多快?
redis-benchmark -t set,lpush -n 100000 -q
结果(本地虚拟机) :
SET: 51813.47 requests per second —— 每秒钟处理 5 万多次 set 请求
LPUSH: 51706.31 requests per second —— 每秒钟处理 5 万多次 lpush 请求
redis-benchmark -n 100000 -q script load "redis.call('set','foo','bar')"
结果(本地虚拟机) :
script load redis.call('set','foo','bar'): 46816.48 requests per second —— 每秒钟 46000 次 lua 脚本调用
根据官方的数据,Redis 的 QPS 可以达到 10 万左右(每秒请求数)。
Redis 为什么这么快?
总结:1)纯内存结构、2)单线程、3)多路复用
- 内存
KV 结构的内存数据库,时间复杂度 O(1)。
第二个,要实现这么高的并发性能,是不是要创建非常多的线程?
恰恰相反,Redis 是单线程的。 - 单线程
单线程有什么好处呢?
1、没有创建线程、销毁线程带来的消耗
2、避免了上线文切换导致的 CPU 消耗
3、避免了线程之间带来的竞争问题,例如加锁释放锁死锁等等 - 异步非阻塞
异步非阻塞 I/O,多路复用处理并发连接
Redis 为什么是单线程的?
不是白白浪费了 CPU 的资源吗?
官方解释说,因为单线程已经够用了,CPU 不是 redis 的瓶颈。Redis 的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了。
单线程为什么这么快?
因为 Redis 是基于内存的操作,我们先从内存开始说起。
- 虚拟存储器(虚拟内存 Vitual Memory)
名词解释:主存:内存;辅存:磁盘(硬盘)
计算机主存(内存)可看作一个由 M 个连续的字节大小的单元组成的数组,每个字节有一个唯一的地址,这个地址叫做物理地址(PA)。早期的计算机中,如果 CPU 需要内存,使用物理寻址,直接访问主存储器。
这种方式有几个弊端:
1、在多用户多任务操作系统中,所有的进程共享主存,如果每个进程都独占一块物理地址空间,主存很快就会被用完。我们希望在不同的时刻,不同的进程可以共用同一块物理地址空间。
2、如果所有进程都是直接访问物理内存,那么一个进程就可以修改其他进程的内存数据,导致物理地址空间被破坏,程序运行就会出现异常。
为了解决这些问题,我们就想了一个办法,在 CPU 和主存之间增加一个中间层。CPU不再使用物理地址访问,而是访问一个虚拟地址,由这个中间层把地址转换成物理地址,最终获得数据。这个中间层就叫做虚拟存储器(Virtual Memory)。
具体的操作如下所示:
在每一个进程开始创建的时候,都会分配一段虚拟地址,然后通过虚拟地址和物理地址的映射来获取真实数据,这样进程就不会直接接触到物理地址,甚至不知道自己调用的哪块物理地址的数据。
目前,大多数操作系统都使用了虚拟内存,如 Windows 系统的虚拟内存、Linux 系统的交换空间等等。Windows 的虚拟内存(pagefile.sys)是磁盘空间的一部分。
在 32 位的系统上,虚拟地址空间大小是 2^32bit=4G。在 64 位系统上,最大虚拟地址空间大小是多少?是不是2^64bit=1024*1014TB=1024PB=16EB?实际上没
有用到 64 位,因为用不到这么大的空间,而且会造成很大的系统开销。Linux 一般用低48 位来表示虚拟地址空间,也就是 2^48bit=256T。
cat /proc/cpuinfo
address sizes : 40 bits physical, 48 bits virtual
实际的物理内存可能远远小于虚拟内存的大小。引入虚拟内存,可以提供更大的地址空间,并且地址空间是连续的,使得程序编写、链接更加简单。并且可以对物理内存进行隔离,不同的进程操作互不影响。还可以通过把同一块物理内存映射到不同的虚拟地址空间实现内存共享。
-
用户空间和内核空间
为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间(Kernel-space),一部分是用户空间(User-space)。
内核是操作系统的核心,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的权限。
内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中,都是对物理地址的映射。
在 Linux 系统中, 内核进程和用户进程所占的虚拟内存比例是 1:3。
当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。
进程在内核空间以执行任意命令,调用系统的一切资源;在用户空间只能执行简单的运算,不能直接调用系统资源,必须通过系统接口(又称 system call),才能向内核发出指令。
top 命令:
us 代表 CPU 消耗在 User space 的时间百分比;
sy 代表 CPU 消耗在 Kernel space 的时间百分比。
-
进程切换(上下文切换)
多任务操作系统是怎么实现运行远大于 CPU 数量的任务个数的?当然,这些任务实际上并不是真的在同时运行,而是因为系统通过时间片分片算法,在很短的时间内,将CPU 轮流分配给它们,造成多任务同时运行的错觉。
为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。
什么叫上下文?
在每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,也就是说,需要系统事先帮它设置好 CPU 寄存器和程序计数器(Program Counter),这个叫做CPU 的上下文。
而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
在切换上下文的时候,需要完成一系列的工作,这是一个很消耗资源的操作。
进程的阻塞
正在运行的进程由于提出系统服务请求(如 I/O 操作),但因为某种原因未得到操作系统的立即响应,该进程只能把自己变成阻塞状态,等待相应的事件出现后才被唤醒。进程在阻塞状态不占用 CPU 资源。文件描述符 FD
Linux 系统将所有设备都当作文件来处理,而 Linux 用文件描述符来标识每个文件对象。
文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引,用于指向被打开的文件,所有执行 I/O 操作的系统调用都通过文件描述符;文件描述符是一个简单的非负整数,用以表明每个被进程打开的文件。
Linux 系统里面有三个标准文件描述符。
0:标准输入(键盘);1:标准输出(显示器);2:标准错误输出(显示器)。
- 传统 I/O 数据拷贝
以读操作为例:当应用程序执行 read 系统调用读取文件描述符(FD)的时候,如果这块数据已经存在于用户进程的页内存中,就直接从内存中读取数据。如果数据不存在,则先将数据从磁盘加载数据到内核缓冲区中,再从内核缓冲区拷贝到用户进程的页内存中。(两次拷贝,两次 user 和 kernel 的上下文切换)。
I/O 的阻塞到底阻塞在哪里?
-
Blocking I/O
当使用 read 或 write 对某个文件描述符进行过读写时,如果当前 FD 不可读,系统就不会对其他的操作做出响应。从设备复制数据到内核缓冲区是阻塞的,从内核缓冲区拷贝到用户空间,也是阻塞的,直到 copy complete,内核返回结果,用户进程才解除block 的状态。
为了解决阻塞的问题,我们有几个思路。
- 在服务端创建多个线程或者使用线程池,但是在高并发的情况下需要的线程会很多,系统无法承受,而且创建和释放线程都需要消耗资源。
- 由请求方定期轮询,在数据准备完毕后再从内核缓存缓冲区复制数据到用户空间(非阻塞式 I/O),这种方式会存在一定的延迟。
能不能用一个线程处理多个客户端请求?
I/O 多路复用(I/O Multiplexing)
- I/O 指的是网络 I/O。
- 多路指的是多个 TCP 连接(Socket 或 Channel)。
- 复用指的是复用一个或多个线程。
它的基本原理就是不再由应用程序自己监视连接,而是由内核替应用程序监视文件描述符。
客户端在操作的时候,会产生具有不同事件类型的 socket。在服务端,I/O 多路复用程序(I/O Multiplexing Module)会把消息放入队列中,然后通过文件事件分派器(File event Dispatcher),转发到不同的事件处理器中。
多路复用有很多的实现,以 select 为例,当用户进程调用了多路复用器,进程会被阻塞。内核会监视多路复用器负责的所有 socket,当任何一个 socket 的数据准备好了,多路复用器就会返回。这时候用户进程再调用 read 操作,把数据从内核缓冲区拷贝到用户空间。
所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪(readable)状态,select()函数就可以返回。
Redis 的多路复用, 提供了 select, epoll, evport, kqueue 几种选择,在编译的时候来选择一种。源码 ae.c
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#include "ae_select.c"
#endif
#endif
#endif
evport 是 Solaris 系统内核提供支持的;
epoll 是 LINUX 系统内核提供支持的;
kqueue 是 Mac 系统提供支持的;
select 是 POSIX 提供的,一般的操作系统都有支撑(保底方案);
源码 ae_epoll.c、ae_select.c、ae_kqueue.c、ae_evport.c