网络编程–多路复用器select、poll、epol,javaNIO原理和实现

之前已经说过了BIO模型的原理和实现,并根据其不足(阻塞,多线程资源消耗等),介绍了内核的升级实现了accpet和read不阻塞的方法,以及介绍了channel和buffer的模型和实现。

上篇结束的时候提到了NIO(os层面)不足之处

java多路分发 java多路复用原理_java多路分发


承接上文,如果有很多的链接进来,单纯的NIO的使用,我们程序需要对所有链接进行地毯式的遍历,监听所有链接事件,大致java实现模型如下:

java多路分发 java多路复用原理_java_02

既然知道了上述模型的弊端,就会有解决的办法:如果程序不需要每次都地毯式的遍历所有链接,而是我们将所有链接(文件描述符)都告诉内核,内核只返回给我们其中有状态的IO,我们程序中只对这些链接进行遍历不就行了。

那接下来就说说这样实现思想的多路复用器的使用。

Select、poll(实现模型类似,当前以select讲解)

int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);

监视并等待多个文件描述符的属性变化(可读、可写或错误异常)。select()函数监视的文件描述符分 3 类,分别是writefds、readfds、和 exceptfds。调用后 select() 函数会阻塞,直到有描述符就绪(有数据可读、可写、或者有错误异常),或者超时( timeout 指定等待时间),函数才返回。当 select()函数返回后,可以通过遍历 fdset,来找到就绪的描述符。

大致模型如下:

java多路分发 java多路复用原理_java多路分发_03

将需要监控的文件描述符作为参数传递给select,select会只返回有事件的资源信息,这样就避免了地毯式遍历,浪费资源。
那么select又是怎么知道哪些是有事件的资源呢?或者说内核又是怎么实现select的呢?

select需要驱动程序的支持,驱动程序实现fops内的poll函数。select通过每个设备文件对应的poll函数提供的信息判断当前是否有资源可用(如可读或写),如果有的话则返回可用资源的文件描述符个数,没有的话则睡眠,等待有资源变为可用时再被唤醒继续执行。

  1. select的睡眠过程

支持阻塞操作的设备驱动通常会实现一组自身的等待队列如读/写等待队列用于支持上层(用户层)所需的BLOCK或NONBLOCK操作。当应用程序通过设备驱动访问该设备时(默认为BLOCK操作),若该设备当前没有数据可读或写,则将该用户进程插入到该设备驱动对应的读/写等待队列让其睡眠一段时间,等到有数据可读/写时再将该进程唤醒。

select就是巧妙的利用等待队列机制让用户进程适当在没有资源可读/写时睡眠,有资源可读/写时唤醒。

下面我们看看select睡眠的详细过程。

select会循环遍历它所监测的fd_set内的所有文件描述符对应的驱动程序的poll函数。驱动程序提供的poll函数首先会将调用select的用户进程插入到该设备驱动对应资源的等待队列(如读/写等待队列),然后返回一个bitmask告诉select当前资源哪些可用。当select循环遍历完所有fd_set内指定的文件描述符对应的poll函数后,如果没有一个资源可用(即没有一个文件可供操作),则select让该进程睡眠,一直等到有资源可用为止,进程被唤醒(或者timeout)继续往下执行。

通过上述的理解,select多路复用器有什么弊端呢?
我们发现

  1. 每次都要通过参数传递的形式将文件描述符传给select,进行对每个文件描述监听。过程中有一个文件描述符的拷贝过程,从用户控件–> 内核空间。
  2. select每次都需要对所有的文件描述符进行遍历,消耗性能。

针对上面问题,有什么改变的思想呢?
如果不用每次都将文件描述符拷贝给内核,不用每次都遍历所有文件描述符,而是让内核自己记录好所有的有事件的文件描述符,我们最终去取就行了。

2. epoll讲解

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。

epoll 的3个系统调用:

  1. epoll_create :创建一个epoll对象的文件描述符,同时开辟一个内核空间,将自己放到该空间中,空间的数据结构为红黑树。
  2. epoll_ctl : 将所有需要监听的链接加入到空间中。
  3. epoll_wait : 返回所有有时间的集合链表 。

实现模型如下:

java多路分发 java多路复用原理_java多路分发_04


通过以上了解,我们对比下selet,epoll不在需要从用户端拷贝fd_set到内核空间,而且也不用对任何fd进行遍历操作,时间复杂度从O(n)直接降到了O(1),现实中,基本上都不使用select和poll,面对多连接的并发场景,基本上都选择使用epoll。

JAVA NIO对多路复用器的整合实现Selector

以上讲些的多路复用器的实现原理实现都是C和部分系统调用相关的代码,那么JAVA是怎么使用的多路复用器呢?

java在1.4之后推出了NIO(new nio) 其中一个核心组件就是selector,它就是对多路复用器的封装实现。(注:selector中的多路复用器可能是select、poll、epoll其中任意一个)

