NIO核心原理深度解析

有一定开发经验的程序员应该都听过Redis和Nginx,因为它们实在是太受欢迎了。那么它们为什么那么受欢迎呢?除了因为他们的功能强大之外,他们底层所采用的技术让它们天生就有处理高并发问题的能力。本文将会通过对NIO问题的逐步深入解析来带你了解Redis和Nginx所采用的网络IO模型。

1 几个基础概念

在开始本文的主题之前,我们先来介绍一下几个基础概念。

本文重点侧重于讨论网络IO,至于磁盘文件IO则不做过多深入。

1.1 同步

同步是指当前线程调用一个方法之后,当前线程必须等到该方法调用返回后,才能继续执行后续的代码。翻译成人话就是:某个人要做多件事时,必须做完第一件事情才能做第二件事情,然后才能做第三件,以此类推。而同步又可以再细分为同步阻塞和同步非阻塞。图示如下:
在这里插入图片描述

同步
主线程在调用了sum方法之后,必须等sum方法的结果返回之后才能继续往下执行,调用打印方法。
1.1.1 同步阻塞

同步阻塞是指在调用结果返回之前,当前线程会被挂起。当前线程只有在得到结果之后才会返回,然后才会继续往下执行。翻译成人话就是:当你要洗衣服时,你把衣服放进洗衣机里面洗,然后傻傻地在旁边等着,什么也不干,一直等到洗好之后再把衣服拿去晾。图示如下:
在这里插入图片描述

同步阻塞
张三在使用洗衣机洗衣服时,傻傻的站在洗衣机旁边等待衣服洗完,然后把衣服拿去晾。
1.1.2 同步非阻塞

同步非阻塞是指某个调用不能立刻得到结果时,该调用不会阻塞当前线程,此时当前线程可以不用等待结果就能继续往下执行其他的代码,等执行完别的代码再去检查一下之前的结果有没有返回。翻译成人话就是:当你想洗衣服时,你把衣服放进洗衣机里面洗,然后去一边看电视,每过一段时间就去看一下衣服洗好了没有,如果某一次看到衣服已经洗好了,就把洗好之后的衣服拿去晾。图示如下:
在这里插入图片描述

同步非阻塞
张三在洗衣服时,把衣服放进洗衣机之后就去看电视了,然后过一段时间再去看看衣服有没有洗好,如果洗好了,就再拿去晾。

1.2 异步

异步是指,当前线程在发出一个调用之后,这个调用就马上返回了,但是并没有返回结果,此时当前线程可以继续去执行别的代码,在调用发出后,调用者会通过状态、通知来通知调用者其返回结果,或者是通过回调函数来处理该调用的结果。翻译成人话就是:当你想洗衣服时,你把衣服放进洗衣机里面,然后你就可以去干别的事情了,不用站在旁边干等着,也不用过一段时间就去看一下洗好了没有,而是在洗衣机设置一个通知,等洗衣机洗完衣服之后就发出“滴滴滴”的声音来通知你衣服已经洗好了,然后你再把衣服拿去晾就行了。

在这里插入图片描述

异步

2 BIO

BIO(Blocking I/O)即阻塞IO,也称为传统IO。在BIO中,对应的工作模式就是同步阻塞的I/O模式,即数据的读取和写入必须阻塞在一个线程内来等待其完成。原因就是在BIO中涉及到的 ServerSocket类的accept()方法、 InputStream类的read()方法和OutputStream类的write() 方法都是会对当前线程进行阻塞的。

2.1 BIO的缺点

在我们学习Java的网络编程的时候,教科书或者是老师教我们都是让我们先写一个服务端,然后再写一个客户端,然后将这两个小程序运行起来,这个时候就可以让这两个小程序进行通信了。但是我们并不知道的是,这种写法会有着一种很严重的缺陷。我们接下来通过代码来看一个看一下他的缺陷。
服务端代码如下:

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class BIOServerTest {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8089);
        System.out.println("第一步:创建端口为8090的端口成功。。。");
        while (true) {
            System.out.println("阻塞中,等待客户端来连接。。。");
            Socket client = serverSocket.accept();//阻塞1
            System.out.println("第二步:接收到端口号为:" + client.getPort() + "的客户端连接");
            InputStream inputStream = client.getInputStream();
            byte[] buffer = new byte[4096];
            System.out.println("阻塞中,等待客户端发送数据。。。");
            inputStream.read(buffer);//阻塞2
            String receivedContent = new String(buffer, "UTF-8");
            System.out.println("第三步:接收到的数据为:" + receivedContent);
        }
    }
}

