简述BIO、NIO、epoll与Netty

BIO与NIO

BIO:同步阻塞式IO,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。
NIO:同步非阻塞式IO,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。

这是传统意义上对BIO与NIO的定义。
下面举个例子来看一下BIO到底阻塞在了哪里,BIO为什么这么慢。

BIO为什么慢


/**
 * 单线程BIOServer
 */
public class BioServerDemo {
  public static void main(String[] args) throws IOException {
	// 创建ServerSocket,并绑定端口
    ServerSocket serverSocket = new ServerSocket(8888);
    while (true) {
      Socket socket = serverSocket.accept();//阻塞
      byte[] bytes = new byte[1024];
      socket.getInputStream().read(bytes);//阻塞
      System.out.println("接收到了数据:" + new String(bytes));
    }
  }
}

可以看到Server会在一个死循环中,处理客户端的连接或者读写。
首先是阻塞在serverSocket.accept(),知道有客户端连接,才会继续往下走。
其次是阻塞在,socket.getInputStream().read(bytes),等待客户端的数据传输。
当然,一个真正的Sever不会在一个单线程中,只处理一个Socket的读写。


/**
 * BIOServer
 */
public class BioServerDemo {
  public static void main(String[] args) throws IOException {
    ServerSocket serverSocket = new ServerSocket(8888);
    while (true) {
      Socket socket = serverSocket.accept();
      //放到一个新的线程中去处理读写,可以使用线程池优化
      new Thread(()->{
      	byte[] bytes = new byte[1024];
        try {
          socket.getInputStream().read(bytes);
        } catch (IOException e) {
          e.printStackTrace();
        }
        System.out.println("接收到了数据:" + new String(bytes));
      }).start();
    }
  }
}

可以看到,每次得到一个Socket之后,Server都会创建一个新的线程,去处理Socket的读写。
但是这里会有一个问题,就是如果绝大部分的连接都没有进行数据传输,只是建立了连接,这样就会产生很多无效的线程,如果有一万个连接过来,那么我们是不是有可能会有一万个线程在等待呢?
这是BIO的瓶颈之一,另一个瓶颈则涉及到select与epoll的原理。
因此,BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高。

什么是阻塞

操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得cpu使用权,正在执行代码的状态;等待状态是阻塞状态,比如上述程序运行到read时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。

当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程。

创建socket

当程序执行到read时,操作系统会将进程A从工作队列移动到该socket的等待队列中(如下图)。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用cpu资源。

socket的等待队列
当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,read可以返回接收到的数据。

唤醒进程

NIO的进化

首先给一个Java NIO 服务端代码。

public class MyNIOServer {
    public static void main(String[] args) throws Exception{

        //创建ServerSocketChannel
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        //得到一个Selecor对象
        Selector selector = Selector.open();
        //绑定端口8888
        serverSocketChannel.socket().bind(new InetSocketAddress(8888));
        //非阻塞
        serverSocketChannel.configureBlocking(false);
        //把 serverSocketChannel注册到selector只关心OP_ACCEPT事件
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        while (true) {
            if(selector.select() == 0) {
                System.out.println("无连接");
                continue;
            }
            //返回触发事件的集合
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            System.out.println("selectionKeys size = " + selectionKeys.size());
            Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
            while (keyIterator.hasNext()) {
                //获取到SelectionKey
                SelectionKey key = keyIterator.next();
                //根据key 对应的通道发生的事件做相应处理
                if(key.isAcceptable()) { //如果是 OP_ACCEPT, 有新的客户端连接
                    //该该客户端生成一个 SocketChannel
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    System.out.println("客户端连接成功 生成了一个 socketChannel " + socketChannel.hashCode());
                    //将  SocketChannel 设置为非阻塞
                    socketChannel.configureBlocking(false);
                    //将socketChannel 注册到selector, 关注事件为 OP_READ, 同时给socketChannel关联一个Buffer
                    socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                    System.out.println("客户端连接后 ,注册的selectionkey 数量=" + selector.keys().size()); 
                }
                //发生 OP_READ
                if(key.isReadable()) {  
                    //通过key 反向获取到对应channel
                    SocketChannel channel = (SocketChannel)key.channel();
                    //获取到该channel关联的buffer
                    ByteBuffer buffer = (ByteBuffer)key.attachment();
                    channel.read(buffer);
                    System.out.println("form 客户端 " + new String(buffer.array()));
                }
                //手动从集合中移动当前的selectionKey, 防止重复操作
                keyIterator.remove();
            }
        }
    }
}

可以看到这个NIO的Server端虽然是单线程的,但是他的性能其实不差,因为其中的selector.select()方法不会阻塞线程,当Socket有连接或者读事件的时候,才会取得对应的SocketChannel进行处理,否则将一直轮询等待,这个方法的奥秘就在底层的epoll原理中。

epoll 与 select 原理简述

此select 非NIO的select()方法
参考知乎,如果这篇文章说不清epoll的本质,那就过来掐死我吧!: https://zhuanlan.zhihu.com/p/64138532.
参考博客,https://blog.csdn.net/daaikuaichuan/article/details/83862311

epoll的要义是高效的监视多个socket。
假如能够预先传入一个socket列表,如果列表中的socket都没有数据,挂起进程,直到有一个socket收到数据,唤醒进程。这种方法很直接,也是select的设计思想。

