Redis事件

学习路线

学习redis事件路线图如下:redis事件 - reactor模式 - IO多路复用 - IO底层原理 - IO多路复用 - reactor模式 - redis事件。归根结底是基于内核提供IO多路复用支持的reactor模式,redis事件基于reactor模式实现了自己的网络事件处理器。所以文章从IO底层原理讲起,了解IO的四种模式,介绍reactor模式,再学习redis的事件驱动程序是如何去设计的。本文参考书籍为《操作系统》、《netty、Redis、Zookeeper高并发实战》、《Redis设计与实现》,参考代码为Redis源码(C实现)。

IO底层原理

linux系统的大致组成模块如下图所示。在用户空间执行IO操作不会直接读写到物理设备上,用户空间也不能直接访问物理设备。内核空间通过各类硬件设备的驱动和虚拟系统层来让上层拥有访问到硬件设备。为了统一多类设备的接口,内核空间定义了各类系统调用供上层使用(如read系统调用和write系统调用),即系统调用层。
在这里插入图片描述
用户空间执行IO操作时实际是进程缓冲区和内核空间缓冲区之间的数据复制传输。为什么提出缓冲区的概念,此处设计一些操作系统的知识。是因为如果内核空间向物理设备写入数据涉及到了操作系统的中断,操作系统在执行中断之前要保存之前的进程信息和状态,在执行完之后恢复进程信息和状态,如果每次执行IO操作都发生此类事件将会造成极大的时间和性能损耗。增加缓冲区就是为了避免频繁的和硬件设备进行交互。
用户空间执行IO操作调用内核空间提供的系统调用read时,实际上是检查内核缓冲区的数据是否准备好,如果准备好则将数据从内核缓冲区复制到进程缓冲区。当用户空间执行write系统调用时是将用户空间进程缓冲区里的数据复制到内核缓冲区。当内核缓冲区的数据到达一定的数量或者过了默认时间后,内核会将内核缓冲区的数据一次性的传输到物理设备中。
用户进程执行read系统调用时,分为两个阶段:
1. 等待内核缓冲区内的数据准备就绪
2. 从内核缓冲区向进程缓冲区复制数据
例如当从一个socket读取数据时,首先数据会从网络中通过网卡传输到内核缓冲区,进而复制到进程缓冲区,读到数据。
根据是否等待数据准备就绪将IO分为阻塞IO和非阻塞IO,根据数据传输的主动方是否是用户进程又可以将IO分为同步IO和异步IO。

IO模型

IO的四种常见模型使用同步异步和阻塞非阻塞进行分类。站在不同的角度可以有不同的理解。这里站在用户空间的角度理解。
阻塞与否是指在执行系统调用时,是否等待数据准备就绪返回结果。阻塞时调用系统调用read,会阻塞等待数据从未就绪到就绪,进而从内核到进程缓冲区复制数据,复制完成则返回结果。非阻塞是指当数据还没有准备就绪的情况下立即返回到用户空间执行用户操作。阻塞指的是用户空间程序的执行状态。
是否同步根据是谁主动的发起IO操作。同步IO是指由用户空间的线程主动发起IO操作,而异步IO是指由内核主动发起IO操作请求。

同步阻塞IO

同步阻塞IO指用户空间线程发起IO请求,直到IO请求返回结果之前,用户空间线程一直处于阻塞状态,返回结果后,继续执行缓冲区数据的处理。
优点:简单,在阻塞的过程中不会占用gpu。
缺点:一个进程只能处理一个IO请求。当并发高时需要大量的线程处理IO请求,线程的创建销毁与调度极其消耗资源。
在Linux下socket默认为阻塞模式。

同步非阻塞IO

同步非阻塞指用户空间线程发起IO请求,在数据没有准备好的情况下立即返回状态,进而继续执行用户空间线程程序。当数据变得可用之后,再阻塞的复制数据到进程缓冲区,复制完成后内核返回结果给用户进程,进而继续执行。
同步非阻塞虽然是非阻塞立即返回状态,但是为了确定数据是否准备好,要不停的查询数据是否准备好。
优点:可以立即返回执行结果,不会阻塞
缺点:要一直查询状态,占用cpu
在实际生产中很少会直接使用同步非阻塞IO,一般是在其他的IO模型中使用同步非阻塞IO的特性。

