【Redis】从IO多路复用到事件驱动框架

86 阅读12分钟

Socket模型

通常实现系统间网络通信的基本方法就是使用Socket编程模型。

    // 创建Socket、监听端口
    ServerSocket ss = new ServerSocket(8888); 
    
    // 处理连接
    Socket s = ss.accept();
    System.out.println("客户端:"+s.getInetAddress().getLocalHost()+"已连接到服务器"); 
    
    //读取客户端发送来的消息 
    BufferedReader br = new BufferedReader(new InputStreamReader(s.getInputStream())); 
    String mess = br.readLine(); 
    System.out.println("客户端:"+mess); 
    
    // 返回服务端消息
    BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(s.getOutputStream())); 
    bw.write(mess+"\n"); 
    bw.flush();

这段代码能够实现服务端和客户端之间的通信,但是accept每调用一次,只能处理一个客户端连接。如果想要并发处理客户端的请求,就需要多线程同时建立多个socket连接。

IO多路复用

在基本的Socket编程模型中,accept函数只能在一个监听套接字上监听客户端连接。而IO多路复用可以同时监听多个套接字上的请求,当有一个或多个套接字上有请求时,多路复用函数就会返回,交给程序进行处理。

Linux 针对每一个套接字都会有个一个文件描述符(一个非负整数),用来唯一标识套接字。在多路复用机制函数中,Linux使用文件描述符作为参数,有了文件描述符,函数就能找到对应的套接字,进而监听、读写操作。

文件描述符分为通信(调用通信函数和已建立连接的客户端通信)和监听(和客户端建立连接)。

Linux 提供的 IO 多路复用机制主要有三种,分别是 select、poll 和 epoll。

select

select 函数可以委托内核检测若干个文件描述符的状态,其实就是检测这些文件描述符对应的读写缓冲区的状态。

//nfds       三个集合中最大的文件描述符
//readfds    内核只检测这个集合中文件描述符对应的读缓存区
//writefds   内核只检测这个集合中文件描述符对应的写缓存区
//exceptfds  内核检测集合中文件描述符是否有异常状态
//timeout    阻塞时长
int select (int __nfds, fd_set *__readfds, fd_set *__writefds, fd_set *__exceptfds, struct timeval *__timeout)

select()函数中第2、3、4个参数都是 fd_set 类型,它表示一个文件描述符的集合,这个类型的数据有128个字节,也就是1024个标志位,内核中文件描述符表中的个数也是1024。

这块内存中的每一个bit 和 文件描述符表中的每一个文件描述符是一一对应的关系,此时集合中只要标志位的值为1,那么它对应的文件描述符肯定是就绪的,我们就可以基于这个文件描述符和客户端建立新连接或者通信了。

select函数基本流程:

image.png

小结:

  1. select函数对单个进程监听的文件描述符有数量限制,默认值1024
  2. select函数需要遍历文件描述符集合,才能找到是哪个描述符就绪,遍历过程中会阻塞进程。
  3. 待检测集合(第2、3、4个参数)需要频繁的在用户区和内核区之间进行数据的拷贝,效率低

poll

poll的机制与select类似,与select在本质上没有多大差别,使用方法也类似。

#include <poll.h>
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
    int   fd;         // 委托内核检测的文件描述符
    short events;     // 委托内核检测文件描述符的事件(输入、输出、错误)
    short revents;    // 文件描述符实际发生的事件 -> 传出(内核检测之后的结果)
};

struct pollfd myfd[100];
//fds     struct pollfd类型数组, 存储待检测的文件描述符的信息
//nfds    指定参数1数组的元素总个数
//timeout 阻塞时长
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

poll函数基本流程:

image.png

小结:

  1. 解决了监听文件描述符上限的问题
  2. poll函数仍然需要遍历每个文件描述符,检测文件描述符是否就绪,再进行处理
  3. 需要频繁的在用户区和内核区之间进行数据的拷贝,效率低

epoll

在epoll中一共提供是三个API函数,分别处理不同的操作,函数原型如下:

#include <sys/epoll.h>
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

1、使用 epoll 需要先调用 epoll_create 函数,创建一个 epoll 红黑树模型的实例。函数返回一个有效的文件描述符,通过这个文件描述符就可以访问创建的epoll实例。

#include <sys/epoll.h>
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);

2、再使用epoll_ctl函数管理(增删改)红黑树实例上的节点。

