Redis高性能设计之epoll和IO多路复用深度解析

一、简介

1. 背景

在处理多个客户端连接redis问题时,在多路复用没有出来之前,通常的解决方案就是同步阻塞网络IO(BIO)。这个模式的特点就是用一个进程来处理一个网络请求(一个用户请求)。这种思路很简单易懂,但是却存在着很大的问题,它的性能时极差的,如果每个用户请求都开辟一个新的进程来处理,那么来一个请求就需要分配一个进程来进行处理,如果来了海量的用户系统根本创建不了这么多线程来处理用户请求。在Linux中,创建一个进程的开销时非常大的,所以为了应对海量用户的请求,我们必须实现这个条件:必须让一个进程能够同时处理很多TCP连接,现在问题又来了,如果一个进程上保持了10000条连接,那么如何发现哪条连接上有数据可读,哪条连接上需要写数据呢?

当然我们可以使用轮询的方式来遍历所有连接,但是这种方式效率是很低的

为了满足上面的需求,IO多路复用技术就出现了,注意复用是指对进程的复用。

2. IO多路复用介绍

IO:网络I/O

多路:多个库护端连接(连接就是套接字描述符,即socket或者channel),指的是多条TCP连接

复用:用一个进程来处理多条连接,使用单进程就可以实现同时处理多个客户端连接

IO多路复用实现了一个进程来处理大量的用户连接。

IO多路复用linux系统已经实现好了:select—>poll—>epoll三个阶段来描述

二、I/O多路复用Redis中的应用

1. 问题分析

redis单线程是如何处理那么多并发客户端连接的,而且还这么快?

Redis利用epoll来实现IO多路复用,将连接信息和时间放在队列中,依次放到文件事件分派其,时间分派器将事件分发给事件处理器。

在这里插入图片描述

Redis是跑在单线程当中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以I/O操作在一般情况下往往不能直接返回,这就导致某一文件的I/O阻塞导致整个进程无法对其它客户提供服务,而I/O多路复用为了解决这个问题而出现。

所谓I/O多路复用机制,就是说通过一种机制,可以监视多个描述符。一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知主程序进行相应的读写操作,这种机制需要select或poll或epoll来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接,当某条连接有新的数据需要处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。

Redis服务采用Reactor的方式来实现文件事件处理(每一个网络都对应一个文件描述符),Redis基于Reactor模式开发了网络事件处理器,这个处理器也称为文件处理器,它的组成结构为4部分:

  • 多个连接套接字
  • 文件事件分派器
  • 事件处理器

因为文件事件分派器队列的消费时单线程的,所以Redis才叫单线程模型

从redis 6开始,将网络数据读写、请求协议解析通过多个IO线程来处理,对于真正执行redis命令,则还是当线程的。

在这里插入图片描述

2. Unix网络编程中的五种IO模型

Unix有下面五种IO模型:

  • Blocking IO:阻塞IO
  • NoneBlockingIO:非阻塞IO
  • IO multiplexing:IO多路复用
  • signal driven IO:信号驱动IO
  • asynchronous IO:异步IO

同步:同步是指在发起一个操作后,必须等待该操作完成后才能继续执行后续的操作。在同步操作中,调用者会阻塞(暂停)直到被调用的函数或任务完成并返回结果。

异步:异步是指发起一个操作后,不需要等待该操作完成,可以立即执行后续的操作。在异步操作中,调用者不会阻塞,而是继续执行其他任务。被调用的任务在后台执行,执行完后通常通过回调函数或者其他机制来通知调用者。

阻塞:阻塞是指调用者在执行一个操作时,如果该操作无法立即完成,调用者会一直等待直到操作完成或者超时。在阻塞模式下,调用者线程或进程会被挂起,直到等待的事件发生。

非阻塞:非阻塞是指调用者在执行一个操作时,如果该操作无法立即完成,调用者会立即返回并继续执行其他任务,而不是等待操作完成。在非阻塞模式下,调用者会周期性地检查等待的事件是否发生,而不是被挂起等待。

