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


前面学院君给大家介绍了 Redis 服务器在处理客户端请求时使用的是单线程 IO 模型,以及为什么选择使用单线程 IO 模型,其实不光是 Redis,Nginx、Node.js 都使用的是这个 IO 模型来实现高性能服务器。

今天,我们就来看看 Redis 是如何设计这个单线程 IO 模型来确保并发时的高性能的。原来,Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,从而实现高吞吐率。

接下来,我们就来看看什么是多路复用机制。学院君在网络协议 Socket 编程时曾经简单介绍过多路 IO 复用,其实和这里的多路复用机制是一个东西,我们这里来系统介绍下这个机制。

从非阻塞 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 操作,图示如下:

16098490570192

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

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

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

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

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

非阻塞 IO(NIO)

在 Socket 模型中,不同操作调用后会返回不同的套接字类型。以上篇教程模拟 Redis 服务端的 Socket 代码为例:

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, $host, $port);
socket_listen($socket);
while (true) {
    $client = socket_accept($socket);
    ...

socket_create() 方法会返回主动套接字,然后调用 socket_listen() 方法,将主动套接字转化为监听套接字,此时,就可以监听来自客户端的连接请求了,当客户端请求到来时,可以通过调用 socket_accept() 方法接收,并返回已连接的客户端套接字。

针对监听套接字,我们可以将其设置为非阻塞模式,还是以 PHP Socket 代码为例,我们可以通过调用 socket_set_nonblock 函数将其设置为非阻塞模式:

...
socket_listen($socket);
socket_set_nonblock($socket);
...

注:以上代码基于 PHP 进行演示,其他语言实现可能不同,但是原理和思路一致。

这样一来,当调用 socket_accept() 但一直没有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。以上是针对 Redis 服务端监听套接字的非阻塞设置,对于已连接客户端套接字,也可以调用 socket_set_nonblock 函数进行非阻塞设置:

$client = socket_accept($socket);
socket_set_nonblock($client);

...

socket_read($client, 1024, PHP_NORMAL_READ)

...

socket_write($client, $response, strlen($response));

...

这样一来,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 的数据结构,解决了单进程监听套接字上限的问题,但是依然使用轮询,效率不高。

事件通知

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

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

epoll 可以看做是 selectpoll 的增强版,没有文件描述符的限制,消息在内核和用户空间传递时不需要拷贝,也不是通过事件轮询获取就绪的套接字,而是通过事件就绪通知的机制:通过 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 仅以区区单线程之躯就可以同时应付这么多并发请求的处理。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: Redis 客户端与服务端通信协议 RESP 详解及 predis 扩展实现原理

>> 下一篇: Redis 常见数据结构的底层实现系列(一):全局哈希表