Redis 为什么快

服务端为了处理客户端的连接和请求的数据,写了如下代码。

1
2
3
4
5
6
7
8
9
listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

这段代码会执行得磕磕绊绊,就像这样。

https://cdn.xiaobinqt.cn/%E4%BC%A0%E7%BB%9FIO.gif
阻塞IO

可以看到,服务端的线程阻塞在了两个地方,一个是accept函数,一个是read函数。

如果再把read函数的细节展开,我们会发现其阻塞在了两个阶段。一个阶段是数据从网卡拷贝到内核缓冲区,第二个阶段是数据从内核缓冲区拷贝到用户缓冲区。

https://cdn.xiaobinqt.cn/read.gif
read 阻塞

这就是传统的阻塞 IO。整体流程如下图👇

https://cdn.xiaobinqt.cn/xiaobinqt.io/20221124/631dedf37f5544d2b74214112468e72f.png
阻塞IO流程

如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在read函数上不返回,也无法接受其他客户端连接,这肯定是不行的。

为了解决阻塞 IO 的问题,其关键在于改造read函数。有一种聪明的办法是,每次都创建一个新的进程或线程,去调用read函数,并做业务处理。

1
2
3
4
5
6
7
8
9
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  pthread_create(doWork);  // 创建一个新的线程
}
void doWork() {
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的read请求上。

https://cdn.xiaobinqt.cn/%E9%9D%9E%E9%98%BB%E5%A1%9EIO.gif
假非阻塞IO

不过,这不叫非阻塞 IO,只不过用了多线程的手段使得主线程没有卡在read函数上不往下走罢了。操作系统为我们提供的read函数仍然是阻塞的

真正的非阻塞 IO,不能是通过我们用户层的小把戏,而是要恳请操作系统为我们提供一个非阻塞的read函数

这个read函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。操作系统提供了这样的功能,只需要在调用read前,将文件描述符(也就是客户端的连接)设置为非阻塞即可。

1
2
fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);

这样,就需要用户线程循环调用read,直到返回值不为 -1,再开始处理业务。

https://cdn.xiaobinqt.cn/%E7%9C%9F%E7%9A%84%E9%9D%9E%E9%98%BB%E5%A1%9EIO.gif
非阻塞IO

这里有一个细节☝️。非阻塞的read,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的,一旦数据已到达内核缓冲区,此时调用read函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。整体流程如下图👇

https://cdn.xiaobinqt.cn/xiaobinqt.io/20221124/15b4551071694947a1df10ef9d80208a.png
非阻塞IO流程

为每个客户端创建一个线程,服务器端的线程资源很容易被耗光。

https://cdn.xiaobinqt.cn/xiaobinqt.io/20221124/1d4069e204fe4c94b059bbaccf8e6262.png
线程资源

当然还有个聪明的办法,我们可以每个accept客户端连接后,将这个文件描述符(connfd)放到一个数组里。

1
fdlist.add(connfd);

然后开一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞read方法。这样,我们就成功用一个线程处理了多个客户端连接。

1
2
3
4
5
6
7
while(1) {
  for(fd <-- fdlist) {
    if(read(fd) != -1) {
      doSomeThing();
    }
  }
}

https://cdn.xiaobinqt.cn/fdlist.gif
fdlist

这样看起来是不是觉得这有些多路复用的意思?

但这和我们用多线程去将阻塞 IO 改造成看起来是非阻塞 IO 一样,这种遍历方式也只是我们用户自己想出的小把戏,每次遍历遇到read返回 -1 时仍然是一次浪费资源的系统调用。在while循环里做系统调用,是不划算的。所以,还是得恳请操作系统老大,提供给我们一个有这样效果的函数,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个问题。

select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统,让操作系统去遍历,确定哪个文件描述符可以读写,然后告诉我们去处理:

https://cdn.xiaobinqt.cn/select.gif
select

select系统调用的函数定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *exceptfds,
    struct timeval *timeout);
// nfds:监控的文件描述符集里最大文件描述符加1
// readfds:监控有读数据到达文件描述符集合,传入传出参数
// writefds:监控写数据到达文件描述符集合,传入传出参数
// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
// timeout:定时阻塞监控时间,3种情况
//  1.NULL,永远等下去
//  2.设置timeval,等待固定时间
//  3.设置timeval里时间均为0,检查描述字后立即返回,轮询

那么我们的服务端代码,就这样来写,首先一个线程不断接受客户端连接,并把 socket 文件描述符放到一个 list 里。

1
2
3
4
5
while(1) {
  connfd = accept(listenfd);
  fcntl(connfd, F_SETFL, O_NONBLOCK);
  fdlist.add(connfd);
}

然后,另一个线程不再自己遍历,而是调用 select,将这批文件描述符 list 交给操作系统去遍历。

1
2
3
4
5
6
while(1) {
  // 把一堆文件描述符 list 传给 select 函数
  // 有已就绪的文件描述符就返回,nready 表示有多少个就绪的
  nready = select(list);
  ...
}

不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list,只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
while(1) {
  nready = select(list);
  // 用户层依然要遍历,只不过少了很多无效的系统调用
  for(fd <-- fdlist) {
    if(fd != -1) {
      // 只读已就绪的文件描述符
      read(fd, buf);
      // 总共只有 nready 个已就绪描述符,不用过多遍历
      if(--nready == 0) break;
    }
  }
}

正如刚刚的动图中所描述的,其直观效果如下。

https://cdn.xiaobinqt.cn/select.gif
select

可以看出几个细节:

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)

  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)

  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

整个 select 的流程图如下👇

