网络通信 IO 模型

198 阅读6分钟

整体大纲

image.png

1、阻塞 IO 技术

简称 BIOB 就是 Blocking,阻塞的意思。

我想 JavaSocket 应该都是用过的,先看一段代码:

public class BioServer {

    // 为了方便,正常来说是应该创建原生的 ThreadPoolExecutor
    private static ExecutorService executors = Executors.newFixedThreadPool(10);

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket();
        /**
         * 对 1009 端口号进行监听
         */
        serverSocket.bind(new InetSocketAddress(1009));
        try {
            while (true) {
                /**
                 * 等待客户端连接,如果没有客户端连接则会一直堵塞
                 */
                Socket socket = serverSocket.accept();
                executors.execute(new Runnable() {
                    @Override
                    public void run() {
                        while (true){
                            InputStream inputStream = null;
                            try {
                                /**
                                 * 等待数据写入,如果没有数据写入则会一直堵塞
                                 * 底层其实是发出了一个 read 系统调用
                                 */
                                inputStream = socket.getInputStream();
                                byte[] result = new byte[1024];
                                int len = inputStream.read(result);
                                if(len != -1){
                                    System.out.println("[response] " + new String(result,0,len));
                                    OutputStream outputStream = socket.getOutputStream();
                                    outputStream.write("response data".getBytes());
                                    outputStream.flush();
                                }
                            } catch (IOException e) {
                                e.printStackTrace();
                                break;
                            }
                        }
                    }
                });
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

这段代码的逻辑很简单:

(1)建立一个服务端,并绑定端口号,等待客户端连接

(2)客户端连接上了就可以向服务端发送数据

这里重点关注 serverSocket.accept();socket.getInputStream();,会在这两个地方发生阻塞情况,这就是 BIO 的体现。

大概流程为

image-20220309100646161

当客户端连接上了服务端之后,accept 的堵塞状态才会放开,然后进入 read 环节(读取客户端发送过来的网络数据)。

客户端如果一直没有发送数据过来,那么服务端的 read 调用方法就会一直处于堵塞状态,倘若数据通过网络抵达了网卡缓冲区,此时则会将数据从内核态拷贝至用户态,然后返回给 read 调用方。

image-20220309101622679

2、非阻塞 IO 技术

问题:如果客户端没有发送数据,就会导致服务端一直处于堵塞状态,所以出现了非阻塞 IO 技术。

NIO

先看一段 NIO 代码:

public class NioSocketServer extends Thread {
    ServerSocketChannel serverSocketChannel = null;
    Selector selector = null;
    SelectionKey selectionKey = null;

    public void initServer() throws IOException {
        selector = Selector.open();
        serverSocketChannel = ServerSocketChannel.open();
        // 设置为非阻塞模式,默认 serverSocketChannel 是采用了阻塞模式
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.socket().bind(new InetSocketAddress(8888));
        selectionKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    }

    @Override
    public void run() {
        while (true) {
            try {
                // 默认这里会堵塞
                int selectKey = selector.select();
                if (selectKey > 0) {
                    //获取到所有的处于就绪状态的channel,selectionKey中包含了channel的信息
                    Set<SelectionKey> keySet = selector.selectedKeys();
                    Iterator<SelectionKey> iter = keySet.iterator();
                    // 对 selectionKey 进行遍历
                    while (iter.hasNext()) {
                        SelectionKey selectionKey = iter.next();
                        // 需要清空,防止下次重复处理
                        iter.remove();
                        // 就绪事件,处理连接
                        if (selectionKey.isAcceptable()) {
                            accept(selectionKey);
                        }
                        // 读事件,处理数据读取
                        if (selectionKey.isReadable()) {
                            read(selectionKey);
                        }
                        // 写事件,处理写数据
                        if (selectionKey.isWritable()) {
                        }
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
                try {
                    serverSocketChannel.close();
                } catch (IOException e1) {
                    e1.printStackTrace();
                }
            }

        }
    }

    public void accept(SelectionKey key) {
        try {
            ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
            SocketChannel socketChannel = serverSocketChannel.accept();
            System.out.println("conn is acceptable");
            socketChannel.configureBlocking(false);
            // 将当前的 channel 交给 selector 对象监管,并且有 selector 对象管理它的读事件
            socketChannel.register(selector, SelectionKey.OP_READ);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void read(SelectionKey selectionKey) {
        try {
            SocketChannel channel = (SocketChannel) selectionKey.channel();
            ByteBuffer byteBuffer = ByteBuffer.allocate(100);
            int len = channel.read(byteBuffer);
            if (len > 0) {
                byteBuffer.flip();
                byte[] byteArray = new byte[byteBuffer.limit()];
                byteBuffer.get(byteArray);
                System.out.println("NioSocketServer receive from client:" + new String(byteArray,0,len));
                selectionKey.interestOps(SelectionKey.OP_READ);
            }
        } catch (Exception e) {
            try {
                serverSocketChannel.close();
                selectionKey.cancel();
            } catch (IOException e1) {
                e1.printStackTrace();
            }
            e.printStackTrace();
        }
    }


    public static void main(String args[]) throws IOException {
        NioSocketServer server = new NioSocketServer();
        server.initServer();
        server.start();
    }
}

大概流程

image.png

代码流程:

Socket 的服务端启动之后,会对每个 Socket 连接的对象都开启一个线程,然后在一个循环里面去调用 read 函数,此时的 read 函数调用不会进入阻塞状态了,但是还是没有解决根本性问题:每次来请求都要创建一个线程来监听客户端的请求。如果客户端在建立连接之后长时间没有传输数据,此时对于服务端而言就会造成资源浪费的情况。

每次请求都需要建立一个线程,如何优化?

可以将 acceptread 分成两个模块来处理,当 accept 函数接收到新的连接(其实本质就是一个文件描述符 fd)之后,将其放入一个集合,然后会有一个后台任务统一对这个集合中的 fd 遍历执行 read 函数操作。

img

问题:循环调用 read 方法岂不是循环进行用户态和内核态的切换

2.1 select 模型

Linux 内核中有一个 select 的函数,这个函数的作用是在内核态中对 fd 集合进行遍历,如果对应的 fd 接收到客户端的抵达数据,则会返回给用户态调用方。

注意:用户态在发生 select 系统调用的时候仍然会处于阻塞状态。

select 函数的官网介绍

image-20220309103644831

  • 当网卡没有接收到新的数据时,用户态执行 select 函数会处于阻塞状态,此时内核态中会对 fd 集合进行循环遍历,对每个连接的 fd 都执行 read 操作,判断是否有新的数据抵达。

image-20220309170154201

  • 当网卡接收到了新的数据时,则会将数据拷贝至内核态的指定内存块区域,并且返回给调用 select 函数的用户态程序。

image-20220309165911390

总结

一次 select 函数调用,只需要用户态到内核态的一次切换,和内核态的 n 次 fdread 函数调用。

image-20220309165957303

2.2 poll 模型

poll 也是和 select 相似,通过一次系统调用,然后在内核态中对连接的文件描述符集合进行遍历判断,判断是否有就绪状态的连接接收到了数据。

但是 select 函数中只能监听 1024 个文件描述符。而 poll 函数则是去除掉了这块的限制。

poll 函数的官网介绍

2.3 epoll 模型

select 函数主要解决的是用户态和内核态的多次切换问题,把多次切换的过程转移到了内核态中。

select 的不足点:

  • select 函数在从用户态拷贝 fd 集合传入内核态之后,后续主要关注的点就是哪个 fd 的就绪状态发生了改变。举个应用场景:内核态中已经存在一个 fd 集合,这个集合中的任一 fd 的状态发生变更,则整个 fd 集合都需要返回给到用户态,这个拷贝过程会将状态没有变化的 fd 也返回
  • select 函数在内核态中依然是通过遍历的方式来判断究竟哪个 fd 已经处于就绪状态。

epollselect 的优化:

  • 用户态无需将整份 fd 数据在用户态和内核态之间进行拷贝,只会拷贝发生变化的 fd 数据。

  • 内核态中不再是通过循环遍历的方式来判断哪些 fd 处于就绪状态,而是通过异步事件通知的方式告知。

  • 内核态会将有数据抵达的 fd 返回到用户态,此时用户态可以减少不必要的遍历操作。

epoll 官网介绍

epoll 底层使用了红黑树的数据结构,这种结构可以按照事件类型对 socket 的集合信息进行增删改查,相对高效稳定。

2.4 零拷贝

一般我们的数据如果需要从 IO 读取到堆内存,中间需要经过 Socket 缓冲区,也就是说一个数据会被拷贝两次才能到达它的终点,如果数据量大,就会造成不必要的资源浪费。

零拷贝:当需要接收数据的时候,会在堆内存之外开辟一块内存,数据就直接从 IO 读到了那块内存中去,在 Netty 里面通过 ByteBuf 可以直接对这些数据进行直接操作,从而加快了传输速度。