同步和异步主要关注的是任务的调度方式。同步指任务顺序执行,而异步指任务可以在后台独立执行。阻塞和非阻塞主要关注的是等待调用结果时的行为。阻塞表示调用者会等待结果返回,而非阻塞表示调用者不会等待结果返回,可以继续执行其他任务。

同步阻塞:调用者发起一个操作后,必须等待操作完成,期间调用者会被阻塞,无法执行其他任务。直到操作完成并返回结果后,调用者才能继续执行后续的操作。

同步非阻塞:调用者发起一个操作后,仍然需要等待操作完成,但在等待的过程中,调用者可以执行其他任务,而不会被阻塞。通常这种情况下会使用轮询或者多线程等机制来实现

异步阻塞:调用者发起一个异步操作后,立即返回并继续执行其他任务。但在等待异步操作完成的过程中,调用者会被阻塞,无法执行其他操作。异步操作完成后,调用者才能获取到结果。

异步非阻塞:调用者发起一个异步操作后,立即返回并继续执行其他任务。在等待异步操作完成的过程中,调用者不会被阻塞,可以继续执行其他操作。通常通过回调函数或事件通知的方式来处理异步操作的完成。

3. Unix网络编程中的IO模型java验证

  • BIO

在这里插入图片描述

recvfrom函数时Linux底层用于从已连接的套接字上接受数据,并捕获数据发生源的地址

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓存区种是需要一个过程的,而在用户进程这边,整个进程会被阻塞(当然是用户进程自己选择的阻塞)。当kernel一直等待数据准备好了,它就会将数据从kernel种拷贝到用户内存,然后kernel返回结果,用户进程才解除block状态,重新运行起来,所以BIO的特点就是在IO执行的两个阶段都被Block了。

public class RedisClient01 {
    public static void main(String[] args) throws IOException {
        System.out.println("start......");
        Socket socket=new Socket("127.0.0.1",8888);
        System.out.println("stop.......");
    }
}


public class RedisServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket=new ServerSocket(8888);
        while(true){
            System.out.println("服务器启动......");
            Socket socket=serverSocket.accept();
            System.out.println("连接成功.....");
        }
    }
}

上面代码RedisServer一直在等待客户端连接,所以服务端一直在被阻塞。

