Redis为什么快?epoll和IO多路复用

时间:2021-12-21 01:15:51


文章目录

  • 多路复用要解决的问题
  • IO多路复用的定义
  • Redis单线程如何处理那么多并发客户端连接,为什么单线程,为什么快
  • 同步,异步,非阻塞,阻塞
  • 五种IO模型,介绍前三个
  • BIO
  • NIO
  • IO多路复用
  • 重点:select,poll,epoll都是IO多路复用的具体实现
  • select
  • poll
  • epoll(非阻塞的)
  • 三个方法对比

多路复用要解决的问题

多路复用以前最简单的方案就是同步阻塞网络IO模型:用一个进程来处理一个网络连接,优点使容易理解,缺点使性能差,每个用户请求到来都得占用一个进程来处理,来一个请求就得分配一个进程跟进处理。
进程在linux上开销不小,光上下文切换就得几个微秒。为了高效地对海量用户提供服务,必须让一个进程能同时处理多个TCP连接才行。

IO多路复用的定义

IO:网络io
多路:多个客户端连接,指的是多条TCP连接
复用:用一个进程来处理多条的连接,使用单进程就能够实现同时处理多个客户端的连接

实现了用一个进程来处理大量的用户连接,IO多路复用类似一个规范和接口,可以分select->poll->epoll三个阶段来描述。

Redis单线程如何处理那么多并发客户端连接,为什么单线程,为什么快

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

网络事件处理器:

Redis为什么快?epoll和IO多路复用


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

Redis为什么快?epoll和IO多路复用


Redis为什么快?epoll和IO多路复用

同步,异步,非阻塞,阻塞

同步:,调用者要一直等待调用结果的通知后才能进行后续的执行,现在就要,我可以等,等出结果为止。
异步:调用者收到被调用者返回通知,自己先回去,然后再计算调用结果,计算完最终结果后再通知并返回给调用方。异步调用要想获得结果一般通过回调。
同步异步重点在于获得调用结果的消息通知方式。
阻塞:调用方一直等待什么也不做,当前线程被挂起,啥都不干。
非阻塞:调用再发出去后,调用方先去忙别的事情不会阻塞当前线程,而会立即返回。
阻塞和非阻塞重点在于等消息时候的行为,调用者是否能干其它事情。

五种IO模型,介绍前三个

BIO

RedisClient01

package com.atguigu.redis7.iomultiplex.bio.read;

import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;
public class RedisClient01
{
    public static void main(String[] args) throws IOException
    {
        Socket socket = new Socket("127.0.0.1",6379);
        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();
        socket.close();
    }
}

RedisClient02

package com.atguigu.redis7.iomultiplex.bio.read;

import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;


public class RedisClient02
{
    public static void main(String[] args) throws IOException
    {
        Socket socket = new Socket("127.0.0.1",6379);
        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("------RedisClient02 input quit keyword to finish......");
        }
        outputStream.close();
        socket.close();
    }
}

RedisServerBIO

package com.atguigu.redis7.iomultiplex.bio.read;

import cn.hutool.core.util.IdUtil;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * @auther zzyy
 * @create 2021-06-01 10:35
 */
public class RedisServerBIO
{
    public static void main(String[] args) throws IOException
    {
        ServerSocket serverSocket = new ServerSocket(6379);

        while(true)
        {
            System.out.println("-----111 等待连接");
            Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
            System.out.println("-----222 成功连接");

            InputStream inputStream = socket.getInputStream();
            int length = -1;
            byte[] bytes = new byte[1024];
            System.out.println("-----333 等待读取");
            while((length = inputStream.read(bytes)) != -1)//阻塞2 ,等待客户端发送数据
            {
                System.out.println("-----444 成功读取"+new String(bytes,0,length));
                System.out.println("===================="+"\t"+ IdUtil.simpleUUID());
                System.out.println();
            }
            inputStream.close();
            socket.close();
        }
    }
}