客户端代码如下:

import java.io.*;
import java.net.Socket;
import java.util.Scanner;

public class Client {
    public static void main(String[] args) throws IOException {
        System.out.println("开始连接服务器。。。");
        Socket client = new Socket("127.0.0.1", 8089);
        System.out.println("所连接的服务器地址为:" + client.getRemoteSocketAddress());
        OutputStream outToServer = client.getOutputStream();
        DataOutputStream out = new DataOutputStream(outToServer);
        Scanner scanner = new Scanner(System.in);
        String str = scanner.nextLine();
        out.writeUTF(str);
        out.close();
        client.close();
    }
}

首先,我们先启动服务端,看一下控制台输出的结果。
在这里插入图片描述

刚启动服务端时,服务端控制台的输出

通过控制台的输出我们可以发现,当我们启动服务端之后,程序在执行Socket client = serverSocket.accept();//阻塞1时阻塞住了,没有继续往下执行,这时它要一直等到有客户端来连接它时,它才会继续往下执行。我们接下来启动一下客户端,去连接服务端,看看控制台的变化。
在这里插入图片描述

启动客户端时,客户端控制台的输出

在这里插入图片描述

启动客户端时,服务端控制台的输出

通过服务端的控制台输出可以发现,当我们使用客户端对服务端进行连接之后,服务端的控制台多了两行打印信息,然后在执行inputStream.read(buffer);//阻塞2时又再次阻塞住了。
我们接下来通过客户端往服务端发送一句话,然后再观察一下服务端控制台的变化。
在这里插入图片描述

客户端向服务端发送数据时,客户端控制台的输出

在这里插入图片描述

客户端向服务端发送数据时,服务端控制台的输出

可以看见,在客户端往服务端发送了一句你好,我是张三之后,服务端就在控制台输出了客户端发送给它的那句话。
我们接下来,再做一个小实验,为了方便,我们使用一个小工具SocketTest作为客户端来连接服务端。这个小工具的下载地址为:http://sockettest.sourceforge.net。接下来,我们先启动服务端,然后打开两个SocketTest的进程,接着使用第一个SocketTest进程来连接服务端,观察一下服务端的控制台输出。
在这里插入图片描述

使用 第一个SocketTest进程连接服务端时,服务端控制台的输出

然后我们再用第二个SocketTest进程来连接服务端,看看服务端控制台的变化。
在这里插入图片描述

使用 第二个SocketTest进程连接服务端时,服务端控制台的输出

通过观察服务端控制台的输出可以发现,这个时候,服务端控制台输出的信息是没有任何变化的。接下来我们再用第二个SocketTest进程来给服务端发送一句消息,看看服务端控制台的变化。
在这里插入图片描述

使用 第二个SocketTest进程连接服务端并发送消息时,服务端控制台的输出

通过再次观察服务端控制台的输出可以发现,这个时候,服务端控制台输出的信息还是没有任何变化。然后我们再使用第一个SocketTest进程向服务端发送信息,观察一下控制台的输出。
在这里插入图片描述

使用 第一个SocketTest进程连接服务端并发送消息时,服务端控制台的输出

通过观察服务端的输出可以发现,此时服务端打印的是第一个SocketTest进程所发送的信息,而第二个SocketTest进程所发送的信息并没有打印出来,服务端程序就结束了,说明服务端的连接是一直被第一个SocketTest进程所占用的。
通过上面的小实验,我们可以得出如下的结论:

  1. 如果服务端一直没有客户端来对其进行连接,服务端就会一直阻塞在serverSocket.accept()这句代码,不能继续往下执行其他的代码。
  2. 如果客户端在连接了服务端之后,如果一直不发送数据给服务端,那服务端就会一直阻塞在inputStream.read(buffer)这句代码,不能继续往下执行其他的代码。
  3. 如果某个客户端连接了服务端之后,如果一直不发送数据给服务端,那这个连接就会一直被这个客户端占用,其他的客户端无法连接此服务端,更无法向这个服务端发送数据。只有等占用连接的客户端关闭连接之后,其他的客户端才能连接上服务器。

