I/O多路复用-redis单线程模型快的根本原因

目录

BIO

BIO单线程模式

BIO多线程模型

NIO

IO multiplexing

select函数:

poll函数:

epoll函数:


首先了解同步和异步,阻塞和非阻塞的概念:

同步:发起请求的一方需要等待操作完成并获得结果后才能继续执行后续的操作,换句话说,同步操作会阻塞当前线程或进程,直到操作完成。

异步:发起请求的一方可以继续执行后续的操作,而不必等待操作完成。异步操作通常会使用回调函数、事件处理器或者轮询的方式来处理结果。在异步模式下,不同的参与方可以独立地进行操作,无需等待其他操作的完成。

阻塞:在阻塞模式下,当一个I/O操作被调用时,程序会一直等待,直到操作完成或者发生错误才会返回结果。在阻塞状态下,调用线程或进程会被挂起,无法进行其他任务,直到I/O操作完成或者超时。

非阻塞:在非阻塞模式下,当一个I/O操作被调用时,程序会立即返回,而不会等待操作完成。如果操作不能立即完成,会返回一个错误或标记来指示当前操作无法立即完成,而不会阻塞调用线程或进程。

阻塞和非阻塞他们描述的是操作等待的状态,同步和异步描述的是操作执行的顺序。


BIO

在没有IO多路复用之前,遇到并发多客户端连接,通常采用 BIO 模型来处理并发多连接问题。

BIO单线程模式

最开始的BIO单线程(同步)模式:

code案例:

public class redisServerBIO {
    public static void main(String[] args) throws IOException {

        ServerSocket serverSocket = new ServerSocket(6379);

        while (true) {
            System.out.println("模拟RedisServer启动------111等待连接");
            Socket accept = serverSocket.accept();
            System.out.println("--------222 成功连接:" + IdUtil.simpleUUID());
            InputStream inputStream = accept.getInputStream();
            int length = -1;
            byte[] bytes = new byte[1024];
            System.out.println("-------333等待读取");
            while ((length = inputStream.read(bytes)) != -1) {
                System.out.println("------444 成功读取 " + new String(bytes,0,length));
                System.out.println("===================" + "\t" + IdUtil.simpleUUID());
                System.out.println();
            }
            inputStream.close();
            accept.close();
        }
    }
}

存在的问题:如果客户端与服务端建立了连接,但连接的客户端迟迟不发数据,线程就会一直阻塞在read()方法上,这样其让客户旧不能进行连接。

BIO多线程模型

进一步改进BIO单线程模型,采用BIO多线程模型:为每一个客户端连接分配一个线程处理请求,这样,read()方法旧阻塞在每一个具体的线程上,而不会阻塞主线程。

code案例:

public class redisServerBIOMultiThread {
    public static void main(String[] args) throws IOException {

        ServerSocket serverSocket = new ServerSocket(6379);

        while (true) {
            System.out.println("------111等待连接");
            Socket accept = serverSocket.accept();
            System.out.println("--------222 成功连接:" + IdUtil.simpleUUID());

            new Thread(new Runnable() {
                @SneakyThrows
                @Override
                public void run() {
                    InputStream inputStream = accept.getInputStream();
                    int length = -1;
                    byte[] bytes =  new byte[1024];
                    System.out.println("-------333等待读取");
                    while ((length = inputStream.read(bytes)) != -1) {
                        System.out.println("------444 成功读取 " + new String(bytes,0,length));
                        System.out.println("===================" + "\t" + IdUtil.simpleUUID());
                        System.out.println();
                    }
                    inputStream.close();
                    accept.close();
                }
            }).start();
        }
    }
}

存在的问题:为每一个客户端都开辟一个线程的话,并发量大的话,那么开辟的线程也将及其庞大,而在操作系统中用户态不能直接开辟线程,需要调用内核态来创建线程,其中会涉及到上下文的切换,十分耗资源。

改进BIO多线程模型:

方式一:使用线程池

在客户端连接少的情况下可以使用,但是用户量大的情况下,不知道线程池要多大,太大了内存可能不够,太小了也不行。

方式二:采用NIO(非阻塞式IO)方式

因为read方法阻塞了,所以要开辟多个线程,如果有什么方法能使read()方法不阻塞,这样就不用开辟多个线程了——NIO


NIO

在NIO模式中,一切都是非阻塞的:accept()方法式非阻塞的,如果没有客户端连接,就返回无连接标识;read()方法是非阻塞的,如果read()方法读取不到数据就返回空闲中标识,如果读取到数据那么只阻塞read()方法读数据的时间。

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

code案例:

public class redisServerNIO {
    static ArrayList<SocketChannel> socketList = new ArrayList<SocketChannel>();
    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);//设置为非阻塞模式
        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());
            }
        }
    }

}

