高性能IO之Epoll

高性能IO操作在互联网应用中尤为重要,如游戏,社交等,常用的服务端框架如netty,mina等,缓存框架如redis, ignite等,要了解这些框架高性能的原因,就要了解底层IO模式。

使用Netty构建服务器时,需要指定parent线程池和child线程池,parent线程负责监听端口,一旦有连接接入,则注册到child线程池中的一个线程上,该连接的IO操作/任务都由该线程完成。
换句话说,一个线程会负责多个连接的IO操作,也就是多路复用。Netty底层是使用系统提供的select或者epoll来实现多路复用的。

先来科普下select/poll/epoll。

select/poll

服务端建立每个连接,相当于打开文件,会获得对应的文件描述符(fd),相同的源IP/源端口/目标IP/目标端口对应同一个fd。

select和poll是相似的,不一样的地方是,select是使用数组,有连接数限制,而poll使用链表,无连接数限制。

监听连接时,从用户层的角度看,
(1)会构建3个fd数组,分别关注读/写/异常事件,设置超时时间,调用系统提供的select方法。
(2)调用select方法时,需要将fd数组传到内核态,等待部分fd就绪后,把fd数组(包含就绪状态)返回到用户态
(3)用户程序对fd数组进行遍历,处理就绪的fd
(4)重新调用select方法。

可以看出不好的地方是
(1)每次都要传入fd数组,返回整个fd数组,导致了大量在用户空间和内核空间的相互拷贝。
(2)用户程序仍需要遍历fd数组才能找出就绪的fd

从系统层的角度看,调用select方法时
(1)遍历fd数组,对于每个fd,调用其对应的poll方法(由设备对应的驱动程序实现),将fd所在线程加入等待队列,并且检查就绪状态,记录感兴趣的就绪状态。
(2)如果存在感兴趣的就绪状态,直接返回
(3)如果不存在感兴趣的就绪状态,进入休眠,等待fd就绪后,会唤醒等待队列中的线程
(4)被唤醒后,重复1-4的操作。

可以看出不好的地方是每次都需要检查所有fd。

epoll

epoll相对select改善了很多。
(1)在使用epoll时,首先会构建epoll对象。
(2)有连接接入时,会插入到epoll对象中,epoll对象里实际是一个红黑树+双向链表,fd插入到红黑树中,通过红黑树查找到是否重复
(3)一旦fd就绪,会触发回调把fd的插入到就绪链表中,并唤醒等待队列中的线程。
(4)调用epoll_wait方法时只需要检查就绪链表,如有则返回给用户程序,如没有进入等待队列。

由于epoll把fd管理起来,不需要每次都重复传入,而且只返回就绪的fd,因此减少了用户空间和内核空间的相互拷贝,在fd数量庞大的时候更加高效。

Netty可以选择使用不同的多路复用技术。

I/O模型 BIO

阻塞I/O(Blocking I/O BIO)

应用程序进程/线程如果发起1K个请求,则开启1K个socket文件描述符,socket在等待内核返回数据时是阻塞式的,数据未准备好就一直阻塞等待,一次只会返回一个socket结果,直到返回数据后才等待下一个socket的返回

1.png