IO多路复用

为了解决一直查询状态,IO多路复用模式便诞生了。IO多路复用模式是基于linux的几个特性而实现的。所以在彻底明白IO多路复用前建议了解一下linux的select/epoll特性,这里简述这两个特性,不详细说明。
linux提供select/epoll系统调用的原因:为了支持百万级别的IO请求。在之前的模式中为了支持高并发,只能在用户空间创建大量的线程或者进程来支持,但是其性能损耗和程序的复杂度都大大增加,而且并发量非常的低。所以为了解决这个问题,以在用户空间使用一个进程就可以监控成百上千的IO请求为目的,linux内核提供了这两个系统调用。
select系统调用可以同时监控多个文件描述符,查看文件描述符是否处于就绪状态(包括可连接,可写,可读等),只要文件描述符处于就绪状态,就会返回相应的状态。监控的文件描述符使用队列存储。
epoll是select系统调用的升级版本,其中将队列换成了红黑树提高监控效率。
IO多路复用模式利用了内核提供的这些系统调用,在用户空间使用一个进程就可以监控成百上千的socket,达到高并发的需求。流程如下:

  1. 将要监控的socket网络连接注册到select/epoll选择器中。注册完成selct/epoll便会监控socket描述符是否处于就绪状态,例如可连接状态、可写状态或者可读状态。
  2. 轮训监控sockt描述符是否处于就绪状态,某个socket处于就绪状态,通过socket传输的数据传入到内核缓冲区,此socket便被加入到了就绪列表中。这一步是在操作系统内核中完成的,是感知不到的。
  3. 用户空间线程调用select系统调用会返回已经就绪的socket描述符列表,系统调用select是阻塞的,当返回结果后才会回到用户空间线程继续执行。
  4. 用户空间获取到就绪的socket文件描述符后,根据socket描述符的请求执行相应的逻辑处理。例如就绪的socket是要执行read系统调用,则执行read系统,用户空间阻塞,直到数据从内核缓冲区复制到用户进程缓冲区。
  5. 用户获取数据后继续处理,执行逻辑处理之后,继续执行下一个就绪socket描述符。

IO多路复用中的socket一般都会设置成同步非阻塞IO。在Redis的源码中可以看到每个socket在注册之前都会被显示的设置成同步非阻塞IO。
优点:IO多路复用模型使用一个进程一个线程便可以监控成百上千个文件描述符,避免了在用户空间创建多个线程耗费资源和损耗性能。
缺点:select\epoll方法都是阻塞的,在等待IO系统调用(如read/write)执行完成后,才会执行下一次。

异步IO

为了彻底解决线程被阻塞问题,可以使用异步IO模型,异步IO模型在执行IO操作后会立即返回,由内核完成数据的准备,复制到用户空间内核。当复制完成后,使用信号量的方式或者回调的方式返回到用户空间线程继续执行相关处理。
异步IO目前在linux版本并没有得到很好的完善,性能和IO多路复用模型是差不多的,大多数框架还是使用IO多路复用模型作为IO模型选型,例如Reids,Java的Netty等。

reactor模式

在介绍reactor模式之前,了解一下最原始的网络服务器程序。伪代码如下所示:

while(true) {
	socket = serversocket.accept()  // 获取一个监听到的socket
	Handler(socket)  // 读取数据,进行业务处理, 写入数据
}

上述程序最大的问题是同时只能处理一个socket连接。如果Handler处理业务逻辑时间比较长,后续的socket一直被阻塞,无法处理。为了解决这个问题出现了单线程单连接机制。

单线程单连接

顾名思义每个线程处理一个socket连接,已解决docket一直被阻塞的问题。伪代码如下所示:

class ConnectionPerThread implements Runnable {
	public void run() {
	    ServerSocket serverSocket = ServerSocket(Port);  // 初始化服务socket
	    while(!Thread.interrupted()) {
	    	socket = serverSocket.accept();  // 获取新的socket连接
	    	Hander hander = New Handler(socket);  // 将socket和处理器进行连接
	    	 new Thread(handler).start();  // 创建新的线程执行handler处理器
	    }
	}
	static class Handler implement Runnable {
		final Socket socket;
		Handler(Socket s) {
			socket = s;
		}
		public void run(){
			byte[] bytes = socket.read()  // 读取数据
			socket.write(bytes)  // 写入数据
		}
	}
}

