Event-driven model of Redis

Redis is a database of Client-Server architecture, and its source code is indispensable for realizing network communication. And you should also be clear that the basic method for the system to implement network communication is to use the Socket programming model, including creating Sockets, listening ports, processing connection requests and read and write requests. However, since the basic Socket programming model can only handle requests on one client connection at a time, when dealing with high concurrent requests, one solution is to use multiple threads, with each thread responsible for handling a client's request.

However, Redis has only one thread responsible for parsing and processing client requests. If the basic Socket model is directly adopted, it will affect Redis's support for high concurrent client access.

Therefore, in order to achieve high concurrent network communication, our commonly used Linux operating system provides three programming models: select, poll and epoll, and Redis running on Linux usually uses the epoll model for network communication. .

Why does Redis usually choose the epoll model? What is the difference between these three programming models?

To understand the advantages of select, poll and epoll, we need a basis for comparison, which is the basic Socket programming model. So next, let's take a look at the basic Socket programming model and its shortcomings.

Why doesn't Redis use the basic socket programming model?

When using the Socket model to implement network communication, you need to go through multiple steps such as creating a Socket, listening to a port, processing connections, and reading and writing requests. Now let's take a detailed look at the key operations in these steps to help us analyze the Socket model. insufficient.

First of all, when we need to let the server side communicate with the client side, we can create a listening socket (Listening Socket) that listens for client connections through the following three steps on the server side:

  1. Call the socket function to create a socket. We usually call this socket an Active Socket;
  2. Call the bind function to bind the active socket to the IP and listening port of the current server;
  3. Call the listen function to convert the active socket into a listening socket and start listening for client connections.

After completing the above three steps, the server can receive the connection request from the client. In order to receive the client's connection request in a timely manner, we can run a loop process in which the accept function is called to receive the client's connection request.

What you need to pay attention to here is that the accept function is a blocking function, that is, if there is no client connection request at this time, the execution process on the server side will always be blocked in the accept function. Once a client connection request arrives, accept will no longer block, but process the connection request, establish a connection with the client, and return the connected socket (Connected Socket).

Finally, the server can receive and process read and write requests on the connected socket just returned by calling the recv or send functions, or send data to the client.

Code:

listenSocket = socket(); //调用socket系统调用创建一个主动套接字
bind(listenSocket); //绑定地址和端口
listen(listenSocket); //将默认的主动套接字转换为服务器使用的被动套接字,也就是监听套接字
while(1) { //循环监听是否有客户端连接请求到来
connSocket = accept(listenSocket);//接受客户端连接
recv(connSocket);//从客户端读取数据,只能同时处理一个客户端
send(connSocket);//给客户端返回数据,只能同时处理一个客户端
}
复制代码

However, from the above code, you may find that although it can realize the communication between the server and the client, each time the program calls the accept function, it can only handle one client connection. Therefore, if we want to handle multiple concurrent client requests, we need to use multithreading to handle requests on multiple client connections established through the accept function.

After using this method, we need to create a thread after the accept function returns the connected socket, and pass the connected socket to the created thread, which is responsible for subsequent data reading on the connected socket Write. At the same time, the server-side execution process will call the accept function again to wait for the next client connection.

Multithreading:

listenSocket = socket(); //调用socket系统调用创建一个主动套接字
bind(listenSocket); //绑定地址和端口
listen(listenSocket); //将默认的主动套接字转换为服务器使用的被动套接字,也就是监听套接字
while(1) { //循环监听是否有客户端连接请求到来
connSocket = accept(listenSocket);//接受客户端连接
pthread_create(processData, connSocket);//创建新线程对已连接套接字进行处理
}

processData(connSocket){
recv(connSocket);//从客户端读取数据,只能同时处理一个客户端
send(connSocket);//给客户端返回数据,只能同时处理一个客户端
}
复制代码

