漫谈I/O——I/O复用模型

67 阅读6分钟

参考

Linux 网络包发送过程:25 张图,一万字,拆解 Linux 网络包发送过程 (qq.com)

图解Linux网络包接收过程: 图解Linux网络包接收过程 (qq.com)

当内核收到了一个网络包:当内核收到了一个网络包 (qq.com)

epoll 是如何实现 IO 多路复用的:图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的! (qq.com)

你管这破玩意叫 IO 多路复用: 你管这破玩意叫 IO 多路复用? (qq.com)

认认真真的聊聊中断: 认认真真的聊聊中断 (qq.com)

认认真真的聊聊"软"中断:认认真真的聊聊"软"中断 (qq.com)

【视频】netty视频

【书】计算网络自顶向下

【书】UNIX网络编程卷1:套接字联网API(第三版)

【书】图解TCP_IP

本文笔者只是做整合以及阅读总结,建议大家看看原文

I/O复用模型

总结阻塞I/O和非阻塞I/O我可以发现,现在的矛盾点在于两点:

  • 程序的阻塞导致我们需要一个线程一个连接
  • 用户态到内核态的反复切换

那么我们优化的目的也就很明显:

  • 减少程序阻塞
  • 减少用户态和内核态之间的切换

现在让我们会带非阻塞I/O优化的地方,其实换个思路。如果这个遍历发生在内核层面,是不是就减少了用户态到内核态的切换了。如果这里你明白了,那么恭喜你已经get到多路复用的点了。

Select

select 是操作系统提供的系统调用函数。该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后唤醒它。

作为一个例子,我们可以调用 select,告知内核仅在下列情况发生时才返回

  • {1,4,5}中的任何描述符准备好读
  • 集合{2,7}中的任何描述符准备好写
  • 集合{1,4}中的任何描述符有异常条件待处理
  • 已经历了10.2秒。

说白了就是之前我们循环遍历确定那个文件描述符的活现在交给操作系统了。如下动图:

640 (4).gif 继续思考既然遍历的活给了操作系统就意味着我们需要给select函数传递我们需要监听连接(文件描述符)的集合。并且select监听到响应到事件后会返回个我们响应事件的文件描述符集合。我们仍然需要去遍历这个集合只不过,这些描述符都是由确切事件的。不再是无用的内核态到用户态的切换。流程如下:

image.png

select优缺点

优点:

  • 大大减少了内核态到用户态的切换

缺点:

  • 单个进程所打开的FD是有限制的,通过 FD_SETSIZE 设置,默认1024 ;

  • 需要向内核传递文件描述符集合,这其实就涉及到了拷贝的过程也是比较消耗资源的。

    需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

  • 对 socket 扫描时是线性扫描,采用轮询的方法,效率较低(高并发)

    当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。

  • select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。

Poll

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的

poll优缺点

优点:

  • 没有最大连接数限制

缺点:

  • 每次调用 poll ,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
  • 对 socket 扫描是线性扫描,采用轮询的方法,效率较低(高并发时)
  • select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历

epoll

一路优化过来,最后摆在我们面前的问题只剩下来三个了:

  1. 用户空间和内核空间在传递该结构时复制开销大
  2. 对 socket 扫描时是线性扫描,采用轮询的方法,效率较低(高并发)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历

所以epoll做的事情就是优化以上三点:

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

epoll内部原理粗略如下:

640 (5).gif

这里限于篇幅原因我们不可能展开讲解epoll内部原理,大家可以去阅读这篇文章:《图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的!》,这里我只放一个大致流程图:

image-20211123160059717

简单说:epoll是继承了select/poll的I/O复用的思想,并在二者的基础上从监控IO流、查找I/O事件等角度来提高效率,具体地说就是内核句柄列表、红黑树、就绪list链表来实现的。

epoll的优缺点

优点:

  • 减少用户态和内核态之间的拷贝
  • 减少对可读可写文件描述符的遍历
  • 使用了异步 IO 事件模式,减少了轮询

缺点:

  • 对于连接较少的情况其实性能不一定比传统的阻塞I/O+多线程的延迟低

例子

代码清单:MyServer.java

public class MyServer {
    private final static Logger log = LoggerFactory.getLogger(MyServer.class);
​
    public static void main(String[] args) throws IOException {
​
        final Selector selector = Selector.open();
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(3345));
        ssc.configureBlocking(false);
        final SelectionKey sscKey = ssc.register(selector, 0, null);
        log.debug("register key:{}", sscKey);
        sscKey.interestOps(SelectionKey.OP_ACCEPT);
        while (true) {
            selector.select();
            //处理事件
            final Set<SelectionKey> keys = selector.selectedKeys();
            final Iterator<SelectionKey> iterator = keys.iterator();
            while (iterator.hasNext()) {
                final SelectionKey key = iterator.next();
                iterator.remove();
                log.debug("key:{}", key);
                if (key.isAcceptable()) {
                    ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                    final SocketChannel sc = channel.accept();
                    sc.configureBlocking(false);
                    ByteBuffer buffer = ByteBuffer.allocate(4);
                    SelectionKey scKey = sc.register(selector, 0, buffer);
                    scKey.interestOps(SelectionKey.OP_READ);
                    log.debug("{}", sc);
                } else if (key.isReadable()) {
                    ByteBuffer buffer = (ByteBuffer) key.attachment();
                    try {
                        SocketChannel channel = (SocketChannel) key.channel();
                        final int read = channel.read(buffer);
                        if (read == -1) {
                            key.cancel();
                        } else {
                            split(buffer);
                            if (buffer.position() == buffer.limit()) {
                                final ByteBuffer byteBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
                                buffer.flip();
                                byteBuffer.put(buffer);
                                key.attach(byteBuffer);
                            }
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                        key.cancel();
                    }
                }
            }
​
        }
    }
​
​
    private static void split(ByteBuffer source) {
        source.flip();
        int old = source.limit();
        for (int i = 0; i < old; i++) {
            if (source.get(i) == '\n') {
                ByteBuffer target = ByteBuffer.allocate(i + 1 - source.position());
                source.limit(i + 1);
                target.put(source);
                ByteBufferUtil.debugAll(target);
                source.limit(old);
            }
        }
        source.compact();
    }
​
}