为方便理解,我们先复习select的用法。在如下的代码中,先准备一个数组(下面代码中的fds),让fds存放着所有需要监视的socket。然后调用select,如果fds中的所有socket都没有数据,select会阻塞,直到有一个socket接收到数据,select返回,唤醒进程。用户可以遍历fds,通过FD_ISSET判断具体哪个socket收到数据,然后做出处理。

int s = socket(AF_INET, SOCK_STREAM, 0);  
bind(s, ...)
listen(s, ...)

int fds[] =  存放需要监听的socket

while(1){
	//可能有多个socket就绪
    int n = select(..., fds, ...)
    //轮询所有socket查看是否有数据要处理
    for(int i=0; i < fds.count; i++){
        if(FD_ISSET(fds[i], ...)){
            //fds[i]的数据处理
        }
    }
}

select的流程

select的实现思路很直接。假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个socket的等待队列中。

操作系统把进程A分别加入这三个socket的等待队列中
当任何一个socket收到数据后,中断程序将唤起进程。下图展示了sock2接收到了数据的处理流程。
所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。
在这里插入图片描述
经由这些步骤,当进程A被唤醒后,它知道至少有一个socket接收了数据。程序只需遍历一遍socket列表,就可以得到就绪的socket。
缺点有二:

  1. 每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。(写死在头文件中,poll进行了改善可以传入一个数)
  2. 进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。

epoll解决了这些问题。

epoll流程

为方便理解后续的内容,我们先复习下epoll的用法。如下的代码中,先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。

int s = socket(AF_INET, SOCK_STREAM, 0);   
bind(s, ...)
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中

while(1){
	//检查一下rdllist有没有数据
    int n = epoll_wait(...)
    for(接收到数据的socket){
        //处理
    }
}

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关,如下所示:

struct eventpoll {
  ...
  /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
  也就是这个epoll监控的事件*/
  struct rb_root rbr;
  /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
  struct list_head rdllist;
  ...
};

我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。

在epoll中对于每一个事件都会建立一个epitem结构体,如下所示:

struct epitem {
  ...
  //红黑树节点
  struct rb_node rbn;
  //双向链表节点
  struct list_head rdllink;
  //事件句柄等信息
  struct epoll_filefd ffd;
  //指向其所属的eventepoll对象
  struct eventpoll *ep;
  //期待的事件类型
  struct epoll_event event;
  ...
}; // 这里包含每一个事件对应着的信息。

当调用epoll_wait检查是否有发生事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此epoll_wait效率非常高。epoll_ctl在向epoll对象中添加、修改、删除事件时,从rbr红黑树中查找事件也非常快,也就是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接。

总结:
一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。

  1. 执行epoll_create()时,创建了红黑树和就绪链表;
  2. 执行epoll_ctl()时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据;
  3. 执行epoll_wait()时立刻返回准备就绪链表里的数据即可。

Netty 与 NIO

整理自 尚硅谷韩顺平Netty 相关课程。

原生NIO存在的问题

NIO 的类库和 API 繁杂,使用麻烦:需要熟练掌握 SelectorServerSocketChannelSocketChannelByteBuffer 等。

需要具备其他的额外技能:要熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor模式,你必须对多线程和网络编程非常熟悉,才能编写出高质量的 NIO 程序。

开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常流的处理等等。

JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。直到 JDK 1.7 版本该问题仍旧存在,没有被根本解决。

Netty的优点

Netty对JDK自带的NIO的API进行了封装,解决了上述问题。

设计优雅:适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;高度可定制的线程模型 - 单线程,一个或多个线程池.

安全:完整的 SSL/TLSStartTLS 支持

高性能、吞吐量更高:延迟更低;减少资源消耗;最小化不必要的内存复制。

Netty模型

Netty主要是基于主从Reactor多线程模式做了一定的改进,其中主从Reactor都有单一的一个变成了多个。下面是简单的改进图。

在这里插入图片描述

代码演示

public class NettyServer {
    public static void main(String[] args) throws InterruptedException {
        //创建BossGroup 和 WorkerGroup
        //1、创建两个线程组,bossGroup 和 workerGroup
        //2、bossGroup 只是处理连接请求,真正的和客户端业务处理,会交给 workerGroup 完成
        //3、两个都是无限循环
        //4、bossGroup 和 workerGroup 含有的子线程(NioEventLoop)个数为实际 cpu 核数 * 2
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup worderGroup = new NioEventLoopGroup();

        try {
            //创建服务器端的启动对象,配置参数
            ServerBootstrap bootstrap = new ServerBootstrap();

            //使用链式编程来进行设置,配置
            bootstrap.group(bossGroup, worderGroup) //设置两个线程组
                    .channel(NioServerSocketChannel.class) //使用 NioServerSocketChannel 作为服务器的通道实现
                    .option(ChannelOption.SO_BACKLOG, 128) //设置线程队列得到连接个数
                    .childOption(ChannelOption.SO_KEEPALIVE, true) //设置保持活动连接状态
                    .childHandler(new ChannelInitializer<SocketChannel>() { //为accept channel的pipeline预添加的handler
                        //给 pipeline 添加处理器,每当有连接accept时,就会运行到此处。
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            socketChannel.pipeline().addLast(new NettyServerHandler());
                        }
                    }); //给我们的 workerGroup 的 EventLoop 对应的管道设置处理器

            System.out.println("........服务器 is ready...");
            //绑定一个端口并且同步,生成了一个ChannelFuture 对象
            //启动服务器(并绑定端口)
            ChannelFuture future = bootstrap.bind(8888).sync();
            //对关闭通道进行监听
            future.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            worderGroup.shutdownGracefully();
        }
    }
}


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值