侧边栏壁纸
  • 累计撰写 244 篇文章
  • 累计创建 16 个标签
  • 累计收到 0 条评论
隐藏侧边栏

通过非阻塞 IO 和多路复用机制确保 Redis 单线程 IO 模型的高性能

kaixindeken
2021-04-28 / 0 评论 / 0 点赞 / 74 阅读 / 3,324 字

从非阻塞 IO 聊起

我们先从非阻塞 IO 聊起,而要了解什么是非阻塞 IO,又要先了解什么是阻塞 IO。

阻塞 IO

我们已经知道,Redis 客户端和服务端是基于 TCP 连接通过 RESP 协议进行通信的,这就涉及到调用 Socket API,以字符串键值对的 set 指令为例,Redis 服务端需要先监听客户端请求(bind/listen),接收到请求后和客户端建立连接(accept),然后从 Socket 中读取请求(recv)并解析客户端发送的请求(parse),再根据请求类型设置键值对数据(set),最后给客户端返回结果,即向 Socket 中写回数据(send)。

如果你不清楚 Socket 模型的基本实现,可以回顾下Socket 编程(上):套接字底层原理

整个过程中,除 set 是 Redis 键值对操作外,其他都是网络 IO 操作,图示如下:

1.jpeg

作为单线程,最基本的实现就是从前到后依次处理上面的所有操作,而这里面,有潜在的 IO 阻塞点,分别是 accept() 和 recv()。

当 Redis 服务端监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 服务端建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。另外,send() 方法默认不会阻塞,除非分配的缓冲区写满。

上面的 recv 方法和 send 方法可以对应到 Socket 模型中的 read 和 write 方法。

这就会导致整个 Redis 服务端线程阻塞,无法处理其他客户端请求,对于需要支持高并发请求处理的 Redis 服务而言,这是无法忍受的。

好在,Socket 网络模型本身支持非阻塞模式。

非阻塞 IO(NIO)

在 Socket 模型中,不同操作调用后会返回不同的套接字类型。

将主动套接字转化为监听套接字,就可以监听来自客户端的连接请求了,当客户端请求到来时,接收并返回已连接的客户端套接字。

我们可以将监听套接字设置为非阻塞模式,当接收但一直没有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。以上是针对 Redis 服务端监听套接字的非阻塞设置,对于已连接客户端套接字,也可以进行非阻塞设置。

这样一来,Redis 已连接套接字的读写操作就不会再有阻塞了。有了非阻塞 IO 意味着即便是单线程,在读写 IO 时也不会再阻塞了,读写可以瞬间完成然后继续干别的事情。

但是事情到这里并没有完,非阻塞 IO 虽然可以让单线程不再在阻塞点阻塞,从而提升并发处理请求和读写操作的性能,但是引入了另一个问题,就是监听套接字接收到客户端请求,或者已连接套接字读写操作完毕,需要有一个通知机制告知 Redis,以便监听和处理下一个请求。

这个时候,我们的 IO 多路复用机制就要闪亮登场了。

IO 多路复用机制

所谓 IO 多路复用就是一个线程处理多个 IO 流(在处理网络请求时,多个 IO 流即多个 Socket),在多路复用机制下,以单线程方式运行的 Redis 服务端,允许内核同时存在多个监听套接字和已连接套接字,内核会一直监听这些套接字上的连接请求或读写操作。一旦有请求到达或者数据读写完毕,就会交给 Redis 线程进行后续处理,否则的话,Redis 线程可以处理其他操作。这就实现了一个 Redis 线程处理多个 IO 流的效果。

现代操作系统一般都支持 IO 多路复用,并为其提供了两种实现方式 —— 事件轮询和事件通知。接下来,我们来看看这两种实现方式的细节。不同操作系统为事件轮询和通知提供的用户函数不同,但原理都是一样的,这里我们以 Linux 系统为例进行介绍。

事件轮询

事件轮询的最简单实现 API 就是 select 函数,它是操作系统提供给用户程序的 API:

read_events, write_events = select(read_fds, write_fds, timeout)

它会接收读写 fd(文件描述符,这里是套接字)和超时时间作为参数,然后有事件到来时返回对应的可读写事件。调用 select 函数会监听所有传入的套接字并以轮询方式扫描,在超时时间内,如果任意套接字有任何事件到来,就会立刻返回对应的事件进行处理,超过超时时间后,即使没有任何事件,也会立刻返回,避免长时间阻塞。拿到返回结果进行处理后,会继续轮询套接字,进入下一个循环。

select 实现简单,但是存在以下缺点:

  • 单进程可监听的 fd(文件描述符,这里是 Socket)数量有限,32 位机器上限是 1024,64 位是 2048;
  • 对 Socket 进行扫描采用的是线性轮询扫描,效率低,造成 CPU 资源的浪费(就像我们使用 Ajax 读取实时消息一样,在后端没有新的消息时存在资源浪费,同理,不是每个 Socket 上都有事件发生);
  • 需要维护一个用来存放大量 fd 的数据结构,这样会使得用户空间和内核空间在通信时传递该结构复制开销很大。

为了优化 select 实现,操作系统提供一个 poll 函数,它和 select 本质上都是通过事件轮询实现 IO 多路复用,不同之处在使用链表来替换原来存放 fd 的数据结构,解决了单进程监听套接字上限的问题,但是依然使用轮询,效率不高。

事件通知

select 和 poll 都需要通过遍历所有文件描述符来获取已经就绪的 Socket,可事实上,同时连接的客户端在同一时刻可能只有很少的套接字处于就绪状态,并且随着监听的文件描述符数量的增长,效率也会线性下降。

因此,现在操作系统的 IO 多路复用实现大多使用的是事件通知方式,在 Linux 系统中,这一实现对上层用户程序提供的 API 是 epoll 函数,Redis 也是使用这个函数实现 IO 多路复用的(后面介绍的 Nginx 也是)。

epoll 可以看做是 select 和 poll 的增强版,没有文件描述符的限制,消息在内核和用户空间传递时不需要拷贝,也不是通过事件轮询获取就绪的套接字,而是通过事件就绪通知的机制:通过 epoll_ctl 注册 fd,一旦该 fd 就绪,内核就会采用类似 callback 的回调机制来激活该 fd,epoll_wait 便可以收到通知,然后进行相应的处理。

可以将基于 select/epoll 实现 IO 多路复用类比为基于 Ajax/Websocket 实现实时消息系统来理解,前者都是基于轮询,后者都是基于回调触发的主动通知。

使用 epoll 实现的 IO 多路复用,理论上可以在 1G 内存中监听 10 万个套接字,即处理 10 万个客户端并发请求,所以说 epoll 是解决 C10K 问题的利器,一点也不会过。

小结

不管是事件轮询还是事件通知,Redis 为不同的 IO 事件注册了不同的回调函数,以便事件到达时执行对应的处理函数,在具体实现时,Redis 还提供了一个事件队列来处理事件,这样一来,就不需要去轮询是否有客户端请求,而只需要消费这个事件队列即可及时响应客户端请求,提升系统性能。

综合以上设计,当源源不断的、数以万计的客户端请求到达 Redis 服务端后,通过非阻塞 IO 和 IO 多路复用机制,Redis 仅以区区单线程之躯就可以同时应付这么多并发请求的处理。

0

评论区