BIO和NIO

介绍

IO是指系统和外围设备进行数据交换。主要包括磁盘IO、网络IO以及一些其它的如鼠标、显示器等外网设备IO。本文中讨论的BIO和NIO特指网络IO,其它的IO暂时先不聊。

BIO

BIO 顾名思义就是 Blocking IO, 翻译过来就是阻塞型IO。到底什么是BIO就看一段代码吧:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

/**
 * Function: BIO 示例代码
 */
public class SocketBio {
    private static final int PORT = 9090;

    private static final int BUFFER_SIZE = 4096;

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

        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(PORT));

        // 死循环 监听是否有新的连接
        while (true) {
            System.out.println("wait to be connected.");
            // 会阻塞
            SocketChannel client = serverSocketChannel.accept();
            System.out.println("Accept a client: " + client.socket().getPort());
            // 每次有新的连接来就需要new一个线程去处理请求
            Thread clientHandler = new Thread(() -> handleConnect(client));
            clientHandler.start();
        }
    }

    private static void handleConnect(SocketChannel client) {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
        System.out.println("handle client message.");
        try {
            // 会阻塞
            client.read(byteBuffer);
            System.out.println("read something.");
            // do something.
            client.close();
        } catch (IOException ignore) {

        }
        System.out.println("handle client message finished.");
    }
}

NIO

那相对的NIO意思就是 Non-blocking IO, 让我们也直接看一段示例代码吧

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

/**
 * Function: 初学NIO示例
 */
public class SocketNio {
    private static final int PORT = 9090;

    private static final int BUFFER_SIZE = 64 * 4096;

    public static void main(String[] args) throws Exception {
        List<SocketChannel> clientList = new LinkedList<>();

        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(PORT));
        // 设置为非阻塞
        serverSocketChannel.configureBlocking(false);

        while (true) {
            System.out.println("wait to be connected.");
            SocketChannel client = serverSocketChannel.accept();
            if (null != client) {
                // 对应的连接也设置为非阻塞
                client.configureBlocking(false);
                clientList.add(client);
                System.out.println("Accept a client: " + client.socket().getPort());
            }
            Iterator<SocketChannel> iterator = clientList.iterator();
            while (iterator.hasNext()) {
                SocketChannel socketChannel = iterator.next();
                boolean res = handleConnect(socketChannel);
                if (res) {
                    iterator.remove();
                }
            }
            Thread.sleep(5000);
        }
    }

    private static boolean handleConnect(SocketChannel client) {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
        System.out.println("handle client message.");
        try {
            // 不会阻塞
            int res = client.read(byteBuffer);
            if (res > 0) {
                // do something.
                System.out.println("read something.");
                client.close();
                return true;
            }
        } catch (IOException ignore) {
            return true;
        }
        return false;
    }
}

NIO和BIO的区别

从上面的例子可以看到两者的区别。对于BIO由于每一个连接的内容读取(read),以及建立连接的监听(accept),都会阻塞住当前的线程,所以我们不得不去创建更多的线程来处理每一个连接。随着连接增多,线程就越来越多。就会带来两个主要问题:

  • 大量的线程就会占用一部分内存作为内存栈。
  • 并且线程的增加也会导致线程间切换浪费了大量CPU资源。

而NIO不会阻塞线程,如果调用(accept read)时没有连接或者数据,会直接返回(-1 或者 0) 。这样我们就可以通过一个线程来做更多的事情,解决了BIO带来的问题。但同时,NIO也存在着新的问题:

  • 如果服务端建立了大量的连接。每个连接去读取数据时都需要调用read系统调用(需要用户态、内核态的切换)。而大多数的连接可能并没有数据写入,这时候就会造成大量CPU资源的浪费。

所以,IO多路复用就应运而生了。

IO多路复用

多路复用复用的是啥?

select

