IO多路复用

学习Redis和Netty等能够高效处理Socket请求的框架或工具都提到了IO多路复用模型,那么IO多路复用模型到底是什么,本文将会对其进行一个简单的介绍。主要涉及到以下几方面知识:
  • 阻塞IO
  • 非阻塞IO
  • IO多路复用

阻塞IO

所谓阻塞IO是指调用方从发起IO请到收到被调用方返回的数据之间的这段时间,调用方线程一直处于阻塞状态,如果是UI线程则意味着界面不响应,假死。借用网上的一张经典阻塞IO模型如下:

在这里插入图片描述
用Socket编程代码演示如下:

  • Server端:
public class Server {

    public static void main(String[] args) {
        try {
            ServerSocket serverSocket = new ServerSocket(8080);
            System.out.println("服务器启动完成...监听启动!");
            //开启监听,等待客户端的访问
           while(true) {
               Socket socket = serverSocket.accept();
               // 获取输入流,因为是客户端向服务器端发送了数据
               InputStream inputStream = socket.getInputStream();
               // 创建一个缓冲流
               BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
               String info = null;
               while ((info = br.readLine()) != null) {
                   System.out.println("这里是服务端 客户端是:" + info);
               }
               //向客户端做出响应
               OutputStream outputStream = socket.getOutputStream();
               info = "这里是服务器端,我们接受到了你的请求信息,正在处理...处理完成!";
               outputStream.write(info.getBytes());
               outputStream.close();
           }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  • Client端:
public class Client {
    public static void main(String[] args) throws IOException, InterruptedException {
        try {
            Socket socket = new Socket("localhost",8080);
            OutputStream outputStream = socket.getOutputStream();
            String info = "你好啊!";
            //输出!
            Thread.sleep(1000);
            outputStream.write(info.getBytes());
            socket.shutdownOutput();
            //接收服务器端的响应
            InputStream inputStream = socket.getInputStream();
            BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
            while ((info = br.readLine())!=null){
                System.out.println("接收到了服务端的响应!" + info);
            }
            //刷新缓冲区
            outputStream.flush();
            outputStream.close();
            inputStream.close();
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
上面的服务端程序有个很严重的问题:如果有多个客户端访问的话,则客户端必须按顺序访问,如果第一个连接连上来之后,不管发不发消息,后面的连接只能等待。这样会导致大量的客户端阻塞。
    
    为了解决这个问题,一般会在一个客户端建立连接之后,服务端启动一个线程来处理与之的通讯。但是如果客户端数太多,就会导致服务端创建大量的线程,线程的创建、上下文切换也会导致服务器的负载大幅度升高。因此也就产生了下面要说的非阻塞IO。

非阻塞IO

Linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:

在这里插入图片描述

当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是用户就可以在本次到下次再发起read询问的时间间隔内做其他事情,或者直接再次发送read操作。

对应到Socket服务器的代码如下:

public class SelectorServer {

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

        Selector selector = Selector.open();

        ServerSocketChannel server = ServerSocketChannel.open();
        server.socket().bind(new InetSocketAddress(8080));

        // 将其注册到 Selector 中,监听 OP_ACCEPT 事件
        server.configureBlocking(false);//非阻塞IO的设置
        server.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            int readyChannels = selector.select();
            if (readyChannels == 0) {
                continue;
            }
            Set<SelectionKey> readyKeys = selector.selectedKeys();
            // 遍历
            Iterator<SelectionKey> iterator = readyKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();

                if (key.isAcceptable()) {
                    // 有已经接受的新的到服务端的连接
                    SocketChannel socketChannel = server.accept();

                    // 有新的连接并不代表这个通道就有数据,
                    // 这里将这个新的 SocketChannel 注册到 Selector,监听 OP_READ 事件,等待数据
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    // 有数据可读
                    // 上面一个 if 分支中注册了监听 OP_READ 事件的 SocketChannel
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    ByteBuffer readBuffer = ByteBuffer.allocate(1024);
                    int num = socketChannel.read(readBuffer);
                    if (num > 0) {
                        // 处理进来的数据...
                        System.out.println("收到数据:" + new String(readBuffer.array()).trim());
                        ByteBuffer buffer = ByteBuffer.wrap("返回给客户端的数据...".getBytes());
                        socketChannel.write(buffer);
                    } else if (num == -1) {
                        // -1 代表连接已经关闭
                        socketChannel.close();
                    }
                }
            }
        }

Client端可以继续沿用上面的。

该种实现方式的好处是,不用创建多个线程。在一个主线程里即把所有客户端连接都处理了。因为是非阻塞连接,服务端在收到连接后直接将该连接对应的socketChannel注册到selector中,并监控该连接的Read操作。当有数据到来时在对Socket进行数据的传输处理。

但是这种方案仍然有个问题,就是主程序要不断的轮询,不管有没有连接,连接是否可用。并且收到selector的事件后也不知道到底是什么操作准备好了,只能逐个判断。这样催生了后来的异步IO。异步IO的问题在合理先不讲了,后面再说。

IO多路复用

写到这里对IO多路复用也有了个大致的了解,其实应该把“IO多路复用”拆解来看。

  • IO:对一台设备的输入输出操作。可以是磁盘IO,也可以是SocketIO。
  • 多路:对应服务器就是多个客户端的连接。每个客户端算一路。
  • 复用:复用同一个线程。即用一个线程完成对所有客户端连接的输入输出处理。

这里在举一个现实生活中的例子。一个人开饭馆(服务器),来了10个客人(客户端连接),这时老板是选择雇佣10个服务员(线程)分别为每个客人服务还是用一个服务员来监听所有客人的动作等着为各个客人服务呢,相信大家都会做出正确的选择。

参考资料:
https://www.zhihu.com/questio...
https://www.javadoop.com/post...

阅读 217

推荐阅读