上面的模型存在很大问题,如果客户端与服务端建立连接,如果这个连接的客户端迟迟不发数据,程序就会一直堵塞在read方法上,这样其他客户端也不能进行连接。一次只能处理一个客户端,对客户很不友好,因此我们可以采用多线程的方式来解决这个问题。
多线程处理方式就是只要连接了一个socket,操作系统分配一个线程来处理,这样read方法堵塞在每个具体线程上而不堵塞在主线程上了,程序服务端只负责监听是否有客户端连接,使用accept方法阻塞,客户端1连接服务端就开辟一个线程执行read方法,程序服务端继续监听,以此类推。
任何一个线程上的socket有数据发送过来,read方法都能读到,cpu就能进行处理。
客户端代码和上面一样
RedisServerBIOMultiThread

package com.atguigu.redis7.iomultiplex.bio.read.mthread;

import cn.hutool.core.util.IdUtil;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class RedisServerBIOMultiThread
{
    public static void main(String[] args) throws IOException
    {
        ServerSocket serverSocket = new ServerSocket(6379);

        while(true)
        {
            System.out.println("-----RedisServerBIOMultiThread 111 等待连接");
            Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
            System.out.println("-----RedisServerBIOMultiThread 222 成功连接");

            new Thread(() -> {
                try {
                    InputStream inputStream = socket.getInputStream();
                    int length = -1;
                    byte[] bytes = new byte[1024];
                    System.out.println("-----333 等待读取"+ IdUtil.simpleUUID());
                    while((length = inputStream.read(bytes)) != -1)//阻塞2 ,等待客户端发送数据
                    {
                        System.out.println("-----444 成功读取"+new String(bytes,0,length));
                        System.out.println("====================");
                        System.out.println();
                    }
                    inputStream.close();
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            },Thread.currentThread().getName()).start();

            new Thread().start();

        }
    }
}

多线程的方式存在哪些问题呢?

每来一个客户端就要开辟一个线程,如果来一万个客户端就要开辟一万个线程,操作系统用户态不能开辟线程,需要调用内核来创建一个线程,其中涉及上下文切换,很耗资源。

可以采用线程池的方式来解决,但仅限于客户端连接少的情况,太多客户端连接,内存可能不够用,也不可行。

需要注意我们是由于read方法堵塞了,所以要开辟多个线程,可不可以不让read方法堵塞,这时候就要引出非阻塞式IO也就是NIO了。

Redis为什么快?epoll和IO多路复用

NIO

accept方法和read方法都是非阻塞的,如果没有客户端连接就返回无连接标识,如果没有数据被读取就返回空闲标识,如果读取到数据时只阻塞read方法读数据的时间。
NIO只有一个线程:
当一个客户端和服务端进行连接,这个socket就会加入到一个数组中,隔一段时间遍历一次(轮询),看这个socket的read方法是否读到数据,这样一个线程就能够处理多个客户端的连接和读取了。
客户端代码一样
服务端代码

package com.atguigu.redis7.iomultiplex.nio;

import cn.hutool.core.util.IdUtil;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
public class RedisServerNIO
{
    static ArrayList<SocketChannel> socketList = new ArrayList<>();
    static ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    public static void main(String[] args) throws IOException
    {
        System.out.println("---------RedisServerNIO 启动等待中......");
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.bind(new InetSocketAddress("127.0.0.1",6379));
        serverSocket.configureBlocking(false);//设置为非阻塞模式
        /**
         * select 其实就是把NIO中用户态要遍历的fd数组(我们的每一个socket链接,安装进ArrayList里面的那个)拷贝到了内核态,
         * 让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,
         * 这样遍历判断的时候就不用一直用户态和内核态频繁切换了
         */

        while (true) {
            for (SocketChannel element : socketList) {
                int read = element.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();
                }
            }
            //监听客户端请求
            SocketChannel socketChannel = serverSocket.accept();
            if(socketChannel != null) {
                System.out.println("-----成功连接: ");
                socketChannel.configureBlocking(false);//设置为非阻塞模式
                socketList.add(socketChannel);
                System.out.println("-----socketList size: "+socketList.size());
            }
        }
    }
}