通过上述实现方式可以完成单线程单连接的模型搭建网络服务程序,并且解决了socket因为之前的socket处理业务逻辑而被阻塞不能请求的问题。带来的问题是如果并发量稍微大一点会创建多个线程,多线程的创建,销毁与切换调度带来了极大的资源浪费与性能损耗。为了解决这个问题,reactor模式便诞生了。

单线程reactor

reactor反应器模式由reactor反应器和handler处理器两个模块组成。先打出reactor反应器模式的官方定义:

  1. Reactor反应器:负责查询IO事件,并且分发到handler处理器
  2. Headler处理器:负责非阻塞的业务处理逻辑
    在单线程reactor中的结构如图:
    在这里插入图片描述

单线程reactor的意思就是reactor反应器和handler处理器处于同一个线程之中。
reactor反应器的主要功能就是监听所有注册到监听队列的socket描述符,也就是IO事件,当有IO事件发生时,将IO事件发送给之前绑定好的对应handler处理器处理。
handler处理器负责真正的socket请求连接,IO数据的读取,业务逻辑处理和将结果写出到socket。
下面是用代码做深入理解,先介绍每个代码块的含义,最后给出完整代码。代码使用java语言,利用了java的nio库中的selector方法和SelectionKey结构。其中Selector方法是底层系统调用的封装,可以获取目前就绪的IO事件的数量。SelectionKey为注册socket到Selector选择器后保存socket对应信息的对象。其保存了描述符,处理器等信息。
还有一个用到的是SelectionKey对象提供的接口:attach()和attachment()
attach()接口可以将某个对象作为附件保存到SelectionKey的属性中。起作用就是把IO事件对应的handler处理器保存到SelectionKey的属性中。
attachment()方法则相反,是将已经保存到SelectionKey中的附件取出来。

示例实现的是一个叫Echo的服务器程序,就是接收客户端发来的信息,并打印到控制台。先来看一下ServerReactor对象实现的reactor反应器
构造函数:

public class EchoServerReactor implements Runnable{
    Selector selector;
    ServerSocketChannel serverSocket;

    EchoServerReactor() throws IOException {
    	// 创建选择器Selector
        selector = Selector.open();
        // 创建Serversocket,用来获取连接到server的客户端socket,将socket设置成非阻塞模式,并且绑定地址
        serverSocket = ServerSocketChannel.open();
        serverSocket.configureBlocking(false);
        serverSocket.bind(new InetSocketAddress(8000));
        // 将serverSocket注册到选择器Selector中,并且指定IO时间为accept事件(接收其他socket连接)
        // 注册后将返回SelectionKey对象实例
        SelectionKey sk = serverSocket.register(selector, SelectionKey.OP_ACCEPT);
        // 将内部AcceptorHandler对象注册到sk的附加属性上。以便将handler处理器和socket做对应
        sk.attach(new AcceptorHander());
    }
    //...
}

主函数:

public class EchoServerReactor implements Runnable{
    Selector selector;
    ServerSocketChannel serverSocket;
 	//...
    public static void main(String[] args) throws IOException {
        new Thread(new EchoServerReactor()).start();
    }
	