如上文说到的,NIO中会出现一个问题。就是当连接数多的时候,针对每一个连接都需要调用read来读取当前连接是否有新的数据包。举个比较极端的例子,当前服务端有1000个连接,其中只有5个连接有新的数据包。此时,NIO进行了1000次read系统调用,只有5次是真正的有效读。
所以,为了解决这个问题,内核提供了select系统调用。 `select``的函数签名如下:

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

其中,可以看到它提供了三个文件描述符的集合。分别监听读取,写入和异常事件。nfds是表示传入的三个文件描述符列表中最大的文件描述符编号+1(主要是用来提前结束内核循环的,不重要,忽略吧)。然后,最后一个是阻塞时间。调用select以后,程序会阻塞,直到监听的文件描述符中有对应的IO事件到达,或者达到了参数中给定的最大阻塞时间(也可以通过设置,表示一直阻塞,直到有对应的IO事件就绪)。如果有对应的IO事件就绪了,select会更新对应的文件描述符。告知当前这个文件描述符是否是IO ready状态的。如果是ready状态的话,就需要通过read调用对对应的连接进行数据的读取。那么在上面那个极端的例子中,这里一共经历了1次select和5次read【简化模型,不要较真!我也不清楚中间是否还有其它操作。TT】
select存在几个明显的缺点:

  • 文件描述符列表存在长度限制(32位系统为1024,64位操作系统位2048)
  • 对于文件描述符的监听,分成了三个列表。且每一次调用select时,都需要重新构建这三个列表(select过程中会改变数组的内容)。每次调用select都需要将这三个数组从用户态复制一份到内核态。
  • 事件到达后,需要遍历文件描述符数组。通过FD_ISSET()判断是否有监听的事件到达。
  • 每次调用select,内核也需要通过遍历,来获取对应的文件描述符中是否有事件到达,感觉效率比较低。

poll

poll其实和select差不多,参考poll。函数签名如下:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

poll相对于select主要优化了几个点

  • 没有了文件描述符列表长度限制(1024)
  • 将三个文件描述符数组改成了一个pollfd结构的数组,构建起来相对比较方便。

但是,select遗留的其它问题依旧没有得到解决。

epoll

epoll就比较牛逼了,也被称为事件驱动
使用epoll也就是对应这三个系统调用:
epoll包含了三个系统调用 epoll_create, epoll_ctl, epoll_wait

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll_create 函数创建一个epoll文件描述符,参数size表明内核要监听的描述符数量(使用过程中会根据使用情况动态调整,不用太在意)。返回一个epoll文件描述符。
epoll_ctl 函数注册要监听的事件类型。四个参数解释如下:

epfd 是epoll_create 返回的文件描述符
op 表示fd操作类型,有如下3种

  1. EPOLL_CTL_ADD 注册新的fd到epfd中
  2. EPOLL_CTL_MOD 修改已注册的fd的监听事件
  3. EPOLL_CTL_DEL 从epfd中删除一个fd

fd 是要监听的描述符
event 表示要监听的事件
epoll_wait 函数等待事件的就绪,成功时返回就绪的事件数目,等待超时返回 0。

epfd 对应epoll_create 返回的文件描述符
events 表示从内核得到的就绪事件集合
maxevents 告诉内核events的大小
timeout 表示等待的超时事件
epoll比select和poll强在哪呢? select和poll每次都需要把需要监听的文件描述符列表传入到内核,内核每次都需要遍历获取对应连接的状态。而epoll完全是反过来的。epoll在内核的数据被建立好了之后,每次某个被监听的文件描述符一旦有事件发生,内核就会做个标记表示有事件到达。epoll_wait调用时,会尝试直接读取到当时已经标记好的fd列表,如果没有就会进入等待状态。所以epll_wati的时间复杂度可以看作是O(1), 而Select和Poll的时间复杂度都是O(n)

同时,epoll_wait直接只返回了有事件到达的文件描述符列表。这样上层应用处理起来也轻松愉快,不需要从大量注册的文件描述符中筛选出有事件到达的文件描述符。

你可能感兴趣的:(日常学习)