ServerSocketChannel和SocketChannel是可以设置为阻塞的。

Redis为什么快?epoll和IO多路复用

IO多路复用

多路指的是单个线程通过记录跟踪每个Socket的状态来同时管理多个IO流,目的是尽量多的提高服务器的吞吐能力。

多个Socket复用一根网线这个功能是在内核+驱动层实现的。

Redis为什么快?epoll和IO多路复用

什么是文件描述符?

当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。(每个网络连接对应一个文件描述符)

Redis为什么快?epoll和IO多路复用


Redis的IO多路复用:Redis利用epoll实现IO多路复用,将连接信息和事件放到事件队列中,依次放到事件分派器,事件分派器将事件分发给事件处理器

Redis为什么快?epoll和IO多路复用


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

Reactor模式是什么?

IO多了复用统一监听事件,收到事件后分发(Dispatch给某进程),是编写高性能网络服务器的必备技术。

Reactor模式 有两个关键组成:

  • Reactor:在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序对IO事件做出反应。(接线员)
  • Handlers:处理程序执行IO事件要完成的实际事件。(办理人)
    Redis基于Reactor模式开发了网络事件处理器,也叫做文件事件处理器。
    组成结构:
  • 多个套接字
  • IO多路复用程序
  • 文件事件分派器
  • 事件处理器(由于文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型)

重点:select,poll,epoll都是IO多路复用的具体实现

select

该方法就是把NIO中用户态要遍历的fd数组拷贝到内核态,让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换了。

从代码中看,select方法调用后,返回一个置位后的&rset,&rset是一个bitmap,用户态只需要进行很简单的二进制比较就能很快知道哪些socket需要read数据,有效提高了效率。

Redis为什么快?epoll和IO多路复用


缺点:

bitmap最大1024位也就戴白哦。一个进程最多只能处理1024个客户端。

&rset不可重用,每次循环都必须置位为0.

虽然将rset从用户态拷贝到内核态,由内核态判断是否有数据,但是还是有拷贝的开销(select调用时需要传入fd数组,拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的)。

当有数据时select就会返回,但select函数并不知道哪个描述符有数据,后面还需要再次对文件描述符数组进行遍历,效率低。

总的来说:select方式既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销,多个文件描述符只有依次select的系统调用+n次就绪状态的文件描述符的read系统调用。

poll

Redis为什么快?epoll和IO多路复用


Redis为什么快?epoll和IO多路复用


poll方法使用pollfd数组来代替select中的bitmap,数组没有1024的限制,可以一次管理更多的client。它和select的主要区别就是去掉了select只能监听1024个文件描述符的限制。

当pollfds数组中有事件发生,相应的revents置位为1,遍历的时候又置位回0,实现pollfd数组的重用。

epoll(非阻塞的)

epoll_create 创建一个epoll句柄
在内核中开辟一块内存空间,用来存放epoll中的fd数据结构(epoll中fd的数据结构和poll中的差不多,只是没有了revents)
epoll_ctl 向内核添加,修改或删除要监控的文件描述符
把每个socket的fd数据结构放到刚创建的内存空间中

epoll_wait 类似发起了select方法调用

阻塞,只有当刚创建的内存空间中的fd有事件发生,才会把这些fd放入就绪链表中,返回就绪fd的个数。
遍历就绪链表,读取数据。

Redis为什么快?epoll和IO多路复用


Redis为什么快?epoll和IO多路复用


多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。

1、一个socket的生命周期中只有一次从用户态拷贝到内核态的过程,开销小

2、使用event事件通知机制,每次socket中有数据会主动通知内核,并加入到就绪链表中,不需要遍历所有的socket(epoll只轮询那些真正发出了事件的流

三个方法对比

Redis为什么快?epoll和IO多路复用


io多路复用:一次系统调用+内核层遍历这些文件描述符

Redis为什么快?epoll和IO多路复用


linux使用redis最好的原因出来了:

Redis为什么快?epoll和IO多路复用