Although this method can improve the concurrent processing capability of the server, the main execution process of Redis is executed by one thread, and multi-threading cannot be used to improve the concurrent processing capability. Therefore, this method does not work for redis.

Are there any other ways to help Redis improve the processing power of concurrent clients? This uses the IO multiplexing function provided by the operating system. In the basic Socket programming model, the accept function can only listen to the client's connection on one listening socket, and the recv function can only be on a connected socket, waiting for the request sent by the client.

因为 Linux 操作系统在实际应用中比较广泛,所以这节课,我们主要来学习 Linux 上的 IO 多路复用机制。Linux 提供的 IO 多路复用机制主要有三种,分别是 select、poll 和 epoll。下面,我们就分别来学习下这三种机制的实现思路和使用方法。然后,我们再来看看,为什么 Redis 通常是选择使用 epoll 这种机制来实现网络通信。

select 和 poll 机制实现 IO 多路复用

首先,我们来了解下 select 机制的编程模型。

不过在具体学习之前,我们需要知道,对于一种 IO 多路复用机制来说,我们需要掌握哪些要点,这样可以帮助我们快速抓住不同机制的联系与区别。其实,当我们学习 IO 多路复用机制时,我们需要能回答以下问题:第一,多路复用机制会监听套接字上的哪些事件?第二,多路复用机制可以监听多少个套接字?第三,当有套接字就绪时,多路复用机制要如何找到就绪的套接字?

select机制

select 机制中的一个重要函数就是 select 函数。对于 select 函数来说,它的参数包括监听的文件描述符数量__nfds、、被监听描述符的三个集合readfds、writefds、exceptfds,以及监听时阻塞等待的超时时长timeout。select函数原型:

int select(int __nfds, fd_set *__readfds, fd_set *__writefds, fd_set *__exceptfds, struct timeval *__timeout)
复制代码

这里你需要注意的是,Linux 针对每一个套接字都会有一个文件描述符,也就是一个非负整数,用来唯一标识该套接字。所以,在多路复用机制的函数中,Linux 通常会用文件描述符作为参数。有了文件描述符,函数也就能找到对应的套接字,进而进行监听、读写等操作。

select函数三个参数表示的是,被监听描述符的集合,其实就是被监听套接字的集合。那么,为什么会有三个集合呢?

刚才提出的第一个问题相关,也就是多路复用机制会监听套接字上的哪些事件。select 函数使用三个集合,表示监听的三类事件,分别是读数据事件,写数据事件,异常事件。

我们进一步可以看到,参数 readfds、writefds 和 exceptfds 的类型是 fd_set 结构体,它主要定义部分如下所示。其中,fd_mask类型是 long int 类型的别名,__FD_SETSIZE 和 __NFDBITS 这两个宏定义的大小默认为 1024 和 32。

所以,fd_set 结构体的定义,其实就是一个 long int 类型的数组,该数组中一共有 32 个元素(1024/32=32),每个元素是 32 位(long int 类型的大小),而每一位可以用来表示一个文件描述符的状态。了解了 fd_set 结构体的定义,我们就可以回答刚才提出的第二个问题了。select 函数对每一个描述符集合,都可以监听 1024 个描述符。

如何使用 select 机制来实现网络通信

首先,我们在调用 select 函数前,可以先创建好传递给 select 函数的描述符集合,然后再创建监听套接字。而为了让创建的监听套接字能被 select 函数监控,我们需要把这个套接字的描述符加入到创建好的描述符集合中。

然后,我们就可以调用 select 函数,并把创建好的描述符集合作为参数传递给 select 函数。程序在调用 select 函数后,会发生阻塞。而当 select 函数检测到有描述符就绪后,就会结束阻塞,并返回就绪的文件描述符个数。

那么此时,我们就可以在描述符集合中查找哪些描述符就绪了。然后,我们对已就绪描述符对应的套接字进行处理。比如,如果是 readfds 集合中有描述符就绪,这就表明这些就绪描述符对应的套接字上,有读事件发生,此时,我们就在该套接字上读取数据。