// 联合体, 多个变量共用同一块内存        
typedef union epoll_data {
 	void        *ptr;
	int          fd;	// 套接字的文件描述符
	uint32_t     u32;
	uint64_t     u64;
} epoll_data_t;

struct epoll_event {
	uint32_t     events;      //epoll监听的事件类型(读、写、异常事件)
	epoll_data_t data;        //应用程序数据
};

//epfd   epoll_ctl的返回值,通过这个参数找到epoll实例
//op     枚举值,控制epoll_ctl函数执行的操作(增删改)
//fd     要增删改的文件描述符
//event  epoll_event结构体,来记录待监听fd对应的的文件描述符及其监听的事件类型
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

3、最后使用 epoll_wait 函数检测并获取epoll实例中就绪的文件描述符。

//epfd       epoll_ctl的返回值,通过这个参数找到epoll实例
//events     传出参数(类型:结构体数组地址),记录了已经就绪的文件描述符信息
//maxevents  events数组的个数
//timeout    阻塞时长
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll函数基本流程:

image.png

小结:

  1. epoll使用回调机制处理就绪套接字,效率高,不会随着检测集合变大而下降
  2. 在epoll中内核和用户区使用的是共享内存(基于mmap内存映射区实现),省去了不必要的内存拷贝
  3. epoll可以直接得到已就绪的文件描述符集合,无需再次检测

对比

  • select:调用开销大(需要复制集合);集合大小有限制;需要遍历整个集合找到就绪的描述符
  • poll:poll 采用数组的方式存储文件描述符,没有最大存储数量的限制,其他方面和 select 没有区别
  • epoll:调用开销小(不需要复制);集合大小无限制;内核主动将就绪的文件标识符加到就绪的文件描述符列表,不需要遍历整个集合

Reactor模型

Reactor基础

Reactor模型是网络服务器端用来处理高并发网络IO请求的一种编程模型。总结起来分为:

三类处理事件:连接事件、写事件、读事件。

三个关键角色:reactor、acceptor、handler

处理事件

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

  • 连接事件:客户端要和服务器端进行交互时,客户端会向服务器端发送连接请求
  • 读事件:建立连接后,服务器端从客户端读取请求数据
  • 写事件:处理读请求时,服务端向客户端写回数据

关键角色

  • acceptor:负责接收连接,并创建Handler。
  • handler:处理读写事件
  • reactor:监听和分配事件,因为高并发场景中,连接事件、读写事件会同时发生。

Reactor模型:

image.png

事件驱动框架

事件驱动框架就是在实现Reactor模型时,用代码控制Reactor三类角色的交互逻辑。简单来说,事件驱动框架包括了2个部分:一是事件初始化,二是事件捕获、分发和处理主循环

事件初始化:服务器启动时执行,用来创建需要监听的事件类型,以及该类事件类型对应的Handler。

事件捕获、分发和处理主循环:通常会用一个 while 循环来作为这个主循环。在循环中捕获发生的事件、判断事件的类型、并根据事件类型,调用在初始化时创建好的事件Handler来实际处理事件。

Redis中Reactor模型实现

Redis 的网络框架实现了 Reactor 模型,并且自行开发实现了一个事件驱动框架。这个框架对应的 Redis 代码实现文件是ae.c,对应的头文件是ae.h

从 ae.h 头文件中可以看到,Redis 为了实现事件驱动框架,相应地定义了事件数据结构、框架主循环函数、事件捕获分发函数、事件和 handler 注册函数。

事件数据结构

Redis事件驱动框架实现中,事件的数据结构是关联事件类型和事件处理函数的关键要素。Redis的事件驱动框架定义了两类事件:IO事件(客户端发送的网络请求)和时间事件(Redis自身周期性操作)。

这也就是说,不同类型事件的数据结构定义是不一样的,此处以aeFileEvent举例。

typedef struct aeFileEvent {
    //事件类型掩码 枚举AE_READABLE|AE_WRITABLE|AE_BARRIER
    int mask;
    //指向AE_READABLE事件处理函数,即Reactor模型的Handler
    aeFileProc *rfileProc;
    //指向AE_WRITABLE事件处理函数,即Reactor模型的Handler
    aeFileProc *wfileProc;
    //指向客户端私有数据的指针
    void *clientData;
} aeFileEvent;

主循环:aeMain函数

aeMain 函是用一个循环不停地判断事件循环的停止标记。如果事件循环的停止标记不为true,那么一直处理事件捕获、分发和处理;