2.2 解决BIO缺点的方案

通过上面的实验,我们知道了BIO的缺点,那我们有没有办法来解决这个缺点呢?答案是有的。那我们要如何做才能解决BIO这一个缺点呢?我们首先要明确的一点是,BIO的工作模型是同步阻塞,而在介绍同步阻塞时我们说过,此时发生阻塞的是当前线程,既然当前线程阻塞住了,那我们新开一个线程不就好了,但是如果有100个、1000个甚至更多的线程来连接服务端怎么办?新开一个线程也不够分呀。为了不让每个客户端在连接服务端时需要等待其他的客户端释放连接,我们干脆给每个Socket都分配一个线程好了,这样就可以解决BIO的阻塞问题了。具体的代码如下:

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class BIOServerWithThreadTest {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8089);
        System.out.println("第一步:创建端口为8090的端口成功。。。");
        System.out.println("阻塞中,等待客户端来连接。。。");
        while (true) {
            Socket client = serverSocket.accept();//阻塞1
            System.out.println("第二步:接收到端口号为:" +
             client.getPort() + "的客户端连接");
            new Thread(new MultiThreadServer(client)).start();
        }
        }

     static class MultiThreadServer implements Runnable{
        Socket csocket;
        MultiThreadServer(Socket csocket) {
            this.csocket = csocket;
        }
        @Override
        public void run() {
            try {
                InputStream inputStream = csocket.getInputStream();
                byte[] buffer = new byte[4096];
                System.out.println("阻塞中,等待客户端发送数据。。。");
                inputStream.read(buffer);//阻塞2
                String receivedContent = new String(buffer,"UTF-8");
                System.out.println("第三步:接收到的数据为:" + receivedContent);
                csocket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

然后我们再使用SocketTest来对服务端进行连接,观察一下服务端控制台的输出。
在这里插入图片描述

使用第一个客户端连接服务端时,服务端控制台的输出

在这里插入图片描述

使用第二个客户端连接服务端时,服务端控制台的输出

在这里插入图片描述

使用第三个客户端连接服务端时,服务端控制台的输出

通过观察控制台的输出,我们可以发现,每当有一个新的客户端连接到服务端时,服务端的控制台都会打印有客户端来连接的信息,并且进入等待客户端发送数据的阻塞状态。接下来,我们依次使用第三个SocketTest客户端、第一个SocketTest客户端、第二个SocketTest客户端来向服务端发送数据(这里顺序是我随意定的,实际上按照什么顺序来发都是可以的,按照这个顺序只是为了证明后面连接的客户端是可以想服务端发送数据的),然后观察服务端控制台的变化。
在这里插入图片描述

使用第三个客户端向服务端发送数据时,服务端控制台的输出

在这里插入图片描述

使用第一个客户端向服务端发送数据时,服务端控制台的输出

在这里插入图片描述

使用第二个客户端向服务端发送数据时,服务端控制台的输出

为了方便,本文仅使用三个SocketTest客户端(即三个进程)进行实验,读者们可以自行使用更多的SocketTest客户端来对服务端进行连接并进行相关的实验。

通过观察服务端的控制台信息我们可以发现,三个客户端都是可以连接上服务端的,并且三个客户端都是可以对服务端发送消息,并且没有顺序的限制。但是,我们可以思考一下,这样的做法,是不是就没有缺点了呢?
其实答案很明显,我们为每个Socket连接都分配一个线程的做法肯定是会耗费大量的计算机资源的,因为每个线程的创建和销毁都会占用一定资源和时间,并且线程之间切换的开销也很大,如果Socket连接过多的话,消耗完了所有的计算机资源之后,那肯定是会导致计算机崩溃。
那有没有别的方法来避免计算机的资源被耗尽问题并且可以解决BIO的缺点呢?答案是有的,我们可以使用线程池的方法,当我们需要创建一个新的进程时,直接从线程池获取就行了,线程池中的线程数量是可以由我们来控制的,这样就可以避免创建过多的线程而导致计算机崩溃,并且可以让线程池在程序启动时就把所有的线程创建好,避免了每当有一个新的Socket连接来连接服务端时要去创建新的线程的额外时间消耗。比如Tomcat7以前就是这么做的。当然,因为篇幅与侧重点原因,这里就不再贴出相应的代码,感兴趣的自行搜索相关资料进行学习。
虽然线程池看起来是一个比较完美的解决方案,但是,在线程池中还是有着大量的线程占用这计算机资源。既然有问题存在,那人们肯定就会寻求更加优雅的解决方案,而这个解决方案就是本文的主题——NIO

3 NIO

在本节,我们就开始正式进入了NIO的相关内容,我们先来介绍两个Java的NIO中比较重要的两个组件Channel和Buffer。这里只做简单的介绍,详细用法需要读者自己查阅相关资料,这里只做简单回顾,帮助读者了解下文给出的代码。
Channel与Buffer都是NIO中的三大组件之一,Channel负责传输(可以理解为铁路),Buffer负责存储 (可以理解为火车车厢),此外还有一个组件叫做Selector,这个我们在下文再单独讲。Channel相比BIO中的Stream更加高效,可以异步双向传输,但是必须和Buffer一起使用。
Buffer中有几个重要的属性,分别是:position、limit、limit。它们所表示的含义如下:

  • position: 指向下一次读取或写入的位置。
  • limit: 写模式下表示可以写入多少数据,读模式下表示可以读取多少数据
  • capacity: 缓冲区的初始化大小。
    它们的关系为:0 <= position <= limit <= capacity
    接下来我们通过图表的方式来了解一下Channel与Buffe的关系,及Buffer的几个重要属性的值的变化。
    在这里插入图片描述
Buffer的初始化状态

刚刚初始化时,position指向Buffer的最左边,而limit和capacity都指向buffer数组的最右边。
在这里插入图片描述

Buffer的读写状态转换

从图中我们可以看出,在写模式下position指向下一个要写入的位置。而调用了flip方法之后,缓冲区就由写模式变成了读模式,position与limit的值也变了。如果再次调用flip,它就会又从读模式变成写模式。
在这里插入图片描述

Buffer的读模式下读取数据,position值的变化

从图中我们可以看出,在读模式下position指向下一个要读取的值的位置。

简单了解了一下Buffer和Channel的工作原理之后,让先回顾一下我们之前所讨论的BIO服务端的流程,经过之前的讨论,我们知道,在BIO服务端中会有两个地方存在阻塞,我们假设一下,如果我们引入一个可以将其改成非阻塞状态,尝试性的去检查一下是否有客户端连接或者是否有数据可以读取,然后不管是否有客户端连接或者是否有数据可以读取都马上返回一个数值,假设返回值大于0时表示接受到数据或者是有客户端连接,返回值等于0表示没有接受到数据或者是没有客户端连接,是不是可以解决BIO的三个缺点了呢?为了方便理解,我们画一张图来将其表示出来,在图的左边,是原来的BIO流程,浅绿色的方框表示非阻塞,灰色的方框就表示是会发生阻塞的方法,在图的右边,我们预想的解决方案,我们通过引入图中红色部分来将灰色的方框变成非阻塞状态,也就是浅绿色。图示如下:
在这里插入图片描述

设想的方案

然后我们可以根据我们设想的方案来写一下伪代码,看看如果accept方法和read方法不再阻塞之后,我们的代码应该怎么写,因为只是伪代码,下面给出的代码并不能运行,大家关注其思想即可。代码如下:

public class Solution {
    public static void main(String[] args) throws IOException {
        List<Socket> clientList = new ArrayList<>();
        ServerSocket serverSocket = new ServerSocket(8089);
        while (true) {
            //将serverSocket设置为非阻塞状态
            serverSocket.setNoBlocking();//伪代码,实际上并没有这个方法
            //将serverSocket设置为非阻塞状态
            Socket client = serverSocket.accept();
            //如果接收到客户端的请求了就将其添加到链表中
            if (client.isAccepted()) {//伪代码,实际上并没有这个方法
                clientList.add(client);
            }
            //遍历已经接收到的客户端请求,然后尝试读取客户端发来的数据
            for (Socket clientSocket : clientList) {
                client.setNoBlocking();//伪代码,实际上并没有这个方法
                byte[] buffer = new byte[4096];
                //尝试读取客户端发来的数据,并不一定可以读到,如果客户端发送有数据过来,count的值大于0
                int count = client.getInputStream().read(buffer);
                //如果读取到数据,则将其打印出来
                if (count > 0) {
                    String receivedContent = new String(buffer, "UTF-8");
                    System.out.println(receivedContent);
                }

            }

        }
    }
}

上面的代码的主要思想就是,如果没有阻塞方法的存在了,那么我们就可以在接受到客户端的请求之后,将其放入一共List或者数组中,然后遍历这个存放在已经连上来的客户端的List,看一下其中是否有客户端发送数据过来,如果某个客户端发送过来了,那么就将其数据打印出来。由于我们使用了一个死循环来一致检测是否有客户端来进行连接,同时在死循环中遍历检查连上来的客户端是否发送有数据,因为没有阻塞方法的存在,我们就不再需要额外再开新的线程来对每个客户端的请求进行处理了,极大的提高了代码的运行效率和计算机资源的使用率。
其实NIO就是通过上面的思想来实现的,接下来我们来看看NIO的代码,代码如下:

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.ArrayList;
import java.util.List;

public class NIOServerTest {
    public static void main(String[] args) throws IOException, InterruptedException {
        List<SocketChannel> clientList = new ArrayList<>();
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(8089));
        //设置为非阻塞
        serverSocketChannel.configureBlocking(false);
        while (true) {
            Thread.sleep(2000);
            SocketChannel client = serverSocketChannel.accept();
            if (null == client) {
                System.out.println("没有客户端来连接。。。");
            } else {
               //设置为非阻塞
                client.configureBlocking(false);
                System.out.println(String.format("端口为:%d的客户端已经连接成功。。。", client.socket().getPort()));
                clientList.add(client);
            }
            ByteBuffer byteBuffer = ByteBuffer.allocate(2048);
            for (SocketChannel clientItem : clientList) {

                int count = clientItem.read(byteBuffer);
                if (count > 0) {
                    byteBuffer.flip();
                    byte[] buffer = new byte[byteBuffer.limit()];
                    byteBuffer.get(buffer);
                    System.out.println(String.format("接收到端口号为:%d的客户端发来的数据,值为:%s", clientItem.socket().getPort(), new String(buffer)));
                    byteBuffer.clear();
                }
            }
        }
    }
}

通过代码,我们可以看到,代码中有两行非常重要的代码,即serverSocketChannel.configureBlocking(false);和client.configureBlocking(false);这两行代码就对应着我们伪代码中的setNoBlocking方法,作用就是将serverSocketChannel和client设为非阻塞状态。其思想就是跟我们刚刚在伪代码那里讨论的一样,至此就可以通过一个线程来实现与多个客户端通信了。
我们可以再思考一下,这个方法是否已经十分完美了呢?如果我们将客户端的数量放大,放大到十万个甚至是一百万个,然后只有两个客户端发送有数据过来,这个时候会发生什么呢?因为我们不知道哪个客户端有没有发数据过来,所以我们每次都要把所有的客户端都检查一遍(要进行内核态与用户态切换,下文会细说),如果有非常多的客户端没有发送数据过来,但是我们还是要去检查一遍,那这个时候还是会造成大量的资源浪费。既然问题有了,那么我们还得想办法解决它。

3.1 IO多路复用器

这里大家要先明确一件事情,根据操作系统的知识我们知道,计算机的硬件资源是有内核态来控制的,而我们的程序是运行在用户态中的,但是IO相关的函数(即系统调用)是运行这内核态的(这里给出一个Linux系统调用列表的连接:https://blog.csdn.net/baobao8505/article/details/1115815),所以当发生IO读写操作时,会有一个用户态到内核态的切换过程,这个过程是代价是比较大的,所以我们要想办法减少内核态与用户态的切换次数。
在这里插入图片描述

用户态到内核态的切换

在刚才我们所给出的NIO代码中,就存在这这一个问题,每当我们调用一次read时,都会产生一次用户态到内核态的切换,这就造成了很大的资源浪费,所以我们能不能一次性把要检查的Socket连接都丢给内核,然后让内核告诉我们有哪些Socket有客户端要来连接了,有哪些Socket有客户端给它发送数据了,然后再针对这些连接做相应的处理就行了,而不是一个一个Socket连接去找内核检查对应的状态?这个时候IO多路复用器就应运而生了。

3.1.1 select模型

select模型是一个跨平台的IO模型,在Windows和Linux系统中均有相应的实现。我们通过对调用select这一个系统调用来使用select模型。它的优缺点如下:
优点:跨平台支持。
缺点
(1)每次调用select时都需要把fd集合(socket集合,fd为文件描述符缩写)从用户态拷贝到内核态,在fd集合较大时,系统开销比较⼤。
(2)内核仍然需要遍历fd集合,在有过多空闲socket连接时,仍会造成资源浪费。
(3)select所支持的fd数量太小了,默认只有1024。
select的函数定义I如下:

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

它所需的参数分别是:maxfdp所有文件描述符中的最大值+1(在Windows中没有意义,只是为了兼容伯克利套接字规范),readfds是需要监听读事件的文件描述符集合,writefds是需要监听写事件的文件描述符集合,exceptfds是需要监听异常事件的文件描述符集合,timeout是超时时间。
它的大概工作流程如下:
在这里插入图片描述

select的大概工作流程

3.1.2 poll模型

poll模型是对select模型的改进,但是Windows并没有poll的相应实现,Linux中才有相应的实现。poll改进了select最大fd数量的限制,但依然没有解决掉select的需要遍历fd集合问题和内存拷贝问题。他的优缺点如下:
优点
(1)不要求调用者计算所有fd的最大值+1的大小。
(2)简化了select三种集合操作的流程。
(3)没有fd数量限制。
缺点
(1)不能跨平台。
(2)select模型缺点中的(1)和(2)。
pll的函数定义I如下:

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

他所需的参数分别是:fds指向结构体数组的第0个元素的指针,nfds是用来指定fds数组元素的个数,timeout是超时时间。
pollfd结构体定义如下:


struct pollfd{
	int fd;			//文件描述符
	short events;	//等待的事件
	short revents;	//实际发生的事件
};

它的大概工作流程如下:
在这里插入图片描述

poll的大概工作流程

3.1.3 epoll模型

epoll模型是对select模型和poll模型的超级增强版本,他也只在Linux系统的有对应的实现,在Windows下并没有对应的实现。而我们所用的Redis和Nginx就是使用了epoll模型,这也是为什么Redis没有Windows版本的原因。epoll的优缺点如下:

优点
(1)epoll则没有对描述符数目的限制,它所支持的文件描述符上限是整个系统最大可以打开的文件数目,例如,在1GB内存的机器上,这个限制大概为10万左右。
(2)IO效率不会随d的增加而线性下降,只有活跃可用的fd才会调用callback函数。

看到有的文章说epoll使用了mmap技术来实现fd在内核态与用户态之间的零拷贝问题,但是有某些看了Linux源码的大神说并没有使用该技术,但是博主并没有看过对应的源码,无法验证哪个观点正确,所以这点暂时存疑。想了解mmap技术的请移步:https://zhuanlan.zhihu.com/p/83398714

缺点:调用相对繁琐,对程序员的能力要求较高。
epoll是提供了三个函数来供程序员调用,这三个函数是配套的,分别是epoll_create、epoll_ctl、epoll_wait,它们的定义如下:
epoll_create:

int epoll_create(int size);

这个函数是用来创建epoll的,它所需的参数是:size表示要监听事件列表的长度的初始值,后面会根据实际情况自动调整。
epoll_ctl:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

这个函数是用来管理所创建的epoll中的fd的(增删改),它所需的参数是:epfd表示epoll_create函数的返回值,即epoll对应的fd,op表示要进行的操作(用三个宏来表示,分别是:EPOLL_CTL_ADD:注册新的fd到epfd中;EPOLL_CTL_MOD:修改已经注册的fd的监听事件;EPOLL_CTL_DEL:从epfd中删除一个fd),fd表示要监听的fd,这里必须是支持NIO的fd(比如socket),event表示对要监听事件的描述。
epoll_event 的定义如下:

typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;

struct epoll_event {
    __uint32_t events; /* Epoll 事件*/
    epoll_data_t data; /* 用户数据 */
};

epoll_wait:

 int epoll_wait(int epfd, struct epoll_event * evlist, int maxevents, int timeout);

这个函数是用来等待epoll中的事件发生的,它所需要的参数是:epfd表示epoll_create函数的返回值,即epoll对应的fd,evlistepoll_wait函数的返回数组,里面会存放着有事件发生的fd,maxevents表示evlist数组的大小,timeout是超时时间。

这里只是简单介绍一下epoll对应的函数及其工作流程,epoll的工作模式(LT和EL)其实也是我们需要关注的问题,但是由于篇幅问题,这里不再赘述,感兴趣的可以参考这篇博客:https://www.cnblogs.com/xuewangkai/p/11158576.html

它的大概工作流程如下:
在这里插入图片描述

3.2 NIO的Selector

经过前面的讨论,我们已经知道了什么是NIO,也知道什么是IO多路复用器,这两个在操作系统层面是相对独立的,没有人规定这两个东西必须要一起使用。但是经过我们前面的讨论,我们知道,单单只使用NIO还是存在着一些缺陷的。所以我们将IO多路复用和NIO配合一起使用才有才能达到最好的效果。而而Java中的NIO提供了一个对多路复用器进行了封装的Selector类,它在不同的操作系统下的实现是不一样的,在Windows中使用的是select模型,而在Linux系统下使用的是epoll模型。
接下来,让我们看看使用了多路复用器的NIO代码写法吧,代码如下:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;

public class SingleThreadMultiplexingNIOTest {
    private static ServerSocketChannel socketChannel = null;
    private static Selector selector = null;

    /**
     * 初始化服务端socketChannel
     * @throws Exception
     */
    public static void initServer() throws Exception {
        socketChannel = ServerSocketChannel.open();
        //设为非阻塞状态
        socketChannel.configureBlocking(false);
        socketChannel.bind(new InetSocketAddress(8089));
        selector = Selector.open();
        //将socketChannel注册到selector中,监听此socketChannel是否有客户端连接事件发生
        socketChannel.register(selector, SelectionKey.OP_ACCEPT);
    }

    /**
     *  处理客户端连接事件
     * @param key
     * @throws IOException
     */
    private static void handleAccept(SelectionKey key) throws IOException {
        //获取服务端端连接
        ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
        //创建客户端连接
        SocketChannel client = serverSocketChannel.accept();
        //设为非阻塞状态
        client.configureBlocking(false);
        ByteBuffer buffer = ByteBuffer.allocate(4096);
        //将client注册到selector中,监听此client是否有数据可以读取事件发生
        client.register(selector, SelectionKey.OP_READ, buffer);
        System.out.println("端口号为:" + client.getLocalAddress() + "的客户端已连接。。。");
    }

    /**
     *  处理数据读取事件
     * @param key
     * @throws IOException
     */
    private static void handleRead(SelectionKey key) throws IOException {
        //获取客户端连接
        SocketChannel client = (SocketChannel) key.channel();
        //获取客户端的缓冲区
        ByteBuffer buffer = (ByteBuffer) key.attachment();
        buffer.clear();
        while (true) {
            //开始往缓冲区写入数据
            int count = client.read(buffer);
            if (count > 0) {
                buffer.flip();
                //如果缓冲区有数据
                while (buffer.hasRemaining()){
                    //从缓冲区读取数据
                    client.write(buffer);
                    buffer.flip();
                    byte[] data = new byte[buffer.limit()];
                    buffer.get(data);
                    System.out.println(String.format("接收到端口号为:%d的客户端发来的数据,值为:%s", client.socket().getPort(), new String(data)));
                }
                buffer.clear();
            }else if (0==count){
                break;
            }else {
                client.close();
                break;
            }
        }
    }

    public static void main(String[] args) {
        try {
            initServer();
            while (true) {
                //检查是否有事件发生
                while (selector.select(0) > 0) {
                    //获取有事件发生,获取所有对应的Channel
                    Set<SelectionKey> selectionKeys = selector.selectedKeys();
                    Iterator<SelectionKey> iterator = selectionKeys.iterator();
                    //遍历所有有事件发生的Channel
                    while (iterator.hasNext()) {
                        //取出Channel
                        SelectionKey key = iterator.next();
                        //移除Channel
                        iterator.remove();
                        if (key.isAcceptable()) {
                            handleAccept(key);
                        } else if (key.isReadable()) {
                            handleRead(key);
                        }
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

以上就是本文的所有内容了,由于篇幅过大,内容较多,所以有的地方可能有错误之处,如有发现错误之处恳请原谅,亦可在评论区与我进行讨论。

  • 4
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值