    @Override
    public void run() {
        try {
            while (!Thread.interrupted()){
            	// 调用select的slector方法,返回就绪IO事件的socket数目
                selector.select();
                // 返回已经就绪的socket描述符对应的SelectionKey结构列表,然后对列表叫进行遍历。
                Set<SelectionKey> selected= selector.selectedKeys();
                Iterator<SelectionKey> iterator = selected.iterator();
                while (iterator.hasNext()){
                    SelectionKey sk = iterator.next();
                    // 调度sk
                    dispatch(sk);
                }
                selected.clear();
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }

调度

public class EchoServerReactor implements Runnable{
    Selector selector;
    ServerSocketChannel serverSocket;

	//...

    private void dispatch(SelectionKey sk) {
    	// 调度sk实际上是取出之前绑定的Handler处理器,然后执行Handler处理器,
        Runnable handler = (Runnable) sk.attachment();
        if (handler != null){
            handler.run();
        }
    }

    private class AcceptorHander implements Runnable{
        @Override
        // AcceptorHander处理器是serversocket对应的handler处理器。当由socket请求连接时,便会触发连接IO事件。
        // 此时serversocket处于就绪状态,于是被actor反应器监听到并且执行对应的,也就是此处理器。
        public void run() {
            try {
            	// 通过sererSocket获取连接到server的客户端socket
                SocketChannel channel = serverSocket.accept();
                if (channel != null){
                	//执行socket对应的handler处理器,同时将socket和select选择器传入到处理器。
                    new EchoHandler(selector,channel);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

进而看一下客户端socket对应的handler处理器。

public class EchoHandler implements Runnable{
    final SocketChannel channel;
    final SelectionKey sk;
    final ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    static final int RECIEVING = 0, SENDGING = 1;
    int state = RECIEVING;
    public EchoHandler(Selector selector, SocketChannel c) throws IOException {
        channel = c;
       	// 将客户socket设置成非阻塞模式,注册到selector选择器
        c.configureBlocking(false);
        sk = c.register(selector,0);
        // 将自己这个对象保存在socket对应的selectionKey中的附件属性。当客户端socket对服务端发送请求时,在服务端发生IO事件,
        // reactor反应器识别到就绪的IO事件,将socket传入到对应的Handler处理器去执行。
        sk.attach(this);
        sk.interestOps(SelectionKey.OP_READ);
        selector.wakeup();
    }


    @Override
    public void run() {
        try {
            if (state == SENDGING){
            	// 向客户端发送数据
                channel.write(byteBuffer);
                byteBuffer.clear();
                sk.interestOps(SelectionKey.OP_READ);
                state = RECIEVING;
            }else if (state ==RECIEVING){
                int length = 0;
                // read客户端发过来的数据,将数据打印到屏幕上。其实read之前就已经保存在了用户空间的内核缓冲区。
                while ((length = channel.read(byteBuffer)) > 0){
                    System.out.println(new String(byteBuffer.array(),0,length));
                }
                byteBuffer.flip();
                sk.interestOps(SelectionKey.OP_WRITE);
                state = SENDGING;
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

通过上面的例子可以看出,reactor反应器模式的两大模块实现原理。整体的流程就是之前提到的reactor处理器监听就绪的IO事件,当IO事件就绪时,reactor处理器将产生IO事件的socket发送到之前注册好的handler处理器,处理业务逻辑。
下面列出的是client的实现代码,逻辑是一样的,连接server地址,监听就绪IO事件,执行相应的业务处理逻辑。

package reactor;


import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Scanner;
import java.util.Set;

/**
 * create by 尼恩 @ 疯狂创客圈
 **/
public class EchoClient {

    public void start() throws IOException {

        InetSocketAddress address =
                new InetSocketAddress("127.0.0.1",8000);

        // 1、获取通道(channel)
        SocketChannel socketChannel = SocketChannel.open(address);
        // 2、切换成非阻塞模式
        socketChannel.configureBlocking(false);
        //不断的自旋、等待连接完成,或者做一些其他的事情
        while (!socketChannel.finishConnect()) {

        }
        System.out.println("客户端启动成功!");

        //启动接受线程
        Processer processer = new Processer(socketChannel);
        new Thread(processer).start();

    }

    static class Processer implements Runnable {
        final Selector selector;
        final SocketChannel channel;

        Processer(SocketChannel channel) throws IOException {
            //Reactor初始化
            selector = Selector.open();
            this.channel = channel;
            channel.register(selector,
                    SelectionKey.OP_READ | SelectionKey.OP_WRITE);
        }

        public void run() {
            try {
                while (!Thread.interrupted()) {
                    selector.select();
                    Set<SelectionKey> selected = selector.selectedKeys();
                    Iterator<SelectionKey> it = selected.iterator();
                    while (it.hasNext()) {
                        SelectionKey sk = it.next();
                        if (sk.isWritable()) {
                            ByteBuffer buffer = ByteBuffer.allocate(1024);

                            Scanner scanner = new Scanner(System.in);
                            System.out.println("请输入发送内容:");
                            if (scanner.hasNext()) {
                                SocketChannel socketChannel = (SocketChannel) sk.channel();
                                String next = scanner.next();
                                buffer.put((System.currentTimeMillis() + " >>" + next).getBytes());
                                buffer.flip();
                                // 操作三:通过DatagramChannel数据报通道发送数据
                                socketChannel.write(buffer);
                                buffer.clear();
                            }

                        }
                        if (sk.isReadable()) {
                            // 若选择键的IO事件是“可读”事件,读取数据
                            SocketChannel socketChannel = (SocketChannel) sk.channel();

                            //读取数据
                            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                            int length = 0;
                            while ((length = socketChannel.read(byteBuffer)) > 0) {
                                byteBuffer.flip();
                                System.out.println("server echo:" + new String(byteBuffer.array(), 0, length));
                                byteBuffer.clear();
                            }

                        }
                        //处理结束了, 这里不能关闭select key,需要重复使用
                        //selectionKey.cancel();
                    }
                    selected.clear();
                }
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws IOException {
        new EchoClient().start();
    }
}

运行结果:
左侧为服务端,右侧为客户端
在单线程reactor模式中,server服务端使用一个线程便可以监控成千上百个客户端socket请求。和上节单线程单连接的模式相比,解决了用户空间靠创建线程数量来支持并发消耗性能的问题。
缺点:因为reactor反应器和handler处理器处在同一个线程,一旦某个handler处理器发生了阻塞,其他的handler都将阻塞;如果被阻塞的handler处理器不是负责处理业务逻辑的,而是负责监听连接socket的AcceptorHandler处理器,那么整个服务器都将不能够处理新的IO请求,整个服务都会垮掉。

多线程reactor

为了解决单线程reactor的弊端,可以对其进行升级,在reactor反应模式的两个模块分别进行升级。
(1)reactor反应器:创建多个selector,多个子reactor反应器,每个subServerReactor处理一个selector。在多核cpu的机器上开启多个线程,每个线程执行一个reactor子反应器
(2)handler处理器:handler处理器的类型会有多类,所以创建线程池的方法管理执行handler处理器的线程,不仅可以提高处理效率,还可以又可以和负责监听IO事件并且将IO事件和handler处理器相关联的reactor反应器相互隔离。
从这俩个角度升级reactor反应器可以增加服务端的执行效率,也增加了程序的复杂度。

reactor模式和生产者消费者模式对对比:
相似:生产者消费者模式有一个或多个生成者产生事件并加入到队列中,一个或多个消费者从队列中获取事件进行消费。
不同之处:reactor模式并没有在队列中存储事件,而是直接将获取到的事件转发给handler处理器执行。真正的IO事件存储是在内核空间执行的。

redis事件

redis一共拥有两个事件类型,文件事件和时间事件。
文件事件:redis客户端使用套接字与redis服务器进行通讯,文件事件是服务器对套接字操作的抽象。服务器与客户端之间产生通讯会产生相应的文件事件,服务器就是通过监控文件事件和处理文件事件来完成一系列的网络通讯操作。
时间事件:redis为了维护自身资源会有一系列需要定时或者隔一段时间来执行的操作,时间事件是对这一类操作的抽象。

文件事件

redis服务器基于reactor模式实现了自己的一套网络服务程序,网络事件处理器,这个处理器被叫做文件事件处理器。

  • 文件事件处理器使用IO多路复用模型程序来监听多个套接字,并且根据套接字所要执行的任务为套接字关联不同的事件处理器。
  • 文件处理器监听的socket一旦处于就绪状态,例如可连接,可读或者可写状态,就会产生相应的文件事件。文件事件处理器监听到此文件事件后,将文件事件传送到其关联的事件处理器执行。
    文件事件处理器由四个组成部分组成,分别如下图所示:
    在这里插入图片描述

从上图可以看出redis的文件事件处理器组成和reactor模式如出一辙,非常相似。IO多路复用程序监控多个socket文件事件描述符,当某个socket处于就绪状态时就会产生对应操作的文件事件,文件事件分配器会将文件事件发送到关联好的事件处理器来执行相应的操作。例如服务器监听客户端连接的监听连接serverSocket在启动服务时便注册到了IO多路复用程序的监听队列中,并且文件事件处理器将监听连接serverSocket与相应的处理器进行关联。

文件事件类型

文件事件的类型:

  • AE_READABLE事件:当客户端向服务器发送新的连接请求时,监听连接的serverSocket会监听到连接请求,进而产生新的客户端socket,redis就将此操作抽象为文件事件,对应的类型为AE_READABLE,进而文件事件处理器操作的就是此文件事件;当客户端向服务器发送write操作命令时,也就是客户端socket变得可读时,也会产生一个文件事件,本质上就是处理socket发生的这个操作,文件事件的类型为AE_READABLE。

  • AE_WRITABLE事件:当客户端向服务器发送read操作命令时,客户端socket变成可写状态,redis将对socket写入这个操作抽象为一个文件事件,文件事件处理器监听到之后,便会处理文件事件,此文件事件的类型为AE_WRITABLE。

在阅读源码前要仅记文件事件只是redis对socket执行相关操作的抽象,不然可能会混淆。实际上监听的是多个socket,当用户向客户端发送连接请求或者读写操作命令时,服务器需要对socket做相关的操作,redis将这些操作抽象为不同类型的文件事件,然后处理器在处理文件事件就是处理针对socket执行各种操作。
具体是如何实现的,下面使用源码来逐步介绍。

文件事件处理器

Redis提供多个文件事件处理器,其中主要的经常用到的事件处理器为连接应答事件处理器、命令请求事件处理器和命令回复事件处理器。
连接应答事件处理器:在客户端向服务器发送连接请求时,服务器serversocket监听到请求之后,产生对应的AE_READABLE文件事件,并且将文件事件关联到连接应答事件处理器。在文件事件分派器后获取到就绪事件之后将事件分派给连接应答事件处理器。
命令请求事件处理器和命令回复事件处理器与连接应答事件处理器类似。

结构体

文件事件处理器的大部分代码实现都是在如下文件中:
在这里插入图片描述
其中可以看到基本上分为两个模块,其中ae_epoll.c/evport.c/ae_kqueue.c/ae_select.c属于上述组成部分的IO多路复用程序,其是通过包装系统调用epoll/evport/kqueue/select而实现的。实现这么多种类型是为了在跨平台时会根据操作系统选择性能最高的系统调用来作为socket套接字的监控程序。具体选择哪一个作为系统调用监控器是如下实现的(ae.c/):

#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif

从上述代码可以看出redis按照性能选择系统调用的顺序如下:evport – epoll – kqueue – select。针对每个类型系统调用的包装都提供同样的api。
ae.c函数则实现了事件驱动模式的redis事件调度。
首先介绍较重要的结构体(ae.h/)

/* State of an event based program */
typedef struct aeEventLoop {
    int maxfd;   /* highest file descriptor currently registered */
    int setsize; /* max number of file descriptors tracked */
    long long timeEventNextId;
    aeFileEvent *events; /* Registered events */
    aeFiredEvent *fired; /* Fired events */
    aeTimeEvent *timeEventHead;
    int stop;
    void *apidata; /* This is used for polling API specific data */
    aeBeforeSleepProc *beforesleep;
    aeBeforeSleepProc *aftersleep;
    int flags;
} aeEventLoop;

/* A fired event */
typedef struct aeFiredEvent {
    int fd;
    int mask;
} aeFiredEvent;

aeEventLoop用来存储事件处理器的状态。其中events属性记录了监听的所有事件。fired属性为就绪的事件,两个属性都是指向aeFiredEvent结构体的指针。aeFiredEvent结构体的属性分别为就绪事件的套接字文件描述符和事件的类型。

以客户端向server服务器发送连接请求为例,当由客户端连接server服务器时,redis服务器的serversocket便会收到请求,产生相应的文件事件,创建AE_READABLE类型的文件事件,并且关联连接请求操作对应的连接应答处理器。具体实现代码如下,其中最主要的为调用aeCreateFileEvent函数。
具体实现如下,当socket处于accept状态时,便会执行aeCreateFileEvent函数,传入的文件描述符为serversocket,mask为操作类型,继而产生文件事件,产生的AE_READABLE文件事件会注册到文件事件处理器状态结构aeEventLoop中,并且关联文件事件和文件事件处理器,并且文件事件的描述符和事件类型也都存储在事件处理器状态结构体aeEventLoop中。(ae.c/)

int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
        aeFileProc *proc, void *clientData)
{
    if (fd >= eventLoop->setsize) {
        errno = ERANGE;
        return AE_ERR;
    }
    // 将对应文件事件添加到监听注册队列中
    aeFileEvent *fe = &eventLoop->events[fd];

    if (aeApiAddEvent(eventLoop, fd, mask) == -1)
        return AE_ERR;
   	//设置文件事件的类型
    fe->mask |= mask;
    //关联文件事件的读事件处理器和写文件处理器
    if (mask & AE_READABLE) fe->rfileProc = proc;
    if (mask & AE_WRITABLE) fe->wfileProc = proc;
    fe->clientData = clientData;
    if (fd > eventLoop->maxfd)
        eventLoop->maxfd = fd;
    return AE_OK;
}

aeProcessEvents()函数:对文件事件和时间事件进行调度的主函数。暂时先用伪代码的形式来介绍其中对文件处理调用的部分。示例代码的前后省略了部分代码。整体为获取就绪文件事件及相关信息,之后针对每一个获取到的文件事件,调用之前根据文件事件类型关联的不同事件处理器(ae.c/)。

int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
	//...
	// 阻塞的获取就绪的文件事件数量,在这个函数中,也将就绪的文件事件和相关信息保存到了aeEventLoop结构体的fired字段中。
	numevents = aeApiPoll(eventLoop, tvp);
	for (j = 0; j < numevents; j++) {
		// 获取就绪的文件事件
		aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
		// 获取文件事件类型和文件描述符
		int mask = eventLoop->fired[j].mask;
        int fd = eventLoop->fired[j].fd;
		if (mask & AE_READABLE) {
			// 调用命令请求事件处理器
			fe->rfileProc(eventLoop,fd,fe->clientData,mask);
		}
		if (mask & AE_WRITABLE) {
			// 调用命令回复事件处理器
			fe->wfileProc(eventLoop,fd,fe->clientData,mask);
		}
	}
	//...
}

在这里要提到的api为aeApiPoll(),aeApiPoll是IO多路复用程序提供的api,以epoll为例,aeApiPoll()api做两个事情,函数调用epoll系统调用返回就绪的socket套接字数量,将就绪的socket对应的文件描述符和socket产生的文件事件类型设置到就绪的socket文件事件中。具体调用如下(ae_epoll.c):

static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
	// 获取IO多路复用程序的api状态结构信息,其中存储就绪的socket描述符和
	//socket要执行的请求类型。
    aeApiState *state = eventLoop->apidata;
    int retval, numevents = 0;
	// 调用java提供的系统调用函数,在监听的文件事件中返回就绪的文件事件数量
    retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,
            tvp ? (tvp->tv_sec*1000 + (tvp->tv_usec + 999)/1000) : -1);
    if (retval > 0) {
        int j;

        numevents = retval;
        for (j = 0; j < numevents; j++) {
            int mask = 0;
            // 获取轮训的就绪event事件
            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|AE_READABLE;
            if (e->events & EPOLLHUP) mask |= AE_WRITABLE|AE_READABLE;
            //将就绪的socket文件描述符和请求类型存储到eventloopstate的就绪事件属性中。
            eventLoop->fired[j].fd = e->data.fd;
            eventLoop->fired[j].mask = mask;
        }
    }
    return numevents;
}

到这里为止基本的文件事件处理器核心代码介绍差不多。其中的核心思路是reactor模式加上redis对socket请求执行的操作抽象成的文件事件,整体流程的控制在aeProcessEvents函数中。流程总结:
(1)当有新的socket处于可连接、可写、可读或者可关闭等状态时时,便会调用aeCreateFileEvent()函数创建想类型的文件事件注册到事件文件处理器中,并且将文件事件和事件处理器关联。
(2)aeProcessEvents通过调用IO多路复用程序提供的aeApiPoll API来获取已经就绪的文件事件数量,aeApiPoll API会将就绪的文件事件的socket文件描述符和文件事件类型存储到aeProcessEvents的fired属性中。
(3) aeProcessEvents获取就绪的文件事件之后,根据文件事件的类型执行之前关联好的文件事件处理器。

Redis的文件事件处理器使用单线程来监听多个socket文件描述符,又因为是内存数据库,其可以提供很好的高并发网络服务。当监听多个文件描述符时,同时可能并发出多个文件事件,这些文件事件会被IO多路复用程序按照有序的,一个一个的发送给文件事件分派器。也就是说,当文件事件被事件处理器处理完成之后,下一个socket产生的文件事件才会被IO多路复用程序发送到文件事件分派器继续执行。

时间事件

Redis为了维护自身的资源与管理自己的状态设定了一些定期或者周期性的操作,比如周期性执行rdb或者aof持久化、定期删除过期键等,redis将这类操作抽象为时间事件。
时间事件包括定时事件和周期性事件。定时事件为指定时间执行某个操作;周期性事件为隔一段时间执行某个操作。在目前Redis的实现中只包括周期性时间事件。
看一下时间事件的结构声明:

/* Time event structure */
typedef struct aeTimeEvent {
    long long id; /* time event identifier. */
    monotime when;
    aeTimeProc *timeProc;
    aeEventFinalizerProc *finalizerProc;
    void *clientData;
    struct aeTimeEvent *prev;
    struct aeTimeEvent *next;
    int refcount; /* refcount to prevent timer events from being
  		   * freed in recursive time event calls. */
} aeTimeEvent;

其中有三个属性比较重要,id为每个时间事件的唯一表示,并且随着时间事件的增加依次增大;when属性记录了时间事件的到达时间,是毫秒单位的UNIX时间戳;aeTimeProc属性记录了时间事件处理器。

typedef struct aeEventLoop {
    int maxfd;   /* highest file descriptor currently registered */
    int setsize; /* max number of file descriptors tracked */
    long long timeEventNextId;
    aeFileEvent *events; /* Registered events */
    aeFiredEvent *fired; /* Fired events */
    aeTimeEvent *timeEventHead;
    int stop;
    void *apidata; /* This is used for polling API specific data */
    aeBeforeSleepProc *beforesleep;
    aeBeforeSleepProc *aftersleep;
    int flags;
} aeEventLoop;

Redis将所有的时间事件存储在一个无序链表中,也就是上面代码的timeEventHead字段。但是Redis目前其实只有一个时间事件,对应的实现函数为serverCron(server.c/serverCron)

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {}

此函数每个100毫秒就执行一次。它的主要工作比较多,都是为了维护redis服务器自身资源和状态。主要工作包括更新服务器基本信息(如内存使用)、清理数据库过期键、进程AOF或者RDB持久化存储、或者清除过期客户端等等。

文件事件和时间事件之间的调度

redis服务器文件事件和时间事件之间的调度是非常巧妙的。主要的逻辑就是ae.c/aeProcessEvents函数实现的。伪代码如下所示:

int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
	// 获取最近要发生的时间事件的执行时间
	int64_t usUntilTimer = usUntilEarliestTimer(eventLoop);
	// 计算距离执行时间事件的时间差
	tvp = now_time - usUntilTimer
	if tvp < 0:
		tvp = 0
	// 阻塞tvp时间获取就绪socket
	numevents = aeApiPoll(eventLoop, tvp);
	for (j = 0; j < numevents; j++) {
		process_event_handler()
	}
	// 当就绪socket处理完成之后,便处理时间事件
	processTimeEvents(eventLoop)

}

在文件事件和时间事件的调度中,先获取最近要执行的时间事件,计算距离执行的时间差tvp,然后阻塞tvp时间获取就绪socket,执行完文件事件处理器之后在执行时间事件。这里面有两个点,
第一是通过阻塞tvp的时间获取就绪的文件事件,就是说即使有更多的就绪socket也不会继续获取,当没有socket就绪,或者根本就没有执行文件事件时,此处也要阻塞tvp的时间,redis在执行文件事件的同时控制了时间事件何时执行,没有执行文件事件时就把aeApiPoll当成了sleep来使用。
第二点就是在阻塞了tvp时间获取就绪文件事件后,还要执行文件事件处理器,所以时间事件的真正执行时间其实会比设定的时间晚一点。

到此为止基本完成了redis网络服务程序的实现方式,在IO多路复用模式和reactor设计模式的基础之上,redis设计了自己的单线程处理高并发的网络服务程序。但是Redis为了提高效率有很多其他的细节,包括底层每个数据结构的编码类型,例如List类型底层有两种数据结构作为编码格式,在不同的情况下使用不同的数据结构作为底层编码以提高效率;对象共享节省内存,将1~9999的int类型的字符串对象作为共享对象以节省内存节约资源等等。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值