Selector 一般称 为选择器 ,当然你也可以翻译为 多路复用器 。它是Java NIO核心组件中的一个,用于检查一个或多个NIO Channel(通道)的状态是否处于可读、可写。如此可以实现单线程管理多个channels,也就是可以管理多个网络链接。
使用Selector的好处在于: 使用更少的线程来就可以来处理通道了, 相比使用多个线程,避免了线程上下文切换带来的开销。

实现模型;

java多路分发 java多路复用原理_java_05


java代码演示,实现聊天功能:

package main.java.com.nio;

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

/**
 * Created by cmm on 2021/6/12.
 */
public class NioServer {
    private ServerSocketChannel serverSocketChannel;
    private Selector selector ;
    NioServer(){
        try {
            // 1. 创建服务器端通道
            serverSocketChannel = ServerSocketChannel.open();
            // 2. 设置成非阻塞状态
            serverSocketChannel.configureBlocking(false);
            // 3. 创建一个seletor选择器
            /**
            创建多路复用器:优先epoll
            epoll : 调用epoll_create 创建epoll对象,并开辟一个内存空间
            select | poll : 创建一个数组来存放文件描述符
            */
            selector = Selector.open();
            // 4. 建立服务器端
            serverSocketChannel.bind(new InetSocketAddress("127.0.0.1",9999));
            
            // 5. 将该服务器通道注册到选择器,并开始监控accpet事件
             /**
             select | poll : 将文件描述符fd 放入数组中。
             epoll : 调用epoll_ctl,将文件描述符fd放入内存空间。
            */
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
            System.out.print("服务器建立成...");

            lisent(selector);
        }catch (Exception e){
            System.out.print("服务器建立异常");
        }
    }

    // 实施监听
    private void lisent(Selector selector) {
        while (true){
            try {
            /**
              select | poll : 查看数组,返回所有fd
              epoll : 查看红黑树中的fd
              */
              selector.keys()
  
               /**
                   select | poll : 传入df,遍历查找有事件状态的IO
                   epoll :调用epoll_wait 
                */
                if(selector.select(1000)>0){
                     // 1. 将活动的事件接收selector.selectedKeys()---获取所有活动的事件
                    Iterator iterator = selector.selectedKeys().iterator();
                    while(iterator.hasNext()){
                        // 2. 迭代遍历一个个有事件的selectionkey
                         SelectionKey selectionKey = (SelectionKey) iterator.next();
                         if(selectionKey.isAcceptable()){
                             // 3.接收链接
                             SocketChannel socketChannel = serverSocketChannel.accept();
                             socketChannel.configureBlocking(false);
                             if(socketChannel.finishConnect()){
                                 System.out.println("欢迎【客户端】"+socketChannel.getRemoteAddress()+"登录");
                                 // 4.并将客户端也加入selector管理,同时开启read事件监听
                                 socketChannel.register(selector,SelectionKey.OP_READ);
                             }
                         }
                         // 5 处理可读事件
                         if(selectionKey.isReadable()){
                             ReadContent(selectionKey);
                         }
                         iterator.remove();
                    }

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

    // 读取客户端信息
    private void ReadContent(SelectionKey selectionKey) throws IOException {
        // 1. 接收客户端channel、
        SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
        // 2. 创建缓冲区
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        // 3. 读取信息
        try {
            int read = socketChannel.read(byteBuffer);
            if(read>0){
                System.out.println("【客户端】"+socketChannel.getRemoteAddress()+"说:"+new String(byteBuffer.array()));
                // 信息转发给其他客户端
                for(SelectionKey selectionKeyother : selector.keys()){
                    Channel channel = selectionKeyother.channel();
                    // 屏蔽自己
                    if(channel instanceof SocketChannel && socketChannel!=channel){
                        SocketChannel socketChannelohter1 = (SocketChannel) channel;
                        socketChannelohter1.write(ByteBuffer.wrap(byteBuffer.array()));
                    }
                }
            }
        }catch (Exception e){
            selectionKey.cancel();
            System.out.println("【客户端】"+socketChannel.getRemoteAddress()+"已下线");
        }
    }

    public static void main(String[] args) {
        new NioServer();
    }
}

总结

服务端将ServerSocketChannel注册到Selector,Selector轮训,通过selector.select()阻塞判断是否有监听事件到达,如果有事件到达,获取到事件SelectionKey的集合,通过迭代SelectionKey集合,判断事件类型,如果是连接事件,则把当前Channel注册到Selector,如果当前Channel有数据可读,则执行相应的操作,执行完成后移除当前SelectionKey,继续迭代处理下一个SelectionKey。

既然如上面所说, Oracle JDK在Linux已经默认使用epoll方式, 为什么netty还要提供一个基于epoll的实现呢?
这是stackoverflow上的一个问题。 Netty的核心开发者 Norman Maurer这么说的:

1. Netty的 epoll transport使用 epoll edge-triggered 而 java的 nio 使用   
level-triggered。
2.另外netty epoll transport 暴露了更多的nio没有的配置参数, 如   
TCP_CORK, SO_REUSEADDR等等

所以后续开始说说Netty的模型和实现。