网络与IO知识扫盲(五):从 NIO 到多路复用器

网络与IO扫盲 专栏收录该内容
7 篇文章 3 订阅

NIO 的优劣

优势:相比 BIO 来说,NIO 可以通过1个或几个线程,来解决 N 个 IO 连接的处理
弊端:当有大量文件描述符存在时,不管你用多少个线程,都是O(n)复杂度的recv调用,需要用户态内核态切换才能实现,而这些调用有很多是无意义的(有数据返回数据,无数据返回-1),浪费资源。
read是无罪的,大量无效的read被调用才是性能损耗的关键。

下图:左侧是 NIO,右侧是多路复用器。
在这里插入图片描述

多路复用器的实现

常问的几个概念

(我们先只关注IO,不关注IO之后的处理)
同步:application 自己读写内容
异步:由 kernel 完成 IO 内容的读写,写到进程的一个 buffer 区域里,看起来好像程序没有访问 IO,只是访问了 buffer 就能拿到数据(实际上是在IO注册了一些回调),只有 windows 上的 iocp 是纯异步的
阻塞:Blocking,如果没有则等待
非阻塞:Non-Blocking,一定能拿到返回值,就算没有数据,也会返回-1

目前来说,在Linux以及主流成熟框架中,我们常用的是同步阻塞、同步非阻塞的组合。

通过多路复用器只能获取状态,最终还是需要由程序对有状态的IO进行读/写。
只要程序自己读写,那么你的IO模型就是同步的。(而不是你读完了IO数据之后的处理的同步或异步)

多路复用器:select, poll, epoll 都是多路复用器,都属于同步状态下非阻塞的模型。
select 是在不同操作系统中很容易实现的,不依赖特定的软硬件的一个系统调用。
epoll 要求内核当中要求一定的实现,在linux上是epoll,在unix上是kqueue。技术是随着问题的产生一步步发展起来的。
这两者都是基于IO事件的一种通知行为。

异步阻塞是没有意义的。异步都是用非阻塞。
在这里插入图片描述

关于为什么linux目前没有通用的内核异步处理方案,因为这样不安全,会让linux的内核做的事情太多,容易出bug。windows敢于这么做,是因为windows的市场比较广,一方面是用户市场,一方面是服务器市场,它的市场比较广,况且windows比较注重用户市场,所以敢于把内核做的胖一些,也是因此虽然现在已经win10了,但是蓝屏啊,死机啊,挂机啊这些问题也还是会出现。Linux现在6.x版本当中,对异步也开始上心了。

select

man select 帮助文档中的描述:
在这里插入图片描述
翻译:select()和pselect()允许程序监视多个文件描述符,直到其中一个或多个文件描述符为某种I/O操作(如输入可能)“准备好”。如果文件描述符可以不阻塞地执行相应的I/O操作(如read(2)),则认为它已经准备好了。

select在linux中有一个FD_SETSIZE(大小为1024)的限制,所以现在一般不用select了

在这里插入图片描述
其实,无论是NIO,还是SELECT,还是POLL,这些多路复用器都是要遍历所有的IO询问状态。

只不过,在NIO中,这个遍历的成本在用户态到内核态的切换。
但是在SELECT、POLL的模型下,遍历的过程触发了一次系统调用(用户态到内核态的切换),过程中把很多的fd文件描述符传递给内核,内核重新根据用户这次调用传过来的所有fd,遍历并修改状态。每次都要重新重复传递fd。

所以多路复用器在这个时期,就已经比NIO快了。

SELECT、POLL的弊端在于,每次都要重新传递fd,造成每次内核被调用之后,针对这次调用都要触发一个fd的全量遍历的复杂度。

这里插入一个概念

在内存中,有 kernel,有 app 等等的这些程序
软中断: trap int 80
硬中断:时钟中断(晶振)
IO中断:网卡、硬盘、鼠标

关于网络IO中断
最开始的时候,网卡来了IO数据包的时候,是可以产生中断的,这时候就会打断CPU,将输入的数据存到内存中。
后来经过改进,网卡是有buffer的,在内存中开辟一个DMA区域,专门给网卡用,网卡可以收集很多数据之后积攒起来,积攒到一定量之后一起发给DMA。

中断会产生callback回调函数
event有事件,就要去处理
在epoll之前的callback,只是完成了将网卡发来的数据,走一下内核的网络协议栈(2链路,3网络,4传输层),最终关联到fd的buffer里面
所以你在某一时间,如果从application询问内核某一个或者某些fd是否可读/可写,会有状态返回。

如果内核在回调的处理中,再加入(?红黑树、list),就有了多selector
在这里插入图片描述

epoll

epoll 规避了遍历的问题。

帮助手册:
在这里插入图片描述
翻译:

epoll API执行与poll(2)类似的任务:监视多个文件描述符,看看其中任何一个文件描述符上是否有I/O。epoll API既可以用作边缘触发接口,也可以用作级别触发接口,可以很好地扩展到大量监视的文件描述符。提供以下系统调用来创建和管理一个epoll实例:

  • epoll_create(2)创建一个epoll实例,并返回引用该实例的文件描述符。(最近的epoll_create1(2)扩展了epoll_create(2)的功能。)
  • 然后通过epoll_ctl(2)注册对特定文件描述符的兴趣。当前在epoll实例上注册的文件描述符集有时称为epoll集。
  • epoll_wait(2)等待I/O事件,如果当前没有可用的事件,则阻塞调用线程。

epoll_create
在内核中开辟一块空间,用来放红黑树

epoll_ctl
添加、修改、删除某一个文件描述符,并记录关注它的哪些事件(如read事件)

epoll_wait
epoll_wait 在等待从红黑树复制过来的一个链表