而因为 select 函数一次可以监听 1024 个文件描述符的状态,所以 select 函数在返回时,也可能会一次返回多个就绪的文件描述符。这样一来,我们就可以使用一个循环流程,依次对就绪描述符对应的套接字进行读写或异常处理操作。

select函数有两个不足

  • 首先,select 函数对单个进程能监听的文件描述符数量是有限制的,它能监听的文件描述符个数由 __FD_SETSIZE 决定,默认值是 1024。

  • 其次,当 select 函数返回后,我们需要遍历描述符集合,才能找到具体是哪些描述符就绪了。这个遍历过程会产生一定开销,从而降低程序的性能。

poll机制

poll 机制的主要函数是 poll 函数,我们先来看下它的原型定义,如下所示:

int poll(struct pollfd *__fds, nfds_t __nfds, int __timeout)
复制代码

其中,参数 *__fds 是 pollfd 结构体数组,参数 __nfds 表示的是 *__fds 数组的元素个数,而 __timeout 表示 poll 函数阻塞的超时时间。

pollfd 结构体里包含了要监听的描述符,以及该描述符上要监听的事件类型。这个我们可以从 pollfd 结构体的定义中看出来,如下所示。pollfd 结构体中包含了三个成员变量 fd、events 和 revents,分别表示要监听的文件描述符、要监听的事件类型和实际发生的事件类型。

pollfd 结构体中要监听和实际发生的事件类型,是通过以下三个宏定义来表示的,分别是 POLLRDNORM、POLLWRNORM 和 POLLERR,它们分别表示可读、可写和错误事件。

了解了 poll 函数的参数后,我们来看下如何使用 poll 函数完成网络通信。这个流程主要可以分成三步:

  • 第一步,创建 pollfd 数组和监听套接字,并进行绑定;
  • 第二步,将监听套接字加入 pollfd 数组,并设置其监听读事件,也就是客户端的连接请求;
  • 第三步,循环调用 poll 函数,检测 pollfd 数组中是否有就绪的文件描述符。

而在第三步的循环过程中,其处理逻辑又分成了两种情况:

  • 如果是连接套接字就绪,这表明是有客户端连接,我们可以调用 accept 接受连接,并创建已连接套接字,并将其加入 pollfd 数组,并监听读事件;

  • 如果是已连接套接字就绪,这表明客户端有读写请求,我们可以调用 recv/send 函数处理读写请求。

其实,和 select 函数相比,poll 函数的改进之处主要就在于,它允许一次监听超过 1024 个文件描述符。但是当调用了 poll 函数后,我们仍然需要遍历每个文件描述符,检测该描述符是否就绪,然后再进行处理。

epoll机制

首先,epoll 机制是使用 epoll_event 结构体,来记录待监听的文件描述符及其监听的事件类型的,这和 poll 机制中使用 pollfd 结构体比较类似。

那么,对于 epoll_event 结构体来说,其中包含了 epoll_data_t 联合体变量,以及整数类型的 events 变量。epoll_data_t 联合体中有记录文件描述符的成员变量 fd,而 events 变量会取值使用不同的宏定义值,来表示 epoll_data_t 变量中的文件描述符所关注的事件类型,比如一些常见的事件类型包括以下这几种。

  • EPOLLIN:读事件,表示文件描述符对应套接字有数据可读。
  • EPOLLOUT:写事件,表示文件描述符对应套接字有数据要写。
  • EPOLLERR:错误事件,表示文件描述符对于套接字出错。

在使用 select 或 poll 函数的时候,创建好文件描述符集合或 pollfd 数组后,就可以往数组中添加我们需要监听的文件描述符。

但是对于 epoll 机制来说,我们则需要先调用 epoll_create 函数,创建一个 epoll 实例。这个 epoll 实例内部维护了两个结构,分别是记录要监听的文件描述符和已经就绪的文件描述符,,而对于已经就绪的文件描述符来说,它们会被返回给用户程序进行处理。