NIO模式依然存在问题:

问题一:有一万个客户端进行连接,那么每次就要遍历一万个socket,如果一万个socket中只有10个socket有数据,就会做很多的无用功,每次遍历read()返回-1 时仍然时一次浪费资源的系统调用。

问题二:遍历socket的过程是在用户态,用户态哦按段socket是否有数据还是在内核态调用read()方法来实现的,还是会涉及到用户态和内核态的切换,开销依然很大。

抛开NIO存在的问题,其实上述的 NIO 模型的实现思路就是我们要讨论的IO多路复用(IO multiplexing)的思想。


IO multiplexing

IO multiplexing:就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。可以基于一个阻塞对象并同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程,每次new一个线程),这样可以大大节省系统资源。所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态。

对于NIO存在的问题二,我们只需要在操作系统内核中提供类似NIO模型的函数,就避免了用户态和内核态之间的切换,所以,在操作系统内核就有了select、poll、epoll函数来实现IOmultiplexing。

select函数:

其实类似于上述的NIO模型的代码逻辑。只不过把NIO中用户态要遍历的fd数组(socket连接数组)拷贝到了内核态,让内核态来遍历,这样就不用在判断每个socket中是否有数据的时候切换用户态和内核态了,问题二解决。

select函数是一个阻塞函数,当没有数据时会一直阻塞在select那一行。select函数使用bitmap来代替我们上述代码中的Arraylist,当有数据时会将bitmap中对应的位置置为1,但并不会返回哪一个socket有数据。用户态只需要遍历bitmap,看bitmap哪一位为1,就read哪一个socket,不在遍历每一个socket了,问题一解决。

select函数存在的问题:

1、每次的bitmap不可重用,只能置1,不能恢复

2、bitmap默认大小为1024,虽可调整但还是有限度

3、需要将fd数组从用户态拷贝到内核态,由内核态调用read(),但还是由拷贝的开销

4、select并没有通知用户态哪一个socket有数据,仍然要O(N)的遍历。

poll函数:

阻塞函数没有数据时会阻塞在poll那一行。用数组代替了bitmap,哪个socket有数据,就将对应的位置置为POLLIN(置1),遍历数组找到置1的socket后,再置0,恢复数组,便于重用。解决了select的问题1、2。

epoll函数:

非阻塞函数。当有数据的时候,会把相应的文件描述符(可以理解为socket)“置位”,但是epoll并不是真正的置位,这时候会把有数据的文件描述符放到队首,epoll会返回有数据的文件描述符(socket)的个数N,根据返回的个数只需要遍历钱N个文件描述符即可。解决了select的问题4

epoll函数对select的问题3的解决的处理,看了大牛的帖子,是这样解决的,但还不是很理解,记录一下:

  epoll函数之所以不需要用户态将文件描述符(fd)数组拷贝到内核态,是因为它利用了操作系统内核的事件表机制,可以直接操作内核态中的数据结构。

        当使用epoll机制时,首先通过epoll_create函数创建一个epoll实例,该实例会在内核态中创建一个事件表。然后,使用epoll_ctl函数向事件表中添加感兴趣的文件描述符和关注的事件类型。

        在添加文件描述符到事件表时,内核会直接引用用户程序传递的文件描述符,而不是将文件描述符数组从用户态拷贝到内核态。这样可以避免不必要的内存拷贝开销。

        当调用epoll_wait函数等待事件发生时,内核会阻塞等待,直到有事件发生或超时。一旦有事件就绪,内核会将就绪的事件信息直接填充到用户程序提供的事件数组中,而不需要再次进行内存拷贝。这样用户程序可以直接访问内核态中的事件信息。

        通过避免文件描述符数组的用户态到内核态的拷贝,epoll能够提高效率并减少系统调用的开销,特别是在大规模的并发连接处理中。这是epoll相比于传统的selectpoll机制更高效的一个重要原因。

redis是单线程模型之所以这么快的根本原因也是因为底层使用epoll函数实现了IO多路复用,将连接信息和事件放到队列中(通过IO多路复用),一次放到事件分配器,事件分配器将事件分发给事件处理器

文件事件分配器:

文件事件分派器是Redis事件驱动模型的核心组件。它使用I/O多路复用机制(如select、poll、epoll等)来监视文件描述符(包括网络套接字和文件描述符)的就绪状态。当一个文件描述符就绪时(如可读或可写),文件事件分派器会将就绪事件分派给相应的事件处理器进行处理。

事件处理器:

事件处理器是一组处理不同类型事件的回调函数。每种事件类型都有对应的事件处理器。当文件事件分派器将一个就绪事件分派给事件处理器时,相应的事件处理器会被调用来处理事件。事件处理器根据事件类型执行相应的逻辑,如处理客户端请求、读取或写入数据等。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值