public class RedisClient01 {
    public static void main(String[] args) throws IOException {
        System.out.println("start......");
        Socket socket=new Socket("127.0.0.1",8888);
        OutputStream outputStream=socket.getOutputStream();
        while(true){
            Scanner scanner=new Scanner(System.in);
            String string=scanner.next();
            if(string.equalsIgnoreCase("quit")){
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("RedisClient01 input quit keyword to finish.....");
        }
        outputStream.close();
        System.out.println("stop.......");
    }
}

public class RedisClient02 {
    public static void main(String[] args) throws IOException {
        System.out.println("start......");
        Socket socket=new Socket("127.0.0.1",8889);
        OutputStream outputStream=socket.getOutputStream();
        while(true){
            Scanner scanner=new Scanner(System.in);
            String string=scanner.next();
            if(string.equalsIgnoreCase("quit")){
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("RedisClient01 input quit keyword to finish.....");
        }
        outputStream.close();
        System.out.println("stop.......");
    }
}

public class RedisServerBIO {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket=new ServerSocket(8888);
        while(true){
            System.out.println("等待连接.....");
            Socket socket=serverSocket.accept();
            InputStream inputStream=socket.getInputStream();
            int length=-1;
            byte[] bytes=new byte[1024];
            while((length=inputStream.read(bytes))!=-1){
                System.out.println("成功读取......"+new String(bytes,0,length));
                System.out.println();
            }
            inputStream.close();
            socket.close();
        }
        
    }
}

RedisServerBIO启动,然后启动RedisClient01,现在RedisClient01可以和RedisServerBIO通信,前者发送的信息server都可以收到,现在启动RedisClient02,然后向server发送3条消息,发现server并没有回复,然后RedisClient01退出,发现询价收到了之前RedisClient02发送的所有消息。这个案例我们就可以看出BIO的问题:

如果客户端连接了服务端,但是客户端迟迟不发送数据,此时服务端会一直阻塞在该客户端(read())方法,这样其它客户端永远得不到响应,也就是一次只能处理一个客户端,这样对客户会非常不友好。

下面利用多线程模型对上面案例进行改进,只要连接了一个socket,操作系统就分配一个线程来处理,这样read方法阻塞在每个具体线程上而不阻塞主线程。

public class RedisClient01 {
    public static void main(String[] args) throws IOException {
        System.out.println("start......");
        Socket socket=new Socket("127.0.0.1",8889);
        OutputStream outputStream=socket.getOutputStream();
        while(true){
            Scanner scanner=new Scanner(System.in);
            String string=scanner.next();
            if(string.equalsIgnoreCase("quit")){
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("RedisClient01 input quit keyword to finish.....");
        }
        outputStream.close();
        System.out.println("stop.......");
    }
}

public class RedisClient02 {
    public static void main(String[] args) throws IOException {
        System.out.println("start......");
        Socket socket=new Socket("127.0.0.1",8889);
        OutputStream outputStream=socket.getOutputStream();
        while(true){
            Scanner scanner=new Scanner(System.in);
            String string=scanner.next();
            if(string.equalsIgnoreCase("quit")){
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("RedisClient01 input quit keyword to finish.....");
        }
        outputStream.close();
        System.out.println("stop.......");
    }
}

public class RedisServerBIO {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket=new ServerSocket(8889);
        while(true){
            System.out.println("等待连接.....");
            Socket socket=serverSocket.accept();
            System.out.println("连接成功.....");
            new Thread(()->{
                try (InputStream inputStream=socket.getInputStream()){
                    int length=-1;
                    byte[] bytes=new byte[1024];
                    while((length=inputStream.read(bytes))!=-1){
                        System.out.println("成功读取......"+new String(bytes,0,length));
                        System.out.println();
                    }
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            },Thread.currentThread().getName()).start();
        }

    }
}

这样一来RedisClient01和RedisClient02的请求都得到了处理,解决了上面方案的问题。但是多线程这种方法,对于海量用户请求就不太合适了,在操作系统中用户态不能直接开辟线程,需要调用内核来创建一个线程,这其中还涉及到用户态的切换,十分耗费资源。改进方法:

  • 线程池:但是用户量大的时候,我们并不知道线程池要多大,太大了内存可能不够,所以这种方案可能也不太行
  • NIO:非阻塞IO,因为read()方法阻塞了,所以来开辟多个线程,如果上什么方法能使read()方法不再阻塞,这样就不用开辟多个线程了,这就用到了NIO。
  • NIO

前面我们分析到BIO的缺点就是每个线程分配一个连接,必然会产生多个,既然是多个Socket连接必然需要统一放入容器,纳入统一管理

当用户进程发起read操作的时候,如果kernel的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个error,从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到一个结果。。用户进程判断结果是一个error是,它就知道数据还没有准备好,于是它可以再次发送read操作。移动kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就把数据拷贝到用户内存,然后返回,所以NIO的特点就是用户进程需要不断的主动循环内核数据准备好了吗,一句话用轮询代替阻塞。

在这里插入图片描述

在NIO模式中,一切都是非阻塞的:

  1. accept()方法是非阻塞的,如果没有客户端连接,就返回无连接标识
  2. read()方法是非阻塞的,如果read()方法读取不到数据就返回空闲中标识,如果读取到数据时只阻塞read()方法读数据的时间

在NIO模式中,只有一个线程:当客户端与服务端进行连接,这个socket就会加入一个数组中,隔一段时间遍历一次,看这个socket和read方法是否能读到数据,这样一个线程就能处理多个客户端连接了。

public class RedisClient01 {
    public static void main(String[] args) throws IOException {
        System.out.println("start......");
        Socket socket=new Socket("127.0.0.1",8889);
        OutputStream outputStream=socket.getOutputStream();
        while(true){
            Scanner scanner=new Scanner(System.in);
            String string=scanner.next();
            if(string.equalsIgnoreCase("quit")){
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("RedisClient01 input quit keyword to finish.....");
        }
        outputStream.close();
        System.out.println("stop.......");
    }
}

public class RedisClient02 {
    public static void main(String[] args) throws IOException {
        System.out.println("start......");
        Socket socket=new Socket("127.0.0.1",8889);
        OutputStream outputStream=socket.getOutputStream();
        while(true){
            Scanner scanner=new Scanner(System.in);
            String string=scanner.next();
            if(string.equalsIgnoreCase("quit")){
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("RedisClient01 input quit keyword to finish.....");
        }
        outputStream.close();
        System.out.println("stop.......");
    }
}

public class RedisServerNIO {
    static ArrayList<SocketChannel> socklist = new ArrayList<>();
    static ByteBuffer byteBuffer = ByteBuffer.allocate(2023);