https://cdn.xiaobinqt.cn/xiaobinqt.io/20221124/d4914bc01ce140c790589ec87cf3b181.png
select流程图

可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的read系统调用)。

poll 也是操作系统提供的系统调用函数。

1
2
3
4
5
6
7
int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd {
  intfd; /*文件描述符*/
  shortevents; /*监控的事件*/
  shortrevents; /*监控事件中满足条件返回的事件*/
};

它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。

epoll 是最终的大 boss,它解决了 select 和 poll 的一些问题。

还记得上面说的 select 的三个细节么❓

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)

  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)

  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)。

所以 epoll 主要就是针对这三点进行了改进

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。

  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。

  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

https://cdn.xiaobinqt.cn/epoll.gif
epoll

一切的开始,都起源于这个read函数是操作系统提供的,而且是阻塞的,我们叫它阻塞 IO。为了破这个局,程序员在用户态通过多线程来防止主线程卡死。

后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的read函数,这样程序员就可以在一个线程内完成多个文件描述符的读取,这就是非阻塞 IO。

但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在while循环里进行了越来越多的系统调用。

后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是 IO 多路复用。

多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的三个不足。

所以,IO 模型的演进,其实就是时代的变化,倒逼着操作系统将更多的功能加到自己的内核而已。

如果你建立了这样的思维,很容易发现网上的一些错误。比如好多文章说,多路复用之所以效率高,是因为用一个线程就可以监控多个文件描述符。

这显然是知其然而不知其所以然,多路复用产生的效果,完全可以由用户态去遍历文件描述符并调用其非阻塞的 read 函数实现。而多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。就好比平时写业务代码,把原来 while 循环里调 http 接口进行批量,改成了让对方提供一个批量添加的 http 接口,然后一次 rpc 请求就完成了批量添加。

  • 纯内存操作
  • 单线程操作,避免了频繁的上下文切换
  • 采用了非阻塞 I/O 多路复用机制

Redis 作为一个内存服务器,它需要处理很多来自外部的网络请求,它使用 I/O 多路复用机制同时监听多个文件描述符的可读和可写状态,一旦收到网络请求就会在内存中快速处理,由于绝大多数的操作都是纯内存的,所以处理的速度会非常地快。

在 Redis 4.0 之后的版本,情况就有了一些变动,新版的 Redis 服务在执行一些命令时就会使用『主处理线程』之外的其他线程。虽然 Redis 在较新的版本中引入了多线程,不过是在部分命令上引入的,其中包括非阻塞的删除操作,在整体的架构设计上,主处理程序还是单线程模型的。

Redis 从一开始就选择使用单线程模型处理来自客户端的绝大多数网络请求,这种考虑其实是多方面的,其中最重要的几个原因如下👇

  • 使用单线程模型能带来更好的可维护性,方便开发和调试;
  • 使用单线程模型也能并发的处理客户端的请求;
  • Redis 服务中运行的绝大多数操作的性能瓶颈都不是 CPU;

上述三个原因中的最后一个是最终使用单线程模型的决定性因素,其他的两个原因都是使用单线程模型额外带来的好处。

可维护性对于一个项目来说非常重要,如果代码难以调试和测试,问题也经常难以复现,这对于任何一个项目来说都会严重地影响项目的可维护性。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,代码的执行过程不再是串行的,多个线程同时访问的变量如果没有谨慎处理就会带来诡异的问题。

引入了多线程,就必须要同时引入并发控制来保证在多个线程同时访问数据时程序行为的正确性,这就需要工程师额外维护并发控制的相关代码,例如,需要在可能被并发读写的变量上增加互斥锁。在访问这些变量或者内存之前也需要先对获取互斥锁,一旦忘记获取锁或者忘记释放锁就可能会导致各种诡异的问题,管理相关的并发控制机制也需要付出额外的研发成本和负担。

多线程技术能够帮助我们充分利用 CPU 的计算资源来并发的执行不同的任务,但是 CPU 资源往往都不是 Redis 服务器的性能瓶颈。哪怕在一个普通的 Linux 服务器上启动 Redis 服务,它也能在 1s 的时间内处理 1,000,000 个用户请求。

Redis 并不是 CPU 密集型的服务,如果不开启 AOF 备份,所有 Redis 的操作都会在内存中完成不会涉及任何的 I/O 操作,这些数据的读写由于只发生在内存中,所以处理速度是非常快的;整个服务的瓶颈在于网络传输带来的延迟和等待客户端的数据传输,也就是网络 I/O,所以使用多线程模型处理全部的外部请求可能不是一个好的方案。

AOF 是 Redis 的一种持久化机制,它会在每次收到来自客户端的写请求时,将其记录到日志中,每次 Redis 服务器启动时都会重放 AOF 日志构建原始的数据集,保证数据的持久性。

多线程虽然会帮助我们更充分地利用 CPU 资源,但是操作系统上线程的切换也不是免费的,线程切换其实会带来额外的开销。频繁的对线程的上下文进行切换可能还会导致性能地急剧下降,这可能会导致我们不仅没有提升请求处理的平均速度,反而进行了负优化,所以这也是为什么 Redis 对于使用多线程技术非常谨慎。

对于 Redis 中的一些超大键值对,几十 MB 或者几百 MB 的数据并不能在几毫秒的时间内处理完,Redis 可能会需要在释放内存空间上消耗较多的时间,这些操作就会阻塞待处理的任务,然而释放内存空间的工作其实可以由后台线程异步进行处理,这也就是 UNLINK 命令的实现原理,它只会将键从元数据中删除,真正的删除操作会在后台异步执行。