BIO Server端代码

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket ss = new ServerSocket();
        ss.bind(new InetSocketAddress("127.0.0.1", 8888));
        while(true) {
            Socket s = ss.accept(); //阻塞方法
            
            //新起线程来处理client端请求,让主线程可以接受下一个client的accept
            new Thread(() -> {
                handle(s);
            }).start();
        }
    }

    static void handle(Socket s) {
        try {
            byte[] bytes = new byte[1024];
            int len = s.getInputStream().read(bytes);  //阻塞方法
            System.out.println(new String(bytes, 0, len));

            s.getOutputStream().write(bytes, 0, len);  //阻塞方法
            s.getOutputStream().flush();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

BIO Client端代码

public class Client {
    public static void main(String[] args) throws IOException {
        Socket s = new Socket("127.0.0.1", 8888);
        s.getOutputStream().write("HelloServer".getBytes());
        s.getOutputStream().flush();
        //s.getOutputStream().close();
        System.out.println("write over, waiting for msg back...");
        byte[] bytes = new byte[1024];
        int len = s.getInputStream().read(bytes);
        System.out.println(new String(bytes, 0, len));
        s.close();
    }
}

轮询非阻塞I/O(Non-Blocking I/O NIO)

应用进程如果发起1K个请求,则在用户空间不停轮询这1K个socket文件描述符,查看是否有结果返回。这种方法虽然不阻塞,但是效率太低,有大量无效的循环

2.png

单线程NIO Server代码:
一个selector做所有的事,既有accept、又有read

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.socket().bind(new InetSocketAddress("127.0.0.1", 8888));
        ssc.configureBlocking(false);

        System.out.println("server started, listening on :" + ssc.getLocalAddress());
        Selector selector = Selector.open();
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        while(true) {
            selector.select(); //阻塞方法
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> it = keys.iterator();
            while(it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();
                handle(key);
            }
        }

    }

    private static void handle(SelectionKey key) {
        if(key.isAcceptable()) { //判断为连接请求
            try {
                ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
                SocketChannel sc = ssc.accept();
                sc.configureBlocking(false);
                //new Client
                //
                //String hostIP = ((InetSocketAddress)sc.getRemoteAddress()).getHostString();

            /*
            log.info("client " + hostIP + " trying  to connect");
            for(int i=0; i<clients.size(); i++) {
                String clientHostIP = clients.get(i).clientAddress.getHostString();
                if(hostIP.equals(clientHostIP)) {
                    log.info("this client has already connected! is he alvie " + clients.get(i).live);
                    sc.close();
                    return;
                }
            }*/

                sc.register(key.selector(), SelectionKey.OP_READ );
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
            }
        } else if (key.isReadable()) { //判断为读请求
            SocketChannel sc = null;
            try {
                sc = (SocketChannel)key.channel();
                //需要flip复位操作,写的时候很难用 
                ByteBuffer buffer = ByteBuffer.allocate(512);
                buffer.clear();
                int len = sc.read(buffer);

                if(len != -1) {
                    System.out.println(new String(buffer.array(), 0, len));
                }

                ByteBuffer bufferToWrite = ByteBuffer.wrap("HelloClient".getBytes());
                sc.write(bufferToWrite);
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if(sc != null) {
                    try {
                        sc.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
}

NIO-reactor Server代码:
一个selector只负责accept(Boss),对每个client的handler由一个线程池处理(Workers)

public class PoolServer {

    ExecutorService pool = Executors.newFixedThreadPool(50);

    private Selector selector;
    //中文测试

    /**
     * @throws IOException
     */
    public static void main(String[] args) throws IOException {
        PoolServer server = new PoolServer();
        server.initServer(8000);
        server.listen();
    }

    /**
     * @param port
     * @throws IOException
     */
    public void initServer(int port) throws IOException {
        //
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        //
        serverChannel.configureBlocking(false);
        //
        serverChannel.socket().bind(new InetSocketAddress(port));
        //
        this.selector = Selector.open();

        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("服务端启动成功!");
    }

    /**
     * @throws IOException
     */
    @SuppressWarnings("unchecked")
    public void listen() throws IOException {
        // 轮询访问selector  
        while (true) {
            selector.select();
            //
            Iterator ite = this.selector.selectedKeys().iterator();
            while (ite.hasNext()) {
                SelectionKey key = (SelectionKey) ite.next();
                //
                ite.remove();
                //
                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    //
                    SocketChannel channel = server.accept();
                    //
                    channel.configureBlocking(false);
                    //
                    channel.register(this.selector, SelectionKey.OP_READ);
                    //
                } else if (key.isReadable()) {
                    //
                    key.interestOps(key.interestOps()&(~SelectionKey.OP_READ));
                    //
                    pool.execute(new ThreadHandlerChannel(key));
                }
            }
        }
    }
}

/**
 *
 * @param
 * @throws IOException
 */
class ThreadHandlerChannel extends Thread{
    private SelectionKey key;
    ThreadHandlerChannel(SelectionKey key){
        this.key=key;
    }
    @Override
    public void run() {
        SocketChannel channel = (SocketChannel) key.channel();
        //
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        //
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            int size = 0;
            while ((size = channel.read(buffer)) > 0) {
                buffer.flip();
                baos.write(buffer.array(),0,size);
                buffer.clear();
            }
            baos.close();
            //
            byte[] content=baos.toByteArray();
            ByteBuffer writeBuf = ByteBuffer.allocate(content.length);
            writeBuf.put(content);
            writeBuf.flip();
            channel.write(writeBuf);//
            if(size==-1){
                channel.close();
            }else{
                //
                key.interestOps(key.interestOps()|SelectionKey.OP_READ);
                key.selector().wakeup();
            }
        }catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }
}

多路复用I/O(Multiplexing I/O)

select:能打开的文件描述符个数有限(最多1024个),如果有1K个请求,用户进程每次都要把1K个文件描述符发送给内核,内核在内部轮询后将可读描述符返回,用户进程再依次读取。因为文件描述符(fd)相关数据需要在用户态和内核态之间拷来拷去,所以性能还是比较低
poll:可打开的文件描述符数量提高,因为用链表存储,但性能仍然不够,和selector一样数据需要在用户态和内核态之间拷来拷去
epoll(Linux下多为该技术):用户态和内核态之间不用文件描述符(fd)的拷贝,而是通过mmap技术开辟共享空间,所有fd用红黑树存储,有返回结果的fd放在链表中,用户进程通过链表读取返回结果,伪异步I/O,性能较高。epoll分为水平触发和边缘出发两种模式,ET是边缘触发,LT是水平触发,一个表示只有在变化的边际触发,一个表示在某个阶段都会触发

用Netty的示例代码来展现一下epoll的应用层用法
Server代码:

class Handler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        //super.channelRead(ctx, msg);
        System.out.println("server: channel read");
        ByteBuf buf = (ByteBuf)msg;

        System.out.println(buf.toString(CharsetUtil.UTF_8));

        ctx.writeAndFlush(msg);

        ctx.close();
        //buf.release();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        //super.exceptionCaught(ctx, cause);
        cause.printStackTrace();
        ctx.close();
    }
}

Client代码:

public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("channel is activated.");

        final ChannelFuture f = ctx.writeAndFlush(Unpooled.copiedBuffer("HelloNetty".getBytes()));
        f.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                System.out.println("msg send!");
                //ctx.close();
            }
        });
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        try {
            ByteBuf buf = (ByteBuf)msg;
            System.out.println(buf.toString());
        } finally {
            ReferenceCountUtil.release(msg);
        }
    }
}
3.png
4.png

小结

  1. 多路复用技术的发展代表目前I/O发展的方向。
  2. select --- 只支持1024个句柄,轮询导致性能下降
  3. poll --- 支持句柄数增多,性能仍然不高
  4. epoll --- 支持句柄数增多,事件性驱动,性能高

Redis的I/O采用Linux下最先进的epoll,包括Netty也是使用的epoll

AIO虽然更加先进,但是写起来更加复杂,而且在Linux内核下还没有真正支持AIO,但是Windows支持AIO.