按照事件驱动框架的编程规范,框架主循环是在服务器程序初始化完成后,就会开始执行。Redis实现中,Redis server 的初始化后,会调用 aeMain 函数开始执行事件驱动框架。

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        if (eventLoop->beforesleep != NULL)
            eventLoop->beforesleep(eventLoop);
        aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
    }
}

事件捕获与分发:aeProcessEvents 函数

aeProcessEvents 函数实现的主要功能,包括捕获事件、判断事件类型和调用具体的事件处理函数,从而实现事件的处理。

int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
    int processed = 0, numevents;

    // 如果没有事件处理、直接返回
    if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;


    // 如果有IO发生或者强制执行Flag 则开始处理
    if (eventLoop->maxfd != -1 ||
            ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
            ...
            //调用aeApiPoll函数捕获事件 
            numevents = aeApiPoll(eventLoop, tvp);
            ...
    }
    // 检测是否有时间事件
    if (flags & AE_TIME_EVENTS)
        processed += processTimeEvents(eventLoop);

    //返回已经处理的文件或者时间事件数量
    return processed; 
}

当Redis感知有IO事件发生,此时会调用aeApiPoll函数,用来捕获事件。在ae_epoll.c文件中,aeApiPoll 函数就是封装了对 epoll_wait 的调用。

static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
    aeApiState *state = eventLoop->apidata;
    int retval, numevents = 0;
    
    // 调用epoll_wait获取监听到的事件
    retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,
            tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
    if (retval > 0) {
        int j;
        numevents = retval;
        // 监听到的事件数量 循环处理
        for (j = 0; j < numevents; j++) {
            int mask = 0;
            struct epoll_event *e = state->events+j;

            if (e->events & EPOLLIN) mask |= AE_READABLE;
            if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
            if (e->events & EPOLLERR) mask |= AE_WRITABLE;
            if (e->events & EPOLLHUP) mask |= AE_WRITABLE;
            // 判断事件类型 保存事件信息
            eventLoop->fired[j].fd = e->data.fd;
            eventLoop->fired[j].mask = mask;
        }
    }
    return numevents;
}

事件注册:aeCreateFileEvent 函数

当 Redis 启动后server.c,服务器程序的 main 函数会调用 initSever 函数来进行初始化,而在初始化的过程中,initServer 函数会根据启用的 IP 端口个数,为每个 IP 端口上的网络事件,调用 aeCreateFileEvent,创建对 AE_READABLE 事件的监听,并且注册 AE_READABLE 事件的处理 handler,也就是 acceptTcpHandler 函数。

那么,aeCreateFileEvent 如何实现事件和处理函数的注册呢?

Redis封装了 aeApiAddEvent 函数,对 epoll_ctl 进行调用。aeCreateFileEvent 就会调用 aeApiAddEvent,然后 aeApiAddEvent 再通过调用 epoll_ctl,来注册希望监听的事件和相应的处理函数。等到 aeProceeEvents 函数捕获到实际事件时,它就会调用注册的函数对事件进行处理了。

void initServer(void) {
    … 
    for (j = 0; j < server.ipfd_count; j++) { 
    if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE, acceptTcpHandler,NULL) == AE_ERR) {
    serverPanic("Unrecoverable error creating server.ipfd file event."); }
    } 
    … 
}

Redis是单线程程序吗?

Redis Server启动后,接收客户端请求、解析请求、数据读写操作都是单线程来执行的。但整个 Redis Server 并不是单线程的,还有后台线程在辅助处理一些工作(AOF同步写、惰性删除等)。

Redis 选择单线程也能效率这么高?

  • Redis 的操作是内存操作
  • 采用了高效的数据结构
  • Redis 基于 Reactor 模型,实现了高性能的网络框架
  • 没有多线程的并发问题

Redis6 多 IO 线程机制

Redis 6.0 中新设计实现的多 IO 线程机制。这个机制的设计主要是为了使用多个 IO 线程,来并发处理客户端读取数据、解析命令和写回数据。使用了多线程后,Redis 就可以充分利用服务器的多核特性,从而提高 IO 效率

但是多 IO 线程本身并不会执行命令,它们只是利用多核并行地读取数据和解析命令,或是将 server 数据写回。

所以,Redis 执行命令的线程还是主 IO 线程并不是Redis 有多线程同时执行命令。

最后

本文是Redis系列的第五篇,这个系列会系统全面的梳理Redis的知识体系,如果有遗漏或者错误,欢迎留言沟通交流。