    public static void main(String[] args) throws IOException {
        System.out.println("RedisSServerNiO启动中.......");
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.bind(new InetSocketAddress("127.0.0.1", 8889));
        serverSocket.configureBlocking(false);  //设置为非阻塞模式
        while (true) {
            for (SocketChannel channel : socklist) {
                int read = channel.read(byteBuffer);
                if (read > 0) {
                    System.out.println("读取数据......" + read);
                    byteBuffer.flip();
                    byte[] bytes = new byte[read];
                    byteBuffer.get(bytes);
                    System.out.println(new String(bytes));
                    byteBuffer.clear();
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
            SocketChannel socketChannel = serverSocket.accept();
            if (socketChannel != null) {
                System.out.println("成功连接......");
                socketChannel.configureBlocking(false);
                socklist.add(socketChannel);
                System.out.println("socketlist Size....." + socklist.size());
            }

        }
    }
}

上面代码就是NIO模型,它成功解决了BIO需要开启多线程的问题,NIO中一个线程就能解决多个socket,但是还存在两个问题。

  1. 这个模型在客户端少的时候非常好用,但是客户端如果多了,比如有1万个客户端进行连接,那么每次循环就是1万个socket,如果一万个socket中,只有10个socket有数据,也会遍历一万个socket,就会做很多无用功,每次遇到read返回-1仍然是一次浪费资源的系统调用。
  2. 而且这个遍历是在用户态进行的,用户态判断socket是否有数据还要调用内核的read()方法实现的,这就涉及到用户态和内核态的遍切换,每遍历一次就需要进行一次内核切换,这样开销是很大的。

怎么解决上述问题,方法是让linux搞定上述需求,我们将一批文件描述符通过一次系统调用传给内核层去遍历,才能真正解决这个问题。IO多路复用就这么产生了,也就是将上面工作交给内核,不再两态转换而是直接获取结果,因为内核是非阻塞的。

4. IO多路复用

多路复用:数据通信系统中或计算机网络系统中,传输媒体的带宽或容量往往会大于传输单一信号的需求,为了有效地利用通信线路,希望一个信道同时传输多路信号,这就是多路复用计数。采用多路复用技术能把多个信号组合在一条物理信道上进行传输,在远距离传输时可以大大节省电缆的安装和维护费用,频分多路复用(FDM)和时分多路复用(TDM)是两种最常用的多路复用技术。

在这里插入图片描述
在这里插入图片描述
nginx底层也是使用epoll接收请求,nginx会有很多连接进来,epoll会把它们都监视起来,然后像拔开关一样,谁有数据就拔向谁,然后调用相应的代码处理。

文件标识符(FileDescriptor):文件标识符是计算机科学的一个术语,是一个用于表述指向文件引用的抽象化概念。文件描述符在形式上是一个非负整数,实际上它是一个索引值,指向内核为每一个进程所维护的改进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件的时候,内核向进程返回一个文件描述符。在程序设计中,一些底层的编码会往往围绕文件描述符来进行。但是文件描述符通常适用UNIX、Linux这样的操作系统。

IO多路复用:IO多路复用就是我们说的select、poll和epoll,有些技术书籍也称这种IO方式为event dirven IO。就是通过一种机制,一个进程可以监视多个文件描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的写操作。可以基于一个阻塞的对象并同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符就是一个线程,每次new一个线程),这样可以大大节省系统资源,所以,IO多路复用的特点就是通过一种机制一个进程能同时等待多个文件描述符而这些文件描述符(套接字描述符)其中任意一个进入读就绪状态,select、poll和epoll等函数的返回。

在这里插入图片描述

redis单线程为什么能够处理并发客户端连接,为什么这么快?

在这里插入图片描述

Reactor设计模式:基于IO复用模型,多个连接共用一个阻塞对象,应用程序只需要阻塞在一个对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始业务处理。Reactor模式,是指通过一个或多个同事传递给服务器处理的服务请求的时间驱动模型,服务端程序处理传入多路请求,并将它们同步分派给对应的处理线程,Reactor模型又称为Dispacher模式,即IO多路复用统一监听时间,收到时间后分发给线程,是编写高性能网络服务器的必备技术。

在这里插入图片描述
Reactor有两个关键部分:

Reactor:Reactor在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序对IO事件做出反应
Handler:处理程序执行IO事件要完成的实际操作

5. select、poll和epoll

select、poll和epoll都是IO多路复用的具体实现。

