BIO、NIO、EPOLL 多路复用器

2,604 阅读10分钟

Android 写后台网络这块,写的不好,大家见谅,文章没校对,不通顺的地方大家脑补,实在没时间改了~

前言

作为 Android 开发偶然看到马老师的 NIO,好奇心驱使我进来看看,收获很大,虽然不是做后台开发的,但我还是听懂了,听懂了就要做笔记,要不忘了、想不起来,今天的努力就白费了

NIO,epoll 这些,tomcat、netty、nginx、redis 都使用到了,区别是有的是多线程的,有的是单线程的。这些都是开源主流大件,使用的原理都是趋同的,可见基础的重要性呀,你把 NIO,epoll 都搞定了,这些框架你还看不懂吗,get 不到核心点吗,面试时还不会答吗

资料:

strace 命令

strace -ff -o out /javapath/java BioTest.java

strace 可以追踪进程内核调用,查看内核态中的运行情况,分析性能用的,下面这些都是系统调用

像这样办个括号的,就是在这里阻塞了

Linux 中一切皆文件,每创建一个线程,都会在创建线程方法所属的文件的目录下生成一个 .out file

下面会用到 strace 命令

BIO

class BioTest {

    ServerSocket serverSocket;
    Socket client;

    void main() throws IOException {

        serverSocket = new ServerSocket(8090);

        while (true) {
            client = serverSocket.accept();

            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        InputStream inputStream = client.getInputStream();
                        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
                        while (true) {
                            reader.readLine();
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
    }
}

这是经典的 java Socket 写法,上面启动的是服务端 Socket,Socket 在整个过程中会有2处阻塞:

  • serverSocket.accept(),这里一处,因为服务端要等待客服端完成 TCP 3次握手,所以这里要阻塞时等待
  • reader.readLine(),TCP 连接建立了,要等待客户端把数据包发过来,这里也要等待

因为服务端主线程是不能阻塞的,所以要给每一个 TCP 连接配一个线程,reader.readLine()要阻塞也是阻塞新开辟的线程

所以:BIO = 1TCP,1线程

  • 优势:
    • 可以接收很多个连接进来
  • 缺点:
    • 线程开启的数量太多了,太消耗资源
    • 线程太多了,线程之间争强 CPU,频繁的切换线程同样会过度消耗 CPU 时间,线程切换也是有代价的

BIO 瓶颈 --> 就在于阻塞,我们不能让主线程阻塞,所以不得不每一个连接进来都开启线程,这是 BIO 性能问题的关键所在

strace 跟一下 BIO 的 Socket 通信

从内核的执行角度看: 1. socket => 3 内核会启动一个 socket 并给该 socket 在内核中分配一块缓存空间(就是一个文件),返回一个名为3的文件描述符,这个文件描述符就代表了 Socket 对象 2. bind(3,8090) 绑定3这个文件描述符给 8090 端口使用 3. listen(3) 内核开始监听 3 这个文件描述符,也就是监听 3 这个 socket 通信 4. clone() 根据 BIO 的代码,内核开启一个新的线程 5. accept(3, =5 阻塞,前面 (3, 这里会阻塞,一直到客户端有连接完成3次 TCP 握手进来,会返回一个名为 5 的文件描述符,该文件描述符代表一个客户端连接 clien,内核同样会在给该连接在内核空间分配一块缓存用来存储数据 6. recv(5, ,读取 http 数据,客户端不发过来会一直阻塞在这里

BIO 非常容易受到攻击:

  1. 发起大量 TCP 连接,每次3次握手就是不完成,服务端没都一个TCP连接申请都会分配一块资源,TCP 连接不终止就不会释放,通过这种流氓做法短时间内会耗尽服务器系统资源
  2. 跟上面一样的思路,TCP 通了就是不发数据包过来,服务器你就淂等,短时间内大量这样的操作一样会会耗尽资源

NIO

前文说了: BIO 瓶颈 --> 就在于阻塞,我们不能让主线程阻塞,所以不得不每一个连接进来都开启线程

BIO 阻塞的方法来自于系统调用:accept()、rect(),这2个方法是内核级别的,是我们在 java 应用层可以控制的吗?显然不是,我们淂依赖于系统内核资深的进步,为了解决这个问题:NIO 诞生了

Linux 系统内核提供了一种:SOCK_NONBLOCK 新模式,socket 可以无阻塞式运行,accept() 函数没有连接进来时就返回 null,我们自己判断下

没有阻塞了,这样我们就可以用一个线程 hold 住所有连接了,需要做的就是不停的遍历所有连接就行了

**另外还有一点,NIO 其实有2个角度: **

  • 一个是 java 中的 new IO,一套新的IO体系、新的包、包含通道、缓存、多路复用器,JDK 1.7 开始可以使用
  • 一个是操作系统,Linux 提供了 nonblocking 非阻塞式 socket 设置:SOCK_NONBLOCK

要问明白面试官问的是哪个

从内核的执行角度看: 1. socket => 3 :内核会启动一个 socket 并给该 socket 在内核中分配名为3的文件描述符 2. bind(3,8090) :绑定3这个文件描述符给 8090 端口使用 3. listen(3) :内核开始监听 3 这个文件描述符,也就是监听 3 这个 socket 通信 4. 3.nonblocking :socket 设置非阻塞式 5. accept(3) return NULL/5 :非阻塞,方法执行到这里没有连接就返回 null 6. 5.nonblocking :连接设置非阻塞式 7. recv(5) return -1/xxx :非阻塞,读取数据包没有就返回 -1

class Test
    void main2() throws IOException {

        LinkedList<SocketChannel> channels = new LinkedList<>();

        ServerSocketChannel socketChannel = ServerSocketChannel.open();
        socketChannel.bind(new InetSocketAddress(8090));
        socketChannel.configureBlocking(false); // 启动内核 NONBLOCKING 非阻塞模式

        while (true) {
            SocketChannel channel = socketChannel.accept(); // 不阻塞,没有返回 null

            if (channel != null) {
                channel.configureBlocking(false); // 启动内核 NONBLOCKING 非阻塞模式
            	channels.add(channel);
            }

            ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
            for (SocketChannel chan : channels) {
                int read = chan.read(buffer);  // 不阻塞,没有数据包返回 -1
                if (read > 0) {
                    buffer.flip();
                    byte[] bytes = buffer.array();
                    String data = new String(bytes);
                }
            }
        }
    }
}
  • 优点:
    • 1个线程就行就能处理所有连接,这个线程不停循环遍历就行了
  • 缺点:
    • 单线程不停循环发起系统调用,一样会耗尽 CPU 资源

NIO 的瓶颈 --> 在于需要不停的调起系统调用,每个链接我们都要调系统调用询问是否有过来数据,我们要是明确的知道哪个连接有数据包过来呢,就不用挨个遍历寻找找答案了

select、poll、epoll 多路复用器

上文说到 NIO 的瓶颈 --> 在于我们要不停的发起系统调用询问内核是否有新的连接、新的数据包,单线程循环这么跑的话性能一样也好不了,每一次系统调用,都要经历用户态-->内核态的来回切换

既然是内核问题,那我们在应用层代码肯定是无能为力的,幸好 Linux 内核又进步了,提供了多路复用器这个概念

多路复用器 --> 指的是不管有多少连接进来,我们都可以使用1条系统调用询问内核,然后内核告诉我们哪些连接有变化,然后我们自己对这些有变化的连接进行IO操作。多路复用器解决的是状态的问题,不解决IO读写的问题,用1次系统调用询问所有的IO状态,而不是每一个IO都问一次,减少了用户态到内核态的切换

多路复用器实现:

  • select
  • poll
  • epoll

首先要明确,多路复用器是内核级实现,现在变成一种 IO 规范了,Linux、unix、window 各大操作系统都有支持,区别是具体的实现不同。select、poll 是一种老式实现,epoll 是最新的实现,2者实现思路不同,但是都遵循 select 这个接口规范

其次要知道,java 针对 NIO、多路复用器这种新的 IO 模型,专门推出了全新的 IO 包,java.nio.*,里面包含上面说的所有

多路复用器允许一个程序监控多个文件操作符,不是快速 IO 读写,而是告诉你那些文件描述符有变化

select、poll

select、poll 实现逻辑是一样的,区别是 select 有 1024 个连接限制,poll 没有,poll 的限制是系统上限

实现逻辑:

  1. 用户进程还是维护所有连接的队列集合
  2. 通过 select(fds) 这个新增的系统调用,询问内核那些连接有变化,该方法阻塞,但可以设置超时时间
  3. 内核去挨个遍历所有连接找出状态发生变化的,把这些连接返回给用户进程

OK,原理就是这么简单~

  • 优点:
    • 1个系统调用就行了,节省了大量用户态-->内核态的切换,性能好很多
  • 缺点:
    • 内核自己还是要去遍历多有的连接,内核中的操作还是要消耗一部分性能的,在高并发环境下,这部分性能损耗也是很多的

epoll

到 epoll 这里,在 select、poll 的基础上又进化了,epoll 可以让内核开辟空间去记录所有的连接,我们只要询问内核就行了,节省了每个询问内核时传递大量的文件描述符了

原理也是这么简单,大家看看图就知道了

epoll 3个方法

  • epoll_create():epoll 开辟一块空间保存监听对象
  • epoll_ctl():往 epoll 中添加一个类型的监听
  • epoll_wait():用户查询结果,该方法会阻塞,但是可以设置超时时间

结合内核调用看:

socket --> 3
bind(3,8090)
listen(3)
epoll_create() --> 7:epoll 创建一块空间出来保存注册的监听内容
epoll_ctl(7,ADD,3,accept):添加一个监听进来,文件是 socket,类型是 accept
accept(3) --> 8:此时连接进来了
epoll_ctl(7,ADD,8,read):添加监听,文件是连接,类型是读写
epoll wait()

  • 优点:
    • 查询结果时不用再传大量的文件描述符的,这些文件描述符的传递也是需要内存操作的
    • 可以有效利用多核了,内核中监听的操作不再是以前那样遍历式的连贯操作了,需要一口气执行,监听连接状态变换,任务之间没有连贯性,可以由多个核心执行,碰到任务来了,哪个核心有空哪个核心写好了
  • 缺点:
    • 在大并发的场景下,单线程管理所有的连接,单次 wait() 操作耗时还是比较长,wait() 之间的间隔可能会比较长,这就造成了连接响应不及时

代码

select、poll、epoll 他们都遵循 select 接口,java 代码上都是一样的,你可以设置 java 代码中具体使用哪个实现,一般是 epoll

class Test{

    void main3() throws IOException {

        ServerSocketChannel socketChannel = ServerSocketChannel.open();
        socketChannel.bind(new InetSocketAddress(8090));
        socketChannel.configureBlocking(false);

        Selector selector = Selector.open();
        socketChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (selector.select(200) > 0) {
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = keys.iterator();
            while (iterator.hasNext()) {
                SelectionKey selectionKey = iterator.next();
                iterator.remove();

                if (selectionKey.isAcceptable()) {
                    SocketChannel channel = ((ServerSocketChannel) selectionKey.channel()).accept();
                    channel.configureBlocking(false);
                    channel.register(selector,SelectionKey.OP_READ,ByteBuffer.allocateDirect(4096));
                }

                if (selectionKey.isReadable()) {
                    SocketChannel channel = (SocketChannel) selectionKey.channel();
                    ByteBuffer buffter = (ByteBuffer) selectionKey.attachment();
                    // 读操作
                }

				if (selectionKey.isWritable()) {
                   // 写操作
                }
            }
        }
    }
}    

AIO

如果是线程自己读取IO,那就是同步IO模型。不管是 BIO、NIO、还是多路复用器,他们都是同步IO模型

AIO 是 window 系统的,window IOCP 机制是真正的异步IO,程序你不用自己去读写IO了,只需要注册一个回调就行了,内核会自己开启线程,获取数据包然后写到程序的用户空间里

这点大家清楚就行了,没啥多说的

多路复用器 + 多线程

上文我们说了 epoll 的瓶颈 -->,在于大并发下,单线程是 hlod 不住的,单次 wait() 函数耗时太长,会影响连接响应

于是大家就想到了使用 多线程 + 多个 select 配合使用的思路,这里我简单说下:

  1. 每个 select 都运行在独立的 Thread 中
  2. 其中一个 select 作为控制器,在接到连接进来后,把连接分配给不同的 select 去注册
  3. 剩下的复数的 select 负责连接的读写

下面的代码意思一下,大家看个意思

class Test{

    void main3() throws IOException {

        ServerSocketChannel socketChannel = ServerSocketChannel.open();
        socketChannel.bind(new InetSocketAddress(8090));
        socketChannel.configureBlocking(false);

        Selector selector_root = Selector.open();
        Selector selector_work1 = Selector.open();
        Selector selector_work2 = Selector.open();
        Selector[] works = {selector_work1, selector_work2};
        AtomicInteger index = new AtomicInteger(0);

        socketChannel.register(selector_root, SelectionKey.OP_ACCEPT);

        while (selector_root.select(200) > 0) {
            Set<SelectionKey> keys = selector_root.selectedKeys();
            Iterator<SelectionKey> iterator = keys.iterator();
            while (iterator.hasNext()) {
                SelectionKey selectionKey = iterator.next();
                iterator.remove();

                // 在新连接进来时分发给其他 select,进行负载均衡
                if (selectionKey.isAcceptable()) {
                    SocketChannel channel = ((ServerSocketChannel) selectionKey.channel()).accept();
                    channel.configureBlocking(false);
                    channel.register(works[index.get() % 2], SelectionKey.OP_READ, ByteBuffer.allocateDirect(4096));
                    index.incrementAndGet();
                }
            }

        }
    }
}

tomcat、netty、nginx、redis 用的也是这个思路,区别是有的是多线程的,有的是单线程的