下图:epoll(左) 与 select/poll 的本质区别(右):
epoll 已经悄悄地将结果集给你准备好了,你需要有状态的结果集fds的时候,直接取就可以了。它不传递 fds, 也不触发内核遍历。
在这里插入图片描述
讲了这么多操作系统内核提供的多路复用器,最终我们都要回归到受制于Java对于这些系统调用的包装:Selector

回归到 Java 代码

SocketMultiplexingSingleThreadv1.java

package com.bjmashibing.system.io;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;

public class SocketMultiplexingSingleThreadv1 {

    //这个代码看不懂的话,可以去看马老师的坦克 一、二期(netty)
    private ServerSocketChannel server = null;
    private Selector selector = null;   //linux 多路复用器(select poll epoll kqueue) nginx  event{}
    int port = 9090;

    public void initServer() {
        try {
            server = ServerSocketChannel.open();
            server.configureBlocking(false); // 设置成非阻塞
            server.bind(new InetSocketAddress(port));  // 绑定监听的端口号

            //如果在epoll模型下,Selector.open()其实完成了epoll_create,可能给你返回了一个 fd3
            selector = Selector.open();  // 可以选择 select  poll  *epoll,在linux中会优先选择epoll  但是可以在JVM使用-D参数修正

            //server 约等于 listen 状态的 fd4
            /*
                register 初始化过程
                如果在select,poll的模型下,是在jvm里开辟一个数组,把fd4放进去
                如果在epoll的模型下,调用了epoll_ctl(fd3,ADD,fd4,关注的是EPOLLIN
             */
            server.register(selector, SelectionKey.OP_ACCEPT);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start() {
        initServer();
        System.out.println("服务器启动了。。。。。");
        try {
            while (true) {  //死循环
                Set<SelectionKey> keys = selector.keys();
                System.out.println(keys.size() + "   size");
                //1,调用多路复用器(select,poll or epoll(实质上是调用的epoll_wait))
                /*
                    java中的select()是啥意思:
                    1,如果用select,poll 模型,其实调的是内核的select方法,并传入参数(fd4),或者poll(fd4)
                    2,如果用epoll模型,其实调用的是内核的epoll_wait()
                    注意:参数可以带时间。如果没有时间,或者时间是0,代表阻塞。如果有时间,则设置一个超时时间。
                         方法selector.wakeup()可以外部控制让它不阻塞。这时select的结果返回是0。
                 */
                while (selector.select(500) > 0) {
                    Set<SelectionKey> selectionKeys = selector.selectedKeys();  //拿到返回的有状态的fd集合
                    Iterator<SelectionKey> iter = selectionKeys.iterator();  // 转成迭代器
                    //所以,不管你是啥多路复用器,你只能告诉我fd的状态,我还得一个一个的去处理他们的R/W。同步好辛苦!!!
                    //我们之前用NIO的时候,需要自己对着每一个fd调用系统调用,浪费资源,那么你看,这里是不是调用了一次select方法,知道具体的那些可以R/W了?是不是很省力?
                    while (iter.hasNext()) {
                        SelectionKey key = iter.next();
                        iter.remove(); //这时一个set,不移除的话会重复循环处理
                        if (key.isAcceptable()) { //我前边强调过,socket分为两种,一种是listen的,一种是用于通信 R/W 的
                            //这里是重点,如果要去接受一个新的连接
                            //语义上,accept接受连接且返回新连接的FD,对吧?
                            //那新的FD怎么办?
                            //如果使用select,poll的时候,因为他们内核没有空间,那么在jvm中保存,和前边的fd4那个listen的放在一起
                            //如果使用epoll的话,我们希望通过epoll_ctl把新的客户端fd注册到内核空间
                            acceptHandler(key);
                        } else if (key.isReadable()) {
                            readHandler(key);
                            //在当前线程,这个方法可能会阻塞,如果阻塞了十年,其他的IO早就没电了。。。
                            //所以,为什么提出了 IO THREADS,我把读到的东西扔出去,而不是现场处理
                            //你想,redis是不是用了epoll?redis是不是有个io threads的概念?redis是不是单线程的?
                            //你想,tomcat 8,9版本之后,是不是也提出了一种异步的处理方式?是不是也在 IO 和处理上解耦?
                            //这些都是等效的。
                        }
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void acceptHandler(SelectionKey key) {
        try {
            ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
            SocketChannel client = ssc.accept(); //来啦,目的是调用accept接受客户端  fd7
            client.configureBlocking(false);

            ByteBuffer buffer = ByteBuffer.allocate(8192);  //前边讲过了

            // 0.0  我类个去
            //你看,调用了register
            /*
                select,poll:    jvm里开辟一个数组 fd7 放进去
                epoll:          epoll_ctl(fd3,ADD,fd7,EPOLLIN
             */
            client.register(selector, SelectionKey.OP_READ, buffer);
            System.out.println("-------------------------------------------");
            System.out.println("新客户端:" + client.getRemoteAddress());
            System.out.println("-------------------------------------------");

        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void readHandler(SelectionKey key) {
        SocketChannel client = (SocketChannel) key.channel();
        ByteBuffer buffer = (ByteBuffer) key.attachment();
        buffer.clear();
        int read = 0;
        try {
            while (true) {
                read = client.read(buffer);
                if (read > 0) {
                    buffer.flip();
                    while (buffer.hasRemaining()) {
                        client.write(buffer);
                    }
                    buffer.clear();
                } else if (read == 0) {
                    break;
                } else {
                    client.close();
                    break;
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        SocketMultiplexingSingleThreadv1 service = new SocketMultiplexingSingleThreadv1();
        service.start();
    }
}
  • 0
    点赞
  • 0
    评论
  • 3
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

相关推荐
©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值