  • select方法:

在这里插入图片描述

select函数的执行流程如下:

  1. select是一个阻塞函数,当没有数据时,会一直阻塞在select
  2. 当有数据时会将reset中对应的那一个位置设置为1
  3. select函数返回,不再阻塞
  4. 遍历文件描述符数组,判断哪个fd被置位
  5. 读取数据,然后处理

在这里插入图片描述
在这里插入图片描述
总结:select其实就是把NIO中用户态要遍历的fd数组(Arraylist中的那个)拷贝到了内核态,让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直在用户态和内核态之间频繁切换了。

缺点:

  1. bitmap的默认大小时1024个字节,虽然可以调整但还是有限度的
  2. reset每次循环都必须重新置位为0,不能重复使用
  3. 尽管将reset从用户态拷贝到了内核态,由内核态判断是否有数据,但是还是有拷贝的开销
  4. 当有数据时select就会返回,但是select函数并不知道哪个文件符有数据了,后面还需要对文件描述符进行遍历,效率比较低

在这里插入图片描述

  • poll方法

poll的执行流程

  1. 将5个fd从用户态拷贝到核心态
  2. poll为阻塞方法,执行poll方法,如果有数据就会将fd对应的revents置为POLLIN
  3. poll方法返回
  4. 循环遍历,查找那个fd被置位为pollin了
  5. 将revents重置为0,便于复用
  6. 对置位的fd进行读取和处理

poll就解决了bitmap的大小限制,解决了reset不可重用的情况

在这里插入图片描述
poll使用pollfd数组来代替select中的bitmap,数组没有1024限制,可以一次管理多个client,它和select的主要区别是select只能监听1024个文件描述符的限制。当pollfds数组有事件发生时,相应的revents置为1,遍历的时候又置为0,实现pollfd数组的重用。poll虽然解决了select缺点中的前两条,其工作原理还是select方法,还存在select原来的问题:

  1. pollfds数组拷贝到内核态,仍然有开销
  2. poll并没有通知用户态哪一个有数据,仍需要O(N)遍历
  • epoll

epoll的主要组成:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

epoll_create:创建一个句柄

epoll_ctl:向内核汇总添加、修改或删除文件描述符

epoll_wait:类似发起了selelct调用

epoll是非阻塞的,它的执行流程如下:

  1. 当有数据的时候,就会将相应的文件描述符置位,但是epoll没有revent标志位,所以并不是真正的置位,这时候会把有数据的文件符放到队首
  2. epoll会返回有数据的文件描述符的个数
  3. 根据返回的个数读取N个文件描述符即可
  4. 读取处理

在这里插入图片描述
在这里插入图片描述

  • 结论:多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的while循环里面的多次系统调用,变成了一次系统调用+内核层遍历这些文件描述符。epoll是现在最先进的IO多路复用器,redis、Nginx和linux中的java NIO都使用的是epoll。
  1. 一个socket的生命周期只有一次从用户态拷贝到内核态,开销小
  2. 使用event事件通知机制,每次socket有数据就主动通知内核,并加入到就绪链表中,不需要遍历所有的socket

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值