所以,我们在使用 epoll 机制时,就不用像使用 select 和 poll 一样,遍历查询哪些文件描述符已经就绪了。这样一来, epoll 的效率就比 select 和 poll 有了更高的提升。

在创建了 epoll 实例后,我们需要再使用 epoll_ctl 函数,给被监听的文件描述符添加监听事件类型,以及使用 epoll_wait 函数获取就绪的文件描述符。

了解了 epoll 函数的使用方法了。实际上,也正是因为 epoll 能自定义监听的描述符数量,以及可以直接返回就绪的描述符,Redis 在设计和实现网络通信框架时,就基于 epoll 机制中的 epoll_create、epoll_ctl 和 epoll_wait 等函数和读写事件,进行了封装开发,实现了用于网络通信的事件驱动框架,从而使得 Redis 虽然是单线程运行,但是仍然能高效应对高并发的客户端访问。

Reactor 模型的工作机制

Reactor 模型就是网络服务器端用来处理高并发网络 IO 请求的一种编程模型,模型特征:

  • 三类处理事件,即连接事件、写事件、读事件;
  • 三个关键角色,即 reactor、acceptor、handler。

Reactor 模型处理的是客户端和服务器端的交互过程,而这三类事件正好对应了客户端和服务器端交互过程中,不同类请求在服务器端引发的待处理事件:

  • 当一个客户端要和服务器端进行交互时,客户端会向服务器端发送连接请求,以建立连接,这就对应了服务器端的一个链接事件

  • 一旦连接建立后,客户端会给服务器端发送读请求,以便读取数据。服务器端在处理读请求时,需要向客户端写回数据,这对应了服务器端的写事件

  • 无论客户端给服务器端发送读或写请求,服务器端都需要从客户端读取请求内容,所以在这里,读或写请求的读取就对应了服务器端的读事件

三个关键角色:

  • 首先,连接事件由 acceptor 来处理,负责接收连接;acceptor 在接收连接后,会创建 handler,用于网络连接上对后续读写事件的处理;

  • 其次,读写事件由 handler 处理;

  • 最后,在高并发场景中,连接事件、读写事件会同时发生,所以,我们需要有一个角色专门监听和分配事件,这就是 reactor 角色。当有连接请求时,reactor 将产生的连接事件交由 acceptor 处理;当有读写请求时,reactor 将读写事件交由 handler 处理。

那么,现在我们已经知道,这三个角色是围绕事件的监听、转发和处理来进行交互的,那么在编程时,我们又该如何实现这三者的交互呢?这就离不开事件驱动。

所谓的事件驱动框架,就是在实现 Reactor 模型时,需要实现的代码整体控制逻辑。简单来说,事件驱动框架包括了两部分:一是事件初始化,二事件捕获,分化和处理主循环。

事件初始化是在服务器程序启动时就执行的,它的作用主要是创建需要监听的事件类型,以及该类事件对应的 handler。而一旦服务器完成初始化后,事件初始化也就相应完成了,服务器程序就需要进入到事件捕获、分发和处理的主循环中。

用while循环来作为这个主循环。然后在这个主循环中,我们需要捕获发生的事件、判断事件类型,并根据事件类型,调用在初始化时创建好的事件 handler 来实际处理事件。

比如说,当有连接事件发生时,服务器程序需要调用 acceptor 处理函数,创建和客户端的连接。而当有读事件发生时,就表明有读或写请求发送到了服务器端,服务器程序就要调用具体的请求处理函数,从客户端连接中读取请求内容,进而就完成了读事件的处理。

Reactor 模型的基本工作机制:客户端的不同类请求会在服务器端触发连接、读、写三类事件,这三类事件的监听、分发和处理又是由 reactor、acceptor、handler 三类角色来完成的,然后这三类角色会通过事件驱动框架来实现交互和事件处理。

Guess you like

Origin juejin.im/post/7102692279076257806